Skip to content

Commit

Permalink
feat: implemented path roles
Browse files Browse the repository at this point in the history
Signed-off-by: manhtukhang <[email protected]>
  • Loading branch information
manhtukhang committed Nov 21, 2024
1 parent 4ca4352 commit 9cdb63c
Show file tree
Hide file tree
Showing 6 changed files with 555 additions and 2 deletions.
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/hashicorp/go-hclog v1.6.3
github.com/hashicorp/vault/api v1.15.0
github.com/hashicorp/vault/sdk v0.14.0
github.com/mitchellh/mapstructure v1.5.0
github.com/sethvargo/go-password v0.3.1
github.com/stretchr/testify v1.9.0
go.nhat.io/httpmock v0.11.0
Expand Down Expand Up @@ -42,6 +43,7 @@ require (
github.com/hashicorp/go-plugin v1.6.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 // indirect
github.com/hashicorp/go-secure-stdlib/mlock v0.1.2 // indirect
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 // indirect
github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.0 // indirect
Expand All @@ -60,7 +62,6 @@ require (
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/oklog/run v1.1.0 // indirect
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISH
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 h1:ET4pqyjiGmY09R5y+rSd70J2w45CtbWDNvGqWp/R3Ng=
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw=
github.com/hashicorp/go-secure-stdlib/mlock v0.1.2 h1:p4AKXPPS24tO8Wc8i1gLvSKdmkiSY5xuju57czJ/IJQ=
github.com/hashicorp/go-secure-stdlib/mlock v0.1.2/go.mod h1:zq93CJChV6L9QTfGKtfBxKqD7BqqXx5O04A/ns2p5+I=
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 h1:iBt4Ew4XEGLfh6/bPk4rSYmuZJGizr6/x/AEizP0CQc=
Expand All @@ -126,6 +128,7 @@ github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25L
github.com/hashicorp/go-sockaddr v1.0.6 h1:RSG8rKU28VTUTvEKghe5gIhIQpv8evvNpnDEyqO4u9I=
github.com/hashicorp/go-sockaddr v1.0.6/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
Expand Down
3 changes: 2 additions & 1 deletion src/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ type backend struct {
*framework.Backend
client *nxrClient
configMutex sync.RWMutex
// rolesMutex sync.RWMutex
rolesMutex sync.RWMutex
// version string
}

Expand All @@ -55,6 +55,7 @@ func newBackend() *backend {
pathConfigAdmin(b),
pathConfigRotate(b),
},
pathRoles(b),
),
}

Expand Down
329 changes: 329 additions & 0 deletions src/path_roles.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,329 @@
package nxr

import (
"context"
"fmt"
"regexp"
"time"

"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/helper/template"
"github.com/hashicorp/vault/sdk/logical"
"github.com/mitchellh/mapstructure"
)

const (
rolesPath = "roles/"
defaultUserIdTemplate = `{{ printf "v-%s-%s-%s-%s" (.RoleName | truncate 64) (.DisplayName | truncate 64) (unix_time) (random 24) | truncate 192 | lowercase }}`
defaultUserEmail = "[email protected]" // Suppose that the email domain will never be owned by any organization or individual
emailValidationRegexString = "^(?:(?:(?:(?:[a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+(?:\\.([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+)*)|(?:(?:\\x22)(?:(?:(?:(?:\\x20|\\x09)*(?:\\x0d\\x0a))?(?:\\x20|\\x09)+)?(?:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]|\\x21|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(?:(?:[\\x01-\\x09\\x0b\\x0c\\x0d-\\x7f]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}]))))*(?:(?:(?:\\x20|\\x09)*(?:\\x0d\\x0a))?(\\x20|\\x09)+)?(?:\\x22))))@(?:(?:(?:[a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(?:(?:[a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])(?:[a-zA-Z]|\\d|-|\\.|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*(?:[a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.)+(?:(?:[a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(?:(?:[a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])(?:[a-zA-Z]|\\d|-|\\.|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*(?:[a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.?$"
)

var emailValidationRegex = regexp.MustCompile(emailValidationRegexString)

// nxrRoleEntry defines the data required for a Vault role
// to access and call the Nexus Repository API endpoints
type nxrRoleEntry struct {
Name string `json:"name" mapstructure:"name"`
NexusRoles []string `json:"nexus_roles" mapstructure:"nexus_roles"`
UserIdTemplate string `json:"user_id_template" mapstructure:"user_id_template"`
UserEmail string `json:"user_email" mapstructure:"user_email"`
TTL time.Duration `json:"ttl" mapstructure:"ttl"`
MaxTTL time.Duration `json:"max_ttl" mapstructure:"max_ttl"`
// NexusRolesCheck bool `json:"nexus_roles_check" mapstructure:"nexus_roles_check"`
// Cache bool `json:"cache" mapstructure:"cache"`
}

// toResponseData returns response data for a role
func (r *nxrRoleEntry) toResponseData() (map[string]interface{}, error) {
respData := map[string]interface{}{}

err := mapstructure.Decode(r, &respData)
if err != nil {
return nil, err
}

// Using seconds as format for TTLs
respData["ttl"] = r.TTL.Seconds()
respData["max_ttl"] = r.MaxTTL.Seconds()

return respData, err
}

// pathRoles extends the Vault API with a `/roles`
// endpoint for the backend.
func pathRoles(b *backend) []*framework.Path {
return []*framework.Path{
{
Pattern: rolesPath + framework.GenericNameRegex("name"),
Fields: map[string]*framework.FieldSchema{
"name": {
Type: framework.TypeNameString,
Description: "Name of the role.",
Required: true,
},
"nexus_roles": {
Type: framework.TypeCommaStringSlice,
Description: "The Nexus Repository roles for the user.",
Required: true,
},
"user_id_template": {
Type: framework.TypeString,
Description: fmt.Sprintf("Optional. Template to generate UserId field for the user. Default to %s.", defaultUserIdTemplate),
Default: defaultUserIdTemplate,
},
"user_email": {
Type: framework.TypeString,
Description: fmt.Sprintf("Optional. Email field for the user. Default to %s.", defaultUserEmail),
Default: defaultUserEmail,
},
"ttl": {
Type: framework.TypeDurationSecond,
Description: "Optional. Default lease for generated users. If not set or set to 0, will use system default.",
},
"max_ttl": {
Type: framework.TypeDurationSecond,
Description: "Optional. Maximum lease time for generated users. If not set or set to 0, will use system default.",
},
// TODO: check if all nexus_roles are existing on Nexus Repository server to allow create the role
// "nexus_roles_check": {
// Type: framework.TypeBool,
// Description: "Optional. Check if all nexus_roles are existing on Nexus Repository server before create the role. If not set or set to false, will skip the checking.",
// Default: false,
// },
// TODO: cache and response the previous created user for next requests (within max_ttl) to reduce API abusing
// "cache": {
// Type: framework.TypeBool,
// Description: "Optional. Cache the previous created user in this role (from a same bound claim user) to avoid creating to many users with the same privileges. Default to false.",
// Default: false,
// },
},
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: pathRolesHelpSynopsis,
HelpDescription: pathRolesHelpDescription,
ExistenceCheck: b.pathRolesExistenceCheck,
},
{
Pattern: rolesPath + "?$",
Operations: map[logical.Operation]framework.OperationHandler{
logical.ListOperation: &framework.PathOperation{
Callback: b.pathRolesList,
},
},
HelpSynopsis: pathRolesListHelpSynopsis,
HelpDescription: pathRolesListHelpDescription,
},
}
}

// pathRolesExistenceCheck verifies if the role exists
func (b *backend) pathRolesExistenceCheck(ctx context.Context, req *logical.Request, data *framework.FieldData) (bool, error) {
out, err := req.Storage.Get(ctx, req.Path)
if err != nil {
return false, err
}

return out != nil, nil
}

// pathRolesList makes a request to Vault storage to retrieve a list of roles for the backend
func (b *backend) pathRolesList(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
b.rolesMutex.RLock()
defer b.rolesMutex.RUnlock()

entries, err := req.Storage.List(ctx, rolesPath)
if err != nil {
return nil, err
}

return logical.ListResponse(entries), nil
}

// pathRolesRead makes a request to Vault storage to read a role and return response data
func (b *backend) pathRolesRead(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
b.rolesMutex.RLock()
b.configMutex.RLock()
defer b.configMutex.RUnlock()
defer b.rolesMutex.RUnlock()

config, err := b.fetchAdminConfig(ctx, req.Storage)
if err != nil {
return nil, err
}
if config == nil {
return logical.ErrorResponse("admin configuration not found"), nil
}

entry, err := getRole(ctx, req.Storage, d.Get("name").(string))
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}

respData, err := entry.toResponseData()
if err != nil {
return nil, err
}
return &logical.Response{Data: respData}, nil
}

// pathRolesWrite makes a request to Vault storage to update a role
// based on the attributes are passed to the role configuration
func (b *backend) pathRolesWrite(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
b.rolesMutex.RLock()
b.configMutex.RLock()
defer b.configMutex.RUnlock()
defer b.rolesMutex.RUnlock()

config, err := b.fetchAdminConfig(ctx, req.Storage)
if err != nil {
return nil, err
}
if config == nil {
return logical.ErrorResponse("admin configuration not found"), nil
}

name := d.Get("name").(string)
if name == "" {
return logical.ErrorResponse("missing role name"), nil
}

entry, err := getRole(ctx, req.Storage, name)
if err != nil {
return nil, err
}
if entry == nil {
entry = &nxrRoleEntry{
Name: name,
}
}

createOperation := (req.Operation == logical.CreateOperation)

if nexusRolesRaw, ok := d.GetOk("nexus_roles"); ok {
entry.NexusRoles = nexusRolesRaw.([]string)
} else if !ok && createOperation {
return logical.ErrorResponse(`missing "nexus_roles" in role definition`), nil
}

entry.UserIdTemplate = d.Get("user_id_template").(string)

entry.UserEmail = d.Get("user_email").(string)

if ttlRaw, ok := d.GetOk("ttl"); ok {
entry.TTL = time.Duration(ttlRaw.(int)) * time.Second
} else if createOperation {
entry.TTL = time.Duration(d.Get("ttl").(int)) * time.Second
}

if maxTTLRaw, ok := d.GetOk("max_ttl"); ok {
entry.MaxTTL = time.Duration(maxTTLRaw.(int)) * time.Second
} else if createOperation {
entry.MaxTTL = time.Duration(d.Get("max_ttl").(int)) * time.Second
}

// Verifying
if _, err := template.NewTemplate(template.Template(entry.UserIdTemplate)); err != nil {
return logical.ErrorResponse(`unable to initialize "user_id_template"`), err
}

if !emailValidationRegex.MatchString(entry.UserEmail) {
return logical.ErrorResponse(`"user_email" is not valid`), nil
}

if entry.MaxTTL != 0 && entry.TTL > entry.MaxTTL {
return logical.ErrorResponse(`"ttl" cannot be greater than "max_ttl"`), nil
}

if err := setRole(ctx, req.Storage, name, entry); err != nil {
return nil, err
}

return nil, nil
}

// pathRolesDelete makes a request to Vault storage to delete a role
func (b *backend) pathRolesDelete(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
b.rolesMutex.RLock()
b.configMutex.RLock()
defer b.configMutex.RUnlock()
defer b.rolesMutex.RUnlock()

config, err := b.fetchAdminConfig(ctx, req.Storage)
if err != nil {
return nil, err
}
if config == nil {
return logical.ErrorResponse("admin configuration not found"), nil
}

err = req.Storage.Delete(ctx, rolesPath+d.Get("name").(string))
if err != nil {
return nil, err
}

return nil, nil
}

// setRole adds the role to the Vault storage API
func setRole(ctx context.Context, s logical.Storage, name string, roleEntry *nxrRoleEntry) error {
entry, err := logical.StorageEntryJSON(rolesPath+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
}

// getRole gets the role from the Vault storage API
func getRole(ctx context.Context, s logical.Storage, name string) (*nxrRoleEntry, error) {
if name == "" {
return nil, fmt.Errorf("missing role name")
}

entry, err := s.Get(ctx, rolesPath+name)
if err != nil {
return nil, err
}

if entry == nil {
return nil, nil
}

var role nxrRoleEntry

if err := entry.DecodeJSON(&role); err != nil {
return nil, err
}
return &role, nil
}

const (
pathRolesHelpSynopsis = `Manage the roles that can be created with this secrets engine.`
pathRolesHelpDescription = `This path lets you manage the roles that can be created with this secrets engine.`
pathRolesListHelpSynopsis = `List the existing roles in this secrets engine.`
pathRolesListHelpDescription = `A list of existing role names will be returned.`
)
Loading

0 comments on commit 9cdb63c

Please sign in to comment.