diff --git a/builtin/credential/approle/backend.go b/builtin/credential/approle/backend.go new file mode 100644 index 0000000000000..3a44663afaf4a --- /dev/null +++ b/builtin/credential/approle/backend.go @@ -0,0 +1,131 @@ +package approle + +import ( + "fmt" + "sync" + + "github.com/hashicorp/vault/helper/locksutil" + "github.com/hashicorp/vault/helper/salt" + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +type backend struct { + *framework.Backend + + // The salt value to be used by the information to be accessed only + // by this backend. + salt *salt.Salt + + // Guard to clean-up the expired SecretID entries + tidySecretIDCASGuard uint32 + + // Lock to make changes to Role entries. This is a low-traffic + // operation. So, using a single lock would suffice. + roleLock *sync.RWMutex + + // Map of locks to make changes to the storage entries of RoleIDs + // generated. This will be initiated to a predefined number of locks + // when the backend is created, and will be indexed based on the salted + // RoleIDs. + roleIDLocksMap map[string]*sync.RWMutex + + // Map of locks to make changes to the storage entries of SecretIDs + // generated. This will be initiated to a predefined number of locks + // when the backend is created, and will be indexed based on the HMAC-ed + // SecretIDs. + secretIDLocksMap map[string]*sync.RWMutex +} + +func Factory(conf *logical.BackendConfig) (logical.Backend, error) { + b, err := Backend(conf) + if err != nil { + return nil, err + } + return b.Setup(conf) +} + +func Backend(conf *logical.BackendConfig) (*backend, error) { + // Initialize the salt + salt, err := salt.NewSalt(conf.StorageView, &salt.Config{ + HashFunc: salt.SHA256Hash, + }) + if err != nil { + return nil, err + } + + // Create a backend object + b := &backend{ + // Set the salt object for the backend + salt: salt, + + // Create the lock for making changes to the Roles registered with the backend + roleLock: &sync.RWMutex{}, + + // Create the map of locks to modify the generated RoleIDs. + roleIDLocksMap: map[string]*sync.RWMutex{}, + + // Create the map of locks to modify the generated SecretIDs. + secretIDLocksMap: map[string]*sync.RWMutex{}, + } + + // Create 256 locks each for managing RoleID and SecretIDs. This will avoid + // a superfluous number of locks directly proportional to the number of RoleID + // and SecretIDs. These locks can be accessed by indexing based on the first two + // characters of a randomly generated UUID. + if err = locksutil.CreateLocks(b.roleIDLocksMap, 256); err != nil { + return nil, fmt.Errorf("failed to create role ID locks: %v", err) + } + + if err = locksutil.CreateLocks(b.secretIDLocksMap, 256); err != nil { + return nil, fmt.Errorf("failed to create secret ID locks: %v", err) + } + + // Have an extra lock to use in case the indexing does not result in a lock. + // This happens if the indexing value is not beginning with hex characters. + // These locks can be used for listing purposes as well. + b.secretIDLocksMap["custom"] = &sync.RWMutex{} + b.roleIDLocksMap["custom"] = &sync.RWMutex{} + + // Attach the paths and secrets that are to be handled by the backend + b.Backend = &framework.Backend{ + // Register a periodic function that deletes the expired SecretID entries + PeriodicFunc: b.periodicFunc, + Help: backendHelp, + AuthRenew: b.pathLoginRenew, + PathsSpecial: &logical.Paths{ + Unauthenticated: []string{ + "login", + }, + }, + Paths: framework.PathAppend( + rolePaths(b), + []*framework.Path{ + pathLogin(b), + pathTidySecretID(b), + }, + ), + } + return b, nil +} + +// periodicFunc of the backend will be invoked once a minute by the RollbackManager. +// RoleRole backend utilizes this function to delete expired SecretID entries. +// This could mean that the SecretID may live in the backend upto 1 min after its +// expiration. The deletion of SecretIDs are not security sensitive and it is okay +// to delay the removal of SecretIDs by a minute. +func (b *backend) periodicFunc(req *logical.Request) error { + // Initiate clean-up of expired SecretID entries + b.tidySecretID(req.Storage) + return nil +} + +const backendHelp = ` +Any registered Role can authenticate itself with Vault. The credentials +depends on the constraints that are set on the Role. One common required +credential is the 'role_id' which is a unique identifier of the Role. +It can be retrieved from the 'role//role-id' endpoint. + +The default constraint configuration is 'bind_secret_id', which requires +the credential 'secret_id' to be presented during login. Refer to the +documentation for other types of constraints.` diff --git a/builtin/credential/approle/backend_test.go b/builtin/credential/approle/backend_test.go new file mode 100644 index 0000000000000..2a3e3773ec8cf --- /dev/null +++ b/builtin/credential/approle/backend_test.go @@ -0,0 +1,25 @@ +package approle + +import ( + "testing" + + "github.com/hashicorp/vault/logical" +) + +func createBackendWithStorage(t *testing.T) (*backend, logical.Storage) { + config := logical.TestBackendConfig() + config.StorageView = &logical.InmemStorage{} + + b, err := Backend(config) + if err != nil { + t.Fatal(err) + } + if b == nil { + t.Fatalf("failed to create backend") + } + _, err = b.Backend.Setup(config) + if err != nil { + t.Fatal(err) + } + return b, config.StorageView +} diff --git a/builtin/credential/approle/path_login.go b/builtin/credential/approle/path_login.go new file mode 100644 index 0000000000000..65a9ec4f57eeb --- /dev/null +++ b/builtin/credential/approle/path_login.go @@ -0,0 +1,105 @@ +package approle + +import ( + "fmt" + "time" + + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func pathLogin(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "login$", + Fields: map[string]*framework.FieldSchema{ + "role_id": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Unique identifier of the Role. Required to be supplied when the 'bind_secret_id' constraint is set.", + }, + "secret_id": &framework.FieldSchema{ + Type: framework.TypeString, + Default: "", + Description: "SecretID belong to the App role", + }, + }, + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.UpdateOperation: b.pathLoginUpdate, + }, + HelpSynopsis: pathLoginHelpSys, + HelpDescription: pathLoginHelpDesc, + } +} + +// Returns the Auth object indicating the authentication and authorization information +// if the credentials provided are validated by the backend. +func (b *backend) pathLoginUpdate(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + role, roleName, metadata, err := b.validateCredentials(req, data) + if err != nil || role == nil { + return logical.ErrorResponse(fmt.Sprintf("failed to validate SecretID: %s", err)), nil + } + + auth := &logical.Auth{ + Period: role.Period, + InternalData: map[string]interface{}{ + "role_name": roleName, + }, + Metadata: metadata, + Policies: role.Policies, + LeaseOptions: logical.LeaseOptions{ + Renewable: true, + }, + } + + // If 'Period' is set, use the value of 'Period' as the TTL. + // Otherwise, set the normal TokenTTL. + if role.Period > time.Duration(0) { + auth.TTL = role.Period + } else { + auth.TTL = role.TokenTTL + } + + return &logical.Response{ + Auth: auth, + }, nil +} + +// Invoked when the token issued by this backend is attempting a renewal. +func (b *backend) pathLoginRenew(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := req.Auth.InternalData["role_name"].(string) + if roleName == "" { + return nil, fmt.Errorf("failed to fetch role_name during renewal") + } + + // Ensure that the Role still exists. + role, err := b.roleEntry(req.Storage, roleName) + if err != nil { + return nil, fmt.Errorf("failed to validate role %s during renewal:%s", roleName, err) + } + if role == nil { + return nil, fmt.Errorf("role %s does not exist during renewal", roleName) + } + + // If 'Period' is set on the Role, the token should never expire. + // Replenish the TTL with 'Period's value. + if role.Period > time.Duration(0) { + // If 'Period' was updated after the token was issued, + // token will bear the updated 'Period' value as its TTL. + req.Auth.TTL = role.Period + return &logical.Response{Auth: req.Auth}, nil + } else { + return framework.LeaseExtend(role.TokenTTL, role.TokenMaxTTL, b.System())(req, data) + } +} + +const pathLoginHelpSys = "Issue a token based on the credentials supplied" + +const pathLoginHelpDesc = ` +While the credential 'role_id' is required at all times, +other credentials required depends on the properties App role +to which the 'role_id' belongs to. The 'bind_secret_id' +constraint (enabled by default) on the App role requires the +'secret_id' credential to be presented. + +'role_id' is fetched using the 'role//role_id' +endpoint and 'secret_id' is fetched using the 'role//secret_id' +endpoint.` diff --git a/builtin/credential/approle/path_login_test.go b/builtin/credential/approle/path_login_test.go new file mode 100644 index 0000000000000..03d3f68c34965 --- /dev/null +++ b/builtin/credential/approle/path_login_test.go @@ -0,0 +1,55 @@ +package approle + +import ( + "testing" + + "github.com/hashicorp/vault/logical" +) + +func TestAppRole_RoleLogin(t *testing.T) { + var resp *logical.Response + var err error + b, storage := createBackendWithStorage(t) + + createRole(t, b, storage, "role1", "a,b,c") + roleRoleIDReq := &logical.Request{ + Operation: logical.ReadOperation, + Path: "role/role1/role-id", + Storage: storage, + } + resp, err = b.HandleRequest(roleRoleIDReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + roleID := resp.Data["role_id"] + + roleSecretIDReq := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "role/role1/secret-id", + Storage: storage, + } + resp, err = b.HandleRequest(roleSecretIDReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + secretID := resp.Data["secret_id"] + + loginData := map[string]interface{}{ + "role_id": roleID, + "secret_id": secretID, + } + loginReq := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "login", + Storage: storage, + Data: loginData, + } + resp, err = b.HandleRequest(loginReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + if resp.Auth == nil { + t.Fatalf("expected a non-nil auth object in the response") + } +} diff --git a/builtin/credential/approle/path_role.go b/builtin/credential/approle/path_role.go new file mode 100644 index 0000000000000..d496d9bcd88a7 --- /dev/null +++ b/builtin/credential/approle/path_role.go @@ -0,0 +1,1654 @@ +package approle + +import ( + "fmt" + "strings" + "sync" + "time" + + "github.com/fatih/structs" + "github.com/hashicorp/go-uuid" + "github.com/hashicorp/vault/helper/policyutil" + "github.com/hashicorp/vault/helper/strutil" + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +// roleStorageEntry stores all the options that are set on an role +type roleStorageEntry struct { + // UUID that uniquely represents this role. This serves as a credential + // to perform login using this role. + RoleID string `json:"role_id" structs:"role_id" mapstructure:"role_id"` + + // UUID that serves as the HMAC key for the hashing the 'secret_id's + // of the role + HMACKey string `json:"hmac_key" structs:"hmac_key" mapstructure:"hmac_key"` + + // Policies that are to be required by the token to access this role + Policies []string `json:"policies" structs:"policies" mapstructure:"policies"` + + // Number of times the SecretID generated against this role can be + // used to perform login operation + SecretIDNumUses int `json:"secret_id_num_uses" structs:"secret_id_num_uses" mapstructure:"secret_id_num_uses"` + + // Duration (less than the backend mount's max TTL) after which a + // SecretID generated against the role will expire + SecretIDTTL time.Duration `json:"secret_id_ttl" structs:"secret_id_ttl" mapstructure:"secret_id_ttl"` + + // Duration before which an issued token must be renewed + TokenTTL time.Duration `json:"token_ttl" structs:"token_ttl" mapstructure:"token_ttl"` + + // Duration after which an issued token should not be allowed to be renewed + TokenMaxTTL time.Duration `json:"token_max_ttl" structs:"token_max_ttl" mapstructure:"token_max_ttl"` + + // A constraint, if set, requires 'secret_id' credential to be presented during login + BindSecretID bool `json:"bind_secret_id" structs:"bind_secret_id" mapstructure:"bind_secret_id"` + + // A constraint, if set, specifies the CIDR blocks from which logins should be allowed + BoundCIDRList string `json:"bound_cidr_list" structs:"bound_cidr_list" mapstructure:"bound_cidr_list"` + + // Period, if set, indicates that the token generated using this role + // should never expire. The token should be renewed within the duration + // specified by this value. The renewal duration will be fixed if the + // value is not modified on the role. If the `Period` in the role is modified, + // a token will pick up the new value during its next renewal. + Period time.Duration `json:"period" mapstructure:"period" structs:"period"` +} + +// roleIDStorageEntry represents the reverse mapping from RoleID to Role +type roleIDStorageEntry struct { + Name string `json:"name" structs:"name" mapstructure:"name"` +} + +// rolePaths creates all the paths that are used to register and manage an role. +// +// Paths returned: +// role/ - For listing all the registered roles +// role/ - For registering an role +// role//policies - For updating the param +// role//secret-id-num-uses - For updating the param +// role//secret-id-ttl - For updating the param +// role//token-ttl - For updating the param +// role//token-max-ttl - For updating the param +// role//bind-secret-id - For updating the param +// role//bound-cidr-list - For updating the param +// role//period - For updating the param +// role//role-id - For fetching the role_id of an role +// role//secret-id - For issuing a secret_id against an role, also to list the secret_id_accessorss +// role//secret-id/ - For reading the properties of, or deleting a secret_id +// role//custom-secret-id - For assigning a custom SecretID against an role +func rolePaths(b *backend) []*framework.Path { + return []*framework.Path{ + &framework.Path{ + Pattern: "role/?", + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.ListOperation: b.pathRoleList, + }, + HelpSynopsis: strings.TrimSpace(roleHelp["role-list"][0]), + HelpDescription: strings.TrimSpace(roleHelp["role-list"][1]), + }, + &framework.Path{ + Pattern: "role/" + framework.GenericNameRegex("role_name"), + Fields: map[string]*framework.FieldSchema{ + "role_name": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Name of the role.", + }, + "bind_secret_id": &framework.FieldSchema{ + Type: framework.TypeBool, + Default: true, + Description: "Impose secret_id to be presented when logging in using this role. Defaults to 'true'.", + }, + "bound_cidr_list": &framework.FieldSchema{ + Type: framework.TypeString, + Description: `Comma separated list of CIDR blocks, if set, specifies blocks of IP +addresses which can perform the login operation`, + }, + "policies": &framework.FieldSchema{ + Type: framework.TypeString, + Default: "default", + Description: "Comma separated list of policies on the role.", + }, + "secret_id_num_uses": &framework.FieldSchema{ + Type: framework.TypeInt, + Description: `Number of times a SecretID can access the role, after which the SecretID +will expire. Defaults to 0 meaning that the the secret_id is of unlimited use.`, + }, + "secret_id_ttl": &framework.FieldSchema{ + Type: framework.TypeDurationSecond, + Description: `Duration in seconds after which the issued SecretID should expire. Defaults +to 0, in which case the value will fall back to the system/mount defaults.`, + }, + "token_ttl": &framework.FieldSchema{ + Type: framework.TypeDurationSecond, + Description: `Duration in seconds after which the issued token should expire. Defaults +to 0, in which case the value will fall back to the system/mount defaults.`, + }, + "token_max_ttl": &framework.FieldSchema{ + Type: framework.TypeDurationSecond, + Description: `Duration in seconds after which the issued token should not be allowed to +be renewed. Defaults to 0, in which case the value will fall back to the system/mount defaults.`, + }, + "period": &framework.FieldSchema{ + Type: framework.TypeDurationSecond, + Default: 0, + Description: `If set, indicates that the token generated using this role +should never expire. The token should be renewed within the +duration specified by this value. At each renewal, the token's +TTL will be set to the value of this parameter.`, + }, + "role_id": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Identifier of the role. Defaults to a UUID.", + }, + }, + ExistenceCheck: b.pathRoleExistenceCheck, + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.CreateOperation: b.pathRoleCreateUpdate, + logical.UpdateOperation: b.pathRoleCreateUpdate, + logical.ReadOperation: b.pathRoleRead, + logical.DeleteOperation: b.pathRoleDelete, + }, + HelpSynopsis: strings.TrimSpace(roleHelp["role"][0]), + HelpDescription: strings.TrimSpace(roleHelp["role"][1]), + }, + &framework.Path{ + Pattern: "role/" + framework.GenericNameRegex("role_name") + "/policies$", + Fields: map[string]*framework.FieldSchema{ + "role_name": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Name of the role.", + }, + "policies": &framework.FieldSchema{ + Type: framework.TypeString, + Default: "default", + Description: "Comma separated list of policies on the role.", + }, + }, + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.UpdateOperation: b.pathRolePoliciesUpdate, + logical.ReadOperation: b.pathRolePoliciesRead, + logical.DeleteOperation: b.pathRolePoliciesDelete, + }, + HelpSynopsis: strings.TrimSpace(roleHelp["role-policies"][0]), + HelpDescription: strings.TrimSpace(roleHelp["role-policies"][1]), + }, + &framework.Path{ + Pattern: "role/" + framework.GenericNameRegex("role_name") + "/bound-cidr-list$", + Fields: map[string]*framework.FieldSchema{ + "role_name": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Name of the role.", + }, + "bound_cidr_list": &framework.FieldSchema{ + Type: framework.TypeString, + Description: `Comma separated list of CIDR blocks, if set, specifies blocks of IP +addresses which can perform the login operation`, + }, + }, + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.UpdateOperation: b.pathRoleBoundCIDRListUpdate, + logical.ReadOperation: b.pathRoleBoundCIDRListRead, + logical.DeleteOperation: b.pathRoleBoundCIDRListDelete, + }, + HelpSynopsis: strings.TrimSpace(roleHelp["role-bound-cidr-list"][0]), + HelpDescription: strings.TrimSpace(roleHelp["role-bound-cidr-list"][1]), + }, + &framework.Path{ + Pattern: "role/" + framework.GenericNameRegex("role_name") + "/bind-secret-id$", + Fields: map[string]*framework.FieldSchema{ + "role_name": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Name of the role.", + }, + "bind_secret_id": &framework.FieldSchema{ + Type: framework.TypeBool, + Default: true, + Description: "Impose secret_id to be presented when logging in using this role.", + }, + }, + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.UpdateOperation: b.pathRoleBindSecretIDUpdate, + logical.ReadOperation: b.pathRoleBindSecretIDRead, + logical.DeleteOperation: b.pathRoleBindSecretIDDelete, + }, + HelpSynopsis: strings.TrimSpace(roleHelp["role-bind-secret-id"][0]), + HelpDescription: strings.TrimSpace(roleHelp["role-bind-secret-id"][1]), + }, + &framework.Path{ + Pattern: "role/" + framework.GenericNameRegex("role_name") + "/secret-id-num-uses$", + Fields: map[string]*framework.FieldSchema{ + "role_name": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Name of the role.", + }, + "secret_id_num_uses": &framework.FieldSchema{ + Type: framework.TypeInt, + Description: "Number of times a SecretID can access the role, after which the SecretID will expire.", + }, + }, + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.UpdateOperation: b.pathRoleSecretIDNumUsesUpdate, + logical.ReadOperation: b.pathRoleSecretIDNumUsesRead, + logical.DeleteOperation: b.pathRoleSecretIDNumUsesDelete, + }, + HelpSynopsis: strings.TrimSpace(roleHelp["role-secret-id-num-uses"][0]), + HelpDescription: strings.TrimSpace(roleHelp["role-secret-id-num-uses"][1]), + }, + &framework.Path{ + Pattern: "role/" + framework.GenericNameRegex("role_name") + "/secret-id-ttl$", + Fields: map[string]*framework.FieldSchema{ + "role_name": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Name of the role.", + }, + "secret_id_ttl": &framework.FieldSchema{ + Type: framework.TypeDurationSecond, + Description: `Duration in seconds after which the issued SecretID should expire. Defaults +to 0, in which case the value will fall back to the system/mount defaults.`, + }, + }, + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.UpdateOperation: b.pathRoleSecretIDTTLUpdate, + logical.ReadOperation: b.pathRoleSecretIDTTLRead, + logical.DeleteOperation: b.pathRoleSecretIDTTLDelete, + }, + HelpSynopsis: strings.TrimSpace(roleHelp["role-secret-id-ttl"][0]), + HelpDescription: strings.TrimSpace(roleHelp["role-secret-id-ttl"][1]), + }, + &framework.Path{ + Pattern: "role/" + framework.GenericNameRegex("role_name") + "/period$", + Fields: map[string]*framework.FieldSchema{ + "role_name": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Name of the role.", + }, + "period": &framework.FieldSchema{ + Type: framework.TypeDurationSecond, + Default: 0, + Description: `If set, indicates that the token generated using this role +should never expire. The token should be renewed within the +duration specified by this value. At each renewal, the token's +TTL will be set to the value of this parameter.`, + }, + }, + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.UpdateOperation: b.pathRolePeriodUpdate, + logical.ReadOperation: b.pathRolePeriodRead, + logical.DeleteOperation: b.pathRolePeriodDelete, + }, + HelpSynopsis: strings.TrimSpace(roleHelp["role-period"][0]), + HelpDescription: strings.TrimSpace(roleHelp["role-period"][1]), + }, + + &framework.Path{ + Pattern: "role/" + framework.GenericNameRegex("role_name") + "/token-ttl$", + Fields: map[string]*framework.FieldSchema{ + "role_name": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Name of the role.", + }, + "token_ttl": &framework.FieldSchema{ + Type: framework.TypeDurationSecond, + Description: `Duration in seconds after which the issued token should expire. Defaults +to 0, in which case the value will fall back to the system/mount defaults.`, + }, + }, + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.UpdateOperation: b.pathRoleTokenTTLUpdate, + logical.ReadOperation: b.pathRoleTokenTTLRead, + logical.DeleteOperation: b.pathRoleTokenTTLDelete, + }, + HelpSynopsis: strings.TrimSpace(roleHelp["role-token-ttl"][0]), + HelpDescription: strings.TrimSpace(roleHelp["role-token-ttl"][1]), + }, + &framework.Path{ + Pattern: "role/" + framework.GenericNameRegex("role_name") + "/token-max-ttl$", + Fields: map[string]*framework.FieldSchema{ + "role_name": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Name of the role.", + }, + "token_max_ttl": &framework.FieldSchema{ + Type: framework.TypeDurationSecond, + Description: `Duration in seconds after which the issued token should not be allowed to +be renewed. Defaults to 0, in which case the value will fall back to the system/mount defaults.`, + }, + }, + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.UpdateOperation: b.pathRoleTokenMaxTTLUpdate, + logical.ReadOperation: b.pathRoleTokenMaxTTLRead, + logical.DeleteOperation: b.pathRoleTokenMaxTTLDelete, + }, + HelpSynopsis: strings.TrimSpace(roleHelp["role-token-max-ttl"][0]), + HelpDescription: strings.TrimSpace(roleHelp["role-token-max-ttl"][1]), + }, + &framework.Path{ + Pattern: "role/" + framework.GenericNameRegex("role_name") + "/role-id$", + Fields: map[string]*framework.FieldSchema{ + "role_name": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Name of the role.", + }, + "role_id": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Identifier of the role. Defaults to a UUID.", + }, + }, + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.ReadOperation: b.pathRoleRoleIDRead, + logical.UpdateOperation: b.pathRoleRoleIDUpdate, + }, + HelpSynopsis: strings.TrimSpace(roleHelp["role-id"][0]), + HelpDescription: strings.TrimSpace(roleHelp["role-id"][1]), + }, + &framework.Path{ + Pattern: "role/" + framework.GenericNameRegex("role_name") + "/secret-id/?$", + Fields: map[string]*framework.FieldSchema{ + "role_name": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Name of the role.", + }, + "metadata": &framework.FieldSchema{ + Type: framework.TypeString, + Description: `Metadata to be tied to the SecretID. This should be a JSON +formatted string containing the metadata in key value pairs.`, + }, + }, + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.UpdateOperation: b.pathRoleSecretIDUpdate, + logical.ListOperation: b.pathRoleSecretIDList, + }, + HelpSynopsis: strings.TrimSpace(roleHelp["role-secret-id"][0]), + HelpDescription: strings.TrimSpace(roleHelp["role-secret-id"][1]), + }, + &framework.Path{ + Pattern: "role/" + framework.GenericNameRegex("role_name") + "/secret-id/" + framework.GenericNameRegex("secret_id_accessor"), + Fields: map[string]*framework.FieldSchema{ + "role_name": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Name of the role.", + }, + "secret_id_accessor": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Accessor of the SecretID", + }, + }, + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.ReadOperation: b.pathRoleSecretIDAccessorRead, + logical.DeleteOperation: b.pathRoleSecretIDAccessorDelete, + }, + HelpSynopsis: strings.TrimSpace(roleHelp["role-secret-id-accessor"][0]), + HelpDescription: strings.TrimSpace(roleHelp["role-secret-id-accessor"][1]), + }, + &framework.Path{ + Pattern: "role/" + framework.GenericNameRegex("role_name") + "/custom-secret-id$", + Fields: map[string]*framework.FieldSchema{ + "role_name": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Name of the role.", + }, + "secret_id": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "SecretID to be attached to the role.", + }, + "metadata": &framework.FieldSchema{ + Type: framework.TypeString, + Description: `Metadata to be tied to the SecretID. This should be a JSON +formatted string containing metadata in key value pairs.`, + }, + }, + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.UpdateOperation: b.pathRoleCustomSecretIDUpdate, + }, + HelpSynopsis: strings.TrimSpace(roleHelp["role-custom-secret-id"][0]), + HelpDescription: strings.TrimSpace(roleHelp["role-custom-secret-id"][1]), + }, + } +} + +// pathRoleExistenceCheck returns whether the role with the given name exists or not. +func (b *backend) pathRoleExistenceCheck(req *logical.Request, data *framework.FieldData) (bool, error) { + role, err := b.roleEntry(req.Storage, data.Get("role_name").(string)) + if err != nil { + return false, err + } + return role != nil, nil +} + +// pathRoleList is used to list all the Roles registered with the backend. +func (b *backend) pathRoleList(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + b.roleLock.RLock() + defer b.roleLock.RUnlock() + roles, err := req.Storage.List("role/") + if err != nil { + return nil, err + } + return logical.ListResponse(roles), nil +} + +// pathRoleSecretIDList is used to list all the 'secret_id_accessor's issued against the role. +func (b *backend) pathRoleSecretIDList(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + // Get the role entry + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + if role == nil { + return logical.ErrorResponse(fmt.Sprintf("role %s does not exist", roleName)), nil + } + + // If the argument to secretIDLock does not start with 2 hex + // chars, a generic lock is returned. So, passing empty string + // to get the "custom" lock that could be used for listing. + lock := b.secretIDLock("") + lock.RLock() + defer lock.RUnlock() + + roleNameHMAC, err := createHMAC(role.HMACKey, roleName) + if err != nil { + return nil, fmt.Errorf("failed to create HMAC of role_name: %s", err) + } + + // Listing works one level at a time. Get the first level of data + // which could then be used to get the actual SecretID storage entries. + secretIDHMACs, err := req.Storage.List(fmt.Sprintf("secret_id/%s/", roleNameHMAC)) + if err != nil { + return nil, err + } + + var listItems []string + for _, secretIDHMAC := range secretIDHMACs { + // Prepare the full index of the SecretIDs. + entryIndex := fmt.Sprintf("secret_id/%s/%s", roleNameHMAC, secretIDHMAC) + + // SecretID locks are not indexed by SecretIDs itself. + // This is because SecretIDs are not stored in plaintext + // form anywhere in the backend, and hence accessing its + // corresponding lock many times using SecretIDs is not + // possible. Also, indexing it everywhere using secretIDHMACs + // makes listing operation easier. + lock := b.secretIDLock(secretIDHMAC) + lock.RLock() + + result := secretIDStorageEntry{} + if entry, err := req.Storage.Get(entryIndex); err != nil { + lock.RUnlock() + return nil, err + } else if entry == nil { + lock.RUnlock() + return nil, fmt.Errorf("storage entry for SecretID is present but no content found at the index") + } else if err := entry.DecodeJSON(&result); err != nil { + lock.RUnlock() + return nil, err + } + listItems = append(listItems, result.SecretIDAccessor) + lock.RUnlock() + } + + return logical.ListResponse(listItems), nil +} + +// setRoleEntry grabs a write lock and stores the options on an role into the storage. +// Also creates a reverse index from the role's RoleID to the role itself. +func (b *backend) setRoleEntry(s logical.Storage, roleName string, role *roleStorageEntry, previousRoleID string) error { + b.roleLock.Lock() + defer b.roleLock.Unlock() + + // Create a storage entry for the role + entry, err := logical.StorageEntryJSON("role/"+strings.ToLower(roleName), role) + if err != nil { + return err + } + if entry == nil { + return fmt.Errorf("failed to create storage entry for role %s", roleName) + } + + // Check if the index from the role_id to role already exists + roleIDIndex, err := b.roleIDEntry(s, role.RoleID) + if err != nil { + return fmt.Errorf("failed to read role_id index: %v", err) + } + + // If the entry exists, make sure that it belongs to the current role + if roleIDIndex != nil && roleIDIndex.Name != roleName { + return fmt.Errorf("role_id already in use") + } + + // When role_id is getting updated, delete the old index before + // a new one is created + if previousRoleID != "" && previousRoleID != role.RoleID { + if err = b.roleIDEntryDelete(s, previousRoleID); err != nil { + return fmt.Errorf("failed to delete previous role ID index") + } + } + + // Save the role entry only after all the validations + if err = s.Put(entry); err != nil { + return err + } + + // Create a storage entry for reverse mapping of RoleID to role. + // Note that secondary index is created when the roleLock is held. + return b.setRoleIDEntry(s, role.RoleID, &roleIDStorageEntry{ + Name: roleName, + }) +} + +// roleEntry grabs the read lock and fetches the options of an role from the storage +func (b *backend) roleEntry(s logical.Storage, roleName string) (*roleStorageEntry, error) { + if roleName == "" { + return nil, fmt.Errorf("missing role_name") + } + + var result roleStorageEntry + + b.roleLock.RLock() + defer b.roleLock.RUnlock() + + if entry, err := s.Get("role/" + strings.ToLower(roleName)); err != nil { + return nil, err + } else if entry == nil { + return nil, nil + } else if err := entry.DecodeJSON(&result); err != nil { + return nil, err + } + + return &result, nil +} + +// pathRoleCreateUpdate registers a new role with the backend or updates the options +// of an existing role +func (b *backend) pathRoleCreateUpdate(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + // Check if the role already exists + role, err := b.roleEntry(req.Storage, roleName) + if err != nil { + return nil, err + } + + // Create a new entry object if this is a CreateOperation + if role == nil && req.Operation == logical.CreateOperation { + hmacKey, err := uuid.GenerateUUID() + if err != nil { + return nil, fmt.Errorf("failed to create role_id: %s\n", err) + } + role = &roleStorageEntry{ + HMACKey: hmacKey, + } + } else if role == nil { + return nil, fmt.Errorf("role entry not found during update operation") + } + + previousRoleID := role.RoleID + if roleIDRaw, ok := data.GetOk("role_id"); ok { + role.RoleID = roleIDRaw.(string) + } else if req.Operation == logical.CreateOperation { + roleID, err := uuid.GenerateUUID() + if err != nil { + return nil, fmt.Errorf("failed to generate role_id: %s\n", err) + } + role.RoleID = roleID + } + if role.RoleID == "" { + return logical.ErrorResponse("invalid role_id"), nil + } + + if bindSecretIDRaw, ok := data.GetOk("bind_secret_id"); ok { + role.BindSecretID = bindSecretIDRaw.(bool) + } else if req.Operation == logical.CreateOperation { + role.BindSecretID = data.Get("bind_secret_id").(bool) + } + + if boundCIDRListRaw, ok := data.GetOk("bound_cidr_list"); ok { + role.BoundCIDRList = strings.TrimSpace(boundCIDRListRaw.(string)) + } else if req.Operation == logical.CreateOperation { + role.BoundCIDRList = data.Get("bound_cidr_list").(string) + } + if err = validateCIDRList(role.BoundCIDRList); err != nil { + return logical.ErrorResponse(fmt.Sprintf("failed to validate CIDR blocks: %s", err)), nil + } + + if policiesRaw, ok := data.GetOk("policies"); ok { + role.Policies = policyutil.ParsePolicies(policiesRaw.(string)) + } else if req.Operation == logical.CreateOperation { + role.Policies = policyutil.ParsePolicies(data.Get("policies").(string)) + } + + periodRaw, ok := data.GetOk("period") + if ok { + role.Period = time.Second * time.Duration(periodRaw.(int)) + } else if req.Operation == logical.CreateOperation { + role.Period = time.Second * time.Duration(data.Get("period").(int)) + } + if role.Period > b.System().MaxLeaseTTL() { + return logical.ErrorResponse(fmt.Sprintf("'period' of '%s' is greater than the backend's maximum lease TTL of '%s'", role.Period.String(), b.System().MaxLeaseTTL().String())), nil + } + + if secretIDNumUsesRaw, ok := data.GetOk("secret_id_num_uses"); ok { + role.SecretIDNumUses = secretIDNumUsesRaw.(int) + } else if req.Operation == logical.CreateOperation { + role.SecretIDNumUses = data.Get("secret_id_num_uses").(int) + } + if role.SecretIDNumUses < 0 { + return logical.ErrorResponse("secret_id_num_uses cannot be negative"), nil + } + + if secretIDTTLRaw, ok := data.GetOk("secret_id_ttl"); ok { + role.SecretIDTTL = time.Second * time.Duration(secretIDTTLRaw.(int)) + } else if req.Operation == logical.CreateOperation { + role.SecretIDTTL = time.Second * time.Duration(data.Get("secret_id_ttl").(int)) + } + + if tokenTTLRaw, ok := data.GetOk("token_ttl"); ok { + role.TokenTTL = time.Second * time.Duration(tokenTTLRaw.(int)) + } else if req.Operation == logical.CreateOperation { + role.TokenTTL = time.Second * time.Duration(data.Get("token_ttl").(int)) + } + + if tokenMaxTTLRaw, ok := data.GetOk("token_max_ttl"); ok { + role.TokenMaxTTL = time.Second * time.Duration(tokenMaxTTLRaw.(int)) + } else if req.Operation == logical.CreateOperation { + role.TokenMaxTTL = time.Second * time.Duration(data.Get("token_max_ttl").(int)) + } + + // Check that the TokenTTL value provided is less than the TokenMaxTTL. + // Sanitizing the TTL and MaxTTL is not required now and can be performed + // at credential issue time. + if role.TokenMaxTTL > time.Duration(0) && role.TokenTTL > role.TokenMaxTTL { + return logical.ErrorResponse("token_ttl should not be greater than token_max_ttl"), nil + } + + var resp *logical.Response + if role.TokenMaxTTL > b.System().MaxLeaseTTL() { + resp = &logical.Response{} + resp.AddWarning("token_max_ttl is greater than the backend mount's maximum TTL value; issued tokens' max TTL value will be truncated") + } + + // Store the entry. + return resp, b.setRoleEntry(req.Storage, roleName, role, previousRoleID) +} + +// pathRoleRead grabs a read lock and reads the options set on the role from the storage +func (b *backend) pathRoleRead(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + if role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)); err != nil { + return nil, err + } else if role == nil { + return nil, nil + } else { + // Convert the 'time.Duration' values to second. + role.SecretIDTTL /= time.Second + role.TokenTTL /= time.Second + role.TokenMaxTTL /= time.Second + role.Period /= time.Second + + // Create a map of data to be returned and remove sensitive information from it + data := structs.New(role).Map() + delete(data, "role_id") + delete(data, "hmac_key") + + return &logical.Response{ + Data: data, + }, nil + } +} + +// pathRoleDelete removes the role from the storage +func (b *backend) pathRoleDelete(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + + // Acquire the lock before deleting the secrets. + b.roleLock.Lock() + defer b.roleLock.Unlock() + + // Just before the role is deleted, remove all the SecretIDs issued as part of the role. + if err = b.flushRoleSecrets(req.Storage, roleName, role.HMACKey); err != nil { + return nil, fmt.Errorf("failed to invalidate the secrets belonging to role '%s': %s", roleName, err) + } + + // Delete the reverse mapping from RoleID to the role + if err = b.roleIDEntryDelete(req.Storage, role.RoleID); err != nil { + return nil, fmt.Errorf("failed to delete the mapping from RoleID to role '%s': %s", roleName, err) + } + + // After deleting the SecretIDs and the RoleID, delete the role itself + if err = req.Storage.Delete("role/" + strings.ToLower(roleName)); err != nil { + return nil, err + } + + return nil, nil +} + +// Returns the properties of the SecretID +func (b *backend) pathRoleSecretIDAccessorRead(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + secretIDAccessor := data.Get("secret_id_accessor").(string) + if secretIDAccessor == "" { + return logical.ErrorResponse("missing secret_id_accessor"), nil + } + + // SecretID is indexed based on HMACed roleName and HMACed SecretID. + // Get the role details to fetch the RoleID and accessor to get + // the HMACed SecretID. + + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + if role == nil { + return nil, fmt.Errorf("role %s does not exist", roleName) + } + + accessorEntry, err := b.secretIDAccessorEntry(req.Storage, secretIDAccessor) + if err != nil { + return nil, err + } + if accessorEntry == nil { + return nil, fmt.Errorf("failed to find accessor entry for secret_id_accessor:%s\n", secretIDAccessor) + } + + roleNameHMAC, err := createHMAC(role.HMACKey, roleName) + if err != nil { + return nil, fmt.Errorf("failed to create HMAC of role_name: %s", err) + } + + entryIndex := fmt.Sprintf("secret_id/%s/%s", roleNameHMAC, accessorEntry.SecretIDHMAC) + + lock := b.secretIDLock(accessorEntry.SecretIDHMAC) + lock.RLock() + defer lock.RUnlock() + + result := secretIDStorageEntry{} + if entry, err := req.Storage.Get(entryIndex); err != nil { + return nil, err + } else if entry == nil { + return nil, nil + } else if err := entry.DecodeJSON(&result); err != nil { + return nil, err + } + + result.SecretIDTTL /= time.Second + d := structs.New(result).Map() + + // Converting the time values to RFC3339Nano format. + // + // Map() from 'structs' package formats time in RFC3339Nano. + // In order to not break the API due to a modification in the + // third party package, converting the time values again. + d["creation_time"] = (d["creation_time"].(time.Time)).Format(time.RFC3339Nano) + d["expiration_time"] = (d["expiration_time"].(time.Time)).Format(time.RFC3339Nano) + d["last_updated_time"] = (d["last_updated_time"].(time.Time)).Format(time.RFC3339Nano) + + return &logical.Response{ + Data: d, + }, nil +} + +func (b *backend) pathRoleSecretIDAccessorDelete(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + secretIDAccessor := data.Get("secret_id_accessor").(string) + if secretIDAccessor == "" { + return logical.ErrorResponse("missing secret_id_accessor"), nil + } + + // SecretID is indexed based on HMACed roleName and HMACed SecretID. + // Get the role details to fetch the RoleID and accessor to get + // the HMACed SecretID. + + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + if role == nil { + return nil, fmt.Errorf("role %s does not exist", roleName) + } + + accessorEntry, err := b.secretIDAccessorEntry(req.Storage, secretIDAccessor) + if err != nil { + return nil, err + } + if accessorEntry == nil { + return nil, fmt.Errorf("failed to find accessor entry for secret_id_accessor:%s\n", secretIDAccessor) + } + + roleNameHMAC, err := createHMAC(role.HMACKey, roleName) + if err != nil { + return nil, fmt.Errorf("failed to create HMAC of role_name: %s", err) + } + + entryIndex := fmt.Sprintf("secret_id/%s/%s", roleNameHMAC, accessorEntry.SecretIDHMAC) + accessorEntryIndex := "accessor/" + b.salt.SaltID(secretIDAccessor) + + lock := b.secretIDLock(accessorEntry.SecretIDHMAC) + lock.Lock() + defer lock.Unlock() + + // Delete the accessor of the SecretID first + if err := req.Storage.Delete(accessorEntryIndex); err != nil { + return nil, fmt.Errorf("failed to delete accessor storage entry: %s", err) + } + + // Delete the storage entry that corresponds to the SecretID + if err := req.Storage.Delete(entryIndex); err != nil { + return nil, fmt.Errorf("failed to delete SecretID: %s", err) + } + + return nil, nil +} + +func (b *backend) pathRoleBoundCIDRListUpdate(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + if role == nil { + return nil, nil + } + + role.BoundCIDRList = strings.TrimSpace(data.Get("bound_cidr_list").(string)) + if role.BoundCIDRList == "" { + return logical.ErrorResponse("missing bound_cidr_list"), nil + } + + if err = validateCIDRList(role.BoundCIDRList); err != nil { + return logical.ErrorResponse(fmt.Sprintf("failed to validate CIDR blocks: %s", err)), nil + } + + return nil, b.setRoleEntry(req.Storage, roleName, role, "") +} + +func (b *backend) pathRoleBoundCIDRListRead(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + if role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)); err != nil { + return nil, err + } else if role == nil { + return nil, nil + } else { + return &logical.Response{ + Data: map[string]interface{}{ + "bound_cidr_list": role.BoundCIDRList, + }, + }, nil + } +} + +func (b *backend) pathRoleBoundCIDRListDelete(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + if role == nil { + return nil, nil + } + + // Deleting a field implies setting the value to it's default value. + role.BoundCIDRList = data.GetDefaultOrZero("bound_cidr_list").(string) + + return nil, b.setRoleEntry(req.Storage, roleName, role, "") +} + +func (b *backend) pathRoleBindSecretIDUpdate(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + if role == nil { + return nil, nil + } + + if bindSecretIDRaw, ok := data.GetOk("bind_secret_id"); ok { + role.BindSecretID = bindSecretIDRaw.(bool) + return nil, b.setRoleEntry(req.Storage, roleName, role, "") + } else { + return logical.ErrorResponse("missing bind_secret_id"), nil + } +} + +func (b *backend) pathRoleBindSecretIDRead(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + if role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)); err != nil { + return nil, err + } else if role == nil { + return nil, nil + } else { + return &logical.Response{ + Data: map[string]interface{}{ + "bind_secret_id": role.BindSecretID, + }, + }, nil + } +} + +func (b *backend) pathRoleBindSecretIDDelete(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + if role == nil { + return nil, nil + } + + // Deleting a field implies setting the value to it's default value. + role.BindSecretID = data.GetDefaultOrZero("bind_secret_id").(bool) + + return nil, b.setRoleEntry(req.Storage, roleName, role, "") +} + +func (b *backend) pathRolePoliciesUpdate(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + if role == nil { + return nil, nil + } + + policies := strings.TrimSpace(data.Get("policies").(string)) + if policies == "" { + return logical.ErrorResponse("missing policies"), nil + } + + role.Policies = policyutil.ParsePolicies(policies) + + return nil, b.setRoleEntry(req.Storage, roleName, role, "") +} + +func (b *backend) pathRolePoliciesRead(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + if role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)); err != nil { + return nil, err + } else if role == nil { + return nil, nil + } else { + return &logical.Response{ + Data: map[string]interface{}{ + "policies": role.Policies, + }, + }, nil + } +} + +func (b *backend) pathRolePoliciesDelete(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + if role == nil { + return nil, nil + } + + role.Policies = policyutil.ParsePolicies(data.GetDefaultOrZero("policies").(string)) + + return nil, b.setRoleEntry(req.Storage, roleName, role, "") +} + +func (b *backend) pathRoleSecretIDNumUsesUpdate(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + if role == nil { + return nil, nil + } + + if numUsesRaw, ok := data.GetOk("secret_id_num_uses"); ok { + role.SecretIDNumUses = numUsesRaw.(int) + if role.SecretIDNumUses < 0 { + return logical.ErrorResponse("secret_id_num_uses cannot be negative"), nil + } + return nil, b.setRoleEntry(req.Storage, roleName, role, "") + } else { + return logical.ErrorResponse("missing secret_id_num_uses"), nil + } +} + +func (b *backend) pathRoleRoleIDUpdate(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + if role == nil { + return nil, nil + } + + previousRoleID := role.RoleID + role.RoleID = data.Get("role_id").(string) + if role.RoleID == "" { + return logical.ErrorResponse("missing role_id"), nil + } + + return nil, b.setRoleEntry(req.Storage, roleName, role, previousRoleID) +} + +func (b *backend) pathRoleRoleIDRead(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + if role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)); err != nil { + return nil, err + } else if role == nil { + return nil, nil + } else { + return &logical.Response{ + Data: map[string]interface{}{ + "role_id": role.RoleID, + }, + }, nil + } +} + +func (b *backend) pathRoleSecretIDNumUsesRead(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + if role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)); err != nil { + return nil, err + } else if role == nil { + return nil, nil + } else { + return &logical.Response{ + Data: map[string]interface{}{ + "secret_id_num_uses": role.SecretIDNumUses, + }, + }, nil + } +} + +func (b *backend) pathRoleSecretIDNumUsesDelete(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + if role == nil { + return nil, nil + } + + role.SecretIDNumUses = data.GetDefaultOrZero("secret_id_num_uses").(int) + + return nil, b.setRoleEntry(req.Storage, roleName, role, "") +} + +func (b *backend) pathRoleSecretIDTTLUpdate(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + if role == nil { + return nil, nil + } + + if secretIDTTLRaw, ok := data.GetOk("secret_id_ttl"); ok { + role.SecretIDTTL = time.Second * time.Duration(secretIDTTLRaw.(int)) + return nil, b.setRoleEntry(req.Storage, roleName, role, "") + } else { + return logical.ErrorResponse("missing secret_id_ttl"), nil + } +} + +func (b *backend) pathRoleSecretIDTTLRead(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + if role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)); err != nil { + return nil, err + } else if role == nil { + return nil, nil + } else { + role.SecretIDTTL /= time.Second + return &logical.Response{ + Data: map[string]interface{}{ + "secret_id_ttl": role.SecretIDTTL, + }, + }, nil + } +} + +func (b *backend) pathRoleSecretIDTTLDelete(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + if role == nil { + return nil, nil + } + + role.SecretIDTTL = time.Second * time.Duration(data.GetDefaultOrZero("secret_id_ttl").(int)) + + return nil, b.setRoleEntry(req.Storage, roleName, role, "") +} + +func (b *backend) pathRolePeriodUpdate(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + if role == nil { + return nil, nil + } + + if periodRaw, ok := data.GetOk("period"); ok { + role.Period = time.Second * time.Duration(periodRaw.(int)) + if role.Period > b.System().MaxLeaseTTL() { + return logical.ErrorResponse(fmt.Sprintf("'period' of '%s' is greater than the backend's maximum lease TTL of '%s'", role.Period.String(), b.System().MaxLeaseTTL().String())), nil + } + return nil, b.setRoleEntry(req.Storage, roleName, role, "") + } else { + return logical.ErrorResponse("missing period"), nil + } +} + +func (b *backend) pathRolePeriodRead(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + if role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)); err != nil { + return nil, err + } else if role == nil { + return nil, nil + } else { + role.Period /= time.Second + return &logical.Response{ + Data: map[string]interface{}{ + "period": role.Period, + }, + }, nil + } +} + +func (b *backend) pathRolePeriodDelete(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + if role == nil { + return nil, nil + } + + role.Period = time.Second * time.Duration(data.GetDefaultOrZero("period").(int)) + + return nil, b.setRoleEntry(req.Storage, roleName, role, "") +} + +func (b *backend) pathRoleTokenTTLUpdate(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + if role == nil { + return nil, nil + } + + if tokenTTLRaw, ok := data.GetOk("token_ttl"); ok { + role.TokenTTL = time.Second * time.Duration(tokenTTLRaw.(int)) + if role.TokenMaxTTL > time.Duration(0) && role.TokenTTL > role.TokenMaxTTL { + return logical.ErrorResponse("token_ttl should not be greater than token_max_ttl"), nil + } + return nil, b.setRoleEntry(req.Storage, roleName, role, "") + } else { + return logical.ErrorResponse("missing token_ttl"), nil + } +} + +func (b *backend) pathRoleTokenTTLRead(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + if role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)); err != nil { + return nil, err + } else if role == nil { + return nil, nil + } else { + role.TokenTTL /= time.Second + return &logical.Response{ + Data: map[string]interface{}{ + "token_ttl": role.TokenTTL, + }, + }, nil + } +} + +func (b *backend) pathRoleTokenTTLDelete(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + if role == nil { + return nil, nil + } + + role.TokenTTL = time.Second * time.Duration(data.GetDefaultOrZero("token_ttl").(int)) + + return nil, b.setRoleEntry(req.Storage, roleName, role, "") +} + +func (b *backend) pathRoleTokenMaxTTLUpdate(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + if role == nil { + return nil, nil + } + + if tokenMaxTTLRaw, ok := data.GetOk("token_max_ttl"); ok { + role.TokenMaxTTL = time.Second * time.Duration(tokenMaxTTLRaw.(int)) + if role.TokenMaxTTL > time.Duration(0) && role.TokenTTL > role.TokenMaxTTL { + return logical.ErrorResponse("token_max_ttl should be greater than or equal to token_ttl"), nil + } + return nil, b.setRoleEntry(req.Storage, roleName, role, "") + } else { + return logical.ErrorResponse("missing token_max_ttl"), nil + } +} + +func (b *backend) pathRoleTokenMaxTTLRead(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + if role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)); err != nil { + return nil, err + } else if role == nil { + return nil, nil + } else { + role.TokenMaxTTL /= time.Second + return &logical.Response{ + Data: map[string]interface{}{ + "token_max_ttl": role.TokenMaxTTL, + }, + }, nil + } +} + +func (b *backend) pathRoleTokenMaxTTLDelete(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + if role == nil { + return nil, nil + } + + role.TokenMaxTTL = time.Second * time.Duration(data.GetDefaultOrZero("token_max_ttl").(int)) + + return nil, b.setRoleEntry(req.Storage, roleName, role, "") +} + +func (b *backend) pathRoleSecretIDUpdate(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + secretID, err := uuid.GenerateUUID() + if err != nil { + return nil, fmt.Errorf("failed to generate SecretID:%s", err) + } + return b.handleRoleSecretIDCommon(req, data, secretID) +} + +func (b *backend) pathRoleCustomSecretIDUpdate(req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + return b.handleRoleSecretIDCommon(req, data, data.Get("secret_id").(string)) +} + +func (b *backend) handleRoleSecretIDCommon(req *logical.Request, data *framework.FieldData, secretID string) (*logical.Response, error) { + roleName := data.Get("role_name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role_name"), nil + } + + if secretID == "" { + return logical.ErrorResponse("missing secret_id"), nil + } + + role, err := b.roleEntry(req.Storage, strings.ToLower(roleName)) + if err != nil { + return nil, err + } + if role == nil { + return logical.ErrorResponse(fmt.Sprintf("role %s does not exist", roleName)), nil + } + + if !role.BindSecretID { + return logical.ErrorResponse("bind_secret_id is not set on the role"), nil + } + + secretIDStorage := &secretIDStorageEntry{ + SecretIDNumUses: role.SecretIDNumUses, + SecretIDTTL: role.SecretIDTTL, + Metadata: make(map[string]string), + } + + if _, err = strutil.ParseArbitraryKeyValues(data.Get("metadata").(string), secretIDStorage.Metadata); err != nil { + return logical.ErrorResponse(fmt.Sprintf("failed to parse metadata: %v", err)), nil + } + + if secretIDStorage, err = b.registerSecretIDEntry(req.Storage, roleName, secretID, role.HMACKey, secretIDStorage); err != nil { + return nil, fmt.Errorf("failed to store SecretID: %s", err) + } + + return &logical.Response{ + Data: map[string]interface{}{ + "secret_id": secretID, + "secret_id_accessor": secretIDStorage.SecretIDAccessor, + }, + }, nil +} + +// roleIDLock is used to get a lock from the pre-initialized map +// of locks. Map is indexed based on the first 2 characters of the +// RoleID, which is a random UUID. If the input is not hex encoded +// or if it is empty a "custom" lock will be returned. +func (b *backend) roleIDLock(roleID string) *sync.RWMutex { + var lock *sync.RWMutex + var ok bool + if len(roleID) >= 2 { + lock, ok = b.roleIDLocksMap[roleID[0:2]] + } + if !ok || lock == nil { + // Fall back for custom SecretIDs + lock = b.roleIDLocksMap["custom"] + } + return lock +} + +// setRoleIDEntry creates a storage entry that maps RoleID to Role +func (b *backend) setRoleIDEntry(s logical.Storage, roleID string, roleIDEntry *roleIDStorageEntry) error { + lock := b.roleIDLock(roleID) + lock.Lock() + defer lock.Unlock() + + entryIndex := "role_id/" + b.salt.SaltID(roleID) + + entry, err := logical.StorageEntryJSON(entryIndex, roleIDEntry) + if err != nil { + return err + } + if err = s.Put(entry); err != nil { + return err + } + return nil +} + +// roleIDEntry is used to read the storage entry that maps RoleID to Role +func (b *backend) roleIDEntry(s logical.Storage, roleID string) (*roleIDStorageEntry, error) { + if roleID == "" { + return nil, fmt.Errorf("missing roleID") + } + + lock := b.roleIDLock(roleID) + lock.RLock() + defer lock.RUnlock() + + var result roleIDStorageEntry + + entryIndex := "role_id/" + b.salt.SaltID(roleID) + + if entry, err := s.Get(entryIndex); err != nil { + return nil, err + } else if entry == nil { + return nil, nil + } else if err := entry.DecodeJSON(&result); err != nil { + return nil, err + } + + return &result, nil +} + +// roleIDEntryDelete is used to remove the secondary index that maps the +// RoleID to the Role itself. +func (b *backend) roleIDEntryDelete(s logical.Storage, roleID string) error { + if roleID == "" { + return fmt.Errorf("missing roleID") + } + + lock := b.roleIDLock(roleID) + lock.Lock() + defer lock.Unlock() + + entryIndex := "role_id/" + b.salt.SaltID(roleID) + + return s.Delete(entryIndex) +} + +var roleHelp = map[string][2]string{ + "role-list": { + "Lists all the roles registered with the backend.", + "The list will contain the names of the roles.", + }, + "role": { + "Register an role with the backend.", + `A role can represent a service, a machine or anything that can be IDed. +The set of policies on the role defines access to the role, meaning, any +Vault token with a policy set that is a superset of the policies on the +role registered here will have access to the role. If a SecretID is desired +to be generated against only this specific role, it can be done via +'role//secret-id' and 'role//custom-secret-id' endpoints. +The properties of the SecretID created against the role and the properties +of the token issued with the SecretID generated againt the role, can be +configured using the parameters of this endpoint.`, + }, + "role-bind-secret-id": { + "Impose secret_id to be presented during login using this role.", + `By setting this to 'true', during login the parameter 'secret_id' becomes a mandatory argument. +The value of 'secret_id' can be retrieved using 'role//secret-id' endpoint.`, + }, + "role-bound-cidr-list": { + `Comma separated list of CIDR blocks, if set, specifies blocks of IP +addresses which can perform the login operation`, + `During login, the IP address of the client will be checked to see if it +belongs to the CIDR blocks specified. If CIDR blocks were set and if the +IP is not encompassed by it, login fails`, + }, + "role-policies": { + "Policies of the role.", + `A comma-delimited set of Vault policies that defines access to the role. +All the Vault tokens with policies that encompass the policy set +defined on the role, can access the role.`, + }, + "role-secret-id-num-uses": { + "Use limit of the SecretID generated against the role.", + `If the SecretIDs are generated/assigned against the role using the +'role//secret-id' or 'role//custom-secret-id' endpoints, +then the number of times that SecretID can access the role is defined by +this option.`, + }, + "role-secret-id-ttl": { + `Duration in seconds, representing the lifetime of the SecretIDs +that are generated against the role using 'role//secret-id' or +'role//custom-secret-id' endpoints.`, + ``, + }, + "role-secret-id-accessor": { + "Read or delete a issued secret_id", + `This is particularly useful to clean-up the non-expiring 'secret_id's. +The list operation on the 'role//secret-id' endpoint will return +the 'secret_id_accessor's. This endpoint can be used to read the properties +of the secret. If the 'secret_id_num_uses' field in the response is 0, it +represents a non-expiring 'secret_id'. This endpoint can be invoked to delete +the 'secret_id's as well.`, + }, + "role-token-ttl": { + `Duration in seconds, the lifetime of the token issued by using the SecretID that +is generated against this role, before which the token needs to be renewed.`, + `If SecretIDs are generated against the role, using 'role//secret-id' or the +'role//custom-secret-id' endpoints, and if those SecretIDs are used +to perform the login operation, then the value of 'token-ttl' defines the +lifetime of the token issued, before which the token needs to be renewed.`, + }, + "role-token-max-ttl": { + `Duration in seconds, the maximum lifetime of the tokens issued by using +the SecretIDs that were generated against this role, after which the +tokens are not allowed to be renewed.`, + `If SecretIDs are generated against the role using 'role//secret-id' +or the 'role//custom-secret-id' endpoints, and if those SecretIDs +are used to perform the login operation, then the value of 'token-max-ttl' +defines the maximum lifetime of the tokens issued, after which the tokens +cannot be renewed. A reauthentication is required after this duration. +This value will be croleed by the backend mount's maximum TTL value.`, + }, + "role-id": { + "Returns the 'role_id' of the role.", + `If login is performed from an role, then its 'role_id' should be presented +as a credential during the login. This 'role_id' can be retrieved using +this endpoint.`, + }, + "role-secret-id": { + "Generate a SecretID against this role.", + `The SecretID generated using this endpoint will be scoped to access +just this role and none else. The properties of this SecretID will be +based on the options set on the role. It will expire after a period +defined by the 'secret_id_ttl' option on the role and/or the backend +mount's maximum TTL value.`, + }, + "role-custom-secret-id": { + "Assign a SecretID of choice against the role.", + `This option is not recommended unless there is a specific need +to do so. This will assign a client supplied SecretID to be used to access +the role. This SecretID will behave similarly to the SecretIDs generated by +the backend. The properties of this SecretID will be based on the options +set on the role. It will expire after a period defined by the 'secret_id_ttl' +option on the role and/or the backend mount's maximum TTL value.`, + }, + "role-period": { + "Updates the value of 'period' on the role", + `If set, indicates that the token generated using this role +should never expire. The token should be renewed within the +duration specified by this value. The renewal duration will +be fixed. If the Period in the role is modified, the token +will pick up the new value during its next renewal.`, + }, +} diff --git a/builtin/credential/approle/path_role_test.go b/builtin/credential/approle/path_role_test.go new file mode 100644 index 0000000000000..baed802d31e33 --- /dev/null +++ b/builtin/credential/approle/path_role_test.go @@ -0,0 +1,787 @@ +package approle + +import ( + "reflect" + "testing" + "time" + + "github.com/hashicorp/vault/helper/policyutil" + "github.com/hashicorp/vault/logical" + "github.com/mitchellh/mapstructure" +) + +func TestAppRole_RoleIDUniqueness(t *testing.T) { + var resp *logical.Response + var err error + b, storage := createBackendWithStorage(t) + + roleData := map[string]interface{}{ + "role_id": "role-id-123", + "policies": "a,b", + "secret_id_num_uses": 10, + "secret_id_ttl": 300, + "token_ttl": 400, + "token_max_ttl": 500, + } + roleReq := &logical.Request{ + Operation: logical.CreateOperation, + Path: "role/testrole1", + Storage: storage, + Data: roleData, + } + + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Path = "role/testrole2" + resp, err = b.HandleRequest(roleReq) + if err == nil && !(resp != nil && resp.IsError()) { + t.Fatalf("expected an error: got resp:%#v", resp) + } + + roleData["role_id"] = "role-id-456" + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Operation = logical.UpdateOperation + roleData["role_id"] = "role-id-123" + resp, err = b.HandleRequest(roleReq) + if err == nil && !(resp != nil && resp.IsError()) { + t.Fatalf("expected an error: got resp:%#v", resp) + } + + roleReq.Path = "role/testrole1" + roleData["role_id"] = "role-id-456" + resp, err = b.HandleRequest(roleReq) + if err == nil && !(resp != nil && resp.IsError()) { + t.Fatalf("expected an error: got resp:%#v", resp) + } + + roleIDData := map[string]interface{}{ + "role_id": "role-id-456", + } + roleIDReq := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "role/testrole1/role-id", + Storage: storage, + Data: roleIDData, + } + resp, err = b.HandleRequest(roleIDReq) + if err == nil && !(resp != nil && resp.IsError()) { + t.Fatalf("expected an error: got resp:%#v", resp) + } + + roleIDData["role_id"] = "role-id-123" + roleIDReq.Path = "role/testrole2/role-id" + resp, err = b.HandleRequest(roleIDReq) + if err == nil && !(resp != nil && resp.IsError()) { + t.Fatalf("expected an error: got resp:%#v", resp) + } + + roleIDData["role_id"] = "role-id-2000" + resp, err = b.HandleRequest(roleIDReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleIDData["role_id"] = "role-id-1000" + roleIDReq.Path = "role/testrole1/role-id" + resp, err = b.HandleRequest(roleIDReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } +} + +func TestAppRole_RoleDeleteSecretID(t *testing.T) { + var resp *logical.Response + var err error + b, storage := createBackendWithStorage(t) + + createRole(t, b, storage, "role1", "a,b") + secretIDReq := &logical.Request{ + Operation: logical.UpdateOperation, + Storage: storage, + Path: "role/role1/secret-id", + } + // Create 3 secrets on the role + resp, err = b.HandleRequest(secretIDReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + resp, err = b.HandleRequest(secretIDReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + resp, err = b.HandleRequest(secretIDReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + listReq := &logical.Request{ + Operation: logical.ListOperation, + Storage: storage, + Path: "role/role1/secret-id", + } + resp, err = b.HandleRequest(listReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + secretIDAccessors := resp.Data["keys"].([]string) + if len(secretIDAccessors) != 3 { + t.Fatalf("bad: len of secretIDAccessors: expected:3 actual:%d", len(secretIDAccessors)) + } + + roleReq := &logical.Request{ + Operation: logical.DeleteOperation, + Storage: storage, + Path: "role/role1", + } + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + resp, err = b.HandleRequest(listReq) + if err != nil || resp == nil || (resp != nil && !resp.IsError()) { + t.Fatalf("expected an error. err:%v resp:%#v", err, resp) + } +} + +func TestAppRole_RoleSecretIDReadDelete(t *testing.T) { + var resp *logical.Response + var err error + b, storage := createBackendWithStorage(t) + + createRole(t, b, storage, "role1", "a,b") + secretIDReq := &logical.Request{ + Operation: logical.UpdateOperation, + Storage: storage, + Path: "role/role1/secret-id", + } + resp, err = b.HandleRequest(secretIDReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + listReq := &logical.Request{ + Operation: logical.ListOperation, + Storage: storage, + Path: "role/role1/secret-id", + } + resp, err = b.HandleRequest(listReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + hmacSecretID := resp.Data["keys"].([]string)[0] + + hmacReq := &logical.Request{ + Operation: logical.ReadOperation, + Storage: storage, + Path: "role/role1/secret-id/" + hmacSecretID, + } + resp, err = b.HandleRequest(hmacReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + if resp.Data == nil { + t.Fatal(err) + } + + hmacReq.Operation = logical.DeleteOperation + resp, err = b.HandleRequest(hmacReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + hmacReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(hmacReq) + if resp != nil && resp.IsError() { + t.Fatalf("error response:%#v", err, resp) + } + if err == nil { + t.Fatalf("expected an error") + } +} + +func TestAppRoleRoleListSecretID(t *testing.T) { + var resp *logical.Response + var err error + b, storage := createBackendWithStorage(t) + + createRole(t, b, storage, "role1", "a,b") + + secretIDReq := &logical.Request{ + Operation: logical.UpdateOperation, + Storage: storage, + Path: "role/role1/secret-id", + } + // Create 5 'secret_id's + resp, err = b.HandleRequest(secretIDReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + resp, err = b.HandleRequest(secretIDReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + resp, err = b.HandleRequest(secretIDReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + resp, err = b.HandleRequest(secretIDReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + resp, err = b.HandleRequest(secretIDReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + listReq := &logical.Request{ + Operation: logical.ListOperation, + Storage: storage, + Path: "role/role1/secret-id/", + } + resp, err = b.HandleRequest(listReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + secrets := resp.Data["keys"].([]string) + if len(secrets) != 5 { + t.Fatalf("bad: len of secrets: expected:5 actual:%d", len(secrets)) + } +} + +func TestAppRole_RoleList(t *testing.T) { + var resp *logical.Response + var err error + b, storage := createBackendWithStorage(t) + + createRole(t, b, storage, "role1", "a,b") + createRole(t, b, storage, "role2", "c,d") + createRole(t, b, storage, "role3", "e,f") + createRole(t, b, storage, "role4", "g,h") + createRole(t, b, storage, "role5", "i,j") + + listReq := &logical.Request{ + Operation: logical.ListOperation, + Path: "role", + Storage: storage, + } + resp, err = b.HandleRequest(listReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + actual := resp.Data["keys"].([]string) + expected := []string{"role1", "role2", "role3", "role4", "role5"} + if !policyutil.EquivalentPolicies(actual, expected) { + t.Fatalf("bad: listed roles: expected:%s\nactual:%s", expected, actual) + } +} + +func TestAppRole_RoleSecretID(t *testing.T) { + var resp *logical.Response + var err error + b, storage := createBackendWithStorage(t) + + roleData := map[string]interface{}{ + "policies": "p,q,r,s", + "secret_id_num_uses": 10, + "secret_id_ttl": 300, + "token_ttl": 400, + "token_max_ttl": 500, + } + roleReq := &logical.Request{ + Operation: logical.CreateOperation, + Path: "role/role1", + Storage: storage, + Data: roleData, + } + + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleSecretIDReq := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "role/role1/secret-id", + Storage: storage, + } + resp, err = b.HandleRequest(roleSecretIDReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + if resp.Data["secret_id"].(string) == "" { + t.Fatalf("failed to generate secret_id") + } + + roleSecretIDReq.Path = "role/role1/custom-secret-id" + roleCustomSecretIDData := map[string]interface{}{ + "secret_id": "abcd123", + } + roleSecretIDReq.Data = roleCustomSecretIDData + roleSecretIDReq.Operation = logical.UpdateOperation + resp, err = b.HandleRequest(roleSecretIDReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + if resp.Data["secret_id"] != "abcd123" { + t.Fatalf("failed to set specific secret_id to role") + } +} + +func TestAppRole_RoleCRUD(t *testing.T) { + var resp *logical.Response + var err error + b, storage := createBackendWithStorage(t) + + roleData := map[string]interface{}{ + "policies": "p,q,r,s", + "secret_id_num_uses": 10, + "secret_id_ttl": 300, + "token_ttl": 400, + "token_max_ttl": 500, + "bound_cidr_list": "127.0.0.1/32,127.0.0.1/16", + } + roleReq := &logical.Request{ + Operation: logical.CreateOperation, + Path: "role/role1", + Storage: storage, + Data: roleData, + } + + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + expected := map[string]interface{}{ + "bind_secret_id": true, + "policies": []string{"default", "p", "q", "r", "s"}, + "secret_id_num_uses": 10, + "secret_id_ttl": 300, + "token_ttl": 400, + "token_max_ttl": 500, + "bound_cidr_list": "127.0.0.1/32,127.0.0.1/16", + } + var expectedStruct roleStorageEntry + err = mapstructure.Decode(expected, &expectedStruct) + if err != nil { + t.Fatal(err) + } + + var actualStruct roleStorageEntry + err = mapstructure.Decode(resp.Data, &actualStruct) + if err != nil { + t.Fatal(err) + } + + expectedStruct.RoleID = actualStruct.RoleID + if !reflect.DeepEqual(expectedStruct, actualStruct) { + t.Fatalf("bad:\nexpected:%#v\nactual:%#v\n", expectedStruct, actualStruct) + } + + roleData = map[string]interface{}{ + "role_id": "test_role_id", + "policies": "a,b,c,d", + "secret_id_num_uses": 100, + "secret_id_ttl": 3000, + "token_ttl": 4000, + "token_max_ttl": 5000, + } + roleReq.Data = roleData + roleReq.Operation = logical.UpdateOperation + + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + expected = map[string]interface{}{ + "policies": []string{"a", "b", "c", "d", "default"}, + "secret_id_num_uses": 100, + "secret_id_ttl": 3000, + "token_ttl": 4000, + "token_max_ttl": 5000, + } + err = mapstructure.Decode(expected, &expectedStruct) + if err != nil { + t.Fatal(err) + } + + err = mapstructure.Decode(resp.Data, &actualStruct) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(expectedStruct, actualStruct) { + t.Fatalf("bad:\nexpected:%#v\nactual:%#v\n", expectedStruct, actualStruct) + } + + // RU for role_id field + roleReq.Path = "role/role1/role-id" + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + if resp.Data["role_id"].(string) != "test_role_id" { + t.Fatalf("bad: role_id: expected:test_role_id actual:%s\n", resp.Data["role_id"].(string)) + } + + roleReq.Data = map[string]interface{}{"role_id": "custom_role_id"} + roleReq.Operation = logical.UpdateOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + if resp.Data["role_id"].(string) != "custom_role_id" { + t.Fatalf("bad: role_id: expected:custom_role_id actual:%s\n", resp.Data["role_id"].(string)) + } + + // RUD for bind_secret_id field + roleReq.Path = "role/role1/bind-secret-id" + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Data = map[string]interface{}{"bind_secret_id": false} + roleReq.Operation = logical.UpdateOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + if resp.Data["bind_secret_id"].(bool) { + t.Fatalf("bad: bind_secret_id: expected:false actual:%t\n", resp.Data["bind_secret_id"].(bool)) + } + roleReq.Operation = logical.DeleteOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + if !resp.Data["bind_secret_id"].(bool) { + t.Fatalf("expected the default value of 'true' to be set") + } + + // RUD for policiess field + roleReq.Path = "role/role1/policies" + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Data = map[string]interface{}{"policies": "a1,b1,c1,d1"} + roleReq.Operation = logical.UpdateOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + if !reflect.DeepEqual(resp.Data["policies"].([]string), []string{"a1", "b1", "c1", "d1", "default"}) { + t.Fatalf("bad: policies: actual:%s\n", resp.Data["policies"].([]string)) + } + roleReq.Operation = logical.DeleteOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + expectedPolicies := []string{"default"} + actualPolicies := resp.Data["policies"].([]string) + if !policyutil.EquivalentPolicies(expectedPolicies, actualPolicies) { + t.Fatalf("bad: policies: expected:%s actual:%s", expectedPolicies, actualPolicies) + } + + // RUD for secret-id-num-uses field + roleReq.Path = "role/role1/secret-id-num-uses" + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Data = map[string]interface{}{"secret_id_num_uses": 200} + roleReq.Operation = logical.UpdateOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + if resp.Data["secret_id_num_uses"].(int) != 200 { + t.Fatalf("bad: secret_id_num_uses: expected:200 actual:%d\n", resp.Data["secret_id_num_uses"].(int)) + } + roleReq.Operation = logical.DeleteOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + if resp.Data["secret_id_num_uses"].(int) != 0 { + t.Fatalf("expected value to be reset") + } + + // RUD for secret_id_ttl field + roleReq.Path = "role/role1/secret-id-ttl" + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Data = map[string]interface{}{"secret_id_ttl": 3001} + roleReq.Operation = logical.UpdateOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + if resp.Data["secret_id_ttl"].(time.Duration) != 3001 { + t.Fatalf("bad: secret_id_ttl: expected:3001 actual:%d\n", resp.Data["secret_id_ttl"].(time.Duration)) + } + roleReq.Operation = logical.DeleteOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + if resp.Data["secret_id_ttl"].(time.Duration) != 0 { + t.Fatalf("expected value to be reset") + } + + // RUD for 'period' field + roleReq.Path = "role/role1/period" + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Data = map[string]interface{}{"period": 9001} + roleReq.Operation = logical.UpdateOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + if resp.Data["period"].(time.Duration) != 9001 { + t.Fatalf("bad: period: expected:9001 actual:%d\n", resp.Data["9001"].(time.Duration)) + } + roleReq.Operation = logical.DeleteOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + if resp.Data["period"].(time.Duration) != 0 { + t.Fatalf("expected value to be reset") + } + + // RUD for token_ttl field + roleReq.Path = "role/role1/token-ttl" + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Data = map[string]interface{}{"token_ttl": 4001} + roleReq.Operation = logical.UpdateOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + if resp.Data["token_ttl"].(time.Duration) != 4001 { + t.Fatalf("bad: token_ttl: expected:4001 actual:%d\n", resp.Data["token_ttl"].(time.Duration)) + } + roleReq.Operation = logical.DeleteOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + if resp.Data["token_ttl"].(time.Duration) != 0 { + t.Fatalf("expected value to be reset") + } + + // RUD for token_max_ttl field + roleReq.Path = "role/role1/token-max-ttl" + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Data = map[string]interface{}{"token_max_ttl": 5001} + roleReq.Operation = logical.UpdateOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + if resp.Data["token_max_ttl"].(time.Duration) != 5001 { + t.Fatalf("bad: token_max_ttl: expected:5001 actual:%d\n", resp.Data["token_max_ttl"].(time.Duration)) + } + roleReq.Operation = logical.DeleteOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + if resp.Data["token_max_ttl"].(time.Duration) != 0 { + t.Fatalf("expected value to be reset") + } + + // Delete test for role + roleReq.Path = "role/role1" + roleReq.Operation = logical.DeleteOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + roleReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } + + if resp != nil { + t.Fatalf("expected a nil response") + } +} + +func createRole(t *testing.T, b *backend, s logical.Storage, roleName, policies string) { + roleData := map[string]interface{}{ + "policies": policies, + "secret_id_num_uses": 10, + "secret_id_ttl": 300, + "token_ttl": 400, + "token_max_ttl": 500, + } + roleReq := &logical.Request{ + Operation: logical.CreateOperation, + Path: "role/" + roleName, + Storage: s, + Data: roleData, + } + + resp, err := b.HandleRequest(roleReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%v resp:%#v", err, resp) + } +} diff --git a/builtin/credential/approle/path_tidy_user_id.go b/builtin/credential/approle/path_tidy_user_id.go new file mode 100644 index 0000000000000..bf04ffb26cc80 --- /dev/null +++ b/builtin/credential/approle/path_tidy_user_id.go @@ -0,0 +1,100 @@ +package approle + +import ( + "fmt" + "sync/atomic" + "time" + + "github.com/hashicorp/errwrap" + "github.com/hashicorp/go-multierror" + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func pathTidySecretID(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "tidy/secret-id$", + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.UpdateOperation: b.pathTidySecretIDUpdate, + }, + + HelpSynopsis: pathTidySecretIDSyn, + HelpDescription: pathTidySecretIDDesc, + } +} + +// tidySecretID is used to delete entries in the whitelist that are expired. +func (b *backend) tidySecretID(s logical.Storage) error { + grabbed := atomic.CompareAndSwapUint32(&b.tidySecretIDCASGuard, 0, 1) + if grabbed { + defer atomic.StoreUint32(&b.tidySecretIDCASGuard, 0) + } else { + return fmt.Errorf("SecretID tidy operation already running") + } + + roleNameHMACs, err := s.List("secret_id/") + if err != nil { + return err + } + + var result error + for _, roleNameHMAC := range roleNameHMACs { + // roleNameHMAC will already have a '/' suffix. Don't append another one. + secretIDHMACs, err := s.List(fmt.Sprintf("secret_id/%s", roleNameHMAC)) + if err != nil { + return err + } + for _, secretIDHMAC := range secretIDHMACs { + // In order to avoid lock swroleing in case there is need to delete, + // grab the write lock. + lock := b.secretIDLock(secretIDHMAC) + lock.Lock() + // roleNameHMAC will already have a '/' suffix. Don't append another one. + entryIndex := fmt.Sprintf("secret_id/%s%s", roleNameHMAC, secretIDHMAC) + secretIDEntry, err := s.Get(entryIndex) + if err != nil { + lock.Unlock() + return fmt.Errorf("error fetching SecretID %s: %s", secretIDHMAC, err) + } + + if secretIDEntry == nil { + result = multierror.Append(result, errwrap.Wrapf("[ERR] {{err}}", fmt.Errorf("entry for SecretID %s is nil", secretIDHMAC))) + lock.Unlock() + continue + } + + if secretIDEntry.Value == nil || len(secretIDEntry.Value) == 0 { + lock.Unlock() + return fmt.Errorf("found entry for SecretID %s but actual SecretID is empty", secretIDHMAC) + } + + var result secretIDStorageEntry + if err := secretIDEntry.DecodeJSON(&result); err != nil { + lock.Unlock() + return err + } + + // ExpirationTime not being set indicates non-expiring SecretIDs + if !result.ExpirationTime.IsZero() && time.Now().After(result.ExpirationTime) { + if err := s.Delete(entryIndex); err != nil { + lock.Unlock() + return fmt.Errorf("error deleting SecretID %s from storage: %s", secretIDHMAC, err) + } + } + lock.Unlock() + } + } + return result +} + +// pathTidySecretIDUpdate is used to delete the expired SecretID entries +func (b *backend) pathTidySecretIDUpdate( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + return nil, b.tidySecretID(req.Storage) +} + +const pathTidySecretIDSyn = "Trigger the clean-up of expired SecretID entries." +const pathTidySecretIDDesc = `SecretIDs will have expiratin time attached to them. The periodic function +of the backend will look for expired entries and delete them. This happens once in a minute. Invoking +this endpoint will trigger the clean-up action, without waiting for the backend's periodic function.` diff --git a/builtin/credential/approle/validation.go b/builtin/credential/approle/validation.go new file mode 100644 index 0000000000000..91c0368dbf246 --- /dev/null +++ b/builtin/credential/approle/validation.go @@ -0,0 +1,424 @@ +package approle + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "net" + "strings" + "sync" + "time" + + "github.com/hashicorp/go-uuid" + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +// secretIDStorageEntry represents the information stored in storage +// when a SecretID is created. The structure of the SecretID storage +// entry is the same for all the types of SecretIDs generated. +type secretIDStorageEntry struct { + // Accessor for the SecretID. It is a random UUID serving as + // a secondary index for the SecretID. This uniquely identifies + // the SecretID it belongs to, and hence can be used for listing + // and deleting SecretIDs. Accessors cannot be used as valid + // SecretIDs during login. + SecretIDAccessor string `json:"secret_id_accessor" structs:"secret_id_accessor" mapstructure:"secret_id_accessor"` + + // Number of times this SecretID can be used to perform the login operation + SecretIDNumUses int `json:"secret_id_num_uses" structs:"secret_id_num_uses" mapstructure:"secret_id_num_uses"` + + // Duration after which this SecretID should expire. This is + // croleed by the backend mount's max TTL value. + SecretIDTTL time.Duration `json:"secret_id_ttl" structs:"secret_id_ttl" mapstructure:"secret_id_ttl"` + + // The time when the SecretID was created + CreationTime time.Time `json:"creation_time" structs:"creation_time" mapstructure:"creation_time"` + + // The time when the SecretID becomes eligible for tidy + // operation. Tidying is performed by the PeriodicFunc of the + // backend which is 1 minute apart. + ExpirationTime time.Time `json:"expiration_time" structs:"expiration_time" mapstructure:"expiration_time"` + + // The time representing the last time this storage entry was modified + LastUpdatedTime time.Time `json:"last_updated_time" structs:"last_updated_time" mapstructure:"last_updated_time"` + + // Metadata that belongs to the SecretID. + Metadata map[string]string `json:"metadata" structs:"metadata" mapstructure:"metadata"` +} + +// Represents the payload of the storage entry of the accessor that maps to a unique +// SecretID. Note that SecretIDs should never be stored in plaintext anywhere in the +// backend. SecretIDHMAC will be used as an index to fetch the properties of the +// SecretID and to delete the SecretID. +type secretIDAccessorStorageEntry struct { + // Hash of the SecretID which can be used to find the storage index at which + // properties of SecretID is stored. + SecretIDHMAC string `json:"secret_id_hmac" structs:"secret_id_hmac" mapstructure:"secret_id_hmac"` +} + +// Checks if all the CIDR blocks in the comma separated list are valid by parsing it. +func validateCIDRList(cidrList string) error { + if cidrList == "" { + return nil + } + + cidrBlocks := strings.Split(cidrList, ",") + for _, block := range cidrBlocks { + if _, _, err := net.ParseCIDR(strings.TrimSpace(block)); err != nil { + return err + } + } + return nil +} + +// Checks if the Role represented by the RoleID still exists +func (b *backend) validateRoleID(s logical.Storage, roleID string) (*roleStorageEntry, string, error) { + // Look for the storage entry that maps the roleID to role + roleIDIndex, err := b.roleIDEntry(s, roleID) + if err != nil { + return nil, "", err + } + if roleIDIndex == nil { + return nil, "", fmt.Errorf("failed to find secondary index for role_id:%s\n", roleID) + } + + role, err := b.roleEntry(s, roleIDIndex.Name) + if err != nil { + return nil, "", err + } + if role == nil { + return nil, "", fmt.Errorf("role %s referred by the SecretID does not exist", roleIDIndex.Name) + } + + return role, roleIDIndex.Name, nil +} + +// Validates the supplied RoleID and SecretID +func (b *backend) validateCredentials(req *logical.Request, data *framework.FieldData) (*roleStorageEntry, string, map[string]string, error) { + var metadata map[string]string + // RoleID must be supplied during every login + roleID := strings.TrimSpace(data.Get("role_id").(string)) + if roleID == "" { + return nil, "", metadata, fmt.Errorf("missing role_id") + } + + // Validate the RoleID and get the Role entry + role, roleName, err := b.validateRoleID(req.Storage, roleID) + if err != nil { + return nil, "", metadata, err + } + if role == nil || roleName == "" { + return nil, "", metadata, fmt.Errorf("failed to validate role_id") + } + + // Calculate the TTL boundaries since this reflects the properties of the token issued + if role.TokenTTL, role.TokenMaxTTL, err = b.SanitizeTTL(role.TokenTTL, role.TokenMaxTTL); err != nil { + return nil, "", metadata, err + } + + if role.BindSecretID { + // If 'bind_secret_id' was set on role, look for the field 'secret_id' + // to be specified and validate it. + secretID := strings.TrimSpace(data.Get("secret_id").(string)) + if secretID == "" { + return nil, "", metadata, fmt.Errorf("missing secret_id") + } + + // Check if the SecretID supplied is valid. If use limit was specified + // on the SecretID, it will be decremented in this call. + var valid bool + valid, metadata, err = b.validateBindSecretID(req.Storage, roleName, secretID, role.HMACKey) + if err != nil { + return nil, "", metadata, err + } + if !valid { + return nil, "", metadata, fmt.Errorf("invalid secret_id: %s\n", secretID) + } + } + + if role.BoundCIDRList != "" { + // If 'bound_cidr_list' was set, verify the CIDR restrictions + cidrBlocks := strings.Split(role.BoundCIDRList, ",") + for _, block := range cidrBlocks { + _, cidr, err := net.ParseCIDR(block) + if err != nil { + return nil, "", metadata, fmt.Errorf("invalid cidr: %s", err) + } + + var addr string + if req.Connection != nil { + addr = req.Connection.RemoteAddr + } + if addr == "" || !cidr.Contains(net.ParseIP(addr)) { + return nil, "", metadata, fmt.Errorf("unauthorized source address") + } + } + } + + return role, roleName, metadata, nil +} + +// validateBindSecretID is used to determine if the given SecretID is a valid one. +func (b *backend) validateBindSecretID(s logical.Storage, roleName, secretID, hmacKey string) (bool, map[string]string, error) { + secretIDHMAC, err := createHMAC(hmacKey, secretID) + if err != nil { + return false, nil, fmt.Errorf("failed to create HMAC of secret_id: %s", err) + } + + roleNameHMAC, err := createHMAC(hmacKey, roleName) + if err != nil { + return false, nil, fmt.Errorf("failed to create HMAC of role_name: %s", err) + } + + entryIndex := fmt.Sprintf("secret_id/%s/%s", roleNameHMAC, secretIDHMAC) + + // SecretID locks are always index based on secretIDHMACs. This helps + // acquiring the locks when the SecretIDs are listed. This allows grabbing + // the correct locks even if the SecretIDs are not known in plaintext. + lock := b.secretIDLock(secretIDHMAC) + lock.RLock() + + result := secretIDStorageEntry{} + if entry, err := s.Get(entryIndex); err != nil { + lock.RUnlock() + return false, nil, err + } else if entry == nil { + lock.RUnlock() + return false, nil, nil + } else if err := entry.DecodeJSON(&result); err != nil { + lock.RUnlock() + return false, nil, err + } + + // SecretIDNumUses will be zero only if the usage limit was not set at all, + // in which case, the SecretID will remain to be valid as long as it is not + // expired. + if result.SecretIDNumUses == 0 { + lock.RUnlock() + return true, result.Metadata, nil + } + + // If the SecretIDNumUses is non-zero, it means that its use-count should be updated + // in the storage. Switch the lock from a `read` to a `write` and update + // the storage entry. + lock.RUnlock() + + lock.Lock() + defer lock.Unlock() + + // Lock switching may change the data. Refresh the contents. + result = secretIDStorageEntry{} + if entry, err := s.Get(entryIndex); err != nil { + return false, nil, err + } else if entry == nil { + return false, nil, nil + } else if err := entry.DecodeJSON(&result); err != nil { + return false, nil, err + } + + // If there exists a single use left, delete the SecretID entry from + // the storage but do not fail the validation request. Subsequest + // requests to use the same SecretID will fail. + if result.SecretIDNumUses == 1 { + accessorEntryIndex := "accessor/" + b.salt.SaltID(result.SecretIDAccessor) + if err := s.Delete(accessorEntryIndex); err != nil { + return false, nil, fmt.Errorf("failed to delete accessor storage entry: %s", err) + } + if err := s.Delete(entryIndex); err != nil { + return false, nil, fmt.Errorf("failed to delete SecretID: %s", err) + } + } else { + // If the use count is greater than one, decrement it and update the last updated time. + result.SecretIDNumUses -= 1 + result.LastUpdatedTime = time.Now() + if entry, err := logical.StorageEntryJSON(entryIndex, &result); err != nil { + return false, nil, fmt.Errorf("failed to decrement the use count for SecretID:%s", secretID) + } else if err = s.Put(entry); err != nil { + return false, nil, fmt.Errorf("failed to decrement the use count for SecretID:%s", secretID) + } + } + + return true, result.Metadata, nil +} + +// Creates a SHA256 HMAC of the given 'value' using the given 'key' +// and returns a hex encoded string. +func createHMAC(key, value string) (string, error) { + if key == "" { + return "", fmt.Errorf("invalid HMAC key") + } + hm := hmac.New(sha256.New, []byte(key)) + hm.Write([]byte(value)) + return hex.EncodeToString(hm.Sum(nil)), nil +} + +// secretIDLock is used to get a lock from the pre-initialized map +// of locks. Map is indexed based on the first 2 characters of the +// secretIDHMAC. If the input is not hex encoded or if empty, a +// "custom" lock will be returned. +func (b *backend) secretIDLock(secretIDHMAC string) *sync.RWMutex { + var lock *sync.RWMutex + var ok bool + if len(secretIDHMAC) >= 2 { + lock, ok = b.secretIDLocksMap[secretIDHMAC[0:2]] + } + if !ok || lock == nil { + // Fall back for custom SecretIDs + lock = b.secretIDLocksMap["custom"] + } + return lock +} + +// registerSecretIDEntry creates a new storage entry for the given SecretID. +func (b *backend) registerSecretIDEntry(s logical.Storage, roleName, secretID, hmacKey string, secretEntry *secretIDStorageEntry) (*secretIDStorageEntry, error) { + secretIDHMAC, err := createHMAC(hmacKey, secretID) + if err != nil { + return nil, fmt.Errorf("failed to create HMAC of secret_id: %s", err) + } + roleNameHMAC, err := createHMAC(hmacKey, roleName) + if err != nil { + return nil, fmt.Errorf("failed to create HMAC of role_name: %s", err) + } + + entryIndex := fmt.Sprintf("secret_id/%s/%s", roleNameHMAC, secretIDHMAC) + + lock := b.secretIDLock(secretIDHMAC) + lock.RLock() + + entry, err := s.Get(entryIndex) + if err != nil { + lock.RUnlock() + return nil, err + } + if entry != nil { + lock.RUnlock() + return nil, fmt.Errorf("SecretID is already registered") + } + + // If there isn't an entry for the secretID already, switch the read lock + // with a write lock and create an entry. + lock.RUnlock() + lock.Lock() + defer lock.Unlock() + + // But before saving a new entry, check if the secretID entry was created during the lock switch. + entry, err = s.Get(entryIndex) + if err != nil { + return nil, err + } + if entry != nil { + return nil, fmt.Errorf("SecretID is already registered") + } + + // Create a new entry for the SecretID + + // Set the creation time for the SecretID + currentTime := time.Now() + secretEntry.CreationTime = currentTime + secretEntry.LastUpdatedTime = currentTime + + // If SecretIDTTL is not specified or if it crosses the backend mount's limit, + // cap the expiration to backend's max. Otherwise, use it to determine the + // expiration time. + if secretEntry.SecretIDTTL < time.Duration(0) || secretEntry.SecretIDTTL > b.System().MaxLeaseTTL() { + secretEntry.ExpirationTime = currentTime.Add(b.System().MaxLeaseTTL()) + } else if secretEntry.SecretIDTTL != time.Duration(0) { + // Set the ExpirationTime only if SecretIDTTL was set. SecretIDs should not + // expire by default. + secretEntry.ExpirationTime = currentTime.Add(secretEntry.SecretIDTTL) + } + + // Before storing the SecretID, store its accessor. + if err := b.createAccessor(s, secretEntry, secretIDHMAC); err != nil { + return nil, err + } + + if entry, err := logical.StorageEntryJSON(entryIndex, secretEntry); err != nil { + return nil, err + } else if err = s.Put(entry); err != nil { + return nil, err + } + + return secretEntry, nil +} + +// secretIDAccessorEntry is used to read the storage entry that maps an +// accessor to a secret_id. This method should be called when the lock +// for the corresponding SecretID is held. +func (b *backend) secretIDAccessorEntry(s logical.Storage, secretIDAccessor string) (*secretIDAccessorStorageEntry, error) { + if secretIDAccessor == "" { + return nil, fmt.Errorf("missing secretIDAccessor") + } + + var result secretIDAccessorStorageEntry + + // Create index entry, mapping the accessor to the token ID + entryIndex := "accessor/" + b.salt.SaltID(secretIDAccessor) + + if entry, err := s.Get(entryIndex); err != nil { + return nil, err + } else if entry == nil { + return nil, nil + } else if err := entry.DecodeJSON(&result); err != nil { + return nil, err + } + + return &result, nil +} + +// createAccessor creates an identifier for the SecretID. A storage index, +// mapping the accessor to the SecretID is also created. This method should +// be called when the lock for the corresponding SecretID is held. +func (b *backend) createAccessor(s logical.Storage, entry *secretIDStorageEntry, secretIDHMAC string) error { + // Create a random accessor + accessorUUID, err := uuid.GenerateUUID() + if err != nil { + return err + } + entry.SecretIDAccessor = accessorUUID + + // Create index entry, mapping the accessor to the token ID + entryIndex := "accessor/" + b.salt.SaltID(entry.SecretIDAccessor) + if entry, err := logical.StorageEntryJSON(entryIndex, &secretIDAccessorStorageEntry{ + SecretIDHMAC: secretIDHMAC, + }); err != nil { + return err + } else if err = s.Put(entry); err != nil { + return fmt.Errorf("failed to persist accessor index entry: %s", err) + } + + return nil +} + +// flushRoleSecrets deletes all the SecretIDs that belong to the given +// RoleID. +func (b *backend) flushRoleSecrets(s logical.Storage, roleName, hmacKey string) error { + roleNameHMAC, err := createHMAC(hmacKey, roleName) + if err != nil { + return fmt.Errorf("failed to create HMAC of role_name: %s", err) + } + + // Acquire the custom lock to perform listing of SecretIDs + customLock := b.secretIDLock("") + customLock.RLock() + defer customLock.RUnlock() + + secretIDHMACs, err := s.List(fmt.Sprintf("secret_id/%s/", roleNameHMAC)) + if err != nil { + return err + } + for _, secretIDHMAC := range secretIDHMACs { + // Acquire the lock belonging to the SecretID + lock := b.secretIDLock(secretIDHMAC) + lock.Lock() + entryIndex := fmt.Sprintf("secret_id/%s/%s", roleNameHMAC, secretIDHMAC) + if err := s.Delete(entryIndex); err != nil { + lock.Unlock() + return fmt.Errorf("error deleting SecretID %s from storage: %s", secretIDHMAC, err) + } + lock.Unlock() + } + return nil +} diff --git a/cli/commands.go b/cli/commands.go index aafd9f146ec6a..afd861122195b 100644 --- a/cli/commands.go +++ b/cli/commands.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/vault/version" credAppId "github.com/hashicorp/vault/builtin/credential/app-id" + credAppRole "github.com/hashicorp/vault/builtin/credential/approle" credAwsEc2 "github.com/hashicorp/vault/builtin/credential/aws-ec2" credCert "github.com/hashicorp/vault/builtin/credential/cert" credGitHub "github.com/hashicorp/vault/builtin/credential/github" @@ -65,6 +66,7 @@ func Commands(metaPtr *meta.Meta) map[string]cli.CommandFactory { "syslog": auditSyslog.Factory, }, CredentialBackends: map[string]logical.Factory{ + "approle": credAppRole.Factory, "cert": credCert.Factory, "aws-ec2": credAwsEc2.Factory, "app-id": credAppId.Factory, diff --git a/helper/strutil/strutil.go b/helper/strutil/strutil.go index 44d7dc4c65b14..bd09e21d8681e 100644 --- a/helper/strutil/strutil.go +++ b/helper/strutil/strutil.go @@ -1,6 +1,9 @@ package strutil import ( + "encoding/base64" + "encoding/json" + "fmt" "sort" "strings" ) @@ -39,6 +42,73 @@ func ParseStrings(input string) []string { return RemoveDuplicates(strings.Split(input, ",")) } +// Parses a comma separated list of `=` tuples into a +// map[string]string. +func ParseKeyValues(input string, out map[string]string) error { + keyValues := ParseStrings(input) + if len(keyValues) == 0 { + return nil + } + + for _, keyValue := range keyValues { + shards := strings.Split(keyValue, "=") + key := strings.TrimSpace(shards[0]) + value := strings.TrimSpace(shards[1]) + if key == "" || value == "" { + return fmt.Errorf("invalid pair: key:'%s' value:'%s'", key, value) + } + out[key] = value + } + return nil +} + +// Parses arbitrary tuples. The input can be one of +// the following: +// * JSON string +// * Base64 encoded JSON string +// * Comma separated list of `=` pairs +// * Base64 encoded string containing comma separated list of +// `=` pairs +// +// Input will be parsed into the output paramater, which should +// be a non-nil map[string]string. +func ParseArbitraryKeyValues(input string, out map[string]string) (string, error) { + input = strings.TrimSpace(input) + if input == "" { + return "", nil + } + if out == nil { + return "", fmt.Errorf("'out' is nil") + } + + // Try to base64 decode the input. If successful, consider the decoded + // value as input. + inputBytes, err := base64.StdEncoding.DecodeString(input) + if err == nil { + input = string(inputBytes) + } + + // Try to JSON unmarshal the input. If successful, consider that the + // metadata was supplied as JSON input. + err = json.Unmarshal([]byte(input), &out) + if err != nil { + // If JSON unmarshalling fails, consider that the input was + // supplied as a comma separated string of 'key=value' pairs. + if err = ParseKeyValues(input, out); err != nil { + return "", fmt.Errorf("failed to parse the input: %v", err) + } + } + + // Validate the parsed input + for key, value := range out { + if key != "" && value == "" { + return "", fmt.Errorf("invalid value for key '%s'", key) + } + } + + return input, nil +} + // Removes duplicate and empty elements from a slice of strings. // This also converts the items in the slice to lower case and // returns a sorted slice. diff --git a/helper/strutil/strutil_test.go b/helper/strutil/strutil_test.go index 95e45076a4694..5e8f07f43a7e2 100644 --- a/helper/strutil/strutil_test.go +++ b/helper/strutil/strutil_test.go @@ -1,6 +1,10 @@ package strutil -import "testing" +import ( + "encoding/base64" + "reflect" + "testing" +) func TestStrutil_EquivalentSlices(t *testing.T) { slice1 := []string{"test2", "test1", "test3"} @@ -15,7 +19,7 @@ func TestStrutil_EquivalentSlices(t *testing.T) { } } -func TestStrListContains(t *testing.T) { +func TestStrutil_ListContains(t *testing.T) { haystack := []string{ "dev", "ops", @@ -30,7 +34,7 @@ func TestStrListContains(t *testing.T) { } } -func TestStrListSubset(t *testing.T) { +func TestStrutil_ListSubset(t *testing.T) { parent := []string{ "dev", "ops", @@ -60,3 +64,117 @@ func TestStrListSubset(t *testing.T) { t.Fatalf("Bad") } } + +func TestStrutil_ParseKeyValues(t *testing.T) { + actual := make(map[string]string) + expected := map[string]string{ + "key1": "value1", + "key2": "value2", + } + var input string + var err error + + input = "key1=value1,key2=value2" + err = ParseKeyValues(input, actual) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(expected, actual) { + t.Fatalf("bad: expected: %#v\nactual: %#v", expected, actual) + } + for k, _ := range actual { + delete(actual, k) + } + + input = "key1 = value1, key2 = value2" + err = ParseKeyValues(input, actual) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(expected, actual) { + t.Fatalf("bad: expected: %#v\nactual: %#v", expected, actual) + } + for k, _ := range actual { + delete(actual, k) + } + + input = "key1 = value1, key2 = " + err = ParseKeyValues(input, actual) + if err == nil { + t.Fatal("expected an error") + } + for k, _ := range actual { + delete(actual, k) + } + + input = "key1 = value1, = value2 " + err = ParseKeyValues(input, actual) + if err == nil { + t.Fatal("expected an error") + } + for k, _ := range actual { + delete(actual, k) + } +} + +func TestStrutil_ParseArbitraryKeyValues(t *testing.T) { + actual := make(map[string]string) + expected := map[string]string{ + "key1": "value1", + "key2": "value2", + } + var input string + var err error + + // Test = as comma separated string + input = "key1=value1,key2=value2" + _, err = ParseArbitraryKeyValues(input, actual) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(expected, actual) { + t.Fatalf("bad: expected: %#v\nactual: %#v", expected, actual) + } + for k, _ := range actual { + delete(actual, k) + } + + // Test = as base64 encoded comma separated string + input = base64.StdEncoding.EncodeToString([]byte(input)) + _, err = ParseArbitraryKeyValues(input, actual) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(expected, actual) { + t.Fatalf("bad: expected: %#v\nactual: %#v", expected, actual) + } + for k, _ := range actual { + delete(actual, k) + } + + // Test JSON encoded = tuples + input = `{"key1":"value1", "key2":"value2"}` + _, err = ParseArbitraryKeyValues(input, actual) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(expected, actual) { + t.Fatalf("bad: expected: %#v\nactual: %#v", expected, actual) + } + for k, _ := range actual { + delete(actual, k) + } + + // Test base64 encoded JSON string of = tuples + input = base64.StdEncoding.EncodeToString([]byte(input)) + _, err = ParseArbitraryKeyValues(input, actual) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(expected, actual) { + t.Fatalf("bad: expected: %#v\nactual: %#v", expected, actual) + } + for k, _ := range actual { + delete(actual, k) + } +} diff --git a/logical/auth.go b/logical/auth.go index 1636fb5bdfe09..ff09d354e6a54 100644 --- a/logical/auth.go +++ b/logical/auth.go @@ -1,6 +1,9 @@ package logical -import "fmt" +import ( + "fmt" + "time" +) // Auth is the resulting authentication information that is part of // Response for credential backends. @@ -10,7 +13,7 @@ type Auth struct { // InternalData is JSON-encodable data that is stored with the auth struct. // This will be sent back during a Renew/Revoke for storing internal data // used for those operations. - InternalData map[string]interface{} + InternalData map[string]interface{} `json:"internal_data" mapstructure:"internal_data" structs:"internal_data"` // DisplayName is a non-security sensitive identifier that is // applicable to this Auth. It is used for logging and prefixing @@ -18,28 +21,33 @@ type Auth struct { // the github credential backend. If the client token is used to // generate a SQL credential, the user may be "github-armon-uuid". // This is to help identify the source without using audit tables. - DisplayName string + DisplayName string `json:"display_name" mapstructure:"display_name" structs:"display_name"` // Policies is the list of policies that the authenticated user // is associated with. - Policies []string + Policies []string `json:"policies" mapstructure:"policies" structs:"policies"` // Metadata is used to attach arbitrary string-type metadata to // an authenticated user. This metadata will be outputted into the // audit log. - Metadata map[string]string + Metadata map[string]string `json:"metadata" mapstructure:"metadata" structs:"metadata"` // ClientToken is the token that is generated for the authentication. // This will be filled in by Vault core when an auth structure is // returned. Setting this manually will have no effect. - ClientToken string + ClientToken string `json:"client_token" mapstructure:"client_token" structs:"client_token"` // Accessor is the identifier for the ClientToken. This can be used // to perform management functionalities (especially revocation) when // ClientToken in the audit logs are obfuscated. Accessor can be used // to revoke a ClientToken and to lookup the capabilities of the ClientToken, // both without actually knowing the ClientToken. - Accessor string + Accessor string `json:"accessor" mapstructure:"accessor" structs:"accessor"` + + // Period indicates that the token generated using this Auth object + // should never expire. The token should be renewed within the duration + // specified by this period. + Period time.Duration `json:"period" mapstructure:"period" structs:"period"` } func (a *Auth) GoString() string { diff --git a/vault/auth.go b/vault/auth.go index ab8151a4bb7a3..995e67d0fa094 100644 --- a/vault/auth.go +++ b/vault/auth.go @@ -231,6 +231,7 @@ func (c *Core) loadCredentials() error { entry.Type = "aws-ec2" needPersist = true } + if entry.Table == "" { entry.Table = c.auth.Type needPersist = true diff --git a/website/source/docs/auth/approle.html.md b/website/source/docs/auth/approle.html.md new file mode 100644 index 0000000000000..088799ff327a5 --- /dev/null +++ b/website/source/docs/auth/approle.html.md @@ -0,0 +1,767 @@ +--- +layout: "docs" +page_title: "Auth Backend: AppRole" +sidebar_current: "docs-auth-approle" +description: |- + The AppRole backend allows machines and services to authenticate with Vault. +--- + +# Auth Backend: AppRole + +This backend allows machines and services (logically referred as `app`s) to +authenticate with Vault, by registering them as AppRoles. The open design of +AppRoles, enables a varied set of Apps to authenticate themselves. Since an +AppRole can represent a service, or a machine or anything that can be IDed, +this backend is a potential successor for the App-ID backend. + +### AppRole + +An AppRole represents a set of Vault policies, under a name. In essense, if a +machine needs to authenticate with Vault for a set of policies, an AppRole can +be registered under the machine's name with the desired set of policies. If a +service requires a set of Vault policies, an AppRole can be registered under +the service's name with the desired policies. The credentials presented at the +login endpoint depends on the constraints set on AppRoles. + +### RoleID + +RoleID is a credential to be used at the login endpoint. The credentials used +to fetch a Vault token depends on the configured contraints on the AppRole. +The credential `role_id` is a required argument for the login endpoint at all +times. RoleIDs by default are unique UUIDs that map to the human read-able +AppRole names. This credential lets the backend know which AppRole to refer to, +in verifying the set constraints. RoleID for an AppRole can be fetched via +`role//role-id` endpoint. + +### SecretID + +SecretID is a credential to be used at the login endpoint. By default, this +backend enables a login constraint on the AppRole, called `bind_secret_id`. +When this constraint is enabled, the login endpoint expects another credential, +`secret_id` to be presented, along with `role_id`. The backend supports both +creation of SecretID by the backend and setting custom SecretID by the client. +It is recommended that SecretIDs be generated by the backend. The ones +generated by the backend will be cryptographically strong random UUIDs. +SecretIDs have properties like usage-limit, TTLs and expirations; similar to +tokens. SecretID for an AppRole can be fetched via `role//secret-id` +endpoint. + +### Pull And Push SecretID Modes + +If the SecretID generated by the backend is fetched and used for login, it is +referred as `Pull` mode. If a "custom" SecretID is set against an AppRole by +the client, it is referred as a `Push` mode. + +While the `user_id` of the App-ID backend worked in a `Push` mode, this backend +recommends the `Pull` mode. The `Pull` mode is supported in AppRole backend, +*only* to be able to make this backend to do all that App-ID did. + +### AppRole Constraints + +`role_id` is a required credential at the login endpoint. AppRole pointed to by +the `role_id` will have constraints set on it. This dictates other `required` +credentials for login. The `bind_secret_id` constraint requires `secret_id` to +be presented at the login endpoint. Going forward, this backend can support +more constraint parameters to support varied set of Apps. Some constraints will +not require a credential, but still enforce constraints for login. For +example, `bound_cidr_list` will only allow requests coming from IP addresses +belonging to configured CIDR blocks on the AppRole. + +## Authentication + +### Via the CLI + +#### Enable AppRole authentication + +```javascript +$ vault auth-enable approle +``` + +#### Create a role + +```javascript +$ vault write auth/approle/role/testrole secret_id_ttl=10m token_ttl=20m token_max_ttl=30m secret_id_num_uses=40 +``` + +#### Fetch the RoleID of the AppRole + +```javascript +$ vault read auth/approle/role/testrole/role-id +``` + +```javascript +role_id db02de05-fa39-4855-059b-67221c5c2f63 +``` + +#### Get a SecretID issued against the AppRole + +```javascript +$ vault write auth/approle/role/testrole/secret-id metadata=@secret-metadata +``` + +```javascript +secret_id 6a174c20-f6de-a53c-74d2-6018fcceff64 +secret_id_accessor c454f7e5-996e-7230-6074-6ef26b7bcf86 +``` + +```javascript +$ cat secret-metadata +{ + "secret_prefix": "test_secrets", + "secret_version": "v1" +} +``` + +*Note*: Metadata can be of the following formats. + * JSON string + * Base64 encoded JSON string + * String containing comma separated = pairs + * Base64 encoded string containing comma separated = pairs + + +#### Login to get a Vault Token + +```javascript +$ vault write auth/approle/login role_id=db02de05-fa39-4855-059b-67221c5c2f63 secret_id=6a174c20-f6de-a53c-74d2-6018fcceff64 +``` + +```javascript +token 65b74ffd-842c-fd43-1386-f7d7006e520a +token_accessor 3c29bc22-5c72-11a6-f778-2bc8f48cea0e +token_duration 1200 +token_renewable true +token_policies [default] +``` + +### Via the API + +#### Enable AppRole authentication + +```javascript +$ curl -XPOST -H "X-Vault-Token:xxx" "http://127.0.0.1:8200/v1/sys/auth/approle" -d '{"type":"approle"}' +``` + +#### Create a role + +```javascript +$ curl -XPOST -H "X-Vault-Token:xxx" "http://127.0.0.1:8200/v1/auth/approle/role/testrole" -d '{"secret_id_ttl":"10m", "token_ttl":"20m", "token_max_ttl":"30m", "secret_id_num_uses":40}' +``` + +#### Fetch the RoleID of the AppRole + +```javascript +$ curl -XGET -H "X-Vault-Token:xxx" "http://127.0.0.1:8200/v1/auth/approle/role/testrole/role-id" +``` + +#### Get a SecretID issued against the AppRole + +```javascript +$ curl -XPOST -H "X-Vault-Token:xxx" "http://127.0.0.1:8200/v1/auth/approle/role/testrole/secret-id" -d '{"metadata":"{\"secret_prefix\": \"test_secrets\",\"secret_version\": \"v1\"}"}' +``` + +#### Login to get a Vault Token + +```javascript +$ curl -XPOST -H "X-Vault-Token:xxx" "http://127.0.0.1:8200/v1/auth/approle/login" -d '{"role_id":"50bec295-3535-0ddc-b729-e4d0773717b3","secret_id":"0c36edb2-8b34-c077-9e3a-9bdcbb4ab0df"}' +``` + +## API +### /auth/approle/role +#### List +
+
Description
+
+ Lists the existing AppRoles in the backend +
+ +
Method
+
`/auth/approle/role` (LIST) or `/auth/approle/role?list=true` (GET)
+ +
URL
+
`LIST/GET`
+ +
Parameters
+
+ None +
+ +
Returns
+
+ +```javascript +{ + "auth": null, + "warnings": null, + "wrap_info": null, + "data": { + "keys": [ + "dev", + "prod", + "test" + ] + }, + "lease_duration": 0, + "renewable": false, + "lease_id": "" +} +``` + +
+
+ + +### /auth/approle/role/[role_name] +#### POST +
+
Description
+
+ Create a new AppRole or update an existing AppRole. This endpoint + supports both `create` and `update` capabilities. +
+ +
Method
+
`POST`
+ +
URL
+
`/auth/approle/role/[role_name]`
+ +
Parameters
+
+
    +
  • + role_name + required + Name of the Role. +
  • +
+
    +
  • + bind_secret_id + optional + Impose secret_id to be presented when logging in using this Role. + Defaults to 'true'. +
  • +
+
    +
  • + bound_cidr_list + optional + Comma separated list of CIDR blocks, if set, specifies blocks of IP + addresses which can perform the login operation +
  • +
+
    +
  • + policies + optional + Comma separated list of policies on the Role. +
  • +
+
    +
  • + secret_id_num_uses + optional + Number of times a SecretID can access the Role, after which the SecretID will expire. +
  • +
+
    +
  • + secret_id_ttl + optional + Duration in seconds after which the issued SecretID should expire. +
  • +
+
    +
  • + token_ttl + optional + Duration in seconds after which the issued token should expire. +
  • +
+
    +
  • + token_max_ttl + optional + Duration in seconds after which the issued token should not be allowed to be renewed. +
  • +
+
    +
  • + period + optional + If set, indicates that the token generated using this Role + should never expire. The token should be renewed within the + duration specified by this value. The renewal duration will + be fixed, if this value is not modified. If the Period in the + Role is modified, the token will pick up the new value during + its next renewal. +
  • +
+
+ +
Returns
+
`204` response code. +
+
+ +#### GET +
+
Description
+
+ Reads the properties of an existing AppRole. +
+ +
Method
+
`GET`
+ +
URL
+
`/auth/approle/role/[role_name]`
+ +
Parameters
+
+ None. +
+ +
Returns
+
+ +```javascript +{ + "auth": null, + "warnings": null, + "wrap_info": null, + "data": { + "token_ttl": 1200, + "token_max_ttl": 1800, + "secret_id_ttl": 600, + "secret_id_num_uses": 40, + "policies": [ + "default" + ], + "period": 0, + "bind_secret_id": true, + "bound_cidr_list": "" + }, + "lease_duration": 0, + "renewable": false, + "lease_id": "" +} +``` + +
+
+ +#### DELETE +
+
Description
+
+ Deletes an existing AppRole from the backend. +
+ +
Method
+
`DELETE`
+ +
URL
+
`/auth/approle/role/[role_name]`
+ +
Parameters
+
+ None. +
+ +
Returns
+
`204` response code. +
+
+ + +### /auth/approle/role/[role_name]/role-id +#### GET +
+
Description
+
+ Reads the RoleID of an existing AppRole. +
+ +
Method
+
`GET`
+ +
URL
+
`/auth/approle/role/[role_name]/role-id`
+ +
Parameters
+
+ None. +
+ +
Returns
+
+ +```javascript +{ + "auth": null, + "warnings": null, + "wrap_info": null, + "data": { + "role_id": "e5a7b66e-5d08-da9c-7075-71984634b882" + }, + "lease_duration": 0, + "renewable": false, + "lease_id": "" +} +``` + +
+
+ +#### POST +
+
Description
+
+ Updates the RoleID of an existing AppRole. +
+ +
Method
+
`POST`
+ +
URL
+
`/auth/approle/role/[role_name]/role-id`
+ +
Parameters
+
+
    +
  • + role_id + required + Value to be set as RoleID. +
  • +
+
+ +
Returns
+
+ `204` response code. +
+
+ + + +### /auth/approle/role/[role_name]/secret-id +#### POST +
+
Description
+
+ Generates and issues a new SecretID on an existing AppRole. The + response will also contain the `secret_id_accessor` which can be + used to read the properties of the SecretID and also to delete + the SecretID from the backend. +
+ +
Method
+
`POST`
+ +
URL
+
`/auth/approle/role/[role_name]/secret-id`
+ +
Parameters
+
+
    +
  • + metadata + optional + Metadata to be tied to the SecretID. This should be a JSON + formatted string containing the metadata in key value pairs. +
  • +
+
+ +
Returns
+
+ +```javascript +{ + "auth": null, + "warnings": null, + "wrap_info": null, + "data": { + "secret_id_accessor": "84896a0c-1347-aa90-a4f6-aca8b7558780", + "secret_id": "841771dc-11c9-bbc7-bcac-6a3945a69cd9" + }, + "lease_duration": 0, + "renewable": false, + "lease_id": "" +} +``` + +
+
+ +#### List +
+
Description
+
+ Lists the accessors of all the SecretIDs issued against the AppRole. + This includes the accessors for the "custom" SecretIDs as well. +
+ +
Method
+
`LIST/GET`
+ +
URL
+
`/auth/approle/role/[role_name]/secret-id` (LIST) or `/auth/approle/role/[role_name]/secret-id?list=true` (GET)
+ +
Parameters
+
+ None +
+ +
Returns
+
+ +```javascript +{ + "auth": null, + "warnings": null, + "wrap_info": null, + "data": { + "keys": [ + "ce102d2a-8253-c437-bf9a-aceed4241491", + "a1c8dee4-b869-e68d-3520-2040c1a0849a", + "be83b7e2-044c-7244-07e1-47560ca1c787", + "84896a0c-1347-aa90-a4f6-aca8b7558780", + "239b1328-6523-15e7-403a-a48038cdc45a" + ] + }, + "lease_duration": 0, + "renewable": false, + "lease_id": "" +} +``` + +
+
+ +### /auth/approle/role/[role_name]/secret-id/ +#### GET +
+
Description
+
+ Reads out the properties of the SecretID to which the supplied `secret_id_accessor` is an index of. +
+ +
Method
+
`GET`
+ +
URL
+
`/auth/approle/role/[role_name]/secret-id/`
+ +
Parameters
+
+ None. +
+ +
Returns
+
+ +```javascript +{ + "auth": null, + "warnings": null, + "wrap_info": null, + "data": { + "secret_id_ttl": 600, + "secret_id_num_uses": 40, + "secret_id_accessor": "5e222f10-278d-a829-4e74-10d71977bb53", + "metadata": { + "version": "v1", + "prefix": "dev_secrets" + }, + "last_updated_time": "2016-06-29T05:31:09.407042587Z", + "expiration_time": "2016-06-29T05:41:09.407042587Z", + "creation_time": "2016-06-29T05:31:09.407042587Z" + }, + "lease_duration": 0, + "renewable": false, + "lease_id": "" +} +``` + +
+
+ +#### DELETE +
+
Description
+
+ Deletes the SecretID to which the supplied `secret_id_accessor` is an index of. +
+ +
Method
+
`DELETE`
+ +
URL
+
`/auth/approle/role/[role_name]/secret-id/`
+ +
Parameters
+
+ None. +
+ +
Returns
+
+ `204` response code. +
+
+ + +### /auth/approle/role/[role_name]/custom-secret-id +#### POST +
+
Description
+
+ Assigns a "custom" SecretID against an existing AppRole. +
+ +
Method
+
`POST`
+ +
URL
+
`/auth/approle/role/[role_name]/custom-secret-id`
+ +
Parameters
+
+
    +
  • + secret_id + required + SecretID to be attached to the Role. +
  • +
+
    +
  • + metadata + optional + Metadata to be tied to the SecretID. This should be a JSON + formatted string containing the metadata in key value pairs. +
  • +
+
+ +
Returns
+
+ +```javascript +{ + "auth": null, + "warnings": null, + "wrap_info": null, + "data": { + "secret_id_accessor": "a109dc4a-1fd3-6df6-feda-0ca28b2d4a81", + "secret_id": "testsecretid" + }, + "lease_duration": 0, + "renewable": false, + "lease_id": "" +} +``` + +
+
+ + +### /auth/approle/login +#### POST +
+
Description
+
+ Issues a Vault token based on the presented credentials. Credentials + other than `role_id`, to be presented depends on the constraints + set on the AppRole. If `bind_secret_id` is enabled, then parameter + `secret_id` becomes a required credential. +
+ +
Method
+
`POST`
+ +
URL
+
`/auth/approle/login`
+ +
Parameters
+
+
    +
  • + role_id + required + RoleID of the AppRole. +
  • +
+
    +
  • + secret_id + required when `bind_secret_id` is enabled + SecretID belonging to AppRole. +
  • +
+
+ +
Returns
+
+ +```javascript +{ + "auth": { + "renewable": true, + "lease_duration": 1200, + "metadata": null, + "policies": [ + "default" + ], + "accessor": "fd6c9a00-d2dc-3b11-0be5-af7ae0e1d374", + "client_token": "5b1a0318-679c-9c45-e5c6-d1b9a9035d49" + }, + "warnings": null, + "wrap_info": null, + "data": null, + "lease_duration": 0, + "renewable": false, + "lease_id": "" +} +``` + +
+
+ +### /auth/approle/role/[role_name]/policies +### /auth/approle/role/[role_name]/secret-id-num-uses +### /auth/approle/role/[role_name]/secret-id-ttl +### /auth/approle/role/[role_name]/token-ttl +### /auth/approle/role/[role_name]/token-max-ttl +### /auth/approle/role/[role_name]/bind-secret-id +### /auth/approle/role/[role_name]/bound-cidr-list +### /auth/approle/role/[role_name]/period +#### POST/GET/DELETE +
+
Description
+
+ Updates the respective property in the existing AppRole. All of these + parameters of the AppRole can be updated using the `/auth/approle/role/[role_name]` + endpoint directly. The endpoints for each field is provided separately + to be able to delegate specific endpoints using Vault's ACL system. +
+ +
Method
+
`POST/GET/DELETE`
+ +
URL
+
`/auth/approle/role/[role_name]/[field_name]`
+ +
Parameters
+
+ Refer to `/auth/approle/role/[role_name]` endpoint. +
+ +
Returns
+
+ Refer to `/auth/approle/role/[role_name]` endpoint. +
+
diff --git a/website/source/layouts/docs.erb b/website/source/layouts/docs.erb index a30ba6ad3fca3..f4aab1945969a 100644 --- a/website/source/layouts/docs.erb +++ b/website/source/layouts/docs.erb @@ -174,6 +174,14 @@ App ID + > + AppRole + + + > + AWS EC2 Auth + + > GitHub @@ -188,10 +196,10 @@ > TLS Certificates - + > - Tokens + Tokens > @@ -199,7 +207,7 @@ > - AWS EC2 Auth + AWS EC2