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" EnvRateLimit = "VAULT_RATE_LIMIT"
EnvHTTPProxy = "VAULT_HTTP_PROXY" EnvHTTPProxy = "VAULT_HTTP_PROXY"
HeaderIndex = "X-Vault-Index" HeaderIndex = "X-Vault-Index"
HeaderForward = "X-Vault-Forward"
HeaderInconsistent = "X-Vault-Inconsistent"
) )
// Deprecated values // Deprecated values
@ -1395,7 +1397,7 @@ func ParseReplicationState(raw string, hmacKey []byte) (*logical.WALState, error
// conjunction with RequireState. // conjunction with RequireState.
func ForwardInconsistent() RequestCallback { func ForwardInconsistent() RequestCallback {
return func(req *Request) { 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. // This feature must be enabled in Vault's configuration.
func ForwardAlways() RequestCallback { func ForwardAlways() RequestCallback {
return func(req *Request) { 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" "context"
"errors" "errors"
"fmt" "fmt"
"time"
"github.com/mitchellh/mapstructure" "github.com/mitchellh/mapstructure"
) )
@ -65,7 +66,31 @@ func (c *Sys) Unmount(path string) error {
return err 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 { 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{}{ body := map[string]interface{}{
"from": from, "from": from,
"to": to, "to": to,
@ -73,16 +98,59 @@ func (c *Sys) Remount(from, to string) error {
r := c.c.NewRequest("POST", "/v1/sys/remount") r := c.c.NewRequest("POST", "/v1/sys/remount")
if err := r.SetJSONBody(body); err != nil { if err := r.SetJSONBody(body); err != nil {
return err return nil, err
} }
ctx, cancelFunc := context.WithCancel(context.Background()) ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc() defer cancelFunc()
resp, err := c.c.RawRequestWithContext(ctx, r) resp, err := c.c.RawRequestWithContext(ctx, r)
if err == nil { if err != nil {
defer resp.Body.Close() 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 { 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. // Deprecated: This field will always be blank for newer server responses.
PluginName string `json:"plugin_name,omitempty" mapstructure:"plugin_name"` 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) 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( 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, req.Connection.RemoteAddr,
err,
).Error()), nil ).Error()), nil
} }
} }

View File

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

View File

@ -191,17 +191,31 @@ func createDeleteHelper(t *testing.T, b logical.Backend, config *logical.Backend
} }
resp, err := b.HandleRequest(context.Background(), caReq) resp, err := b.HandleRequest(context.Background(), caReq)
if err != nil || (resp != nil && resp.IsError()) { if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("bad case %v: err: %v, resp:%v", index, err, resp) t.Fatalf("bad case %v: err: %v, resp: %v", index, err, resp)
} }
if !strings.Contains(resp.Data["public_key"].(string), caReq.Data["key_type"].(string)) { if !strings.Contains(resp.Data["public_key"].(string), caReq.Data["key_type"].(string)) {
t.Fatalf("bad case %v: expected public key of type %v but was %v", index, caReq.Data["key_type"], resp.Data["public_key"]) 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 // Delete the configured keys
caReq.Operation = logical.DeleteOperation caReq.Operation = logical.DeleteOperation
resp, err = b.HandleRequest(context.Background(), caReq) resp, err = b.HandleRequest(context.Background(), caReq)
if err != nil || (resp != nil && resp.IsError()) { if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("bad case %v: err: %v, resp:%v", index, err, resp) t.Fatalf("bad case %v: err: %v, resp: %v", index, err, resp)
} }
} }
@ -235,6 +249,24 @@ func TestSSH_ConfigCAKeyTypes(t *testing.T) {
{"ed25519", 0}, {"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 { for index, scenario := range cases {
createDeleteHelper(t, b, config, index, scenario.keyType, scenario.keyBits) 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 // 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. // on primary nodes and performance secondary nodes which have a local mount.
func (b *backend) autoRotateKeys(ctx context.Context, req *logical.Request) error { func (b *backend) autoRotateKeys(ctx context.Context, req *logical.Request) error {
// Only check for autorotation once an hour to avoid unnecessarily iterating // 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() 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. // automatically rotate.
if p.AutoRotateInterval == 0 { if p.AutoRotatePeriod == 0 {
return nil return nil
} }
// Retrieve the latest version of the policy and determine if it is time to rotate. // Retrieve the latest version of the policy and determine if it is time to rotate.
latestKey := p.Keys[strconv.Itoa(p.LatestVersion)] 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() { if b.Logger().IsDebug() {
b.Logger().Debug("automatically rotating key", "key", key) b.Logger().Debug("automatically rotating key", "key", key)
} }

View File

@ -1607,7 +1607,7 @@ func TestTransit_AutoRotateKeys(t *testing.T) {
Operation: logical.UpdateOperation, Operation: logical.UpdateOperation,
Path: "keys/test2", Path: "keys/test2",
Data: map[string]interface{}{ Data: map[string]interface{}{
"auto_rotate_interval": 24 * time.Hour, "auto_rotate_period": 24 * time.Hour,
}, },
} }
resp, err = b.HandleRequest(context.Background(), req) 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) 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{ p, _, err := b.GetPolicy(context.Background(), keysutil.PolicyRequest{
Storage: storage, Storage: storage,
Name: "test2", Name: "test2",
@ -1662,7 +1662,7 @@ func TestTransit_AutoRotateKeys(t *testing.T) {
if p == nil { if p == nil {
t.Fatal("expected non-nil policy") t.Fatal("expected non-nil policy")
} }
p.AutoRotateInterval = time.Nanosecond p.AutoRotatePeriod = time.Nanosecond
err = p.Persist(context.Background(), storage) err = p.Persist(context.Background(), storage)
if err != nil { if err != nil {
t.Fatal(err) 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.`, 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, Type: framework.TypeDurationSecond,
Description: `Amount of time the key should live before Description: `Amount of time the key should live before
being automatically rotated. A value of 0 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 { if err != nil {
return nil, err return nil, err
} }
if ok { 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 // Provided value must be 0 to disable or at least an hour
if autoRotateInterval != 0 && autoRotateInterval < time.Hour { if autoRotatePeriod != 0 && autoRotatePeriod < time.Hour {
return logical.ErrorResponse("auto rotate interval must be 0 to disable or at least an hour"), nil return logical.ErrorResponse("auto rotate period must be 0 to disable or at least an hour"), nil
} }
if autoRotateInterval != p.AutoRotateInterval { if autoRotatePeriod != p.AutoRotatePeriod {
p.AutoRotateInterval = autoRotateInterval p.AutoRotatePeriod = autoRotatePeriod
persistNeeded = true persistNeeded = true
} }
} }

View File

@ -294,43 +294,43 @@ func TestTransit_ConfigSettings(t *testing.T) {
func TestTransit_UpdateKeyConfigWithAutorotation(t *testing.T) { func TestTransit_UpdateKeyConfigWithAutorotation(t *testing.T) {
tests := map[string]struct { tests := map[string]struct {
initialAutoRotateInterval interface{} initialAutoRotatePeriod interface{}
newAutoRotateInterval interface{} newAutoRotatePeriod interface{}
shouldError bool shouldError bool
expectedValue time.Duration expectedValue time.Duration
}{ }{
"default (no value)": { "default (no value)": {
initialAutoRotateInterval: "5h", initialAutoRotatePeriod: "5h",
shouldError: false, shouldError: false,
expectedValue: 5 * time.Hour, expectedValue: 5 * time.Hour,
}, },
"0 (int)": { "0 (int)": {
initialAutoRotateInterval: "5h", initialAutoRotatePeriod: "5h",
newAutoRotateInterval: 0, newAutoRotatePeriod: 0,
shouldError: false, shouldError: false,
expectedValue: 0, expectedValue: 0,
}, },
"0 (string)": { "0 (string)": {
initialAutoRotateInterval: "5h", initialAutoRotatePeriod: "5h",
newAutoRotateInterval: 0, newAutoRotatePeriod: 0,
shouldError: false, shouldError: false,
expectedValue: 0, expectedValue: 0,
}, },
"5 seconds": { "5 seconds": {
newAutoRotateInterval: "5s", newAutoRotatePeriod: "5s",
shouldError: true, shouldError: true,
}, },
"5 hours": { "5 hours": {
newAutoRotateInterval: "5h", newAutoRotatePeriod: "5h",
shouldError: false, shouldError: false,
expectedValue: 5 * time.Hour, expectedValue: 5 * time.Hour,
}, },
"negative value": { "negative value": {
newAutoRotateInterval: "-1800s", newAutoRotatePeriod: "-1800s",
shouldError: true, shouldError: true,
}, },
"invalid string": { "invalid string": {
newAutoRotateInterval: "this shouldn't work", newAutoRotatePeriod: "this shouldn't work",
shouldError: true, shouldError: true,
}, },
} }
@ -364,11 +364,11 @@ func TestTransit_UpdateKeyConfigWithAutorotation(t *testing.T) {
keyName := hex.EncodeToString(keyNameBytes) keyName := hex.EncodeToString(keyNameBytes)
_, err = client.Logical().Write(fmt.Sprintf("transit/keys/%s", keyName), map[string]interface{}{ _, 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{}{ 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 { switch {
case test.shouldError && err == nil: case test.shouldError && err == nil:
@ -385,7 +385,7 @@ func TestTransit_UpdateKeyConfigWithAutorotation(t *testing.T) {
if resp == nil { if resp == nil {
t.Fatal("expected non-nil response") 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 { if !ok {
t.Fatal("returned value is of unexpected type") t.Fatal("returned value is of unexpected type")
} }
@ -395,7 +395,7 @@ func TestTransit_UpdateKeyConfigWithAutorotation(t *testing.T) {
} }
want := int64(test.expectedValue.Seconds()) want := int64(test.expectedValue.Seconds())
if got != want { 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.`, return the public key for the given context.`,
}, },
"auto_rotate_interval": { "auto_rotate_period": {
Type: framework.TypeDurationSecond, Type: framework.TypeDurationSecond,
Default: 0, Default: 0,
Description: `Amount of time the key should live before 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) keyType := d.Get("type").(string)
exportable := d.Get("exportable").(bool) exportable := d.Get("exportable").(bool)
allowPlaintextBackup := d.Get("allow_plaintext_backup").(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 { if autoRotatePeriod != 0 && autoRotatePeriod < time.Hour {
return logical.ErrorResponse("auto rotate interval must be 0 to disable or at least an hour"), nil return logical.ErrorResponse("auto rotate period must be 0 to disable or at least an hour"), nil
} }
if !derived && convergent { if !derived && convergent {
@ -150,7 +150,7 @@ func (b *backend) pathPolicyWrite(ctx context.Context, req *logical.Request, d *
Convergent: convergent, Convergent: convergent,
Exportable: exportable, Exportable: exportable,
AllowPlaintextBackup: allowPlaintextBackup, AllowPlaintextBackup: allowPlaintextBackup,
AutoRotateInterval: autoRotateInterval, AutoRotatePeriod: autoRotatePeriod,
} }
switch keyType { switch keyType {
case "aes128-gcm96": 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_decryption": p.Type.DecryptionSupported(),
"supports_signing": p.Type.SigningSupported(), "supports_signing": p.Type.SigningSupported(),
"supports_derivation": p.Type.DerivationSupported(), "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) { func TestTransit_CreateKeyWithAutorotation(t *testing.T) {
tests := map[string]struct { tests := map[string]struct {
autoRotateInterval interface{} autoRotatePeriod interface{}
shouldError bool shouldError bool
expectedValue time.Duration expectedValue time.Duration
}{ }{
@ -103,30 +103,30 @@ func TestTransit_CreateKeyWithAutorotation(t *testing.T) {
shouldError: false, shouldError: false,
}, },
"0 (int)": { "0 (int)": {
autoRotateInterval: 0, autoRotatePeriod: 0,
shouldError: false, shouldError: false,
expectedValue: 0, expectedValue: 0,
}, },
"0 (string)": { "0 (string)": {
autoRotateInterval: "0", autoRotatePeriod: "0",
shouldError: false, shouldError: false,
expectedValue: 0, expectedValue: 0,
}, },
"5 seconds": { "5 seconds": {
autoRotateInterval: "5s", autoRotatePeriod: "5s",
shouldError: true, shouldError: true,
}, },
"5 hours": { "5 hours": {
autoRotateInterval: "5h", autoRotatePeriod: "5h",
shouldError: false, shouldError: false,
expectedValue: 5 * time.Hour, expectedValue: 5 * time.Hour,
}, },
"negative value": { "negative value": {
autoRotateInterval: "-1800s", autoRotatePeriod: "-1800s",
shouldError: true, shouldError: true,
}, },
"invalid string": { "invalid string": {
autoRotateInterval: "this shouldn't work", autoRotatePeriod: "this shouldn't work",
shouldError: true, shouldError: true,
}, },
} }
@ -160,7 +160,7 @@ func TestTransit_CreateKeyWithAutorotation(t *testing.T) {
keyName := hex.EncodeToString(keyNameBytes) keyName := hex.EncodeToString(keyNameBytes)
_, err = client.Logical().Write(fmt.Sprintf("transit/keys/%s", keyName), map[string]interface{}{ _, err = client.Logical().Write(fmt.Sprintf("transit/keys/%s", keyName), map[string]interface{}{
"auto_rotate_interval": test.autoRotateInterval, "auto_rotate_period": test.autoRotatePeriod,
}) })
switch { switch {
case test.shouldError && err == nil: case test.shouldError && err == nil:
@ -177,7 +177,7 @@ func TestTransit_CreateKeyWithAutorotation(t *testing.T) {
if resp == nil { if resp == nil {
t.Fatal("expected non-nil response") 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 { if !ok {
t.Fatal("returned value is of unexpected type") t.Fatal("returned value is of unexpected type")
} }
@ -187,7 +187,7 @@ func TestTransit_CreateKeyWithAutorotation(t *testing.T) {
} }
want := int64(test.expectedValue.Seconds()) want := int64(test.expectedValue.Seconds())
if got != want { 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 secrets engine are revoked, but all configuration associated with the engine
is preserved. is preserved.
This command only works within a namespace; it cannot be used to move engines This command works within or across namespaces, both source and destination paths
to different namespaces. can be prefixed with a namespace heirarchy relative to the current namespace.
WARNING! Moving an existing secrets engine will revoke any leases from the WARNING! Moving an existing secrets engine will revoke any leases from the
old engine. old engine.
@ -39,6 +39,11 @@ Usage: vault secrets move [options] SOURCE DESTINATION
$ vault secrets move secret/ generic/ $ 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() ` + c.Flags().Help()
return strings.TrimSpace(helpText) return strings.TrimSpace(helpText)
@ -84,11 +89,12 @@ func (c *SecretsMoveCommand) Run(args []string) int {
return 2 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)) c.UI.Error(fmt.Sprintf("Error moving secrets engine %s to %s: %s", source, destination, err))
return 2 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 return 0
} }

View File

@ -3,6 +3,7 @@ package command
import ( import (
"strings" "strings"
"testing" "testing"
"time"
"github.com/mitchellh/cli" "github.com/mitchellh/cli"
) )
@ -91,12 +92,16 @@ func TestSecretsMoveCommand_Run(t *testing.T) {
t.Errorf("expected %d to be %d", code, exp) 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() combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
if !strings.Contains(combined, expected) { if !strings.Contains(combined, expected) {
t.Errorf("expected %q to contain %q", 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() mounts, err := client.Sys().ListMounts()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)

1
go.mod
View File

@ -306,6 +306,7 @@ require (
github.com/mattn/go-isatty v0.0.14 // indirect github.com/mattn/go-isatty v0.0.14 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
github.com/miekg/dns v1.1.41 // 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/hashstructure v1.0.0 // indirect
github.com/mitchellh/iochan v1.0.0 // indirect github.com/mitchellh/iochan v1.0.0 // indirect
github.com/mitchellh/pointerstructure v1.2.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 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY=
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= 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/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/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.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= 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:] 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 ( import (
"encoding/json" "encoding/json"
"fmt"
"reflect" "reflect"
"testing" "testing"
"time"
"github.com/go-test/deep" "github.com/go-test/deep"
@ -374,8 +376,24 @@ func TestSysRemount(t *testing.T) {
"from": "foo", "from": "foo",
"to": "bar", "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") resp = testHttpGet(t, token, addr+"/v1/sys/mounts")
var actual map[string]interface{} var actual map[string]interface{}

View File

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

View File

@ -374,9 +374,9 @@ type Policy struct {
// policy object. // policy object.
StoragePrefix string `json:"storage_prefix"` 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. // 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 // versionPrefixCache stores caches of version prefix strings and the split
// version template. // version template.

View File

@ -126,19 +126,6 @@ export default ApplicationAdapter.extend({
return this.ajax(url, verb, options); 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) { urlFor(endpoint) {
if (!ENDPOINTS.includes(endpoint)) { if (!ENDPOINTS.includes(endpoint)) {
throw new Error( throw new Error(

View File

@ -18,13 +18,13 @@ const BACKENDS = supportedAuthBackends();
* *
* @example ```js * @example ```js
* // All properties are passed in via query params. * // 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 wrappedToken=null {String} - 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 cluster=null {Object} - The auth method that is currently selected in the dropdown. This corresponds to an Ember Model.
* @param {string} namespace- The currently active namespace. * @param namespace=null {String} - The currently active namespace.
* @param {string} selectedAuth - The auth method that is currently selected in the dropdown. * @param redirectTo=null {String} - The name of the route to redirect to.
* @param {function} onSuccess - Fired on auth success * @param selectedAuth=null {String} - The auth method that is currently selected in the dropdown.
*/ */
const DEFAULTS = { const DEFAULTS = {
@ -45,6 +45,7 @@ export default Component.extend(DEFAULTS, {
selectedAuth: null, selectedAuth: null,
methods: null, methods: null,
cluster: null, cluster: null,
redirectTo: null,
namespace: null, namespace: null,
wrappedToken: null, wrappedToken: null,
// internal // internal
@ -205,18 +206,54 @@ export default Component.extend(DEFAULTS, {
showLoading: or('isLoading', 'authenticate.isRunning', 'fetchMethods.isRunning', 'unwrapToken.isRunning'), 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( authenticate: task(
waitFor(function* (backendType, data) { waitFor(function* (backendType, data) {
let clusterId = this.cluster.id; let clusterId = this.cluster.id;
try { try {
if (backendType === 'okta') {
this.delayAuthMessageReminder.perform(); 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) { if (Ember.testing) {
this.showLoading = true; this.showLoading = true;
yield timeout(0); yield timeout(0);
} else { return;
yield timeout(5000);
} }
yield timeout(5000);
}), }),
actions: { actions: {
@ -261,10 +298,11 @@ export default Component.extend(DEFAULTS, {
return this.authenticate.unlinked().perform(backend.type, data); return this.authenticate.unlinked().perform(backend.type, data);
}, },
handleError(e) { handleError(e) {
this.setProperties({ if (e) {
loading: false, this.handleError(e, false);
error: e ? this.auth.handleError(e) : null, } else {
}); this.set('error', null);
}
}, },
}, },
}); });

View File

@ -15,6 +15,9 @@ export default class Current extends Component {
return { name: namespace['label'], id: namespace['label'] }; return { name: namespace['label'], id: namespace['label'] };
}); });
@tracked selectedAuthMethod = null;
@tracked authMethodOptions = [];
// Response client count data by namespace for current/partial month // Response client count data by namespace for current/partial month
get byNamespaceCurrent() { get byNamespaceCurrent() {
return this.args.model.monthly?.byNamespace || []; return this.args.model.monthly?.byNamespace || [];
@ -26,7 +29,21 @@ export default class Current extends Component {
} }
get hasAttributionData() { 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() { get countsIncludeOlderData() {
@ -41,16 +58,13 @@ export default class Current extends Component {
// top level TOTAL client counts for current/partial month // top level TOTAL client counts for current/partial month
get totalUsageCounts() { get totalUsageCounts() {
return this.selectedNamespace return this.selectedNamespace ? this.filteredActivity : this.args.model.monthly?.total;
? this.filterByNamespace(this.selectedNamespace)
: this.args.model.monthly?.total;
} }
// total client data for horizontal bar chart in attribution component // total client data for horizontal bar chart in attribution component
get totalClientsData() { get totalClientsData() {
if (this.selectedNamespace) { if (this.selectedNamespace) {
let filteredNamespace = this.filterByNamespace(this.selectedNamespace); return this.filteredActivity?.mounts || null;
return filteredNamespace.mounts ? this.filterByNamespace(this.selectedNamespace).mounts : null;
} else { } else {
return this.byNamespaceCurrent; return this.byNamespaceCurrent;
} }
@ -60,15 +74,26 @@ export default class Current extends Component {
return this.args.model.monthly?.responseTimestamp; return this.args.model.monthly?.responseTimestamp;
} }
// HELPERS
filterByNamespace(namespace) {
return this.byNamespaceCurrent.find((ns) => ns.label === namespace);
}
// ACTIONS // ACTIONS
@action @action
selectNamespace([value]) { selectNamespace([value]) {
// value comes in as [namespace0] // value comes in as [namespace0]
this.selectedNamespace = value; 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) => { years = Array.from({ length: 5 }, (item, i) => {
return new Date().getFullYear() - 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 isEditStartMonthOpen = false;
@tracked startMonth = null; @tracked startMonth = null;
@tracked startYear = null; @tracked startYear = null;
@tracked allowedMonthMax = 12;
@tracked disabledYear = null;
// FOR HISTORY COMPONENT // // FOR HISTORY COMPONENT //
@ -57,14 +62,19 @@ export default class History extends Component {
// SEARCH SELECT // SEARCH SELECT
@tracked selectedNamespace = null; @tracked selectedNamespace = null;
@tracked namespaceArray = this.getActivityResponse.byNamespace.map((namespace) => { @tracked namespaceArray = this.getActivityResponse.byNamespace.map((namespace) => ({
return { name: namespace['label'], id: namespace['label'] }; name: namespace.label,
}); id: namespace.label,
}));
// TEMPLATE MESSAGING // TEMPLATE MESSAGING
@tracked noActivityDate = ''; @tracked noActivityDate = '';
@tracked responseRangeDiffMessage = null; @tracked responseRangeDiffMessage = null;
@tracked isLoadingQuery = false; @tracked isLoadingQuery = false;
@tracked licenseStartIsCurrentMonth = this.args.model.activity?.isLicenseDateError || false;
@tracked selectedAuthMethod = null;
@tracked authMethodOptions = [];
get versionText() { get versionText() {
return this.version.isEnterprise return this.version.isEnterprise
@ -92,7 +102,7 @@ export default class History extends Component {
} }
get hasAttributionData() { get hasAttributionData() {
return this.totalUsageCounts.clients !== 0 && this.totalClientsData.length !== 0; return this.totalUsageCounts.clients !== 0 && !!this.totalClientsData && !this.selectedAuthMethod;
} }
get startTimeDisplay() { get startTimeDisplay() {
@ -113,6 +123,20 @@ export default class History extends Component {
return `${this.arrayOfMonths[month]} ${year}`; 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() { get isDateRange() {
return !isSameMonth( return !isSameMonth(
new Date(this.getActivityResponse.startTime), new Date(this.getActivityResponse.startTime),
@ -122,16 +146,13 @@ export default class History extends Component {
// top level TOTAL client counts for given date range // top level TOTAL client counts for given date range
get totalUsageCounts() { get totalUsageCounts() {
return this.selectedNamespace return this.selectedNamespace ? this.filteredActivity : this.getActivityResponse.total;
? this.filterByNamespace(this.selectedNamespace)
: this.getActivityResponse.total;
} }
// total client data for horizontal bar chart in attribution component // total client data for horizontal bar chart in attribution component
get totalClientsData() { get totalClientsData() {
if (this.selectedNamespace) { if (this.selectedNamespace) {
let filteredNamespace = this.filterByNamespace(this.selectedNamespace); return this.filteredActivity?.mounts || null;
return filteredNamespace.mounts ? this.filterByNamespace(this.selectedNamespace).mounts : null;
} else { } else {
return this.getActivityResponse?.byNamespace; return this.getActivityResponse?.byNamespace;
} }
@ -157,6 +178,7 @@ export default class History extends Component {
@action @action
async handleClientActivityQuery(month, year, dateType) { async handleClientActivityQuery(month, year, dateType) {
this.isEditStartMonthOpen = false;
if (dateType === 'cancel') { if (dateType === 'cancel') {
return; return;
} }
@ -195,6 +217,7 @@ export default class History extends Component {
this.storage().setItem(INPUTTED_START_DATE, this.startTimeFromResponse); this.storage().setItem(INPUTTED_START_DATE, this.startTimeFromResponse);
} }
this.queriedActivityResponse = response; this.queriedActivityResponse = response;
this.licenseStartIsCurrentMonth = response.isLicenseDateError;
// compare if the response startTime comes after the requested startTime. If true throw a warning. // compare if the response startTime comes after the requested startTime. If true throw a warning.
// only display if they selected a startTime // only display if they selected a startTime
if ( if (
@ -209,7 +232,6 @@ export default class History extends Component {
this.responseRangeDiffMessage = null; this.responseRangeDiffMessage = null;
} }
} catch (e) { } catch (e) {
// TODO CMB surface API errors when user selects start date after end date
return e; return e;
} finally { } finally {
this.isLoadingQuery = false; this.isLoadingQuery = false;
@ -225,22 +247,38 @@ export default class History extends Component {
selectNamespace([value]) { selectNamespace([value]) {
// value comes in as [namespace0] // value comes in as [namespace0]
this.selectedNamespace = value; 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 // FOR START DATE MODAL
@action @action
selectStartMonth(month) { selectStartMonth(month, event) {
this.startMonth = month; this.startMonth = month;
// disables months if in the future
this.disabledYear = this.months.indexOf(month) >= this.currentMonth ? this.currentYear : null;
event.close();
} }
@action @action
selectStartYear(year) { selectStartYear(year, event) {
this.startYear = year; this.startYear = year;
} this.allowedMonthMax = year === this.currentYear ? this.currentMonth : 12;
event.close();
// HELPERS //
filterByNamespace(namespace) {
return this.getActivityResponse.byNamespace.find((ns) => ns.label === namespace);
} }
storage() { 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 {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} [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 { 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 startMonth = null;
@tracked startYear = null; @tracked startYear = null;
@ -26,13 +32,18 @@ export default class DateDropdown extends Component {
}); });
@action @action
selectStartMonth(month) { selectStartMonth(month, event) {
this.startMonth = month; this.startMonth = month;
// disables months if in the future
this.disabledYear = this.months.indexOf(month) >= this.currentMonth ? this.currentYear : null;
event.close();
} }
@action @action
selectStartYear(year) { selectStartYear(year, event) {
this.startYear = year; this.startYear = year;
this.allowedMonthMax = year === this.currentYear ? this.currentMonth : 12;
event.close();
} }
@action @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) { handleAutoRotateChange(ttlObj) {
if (ttlObj.enabled) { if (ttlObj.enabled) {
set(this.key, 'autoRotateInterval', ttlObj.goSafeTimeString); set(this.key, 'autoRotatePeriod', ttlObj.goSafeTimeString);
this.set('autoRotateInvalid', ttlObj.seconds < 3600); this.set('autoRotateInvalid', ttlObj.seconds < 3600);
} else { } 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'), clusterController: controller('vault.cluster'),
namespaceService: service('namespace'), namespaceService: service('namespace'),
featureFlagService: service('featureFlag'), featureFlagService: service('featureFlag'),
auth: service(),
router: service(),
queryParams: [{ authMethod: 'with', oidcProvider: 'o' }],
namespaceQueryParam: alias('clusterController.namespaceQueryParam'), namespaceQueryParam: alias('clusterController.namespaceQueryParam'),
queryParams: [{ authMethod: 'with', oidcProvider: 'o' }],
wrappedToken: alias('vaultController.wrappedToken'), wrappedToken: alias('vaultController.wrappedToken'),
redirectTo: alias('vaultController.redirectTo'),
managedNamespaceRoot: alias('featureFlagService.managedNamespaceRoot'),
authMethod: '', authMethod: '',
oidcProvider: '', oidcProvider: '',
redirectTo: alias('vaultController.redirectTo'),
managedNamespaceRoot: alias('featureFlagService.managedNamespaceRoot'),
get managedNamespaceChild() { get managedNamespaceChild() {
let fullParam = this.namespaceQueryParam; let fullParam = this.namespaceQueryParam;
@ -46,39 +41,4 @@ export default Controller.extend({
this.namespaceService.setNamespace(value, true); this.namespaceService.setNamespace(value, true);
this.set('namespaceQueryParam', value); this.set('namespaceQueryParam', value);
}).restartable(), }).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 { helper } from '@ember/component/helper';
import { formatDuration, intervalToDuration } from 'date-fns'; 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: // intervalToDuration creates a durationObject that turns the seconds (ex 3600) to respective:
// { years: 0, months: 0, days: 0, hours: 1, minutes: 0, seconds: 0 } // { years: 0, months: 0, days: 0, hours: 1, minutes: 0, seconds: 0 }
// then formatDuration returns the filled in keys of the durationObject // then formatDuration returns the filled in keys of the durationObject
if (removeZero && time === '0') {
return null;
}
// time must be in seconds // time must be in seconds
let duration = Number.parseInt(time, 10); let duration = Number.parseInt(time, 10);
if (isNaN(duration)) { 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', fieldValue: 'id',
readOnly: true, readOnly: true,
}), }),
autoRotateInterval: attr({ autoRotatePeriod: attr({
defaultValue: '0', defaultValue: '0',
defaultShown: 'Key is not automatically rotated', defaultShown: 'Key is not automatically rotated',
editType: 'ttl', editType: 'ttl',
label: 'Auto-rotation interval', label: 'Auto-rotation period',
}), }),
deletionAllowed: attr('boolean'), deletionAllowed: attr('boolean'),
derived: attr('boolean'), derived: attr('boolean'),

View File

@ -8,10 +8,13 @@ export default class HistoryRoute extends Route {
try { try {
// on init ONLY make network request if we have a start time from the license // on init ONLY make network request if we have a start time from the license
// otherwise user needs to manually input // otherwise user needs to manually input
// TODO CMB what to return here?
return start_time ? await this.store.queryRecord('clients/activity', { start_time }) : {}; return start_time ? await this.store.queryRecord('clients/activity', { start_time }) : {};
} catch (e) { } 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, id: payload.id,
data: { data: {
...payload.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); 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_decryption_version = snapshot.attr('minDecryptionVersion');
const min_encryption_version = snapshot.attr('minEncryptionVersion'); const min_encryption_version = snapshot.attr('minEncryptionVersion');
const deletion_allowed = snapshot.attr('deletionAllowed'); const deletion_allowed = snapshot.attr('deletionAllowed');
const auto_rotate_interval = snapshot.attr('autoRotateInterval'); const auto_rotate_period = snapshot.attr('autoRotatePeriod');
return { return {
min_decryption_version, min_decryption_version,
min_encryption_version, min_encryption_version,
deletion_allowed, deletion_allowed,
auto_rotate_interval, auto_rotate_period,
}; };
} else { } else {
snapshot.id = snapshot.attr('name'); snapshot.id = snapshot.attr('name');

View File

@ -3,7 +3,6 @@ import { resolve, reject } from 'rsvp';
import { assign } from '@ember/polyfills'; import { assign } from '@ember/polyfills';
import { isArray } from '@ember/array'; import { isArray } from '@ember/array';
import { computed, get } from '@ember/object'; import { computed, get } from '@ember/object';
import { capitalize } from '@ember/string';
import fetch from 'fetch'; import fetch from 'fetch';
import { getOwner } from '@ember/application'; import { getOwner } from '@ember/application';
@ -15,10 +14,9 @@ import { task, timeout } from 'ember-concurrency';
const TOKEN_SEPARATOR = '☃'; const TOKEN_SEPARATOR = '☃';
const TOKEN_PREFIX = 'vault-'; const TOKEN_PREFIX = 'vault-';
const ROOT_PREFIX = '_root_'; const ROOT_PREFIX = '_root_';
const TOTP_NOT_CONFIGURED = 'TOTP mfa required but not configured';
const BACKENDS = supportedAuthBackends(); const BACKENDS = supportedAuthBackends();
export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX, TOTP_NOT_CONFIGURED }; export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX };
export default Service.extend({ export default Service.extend({
permissions: service(), permissions: service(),
@ -26,8 +24,6 @@ export default Service.extend({
IDLE_TIMEOUT: 3 * 60e3, IDLE_TIMEOUT: 3 * 60e3,
expirationCalcTS: null, expirationCalcTS: null,
isRenewing: false, isRenewing: false,
mfaErrors: null,
init() { init() {
this._super(...arguments); this._super(...arguments);
this.checkForRootToken(); 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}*/) { async authenticate(/*{clusterId, backend, data}*/) {
const [options] = arguments; const [options] = arguments;
const adapter = this.clusterAdapter(); const adapter = this.clusterAdapter();
let resp;
try { let resp = await adapter.authenticate(options);
resp = await adapter.authenticate(options); let authData = await this.persistAuthData(options, resp.auth || resp.data, this.namespaceService.path);
} 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);
await this.permissions.getPaths.perform(); await this.permissions.getPaths.perform();
return authData; return authData;
}, },
handleError(e) {
if (e.errors) {
return e.errors.map((error) => {
if (error.detail) {
return error.detail;
}
return error;
});
}
return [e];
},
getAuthType() { getAuthType() {
if (!this.authData) return; if (!this.authData) return;
return this.authData.backend.type; return this.authData.backend.type;

View File

@ -51,7 +51,3 @@
margin-right: 4px; 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; background-color: $color;
color: $color-invert; color: $color-invert;
&:hover, &:hover:not([disabled]),
&.is-hovered { &.is-hovered {
background-color: darken($color, 5%); background-color: darken($color, 5%);
border-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; padding: $size-8;
width: 100%; width: 100%;
} }
.icon-button {
background: transparent;
padding: 0;
margin: 0;
border: none;
cursor: pointer;
}

View File

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

View File

@ -32,7 +32,20 @@
@onChange={{this.selectNamespace}} @onChange={{this.selectNamespace}}
@placeholder={{"Filter by namespace"}} @placeholder={{"Filter by namespace"}}
@displayInherit={{true}} @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> </ToolbarFilters>
</Toolbar> </Toolbar>
</div> </div>

View File

@ -14,13 +14,22 @@
Edit Edit
</button> </button>
{{else}} {{else}}
<DateDropdown @handleDateSelection={{this.handleClientActivityQuery}} @name={{"startTime"}} /> <DateDropdown @handleDateSelection={{this.handleClientActivityQuery}} @name={{"startTime"}} @submitText="Save" />
{{/if}} {{/if}}
</div> </div>
<p class="is-8 has-text-grey has-bottom-margin-xl"> <p class="is-8 has-text-grey has-bottom-margin-xl">
{{this.versionText.description}} {{this.versionText.description}}
</p> </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")}} {{#if (eq @model.config.enabled "On")}}
<EmptyState <EmptyState
@title={{concat "No monthly history " (if this.noActivityDate "from ") this.noActivityDate}} @title={{concat "No monthly history " (if this.noActivityDate "from ") this.noActivityDate}}
@ -74,6 +83,19 @@
@onChange={{this.selectNamespace}} @onChange={{this.selectNamespace}}
@placeholder={{"Filter by namespace"}} @placeholder={{"Filter by namespace"}}
@displayInherit={{true}} @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}} {{/if}}
</ToolbarFilters> </ToolbarFilters>
@ -125,8 +147,10 @@
<EmptyState @title="No data received" @message="No data exists for that query period. Try searching again." /> <EmptyState @title="No data received" @message="No data exists for that query period. Try searching again." />
{{/if}} {{/if}}
{{/if}} {{/if}}
{{else}} {{else if (or (not @model.startTimeFromLicense) (not this.startTimeFromResponse))}}
<EmptyState @title={{this.versionText.title}} @message={{this.versionText.message}} /> <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}}
{{/if}} {{/if}}
@ -155,11 +179,12 @@
<D.Content @defaultClass="popup-menu-content is-wide"> <D.Content @defaultClass="popup-menu-content is-wide">
<nav class="box menu scroll"> <nav class="box menu scroll">
<ul class="menu-list"> <ul class="menu-list">
{{#each this.months as |month|}} {{#each this.months as |month index|}}
<button <button
type="button" type="button"
class="link" class="button link"
{{on "click" (queue (fn this.selectStartMonth month) (action D.actions.close))}} disabled={{if (lt index this.allowedMonthMax) false true}}
{{on "click" (fn this.selectStartMonth month D.actions)}}
> >
{{month}} {{month}}
</button> </button>
@ -183,8 +208,9 @@
{{#each this.years as |year|}} {{#each this.years as |year|}}
<button <button
type="button" type="button"
class="link" class="button link"
{{on "click" (queue (fn this.selectStartYear year) (action D.actions.close))}} disabled={{if (eq year this.disabledYear) true false}}
{{on "click" (fn this.selectStartYear year D.actions)}}
> >
{{year}} {{year}}
</button> </button>
@ -199,22 +225,12 @@
<button <button
type="button" type="button"
class="button is-primary" class="button is-primary"
onclick={{queue disabled={{or (if (and this.startMonth this.startYear) false true)}}
(action (mut this.isEditStartMonthOpen) false) {{on "click" (fn this.handleClientActivityQuery this.startMonth this.startYear "startTime")}}
(action "handleClientActivityQuery" this.startMonth this.startYear "startTime")
}}
disabled={{if (and this.startMonth this.startYear) false true}}
> >
Save Save
</button> </button>
<button <button type="button" class="button is-secondary" {{on "click" (fn this.handleClientActivityQuery 0 0 "cancel")}}>
type="button"
class="button is-secondary"
{{on
"click"
(queue (action (mut this.isEditStartMonthOpen) false) (fn this.handleClientActivityQuery 0 0 "cancel"))
}}
>
Cancel Cancel
</button> </button>
</footer> </footer>

View File

@ -10,11 +10,12 @@
<D.Content @defaultClass="popup-menu-content is-wide"> <D.Content @defaultClass="popup-menu-content is-wide">
<nav class="box menu scroll"> <nav class="box menu scroll">
<ul class="menu-list"> <ul class="menu-list">
{{#each this.months as |month|}} {{#each this.months as |month index|}}
<button <button
type="button" type="button"
class="link" class="button link"
{{on "click" (queue (fn this.selectStartMonth month) (action D.actions.close))}} disabled={{if (lt index this.allowedMonthMax) false true}}
{{on "click" (fn this.selectStartMonth month D.actions)}}
> >
{{month}} {{month}}
</button> </button>
@ -36,7 +37,12 @@
<nav class="box menu"> <nav class="box menu">
<ul class="menu-list"> <ul class="menu-list">
{{#each this.years as |year|}} {{#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}} {{year}}
</button> </button>
{{/each}} {{/each}}
@ -50,5 +56,5 @@
disabled={{if (and this.startMonth this.startYear) false true}} disabled={{if (and this.startMonth this.startYear) false true}}
{{on "click" this.saveDateSelection}} {{on "click" this.saveDateSelection}}
> >
Save {{or @submitText "Submit"}}
</button> </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,11 +10,7 @@
</div> </div>
</Nav.items> </Nav.items>
</NavHeader> </NavHeader>
{{! bypass UiWizard and container styling }} <UiWizard>
{{#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="splash-page-container section is-flex-v-centered-tablet is-flex-1 is-fullwidth">
<div class="columns is-centered is-gapless is-fullwidth"> <div class="columns is-centered is-gapless is-fullwidth">
<div class="column is-4-desktop is-6-tablet"> <div class="column is-4-desktop is-6-tablet">
@ -31,5 +27,4 @@
</div> </div>
</div> </div>
</div> </div>
</UiWizard> </UiWizard>
{{/if}}

View File

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

View File

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

View File

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

View File

@ -1,26 +1,15 @@
<SplashPage @hasAltContent={{this.auth.mfaErrors}} as |Page|> <SplashPage as |Page|>
<Page.altContent>
<MfaError @onClose={{fn (mut this.mfaAuthData) null}} />
</Page.altContent>
<Page.header> <Page.header>
{{#if this.oidcProvider}} {{#if this.oidcProvider}}
<div class="box is-shadowless is-flex-v-centered" data-test-auth-logo> <div class="box is-shadowless is-flex-v-centered" data-test-auth-logo>
<LogoEdition aria-label="Sign in with Hashicorp Vault" /> <LogoEdition aria-label="Sign in with Hashicorp Vault" />
</div> </div>
{{else}} {{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"> <h1 class="title is-3">
{{if this.mfaAuthData "Authenticate" "Sign in to Vault"}} Sign in to Vault
</h1> </h1>
</div>
{{/if}} {{/if}}
</Page.header> </Page.header>
{{#unless this.mfaAuthData}}
{{#if this.managedNamespaceRoot}} {{#if this.managedNamespaceRoot}}
<Page.sub-header> <Page.sub-header>
<Toolbar> <Toolbar>
@ -82,20 +71,14 @@
</Toolbar> </Toolbar>
</Page.sub-header> </Page.sub-header>
{{/if}} {{/if}}
{{/unless}}
<Page.content> <Page.content>
{{#if this.mfaAuthData}}
<MfaForm @clusterId={{this.model.id}} @authData={{this.mfaAuthData}} @onSuccess={{action "onMfaSuccess"}} />
{{else}}
<AuthForm <AuthForm
@wrappedToken={{this.wrappedToken}} @wrappedToken={{this.wrappedToken}}
@cluster={{this.model}} @cluster={{this.model}}
@namespace={{this.namespaceQueryParam}} @namespace={{this.namespaceQueryParam}}
@redirectTo={{this.redirectTo}} @redirectTo={{this.redirectTo}}
@selectedAuth={{this.authMethod}} @selectedAuth={{this.authMethod}}
@onSuccess={{action "onAuthResponse"}}
/> />
{{/if}}
</Page.content> </Page.content>
<Page.footer> <Page.footer>
<div class="has-short-padding"> <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}}/> * <Select @label='Date Range' @options={{[{ value: 'berry', label: 'Berry' }]}} @onChange={{onChange}}/>
* ``` * ```
* *
* @param {string} [label=null] - The label for the select element. * @param label=null {String} - 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 options=null {Array} - 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 [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 {string} [name = null] - The name of the select, used for the test selector. * @param [name=null] {String} - 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 [valueAttribute=value] {String} - 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 [labelAttribute=label] {String} - 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 [isInline=false] {Bool} - 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 [isFullwidth=false] {Bool} - 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 onChange=null {Func} - The action to take once the user has selected an item. This method will be passed the `value` of the select.
* @param {Func} [onChange] - 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({ export default Component.extend({
@ -33,6 +32,5 @@ export default Component.extend({
labelAttribute: 'label', labelAttribute: 'label',
isInline: false, isInline: false,
isFullwidth: false, isFullwidth: false,
noDefault: false,
onChange() {}, onChange() {},
}); });

View File

@ -11,11 +11,6 @@
onchange={{action this.onChange value="target.value"}} onchange={{action this.onChange value="target.value"}}
data-test-select={{this.name}} data-test-select={{this.name}}
> >
{{#if this.noDefault}}
<option value="">
Select one
</option>
{{/if}}
{{#each this.options as |op|}} {{#each this.options as |op|}}
<option <option
value={{or (get op this.valueAttribute) op}} 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 { return {
request_id: '00001', 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, non_entity_tokens: 15,
clients: 100, 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', namespace_id: 'RxD81',
@ -5582,6 +5605,24 @@ export default function (server) {
non_entity_tokens: 20, non_entity_tokens: 20,
clients: 55, 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', namespace_id: 'root',
@ -5591,6 +5632,24 @@ export default function (server) {
non_entity_tokens: 8, non_entity_tokens: 8,
clients: 20, 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, distinct_entities: 132,

View File

@ -1,7 +1,6 @@
// add all handlers here // add all handlers here
// individual lookup done in mirage config // individual lookup done in mirage config
import base from './base'; import base from './base';
import mfa from './mfa';
import activity from './activity'; 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 ## AuthForm
The `AuthForm` is used to sign users into Vault. The `AuthForm` is used to sign users into Vault.
**Params**
| Param | Type | Description | | Param | Type | Default | Description |
| --- | --- | --- | | --- | --- | --- | --- |
| wrappedToken | <code>string</code> | The auth method that is currently selected in the dropdown. | | wrappedToken | <code>String</code> | <code></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. | | 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> | The currently active namespace. | | namespace | <code>String</code> | <code></code> | The currently active namespace. |
| selectedAuth | <code>string</code> | The auth method that is currently selected in the dropdown. | | redirectTo | <code>String</code> | <code></code> | The name of the route to redirect to. |
| onSuccess | <code>function</code> | Fired on auth success | | selectedAuth | <code>String</code> | <code></code> | The auth method that is currently selected in the dropdown. |
**Example** **Example**
```js ```js
// All properties are passed in via query params. // 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** **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'); 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 visit('/vault/auth');
await component.selectMethod('token'); await component.selectMethod('token');
await click('[data-test-auth-submit]'); 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'); 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() { async authenticate() {
return fetch('http://localhost:2000'); return fetch('http://localhost:2000');
}, },
handleError() {},
setLastFetch() {}, setLastFetch() {},
}); });
@ -26,7 +25,6 @@ const workingAuthService = Service.extend({
authenticate() { authenticate() {
return resolve({}); return resolve({});
}, },
handleError() {},
setLastFetch() {}, 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) { module('Integration | Helper | format-ttl', function (hooks) {
setupRenderingTest(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) { test('it renders the input if no match found', async function (assert) {
this.set('inputValue', '1234'); this.set('inputValue', '1234');

View File

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

View File

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

View File

@ -311,6 +311,10 @@ type Core struct {
// change underneath a calling function // change underneath a calling function
mountsLock sync.RWMutex 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 // auth is loaded after unseal since it is a protected
// configuration // configuration
auth *MountTable auth *MountTable
@ -855,6 +859,7 @@ func CreateCore(conf *CoreConfig) (*Core, error) {
disableAutopilot: conf.DisableAutopilot, disableAutopilot: conf.DisableAutopilot,
enableResponseHeaderHostname: conf.EnableResponseHeaderHostname, enableResponseHeaderHostname: conf.EnableResponseHeaderHostname,
enableResponseHeaderRaftNodeID: conf.EnableResponseHeaderRaftNodeID, enableResponseHeaderRaftNodeID: conf.EnableResponseHeaderRaftNodeID,
mountMigrationTracker: &sync.Map{},
disableSSCTokens: conf.DisableSSCTokens, disableSSCTokens: conf.DisableSSCTokens,
} }
c.standbyStopCh.Store(make(chan struct{})) 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.capabilitiesPaths()...)
b.Backend.Paths = append(b.Backend.Paths, b.internalPaths()...) 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.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.metricsPath())
b.Backend.Paths = append(b.Backend.Paths, b.monitorPath()) b.Backend.Paths = append(b.Backend.Paths, b.monitorPath())
b.Backend.Paths = append(b.Backend.Paths, b.inFlightRequestPath()) 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 logical.ErrInvalidRequest
} }
if err = validateMountPath(toPath); err != nil { fromPathDetails := b.Core.splitNamespaceAndMountFromPath(ns.Path, fromPath)
return handleError(fmt.Errorf("'to' %v", err)) 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 // 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 // to the primary. We fail early here since the view in use isn't marked as
// readonly // readonly
@ -1211,31 +1233,76 @@ func (b *SystemBackend) handleRemount(ctx context.Context, req *logical.Request,
return nil, logical.ErrReadOnly 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 // Attempt remount
if err := b.Core.remount(ctx, fromPath, toPath, !b.Core.perfStandby); err != nil { if err := b.Core.remountSecretsEngine(revokeCtx, fromPathDetails, toPathDetails, !b.Core.perfStandby); err != nil {
b.Backend.Logger().Error("remount failed", "from_path", fromPath, "to_path", toPath, "error", err) return err
return handleError(err)
} }
// Get the view path if available if err := revokeCtx.Err(); err != nil {
var viewPath string return err
if entry != nil {
viewPath = entry.ViewPath()
} }
logger.Info("Removing the source mount from filtered paths on secondaries")
// Remove from filtered mounts and restart evaluation process // Remove from filtered mounts and restart evaluation process
if err := b.Core.removePathFromFilteredPaths(ctx, ns.Path+fromPath, viewPath); err != nil { if err := b.Core.removePathFromFilteredPaths(revokeCtx, fromPathDetails.GetFullPath(), viewPath); err != nil {
b.Backend.Logger().Error("filtered path removal failed", fromPath, "error", err) return err
return handleError(err)
} }
// Update quotas with the new path if err := revokeCtx.Err(); err != nil {
if err := b.Core.quotaManager.HandleRemount(ctx, ns.Path, sanitizePath(fromPath), sanitizePath(toPath)); err != nil { return err
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)
} }
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 // 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) 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 // 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) { func (b *SystemBackend) handleMountTuneRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
path := data.Get("path").(string) path := data.Get("path").(string)
@ -4519,7 +4614,7 @@ in the plugin catalog.`,
}, },
"remount": { "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. 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": { "auth_tune": {
"Tune the configuration parameters for an auth path.", "Tune the configuration parameters for an auth path.",
`Read and write the 'default-lease-ttl' and 'max-lease-ttl' values of `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 { func (b *SystemBackend) remountPaths() []*framework.Path {
return &framework.Path{ return []*framework.Path{
{
Pattern: "remount", Pattern: "remount",
Fields: map[string]*framework.FieldSchema{ Fields: map[string]*framework.FieldSchema{
@ -1323,12 +1324,34 @@ func (b *SystemBackend) remountPath() *framework.Path {
}, },
}, },
Callbacks: map[logical.Operation]framework.OperationFunc{ Operations: map[logical.Operation]framework.OperationHandler{
logical.UpdateOperation: b.handleRemount, logical.UpdateOperation: &framework.PathOperation{
Callback: b.handleRemount,
Summary: "Initiate a mount migration",
},
}, },
HelpSynopsis: strings.TrimSpace(sysHelp["remount"][0]), HelpSynopsis: strings.TrimSpace(sysHelp["remount"][0]),
HelpDescription: strings.TrimSpace(sysHelp["remount"][1]), 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: case quota == nil:
quota = quotas.NewRateLimitQuota(name, ns.Path, mountPath, rate, interval, blockInterval) quota = quotas.NewRateLimitQuota(name, ns.Path, mountPath, rate, interval, blockInterval)
default: default:
rlq := quota.(*quotas.RateLimitQuota)
// Re-inserting the already indexed object in memdb might cause problems. // Re-inserting the already indexed object in memdb might cause problems.
// So, clone the object. See https://github.com/hashicorp/go-memdb/issues/76. // 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.NamespacePath = ns.Path
rlq.MountPath = mountPath rlq.MountPath = mountPath
rlq.Rate = rate rlq.Rate = rate

View File

@ -691,12 +691,18 @@ func TestSystemBackend_remount(t *testing.T) {
req.Data["to"] = "foo" req.Data["to"] = "foo"
req.Data["config"] = structs.Map(MountConfig{}) req.Data["config"] = structs.Map(MountConfig{})
resp, err := b.HandleRequest(namespace.RootContext(nil), req) 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 { if err != nil {
t.Fatalf("err: %v", err) t.Fatalf("err: %v", err)
} }
if resp != nil { migrationInfo := resp.Data["migration_info"].(*MountMigrationInfo)
t.Fatalf("bad: %v", resp) 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) { func TestSystemBackend_remount_invalid(t *testing.T) {
@ -710,8 +716,8 @@ func TestSystemBackend_remount_invalid(t *testing.T) {
if err != logical.ErrInvalidRequest { if err != logical.ErrInvalidRequest {
t.Fatalf("err: %v", err) t.Fatalf("err: %v", err)
} }
if resp.Data["error"] != `no matching mount at "unknown/"` { if !strings.Contains(resp.Data["error"].(string), "no matching mount at \"unknown/\"") {
t.Fatalf("bad: %v", resp) 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 { if err != logical.ErrInvalidRequest {
t.Fatalf("err: %v", err) t.Fatalf("err: %v", err)
} }
if resp.Data["error"] != `cannot remount "sys/"` { if !strings.Contains(resp.Data["error"].(string), "cannot remount \"sys/\"") {
t.Fatalf("bad: %v", resp) 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 { if err != logical.ErrInvalidRequest {
t.Fatalf("err: %v", err) 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) t.Fatalf("bad: %v", resp)
} }
} }
@ -757,7 +763,7 @@ func TestSystemBackend_remount_nonPrintable(t *testing.T) {
if err != logical.ErrInvalidRequest { if err != logical.ErrInvalidRequest {
t.Fatalf("err: %v", err) 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) t.Fatalf("bad: %v", resp)
} }
} }

View File

@ -126,6 +126,32 @@ type MountTable struct {
Entries []*MountEntry `json:"entries"` 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 // tableMetrics is responsible for setting gauge metrics for
// mount table storage sizes (in bytes) and mount table num // mount table storage sizes (in bytes) and mount table num
// entries. It does this via setGaugeWithLabels. It then // 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 // 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/ // 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) n := len(t.Entries)
ns, err := namespace.FromContext(ctx)
if err != nil {
return nil, err
}
for i := 0; i < n; i++ { 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].Tainted = tainted
t.Entries[i].MountState = mountState t.Entries[i].MountState = mountState
return t.Entries[i], nil 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) entry := c.router.MatchingMountEntry(ctx, path)
// Mark the entry as tainted // 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) c.logger.Error("failed to taint mount entry for path being unmounted", "error", err, "path", path)
return err 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 // 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() c.mountsLock.Lock()
defer c.mountsLock.Unlock() 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, // As modifying the taint of an entry affects shallow clones,
// we simply use the original // 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 { if err != nil {
return err return err
} }
if entry == nil { 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") 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 return nil
} }
// Remount is used to remount a path at a new mount point. func (c *Core) remountSecretsEngineCurrentNamespace(ctx context.Context, src, dst string, updateStorage bool) error {
func (c *Core) remount(ctx context.Context, src, dst string, updateStorage bool) error {
ns, err := namespace.FromContext(ctx) ns, err := namespace.FromContext(ctx)
if err != nil { if err != nil {
return err return err
} }
// Ensure we end the path in a slash srcPathDetails := c.splitNamespaceAndMountFromPath(ns.Path, src)
if !strings.HasSuffix(src, "/") { dstPathDetails := c.splitNamespaceAndMountFromPath(ns.Path, dst)
src += "/" return c.remountSecretsEngine(ctx, srcPathDetails, dstPathDetails, updateStorage)
} }
if !strings.HasSuffix(dst, "/") {
dst += "/"
}
// Prevent protected paths from being remounted // remountSecretsEngine is used to remount a path at a new mount point.
for _, p := range protectedMounts { func (c *Core) remountSecretsEngine(ctx context.Context, src, dst namespace.MountPathDetails, updateStorage bool) error {
if strings.HasPrefix(src, p) { ns, err := namespace.FromContext(ctx)
return fmt.Errorf("cannot remount %q", src) if err != nil {
}
}
// 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 {
return err 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) return fmt.Errorf("existing mount at %q", match)
} }
// Mark the entry as tainted // 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 return err
} }
// Taint the router path to prevent routing // 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 return err
} }
if !c.IsDRSecondary() { if !c.IsDRSecondary() {
// Invoke the rollback manager a final time // Invoke the rollback manager a final time
rCtx := namespace.ContextWithNamespace(c.activeContext, ns) rCtx := namespace.ContextWithNamespace(c.activeContext, ns)
if c.rollback != nil { if c.rollback != nil && c.router.MatchingBackend(ctx, srcRelativePath) != nil {
if err := c.rollback.Rollback(rCtx, src); err != nil { if err := c.rollback.Rollback(rCtx, srcRelativePath); err != nil {
return err return err
} }
} }
if entry := c.router.MatchingMountEntry(ctx, src); entry == nil { revokeCtx := namespace.ContextWithNamespace(ctx, src.Namespace)
return fmt.Errorf("no matching mount at %q", src)
}
// Revoke all the dynamic keys // 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 return err
} }
} }
c.mountsLock.Lock() c.mountsLock.Lock()
if match := c.router.MatchingMount(ctx, dst); match != "" { if match := c.router.MatchingMount(ctx, dstRelativePath); match != "" {
c.mountsLock.Unlock() c.mountsLock.Unlock()
return fmt.Errorf("existing mount at %q", match) 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 { srcMatch.Tainted = false
c.mountsLock.Unlock() srcMatch.NamespaceID = dst.Namespace.ID
c.logger.Error("failed to find entry in mounts table") srcMatch.namespace = dst.Namespace
return logical.CodedError(500, "failed to find entry in mounts table") srcPath := srcMatch.Path
} srcMatch.Path = dst.MountPath
// Update the mount table // Update the mount table
if err := c.persistMounts(ctx, c.mounts, &entry.Local); err != nil { if err := c.persistMounts(ctx, c.mounts, &srcMatch.Local); err != nil {
entry.Path = src srcMatch.Path = srcPath
entry.Tainted = true srcMatch.Tainted = true
c.mountsLock.Unlock() c.mountsLock.Unlock()
if err == logical.ErrReadOnly && c.perfStandby { if err == logical.ErrReadOnly && c.perfStandby {
return err return err
@ -949,23 +962,37 @@ func (c *Core) remount(ctx context.Context, src, dst string, updateStorage bool)
} }
// Remount the backend // 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() c.mountsLock.Unlock()
return err return err
} }
c.mountsLock.Unlock() c.mountsLock.Unlock()
// Un-taint the path // Un-taint the path
if err := c.router.Untaint(ctx, dst); err != nil { if err := c.router.Untaint(ctx, dstRelativePath); err != nil {
return err return err
} }
if c.logger.IsInfo() {
c.logger.Info("successful remount", "old_path", src, "new_path", dst) c.logger.Info("successful remount", "old_path", src, "new_path", dst)
}
return nil 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 // loadMounts is invoked as part of postUnseal to load the mount table
func (c *Core) loadMounts(ctx context.Context) error { func (c *Core) loadMounts(ctx context.Context) error {
// Load the existing mount table // Load the existing mount table
@ -1580,3 +1607,37 @@ func (c *Core) setCoreBackend(entry *MountEntry, backend logical.Backend, view *
c.identityStore = backend.(*IdentityStore) 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) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
err := c2.remount(namespace.RootContext(nil), "test1", "foo", true) err := c2.remountSecretsEngineCurrentNamespace(namespace.RootContext(nil), "test1", "foo", true)
if err != nil { if err != nil {
t.Logf("err: %v", err) t.Logf("err: %v", err)
} }
@ -485,7 +485,7 @@ func TestCore_RemountConcurrent(t *testing.T) {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
err := c2.remount(namespace.RootContext(nil), "test2", "foo", true) err := c2.remountSecretsEngineCurrentNamespace(namespace.RootContext(nil), "test2", "foo", true)
if err != nil { if err != nil {
t.Logf("err: %v", err) t.Logf("err: %v", err)
} }
@ -504,7 +504,7 @@ func TestCore_RemountConcurrent(t *testing.T) {
func TestCore_Remount(t *testing.T) { func TestCore_Remount(t *testing.T) {
c, keys, _ := TestCoreUnsealed(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 { if err != nil {
t.Fatalf("err: %v", err) t.Fatalf("err: %v", err)
} }
@ -612,7 +612,7 @@ func TestCore_Remount_Cleanup(t *testing.T) {
} }
// Remount, this should cleanup // 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) t.Fatalf("err: %v", err)
} }
@ -641,7 +641,7 @@ func TestCore_Remount_Cleanup(t *testing.T) {
func TestCore_Remount_Protected(t *testing.T) { func TestCore_Remount_Protected(t *testing.T) {
c, _, _ := TestCoreUnsealed(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/"` { if err.Error() != `cannot remount "sys/"` {
t.Fatalf("err: %v", err) t.Fatalf("err: %v", err)
} }

View File

@ -11,6 +11,7 @@ import (
log "github.com/hashicorp/go-hclog" log "github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-memdb" "github.com/hashicorp/go-memdb"
"github.com/hashicorp/vault/helper/metricsutil" "github.com/hashicorp/vault/helper/metricsutil"
"github.com/hashicorp/vault/helper/namespace"
"github.com/hashicorp/vault/sdk/helper/pathmanager" "github.com/hashicorp/vault/sdk/helper/pathmanager"
"github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/sdk/logical"
) )
@ -183,8 +184,11 @@ type Quota interface {
// rule is deleted. // rule is deleted.
close(context.Context) error close(context.Context) error
// handleRemount takes in the new mount path in the quota // Clone creates a clone of the calling quota
handleRemount(string) 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 // 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) return m.setQuotaLocked(ctx, qType, quota, loading)
} }
// setQuotaLocked adds or updates a quota rule, modifying the db as well as // setQuotaLocked creates a transaction, passes it into setQuotaLockedWithTxn and manages its lifecycle
// any runtime elements such as goroutines. // along with updating lease quota counts
// It should be called with the write lock held.
func (m *Manager) setQuotaLocked(ctx context.Context, qType string, quota Quota, loading bool) error { 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() { if qType == TypeLeaseCount.String() {
m.setIsPerfStandby(quota) m.setIsPerfStandby(quota)
} }
txn := m.db.Txn(true)
defer txn.Abort()
raw, err := txn.First(qType, indexID, quota.quotaID()) raw, err := txn.First(qType, indexID, quota.quotaID())
if err != nil { if err != nil {
return err return err
@ -306,19 +334,6 @@ func (m *Manager) setQuotaLocked(ctx context.Context, qType string, quota Quota,
return err 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 return nil
} }
@ -937,23 +952,30 @@ func QuotaStoragePath(quotaType, name string) string {
// HandleRemount updates the quota subsystem about the remount operation that // HandleRemount updates the quota subsystem about the remount operation that
// took place. Quota manager will trigger the quota specific updates including // took place. Quota manager will trigger the quota specific updates including
// the mount path update.. // the mount path update and the namespace update
func (m *Manager) HandleRemount(ctx context.Context, nsPath, fromPath, toPath string) error { func (m *Manager) HandleRemount(ctx context.Context, from, to namespace.MountPathDetails) error {
m.lock.Lock() m.lock.Lock()
defer m.lock.Unlock() defer m.lock.Unlock()
// Grab a write transaction, as we want to save the updated quota in memdb
txn := m.db.Txn(true) txn := m.db.Txn(true)
defer txn.Abort() 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. // during query as well.
if nsPath == "" { fromNs := from.Namespace.Path
nsPath = "root" if fromNs == "" {
fromNs = namespace.RootNamespaceID
}
toNs := to.Namespace.Path
if toNs == "" {
toNs = namespace.RootNamespaceID
} }
idx := indexNamespaceMount idx := indexNamespaceMount
leaseQuotaUpdated := false leaseQuotaUpdated := false
args := []interface{}{nsPath, fromPath} args := []interface{}{fromNs, from.MountPath}
for _, quotaType := range quotaTypes() { for _, quotaType := range quotaTypes() {
iter, err := txn.Get(quotaType, idx, args...) iter, err := txn.Get(quotaType, idx, args...)
if err != nil { 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() { for raw := iter.Next(); raw != nil; raw = iter.Next() {
quota := raw.(Quota) 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) entry, err := logical.StorageEntryJSON(QuotaStoragePath(quotaType, quota.QuotaName()), quota)
if err != nil { if err != nil {
return err 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 { if err := m.storage.Put(ctx, entry); err != nil {
return err return err
} }
if err := m.setQuotaLockedWithTxn(ctx, quotaType, clonedQuota, false, txn); err != nil {
return err
}
if quotaType == TypeLeaseCount.String() { if quotaType == TypeLeaseCount.String() {
leaseQuotaUpdated = true 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{ rlq := &RateLimitQuota{
ID: q.ID, ID: q.ID,
Name: q.Name, Name: q.Name,
@ -337,6 +337,7 @@ func (rlq *RateLimitQuota) close(ctx context.Context) error {
return nil return nil
} }
func (rlq *RateLimitQuota) handleRemount(toPath string) { func (rlq *RateLimitQuota) handleRemount(mountpath, nspath string) {
rlq.MountPath = toPath 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) quota := NewRateLimitQuota("tq", "", "kv1/", 10, time.Second, 0)
require.NoError(t, qm.SetQuota(context.Background(), TypeRateLimit.String(), quota, false)) require.NoError(t, qm.SetQuota(context.Background(), TypeRateLimit.String(), quota, false))
quota = quota.Clone() quota = quota.Clone().(*RateLimitQuota)
quota.MountPath = "kv2/" quota.MountPath = "kv2/"
require.NoError(t, qm.SetQuota(context.Background(), TypeRateLimit.String(), quota, false)) 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") 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") 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-3072` - RSA with bit size of 3072 (asymmetric)
- `rsa-4096` - RSA with bit size of 4096 (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) this key should be rotated automatically. Setting this to "0" (the default)
will disable automatic key rotation. This value cannot be shorter than one will disable automatic key rotation. This value cannot be shorter than one
hour. 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 - `allow_plaintext_backup` `(bool: false)` - If set, enables taking backup of
named key in the plaintext format. Once set, this cannot be disabled. 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 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 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 ### 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) - [Jenkins](https://plugins.jenkins.io/hashicorp-vault-plugin)
- [Terraform Enterprise/Terraform Cloud](https://github.com/gitrgoliveira/vault-plugin-auth-tfe) - [Terraform Enterprise/Terraform Cloud](https://github.com/gitrgoliveira/vault-plugin-auth-tfe)
- [SSH](https://github.com/42wim/vault-plugin-auth-ssh)
### Secrets ### Secrets