Revert "MFA (#14049)" (#14135)

This reverts commit 5f17953b5980e6438215d5cb62c8575d16c63193.
This commit is contained in:
Jordan Reimer 2022-02-17 13:17:59 -07:00 committed by GitHub
parent e4aab1b0cc
commit b936db8332
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
80 changed files with 1123 additions and 1402 deletions

View File

@ -51,6 +51,8 @@ const (
EnvRateLimit = "VAULT_RATE_LIMIT"
EnvHTTPProxy = "VAULT_HTTP_PROXY"
HeaderIndex = "X-Vault-Index"
HeaderForward = "X-Vault-Forward"
HeaderInconsistent = "X-Vault-Inconsistent"
)
// Deprecated values
@ -1395,7 +1397,7 @@ func ParseReplicationState(raw string, hmacKey []byte) (*logical.WALState, error
// conjunction with RequireState.
func ForwardInconsistent() RequestCallback {
return func(req *Request) {
req.Headers.Set("X-Vault-Inconsistent", "forward-active-node")
req.Headers.Set(HeaderInconsistent, "forward-active-node")
}
}
@ -1404,7 +1406,7 @@ func ForwardInconsistent() RequestCallback {
// This feature must be enabled in Vault's configuration.
func ForwardAlways() RequestCallback {
return func(req *Request) {
req.Headers.Set("X-Vault-Forward", "active-node")
req.Headers.Set(HeaderForward, "active-node")
}
}

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"time"
"github.com/mitchellh/mapstructure"
)
@ -65,7 +66,31 @@ func (c *Sys) Unmount(path string) error {
return err
}
// Remount kicks off a remount operation, polls the status endpoint using
// the migration ID till either success or failure state is observed
func (c *Sys) Remount(from, to string) error {
remountResp, err := c.StartRemount(from, to)
if err != nil {
return err
}
for {
remountStatusResp, err := c.RemountStatus(remountResp.MigrationID)
if err != nil {
return err
}
if remountStatusResp.MigrationInfo.MigrationStatus == "success" {
return nil
}
if remountStatusResp.MigrationInfo.MigrationStatus == "failure" {
return fmt.Errorf("Failure! Error encountered moving mount %s to %s, with migration ID %s", from, to, remountResp.MigrationID)
}
time.Sleep(1 * time.Second)
}
}
// StartRemount kicks off a mount migration and returns a response with the migration ID
func (c *Sys) StartRemount(from, to string) (*MountMigrationOutput, error) {
body := map[string]interface{}{
"from": from,
"to": to,
@ -73,16 +98,59 @@ func (c *Sys) Remount(from, to string) error {
r := c.c.NewRequest("POST", "/v1/sys/remount")
if err := r.SetJSONBody(body); err != nil {
return err
return nil, err
}
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
resp, err := c.c.RawRequestWithContext(ctx, r)
if err == nil {
defer resp.Body.Close()
if err != nil {
return nil, err
}
return err
defer resp.Body.Close()
secret, err := ParseSecret(resp.Body)
if err != nil {
return nil, err
}
if secret == nil || secret.Data == nil {
return nil, errors.New("data from server response is empty")
}
var result MountMigrationOutput
err = mapstructure.Decode(secret.Data, &result)
if err != nil {
return nil, err
}
return &result, err
}
// RemountStatus checks the status of a mount migration operation with the provided ID
func (c *Sys) RemountStatus(migrationID string) (*MountMigrationStatusOutput, error) {
r := c.c.NewRequest("GET", fmt.Sprintf("/v1/sys/remount/status/%s", migrationID))
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
resp, err := c.c.RawRequestWithContext(ctx, r)
if err != nil {
return nil, err
}
defer resp.Body.Close()
secret, err := ParseSecret(resp.Body)
if err != nil {
return nil, err
}
if secret == nil || secret.Data == nil {
return nil, errors.New("data from server response is empty")
}
var result MountMigrationStatusOutput
err = mapstructure.Decode(secret.Data, &result)
if err != nil {
return nil, err
}
return &result, err
}
func (c *Sys) TuneMount(path string, config MountConfigInput) error {
@ -187,3 +255,18 @@ type MountConfigOutput struct {
// Deprecated: This field will always be blank for newer server responses.
PluginName string `json:"plugin_name,omitempty" mapstructure:"plugin_name"`
}
type MountMigrationOutput struct {
MigrationID string `mapstructure:"migration_id"`
}
type MountMigrationStatusOutput struct {
MigrationID string `mapstructure:"migration_id"`
MigrationInfo *MountMigrationStatusInfo `mapstructure:"migration_info"`
}
type MountMigrationStatusInfo struct {
SourceMount string `mapstructure:"source_mount"`
TargetMount string `mapstructure:"target_mount"`
MigrationStatus string `mapstructure:"status"`
}

View File

@ -178,11 +178,14 @@ func (b *backend) pathLoginUpdate(ctx context.Context, req *logical.Request, dat
}
belongs, err := cidrutil.IPBelongsToCIDRBlocksSlice(req.Connection.RemoteAddr, entry.CIDRList)
if !belongs || err != nil {
if err != nil {
return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest
}
if !belongs {
return logical.ErrorResponse(fmt.Errorf(
"source address %q unauthorized through CIDR restrictions on the secret ID: %w",
"source address %q unauthorized through CIDR restrictions on the secret ID",
req.Connection.RemoteAddr,
err,
).Error()), nil
}
}

View File

@ -10,6 +10,7 @@ import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"io"
@ -17,6 +18,8 @@ import (
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
"golang.org/x/crypto/ssh"
"github.com/mikesmitty/edkey"
)
const (
@ -357,9 +360,9 @@ func generateSSHKeyPair(randomSource io.Reader, keyType string, keyBits int) (st
return "", "", err
}
marshalled, err := x509.MarshalPKCS8PrivateKey(privateSeed)
if err != nil {
return "", "", err
marshalled := edkey.MarshalED25519PrivateKey(privateSeed)
if marshalled == nil {
return "", "", errors.New("unable to marshal ed25519 private key")
}
privateBlock = &pem.Block{

View File

@ -197,6 +197,20 @@ func createDeleteHelper(t *testing.T, b logical.Backend, config *logical.Backend
t.Fatalf("bad case %v: expected public key of type %v but was %v", index, caReq.Data["key_type"], resp.Data["public_key"])
}
issueOptions := map[string]interface{}{
"public_key": testCAPublicKeyEd25519,
}
issueReq := &logical.Request{
Path: "sign/ca-issuance",
Operation: logical.UpdateOperation,
Storage: config.StorageView,
Data: issueOptions,
}
resp, err = b.HandleRequest(context.Background(), issueReq)
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("bad case %v: err: %v, resp: %v", index, err, resp)
}
// Delete the configured keys
caReq.Operation = logical.DeleteOperation
resp, err = b.HandleRequest(context.Background(), caReq)
@ -235,6 +249,24 @@ func TestSSH_ConfigCAKeyTypes(t *testing.T) {
{"ed25519", 0},
}
// Create a role for ssh signing.
roleOptions := map[string]interface{}{
"allow_user_certificates": true,
"allowed_users": "*",
"key_type": "ca",
"ttl": "30s",
}
roleReq := &logical.Request{
Operation: logical.UpdateOperation,
Path: "roles/ca-issuance",
Data: roleOptions,
Storage: config.StorageView,
}
_, err = b.HandleRequest(context.Background(), roleReq)
if err != nil {
t.Fatalf("Cannot create role to issue against: %s", err)
}
for index, scenario := range cases {
createDeleteHelper(t, b, config, index, scenario.keyType, scenario.keyBits)
}

View File

@ -190,7 +190,7 @@ func (b *backend) periodicFunc(ctx context.Context, req *logical.Request) error
}
// autoRotateKeys retrieves all transit keys and rotates those which have an
// auto rotate interval defined which has passed. This operation only happens
// auto rotate period defined which has passed. This operation only happens
// on primary nodes and performance secondary nodes which have a local mount.
func (b *backend) autoRotateKeys(ctx context.Context, req *logical.Request) error {
// Only check for autorotation once an hour to avoid unnecessarily iterating
@ -247,15 +247,15 @@ func (b *backend) rotateIfRequired(ctx context.Context, req *logical.Request, ke
}
defer p.Unlock()
// If the policy's automatic rotation interval is 0, it should not
// If the policy's automatic rotation period is 0, it should not
// automatically rotate.
if p.AutoRotateInterval == 0 {
if p.AutoRotatePeriod == 0 {
return nil
}
// Retrieve the latest version of the policy and determine if it is time to rotate.
latestKey := p.Keys[strconv.Itoa(p.LatestVersion)]
if time.Now().After(latestKey.CreationTime.Add(p.AutoRotateInterval)) {
if time.Now().After(latestKey.CreationTime.Add(p.AutoRotatePeriod)) {
if b.Logger().IsDebug() {
b.Logger().Debug("automatically rotating key", "key", key)
}

View File

@ -1607,7 +1607,7 @@ func TestTransit_AutoRotateKeys(t *testing.T) {
Operation: logical.UpdateOperation,
Path: "keys/test2",
Data: map[string]interface{}{
"auto_rotate_interval": 24 * time.Hour,
"auto_rotate_period": 24 * time.Hour,
},
}
resp, err = b.HandleRequest(context.Background(), req)
@ -1651,7 +1651,7 @@ func TestTransit_AutoRotateKeys(t *testing.T) {
t.Fatalf("incorrect latest_version found, got: %d, want: %d", resp.Data["latest_version"], 1)
}
// Update auto rotate interval on one key to be one nanosecond
// Update auto rotate period on one key to be one nanosecond
p, _, err := b.GetPolicy(context.Background(), keysutil.PolicyRequest{
Storage: storage,
Name: "test2",
@ -1662,7 +1662,7 @@ func TestTransit_AutoRotateKeys(t *testing.T) {
if p == nil {
t.Fatal("expected non-nil policy")
}
p.AutoRotateInterval = time.Nanosecond
p.AutoRotatePeriod = time.Nanosecond
err = p.Persist(context.Background(), storage)
if err != nil {
t.Fatal(err)

View File

@ -49,7 +49,7 @@ the latest version of the key is allowed.`,
Description: `Enables taking a backup of the named key in plaintext format. Once set, this cannot be disabled.`,
},
"auto_rotate_interval": {
"auto_rotate_period": {
Type: framework.TypeDurationSecond,
Description: `Amount of time the key should live before
being automatically rotated. A value of 0
@ -193,19 +193,19 @@ func (b *backend) pathConfigWrite(ctx context.Context, req *logical.Request, d *
}
}
autoRotateIntervalRaw, ok, err := d.GetOkErr("auto_rotate_interval")
autoRotatePeriodRaw, ok, err := d.GetOkErr("auto_rotate_period")
if err != nil {
return nil, err
}
if ok {
autoRotateInterval := time.Second * time.Duration(autoRotateIntervalRaw.(int))
autoRotatePeriod := time.Second * time.Duration(autoRotatePeriodRaw.(int))
// Provided value must be 0 to disable or at least an hour
if autoRotateInterval != 0 && autoRotateInterval < time.Hour {
return logical.ErrorResponse("auto rotate interval must be 0 to disable or at least an hour"), nil
if autoRotatePeriod != 0 && autoRotatePeriod < time.Hour {
return logical.ErrorResponse("auto rotate period must be 0 to disable or at least an hour"), nil
}
if autoRotateInterval != p.AutoRotateInterval {
p.AutoRotateInterval = autoRotateInterval
if autoRotatePeriod != p.AutoRotatePeriod {
p.AutoRotatePeriod = autoRotatePeriod
persistNeeded = true
}
}

View File

@ -294,43 +294,43 @@ func TestTransit_ConfigSettings(t *testing.T) {
func TestTransit_UpdateKeyConfigWithAutorotation(t *testing.T) {
tests := map[string]struct {
initialAutoRotateInterval interface{}
newAutoRotateInterval interface{}
initialAutoRotatePeriod interface{}
newAutoRotatePeriod interface{}
shouldError bool
expectedValue time.Duration
}{
"default (no value)": {
initialAutoRotateInterval: "5h",
initialAutoRotatePeriod: "5h",
shouldError: false,
expectedValue: 5 * time.Hour,
},
"0 (int)": {
initialAutoRotateInterval: "5h",
newAutoRotateInterval: 0,
initialAutoRotatePeriod: "5h",
newAutoRotatePeriod: 0,
shouldError: false,
expectedValue: 0,
},
"0 (string)": {
initialAutoRotateInterval: "5h",
newAutoRotateInterval: 0,
initialAutoRotatePeriod: "5h",
newAutoRotatePeriod: 0,
shouldError: false,
expectedValue: 0,
},
"5 seconds": {
newAutoRotateInterval: "5s",
newAutoRotatePeriod: "5s",
shouldError: true,
},
"5 hours": {
newAutoRotateInterval: "5h",
newAutoRotatePeriod: "5h",
shouldError: false,
expectedValue: 5 * time.Hour,
},
"negative value": {
newAutoRotateInterval: "-1800s",
newAutoRotatePeriod: "-1800s",
shouldError: true,
},
"invalid string": {
newAutoRotateInterval: "this shouldn't work",
newAutoRotatePeriod: "this shouldn't work",
shouldError: true,
},
}
@ -364,11 +364,11 @@ func TestTransit_UpdateKeyConfigWithAutorotation(t *testing.T) {
keyName := hex.EncodeToString(keyNameBytes)
_, err = client.Logical().Write(fmt.Sprintf("transit/keys/%s", keyName), map[string]interface{}{
"auto_rotate_interval": test.initialAutoRotateInterval,
"auto_rotate_period": test.initialAutoRotatePeriod,
})
resp, err := client.Logical().Write(fmt.Sprintf("transit/keys/%s/config", keyName), map[string]interface{}{
"auto_rotate_interval": test.newAutoRotateInterval,
"auto_rotate_period": test.newAutoRotatePeriod,
})
switch {
case test.shouldError && err == nil:
@ -385,7 +385,7 @@ func TestTransit_UpdateKeyConfigWithAutorotation(t *testing.T) {
if resp == nil {
t.Fatal("expected non-nil response")
}
gotRaw, ok := resp.Data["auto_rotate_interval"].(json.Number)
gotRaw, ok := resp.Data["auto_rotate_period"].(json.Number)
if !ok {
t.Fatal("returned value is of unexpected type")
}
@ -395,7 +395,7 @@ func TestTransit_UpdateKeyConfigWithAutorotation(t *testing.T) {
}
want := int64(test.expectedValue.Seconds())
if got != want {
t.Fatalf("incorrect auto_rotate_interval returned, got: %d, want: %d", got, want)
t.Fatalf("incorrect auto_rotate_period returned, got: %d, want: %d", got, want)
}
}
})

View File

@ -95,7 +95,7 @@ if the key type supports public keys, this will
return the public key for the given context.`,
},
"auto_rotate_interval": {
"auto_rotate_period": {
Type: framework.TypeDurationSecond,
Default: 0,
Description: `Amount of time the key should live before
@ -132,10 +132,10 @@ func (b *backend) pathPolicyWrite(ctx context.Context, req *logical.Request, d *
keyType := d.Get("type").(string)
exportable := d.Get("exportable").(bool)
allowPlaintextBackup := d.Get("allow_plaintext_backup").(bool)
autoRotateInterval := time.Second * time.Duration(d.Get("auto_rotate_interval").(int))
autoRotatePeriod := time.Second * time.Duration(d.Get("auto_rotate_period").(int))
if autoRotateInterval != 0 && autoRotateInterval < time.Hour {
return logical.ErrorResponse("auto rotate interval must be 0 to disable or at least an hour"), nil
if autoRotatePeriod != 0 && autoRotatePeriod < time.Hour {
return logical.ErrorResponse("auto rotate period must be 0 to disable or at least an hour"), nil
}
if !derived && convergent {
@ -150,7 +150,7 @@ func (b *backend) pathPolicyWrite(ctx context.Context, req *logical.Request, d *
Convergent: convergent,
Exportable: exportable,
AllowPlaintextBackup: allowPlaintextBackup,
AutoRotateInterval: autoRotateInterval,
AutoRotatePeriod: autoRotatePeriod,
}
switch keyType {
case "aes128-gcm96":
@ -238,7 +238,7 @@ func (b *backend) pathPolicyRead(ctx context.Context, req *logical.Request, d *f
"supports_decryption": p.Type.DecryptionSupported(),
"supports_signing": p.Type.SigningSupported(),
"supports_derivation": p.Type.DerivationSupported(),
"auto_rotate_interval": int64(p.AutoRotateInterval.Seconds()),
"auto_rotate_period": int64(p.AutoRotatePeriod.Seconds()),
},
}

View File

@ -95,7 +95,7 @@ func TestTransit_Issue_2958(t *testing.T) {
func TestTransit_CreateKeyWithAutorotation(t *testing.T) {
tests := map[string]struct {
autoRotateInterval interface{}
autoRotatePeriod interface{}
shouldError bool
expectedValue time.Duration
}{
@ -103,30 +103,30 @@ func TestTransit_CreateKeyWithAutorotation(t *testing.T) {
shouldError: false,
},
"0 (int)": {
autoRotateInterval: 0,
autoRotatePeriod: 0,
shouldError: false,
expectedValue: 0,
},
"0 (string)": {
autoRotateInterval: "0",
autoRotatePeriod: "0",
shouldError: false,
expectedValue: 0,
},
"5 seconds": {
autoRotateInterval: "5s",
autoRotatePeriod: "5s",
shouldError: true,
},
"5 hours": {
autoRotateInterval: "5h",
autoRotatePeriod: "5h",
shouldError: false,
expectedValue: 5 * time.Hour,
},
"negative value": {
autoRotateInterval: "-1800s",
autoRotatePeriod: "-1800s",
shouldError: true,
},
"invalid string": {
autoRotateInterval: "this shouldn't work",
autoRotatePeriod: "this shouldn't work",
shouldError: true,
},
}
@ -160,7 +160,7 @@ func TestTransit_CreateKeyWithAutorotation(t *testing.T) {
keyName := hex.EncodeToString(keyNameBytes)
_, err = client.Logical().Write(fmt.Sprintf("transit/keys/%s", keyName), map[string]interface{}{
"auto_rotate_interval": test.autoRotateInterval,
"auto_rotate_period": test.autoRotatePeriod,
})
switch {
case test.shouldError && err == nil:
@ -177,7 +177,7 @@ func TestTransit_CreateKeyWithAutorotation(t *testing.T) {
if resp == nil {
t.Fatal("expected non-nil response")
}
gotRaw, ok := resp.Data["auto_rotate_interval"].(json.Number)
gotRaw, ok := resp.Data["auto_rotate_period"].(json.Number)
if !ok {
t.Fatal("returned value is of unexpected type")
}
@ -187,7 +187,7 @@ func TestTransit_CreateKeyWithAutorotation(t *testing.T) {
}
want := int64(test.expectedValue.Seconds())
if got != want {
t.Fatalf("incorrect auto_rotate_interval returned, got: %d, want: %d", got, want)
t.Fatalf("incorrect auto_rotate_period returned, got: %d, want: %d", got, want)
}
}
})

View File

@ -1,3 +0,0 @@
```release-note:improvement
ui: Adds multi-factor authentication support
```

3
changelog/14067.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
api: Define constants for X-Vault-Forward and X-Vault-Inconsistent headers
```

3
changelog/14107.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:bug
auth/approle: Fix wrapping of nil errors in `login` endpoint
```

View File

@ -29,8 +29,8 @@ Usage: vault secrets move [options] SOURCE DESTINATION
secrets engine are revoked, but all configuration associated with the engine
is preserved.
This command only works within a namespace; it cannot be used to move engines
to different namespaces.
This command works within or across namespaces, both source and destination paths
can be prefixed with a namespace heirarchy relative to the current namespace.
WARNING! Moving an existing secrets engine will revoke any leases from the
old engine.
@ -39,6 +39,11 @@ Usage: vault secrets move [options] SOURCE DESTINATION
$ vault secrets move secret/ generic/
Move the existing secrets engine at ns1/secret/ across namespaces to ns2/generic/,
where ns1 and ns2 are child namespaces of the current namespace:
$ vault secrets move ns1/secret/ ns2/generic/
` + c.Flags().Help()
return strings.TrimSpace(helpText)
@ -84,11 +89,12 @@ func (c *SecretsMoveCommand) Run(args []string) int {
return 2
}
if err := client.Sys().Remount(source, destination); err != nil {
remountResp, err := client.Sys().StartRemount(source, destination)
if err != nil {
c.UI.Error(fmt.Sprintf("Error moving secrets engine %s to %s: %s", source, destination, err))
return 2
}
c.UI.Output(fmt.Sprintf("Success! Moved secrets engine %s to: %s", source, destination))
c.UI.Output(fmt.Sprintf("Success! Started moving secrets engine %s to %s, with migration ID %s", source, destination, remountResp.MigrationID))
return 0
}

View File

@ -3,6 +3,7 @@ package command
import (
"strings"
"testing"
"time"
"github.com/mitchellh/cli"
)
@ -91,12 +92,16 @@ func TestSecretsMoveCommand_Run(t *testing.T) {
t.Errorf("expected %d to be %d", code, exp)
}
expected := "Success! Moved secrets engine secret/ to: generic/"
expected := "Success! Started moving secrets engine secret/ to generic/"
combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
if !strings.Contains(combined, expected) {
t.Errorf("expected %q to contain %q", combined, expected)
}
// Wait for the move command to complete. Ideally we'd check remount status
// explicitly but we don't have migration id here
time.Sleep(1 * time.Second)
mounts, err := client.Sys().ListMounts()
if err != nil {
t.Fatal(err)

1
go.mod
View File

@ -306,6 +306,7 @@ require (
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
github.com/miekg/dns v1.1.41 // indirect
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a // indirect
github.com/mitchellh/hashstructure v1.0.0 // indirect
github.com/mitchellh/iochan v1.0.0 // indirect
github.com/mitchellh/pointerstructure v1.2.0 // indirect

2
go.sum
View File

@ -1153,6 +1153,8 @@ github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKju
github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY=
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a h1:eU8j/ClY2Ty3qdHnn0TyW3ivFoPC/0F1gQZz8yTxbbE=
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a/go.mod h1:v8eSC2SMp9/7FTKUncp7fH9IwPfw+ysMObcEz5FWheQ=
github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible/go.mod h1:8AuVvqP/mXw1px98n46wfvcGfQ4ci2FwoAjKYxuo3Z4=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=

View File

@ -133,3 +133,20 @@ func SplitIDFromString(input string) (string, string) {
return prefix + input[:idx], input[idx+1:]
}
// MountPathDetails contains the details of a mount's location,
// consisting of the namespace of the mount and the path of the
// mount within the namespace
type MountPathDetails struct {
Namespace *Namespace
MountPath string
}
func (mpd *MountPathDetails) GetRelativePath(currNs *Namespace) string {
subNsPath := strings.TrimPrefix(mpd.Namespace.Path, currNs.Path)
return subNsPath + mpd.MountPath
}
func (mpd *MountPathDetails) GetFullPath() string {
return mpd.Namespace.Path + mpd.MountPath
}

View File

@ -2,8 +2,10 @@ package http
import (
"encoding/json"
"fmt"
"reflect"
"testing"
"time"
"github.com/go-test/deep"
@ -374,8 +376,24 @@ func TestSysRemount(t *testing.T) {
"from": "foo",
"to": "bar",
})
testResponseStatus(t, resp, 204)
testResponseStatus(t, resp, 200)
// Poll until the remount succeeds
var remountResp map[string]interface{}
testResponseBody(t, resp, &remountResp)
vault.RetryUntil(t, 5*time.Second, func() error {
resp = testHttpGet(t, token, addr+"/v1/sys/remount/status/"+remountResp["migration_id"].(string))
testResponseStatus(t, resp, 200)
var remountStatusResp map[string]interface{}
testResponseBody(t, resp, &remountStatusResp)
status := remountStatusResp["data"].(map[string]interface{})["migration_info"].(map[string]interface{})["status"]
if status != "success" {
return fmt.Errorf("Expected migration status to be successful, got %q", status)
}
return nil
})
resp = testHttpGet(t, token, addr+"/v1/sys/mounts")
var actual map[string]interface{}

View File

@ -52,7 +52,7 @@ type PolicyRequest struct {
AllowPlaintextBackup bool
// How frequently the key should automatically rotate
AutoRotateInterval time.Duration
AutoRotatePeriod time.Duration
}
type LockManager struct {
@ -383,7 +383,7 @@ func (lm *LockManager) GetPolicy(ctx context.Context, req PolicyRequest, rand io
Derived: req.Derived,
Exportable: req.Exportable,
AllowPlaintextBackup: req.AllowPlaintextBackup,
AutoRotateInterval: req.AutoRotateInterval,
AutoRotatePeriod: req.AutoRotatePeriod,
}
if req.Derived {

View File

@ -374,9 +374,9 @@ type Policy struct {
// policy object.
StoragePrefix string `json:"storage_prefix"`
// AutoRotateInterval defines how frequently the key should automatically
// AutoRotatePeriod defines how frequently the key should automatically
// rotate. Setting this to zero disables automatic rotation for the key.
AutoRotateInterval time.Duration `json:"auto_rotate_interval"`
AutoRotatePeriod time.Duration `json:"auto_rotate_period"`
// versionPrefixCache stores caches of version prefix strings and the split
// version template.

View File

@ -126,19 +126,6 @@ export default ApplicationAdapter.extend({
return this.ajax(url, verb, options);
},
mfaValidate({ mfa_request_id, mfa_constraints }) {
const options = {
data: {
mfa_request_id,
mfa_payload: mfa_constraints.reduce((obj, { selectedMethod, passcode }) => {
obj[selectedMethod.id] = passcode ? [passcode] : [];
return obj;
}, {}),
},
};
return this.ajax('/v1/sys/mfa/validate', 'POST', options);
},
urlFor(endpoint) {
if (!ENDPOINTS.includes(endpoint)) {
throw new Error(

View File

@ -18,13 +18,13 @@ const BACKENDS = supportedAuthBackends();
*
* @example ```js
* // All properties are passed in via query params.
* <AuthForm @wrappedToken={{wrappedToken}} @cluster={{model}} @namespace={{namespaceQueryParam}} @selectedAuth={{authMethod}} @onSuccess={{action this.onSuccess}} />```
* <AuthForm @wrappedToken={{wrappedToken}} @cluster={{model}} @namespace={{namespaceQueryParam}} @redirectTo={{redirectTo}} @selectedAuth={{authMethod}}/>```
*
* @param {string} wrappedToken - The auth method that is currently selected in the dropdown.
* @param {object} cluster - The auth method that is currently selected in the dropdown. This corresponds to an Ember Model.
* @param {string} namespace- The currently active namespace.
* @param {string} selectedAuth - The auth method that is currently selected in the dropdown.
* @param {function} onSuccess - Fired on auth success
* @param wrappedToken=null {String} - The auth method that is currently selected in the dropdown.
* @param cluster=null {Object} - The auth method that is currently selected in the dropdown. This corresponds to an Ember Model.
* @param namespace=null {String} - The currently active namespace.
* @param redirectTo=null {String} - The name of the route to redirect to.
* @param selectedAuth=null {String} - The auth method that is currently selected in the dropdown.
*/
const DEFAULTS = {
@ -45,6 +45,7 @@ export default Component.extend(DEFAULTS, {
selectedAuth: null,
methods: null,
cluster: null,
redirectTo: null,
namespace: null,
wrappedToken: null,
// internal
@ -205,18 +206,54 @@ export default Component.extend(DEFAULTS, {
showLoading: or('isLoading', 'authenticate.isRunning', 'fetchMethods.isRunning', 'unwrapToken.isRunning'),
handleError(e, prefixMessage = true) {
this.set('loading', false);
let errors;
if (e.errors) {
errors = e.errors.map((error) => {
if (error.detail) {
return error.detail;
}
return error;
});
} else {
errors = [e];
}
let message = prefixMessage ? 'Authentication failed: ' : '';
this.set('error', `${message}${errors.join('.')}`);
},
authenticate: task(
waitFor(function* (backendType, data) {
let clusterId = this.cluster.id;
try {
if (backendType === 'okta') {
this.delayAuthMessageReminder.perform();
const authResponse = yield this.auth.authenticate({ clusterId, backend: backendType, data });
this.onSuccess(authResponse, backendType, data);
} catch (e) {
this.set('loading', false);
if (!this.auth.mfaError) {
this.set('error', `Authentication failed: ${this.auth.handleError(e)}`);
}
let authResponse = yield this.auth.authenticate({ clusterId, backend: backendType, data });
let { isRoot, namespace } = authResponse;
let transition;
let { redirectTo } = this;
if (redirectTo) {
// reset the value on the controller because it's bound here
this.set('redirectTo', '');
// here we don't need the namespace because it will be encoded in redirectTo
transition = this.router.transitionTo(redirectTo);
} else {
transition = this.router.transitionTo('vault.cluster', { queryParams: { namespace } });
}
// returning this w/then because if we keep it
// in the task, it will get cancelled when the component in un-rendered
yield transition.followRedirects().then(() => {
if (isRoot) {
this.flashMessages.warning(
'You have logged in with a root token. As a security precaution, this root token will not be stored by your browser and you will need to re-authenticate after the window is closed or refreshed.'
);
}
});
} catch (e) {
this.handleError(e);
}
})
),
@ -225,9 +262,9 @@ export default Component.extend(DEFAULTS, {
if (Ember.testing) {
this.showLoading = true;
yield timeout(0);
} else {
yield timeout(5000);
return;
}
yield timeout(5000);
}),
actions: {
@ -261,10 +298,11 @@ export default Component.extend(DEFAULTS, {
return this.authenticate.unlinked().perform(backend.type, data);
},
handleError(e) {
this.setProperties({
loading: false,
error: e ? this.auth.handleError(e) : null,
});
if (e) {
this.handleError(e, false);
} else {
this.set('error', null);
}
},
},
});

View File

@ -15,6 +15,9 @@ export default class Current extends Component {
return { name: namespace['label'], id: namespace['label'] };
});
@tracked selectedAuthMethod = null;
@tracked authMethodOptions = [];
// Response client count data by namespace for current/partial month
get byNamespaceCurrent() {
return this.args.model.monthly?.byNamespace || [];
@ -26,7 +29,21 @@ export default class Current extends Component {
}
get hasAttributionData() {
return this.totalUsageCounts.clients !== 0 && this.totalClientsData.length !== 0;
return this.totalUsageCounts.clients !== 0 && !!this.totalClientsData && !this.selectedAuthMethod;
}
get filteredActivity() {
const namespace = this.selectedNamespace;
const auth = this.selectedAuthMethod;
if (!namespace && !auth) {
return this.getActivityResponse;
}
if (!auth) {
return this.byNamespaceCurrent.find((ns) => ns.label === namespace);
}
return this.byNamespaceCurrent
.find((ns) => ns.label === namespace)
.mounts?.find((mount) => mount.label === auth);
}
get countsIncludeOlderData() {
@ -41,16 +58,13 @@ export default class Current extends Component {
// top level TOTAL client counts for current/partial month
get totalUsageCounts() {
return this.selectedNamespace
? this.filterByNamespace(this.selectedNamespace)
: this.args.model.monthly?.total;
return this.selectedNamespace ? this.filteredActivity : this.args.model.monthly?.total;
}
// total client data for horizontal bar chart in attribution component
get totalClientsData() {
if (this.selectedNamespace) {
let filteredNamespace = this.filterByNamespace(this.selectedNamespace);
return filteredNamespace.mounts ? this.filterByNamespace(this.selectedNamespace).mounts : null;
return this.filteredActivity?.mounts || null;
} else {
return this.byNamespaceCurrent;
}
@ -60,15 +74,26 @@ export default class Current extends Component {
return this.args.model.monthly?.responseTimestamp;
}
// HELPERS
filterByNamespace(namespace) {
return this.byNamespaceCurrent.find((ns) => ns.label === namespace);
}
// ACTIONS
@action
selectNamespace([value]) {
// value comes in as [namespace0]
this.selectedNamespace = value;
if (!value) {
// on clear, also make sure auth method is cleared
this.selectedAuthMethod = null;
} else {
// Side effect: set auth namespaces
const mounts = this.filteredActivity.mounts?.map((mount) => ({
id: mount.label,
name: mount.label,
}));
this.authMethodOptions = mounts;
}
}
@action
setAuthMethod([authMount]) {
this.selectedAuthMethod = authMount;
}
}

View File

@ -38,10 +38,15 @@ export default class History extends Component {
years = Array.from({ length: 5 }, (item, i) => {
return new Date().getFullYear() - i;
});
currentDate = new Date();
currentYear = this.currentDate.getFullYear(); // integer of year
currentMonth = this.currentDate.getMonth(); // index of month
@tracked isEditStartMonthOpen = false;
@tracked startMonth = null;
@tracked startYear = null;
@tracked allowedMonthMax = 12;
@tracked disabledYear = null;
// FOR HISTORY COMPONENT //
@ -57,14 +62,19 @@ export default class History extends Component {
// SEARCH SELECT
@tracked selectedNamespace = null;
@tracked namespaceArray = this.getActivityResponse.byNamespace.map((namespace) => {
return { name: namespace['label'], id: namespace['label'] };
});
@tracked namespaceArray = this.getActivityResponse.byNamespace.map((namespace) => ({
name: namespace.label,
id: namespace.label,
}));
// TEMPLATE MESSAGING
@tracked noActivityDate = '';
@tracked responseRangeDiffMessage = null;
@tracked isLoadingQuery = false;
@tracked licenseStartIsCurrentMonth = this.args.model.activity?.isLicenseDateError || false;
@tracked selectedAuthMethod = null;
@tracked authMethodOptions = [];
get versionText() {
return this.version.isEnterprise
@ -92,7 +102,7 @@ export default class History extends Component {
}
get hasAttributionData() {
return this.totalUsageCounts.clients !== 0 && this.totalClientsData.length !== 0;
return this.totalUsageCounts.clients !== 0 && !!this.totalClientsData && !this.selectedAuthMethod;
}
get startTimeDisplay() {
@ -113,6 +123,20 @@ export default class History extends Component {
return `${this.arrayOfMonths[month]} ${year}`;
}
get filteredActivity() {
const namespace = this.selectedNamespace;
const auth = this.selectedAuthMethod;
if (!namespace && !auth) {
return this.getActivityResponse;
}
if (!auth) {
return this.getActivityResponse.byNamespace.find((ns) => ns.label === namespace);
}
return this.getActivityResponse.byNamespace
.find((ns) => ns.label === namespace)
.mounts?.find((mount) => mount.label === auth);
}
get isDateRange() {
return !isSameMonth(
new Date(this.getActivityResponse.startTime),
@ -122,16 +146,13 @@ export default class History extends Component {
// top level TOTAL client counts for given date range
get totalUsageCounts() {
return this.selectedNamespace
? this.filterByNamespace(this.selectedNamespace)
: this.getActivityResponse.total;
return this.selectedNamespace ? this.filteredActivity : this.getActivityResponse.total;
}
// total client data for horizontal bar chart in attribution component
get totalClientsData() {
if (this.selectedNamespace) {
let filteredNamespace = this.filterByNamespace(this.selectedNamespace);
return filteredNamespace.mounts ? this.filterByNamespace(this.selectedNamespace).mounts : null;
return this.filteredActivity?.mounts || null;
} else {
return this.getActivityResponse?.byNamespace;
}
@ -157,6 +178,7 @@ export default class History extends Component {
@action
async handleClientActivityQuery(month, year, dateType) {
this.isEditStartMonthOpen = false;
if (dateType === 'cancel') {
return;
}
@ -195,6 +217,7 @@ export default class History extends Component {
this.storage().setItem(INPUTTED_START_DATE, this.startTimeFromResponse);
}
this.queriedActivityResponse = response;
this.licenseStartIsCurrentMonth = response.isLicenseDateError;
// compare if the response startTime comes after the requested startTime. If true throw a warning.
// only display if they selected a startTime
if (
@ -209,7 +232,6 @@ export default class History extends Component {
this.responseRangeDiffMessage = null;
}
} catch (e) {
// TODO CMB surface API errors when user selects start date after end date
return e;
} finally {
this.isLoadingQuery = false;
@ -225,22 +247,38 @@ export default class History extends Component {
selectNamespace([value]) {
// value comes in as [namespace0]
this.selectedNamespace = value;
if (!value) {
// on clear, also make sure auth method is cleared
this.selectedAuthMethod = null;
} else {
// Side effect: set auth namespaces
const mounts = this.filteredActivity.mounts?.map((mount) => ({
id: mount.label,
name: mount.label,
}));
this.authMethodOptions = mounts;
}
}
@action
setAuthMethod([authMount]) {
this.selectedAuthMethod = authMount;
}
// FOR START DATE MODAL
@action
selectStartMonth(month) {
selectStartMonth(month, event) {
this.startMonth = month;
// disables months if in the future
this.disabledYear = this.months.indexOf(month) >= this.currentMonth ? this.currentYear : null;
event.close();
}
@action
selectStartYear(year) {
selectStartYear(year, event) {
this.startYear = year;
}
// HELPERS //
filterByNamespace(namespace) {
return this.getActivityResponse.byNamespace.find((ns) => ns.label === namespace);
this.allowedMonthMax = year === this.currentYear ? this.currentMonth : 12;
event.close();
}
storage() {

View File

@ -12,9 +12,15 @@ import { tracked } from '@glimmer/tracking';
* ```
* @param {function} handleDateSelection - is the action from the parent that the date picker triggers
* @param {string} [name] - optional argument passed from date dropdown to parent function
* @param {string} [submitText] - optional argument to change submit button text
*/
export default class DateDropdown extends Component {
currentDate = new Date();
currentYear = this.currentDate.getFullYear(); // integer of year
currentMonth = this.currentDate.getMonth(); // index of month
@tracked allowedMonthMax = 12;
@tracked disabledYear = null;
@tracked startMonth = null;
@tracked startYear = null;
@ -26,13 +32,18 @@ export default class DateDropdown extends Component {
});
@action
selectStartMonth(month) {
selectStartMonth(month, event) {
this.startMonth = month;
// disables months if in the future
this.disabledYear = this.months.indexOf(month) >= this.currentMonth ? this.currentYear : null;
event.close();
}
@action
selectStartYear(year) {
selectStartYear(year, event) {
this.startYear = year;
this.allowedMonthMax = year === this.currentYear ? this.currentMonth : 12;
event.close();
}
@action

View File

@ -1,43 +0,0 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { TOTP_NOT_CONFIGURED } from 'vault/services/auth';
const TOTP_NA_MSG =
'Multi-factor authentication is required, but you have not set it up. In order to do so, please contact your administrator.';
const MFA_ERROR_MSG =
'Multi-factor authentication is required, but failed. Go back and try again, or contact your administrator.';
export { TOTP_NA_MSG, MFA_ERROR_MSG };
/**
* @module MfaError
* MfaError components are used to display mfa errors
*
* @example
* ```js
* <MfaError />
* ```
*/
export default class MfaError extends Component {
@service auth;
get isTotp() {
return this.auth.mfaErrors.includes(TOTP_NOT_CONFIGURED);
}
get title() {
return this.isTotp ? 'TOTP not set up' : 'Unauthorized';
}
get description() {
return this.isTotp ? TOTP_NA_MSG : MFA_ERROR_MSG;
}
@action
onClose() {
this.auth.set('mfaErrors', null);
if (this.args.onClose) {
this.args.onClose();
}
}
}

View File

@ -1,89 +0,0 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action, set } from '@ember/object';
import { task, timeout } from 'ember-concurrency';
import { numberToWord } from 'vault/helpers/number-to-word';
/**
* @module MfaForm
* The MfaForm component is used to enter a passcode when mfa is required to login
*
* @example
* ```js
* <MfaForm @clusterId={this.model.id} @authData={this.authData} />
* ```
* @param {string} clusterId - id of selected cluster
* @param {object} authData - data from initial auth request -- { mfa_requirement, backend, data }
* @param {function} onSuccess - fired when passcode passes validation
*/
export default class MfaForm extends Component {
@service auth;
@tracked passcode;
@tracked countdown;
@tracked errors;
get constraints() {
return this.args.authData.mfa_requirement.mfa_constraints;
}
get multiConstraint() {
return this.constraints.length > 1;
}
get singleConstraintMultiMethod() {
return !this.isMultiConstraint && this.constraints[0].methods.length > 1;
}
get singlePasscode() {
return (
!this.isMultiConstraint &&
this.constraints[0].methods.length === 1 &&
this.constraints[0].methods[0].uses_passcode
);
}
get description() {
let base = 'Multi-factor authentication is enabled for your account.';
if (this.singlePasscode) {
base += ' Enter your authentication code to log in.';
}
if (this.singleConstraintMultiMethod) {
base += ' Select the MFA method you wish to use.';
}
if (this.multiConstraint) {
const num = this.constraints.length;
base += ` ${numberToWord(num, true)} methods are required for successful authentication.`;
}
return base;
}
@task *validate() {
try {
const response = yield this.auth.totpValidate({
clusterId: this.args.clusterId,
...this.args.authData,
});
this.args.onSuccess(response);
} catch (error) {
this.errors = error.errors;
// TODO: update if specific error can be parsed for incorrect passcode
// this.newCodeDelay.perform();
}
}
@task *newCodeDelay() {
this.passcode = null;
this.countdown = 30;
while (this.countdown) {
yield timeout(1000);
this.countdown--;
}
}
@action onSelect(constraint, id) {
set(constraint, 'selectedId', id);
set(constraint, 'selectedMethod', constraint.methods.findBy('id', id));
}
@action submit(e) {
e.preventDefault();
this.validate.perform();
}
}

View File

@ -95,10 +95,10 @@ export default Component.extend(FocusOnInsertMixin, {
handleAutoRotateChange(ttlObj) {
if (ttlObj.enabled) {
set(this.key, 'autoRotateInterval', ttlObj.goSafeTimeString);
set(this.key, 'autoRotatePeriod', ttlObj.goSafeTimeString);
this.set('autoRotateInvalid', ttlObj.seconds < 3600);
} else {
set(this.key, 'autoRotateInterval', 0);
set(this.key, 'autoRotatePeriod', 0);
}
},

View File

@ -8,18 +8,13 @@ export default Controller.extend({
clusterController: controller('vault.cluster'),
namespaceService: service('namespace'),
featureFlagService: service('featureFlag'),
auth: service(),
router: service(),
queryParams: [{ authMethod: 'with', oidcProvider: 'o' }],
namespaceQueryParam: alias('clusterController.namespaceQueryParam'),
queryParams: [{ authMethod: 'with', oidcProvider: 'o' }],
wrappedToken: alias('vaultController.wrappedToken'),
redirectTo: alias('vaultController.redirectTo'),
managedNamespaceRoot: alias('featureFlagService.managedNamespaceRoot'),
authMethod: '',
oidcProvider: '',
redirectTo: alias('vaultController.redirectTo'),
managedNamespaceRoot: alias('featureFlagService.managedNamespaceRoot'),
get managedNamespaceChild() {
let fullParam = this.namespaceQueryParam;
@ -46,39 +41,4 @@ export default Controller.extend({
this.namespaceService.setNamespace(value, true);
this.set('namespaceQueryParam', value);
}).restartable(),
authSuccess({ isRoot, namespace }) {
let transition;
if (this.redirectTo) {
// here we don't need the namespace because it will be encoded in redirectTo
transition = this.router.transitionTo(this.redirectTo);
// reset the value on the controller because it's bound here
this.set('redirectTo', '');
} else {
transition = this.router.transitionTo('vault.cluster', { queryParams: { namespace } });
}
transition.followRedirects().then(() => {
if (isRoot) {
this.flashMessages.warning(
'You have logged in with a root token. As a security precaution, this root token will not be stored by your browser and you will need to re-authenticate after the window is closed or refreshed.'
);
}
});
},
actions: {
onAuthResponse(authResponse, backend, data) {
const { mfa_requirement } = authResponse;
// mfa methods handled by the backend are validated immediately in the auth service
// if the user must choose between methods or enter passcodes further action is required
if (mfa_requirement) {
this.set('mfaAuthData', { mfa_requirement, backend, data });
} else {
this.authSuccess(authResponse);
}
},
onMfaSuccess(authResponse) {
this.authSuccess(authResponse);
},
},
});

View File

@ -1,11 +1,15 @@
import { helper } from '@ember/component/helper';
import { formatDuration, intervalToDuration } from 'date-fns';
export function duration([time]) {
export function duration([time], { removeZero = false }) {
// intervalToDuration creates a durationObject that turns the seconds (ex 3600) to respective:
// { years: 0, months: 0, days: 0, hours: 1, minutes: 0, seconds: 0 }
// then formatDuration returns the filled in keys of the durationObject
if (removeZero && time === '0') {
return null;
}
// time must be in seconds
let duration = Number.parseInt(time, 10);
if (isNaN(duration)) {

View File

@ -1,22 +0,0 @@
import { helper } from '@ember/component/helper';
export function numberToWord(number, capitalize) {
const word =
{
0: 'zero',
1: 'one',
2: 'two',
3: 'three',
4: 'four',
5: 'five',
6: 'six',
7: 'seven',
8: 'eight',
9: 'nine',
}[number] || number;
return capitalize && typeof word === 'string' ? `${word.charAt(0).toUpperCase()}${word.slice(1)}` : word;
}
export default helper(function ([number], { capitalize }) {
return numberToWord(number, capitalize);
});

View File

@ -56,11 +56,11 @@ export default Model.extend({
fieldValue: 'id',
readOnly: true,
}),
autoRotateInterval: attr({
autoRotatePeriod: attr({
defaultValue: '0',
defaultShown: 'Key is not automatically rotated',
editType: 'ttl',
label: 'Auto-rotation interval',
label: 'Auto-rotation period',
}),
deletionAllowed: attr('boolean'),
derived: attr('boolean'),

View File

@ -8,10 +8,13 @@ export default class HistoryRoute extends Route {
try {
// on init ONLY make network request if we have a start time from the license
// otherwise user needs to manually input
// TODO CMB what to return here?
return start_time ? await this.store.queryRecord('clients/activity', { start_time }) : {};
} catch (e) {
return e;
// returns 400 when license start date is in the current month
if (e.httpStatus === 400) {
return { isLicenseDateError: true };
}
throw e;
}
}

View File

@ -10,7 +10,7 @@ export default ApplicationSerializer.extend({
id: payload.id,
data: {
...payload.data,
enabled: payload.data.enabled.includes('enable') ? 'On' : 'Off',
enabled: payload.data.enabled?.includes('enable') ? 'On' : 'Off',
},
};
return this._super(store, primaryModelClass, normalizedPayload, id, requestType);

View File

@ -50,12 +50,12 @@ export default RESTSerializer.extend({
const min_decryption_version = snapshot.attr('minDecryptionVersion');
const min_encryption_version = snapshot.attr('minEncryptionVersion');
const deletion_allowed = snapshot.attr('deletionAllowed');
const auto_rotate_interval = snapshot.attr('autoRotateInterval');
const auto_rotate_period = snapshot.attr('autoRotatePeriod');
return {
min_decryption_version,
min_encryption_version,
deletion_allowed,
auto_rotate_interval,
auto_rotate_period,
};
} else {
snapshot.id = snapshot.attr('name');

View File

@ -3,7 +3,6 @@ import { resolve, reject } from 'rsvp';
import { assign } from '@ember/polyfills';
import { isArray } from '@ember/array';
import { computed, get } from '@ember/object';
import { capitalize } from '@ember/string';
import fetch from 'fetch';
import { getOwner } from '@ember/application';
@ -15,10 +14,9 @@ import { task, timeout } from 'ember-concurrency';
const TOKEN_SEPARATOR = '☃';
const TOKEN_PREFIX = 'vault-';
const ROOT_PREFIX = '_root_';
const TOTP_NOT_CONFIGURED = 'TOTP mfa required but not configured';
const BACKENDS = supportedAuthBackends();
export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX, TOTP_NOT_CONFIGURED };
export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX };
export default Service.extend({
permissions: service(),
@ -26,8 +24,6 @@ export default Service.extend({
IDLE_TIMEOUT: 3 * 60e3,
expirationCalcTS: null,
isRenewing: false,
mfaErrors: null,
init() {
this._super(...arguments);
this.checkForRootToken();
@ -326,98 +322,16 @@ export default Service.extend({
});
},
_parseMfaResponse(mfa_requirement) {
// mfa_requirement response comes back in a shape that is not easy to work with
// convert to array of objects and add necessary properties to satisfy the view
if (mfa_requirement) {
const { mfa_request_id, mfa_constraints } = mfa_requirement;
let requiresAction; // if multiple constraints or methods or passcode input is needed further action will be required
const constraints = [];
for (let key in mfa_constraints) {
const methods = mfa_constraints[key].any;
const isMulti = methods.length > 1;
if (isMulti || methods.findBy('uses_passcode')) {
requiresAction = true;
}
// friendly label for display in MfaForm
methods.forEach((m) => {
const typeFormatted = m.type === 'totp' ? m.type.toUpperCase() : capitalize(m.type);
m.label = `${typeFormatted} ${m.uses_passcode ? 'passcode' : 'push notification'}`;
});
constraints.push({
name: key,
methods,
selectedMethod: isMulti ? null : methods[0],
});
}
return {
mfa_requirement: { mfa_request_id, mfa_constraints: constraints },
requiresAction,
};
}
return {};
},
async authenticate(/*{clusterId, backend, data}*/) {
const [options] = arguments;
const adapter = this.clusterAdapter();
let resp;
try {
resp = await adapter.authenticate(options);
} catch (e) {
// TODO: check for totp not configured mfa error before throwing
const errors = this.handleError(e);
// stubbing error - verify once API is finalized
if (errors.includes(TOTP_NOT_CONFIGURED)) {
this.set('mfaErrors', errors);
}
throw e;
}
const { mfa_requirement, requiresAction } = this._parseMfaResponse(resp.auth?.mfa_requirement);
if (mfa_requirement) {
if (requiresAction) {
return { mfa_requirement };
}
// silently make request to validate endpoint when passcode is not required
try {
resp = await adapter.mfaValidate(mfa_requirement);
} catch (e) {
// it's not clear in the auth-form component whether mfa validation is taking place for non-totp method
// since mfa errors display a screen rather than flash message handle separately
this.set('mfaErrors', this.handleError(e));
throw e;
}
}
return this.authSuccess(options, resp.auth || resp.data);
},
async totpValidate({ mfa_requirement, ...options }) {
const resp = await this.clusterAdapter().mfaValidate(mfa_requirement);
return this.authSuccess(options, resp.auth || resp.data);
},
async authSuccess(options, response) {
const authData = await this.persistAuthData(options, response, this.namespaceService.path);
let resp = await adapter.authenticate(options);
let authData = await this.persistAuthData(options, resp.auth || resp.data, this.namespaceService.path);
await this.permissions.getPaths.perform();
return authData;
},
handleError(e) {
if (e.errors) {
return e.errors.map((error) => {
if (error.detail) {
return error.detail;
}
return error;
});
}
return [e];
},
getAuthType() {
if (!this.authData) return;
return this.authData.backend.type;

View File

@ -51,7 +51,3 @@
margin-right: 4px;
}
}
.icon-blue {
color: $blue;
}

View File

@ -54,7 +54,7 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12);
background-color: $color;
color: $color-invert;
&:hover,
&:hover:not([disabled]),
&.is-hovered {
background-color: darken($color, 5%);
border-color: darken($color, 5%);
@ -237,11 +237,3 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12);
padding: $size-8;
width: 100%;
}
.icon-button {
background: transparent;
padding: 0;
margin: 0;
border: none;
cursor: pointer;
}

View File

@ -19,9 +19,6 @@
.is-borderless {
border: none !important;
}
.is-box-shadowless {
box-shadow: none !important;
}
.is-relative {
position: relative;
}
@ -191,9 +188,6 @@
.has-top-margin-xl {
margin-top: $spacing-xl;
}
.has-top-margin-xxl {
margin-top: $spacing-xxl;
}
.has-border-bottom-light {
border-radius: 0;
border-bottom: 1px solid $grey-light;
@ -210,9 +204,7 @@ ul.bullet {
.has-text-semibold {
font-weight: $font-weight-semibold;
}
.is-v-centered {
vertical-align: middle;
}
.has-text-grey-400 {
color: $ui-gray-400;
}

View File

@ -32,7 +32,20 @@
@onChange={{this.selectNamespace}}
@placeholder={{"Filter by namespace"}}
@displayInherit={{true}}
class="is-marginless"
/>
{{#if this.selectedNamespace}}
<SearchSelect
@id="auth-method-search-select"
@options={{this.authMethodOptions}}
@selectLimit="1"
@disallowNewItems={{true}}
@fallbackComponent="input-search"
@onChange={{this.setAuthMethod}}
@placeholder={{"Filter by auth method"}}
@displayInherit={{true}}
/>
{{/if}}
</ToolbarFilters>
</Toolbar>
</div>

View File

@ -14,13 +14,22 @@
Edit
</button>
{{else}}
<DateDropdown @handleDateSelection={{this.handleClientActivityQuery}} @name={{"startTime"}} />
<DateDropdown @handleDateSelection={{this.handleClientActivityQuery}} @name={{"startTime"}} @submitText="Save" />
{{/if}}
</div>
<p class="is-8 has-text-grey has-bottom-margin-xl">
{{this.versionText.description}}
</p>
{{#if (eq @model.config.queriesAvailable false)}}
{{#if this.licenseStartIsCurrentMonth}}
<EmptyState
@title="No data for this billing period"
@subTitle="Your billing period has just begun, so there is no data yet. Data will be available here on the first of next month."
@message="To view data from a previous billing period, you can enter your previous billing start date."
@bottomBorder={{true}}
>
<DateDropdown @handleDateSelection={{this.handleClientActivityQuery}} @name={{"startTime"}} @submitText="View" />
</EmptyState>
{{else if (eq @model.config.queriesAvailable false)}}
{{#if (eq @model.config.enabled "On")}}
<EmptyState
@title={{concat "No monthly history " (if this.noActivityDate "from ") this.noActivityDate}}
@ -74,6 +83,19 @@
@onChange={{this.selectNamespace}}
@placeholder={{"Filter by namespace"}}
@displayInherit={{true}}
class="is-marginless"
/>
{{/if}}
{{#if this.selectedNamespace}}
<SearchSelect
@id="auth-method-search-select"
@options={{this.authMethodOptions}}
@selectLimit="1"
@disallowNewItems={{true}}
@fallbackComponent="input-search"
@onChange={{this.setAuthMethod}}
@placeholder={{"Filter by auth method"}}
@displayInherit={{true}}
/>
{{/if}}
</ToolbarFilters>
@ -125,8 +147,10 @@
<EmptyState @title="No data received" @message="No data exists for that query period. Try searching again." />
{{/if}}
{{/if}}
{{else}}
{{else if (or (not @model.startTimeFromLicense) (not this.startTimeFromResponse))}}
<EmptyState @title={{this.versionText.title}} @message={{this.versionText.message}} />
{{else}}
<EmptyState @title="No data received" @message="No data exists for that query period. Try searching again." />
{{/if}}
{{/if}}
@ -155,11 +179,12 @@
<D.Content @defaultClass="popup-menu-content is-wide">
<nav class="box menu scroll">
<ul class="menu-list">
{{#each this.months as |month|}}
{{#each this.months as |month index|}}
<button
type="button"
class="link"
{{on "click" (queue (fn this.selectStartMonth month) (action D.actions.close))}}
class="button link"
disabled={{if (lt index this.allowedMonthMax) false true}}
{{on "click" (fn this.selectStartMonth month D.actions)}}
>
{{month}}
</button>
@ -183,8 +208,9 @@
{{#each this.years as |year|}}
<button
type="button"
class="link"
{{on "click" (queue (fn this.selectStartYear year) (action D.actions.close))}}
class="button link"
disabled={{if (eq year this.disabledYear) true false}}
{{on "click" (fn this.selectStartYear year D.actions)}}
>
{{year}}
</button>
@ -199,22 +225,12 @@
<button
type="button"
class="button is-primary"
onclick={{queue
(action (mut this.isEditStartMonthOpen) false)
(action "handleClientActivityQuery" this.startMonth this.startYear "startTime")
}}
disabled={{if (and this.startMonth this.startYear) false true}}
disabled={{or (if (and this.startMonth this.startYear) false true)}}
{{on "click" (fn this.handleClientActivityQuery this.startMonth this.startYear "startTime")}}
>
Save
</button>
<button
type="button"
class="button is-secondary"
{{on
"click"
(queue (action (mut this.isEditStartMonthOpen) false) (fn this.handleClientActivityQuery 0 0 "cancel"))
}}
>
<button type="button" class="button is-secondary" {{on "click" (fn this.handleClientActivityQuery 0 0 "cancel")}}>
Cancel
</button>
</footer>

View File

@ -10,11 +10,12 @@
<D.Content @defaultClass="popup-menu-content is-wide">
<nav class="box menu scroll">
<ul class="menu-list">
{{#each this.months as |month|}}
{{#each this.months as |month index|}}
<button
type="button"
class="link"
{{on "click" (queue (fn this.selectStartMonth month) (action D.actions.close))}}
class="button link"
disabled={{if (lt index this.allowedMonthMax) false true}}
{{on "click" (fn this.selectStartMonth month D.actions)}}
>
{{month}}
</button>
@ -36,7 +37,12 @@
<nav class="box menu">
<ul class="menu-list">
{{#each this.years as |year|}}
<button type="button" class="link" {{on "click" (queue (fn this.selectStartYear year) (action D.actions.close))}}>
<button
type="button"
class="button link"
disabled={{if (eq year this.disabledYear) true false}}
{{on "click" (fn this.selectStartYear year D.actions)}}
>
{{year}}
</button>
{{/each}}
@ -50,5 +56,5 @@
disabled={{if (and this.startMonth this.startYear) false true}}
{{on "click" this.saveDateSelection}}
>
Save
{{or @submitText "Submit"}}
</button>

View File

@ -1,15 +0,0 @@
<div class="has-top-margin-xxl">
<EmptyState
@title={{this.title}}
@message={{this.description}}
@icon="alert-circle"
@bottomBorder={{true}}
@subTitle={{join ". " this.auth.mfaErrors}}
class="is-box-shadowless"
>
<button type="button" class="button is-ghost is-transparent" {{on "click" this.onClose}} data-test-go-back>
<Icon @name="chevron-left" />
Go back
</button>
</EmptyState>
</div>

View File

@ -1,70 +0,0 @@
<div class="auth-form" data-test-mfa-form>
<div class="box is-marginless is-shadowless">
<p data-test-mfa-description>
{{this.description}}
</p>
<form id="auth-form" {{on "submit" this.submit}}>
<MessageError @errors={{this.errors}} class="has-top-margin-s" />
<div class="field has-top-margin-l">
{{#each this.constraints as |constraint index|}}
{{#if index}}
<hr />
{{/if}}
{{#if (gt constraint.methods.length 1)}}
<Select
@label="Multi-factor authentication method"
@options={{constraint.methods}}
@valueAttribute={{"id"}}
@labelAttribute={{"label"}}
@isFullwidth={{true}}
@noDefault={{true}}
@selectedValue={{constraint.selectedId}}
@onChange={{fn this.onSelect constraint}}
data-test-mfa-select={{index}}
/>
{{/if}}
{{#if constraint.selectedMethod.uses_passcode}}
<label for="passcode" class="is-label" data-test-mfa-passcode-label>
{{constraint.selectedMethod.label}}
</label>
<div class="control">
<Input
id="passcode"
name="passcode"
class="input"
autocomplete="off"
spellcheck="false"
autofocus="true"
disabled={{or this.validate.isRunning this.newCodeDelay.isRunning}}
@value={{constraint.passcode}}
data-test-mfa-passcode={{index}}
/>
</div>
{{/if}}
{{/each}}
</div>
{{#if this.newCodeDelay.isRunning}}
<div>
<AlertInline
@type="danger"
@sizeSmall={{true}}
@message="This code is invalid. Please wait until a new code is available."
/>
</div>
{{/if}}
<button
id="validate"
type="submit"
disabled={{or this.validate.isRunning this.newCodeDelay.isRunning}}
class="button is-primary {{if this.validate.isRunning "is-loading"}}"
data-test-mfa-validate
>
Verify
</button>
{{#if this.newCodeDelay.isRunning}}
<Icon @name="delay" class="has-text-grey" />
<span class="has-text-grey is-v-centered" data-test-mfa-countdown>{{this.countdown}}</span>
{{/if}}
</form>
</div>
</div>

View File

@ -10,10 +10,6 @@
</div>
</Nav.items>
</NavHeader>
{{! bypass UiWizard and container styling }}
{{#if this.hasAltContent}}
{{yield (hash altContent=(component "splash-page/splash-content"))}}
{{else}}
<UiWizard>
<div class="splash-page-container section is-flex-v-centered-tablet is-flex-1 is-fullwidth">
<div class="columns is-centered is-gapless is-fullwidth">
@ -32,4 +28,3 @@
</div>
</div>
</UiWizard>
{{/if}}

View File

@ -10,7 +10,7 @@
<TtlPicker2
@initialValue="1h"
@initialEnabled={{false}}
@label="Auto-rotation interval"
@label="Auto-rotation period"
@helperTextDisabled="Key will never be automatically rotated"
@helperTextEnabled="Key will be automatically rotated every"
@onChange={{@handleAutoRotateChange}}

View File

@ -18,9 +18,9 @@
</div>
<div class="field">
<TtlPicker2
@initialValue={{or @key.autoRotateInterval "1h"}}
@initialEnabled={{not (eq @key.autoRotateInterval "0s")}}
@label="Auto-rotation interval"
@initialValue={{or @key.autoRotatePeriod "1h"}}
@initialEnabled={{not (eq @key.autoRotatePeriod "0s")}}
@label="Auto-rotation period"
@helperTextDisabled="Key will never be automatically rotated"
@helperTextEnabled="Key will be automatically rotated every"
@onChange={{@handleAutoRotateChange}}

View File

@ -171,8 +171,8 @@
{{else}}
<InfoTableRow @label="Type" @value={{@key.type}} />
<InfoTableRow
@label="Auto-rotation interval"
@value={{or (format-ttl @key.autoRotateInterval removeZero=true) "Key will not be automatically rotated"}}
@label="Auto-rotation period"
@value={{or (format-duration @key.autoRotatePeriod removeZero=true) "Key will not be automatically rotated"}}
/>
<InfoTableRow @label="Deletion allowed" @value={{stringify @key.deletionAllowed}} />

View File

@ -1,26 +1,15 @@
<SplashPage @hasAltContent={{this.auth.mfaErrors}} as |Page|>
<Page.altContent>
<MfaError @onClose={{fn (mut this.mfaAuthData) null}} />
</Page.altContent>
<SplashPage as |Page|>
<Page.header>
{{#if this.oidcProvider}}
<div class="box is-shadowless is-flex-v-centered" data-test-auth-logo>
<LogoEdition aria-label="Sign in with Hashicorp Vault" />
</div>
{{else}}
<div class="is-flex-row">
{{#if this.mfaAuthData}}
<button type="button" class="icon-button" {{on "click" (fn (mut this.mfaAuthData) null)}}>
<Icon @name="arrow-left" @size="24" aria-label="Back to login" class="icon-blue" />
</button>
{{/if}}
<h1 class="title is-3">
{{if this.mfaAuthData "Authenticate" "Sign in to Vault"}}
Sign in to Vault
</h1>
</div>
{{/if}}
</Page.header>
{{#unless this.mfaAuthData}}
{{#if this.managedNamespaceRoot}}
<Page.sub-header>
<Toolbar>
@ -82,20 +71,14 @@
</Toolbar>
</Page.sub-header>
{{/if}}
{{/unless}}
<Page.content>
{{#if this.mfaAuthData}}
<MfaForm @clusterId={{this.model.id}} @authData={{this.mfaAuthData}} @onSuccess={{action "onMfaSuccess"}} />
{{else}}
<AuthForm
@wrappedToken={{this.wrappedToken}}
@cluster={{this.model}}
@namespace={{this.namespaceQueryParam}}
@redirectTo={{this.redirectTo}}
@selectedAuth={{this.authMethod}}
@onSuccess={{action "onAuthResponse"}}
/>
{{/if}}
</Page.content>
<Page.footer>
<div class="has-short-padding">

View File

@ -0,0 +1,29 @@
{{#if (eq @model.httpStatus 404)}}
<NotFound @model={{this.model}} />
{{else}}
<EmptyState
@title={{if (eq @model.httpStatus 403) "You are not authorized" "Error"}}
@subTitle={{concat "Error " @model.httpStatus}}
@icon="skip"
>
{{#if (eq @model.httpStatus 403)}}
<p>
You must be granted permissions to view this page. Ask your administrator if you think you should have access to the
<code>{{@model.path}}</code>
endpoint.
</p>
{{else}}
<ul>
{{#if @model.message}}
<li>{{@model.message}}</li>
{{/if}}
<hr />
{{#each @model.errors as |error|}}
<li>
{{error}}
</li>
{{/each}}
</ul>
{{/if}}
</EmptyState>
{{/if}}

View File

@ -10,16 +10,15 @@ import layout from '../templates/components/select';
* <Select @label='Date Range' @options={{[{ value: 'berry', label: 'Berry' }]}} @onChange={{onChange}}/>
* ```
*
* @param {string} [label=null] - The label for the select element.
* @param {Array} [options=null] - A list of items that the user will select from. This can be an array of strings or objects.
* @param {string} [selectedValue=null] - The currently selected item. Can also be used to set the default selected item. This should correspond to the `value` of one of the `<option>`s.
* @param {string} [name = null] - The name of the select, used for the test selector.
* @param {string} [valueAttribute = value]- When `options` is an array objects, the key to check for when assigning the option elements value.
* @param {string} [labelAttribute = label] - When `options` is an array objects, the key to check for when assigning the option elements' inner text.
* @param {boolean} [isInline = false] - Whether or not the select should be displayed as inline-block or block.
* @param {boolean} [isFullwidth = false] - Whether or not the select should take up the full width of the parent element.
* @param {boolean} [noDefault = false] - shows Select One with empty value as first option
* @param {Func} [onChange] - The action to take once the user has selected an item. This method will be passed the `value` of the select.
* @param label=null {String} - The label for the select element.
* @param options=null {Array} - A list of items that the user will select from. This can be an array of strings or objects.
* @param [selectedValue=null] {String} - The currently selected item. Can also be used to set the default selected item. This should correspond to the `value` of one of the `<option>`s.
* @param [name=null] {String} - The name of the select, used for the test selector.
* @param [valueAttribute=value] {String} - When `options` is an array objects, the key to check for when assigning the option elements value.
* @param [labelAttribute=label] {String} - When `options` is an array objects, the key to check for when assigning the option elements' inner text.
* @param [isInline=false] {Bool} - Whether or not the select should be displayed as inline-block or block.
* @param [isFullwidth=false] {Bool} - Whether or not the select should take up the full width of the parent element.
* @param onChange=null {Func} - The action to take once the user has selected an item. This method will be passed the `value` of the select.
*/
export default Component.extend({
@ -33,6 +32,5 @@ export default Component.extend({
labelAttribute: 'label',
isInline: false,
isFullwidth: false,
noDefault: false,
onChange() {},
});

View File

@ -11,11 +11,6 @@
onchange={{action this.onChange value="target.value"}}
data-test-select={{this.name}}
>
{{#if this.noDefault}}
<option value="">
Select one
</option>
{{/if}}
{{#each this.options as |op|}}
<option
value={{or (get op this.valueAttribute) op}}

View File

@ -1,12 +0,0 @@
import { Factory } from 'ember-cli-mirage';
export default Factory.extend({
type: 'okta',
uses_passcode: false,
afterCreate(mfaMethod) {
if (mfaMethod.type === 'totp') {
mfaMethod.uses_passcode = true;
}
},
});

View File

@ -20,10 +20,15 @@ export default function (server) {
};
});
server.get('sys/internal/counters/config', function (db) {
server.get('sys/internal/counters/config', function () {
return {
request_id: '00001',
data: db['clients/configs'].first(),
data: {
default_report_months: 12,
enabled: 'default-enable',
queries_available: true,
retention_months: 24,
},
};
});
@ -5573,6 +5578,24 @@ export default function (server) {
non_entity_tokens: 15,
clients: 100,
},
mounts: [
{
path: 'auth/method/uMGBU',
counts: {
clients: 35,
entity_clients: 20,
non_entity_clients: 15,
},
},
{
path: 'auth/method/woiej',
counts: {
clients: 35,
entity_clients: 20,
non_entity_clients: 15,
},
},
],
},
{
namespace_id: 'RxD81',
@ -5582,6 +5605,24 @@ export default function (server) {
non_entity_tokens: 20,
clients: 55,
},
mounts: [
{
path: 'auth/method/ABCD1',
counts: {
clients: 35,
entity_clients: 20,
non_entity_clients: 15,
},
},
{
path: 'auth/method/ABCD2',
counts: {
clients: 35,
entity_clients: 20,
non_entity_clients: 15,
},
},
],
},
{
namespace_id: 'root',
@ -5591,6 +5632,24 @@ export default function (server) {
non_entity_tokens: 8,
clients: 20,
},
mounts: [
{
path: 'auth/method/XYZZ2',
counts: {
clients: 35,
entity_clients: 20,
non_entity_clients: 15,
},
},
{
path: 'auth/method/XYZZ1',
counts: {
clients: 35,
entity_clients: 20,
non_entity_clients: 15,
},
},
],
},
],
distinct_entities: 132,

View File

@ -1,7 +1,6 @@
// add all handlers here
// individual lookup done in mirage config
import base from './base';
import mfa from './mfa';
import activity from './activity';
export { base, activity, mfa };
export { base, activity };

View File

@ -1,146 +0,0 @@
import { Response } from 'miragejs';
import Ember from 'ember';
import fetch from 'fetch';
export default function (server) {
// initial auth response cache -- lookup by mfa_request_id key
const authResponses = {};
// mfa requirement cache -- lookup by mfa_request_id key
const mfaRequirement = {};
// generate different constraint scenarios and return mfa_requirement object
const generateMfaRequirement = (req, res) => {
const { user } = req.params;
// uses_passcode automatically set to true in factory for totp type
const m = (type, uses_passcode = false) => server.create('mfa-method', { type, uses_passcode });
let mfa_constraints = {};
let methods = []; // flat array of methods for easy lookup during validation
function generator() {
const methods = [];
const constraintObj = [...arguments].reduce((obj, methodArray, index) => {
obj[`test_${index}`] = { any: methodArray };
methods.push(...methodArray);
return obj;
}, {});
return [constraintObj, methods];
}
if (user === 'mfa-a') {
[mfa_constraints, methods] = generator([m('totp')]); // 1 constraint 1 passcode
} else if (user === 'mfa-b') {
[mfa_constraints, methods] = generator([m('okta')]); // 1 constraint 1 non-passcode
} else if (user === 'mfa-c') {
[mfa_constraints, methods] = generator([m('totp'), m('duo', true)]); // 1 constraint 2 passcodes
} else if (user === 'mfa-d') {
[mfa_constraints, methods] = generator([m('okta'), m('duo')]); // 1 constraint 2 non-passcode
} else if (user === 'mfa-e') {
[mfa_constraints, methods] = generator([m('okta'), m('totp')]); // 1 constraint 1 passcode 1 non-passcode
} else if (user === 'mfa-f') {
[mfa_constraints, methods] = generator([m('totp')], [m('duo', true)]); // 2 constraints 1 passcode for each
} else if (user === 'mfa-g') {
[mfa_constraints, methods] = generator([m('okta')], [m('duo')]); // 2 constraints 1 non-passcode for each
} else if (user === 'mfa-h') {
[mfa_constraints, methods] = generator([m('totp')], [m('okta')]); // 2 constraints 1 passcode 1 non-passcode
} else if (user === 'mfa-i') {
[mfa_constraints, methods] = generator([m('okta'), m('totp')], [m('totp')]); // 2 constraints 1 passcode/1 non-passcode 1 non-passcode
}
const numbers = (length) =>
Math.random()
.toString()
.substring(2, length + 2);
const mfa_request_id = `${numbers(8)}-${numbers(4)}-${numbers(4)}-${numbers(4)}-${numbers(12)}`;
const mfa_requirement = {
mfa_request_id,
mfa_constraints,
};
// cache mfa requests to test different validation scenarios
mfaRequirement[mfa_request_id] = { methods };
// cache auth response to be returned later by sys/mfa/validate
authResponses[mfa_request_id] = { ...res };
return mfa_requirement;
};
// passthrough original request, cache response and return mfa stub
const passthroughLogin = async (schema, req) => {
// test totp not configured scenario
if (req.params.user === 'totp-na') {
return new Response(400, {}, { errors: ['TOTP mfa required but not configured'] });
}
const mock = req.params.user ? req.params.user.includes('mfa') : null;
// bypass mfa for users that do not match type
if (!mock) {
req.passthrough();
} else if (Ember.testing) {
// use root token in test environment
const res = await fetch('/v1/auth/token/lookup-self', { headers: { 'X-Vault-Token': 'root' } });
if (res.status < 300) {
const json = res.json();
if (Ember.testing) {
json.auth = {
...json.data,
policies: [],
metadata: { username: 'foobar' },
};
json.data = null;
}
return { auth: { mfa_requirement: generateMfaRequirement(req, json) } };
}
return new Response(500, {}, { errors: ['Mirage error fetching root token in testing'] });
} else {
const xhr = req.passthrough();
xhr.onreadystatechange = () => {
if (xhr.readyState === 4 && xhr.status < 300) {
// XMLHttpRequest response prop only has a getter -- redefine as writable and set value
Object.defineProperty(xhr, 'response', {
writable: true,
value: JSON.stringify({
auth: { mfa_requirement: generateMfaRequirement(req, JSON.parse(xhr.responseText)) },
}),
});
}
};
}
};
server.post('/auth/:method/login/:user', passthroughLogin);
server.post('/sys/mfa/validate', (schema, req) => {
try {
const { mfa_request_id, mfa_payload } = JSON.parse(req.requestBody);
const mfaRequest = mfaRequirement[mfa_request_id];
if (!mfaRequest) {
return new Response(404, {}, { errors: ['MFA Request ID not found'] });
}
// validate request body
for (let constraintId in mfa_payload) {
// ensure ids were passed in map
const method = mfaRequest.methods.find(({ id }) => id === constraintId);
if (!method) {
return new Response(
400,
{},
{ errors: [`Invalid MFA constraint id ${constraintId} passed in map`] }
);
}
// test non-totp validation by rejecting all pingid requests
if (method.type === 'pingid') {
return new Response(403, {}, { errors: ['PingId MFA validation failed'] });
}
// validate totp passcode
const passcode = mfa_payload[constraintId][0];
if (method.uses_passcode) {
if (passcode !== 'test') {
const error = !passcode ? 'TOTP passcode not provided' : 'Incorrect TOTP passcode provided';
return new Response(403, {}, { errors: [error] });
}
} else if (passcode) {
// for okta and duo, reject if a passcode was provided
return new Response(400, {}, { errors: ['Passcode should only be provided for TOTP MFA type'] });
}
}
return authResponses[mfa_request_id];
} catch (error) {
console.log(error);
return new Response(500, {}, { errors: ['Mirage Handler Error: /sys/mfa/validate'] });
}
});
}

View File

@ -3,21 +3,25 @@
## AuthForm
The `AuthForm` is used to sign users into Vault.
**Params**
| Param | Type | Description |
| --- | --- | --- |
| wrappedToken | <code>string</code> | The auth method that is currently selected in the dropdown. |
| cluster | <code>object</code> | The auth method that is currently selected in the dropdown. This corresponds to an Ember Model. |
| namespace- | <code>string</code> | The currently active namespace. |
| selectedAuth | <code>string</code> | The auth method that is currently selected in the dropdown. |
| onSuccess | <code>function</code> | Fired on auth success |
| Param | Type | Default | Description |
| --- | --- | --- | --- |
| wrappedToken | <code>String</code> | <code></code> | The auth method that is currently selected in the dropdown. |
| cluster | <code>Object</code> | <code></code> | The auth method that is currently selected in the dropdown. This corresponds to an Ember Model. |
| namespace | <code>String</code> | <code></code> | The currently active namespace. |
| redirectTo | <code>String</code> | <code></code> | The name of the route to redirect to. |
| selectedAuth | <code>String</code> | <code></code> | The auth method that is currently selected in the dropdown. |
**Example**
```js
// All properties are passed in via query params.
<AuthForm @wrappedToken={{wrappedToken}} @cluster={{model}} @namespace={{namespaceQueryParam}} @selectedAuth={{authMethod}} @onSuccess={{action this.onSuccess}} />```
<AuthForm
@wrappedToken={{wrappedToken}}
@cluster={{model}}
@namespace={{namespaceQueryParam}}
@redirectTo={{redirectTo}}
@selectedAuth={{authMethod}}/>```
**See**

View File

@ -110,10 +110,16 @@ module('Acceptance | auth', function (hooks) {
assert.dom('[data-test-allow-expiration]').doesNotExist('hides beacon when the api is used again');
});
test('it shows the push notification warning after submit', async function (assert) {
test('it shows the push notification warning only for okta auth method after submit', async function (assert) {
await visit('/vault/auth');
await component.selectMethod('token');
await click('[data-test-auth-submit]');
assert
.dom('[data-test-auth-message="push"]')
.doesNotExist('message is not shown for other authentication methods');
await component.selectMethod('okta');
await click('[data-test-auth-submit]');
assert.dom('[data-test-auth-message="push"]').exists('shows push notification message');
});
});

View File

@ -1,135 +0,0 @@
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { click, currentRouteName, fillIn, visit } from '@ember/test-helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import ENV from 'vault/config/environment';
ENV['ember-cli-mirage'].handler = 'mfa';
module('Acceptance | mfa', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.select = async (select = 0, option = 1) => {
const selector = `[data-test-mfa-select="${select}"]`;
const value = this.element.querySelector(`${selector} option:nth-child(${option + 1})`).value;
await fillIn(`${selector} select`, value);
};
});
const login = async (user) => {
// MfaHandler(server);
await visit('/vault/auth');
await fillIn('[data-test-select="auth-method"]', 'userpass');
await fillIn('[data-test-username]', user);
await fillIn('[data-test-password]', 'test');
await click('[data-test-auth-submit]');
};
const didLogin = (assert) => {
assert.equal(currentRouteName(), 'vault.cluster.secrets.backends', 'Route transitions after login');
};
const validate = async (multi) => {
await fillIn('[data-test-mfa-passcode="0"]', 'test');
if (multi) {
await fillIn('[data-test-mfa-passcode="1"]', 'test');
}
await click('[data-test-mfa-validate]');
};
test('it should handle single mfa constraint with passcode method', async function (assert) {
await login('mfa-a');
assert
.dom('[data-test-mfa-description]')
.includesText(
'Enter your authentication code to log in.',
'Mfa form displays with correct description'
);
assert.dom('[data-test-mfa-select]').doesNotExist('Select is hidden for single method');
assert.dom('[data-test-mfa-passcode]').exists({ count: 1 }, 'Single passcode input renders');
await validate();
didLogin(assert);
});
test('it should handle single mfa constraint with push method', async function (assert) {
await login('mfa-b');
didLogin(assert);
});
test('it should handle single mfa constraint with 2 passcode methods', async function (assert) {
await login('mfa-c');
assert
.dom('[data-test-mfa-description]')
.includesText('Select the MFA method you wish to use.', 'Mfa form displays with correct description');
assert
.dom('[data-test-mfa-select]')
.exists({ count: 1 }, 'Select renders for single constraint with multiple methods');
assert.dom('[data-test-mfa-passcode]').doesNotExist('Passcode input hidden until selection is made');
await this.select();
await validate();
didLogin(assert);
});
test('it should handle single mfa constraint with 2 push methods', async function (assert) {
await login('mfa-d');
await this.select();
await click('[data-test-mfa-validate]');
didLogin(assert);
});
test('it should handle single mfa constraint with 1 passcode and 1 push method', async function (assert) {
await login('mfa-e');
await this.select(0, 2);
assert.dom('[data-test-mfa-passcode]').exists('Passcode input renders');
await this.select();
assert.dom('[data-test-mfa-passcode]').doesNotExist('Passcode input is hidden for push method');
await click('[data-test-mfa-validate]');
didLogin(assert);
});
test('it should handle multiple mfa constraints with 1 passcode method each', async function (assert) {
await login('mfa-f');
assert
.dom('[data-test-mfa-description]')
.includesText(
'Two methods are required for successful authentication.',
'Mfa form displays with correct description'
);
assert.dom('[data-test-mfa-select]').doesNotExist('Selects do not render for single methods');
await validate(true);
didLogin(assert);
});
test('it should handle multi mfa constraint with 1 push method each', async function (assert) {
await login('mfa-g');
didLogin(assert);
});
test('it should handle multiple mfa constraints with 1 passcode and 1 push method', async function (assert) {
await login('mfa-h');
assert
.dom('[data-test-mfa-description]')
.includesText(
'Two methods are required for successful authentication.',
'Mfa form displays with correct description'
);
assert.dom('[data-test-mfa-select]').doesNotExist('Select is hidden for single method');
assert.dom('[data-test-mfa-passcode]').exists({ count: 1 }, 'Passcode input renders');
await validate();
didLogin(assert);
});
test('it should handle multiple mfa constraints with multiple mixed methods', async function (assert) {
await login('mfa-i');
assert
.dom('[data-test-mfa-description]')
.includesText(
'Two methods are required for successful authentication.',
'Mfa form displays with correct description'
);
await this.select();
await fillIn('[data-test-mfa-passcode="1"]', 'test');
await click('[data-test-mfa-validate]');
didLogin(assert);
});
});

View File

@ -18,7 +18,6 @@ const authService = Service.extend({
async authenticate() {
return fetch('http://localhost:2000');
},
handleError() {},
setLastFetch() {},
});
@ -26,7 +25,6 @@ const workingAuthService = Service.extend({
authenticate() {
return resolve({});
},
handleError() {},
setLastFetch() {},
});

View File

@ -1,38 +0,0 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { click } from '@ember/test-helpers';
import { TOTP_NOT_CONFIGURED } from 'vault/services/auth';
import { TOTP_NA_MSG, MFA_ERROR_MSG } from 'vault/components/mfa-error';
const UNAUTH = 'MFA authorization failed';
module('Integration | Component | mfa-error', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
const auth = this.owner.lookup('service:auth');
auth.set('mfaErrors', [TOTP_NOT_CONFIGURED]);
this.onClose = () => assert.ok(true, 'onClose event is triggered');
await render(hbs`<MfaError @onClose={{this.onClose}}/>`);
assert.dom('[data-test-empty-state-title]').hasText('TOTP not set up', 'Title renders for TOTP error');
assert
.dom('[data-test-empty-state-subText]')
.hasText(TOTP_NOT_CONFIGURED, 'Error message renders for TOTP error');
assert.dom('[data-test-empty-state-message]').hasText(TOTP_NA_MSG, 'Description renders for TOTP error');
auth.set('mfaErrors', [UNAUTH]);
await render(hbs`<MfaError @onClose={{this.onClose}}/>`);
assert.dom('[data-test-empty-state-title]').hasText('Unauthorized', 'Title renders for mfa error');
assert.dom('[data-test-empty-state-subText]').hasText(UNAUTH, 'Error message renders for mfa error');
assert.dom('[data-test-empty-state-message]').hasText(MFA_ERROR_MSG, 'Description renders for mfa error');
await click('[data-test-go-back]');
assert.equal(auth.mfaErrors, null, 'mfaErrors unset in auth service');
});
});

View File

@ -1,190 +0,0 @@
import { module, test, skip } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { fillIn, click, waitUntil } from '@ember/test-helpers';
import { run, later } from '@ember/runloop';
module('Integration | Component | mfa-form', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.clusterId = '123456';
this.mfaAuthData = {
backend: 'userpass',
data: { username: 'foo', password: 'bar' },
};
this.authService = this.owner.lookup('service:auth');
});
test('it should render correct descriptions', async function (assert) {
const totpConstraint = this.server.create('mfa-method', { type: 'totp' });
const oktaConstraint = this.server.create('mfa-method', { type: 'okta' });
const duoConstraint = this.server.create('mfa-method', { type: 'duo' });
this.mfaAuthData.mfa_requirement = this.authService._parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: { test_mfa_1: { any: [totpConstraint] } },
}).mfa_requirement;
await render(hbs`<MfaForm @clusterId={{this.clusterId}} @authData={{this.mfaAuthData}} />`);
assert
.dom('[data-test-mfa-description]')
.includesText(
'Enter your authentication code to log in.',
'Correct description renders for single passcode'
);
this.mfaAuthData.mfa_requirement = this.authService._parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: { test_mfa_1: { any: [duoConstraint, oktaConstraint] } },
}).mfa_requirement;
await render(hbs`<MfaForm @clusterId={{this.clusterId}} @authData={{this.mfaAuthData}} />`);
assert
.dom('[data-test-mfa-description]')
.includesText(
'Select the MFA method you wish to use.',
'Correct description renders for multiple methods'
);
this.mfaAuthData.mfa_requirement = this.authService._parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: { test_mfa_1: { any: [oktaConstraint] }, test_mfa_2: { any: [duoConstraint] } },
}).mfa_requirement;
await render(hbs`<MfaForm @clusterId={{this.clusterId}} @authData={{this.mfaAuthData}} />`);
assert
.dom('[data-test-mfa-description]')
.includesText(
'Two methods are required for successful authentication.',
'Correct description renders for multiple constraints'
);
});
test('it should render method selects and passcode inputs', async function (assert) {
const duoConstraint = this.server.create('mfa-method', { type: 'duo', uses_passcode: true });
const oktaConstraint = this.server.create('mfa-method', { type: 'okta' });
const pingidConstraint = this.server.create('mfa-method', { type: 'pingid' });
const { mfa_requirement } = this.authService._parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: {
test_mfa_1: {
any: [pingidConstraint, oktaConstraint],
},
test_mfa_2: {
any: [duoConstraint],
},
},
});
this.mfaAuthData.mfa_requirement = mfa_requirement;
this.server.post('/sys/mfa/validate', (schema, req) => {
const json = JSON.parse(req.requestBody);
const payload = {
mfa_request_id: 'test-mfa-id',
mfa_payload: { [oktaConstraint.id]: [], [duoConstraint.id]: ['test-code'] },
};
assert.deepEqual(json, payload, 'Correct mfa payload passed to validate endpoint');
return {};
});
this.owner.lookup('service:auth').reopen({
// override to avoid authSuccess method since it expects an auth payload
async totpValidate({ mfa_requirement }) {
await this.clusterAdapter().mfaValidate(mfa_requirement);
return 'test response';
},
});
this.onSuccess = (resp) =>
assert.equal(resp, 'test response', 'Response is returned in onSuccess callback');
await render(hbs`
<MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onSuccess={{this.onSuccess}}
/>
`);
await fillIn('[data-test-mfa-select="0"] select', oktaConstraint.id);
await fillIn('[data-test-mfa-passcode="1"]', 'test-code');
await click('[data-test-mfa-validate]');
});
test('it should validate mfa requirement', async function (assert) {
const totpConstraint = this.server.create('mfa-method', { type: 'totp' });
const { mfa_requirement } = this.authService._parseMfaResponse({
mfa_request_id: 'test-mfa-id',
mfa_constraints: {
test_mfa: {
any: [totpConstraint],
},
},
});
this.mfaAuthData.mfa_requirement = mfa_requirement;
this.server.post('/sys/mfa/validate', (schema, req) => {
const json = JSON.parse(req.requestBody);
const payload = {
mfa_request_id: 'test-mfa-id',
mfa_payload: { [totpConstraint.id]: ['test-code'] },
};
assert.deepEqual(json, payload, 'Correct mfa payload passed to validate endpoint');
return {};
});
const expectedAuthData = { clusterId: this.clusterId, ...this.mfaAuthData };
this.owner.lookup('service:auth').reopen({
// override to avoid authSuccess method since it expects an auth payload
async totpValidate(authData) {
await waitUntil(() =>
assert.dom('[data-test-mfa-validate]').hasClass('is-loading', 'Loading class applied to button')
);
assert.dom('[data-test-mfa-validate]').isDisabled('Button is disabled while loading');
assert.deepEqual(authData, expectedAuthData, 'Mfa auth data passed to validate method');
await this.clusterAdapter().mfaValidate(authData.mfa_requirement);
return 'test response';
},
});
this.onSuccess = (resp) =>
assert.equal(resp, 'test response', 'Response is returned in onSuccess callback');
await render(hbs`
<MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onSuccess={{this.onSuccess}}
/>
`);
await fillIn('[data-test-mfa-passcode]', 'test-code');
await click('[data-test-mfa-validate]');
});
// commented out in component until specific error code can be parsed from the api response
skip('it should show countdown on passcode validation failure', async function (assert) {
this.owner.lookup('service:auth').reopen({
totpValidate() {
throw new Error('Incorrect passcode');
},
});
await render(hbs`
<MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
/>
`);
await fillIn('[data-test-mfa-passcode]', 'test-code');
later(() => run.cancelTimers(), 50);
await click('[data-test-mfa-validate]');
assert.dom('[data-test-mfa-validate]').isDisabled('Button is disabled during countdown');
assert.dom('[data-test-mfa-passcode]').isDisabled('Input is disabled during countdown');
assert.dom('[data-test-mfa-passcode]').hasNoValue('Input value is cleared on error');
assert.dom('[data-test-inline-error-message]').exists('Alert message renders');
assert.dom('[data-test-mfa-countdown]').exists('30 second countdown renders');
});
});

View File

@ -6,6 +6,14 @@ import { hbs } from 'ember-cli-htmlbars';
module('Integration | Helper | format-ttl', function (hooks) {
setupRenderingTest(hooks);
test('it does not fail if no input', async function (assert) {
this.set('inputValue', '');
await render(hbs`{{format-ttl inputValue}}`);
assert.equal(this.element.textContent.trim(), '');
});
test('it renders the input if no match found', async function (assert) {
this.set('inputValue', '1234');

View File

@ -10,8 +10,6 @@ export default create({
// make sure we're always logged out and logged back in
await this.logout();
await settled();
// clear local storage to ensure we have a clean state
window.localStorage.clear();
await this.visit({ with: 'token' });
await settled();
if (token) {

View File

@ -424,10 +424,15 @@ func (c *Core) taintCredEntry(ctx context.Context, path string, updateStorage bo
c.authLock.Lock()
defer c.authLock.Unlock()
ns, err := namespace.FromContext(ctx)
if err != nil {
return err
}
// Taint the entry from the auth table
// We do this on the original since setting the taint operates
// on the entries which a shallow clone shares anyways
entry, err := c.auth.setTaint(ctx, strings.TrimPrefix(path, credentialRoutePrefix), true, mountStateUnmounting)
entry, err := c.auth.setTaint(ns.ID, strings.TrimPrefix(path, credentialRoutePrefix), true, mountStateUnmounting)
if err != nil {
return err
}

View File

@ -311,6 +311,10 @@ type Core struct {
// change underneath a calling function
mountsLock sync.RWMutex
// mountMigrationTracker tracks past and ongoing remount operations
// against their migration ids
mountMigrationTracker *sync.Map
// auth is loaded after unseal since it is a protected
// configuration
auth *MountTable
@ -855,6 +859,7 @@ func CreateCore(conf *CoreConfig) (*Core, error) {
disableAutopilot: conf.DisableAutopilot,
enableResponseHeaderHostname: conf.EnableResponseHeaderHostname,
enableResponseHeaderRaftNodeID: conf.EnableResponseHeaderRaftNodeID,
mountMigrationTracker: &sync.Map{},
disableSSCTokens: conf.DisableSSCTokens,
}
c.standbyStopCh.Store(make(chan struct{}))

View File

@ -178,7 +178,7 @@ func NewSystemBackend(core *Core, logger log.Logger) *SystemBackend {
b.Backend.Paths = append(b.Backend.Paths, b.capabilitiesPaths()...)
b.Backend.Paths = append(b.Backend.Paths, b.internalPaths()...)
b.Backend.Paths = append(b.Backend.Paths, b.pprofPaths()...)
b.Backend.Paths = append(b.Backend.Paths, b.remountPath())
b.Backend.Paths = append(b.Backend.Paths, b.remountPaths()...)
b.Backend.Paths = append(b.Backend.Paths, b.metricsPath())
b.Backend.Paths = append(b.Backend.Paths, b.monitorPath())
b.Backend.Paths = append(b.Backend.Paths, b.inFlightRequestPath())
@ -1199,11 +1199,33 @@ func (b *SystemBackend) handleRemount(ctx context.Context, req *logical.Request,
logical.ErrInvalidRequest
}
if err = validateMountPath(toPath); err != nil {
return handleError(fmt.Errorf("'to' %v", err))
fromPathDetails := b.Core.splitNamespaceAndMountFromPath(ns.Path, fromPath)
toPathDetails := b.Core.splitNamespaceAndMountFromPath(ns.Path, toPath)
if err = validateMountPath(toPathDetails.MountPath); err != nil {
return handleError(fmt.Errorf("invalid destination mount: %v", err))
}
entry := b.Core.router.MatchingMountEntry(ctx, fromPath)
// Prevent target and source mounts from being in a protected path
for _, p := range protectedMounts {
if strings.HasPrefix(fromPathDetails.MountPath, p) {
return handleError(fmt.Errorf("cannot remount %q", fromPathDetails.MountPath))
}
if strings.HasPrefix(toPathDetails.MountPath, p) {
return handleError(fmt.Errorf("cannot remount to destination %+v", toPathDetails.MountPath))
}
}
entry := b.Core.router.MatchingMountEntry(ctx, sanitizePath(fromPath))
if entry == nil {
return handleError(fmt.Errorf("no matching mount at %q", sanitizePath(fromPath)))
}
if match := b.Core.router.MatchingMount(ctx, toPath); match != "" {
return handleError(fmt.Errorf("existing mount at %q", match))
}
// If we are a performance secondary cluster we should forward the request
// to the primary. We fail early here since the view in use isn't marked as
// readonly
@ -1211,31 +1233,76 @@ func (b *SystemBackend) handleRemount(ctx context.Context, req *logical.Request,
return nil, logical.ErrReadOnly
}
migrationID, err := b.Core.createMigrationStatus(fromPathDetails, toPathDetails)
if err != nil {
return nil, fmt.Errorf("Error creating migration status %+v", err)
}
// Start up a goroutine to handle the remount operations, and return early to the caller
go func(migrationID string) {
b.Core.stateLock.RLock()
defer b.Core.stateLock.RUnlock()
logger := b.Core.Logger().Named("mounts.migration").With("migration_id", migrationID, "namespace", ns.Path, "to_path", toPath, "from_path", fromPath)
var err error
if !strings.Contains(fromPath, "auth") {
err = b.moveSecretsEngine(ns, logger, migrationID, entry.ViewPath(), fromPathDetails, toPathDetails)
} else {
logger.Error("Remount is unsupported for the source mount", "err", err)
}
if err != nil {
logger.Error("remount failed", "error", err)
if err := b.Core.setMigrationStatus(migrationID, MigrationFailureStatus); err != nil {
logger.Error("Setting migration status failed", "error", err, "target_status", MigrationFailureStatus)
}
}
}(migrationID)
resp := &logical.Response{
Data: map[string]interface{}{
"migration_id": migrationID,
},
}
resp.AddWarning("Mount move has been queued. Progress will be reported in Vault's server log, tagged with the returned migration_id")
return resp, nil
}
// moveSecretsEngine carries out a remount operation on the secrets engine, updating the migration status as required
// It is expected to be called asynchronously outside of a request context, hence it creates a context derived from the active one
// and intermittently checks to see if it is still open.
func (b *SystemBackend) moveSecretsEngine(ns *namespace.Namespace, logger log.Logger, migrationID, viewPath string, fromPathDetails, toPathDetails namespace.MountPathDetails) error {
logger.Info("Starting to update the mount table and revoke leases")
revokeCtx := namespace.ContextWithNamespace(b.Core.activeContext, ns)
// Attempt remount
if err := b.Core.remount(ctx, fromPath, toPath, !b.Core.perfStandby); err != nil {
b.Backend.Logger().Error("remount failed", "from_path", fromPath, "to_path", toPath, "error", err)
return handleError(err)
if err := b.Core.remountSecretsEngine(revokeCtx, fromPathDetails, toPathDetails, !b.Core.perfStandby); err != nil {
return err
}
// Get the view path if available
var viewPath string
if entry != nil {
viewPath = entry.ViewPath()
if err := revokeCtx.Err(); err != nil {
return err
}
logger.Info("Removing the source mount from filtered paths on secondaries")
// Remove from filtered mounts and restart evaluation process
if err := b.Core.removePathFromFilteredPaths(ctx, ns.Path+fromPath, viewPath); err != nil {
b.Backend.Logger().Error("filtered path removal failed", fromPath, "error", err)
return handleError(err)
if err := b.Core.removePathFromFilteredPaths(revokeCtx, fromPathDetails.GetFullPath(), viewPath); err != nil {
return err
}
// Update quotas with the new path
if err := b.Core.quotaManager.HandleRemount(ctx, ns.Path, sanitizePath(fromPath), sanitizePath(toPath)); err != nil {
b.Core.logger.Error("failed to update quotas after remount", "ns_path", ns.Path, "from_path", fromPath, "to_path", toPath, "error", err)
return handleError(err)
if err := revokeCtx.Err(); err != nil {
return err
}
return nil, nil
logger.Info("Updating quotas associated with the source mount")
// Update quotas with the new path and namespace
if err := b.Core.quotaManager.HandleRemount(revokeCtx, fromPathDetails, toPathDetails); err != nil {
return err
}
if err := b.Core.setMigrationStatus(migrationID, MigrationSuccessStatus); err != nil {
return err
}
logger.Info("Completed mount move operations")
return nil
}
// handleAuthTuneRead is used to get config settings on a auth path
@ -1249,6 +1316,34 @@ func (b *SystemBackend) handleAuthTuneRead(ctx context.Context, req *logical.Req
return b.handleTuneReadCommon(ctx, "auth/"+path)
}
func (b *SystemBackend) handleRemountStatusCheck(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
repState := b.Core.ReplicationState()
migrationID := data.Get("migration_id").(string)
if migrationID == "" {
return logical.ErrorResponse(
"migrationID must be specified"),
logical.ErrInvalidRequest
}
migrationInfo := b.Core.readMigrationStatus(migrationID)
if migrationInfo == nil {
// If the migration info is not found and this is a perf secondary
// forward the request to the primary cluster
if repState.HasState(consts.ReplicationPerformanceSecondary) {
return nil, logical.ErrReadOnly
}
return nil, nil
}
resp := &logical.Response{
Data: map[string]interface{}{
"migration_id": migrationID,
"migration_info": migrationInfo,
},
}
return resp, nil
}
// handleMountTuneRead is used to get config settings on a backend
func (b *SystemBackend) handleMountTuneRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
path := data.Get("path").(string)
@ -4519,7 +4614,7 @@ in the plugin catalog.`,
},
"remount": {
"Move the mount point of an already-mounted backend.",
"Move the mount point of an already-mounted backend, within or across namespaces",
`
This path responds to the following HTTP methods.
@ -4528,6 +4623,15 @@ This path responds to the following HTTP methods.
`,
},
"remount-status": {
"Check the status of a mount move operation",
`
This path responds to the following HTTP methods.
GET /sys/remount/status/:migration_id
Check the status of a mount move operation for the given migration_id
`,
},
"auth_tune": {
"Tune the configuration parameters for an auth path.",
`Read and write the 'default-lease-ttl' and 'max-lease-ttl' values of

View File

@ -1308,8 +1308,9 @@ func (b *SystemBackend) leasePaths() []*framework.Path {
}
}
func (b *SystemBackend) remountPath() *framework.Path {
return &framework.Path{
func (b *SystemBackend) remountPaths() []*framework.Path {
return []*framework.Path{
{
Pattern: "remount",
Fields: map[string]*framework.FieldSchema{
@ -1323,12 +1324,34 @@ func (b *SystemBackend) remountPath() *framework.Path {
},
},
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.UpdateOperation: b.handleRemount,
Operations: map[logical.Operation]framework.OperationHandler{
logical.UpdateOperation: &framework.PathOperation{
Callback: b.handleRemount,
Summary: "Initiate a mount migration",
},
},
HelpSynopsis: strings.TrimSpace(sysHelp["remount"][0]),
HelpDescription: strings.TrimSpace(sysHelp["remount"][1]),
},
{
Pattern: "remount/status/(?P<migration_id>.+?)$",
Fields: map[string]*framework.FieldSchema{
"migration_id": {
Type: framework.TypeString,
Description: "The ID of the migration operation",
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{
Callback: b.handleRemountStatusCheck,
Summary: "Check status of a mount migration",
},
},
HelpSynopsis: strings.TrimSpace(sysHelp["remount-status"][0]),
HelpDescription: strings.TrimSpace(sysHelp["remount-status"][1]),
},
}
}

View File

@ -212,10 +212,10 @@ func (b *SystemBackend) handleRateLimitQuotasUpdate() framework.OperationFunc {
case quota == nil:
quota = quotas.NewRateLimitQuota(name, ns.Path, mountPath, rate, interval, blockInterval)
default:
rlq := quota.(*quotas.RateLimitQuota)
// Re-inserting the already indexed object in memdb might cause problems.
// So, clone the object. See https://github.com/hashicorp/go-memdb/issues/76.
rlq = rlq.Clone()
clonedQuota := quota.Clone()
rlq := clonedQuota.(*quotas.RateLimitQuota)
rlq.NamespacePath = ns.Path
rlq.MountPath = mountPath
rlq.Rate = rate

View File

@ -691,12 +691,18 @@ func TestSystemBackend_remount(t *testing.T) {
req.Data["to"] = "foo"
req.Data["config"] = structs.Map(MountConfig{})
resp, err := b.HandleRequest(namespace.RootContext(nil), req)
RetryUntil(t, 5*time.Second, func() error {
req = logical.TestRequest(t, logical.ReadOperation, fmt.Sprintf("remount/status/%s", resp.Data["migration_id"]))
resp, err = b.HandleRequest(namespace.RootContext(nil), req)
if err != nil {
t.Fatalf("err: %v", err)
}
if resp != nil {
t.Fatalf("bad: %v", resp)
migrationInfo := resp.Data["migration_info"].(*MountMigrationInfo)
if migrationInfo.MigrationStatus != MigrationSuccessStatus.String() {
return fmt.Errorf("Expected migration status to be successful, got %q", migrationInfo.MigrationStatus)
}
return nil
})
}
func TestSystemBackend_remount_invalid(t *testing.T) {
@ -710,8 +716,8 @@ func TestSystemBackend_remount_invalid(t *testing.T) {
if err != logical.ErrInvalidRequest {
t.Fatalf("err: %v", err)
}
if resp.Data["error"] != `no matching mount at "unknown/"` {
t.Fatalf("bad: %v", resp)
if !strings.Contains(resp.Data["error"].(string), "no matching mount at \"unknown/\"") {
t.Fatalf("Found unexpected error %q", resp.Data["error"].(string))
}
}
@ -725,8 +731,8 @@ func TestSystemBackend_remount_system(t *testing.T) {
if err != logical.ErrInvalidRequest {
t.Fatalf("err: %v", err)
}
if resp.Data["error"] != `cannot remount "sys/"` {
t.Fatalf("bad: %v", resp)
if !strings.Contains(resp.Data["error"].(string), "cannot remount \"sys/\"") {
t.Fatalf("Found unexpected error %q", resp.Data["error"].(string))
}
}
@ -741,7 +747,7 @@ func TestSystemBackend_remount_clean(t *testing.T) {
if err != logical.ErrInvalidRequest {
t.Fatalf("err: %v", err)
}
if resp.Data["error"] != `'to' path 'foo//bar' does not match cleaned path 'foo/bar'` {
if resp.Data["error"] != `invalid destination mount: path 'foo//bar/' does not match cleaned path 'foo/bar/'` {
t.Fatalf("bad: %v", resp)
}
}
@ -757,7 +763,7 @@ func TestSystemBackend_remount_nonPrintable(t *testing.T) {
if err != logical.ErrInvalidRequest {
t.Fatalf("err: %v", err)
}
if resp.Data["error"] != `'to' path cannot contain non-printable characters` {
if resp.Data["error"] != `invalid destination mount: path cannot contain non-printable characters` {
t.Fatalf("bad: %v", resp)
}
}

View File

@ -126,6 +126,32 @@ type MountTable struct {
Entries []*MountEntry `json:"entries"`
}
type MountMigrationStatus int
const (
MigrationInProgressStatus MountMigrationStatus = iota
MigrationSuccessStatus
MigrationFailureStatus
)
func (m MountMigrationStatus) String() string {
switch m {
case MigrationInProgressStatus:
return "in-progress"
case MigrationSuccessStatus:
return "success"
case MigrationFailureStatus:
return "failure"
}
return "unknown"
}
type MountMigrationInfo struct {
SourceMount string `json:"source_mount"`
TargetMount string `json:"target_mount"`
MigrationStatus string `json:"status"`
}
// tableMetrics is responsible for setting gauge metrics for
// mount table storage sizes (in bytes) and mount table num
// entries. It does this via setGaugeWithLabels. It then
@ -195,14 +221,10 @@ func (t *MountTable) shallowClone() *MountTable {
// setTaint is used to set the taint on given entry Accepts either the mount
// entry's path or namespace + path, i.e. <ns-path>/secret/ or <ns-path>/token/
func (t *MountTable) setTaint(ctx context.Context, path string, tainted bool, mountState string) (*MountEntry, error) {
func (t *MountTable) setTaint(nsID, path string, tainted bool, mountState string) (*MountEntry, error) {
n := len(t.Entries)
ns, err := namespace.FromContext(ctx)
if err != nil {
return nil, err
}
for i := 0; i < n; i++ {
if entry := t.Entries[i]; entry.Path == path && entry.Namespace().ID == ns.ID {
if entry := t.Entries[i]; entry.Path == path && entry.Namespace().ID == nsID {
t.Entries[i].Tainted = tainted
t.Entries[i].MountState = mountState
return t.Entries[i], nil
@ -662,7 +684,7 @@ func (c *Core) unmountInternal(ctx context.Context, path string, updateStorage b
entry := c.router.MatchingMountEntry(ctx, path)
// Mark the entry as tainted
if err := c.taintMountEntry(ctx, path, updateStorage, true); err != nil {
if err := c.taintMountEntry(ctx, ns.ID, path, updateStorage, true); err != nil {
c.logger.Error("failed to taint mount entry for path being unmounted", "error", err, "path", path)
return err
}
@ -780,7 +802,7 @@ func (c *Core) removeMountEntry(ctx context.Context, path string, updateStorage
}
// taintMountEntry is used to mark an entry in the mount table as tainted
func (c *Core) taintMountEntry(ctx context.Context, path string, updateStorage, unmounting bool) error {
func (c *Core) taintMountEntry(ctx context.Context, nsID, mountPath string, updateStorage, unmounting bool) error {
c.mountsLock.Lock()
defer c.mountsLock.Unlock()
@ -791,12 +813,12 @@ func (c *Core) taintMountEntry(ctx context.Context, path string, updateStorage,
// As modifying the taint of an entry affects shallow clones,
// we simply use the original
entry, err := c.mounts.setTaint(ctx, path, true, mountState)
entry, err := c.mounts.setTaint(nsID, mountPath, true, mountState)
if err != nil {
return err
}
if entry == nil {
c.logger.Error("nil entry found tainting entry in mounts table", "path", path)
c.logger.Error("nil entry found tainting entry in mounts table", "path", mountPath)
return logical.CodedError(500, "failed to taint entry in mounts table")
}
@ -846,99 +868,90 @@ func (c *Core) remountForceInternal(ctx context.Context, path string, updateStor
return nil
}
// Remount is used to remount a path at a new mount point.
func (c *Core) remount(ctx context.Context, src, dst string, updateStorage bool) error {
func (c *Core) remountSecretsEngineCurrentNamespace(ctx context.Context, src, dst string, updateStorage bool) error {
ns, err := namespace.FromContext(ctx)
if err != nil {
return err
}
// Ensure we end the path in a slash
if !strings.HasSuffix(src, "/") {
src += "/"
}
if !strings.HasSuffix(dst, "/") {
dst += "/"
srcPathDetails := c.splitNamespaceAndMountFromPath(ns.Path, src)
dstPathDetails := c.splitNamespaceAndMountFromPath(ns.Path, dst)
return c.remountSecretsEngine(ctx, srcPathDetails, dstPathDetails, updateStorage)
}
// Prevent protected paths from being remounted
for _, p := range protectedMounts {
if strings.HasPrefix(src, p) {
return fmt.Errorf("cannot remount %q", src)
}
}
// Verify exact match of the route
srcMatch := c.router.MatchingMountEntry(ctx, src)
if srcMatch == nil {
return fmt.Errorf("no matching mount at %q", src)
}
if srcMatch.NamespaceID != ns.ID {
return fmt.Errorf("source mount in a different namespace than request")
}
if err := verifyNamespace(c, ns, &MountEntry{Path: dst}); err != nil {
// remountSecretsEngine is used to remount a path at a new mount point.
func (c *Core) remountSecretsEngine(ctx context.Context, src, dst namespace.MountPathDetails, updateStorage bool) error {
ns, err := namespace.FromContext(ctx)
if err != nil {
return err
}
if match := c.router.MatchingMount(ctx, dst); match != "" {
// Prevent protected paths from being remounted, or target mounts being in protected paths
for _, p := range protectedMounts {
if strings.HasPrefix(src.MountPath, p) {
return fmt.Errorf("cannot remount %q", src.MountPath)
}
if strings.HasPrefix(dst.MountPath, p) {
return fmt.Errorf("cannot remount to destination %+v", dst)
}
}
srcRelativePath := src.GetRelativePath(ns)
dstRelativePath := dst.GetRelativePath(ns)
// Verify exact match of the route
srcMatch := c.router.MatchingMountEntry(ctx, srcRelativePath)
if srcMatch == nil {
return fmt.Errorf("no matching mount at %+v", src)
}
if match := c.router.MatchingMount(ctx, dstRelativePath); match != "" {
return fmt.Errorf("existing mount at %q", match)
}
// Mark the entry as tainted
if err := c.taintMountEntry(ctx, src, updateStorage, false); err != nil {
if err := c.taintMountEntry(ctx, src.Namespace.ID, src.MountPath, updateStorage, false); err != nil {
return err
}
// Taint the router path to prevent routing
if err := c.router.Taint(ctx, src); err != nil {
if err := c.router.Taint(ctx, srcRelativePath); err != nil {
return err
}
if !c.IsDRSecondary() {
// Invoke the rollback manager a final time
rCtx := namespace.ContextWithNamespace(c.activeContext, ns)
if c.rollback != nil {
if err := c.rollback.Rollback(rCtx, src); err != nil {
if c.rollback != nil && c.router.MatchingBackend(ctx, srcRelativePath) != nil {
if err := c.rollback.Rollback(rCtx, srcRelativePath); err != nil {
return err
}
}
if entry := c.router.MatchingMountEntry(ctx, src); entry == nil {
return fmt.Errorf("no matching mount at %q", src)
}
revokeCtx := namespace.ContextWithNamespace(ctx, src.Namespace)
// Revoke all the dynamic keys
if err := c.expiration.RevokePrefix(rCtx, src, true); err != nil {
if err := c.expiration.RevokePrefix(revokeCtx, src.MountPath, true); err != nil {
return err
}
}
c.mountsLock.Lock()
if match := c.router.MatchingMount(ctx, dst); match != "" {
if match := c.router.MatchingMount(ctx, dstRelativePath); match != "" {
c.mountsLock.Unlock()
return fmt.Errorf("existing mount at %q", match)
}
var entry *MountEntry
for _, mountEntry := range c.mounts.Entries {
if mountEntry.Path == src && mountEntry.NamespaceID == ns.ID {
entry = mountEntry
entry.Path = dst
entry.Tainted = false
break
}
}
if entry == nil {
c.mountsLock.Unlock()
c.logger.Error("failed to find entry in mounts table")
return logical.CodedError(500, "failed to find entry in mounts table")
}
srcMatch.Tainted = false
srcMatch.NamespaceID = dst.Namespace.ID
srcMatch.namespace = dst.Namespace
srcPath := srcMatch.Path
srcMatch.Path = dst.MountPath
// Update the mount table
if err := c.persistMounts(ctx, c.mounts, &entry.Local); err != nil {
entry.Path = src
entry.Tainted = true
if err := c.persistMounts(ctx, c.mounts, &srcMatch.Local); err != nil {
srcMatch.Path = srcPath
srcMatch.Tainted = true
c.mountsLock.Unlock()
if err == logical.ErrReadOnly && c.perfStandby {
return err
@ -949,23 +962,37 @@ func (c *Core) remount(ctx context.Context, src, dst string, updateStorage bool)
}
// Remount the backend
if err := c.router.Remount(ctx, src, dst); err != nil {
if err := c.router.Remount(ctx, srcRelativePath, dstRelativePath); err != nil {
c.mountsLock.Unlock()
return err
}
c.mountsLock.Unlock()
// Un-taint the path
if err := c.router.Untaint(ctx, dst); err != nil {
if err := c.router.Untaint(ctx, dstRelativePath); err != nil {
return err
}
if c.logger.IsInfo() {
c.logger.Info("successful remount", "old_path", src, "new_path", dst)
}
return nil
}
// From an input path that has a relative namespace heirarchy followed by a mount point, return the full
// namespace of the mount point, along with the mount point without the namespace related prefix.
// For example, in a heirarchy ns1/ns2/ns3/secret-mount, when currNs is ns1 and path is ns2/ns3/secret-mount,
// this returns the namespace object for ns1/ns2/ns3/, and the string "secret-mount"
func (c *Core) splitNamespaceAndMountFromPath(currNs, path string) namespace.MountPathDetails {
fullPath := currNs + path
fullNs := c.namespaceByPath(fullPath)
mountPath := strings.TrimPrefix(fullPath, fullNs.Path)
return namespace.MountPathDetails{
Namespace: fullNs,
MountPath: sanitizePath(mountPath),
}
}
// loadMounts is invoked as part of postUnseal to load the mount table
func (c *Core) loadMounts(ctx context.Context) error {
// Load the existing mount table
@ -1580,3 +1607,37 @@ func (c *Core) setCoreBackend(entry *MountEntry, backend logical.Backend, view *
c.identityStore = backend.(*IdentityStore)
}
}
func (c *Core) createMigrationStatus(from, to namespace.MountPathDetails) (string, error) {
migrationID, err := uuid.GenerateUUID()
if err != nil {
return "", fmt.Errorf("error generating uuid for mount move invocation: %w", err)
}
migrationInfo := MountMigrationInfo{
SourceMount: from.Namespace.Path + from.MountPath,
TargetMount: to.Namespace.Path + to.MountPath,
MigrationStatus: MigrationInProgressStatus.String(),
}
c.mountMigrationTracker.Store(migrationID, migrationInfo)
return migrationID, nil
}
func (c *Core) setMigrationStatus(migrationID string, migrationStatus MountMigrationStatus) error {
migrationInfoRaw, ok := c.mountMigrationTracker.Load(migrationID)
if !ok {
return fmt.Errorf("Migration Tracker entry missing for ID %s", migrationID)
}
migrationInfo := migrationInfoRaw.(MountMigrationInfo)
migrationInfo.MigrationStatus = migrationStatus.String()
c.mountMigrationTracker.Store(migrationID, migrationInfo)
return nil
}
func (c *Core) readMigrationStatus(migrationID string) *MountMigrationInfo {
migrationInfoRaw, ok := c.mountMigrationTracker.Load(migrationID)
if !ok {
return nil
}
migrationInfo := migrationInfoRaw.(MountMigrationInfo)
return &migrationInfo
}

View File

@ -476,7 +476,7 @@ func TestCore_RemountConcurrent(t *testing.T) {
wg.Add(1)
go func() {
defer wg.Done()
err := c2.remount(namespace.RootContext(nil), "test1", "foo", true)
err := c2.remountSecretsEngineCurrentNamespace(namespace.RootContext(nil), "test1", "foo", true)
if err != nil {
t.Logf("err: %v", err)
}
@ -485,7 +485,7 @@ func TestCore_RemountConcurrent(t *testing.T) {
wg.Add(1)
go func() {
defer wg.Done()
err := c2.remount(namespace.RootContext(nil), "test2", "foo", true)
err := c2.remountSecretsEngineCurrentNamespace(namespace.RootContext(nil), "test2", "foo", true)
if err != nil {
t.Logf("err: %v", err)
}
@ -504,7 +504,7 @@ func TestCore_RemountConcurrent(t *testing.T) {
func TestCore_Remount(t *testing.T) {
c, keys, _ := TestCoreUnsealed(t)
err := c.remount(namespace.RootContext(nil), "secret", "foo", true)
err := c.remountSecretsEngineCurrentNamespace(namespace.RootContext(nil), "secret", "foo", true)
if err != nil {
t.Fatalf("err: %v", err)
}
@ -612,7 +612,7 @@ func TestCore_Remount_Cleanup(t *testing.T) {
}
// Remount, this should cleanup
if err := c.remount(namespace.RootContext(nil), "test/", "new/", true); err != nil {
if err := c.remountSecretsEngineCurrentNamespace(namespace.RootContext(nil), "test/", "new/", true); err != nil {
t.Fatalf("err: %v", err)
}
@ -641,7 +641,7 @@ func TestCore_Remount_Cleanup(t *testing.T) {
func TestCore_Remount_Protected(t *testing.T) {
c, _, _ := TestCoreUnsealed(t)
err := c.remount(namespace.RootContext(nil), "sys", "foo", true)
err := c.remountSecretsEngineCurrentNamespace(namespace.RootContext(nil), "sys", "foo", true)
if err.Error() != `cannot remount "sys/"` {
t.Fatalf("err: %v", err)
}

View File

@ -11,6 +11,7 @@ import (
log "github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-memdb"
"github.com/hashicorp/vault/helper/metricsutil"
"github.com/hashicorp/vault/helper/namespace"
"github.com/hashicorp/vault/sdk/helper/pathmanager"
"github.com/hashicorp/vault/sdk/logical"
)
@ -183,8 +184,11 @@ type Quota interface {
// rule is deleted.
close(context.Context) error
// handleRemount takes in the new mount path in the quota
handleRemount(string)
// Clone creates a clone of the calling quota
Clone() Quota
// handleRemount updates the mount and namesapce paths of the quota
handleRemount(string, string)
}
// Response holds information about the result of the Allow() call. The response
@ -268,17 +272,41 @@ func (m *Manager) SetQuota(ctx context.Context, qType string, quota Quota, loadi
return m.setQuotaLocked(ctx, qType, quota, loading)
}
// setQuotaLocked adds or updates a quota rule, modifying the db as well as
// any runtime elements such as goroutines.
// It should be called with the write lock held.
// setQuotaLocked creates a transaction, passes it into setQuotaLockedWithTxn and manages its lifecycle
// along with updating lease quota counts
func (m *Manager) setQuotaLocked(ctx context.Context, qType string, quota Quota, loading bool) error {
txn := m.db.Txn(true)
defer txn.Abort()
err := m.setQuotaLockedWithTxn(ctx, qType, quota, loading, txn)
if err != nil {
return err
}
if loading {
txn.Commit()
return nil
}
// For the lease count type, recompute the counters
if !loading && qType == TypeLeaseCount.String() {
if err := m.recomputeLeaseCounts(ctx, txn); err != nil {
return err
}
}
txn.Commit()
return nil
}
// setQuotaLockedWithTxn adds or updates a quota rule, modifying the db as well as
// any runtime elements such as goroutines, using the transaction passed in
// It should be called with the write lock held.
func (m *Manager) setQuotaLockedWithTxn(ctx context.Context, qType string, quota Quota, loading bool, txn *memdb.Txn) error {
if qType == TypeLeaseCount.String() {
m.setIsPerfStandby(quota)
}
txn := m.db.Txn(true)
defer txn.Abort()
raw, err := txn.First(qType, indexID, quota.quotaID())
if err != nil {
return err
@ -306,19 +334,6 @@ func (m *Manager) setQuotaLocked(ctx context.Context, qType string, quota Quota,
return err
}
if loading {
txn.Commit()
return nil
}
// For the lease count type, recompute the counters
if !loading && qType == TypeLeaseCount.String() {
if err := m.recomputeLeaseCounts(ctx, txn); err != nil {
return err
}
}
txn.Commit()
return nil
}
@ -937,23 +952,30 @@ func QuotaStoragePath(quotaType, name string) string {
// HandleRemount updates the quota subsystem about the remount operation that
// took place. Quota manager will trigger the quota specific updates including
// the mount path update..
func (m *Manager) HandleRemount(ctx context.Context, nsPath, fromPath, toPath string) error {
// the mount path update and the namespace update
func (m *Manager) HandleRemount(ctx context.Context, from, to namespace.MountPathDetails) error {
m.lock.Lock()
defer m.lock.Unlock()
// Grab a write transaction, as we want to save the updated quota in memdb
txn := m.db.Txn(true)
defer txn.Abort()
// nsPath would have been made non-empty during insertion. Use non-empty value
// quota namespace would have been made non-empty during insertion. Use non-empty value
// during query as well.
if nsPath == "" {
nsPath = "root"
fromNs := from.Namespace.Path
if fromNs == "" {
fromNs = namespace.RootNamespaceID
}
toNs := to.Namespace.Path
if toNs == "" {
toNs = namespace.RootNamespaceID
}
idx := indexNamespaceMount
leaseQuotaUpdated := false
args := []interface{}{nsPath, fromPath}
args := []interface{}{fromNs, from.MountPath}
for _, quotaType := range quotaTypes() {
iter, err := txn.Get(quotaType, idx, args...)
if err != nil {
@ -961,7 +983,11 @@ func (m *Manager) HandleRemount(ctx context.Context, nsPath, fromPath, toPath st
}
for raw := iter.Next(); raw != nil; raw = iter.Next() {
quota := raw.(Quota)
quota.handleRemount(toPath)
// Clone the object and update it
clonedQuota := quota.Clone()
clonedQuota.handleRemount(to.MountPath, toNs)
// Update both underlying storage and memdb with the quota change
entry, err := logical.StorageEntryJSON(QuotaStoragePath(quotaType, quota.QuotaName()), quota)
if err != nil {
return err
@ -969,6 +995,9 @@ func (m *Manager) HandleRemount(ctx context.Context, nsPath, fromPath, toPath st
if err := m.storage.Put(ctx, entry); err != nil {
return err
}
if err := m.setQuotaLockedWithTxn(ctx, quotaType, clonedQuota, false, txn); err != nil {
return err
}
if quotaType == TypeLeaseCount.String() {
leaseQuotaUpdated = true
}

View File

@ -101,7 +101,7 @@ func NewRateLimitQuota(name, nsPath, mountPath string, rate float64, interval, b
}
}
func (q *RateLimitQuota) Clone() *RateLimitQuota {
func (q *RateLimitQuota) Clone() Quota {
rlq := &RateLimitQuota{
ID: q.ID,
Name: q.Name,
@ -337,6 +337,7 @@ func (rlq *RateLimitQuota) close(ctx context.Context) error {
return nil
}
func (rlq *RateLimitQuota) handleRemount(toPath string) {
rlq.MountPath = toPath
func (rlq *RateLimitQuota) handleRemount(mountpath, nspath string) {
rlq.MountPath = mountpath
rlq.NamespacePath = nspath
}

View File

@ -18,7 +18,7 @@ func TestQuotas_MountPathOverwrite(t *testing.T) {
quota := NewRateLimitQuota("tq", "", "kv1/", 10, time.Second, 0)
require.NoError(t, qm.SetQuota(context.Background(), TypeRateLimit.String(), quota, false))
quota = quota.Clone()
quota = quota.Clone().(*RateLimitQuota)
quota.MountPath = "kv2/"
require.NoError(t, qm.SetQuota(context.Background(), TypeRateLimit.String(), quota, false))

View File

@ -60,6 +60,10 @@ func (l LeaseCountQuota) close(_ context.Context) error {
panic("implement me")
}
func (l LeaseCountQuota) handleRemount(s string) {
func (l LeaseCountQuota) Clone() Quota {
panic("implement me")
}
func (l LeaseCountQuota) handleRemount(mountPath, nsPath string) {
panic("implement me")
}

View File

@ -64,7 +64,7 @@ values set here cannot be changed after key creation.
- `rsa-3072` - RSA with bit size of 3072 (asymmetric)
- `rsa-4096` - RSA with bit size of 4096 (asymmetric)
- `auto_rotate_interval` `(duration: "0", optional)` The interval at which
- `auto_rotate_period` `(duration: "0", optional)` The period at which
this key should be rotated automatically. Setting this to "0" (the default)
will disable automatic key rotation. This value cannot be shorter than one
hour.
@ -232,10 +232,10 @@ are returned during a read operation on the named key.)
- `allow_plaintext_backup` `(bool: false)` - If set, enables taking backup of
named key in the plaintext format. Once set, this cannot be disabled.
- `auto_rotate_interval` `(duration: "", optional)` The interval at which this
- `auto_rotate_period` `(duration: "", optional)` The period at which this
key should be rotated automatically. Setting this to "0" will disable automatic
key rotation. This value cannot be shorter than one hour. When no value is
provided, the interval remains unchanged.
provided, the period remains unchanged.
### Sample Payload

View File

@ -128,6 +128,7 @@ Plugin authors who wish to have their plugins listed may file a submission via a
- [Jenkins](https://plugins.jenkins.io/hashicorp-vault-plugin)
- [Terraform Enterprise/Terraform Cloud](https://github.com/gitrgoliveira/vault-plugin-auth-tfe)
- [SSH](https://github.com/42wim/vault-plugin-auth-ssh)
### Secrets