Define roles for the secrets engine
In the Define a configuration for the secrets
engine tutorial, you added the
config
path to your secrets engine.
In this tutorial, you will create a role schema for your
secrets engine. In a secrets engine, a role describes an identity
with a set of permissions, groups, or policies you want to attach
a user of the secrets engine. You define this at the
role/*
path of the secrets engine.
Note
You will often map a user identity to a specific role. However, this depends on the target API, its access control model, and the secrets you want Vault to rotate.
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. - Explore the attributes for the secrets engine's role entry.
You will examine a file that configures a role entry object for the secrets engine. - Explore the path for the secrets engine's role.
You will examine the path definition for the secrets engine's role. - Define the fields for the secrets engine's role.
You will define a set of fields that a Vault operator passes to create a role for the secrets engine. - Implement read for the secrets engine's role.
You will implement a method to handle reading the role from the secrets engine backend. - Implement create and update for the secrets engine's role.
You will implement a method to handle writing the role to the secrets engine backend. - Implement delete for the secrets engine's role.
You will implement a method to handle deletion of the role from the secrets engine backend. - Implement list for the secrets engine's role.
You will implement a method to list the roles from the secrets engine backend. - Add the role path to the backend.
You will update the backend to add a new API path for the role. - Explore unit tests that verify the role path.
You will examine unit tests that check that Vault can create, read, update, and delete the secrets engine role.
Prerequisites
- Golang 1.16+ installed and configured.
- Vault 1.8+ CLI installed locally.
Note
Complete the tutorial to define the configuration 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.
Explore the attributes for the secrets engine's role entry
Open path_roles.go
. The file contains all of the objects
and methods related to setting up the role/*
path for
the secrets engine.
The role entry for the secrets engine needs to stores a set of
identifiers and relates them to the dynamically generated secret.
hashiCupsRoleEntry
defines a role entry for a specific user based
on username and user ID. It also stores the dynamically generated
token for the target API. Vault needs the role entry to include a
time to live (TTL) and maximum TTL.
Note
Your role entry should always have attributes for
TTL
and MaxTTL
.
path_roles.go
type hashiCupsRoleEntry struct { Username string `json:"username"` UserID int `json:"user_id"` Token string `json:"token"` TokenID string `json:"token_id"` TTL time.Duration `json:"ttl"` MaxTTL time.Duration `json:"max_ttl"`}
Note
What attributes should go into the role entry? Consider your target API's access control and secrets model to determine which attributes to pass. For example, HCP Terraform offers three different types of API tokens with different levels of access. As a result, its secrets engine defines a role that requests user, team, and organization identifiers and generates the specific type of token based on the defined identifiers.
The file extends hashiCupsRoleEntry
with the toResponseData
method.
When you create the role in Vault, it should print out attributes for the
role entry such as TTL, max TTL, and username. Avoiding
including secrets, like tokens or passwords, in the response data!
path_roles.go
func (r *hashiCupsRoleEntry) toResponseData() map[string]interface{} { respData := map[string]interface{}{ "ttl": r.TTL.Seconds(), "max_ttl": r.MaxTTL.Seconds(), "username": r.Username, } return respData}
Note
You can use the toResponseData
method from the scaffold
and modify it for your role entry.
Explore the path for the secrets engine's role
In path_roles.go
, the pathRole
method returns a framework.Path
that extend the
Vault API for the secrets engine's role/*
path. It defines a few
attributes.
Pattern
defines the API path to extend for the secrets engine. You
will add two API points to the secrets engine, one that handles
requests to update individual roles and the other to list roles
for the secrets engine.
path_roles.go
func pathRole(b *hashiCupsBackend) []*framework.Path { return []*framework.Path{ { Pattern: "role/" + framework.GenericNameRegex("name"), Fields: map[string]*framework.FieldSchema{}, Operations: map[logical.Operation]framework.OperationHandler{}, HelpSynopsis: pathRoleHelpSynopsis, HelpDescription: pathRoleHelpDescription, }, { Pattern: "role/?$", Operations: map[logical.Operation]framework.OperationHandler{}, HelpSynopsis: pathRoleListHelpSynopsis, HelpDescription: pathRoleListHelpDescription, }, }}
Note
You can use the Pattern
value for updating and listing roles
from the scaffold for your own secrets engine.
HelpSynopsis
and HelpDescription
offer help text for the specific paths.
path_roles.go
defines them as constants.
path_roles.go
func pathRole(b *hashiCupsBackend) []*framework.Path { return []*framework.Path{ { Pattern: "role/" + framework.GenericNameRegex("name"), Fields: map[string]*framework.FieldSchema{}, Operations: map[logical.Operation]framework.OperationHandler{}, HelpSynopsis: pathRoleHelpSynopsis, HelpDescription: pathRoleHelpDescription, }, { Pattern: "role/?$", Operations: map[logical.Operation]framework.OperationHandler{}, HelpSynopsis: pathRoleListHelpSynopsis, HelpDescription: pathRoleListHelpDescription, }, }} const ( pathRoleHelpSynopsis = `Manages the Vault role for generating HashiCups tokens.` pathRoleHelpDescription = `This path allows you to read and write roles used to generate HashiCups tokens.You can configure a role to manage a user's token by setting the username field.` pathRoleListHelpSynopsis = `List the existing roles in HashiCups backend` pathRoleListHelpDescription = `Roles will be listed by the role name.`)
Define the fields for the secrets engine's role
Open path_roles.go
. The method pathRole
needs to have
Fields
defined in order to retrieve and pass role attributes
to the secrets engine.
Note
Replace the methods and structs in the scaffold with the embedded code examples.
Replace pathRole
with code to define the Fields
for the
role/
Pattern
. You do not define fields for listing roles
because the secrets engine will print all of the role entries.
Add the required secrets engine fields, including:
name
: Name of the role, alwaysframework.TypeLowerCaseString
and required for the secrets engine.ttl
: Default lease for credentials, alwaysframework.TypeDurationSecond
. When unset, it will use system default.max_ttl
: Maximum time for tole, alwaysframework.TypeDurationSecond
. When unset, it will use system default.
You also need to pass the HashiCups username
as a field. It identifies
the access control of the API token the secrets engine will generate for you.
Define it as framework.TypeString
and required.
path_roles.go
func pathRole(b *hashiCupsBackend) []*framework.Path { return []*framework.Path{ { Pattern: "role/" + framework.GenericNameRegex("name"), Fields: map[string]*framework.FieldSchema{ "name": { Type: framework.TypeLowerCaseString, Description: "Name of the role", Required: true, }, "username": { Type: framework.TypeString, Description: "The username for the HashiCups product API", Required: true, }, "ttl": { Type: framework.TypeDurationSecond, Description: "Default lease for generated credentials. If not set or set to 0, will use system default.", }, "max_ttl": { Type: framework.TypeDurationSecond, Description: "Maximum time for role. If not set or set to 0, will use system default.", }, }, Operations: map[logical.Operation]framework.OperationHandler{}, HelpSynopsis: pathRoleHelpSynopsis, HelpDescription: pathRoleHelpDescription, }, { Pattern: "role/?$", Operations: map[logical.Operation]framework.OperationHandler{}, HelpSynopsis: pathRoleListHelpSynopsis, HelpDescription: pathRoleListHelpDescription, }, }}
Implement read for the secrets engine's role
Open path_roles.go
.
Note
Replace the methods and structs in the scaffold with the embedded code examples.
The Operations
field in the pathRole
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 role/*
path.
Note
When you build a secrets engine and define its role, you need to implement operations to read, create, update and delete information at each API path you define for the secrets engine.
Create two new methods named getRole
and pathRolesRead
in path_roles.go
.
getRole
: Retrieves the role from the secrets engine backend and decodes it to the role entry object.pathRolesRead
: Reads the role and outputs non-sensitive fields using thetoResponseData
method.
path_roles.go
func (b *hashiCupsBackend) getRole(ctx context.Context, s logical.Storage, name string) (*hashiCupsRoleEntry, error) { if name == "" { return nil, fmt.Errorf("missing role name") } entry, err := s.Get(ctx, "role/"+name) if err != nil { return nil, err } if entry == nil { return nil, nil } var role hashiCupsRoleEntry if err := entry.DecodeJSON(&role); err != nil { return nil, err } return &role, nil} func (b *hashiCupsBackend) pathRolesRead(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { entry, err := b.getRole(ctx, req.Storage, d.Get("name").(string)) if err != nil { return nil, err } if entry == nil { return nil, nil } return &logical.Response{ Data: entry.toResponseData(), }, nil}
Under the Operations
field for the role/
path, add logical.ReadOperation
to the list of OperationHandler
and callback to pathRolesRead
. The secrets engine
responds to a read operation from Vault with this method.
path_roles.go
func pathRole(b *hashiCupsBackend) []*framework.Path { return []*framework.Path{ { Pattern: "role/" + framework.GenericNameRegex("name"), Fields: map[string]*framework.FieldSchema{ "name": { Type: framework.TypeLowerCaseString, Description: "Name of the role", Required: true, }, "username": { Type: framework.TypeString, Description: "The username for the HashiCups product API", Required: true, }, "ttl": { Type: framework.TypeDurationSecond, Description: "Default lease for generated credentials. If not set or set to 0, will use system default.", }, "max_ttl": { Type: framework.TypeDurationSecond, Description: "Maximum time for role. If not set or set to 0, will use system default.", }, }, Operations: map[logical.Operation]framework.OperationHandler{ logical.ReadOperation: &framework.PathOperation{ Callback: b.pathRolesRead, }, }, HelpSynopsis: pathRoleHelpSynopsis, HelpDescription: pathRoleHelpDescription, }, { Pattern: "role/?$", Operations: map[logical.Operation]framework.OperationHandler{}, HelpSynopsis: pathRoleListHelpSynopsis, HelpDescription: pathRoleListHelpDescription, }, }}
Implement create and update for the secrets engine's role
Open path_roles.go
.
Note
Replace the methods and structs in the scaffold with the embedded code examples.
Create two new methods named setRole
and pathRolesWrite
in path_roles.go
.
setRole
: Write the role to the secrets engine backend.pathRolesWrite
: Updates the role if any fields like username, TTL, or max TTL changes and creates the roles if it does not already exist. The new or updated role gets written to storage.
path_roles.go
func setRole(ctx context.Context, s logical.Storage, name string, roleEntry *hashiCupsRoleEntry) error { entry, err := logical.StorageEntryJSON("role/"+name, roleEntry) if err != nil { return err } if entry == nil { return fmt.Errorf("failed to create storage entry for role") } if err := s.Put(ctx, entry); err != nil { return err } return nil} func (b *hashiCupsBackend) pathRolesWrite(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { name, ok := d.GetOk("name") if !ok { return logical.ErrorResponse("missing role name"), nil } roleEntry, err := b.getRole(ctx, req.Storage, name.(string)) if err != nil { return nil, err } if roleEntry == nil { roleEntry = &hashiCupsRoleEntry{} } createOperation := (req.Operation == logical.CreateOperation) if username, ok := d.GetOk("username"); ok { roleEntry.Username = username.(string) } else if !ok && createOperation { return nil, fmt.Errorf("missing username in role") } if ttlRaw, ok := d.GetOk("ttl"); ok { roleEntry.TTL = time.Duration(ttlRaw.(int)) * time.Second } else if createOperation { roleEntry.TTL = time.Duration(d.Get("ttl").(int)) * time.Second } if maxTTLRaw, ok := d.GetOk("max_ttl"); ok { roleEntry.MaxTTL = time.Duration(maxTTLRaw.(int)) * time.Second } else if createOperation { roleEntry.MaxTTL = time.Duration(d.Get("max_ttl").(int)) * time.Second } if roleEntry.MaxTTL != 0 && roleEntry.TTL > roleEntry.MaxTTL { return logical.ErrorResponse("ttl cannot be greater than max_ttl"), nil } if err := setRole(ctx, req.Storage, name.(string), roleEntry); err != nil { return nil, err } return nil, nil}
Under the Operations
field, add logical.CreateOperation
and
logical.UpdateOperation
to the list of OperationHandler
.
Both operations should call back to pathRolesWrite
.
path_roles.go
func pathRole(b *hashiCupsBackend) []*framework.Path { return []*framework.Path{ { Pattern: "role/" + framework.GenericNameRegex("name"), Fields: map[string]*framework.FieldSchema{ "name": { Type: framework.TypeLowerCaseString, Description: "Name of the role", Required: true, }, "username": { Type: framework.TypeString, Description: "The username for the HashiCups product API", Required: true, }, "ttl": { Type: framework.TypeDurationSecond, Description: "Default lease for generated credentials. If not set or set to 0, will use system default.", }, "max_ttl": { Type: framework.TypeDurationSecond, Description: "Maximum time for role. If not set or set to 0, will use system default.", }, }, Operations: map[logical.Operation]framework.OperationHandler{ logical.ReadOperation: &framework.PathOperation{ Callback: b.pathRolesRead, }, logical.CreateOperation: &framework.PathOperation{ Callback: b.pathRolesWrite, }, logical.UpdateOperation: &framework.PathOperation{ Callback: b.pathRolesWrite, }, }, HelpSynopsis: pathRoleHelpSynopsis, HelpDescription: pathRoleHelpDescription, }, { Pattern: "role/?$", Operations: map[logical.Operation]framework.OperationHandler{}, HelpSynopsis: pathRoleListHelpSynopsis, HelpDescription: pathRoleListHelpDescription, }, }}
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 role
Open path_roles.go
.
Note
Replace the methods and structs in the scaffold with the embedded code examples.
Create a new method named pathRolesDelete
in path_roles.go
.
The method deletes the role from the secrets engine backend.
path_roles.go
func (b *hashiCupsBackend) pathRolesDelete(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { err := req.Storage.Delete(ctx, "role/"+d.Get("name").(string)) if err != nil { return nil, fmt.Errorf("error deleting hashiCups role: %w", err) } return nil, nil}
Under the Operations
field, add logical.DeleteOperation
to the list of OperationHandler
. It should call back to pathRolesDelete
.
path_roles.go
func pathRole(b *hashiCupsBackend) []*framework.Path { return []*framework.Path{ { Pattern: "role/" + framework.GenericNameRegex("name"), Fields: map[string]*framework.FieldSchema{ "name": { Type: framework.TypeLowerCaseString, Description: "Name of the role", Required: true, }, "username": { Type: framework.TypeString, Description: "The username for the HashiCups product API", Required: true, }, "ttl": { Type: framework.TypeDurationSecond, Description: "Default lease for generated credentials. If not set or set to 0, will use system default.", }, "max_ttl": { Type: framework.TypeDurationSecond, Description: "Maximum time for role. If not set or set to 0, will use system default.", }, }, Operations: map[logical.Operation]framework.OperationHandler{ logical.ReadOperation: &framework.PathOperation{ Callback: b.pathRolesRead, }, logical.CreateOperation: &framework.PathOperation{ Callback: b.pathRolesWrite, }, logical.UpdateOperation: &framework.PathOperation{ Callback: b.pathRolesWrite, }, logical.DeleteOperation: &framework.PathOperation{ Callback: b.pathRolesDelete, }, }, HelpSynopsis: pathRoleHelpSynopsis, HelpDescription: pathRoleHelpDescription, }, { Pattern: "role/?$", Operations: map[logical.Operation]framework.OperationHandler{}, HelpSynopsis: pathRoleListHelpSynopsis, HelpDescription: pathRoleListHelpDescription, }, }}
Implement list for the secrets engine's role
Open path_roles.go
.
Note
Replace the methods and structs in the scaffold with the embedded code examples.
Create a new method named pathRolesList
in path_roles.go
.
The method lists all the roles stored in the secrets engine backend
and lists the responses using logical.ListResponses
.
path_roles.go
func (b *hashiCupsBackend) pathRolesList(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { entries, err := req.Storage.List(ctx, "role/") if err != nil { return nil, err } return logical.ListResponse(entries), nil}
Note
You can update the scaffold's implementation of pathRolesList
to extend your own secrets engine backend. The code to list the role
entries should remain the same.
Under the Operations
field within the role/?$
pattern, add logical.ListOperation
to the list of OperationHandler
. It should call back to pathRolesList
.
path_roles.go
func pathRole(b *hashiCupsBackend) []*framework.Path { return []*framework.Path{ { Pattern: "role/" + framework.GenericNameRegex("name"), Fields: map[string]*framework.FieldSchema{ "name": { Type: framework.TypeLowerCaseString, Description: "Name of the role", Required: true, }, "username": { Type: framework.TypeString, Description: "The username for the HashiCups product API", Required: true, }, "ttl": { Type: framework.TypeDurationSecond, Description: "Default lease for generated credentials. If not set or set to 0, will use system default.", }, "max_ttl": { Type: framework.TypeDurationSecond, Description: "Maximum time for role. If not set or set to 0, will use system default.", }, }, Operations: map[logical.Operation]framework.OperationHandler{ logical.ReadOperation: &framework.PathOperation{ Callback: b.pathRolesRead, }, logical.CreateOperation: &framework.PathOperation{ Callback: b.pathRolesWrite, }, logical.UpdateOperation: &framework.PathOperation{ Callback: b.pathRolesWrite, }, logical.DeleteOperation: &framework.PathOperation{ Callback: b.pathRolesDelete, }, }, HelpSynopsis: pathRoleHelpSynopsis, HelpDescription: pathRoleHelpDescription, }, { Pattern: "role/?$", Operations: map[logical.Operation]framework.OperationHandler{ logical.ListOperation: &framework.PathOperation{ Callback: b.pathRolesList, }, }, HelpSynopsis: pathRoleListHelpSynopsis, HelpDescription: pathRoleListHelpDescription, }, }}
Add the role 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 pathRole
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( pathRole(&b), []*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 role 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 role.
Open path_roles_test.go
. The file includes a set of
constants that you will pass as fields for the role/*
path.
The HashiCups roles entry requires name, TTL, max TTL, and
username.
The unit tests will not issue requests to the API endpoint.
path_roles_test.go
const ( roleName = "testhashicups" testTTL = int64(120) testMaxTTL = int64(3600))
Examine the method TestUserRole
. It creates the mock backend
with getTestBackend
and runs a series of tests listing, creating,
reading, updating, and deleting multiple roles.
path_roles_test.go
func TestUserRole(t *testing.T) { b, s := getTestBackend(t) t.Run("List All Roles", func(t *testing.T) { for i := 1; i <= 10; i++ { _, err := testTokenRoleCreate(t, b, s, roleName+strconv.Itoa(i), map[string]interface{}{ "username": username, "ttl": testTTL, "max_ttl": testMaxTTL, }) require.NoError(t, err) } resp, err := testTokenRoleList(t, b, s) require.NoError(t, err) require.Len(t, resp.Data["keys"].([]string), 10) }) t.Run("Create User Role - pass", func(t *testing.T) { resp, err := testTokenRoleCreate(t, b, s, roleName, map[string]interface{}{ "username": username, "ttl": testTTL, "max_ttl": testMaxTTL, }) require.Nil(t, err) require.Nil(t, resp.Error()) require.Nil(t, resp) }) t.Run("Read User Role", func(t *testing.T) { resp, err := testTokenRoleRead(t, b, s) require.Nil(t, err) require.Nil(t, resp.Error()) require.NotNil(t, resp) require.Equal(t, resp.Data["username"], username) }) t.Run("Update User Role", func(t *testing.T) { resp, err := testTokenRoleUpdate(t, b, s, map[string]interface{}{ "ttl": "1m", "max_ttl": "5h", }) require.Nil(t, err) require.Nil(t, resp.Error()) require.Nil(t, resp) }) t.Run("Re-read User Role", func(t *testing.T) { resp, err := testTokenRoleRead(t, b, s) require.Nil(t, err) require.Nil(t, resp.Error()) require.NotNil(t, resp) require.Equal(t, resp.Data["username"], username) }) t.Run("Delete User Role", func(t *testing.T) { _, err := testTokenRoleDelete(t, b, s) require.NoError(t, err) })}
Examine testTokenRoleCreate
as an example. It calls the
mock backend with a logical.CreateOperation
at the path,
role/
, with the name of the role testhashicups
.
path_roles_test.go
func testTokenRoleCreate(t *testing.T, b *hashiCupsBackend, s logical.Storage, name string, d map[string]interface{}) (*logical.Response, error) { t.Helper() resp, err := b.HandleRequest(context.Background(), &logical.Request{ Operation: logical.CreateOperation, Path: "role/" + name, Data: d, Storage: s, }) if err != nil { return nil, err } return resp, nil}
TestUserRole
runs the tests sequentially and passes the same
storage object between tests. You should write your test sequence as follows:
- Create a list of 10 roles with different names.
- List the roles and check that you created 10 roles.
- Create a single role.
- Read the role to test if the creation succeeded.
- Update the role.
- Read the role to test if the update succeeded.
- Delete the role.
- 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 TestUserRole === RUN TestUserRole=== RUN TestUserRole/List_All_Roles=== RUN TestUserRole/Create_User_Role_-_pass=== RUN TestUserRole/Read_User_Role=== RUN TestUserRole/Update_User_Role=== RUN TestUserRole/Re-read_User_Role=== RUN TestUserRole/Delete_User_Role--- PASS: TestUserRole (0.00s) --- PASS: TestUserRole/List_All_Roles (0.00s) --- PASS: TestUserRole/Create_User_Role_-_pass (0.00s) --- PASS: TestUserRole/Read_User_Role (0.00s) --- PASS: TestUserRole/Update_User_Role (0.00s) --- PASS: TestUserRole/Re-read_User_Role (0.00s) --- PASS: TestUserRole/Delete_User_Role (0.00s)PASSok github.com/hashicorp/vault-guides/plugins/vault-plugin-secrets-hashicups 0.200s
Next steps
Congratulations! You added the role/*
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 a secret for the HashiCups token in the next tutorial.