This reverts commit 5f17953b5980e6438215d5cb62c8575d16c63193.
This commit is contained in:
parent
e4aab1b0cc
commit
b936db8332
|
@ -51,6 +51,8 @@ const (
|
|||
EnvRateLimit = "VAULT_RATE_LIMIT"
|
||||
EnvHTTPProxy = "VAULT_HTTP_PROXY"
|
||||
HeaderIndex = "X-Vault-Index"
|
||||
HeaderForward = "X-Vault-Forward"
|
||||
HeaderInconsistent = "X-Vault-Inconsistent"
|
||||
)
|
||||
|
||||
// Deprecated values
|
||||
|
@ -1395,7 +1397,7 @@ func ParseReplicationState(raw string, hmacKey []byte) (*logical.WALState, error
|
|||
// conjunction with RequireState.
|
||||
func ForwardInconsistent() RequestCallback {
|
||||
return func(req *Request) {
|
||||
req.Headers.Set("X-Vault-Inconsistent", "forward-active-node")
|
||||
req.Headers.Set(HeaderInconsistent, "forward-active-node")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1404,7 +1406,7 @@ func ForwardInconsistent() RequestCallback {
|
|||
// This feature must be enabled in Vault's configuration.
|
||||
func ForwardAlways() RequestCallback {
|
||||
return func(req *Request) {
|
||||
req.Headers.Set("X-Vault-Forward", "active-node")
|
||||
req.Headers.Set(HeaderForward, "active-node")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
)
|
||||
|
@ -65,7 +66,31 @@ func (c *Sys) Unmount(path string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Remount kicks off a remount operation, polls the status endpoint using
|
||||
// the migration ID till either success or failure state is observed
|
||||
func (c *Sys) Remount(from, to string) error {
|
||||
remountResp, err := c.StartRemount(from, to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for {
|
||||
remountStatusResp, err := c.RemountStatus(remountResp.MigrationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if remountStatusResp.MigrationInfo.MigrationStatus == "success" {
|
||||
return nil
|
||||
}
|
||||
if remountStatusResp.MigrationInfo.MigrationStatus == "failure" {
|
||||
return fmt.Errorf("Failure! Error encountered moving mount %s to %s, with migration ID %s", from, to, remountResp.MigrationID)
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// StartRemount kicks off a mount migration and returns a response with the migration ID
|
||||
func (c *Sys) StartRemount(from, to string) (*MountMigrationOutput, error) {
|
||||
body := map[string]interface{}{
|
||||
"from": from,
|
||||
"to": to,
|
||||
|
@ -73,16 +98,59 @@ func (c *Sys) Remount(from, to string) error {
|
|||
|
||||
r := c.c.NewRequest("POST", "/v1/sys/remount")
|
||||
if err := r.SetJSONBody(body); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
resp, err := c.c.RawRequestWithContext(ctx, r)
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return err
|
||||
defer resp.Body.Close()
|
||||
secret, err := ParseSecret(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if secret == nil || secret.Data == nil {
|
||||
return nil, errors.New("data from server response is empty")
|
||||
}
|
||||
|
||||
var result MountMigrationOutput
|
||||
err = mapstructure.Decode(secret.Data, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, err
|
||||
}
|
||||
|
||||
// RemountStatus checks the status of a mount migration operation with the provided ID
|
||||
func (c *Sys) RemountStatus(migrationID string) (*MountMigrationStatusOutput, error) {
|
||||
r := c.c.NewRequest("GET", fmt.Sprintf("/v1/sys/remount/status/%s", migrationID))
|
||||
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
resp, err := c.c.RawRequestWithContext(ctx, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
secret, err := ParseSecret(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if secret == nil || secret.Data == nil {
|
||||
return nil, errors.New("data from server response is empty")
|
||||
}
|
||||
|
||||
var result MountMigrationStatusOutput
|
||||
err = mapstructure.Decode(secret.Data, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, err
|
||||
}
|
||||
|
||||
func (c *Sys) TuneMount(path string, config MountConfigInput) error {
|
||||
|
@ -187,3 +255,18 @@ type MountConfigOutput struct {
|
|||
// Deprecated: This field will always be blank for newer server responses.
|
||||
PluginName string `json:"plugin_name,omitempty" mapstructure:"plugin_name"`
|
||||
}
|
||||
|
||||
type MountMigrationOutput struct {
|
||||
MigrationID string `mapstructure:"migration_id"`
|
||||
}
|
||||
|
||||
type MountMigrationStatusOutput struct {
|
||||
MigrationID string `mapstructure:"migration_id"`
|
||||
MigrationInfo *MountMigrationStatusInfo `mapstructure:"migration_info"`
|
||||
}
|
||||
|
||||
type MountMigrationStatusInfo struct {
|
||||
SourceMount string `mapstructure:"source_mount"`
|
||||
TargetMount string `mapstructure:"target_mount"`
|
||||
MigrationStatus string `mapstructure:"status"`
|
||||
}
|
||||
|
|
|
@ -178,11 +178,14 @@ func (b *backend) pathLoginUpdate(ctx context.Context, req *logical.Request, dat
|
|||
}
|
||||
|
||||
belongs, err := cidrutil.IPBelongsToCIDRBlocksSlice(req.Connection.RemoteAddr, entry.CIDRList)
|
||||
if !belongs || err != nil {
|
||||
if err != nil {
|
||||
return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest
|
||||
}
|
||||
|
||||
if !belongs {
|
||||
return logical.ErrorResponse(fmt.Errorf(
|
||||
"source address %q unauthorized through CIDR restrictions on the secret ID: %w",
|
||||
"source address %q unauthorized through CIDR restrictions on the secret ID",
|
||||
req.Connection.RemoteAddr,
|
||||
err,
|
||||
).Error()), nil
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import (
|
|||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
|
@ -17,6 +18,8 @@ import (
|
|||
"github.com/hashicorp/vault/sdk/framework"
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/mikesmitty/edkey"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -357,9 +360,9 @@ func generateSSHKeyPair(randomSource io.Reader, keyType string, keyBits int) (st
|
|||
return "", "", err
|
||||
}
|
||||
|
||||
marshalled, err := x509.MarshalPKCS8PrivateKey(privateSeed)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
marshalled := edkey.MarshalED25519PrivateKey(privateSeed)
|
||||
if marshalled == nil {
|
||||
return "", "", errors.New("unable to marshal ed25519 private key")
|
||||
}
|
||||
|
||||
privateBlock = &pem.Block{
|
||||
|
|
|
@ -191,17 +191,31 @@ func createDeleteHelper(t *testing.T, b logical.Backend, config *logical.Backend
|
|||
}
|
||||
resp, err := b.HandleRequest(context.Background(), caReq)
|
||||
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)) {
|
||||
t.Fatalf("bad case %v: expected public key of type %v but was %v", index, caReq.Data["key_type"], resp.Data["public_key"])
|
||||
}
|
||||
|
||||
issueOptions := map[string]interface{}{
|
||||
"public_key": testCAPublicKeyEd25519,
|
||||
}
|
||||
issueReq := &logical.Request{
|
||||
Path: "sign/ca-issuance",
|
||||
Operation: logical.UpdateOperation,
|
||||
Storage: config.StorageView,
|
||||
Data: issueOptions,
|
||||
}
|
||||
resp, err = b.HandleRequest(context.Background(), issueReq)
|
||||
if err != nil || (resp != nil && resp.IsError()) {
|
||||
t.Fatalf("bad case %v: err: %v, resp: %v", index, err, resp)
|
||||
}
|
||||
|
||||
// Delete the configured keys
|
||||
caReq.Operation = logical.DeleteOperation
|
||||
resp, err = b.HandleRequest(context.Background(), caReq)
|
||||
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},
|
||||
}
|
||||
|
||||
// Create a role for ssh signing.
|
||||
roleOptions := map[string]interface{}{
|
||||
"allow_user_certificates": true,
|
||||
"allowed_users": "*",
|
||||
"key_type": "ca",
|
||||
"ttl": "30s",
|
||||
}
|
||||
roleReq := &logical.Request{
|
||||
Operation: logical.UpdateOperation,
|
||||
Path: "roles/ca-issuance",
|
||||
Data: roleOptions,
|
||||
Storage: config.StorageView,
|
||||
}
|
||||
_, err = b.HandleRequest(context.Background(), roleReq)
|
||||
if err != nil {
|
||||
t.Fatalf("Cannot create role to issue against: %s", err)
|
||||
}
|
||||
|
||||
for index, scenario := range cases {
|
||||
createDeleteHelper(t, b, config, index, scenario.keyType, scenario.keyBits)
|
||||
}
|
||||
|
|
|
@ -190,7 +190,7 @@ func (b *backend) periodicFunc(ctx context.Context, req *logical.Request) error
|
|||
}
|
||||
|
||||
// autoRotateKeys retrieves all transit keys and rotates those which have an
|
||||
// auto rotate interval defined which has passed. This operation only happens
|
||||
// auto rotate period defined which has passed. This operation only happens
|
||||
// on primary nodes and performance secondary nodes which have a local mount.
|
||||
func (b *backend) autoRotateKeys(ctx context.Context, req *logical.Request) error {
|
||||
// Only check for autorotation once an hour to avoid unnecessarily iterating
|
||||
|
@ -247,15 +247,15 @@ func (b *backend) rotateIfRequired(ctx context.Context, req *logical.Request, ke
|
|||
}
|
||||
defer p.Unlock()
|
||||
|
||||
// If the policy's automatic rotation interval is 0, it should not
|
||||
// If the policy's automatic rotation period is 0, it should not
|
||||
// automatically rotate.
|
||||
if p.AutoRotateInterval == 0 {
|
||||
if p.AutoRotatePeriod == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Retrieve the latest version of the policy and determine if it is time to rotate.
|
||||
latestKey := p.Keys[strconv.Itoa(p.LatestVersion)]
|
||||
if time.Now().After(latestKey.CreationTime.Add(p.AutoRotateInterval)) {
|
||||
if time.Now().After(latestKey.CreationTime.Add(p.AutoRotatePeriod)) {
|
||||
if b.Logger().IsDebug() {
|
||||
b.Logger().Debug("automatically rotating key", "key", key)
|
||||
}
|
||||
|
|
|
@ -1607,7 +1607,7 @@ func TestTransit_AutoRotateKeys(t *testing.T) {
|
|||
Operation: logical.UpdateOperation,
|
||||
Path: "keys/test2",
|
||||
Data: map[string]interface{}{
|
||||
"auto_rotate_interval": 24 * time.Hour,
|
||||
"auto_rotate_period": 24 * time.Hour,
|
||||
},
|
||||
}
|
||||
resp, err = b.HandleRequest(context.Background(), req)
|
||||
|
@ -1651,7 +1651,7 @@ func TestTransit_AutoRotateKeys(t *testing.T) {
|
|||
t.Fatalf("incorrect latest_version found, got: %d, want: %d", resp.Data["latest_version"], 1)
|
||||
}
|
||||
|
||||
// Update auto rotate interval on one key to be one nanosecond
|
||||
// Update auto rotate period on one key to be one nanosecond
|
||||
p, _, err := b.GetPolicy(context.Background(), keysutil.PolicyRequest{
|
||||
Storage: storage,
|
||||
Name: "test2",
|
||||
|
@ -1662,7 +1662,7 @@ func TestTransit_AutoRotateKeys(t *testing.T) {
|
|||
if p == nil {
|
||||
t.Fatal("expected non-nil policy")
|
||||
}
|
||||
p.AutoRotateInterval = time.Nanosecond
|
||||
p.AutoRotatePeriod = time.Nanosecond
|
||||
err = p.Persist(context.Background(), storage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
|
@ -49,7 +49,7 @@ the latest version of the key is allowed.`,
|
|||
Description: `Enables taking a backup of the named key in plaintext format. Once set, this cannot be disabled.`,
|
||||
},
|
||||
|
||||
"auto_rotate_interval": {
|
||||
"auto_rotate_period": {
|
||||
Type: framework.TypeDurationSecond,
|
||||
Description: `Amount of time the key should live before
|
||||
being automatically rotated. A value of 0
|
||||
|
@ -193,19 +193,19 @@ func (b *backend) pathConfigWrite(ctx context.Context, req *logical.Request, d *
|
|||
}
|
||||
}
|
||||
|
||||
autoRotateIntervalRaw, ok, err := d.GetOkErr("auto_rotate_interval")
|
||||
autoRotatePeriodRaw, ok, err := d.GetOkErr("auto_rotate_period")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
autoRotateInterval := time.Second * time.Duration(autoRotateIntervalRaw.(int))
|
||||
autoRotatePeriod := time.Second * time.Duration(autoRotatePeriodRaw.(int))
|
||||
// Provided value must be 0 to disable or at least an hour
|
||||
if autoRotateInterval != 0 && autoRotateInterval < time.Hour {
|
||||
return logical.ErrorResponse("auto rotate interval must be 0 to disable or at least an hour"), nil
|
||||
if autoRotatePeriod != 0 && autoRotatePeriod < time.Hour {
|
||||
return logical.ErrorResponse("auto rotate period must be 0 to disable or at least an hour"), nil
|
||||
}
|
||||
|
||||
if autoRotateInterval != p.AutoRotateInterval {
|
||||
p.AutoRotateInterval = autoRotateInterval
|
||||
if autoRotatePeriod != p.AutoRotatePeriod {
|
||||
p.AutoRotatePeriod = autoRotatePeriod
|
||||
persistNeeded = true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -294,44 +294,44 @@ func TestTransit_ConfigSettings(t *testing.T) {
|
|||
|
||||
func TestTransit_UpdateKeyConfigWithAutorotation(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
initialAutoRotateInterval interface{}
|
||||
newAutoRotateInterval interface{}
|
||||
shouldError bool
|
||||
expectedValue time.Duration
|
||||
initialAutoRotatePeriod interface{}
|
||||
newAutoRotatePeriod interface{}
|
||||
shouldError bool
|
||||
expectedValue time.Duration
|
||||
}{
|
||||
"default (no value)": {
|
||||
initialAutoRotateInterval: "5h",
|
||||
shouldError: false,
|
||||
expectedValue: 5 * time.Hour,
|
||||
initialAutoRotatePeriod: "5h",
|
||||
shouldError: false,
|
||||
expectedValue: 5 * time.Hour,
|
||||
},
|
||||
"0 (int)": {
|
||||
initialAutoRotateInterval: "5h",
|
||||
newAutoRotateInterval: 0,
|
||||
shouldError: false,
|
||||
expectedValue: 0,
|
||||
initialAutoRotatePeriod: "5h",
|
||||
newAutoRotatePeriod: 0,
|
||||
shouldError: false,
|
||||
expectedValue: 0,
|
||||
},
|
||||
"0 (string)": {
|
||||
initialAutoRotateInterval: "5h",
|
||||
newAutoRotateInterval: 0,
|
||||
shouldError: false,
|
||||
expectedValue: 0,
|
||||
initialAutoRotatePeriod: "5h",
|
||||
newAutoRotatePeriod: 0,
|
||||
shouldError: false,
|
||||
expectedValue: 0,
|
||||
},
|
||||
"5 seconds": {
|
||||
newAutoRotateInterval: "5s",
|
||||
shouldError: true,
|
||||
newAutoRotatePeriod: "5s",
|
||||
shouldError: true,
|
||||
},
|
||||
"5 hours": {
|
||||
newAutoRotateInterval: "5h",
|
||||
shouldError: false,
|
||||
expectedValue: 5 * time.Hour,
|
||||
newAutoRotatePeriod: "5h",
|
||||
shouldError: false,
|
||||
expectedValue: 5 * time.Hour,
|
||||
},
|
||||
"negative value": {
|
||||
newAutoRotateInterval: "-1800s",
|
||||
shouldError: true,
|
||||
newAutoRotatePeriod: "-1800s",
|
||||
shouldError: true,
|
||||
},
|
||||
"invalid string": {
|
||||
newAutoRotateInterval: "this shouldn't work",
|
||||
shouldError: true,
|
||||
newAutoRotatePeriod: "this shouldn't work",
|
||||
shouldError: true,
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -364,11 +364,11 @@ func TestTransit_UpdateKeyConfigWithAutorotation(t *testing.T) {
|
|||
keyName := hex.EncodeToString(keyNameBytes)
|
||||
|
||||
_, err = client.Logical().Write(fmt.Sprintf("transit/keys/%s", keyName), map[string]interface{}{
|
||||
"auto_rotate_interval": test.initialAutoRotateInterval,
|
||||
"auto_rotate_period": test.initialAutoRotatePeriod,
|
||||
})
|
||||
|
||||
resp, err := client.Logical().Write(fmt.Sprintf("transit/keys/%s/config", keyName), map[string]interface{}{
|
||||
"auto_rotate_interval": test.newAutoRotateInterval,
|
||||
"auto_rotate_period": test.newAutoRotatePeriod,
|
||||
})
|
||||
switch {
|
||||
case test.shouldError && err == nil:
|
||||
|
@ -385,7 +385,7 @@ func TestTransit_UpdateKeyConfigWithAutorotation(t *testing.T) {
|
|||
if resp == nil {
|
||||
t.Fatal("expected non-nil response")
|
||||
}
|
||||
gotRaw, ok := resp.Data["auto_rotate_interval"].(json.Number)
|
||||
gotRaw, ok := resp.Data["auto_rotate_period"].(json.Number)
|
||||
if !ok {
|
||||
t.Fatal("returned value is of unexpected type")
|
||||
}
|
||||
|
@ -395,7 +395,7 @@ func TestTransit_UpdateKeyConfigWithAutorotation(t *testing.T) {
|
|||
}
|
||||
want := int64(test.expectedValue.Seconds())
|
||||
if got != want {
|
||||
t.Fatalf("incorrect auto_rotate_interval returned, got: %d, want: %d", got, want)
|
||||
t.Fatalf("incorrect auto_rotate_period returned, got: %d, want: %d", got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -95,7 +95,7 @@ if the key type supports public keys, this will
|
|||
return the public key for the given context.`,
|
||||
},
|
||||
|
||||
"auto_rotate_interval": {
|
||||
"auto_rotate_period": {
|
||||
Type: framework.TypeDurationSecond,
|
||||
Default: 0,
|
||||
Description: `Amount of time the key should live before
|
||||
|
@ -132,10 +132,10 @@ func (b *backend) pathPolicyWrite(ctx context.Context, req *logical.Request, d *
|
|||
keyType := d.Get("type").(string)
|
||||
exportable := d.Get("exportable").(bool)
|
||||
allowPlaintextBackup := d.Get("allow_plaintext_backup").(bool)
|
||||
autoRotateInterval := time.Second * time.Duration(d.Get("auto_rotate_interval").(int))
|
||||
autoRotatePeriod := time.Second * time.Duration(d.Get("auto_rotate_period").(int))
|
||||
|
||||
if autoRotateInterval != 0 && autoRotateInterval < time.Hour {
|
||||
return logical.ErrorResponse("auto rotate interval must be 0 to disable or at least an hour"), nil
|
||||
if autoRotatePeriod != 0 && autoRotatePeriod < time.Hour {
|
||||
return logical.ErrorResponse("auto rotate period must be 0 to disable or at least an hour"), nil
|
||||
}
|
||||
|
||||
if !derived && convergent {
|
||||
|
@ -150,7 +150,7 @@ func (b *backend) pathPolicyWrite(ctx context.Context, req *logical.Request, d *
|
|||
Convergent: convergent,
|
||||
Exportable: exportable,
|
||||
AllowPlaintextBackup: allowPlaintextBackup,
|
||||
AutoRotateInterval: autoRotateInterval,
|
||||
AutoRotatePeriod: autoRotatePeriod,
|
||||
}
|
||||
switch keyType {
|
||||
case "aes128-gcm96":
|
||||
|
@ -238,7 +238,7 @@ func (b *backend) pathPolicyRead(ctx context.Context, req *logical.Request, d *f
|
|||
"supports_decryption": p.Type.DecryptionSupported(),
|
||||
"supports_signing": p.Type.SigningSupported(),
|
||||
"supports_derivation": p.Type.DerivationSupported(),
|
||||
"auto_rotate_interval": int64(p.AutoRotateInterval.Seconds()),
|
||||
"auto_rotate_period": int64(p.AutoRotatePeriod.Seconds()),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -95,39 +95,39 @@ func TestTransit_Issue_2958(t *testing.T) {
|
|||
|
||||
func TestTransit_CreateKeyWithAutorotation(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
autoRotateInterval interface{}
|
||||
shouldError bool
|
||||
expectedValue time.Duration
|
||||
autoRotatePeriod interface{}
|
||||
shouldError bool
|
||||
expectedValue time.Duration
|
||||
}{
|
||||
"default (no value)": {
|
||||
shouldError: false,
|
||||
},
|
||||
"0 (int)": {
|
||||
autoRotateInterval: 0,
|
||||
shouldError: false,
|
||||
expectedValue: 0,
|
||||
autoRotatePeriod: 0,
|
||||
shouldError: false,
|
||||
expectedValue: 0,
|
||||
},
|
||||
"0 (string)": {
|
||||
autoRotateInterval: "0",
|
||||
shouldError: false,
|
||||
expectedValue: 0,
|
||||
autoRotatePeriod: "0",
|
||||
shouldError: false,
|
||||
expectedValue: 0,
|
||||
},
|
||||
"5 seconds": {
|
||||
autoRotateInterval: "5s",
|
||||
shouldError: true,
|
||||
autoRotatePeriod: "5s",
|
||||
shouldError: true,
|
||||
},
|
||||
"5 hours": {
|
||||
autoRotateInterval: "5h",
|
||||
shouldError: false,
|
||||
expectedValue: 5 * time.Hour,
|
||||
autoRotatePeriod: "5h",
|
||||
shouldError: false,
|
||||
expectedValue: 5 * time.Hour,
|
||||
},
|
||||
"negative value": {
|
||||
autoRotateInterval: "-1800s",
|
||||
shouldError: true,
|
||||
autoRotatePeriod: "-1800s",
|
||||
shouldError: true,
|
||||
},
|
||||
"invalid string": {
|
||||
autoRotateInterval: "this shouldn't work",
|
||||
shouldError: true,
|
||||
autoRotatePeriod: "this shouldn't work",
|
||||
shouldError: true,
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -160,7 +160,7 @@ func TestTransit_CreateKeyWithAutorotation(t *testing.T) {
|
|||
keyName := hex.EncodeToString(keyNameBytes)
|
||||
|
||||
_, err = client.Logical().Write(fmt.Sprintf("transit/keys/%s", keyName), map[string]interface{}{
|
||||
"auto_rotate_interval": test.autoRotateInterval,
|
||||
"auto_rotate_period": test.autoRotatePeriod,
|
||||
})
|
||||
switch {
|
||||
case test.shouldError && err == nil:
|
||||
|
@ -177,7 +177,7 @@ func TestTransit_CreateKeyWithAutorotation(t *testing.T) {
|
|||
if resp == nil {
|
||||
t.Fatal("expected non-nil response")
|
||||
}
|
||||
gotRaw, ok := resp.Data["auto_rotate_interval"].(json.Number)
|
||||
gotRaw, ok := resp.Data["auto_rotate_period"].(json.Number)
|
||||
if !ok {
|
||||
t.Fatal("returned value is of unexpected type")
|
||||
}
|
||||
|
@ -187,7 +187,7 @@ func TestTransit_CreateKeyWithAutorotation(t *testing.T) {
|
|||
}
|
||||
want := int64(test.expectedValue.Seconds())
|
||||
if got != want {
|
||||
t.Fatalf("incorrect auto_rotate_interval returned, got: %d, want: %d", got, want)
|
||||
t.Fatalf("incorrect auto_rotate_period returned, got: %d, want: %d", got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
```release-note:improvement
|
||||
ui: Adds multi-factor authentication support
|
||||
```
|
|
@ -0,0 +1,3 @@
|
|||
```release-note:improvement
|
||||
api: Define constants for X-Vault-Forward and X-Vault-Inconsistent headers
|
||||
```
|
|
@ -0,0 +1,3 @@
|
|||
```release-note:bug
|
||||
auth/approle: Fix wrapping of nil errors in `login` endpoint
|
||||
```
|
|
@ -29,8 +29,8 @@ Usage: vault secrets move [options] SOURCE DESTINATION
|
|||
secrets engine are revoked, but all configuration associated with the engine
|
||||
is preserved.
|
||||
|
||||
This command only works within a namespace; it cannot be used to move engines
|
||||
to different namespaces.
|
||||
This command works within or across namespaces, both source and destination paths
|
||||
can be prefixed with a namespace heirarchy relative to the current namespace.
|
||||
|
||||
WARNING! Moving an existing secrets engine will revoke any leases from the
|
||||
old engine.
|
||||
|
@ -39,6 +39,11 @@ Usage: vault secrets move [options] SOURCE DESTINATION
|
|||
|
||||
$ vault secrets move secret/ generic/
|
||||
|
||||
Move the existing secrets engine at ns1/secret/ across namespaces to ns2/generic/,
|
||||
where ns1 and ns2 are child namespaces of the current namespace:
|
||||
|
||||
$ vault secrets move ns1/secret/ ns2/generic/
|
||||
|
||||
` + c.Flags().Help()
|
||||
|
||||
return strings.TrimSpace(helpText)
|
||||
|
@ -84,11 +89,12 @@ func (c *SecretsMoveCommand) Run(args []string) int {
|
|||
return 2
|
||||
}
|
||||
|
||||
if err := client.Sys().Remount(source, destination); err != nil {
|
||||
remountResp, err := client.Sys().StartRemount(source, destination)
|
||||
if err != nil {
|
||||
c.UI.Error(fmt.Sprintf("Error moving secrets engine %s to %s: %s", source, destination, err))
|
||||
return 2
|
||||
}
|
||||
|
||||
c.UI.Output(fmt.Sprintf("Success! Moved secrets engine %s to: %s", source, destination))
|
||||
c.UI.Output(fmt.Sprintf("Success! Started moving secrets engine %s to %s, with migration ID %s", source, destination, remountResp.MigrationID))
|
||||
return 0
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package command
|
|||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mitchellh/cli"
|
||||
)
|
||||
|
@ -91,12 +92,16 @@ func TestSecretsMoveCommand_Run(t *testing.T) {
|
|||
t.Errorf("expected %d to be %d", code, exp)
|
||||
}
|
||||
|
||||
expected := "Success! Moved secrets engine secret/ to: generic/"
|
||||
expected := "Success! Started moving secrets engine secret/ to generic/"
|
||||
combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
|
||||
if !strings.Contains(combined, expected) {
|
||||
t.Errorf("expected %q to contain %q", combined, expected)
|
||||
}
|
||||
|
||||
// Wait for the move command to complete. Ideally we'd check remount status
|
||||
// explicitly but we don't have migration id here
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
mounts, err := client.Sys().ListMounts()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
1
go.mod
1
go.mod
|
@ -306,6 +306,7 @@ require (
|
|||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
|
||||
github.com/miekg/dns v1.1.41 // indirect
|
||||
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a // indirect
|
||||
github.com/mitchellh/hashstructure v1.0.0 // indirect
|
||||
github.com/mitchellh/iochan v1.0.0 // indirect
|
||||
github.com/mitchellh/pointerstructure v1.2.0 // indirect
|
||||
|
|
2
go.sum
2
go.sum
|
@ -1153,6 +1153,8 @@ github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKju
|
|||
github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY=
|
||||
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
|
||||
github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
|
||||
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a h1:eU8j/ClY2Ty3qdHnn0TyW3ivFoPC/0F1gQZz8yTxbbE=
|
||||
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a/go.mod h1:v8eSC2SMp9/7FTKUncp7fH9IwPfw+ysMObcEz5FWheQ=
|
||||
github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible/go.mod h1:8AuVvqP/mXw1px98n46wfvcGfQ4ci2FwoAjKYxuo3Z4=
|
||||
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
|
||||
github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
|
||||
|
|
|
@ -133,3 +133,20 @@ func SplitIDFromString(input string) (string, string) {
|
|||
|
||||
return prefix + input[:idx], input[idx+1:]
|
||||
}
|
||||
|
||||
// MountPathDetails contains the details of a mount's location,
|
||||
// consisting of the namespace of the mount and the path of the
|
||||
// mount within the namespace
|
||||
type MountPathDetails struct {
|
||||
Namespace *Namespace
|
||||
MountPath string
|
||||
}
|
||||
|
||||
func (mpd *MountPathDetails) GetRelativePath(currNs *Namespace) string {
|
||||
subNsPath := strings.TrimPrefix(mpd.Namespace.Path, currNs.Path)
|
||||
return subNsPath + mpd.MountPath
|
||||
}
|
||||
|
||||
func (mpd *MountPathDetails) GetFullPath() string {
|
||||
return mpd.Namespace.Path + mpd.MountPath
|
||||
}
|
||||
|
|
|
@ -2,8 +2,10 @@ package http
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-test/deep"
|
||||
|
||||
|
@ -374,8 +376,24 @@ func TestSysRemount(t *testing.T) {
|
|||
"from": "foo",
|
||||
"to": "bar",
|
||||
})
|
||||
testResponseStatus(t, resp, 204)
|
||||
testResponseStatus(t, resp, 200)
|
||||
|
||||
// Poll until the remount succeeds
|
||||
var remountResp map[string]interface{}
|
||||
testResponseBody(t, resp, &remountResp)
|
||||
vault.RetryUntil(t, 5*time.Second, func() error {
|
||||
resp = testHttpGet(t, token, addr+"/v1/sys/remount/status/"+remountResp["migration_id"].(string))
|
||||
testResponseStatus(t, resp, 200)
|
||||
|
||||
var remountStatusResp map[string]interface{}
|
||||
testResponseBody(t, resp, &remountStatusResp)
|
||||
|
||||
status := remountStatusResp["data"].(map[string]interface{})["migration_info"].(map[string]interface{})["status"]
|
||||
if status != "success" {
|
||||
return fmt.Errorf("Expected migration status to be successful, got %q", status)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
resp = testHttpGet(t, token, addr+"/v1/sys/mounts")
|
||||
|
||||
var actual map[string]interface{}
|
||||
|
|
|
@ -52,7 +52,7 @@ type PolicyRequest struct {
|
|||
AllowPlaintextBackup bool
|
||||
|
||||
// How frequently the key should automatically rotate
|
||||
AutoRotateInterval time.Duration
|
||||
AutoRotatePeriod time.Duration
|
||||
}
|
||||
|
||||
type LockManager struct {
|
||||
|
@ -383,7 +383,7 @@ func (lm *LockManager) GetPolicy(ctx context.Context, req PolicyRequest, rand io
|
|||
Derived: req.Derived,
|
||||
Exportable: req.Exportable,
|
||||
AllowPlaintextBackup: req.AllowPlaintextBackup,
|
||||
AutoRotateInterval: req.AutoRotateInterval,
|
||||
AutoRotatePeriod: req.AutoRotatePeriod,
|
||||
}
|
||||
|
||||
if req.Derived {
|
||||
|
|
|
@ -374,9 +374,9 @@ type Policy struct {
|
|||
// policy object.
|
||||
StoragePrefix string `json:"storage_prefix"`
|
||||
|
||||
// AutoRotateInterval defines how frequently the key should automatically
|
||||
// AutoRotatePeriod defines how frequently the key should automatically
|
||||
// rotate. Setting this to zero disables automatic rotation for the key.
|
||||
AutoRotateInterval time.Duration `json:"auto_rotate_interval"`
|
||||
AutoRotatePeriod time.Duration `json:"auto_rotate_period"`
|
||||
|
||||
// versionPrefixCache stores caches of version prefix strings and the split
|
||||
// version template.
|
||||
|
|
|
@ -126,19 +126,6 @@ export default ApplicationAdapter.extend({
|
|||
return this.ajax(url, verb, options);
|
||||
},
|
||||
|
||||
mfaValidate({ mfa_request_id, mfa_constraints }) {
|
||||
const options = {
|
||||
data: {
|
||||
mfa_request_id,
|
||||
mfa_payload: mfa_constraints.reduce((obj, { selectedMethod, passcode }) => {
|
||||
obj[selectedMethod.id] = passcode ? [passcode] : [];
|
||||
return obj;
|
||||
}, {}),
|
||||
},
|
||||
};
|
||||
return this.ajax('/v1/sys/mfa/validate', 'POST', options);
|
||||
},
|
||||
|
||||
urlFor(endpoint) {
|
||||
if (!ENDPOINTS.includes(endpoint)) {
|
||||
throw new Error(
|
||||
|
|
|
@ -18,13 +18,13 @@ const BACKENDS = supportedAuthBackends();
|
|||
*
|
||||
* @example ```js
|
||||
* // All properties are passed in via query params.
|
||||
* <AuthForm @wrappedToken={{wrappedToken}} @cluster={{model}} @namespace={{namespaceQueryParam}} @selectedAuth={{authMethod}} @onSuccess={{action this.onSuccess}} />```
|
||||
* <AuthForm @wrappedToken={{wrappedToken}} @cluster={{model}} @namespace={{namespaceQueryParam}} @redirectTo={{redirectTo}} @selectedAuth={{authMethod}}/>```
|
||||
*
|
||||
* @param {string} wrappedToken - The auth method that is currently selected in the dropdown.
|
||||
* @param {object} cluster - The auth method that is currently selected in the dropdown. This corresponds to an Ember Model.
|
||||
* @param {string} namespace- The currently active namespace.
|
||||
* @param {string} selectedAuth - The auth method that is currently selected in the dropdown.
|
||||
* @param {function} onSuccess - Fired on auth success
|
||||
* @param wrappedToken=null {String} - The auth method that is currently selected in the dropdown.
|
||||
* @param cluster=null {Object} - The auth method that is currently selected in the dropdown. This corresponds to an Ember Model.
|
||||
* @param namespace=null {String} - The currently active namespace.
|
||||
* @param redirectTo=null {String} - The name of the route to redirect to.
|
||||
* @param selectedAuth=null {String} - The auth method that is currently selected in the dropdown.
|
||||
*/
|
||||
|
||||
const DEFAULTS = {
|
||||
|
@ -45,6 +45,7 @@ export default Component.extend(DEFAULTS, {
|
|||
selectedAuth: null,
|
||||
methods: null,
|
||||
cluster: null,
|
||||
redirectTo: null,
|
||||
namespace: null,
|
||||
wrappedToken: null,
|
||||
// internal
|
||||
|
@ -205,18 +206,54 @@ export default Component.extend(DEFAULTS, {
|
|||
|
||||
showLoading: or('isLoading', 'authenticate.isRunning', 'fetchMethods.isRunning', 'unwrapToken.isRunning'),
|
||||
|
||||
handleError(e, prefixMessage = true) {
|
||||
this.set('loading', false);
|
||||
let errors;
|
||||
if (e.errors) {
|
||||
errors = e.errors.map((error) => {
|
||||
if (error.detail) {
|
||||
return error.detail;
|
||||
}
|
||||
return error;
|
||||
});
|
||||
} else {
|
||||
errors = [e];
|
||||
}
|
||||
let message = prefixMessage ? 'Authentication failed: ' : '';
|
||||
this.set('error', `${message}${errors.join('.')}`);
|
||||
},
|
||||
|
||||
authenticate: task(
|
||||
waitFor(function* (backendType, data) {
|
||||
let clusterId = this.cluster.id;
|
||||
try {
|
||||
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)}`);
|
||||
if (backendType === 'okta') {
|
||||
this.delayAuthMessageReminder.perform();
|
||||
}
|
||||
let authResponse = yield this.auth.authenticate({ clusterId, backend: backendType, data });
|
||||
|
||||
let { isRoot, namespace } = authResponse;
|
||||
let transition;
|
||||
let { redirectTo } = this;
|
||||
if (redirectTo) {
|
||||
// reset the value on the controller because it's bound here
|
||||
this.set('redirectTo', '');
|
||||
// here we don't need the namespace because it will be encoded in redirectTo
|
||||
transition = this.router.transitionTo(redirectTo);
|
||||
} else {
|
||||
transition = this.router.transitionTo('vault.cluster', { queryParams: { namespace } });
|
||||
}
|
||||
// returning this w/then because if we keep it
|
||||
// in the task, it will get cancelled when the component in un-rendered
|
||||
yield transition.followRedirects().then(() => {
|
||||
if (isRoot) {
|
||||
this.flashMessages.warning(
|
||||
'You have logged in with a root token. As a security precaution, this root token will not be stored by your browser and you will need to re-authenticate after the window is closed or refreshed.'
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
this.handleError(e);
|
||||
}
|
||||
})
|
||||
),
|
||||
|
@ -225,9 +262,9 @@ export default Component.extend(DEFAULTS, {
|
|||
if (Ember.testing) {
|
||||
this.showLoading = true;
|
||||
yield timeout(0);
|
||||
} else {
|
||||
yield timeout(5000);
|
||||
return;
|
||||
}
|
||||
yield timeout(5000);
|
||||
}),
|
||||
|
||||
actions: {
|
||||
|
@ -261,10 +298,11 @@ export default Component.extend(DEFAULTS, {
|
|||
return this.authenticate.unlinked().perform(backend.type, data);
|
||||
},
|
||||
handleError(e) {
|
||||
this.setProperties({
|
||||
loading: false,
|
||||
error: e ? this.auth.handleError(e) : null,
|
||||
});
|
||||
if (e) {
|
||||
this.handleError(e, false);
|
||||
} else {
|
||||
this.set('error', null);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -15,6 +15,9 @@ export default class Current extends Component {
|
|||
return { name: namespace['label'], id: namespace['label'] };
|
||||
});
|
||||
|
||||
@tracked selectedAuthMethod = null;
|
||||
@tracked authMethodOptions = [];
|
||||
|
||||
// Response client count data by namespace for current/partial month
|
||||
get byNamespaceCurrent() {
|
||||
return this.args.model.monthly?.byNamespace || [];
|
||||
|
@ -26,7 +29,21 @@ export default class Current extends Component {
|
|||
}
|
||||
|
||||
get hasAttributionData() {
|
||||
return this.totalUsageCounts.clients !== 0 && this.totalClientsData.length !== 0;
|
||||
return this.totalUsageCounts.clients !== 0 && !!this.totalClientsData && !this.selectedAuthMethod;
|
||||
}
|
||||
|
||||
get filteredActivity() {
|
||||
const namespace = this.selectedNamespace;
|
||||
const auth = this.selectedAuthMethod;
|
||||
if (!namespace && !auth) {
|
||||
return this.getActivityResponse;
|
||||
}
|
||||
if (!auth) {
|
||||
return this.byNamespaceCurrent.find((ns) => ns.label === namespace);
|
||||
}
|
||||
return this.byNamespaceCurrent
|
||||
.find((ns) => ns.label === namespace)
|
||||
.mounts?.find((mount) => mount.label === auth);
|
||||
}
|
||||
|
||||
get countsIncludeOlderData() {
|
||||
|
@ -41,16 +58,13 @@ export default class Current extends Component {
|
|||
|
||||
// top level TOTAL client counts for current/partial month
|
||||
get totalUsageCounts() {
|
||||
return this.selectedNamespace
|
||||
? this.filterByNamespace(this.selectedNamespace)
|
||||
: this.args.model.monthly?.total;
|
||||
return this.selectedNamespace ? this.filteredActivity : this.args.model.monthly?.total;
|
||||
}
|
||||
|
||||
// total client data for horizontal bar chart in attribution component
|
||||
get totalClientsData() {
|
||||
if (this.selectedNamespace) {
|
||||
let filteredNamespace = this.filterByNamespace(this.selectedNamespace);
|
||||
return filteredNamespace.mounts ? this.filterByNamespace(this.selectedNamespace).mounts : null;
|
||||
return this.filteredActivity?.mounts || null;
|
||||
} else {
|
||||
return this.byNamespaceCurrent;
|
||||
}
|
||||
|
@ -60,15 +74,26 @@ export default class Current extends Component {
|
|||
return this.args.model.monthly?.responseTimestamp;
|
||||
}
|
||||
|
||||
// HELPERS
|
||||
filterByNamespace(namespace) {
|
||||
return this.byNamespaceCurrent.find((ns) => ns.label === namespace);
|
||||
}
|
||||
|
||||
// ACTIONS
|
||||
@action
|
||||
selectNamespace([value]) {
|
||||
// value comes in as [namespace0]
|
||||
this.selectedNamespace = value;
|
||||
if (!value) {
|
||||
// on clear, also make sure auth method is cleared
|
||||
this.selectedAuthMethod = null;
|
||||
} else {
|
||||
// Side effect: set auth namespaces
|
||||
const mounts = this.filteredActivity.mounts?.map((mount) => ({
|
||||
id: mount.label,
|
||||
name: mount.label,
|
||||
}));
|
||||
this.authMethodOptions = mounts;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
setAuthMethod([authMount]) {
|
||||
this.selectedAuthMethod = authMount;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,10 +38,15 @@ export default class History extends Component {
|
|||
years = Array.from({ length: 5 }, (item, i) => {
|
||||
return new Date().getFullYear() - i;
|
||||
});
|
||||
currentDate = new Date();
|
||||
currentYear = this.currentDate.getFullYear(); // integer of year
|
||||
currentMonth = this.currentDate.getMonth(); // index of month
|
||||
|
||||
@tracked isEditStartMonthOpen = false;
|
||||
@tracked startMonth = null;
|
||||
@tracked startYear = null;
|
||||
@tracked allowedMonthMax = 12;
|
||||
@tracked disabledYear = null;
|
||||
|
||||
// FOR HISTORY COMPONENT //
|
||||
|
||||
|
@ -57,14 +62,19 @@ export default class History extends Component {
|
|||
|
||||
// SEARCH SELECT
|
||||
@tracked selectedNamespace = null;
|
||||
@tracked namespaceArray = this.getActivityResponse.byNamespace.map((namespace) => {
|
||||
return { name: namespace['label'], id: namespace['label'] };
|
||||
});
|
||||
@tracked namespaceArray = this.getActivityResponse.byNamespace.map((namespace) => ({
|
||||
name: namespace.label,
|
||||
id: namespace.label,
|
||||
}));
|
||||
|
||||
// TEMPLATE MESSAGING
|
||||
@tracked noActivityDate = '';
|
||||
@tracked responseRangeDiffMessage = null;
|
||||
@tracked isLoadingQuery = false;
|
||||
@tracked licenseStartIsCurrentMonth = this.args.model.activity?.isLicenseDateError || false;
|
||||
|
||||
@tracked selectedAuthMethod = null;
|
||||
@tracked authMethodOptions = [];
|
||||
|
||||
get versionText() {
|
||||
return this.version.isEnterprise
|
||||
|
@ -92,7 +102,7 @@ export default class History extends Component {
|
|||
}
|
||||
|
||||
get hasAttributionData() {
|
||||
return this.totalUsageCounts.clients !== 0 && this.totalClientsData.length !== 0;
|
||||
return this.totalUsageCounts.clients !== 0 && !!this.totalClientsData && !this.selectedAuthMethod;
|
||||
}
|
||||
|
||||
get startTimeDisplay() {
|
||||
|
@ -113,6 +123,20 @@ export default class History extends Component {
|
|||
return `${this.arrayOfMonths[month]} ${year}`;
|
||||
}
|
||||
|
||||
get filteredActivity() {
|
||||
const namespace = this.selectedNamespace;
|
||||
const auth = this.selectedAuthMethod;
|
||||
if (!namespace && !auth) {
|
||||
return this.getActivityResponse;
|
||||
}
|
||||
if (!auth) {
|
||||
return this.getActivityResponse.byNamespace.find((ns) => ns.label === namespace);
|
||||
}
|
||||
return this.getActivityResponse.byNamespace
|
||||
.find((ns) => ns.label === namespace)
|
||||
.mounts?.find((mount) => mount.label === auth);
|
||||
}
|
||||
|
||||
get isDateRange() {
|
||||
return !isSameMonth(
|
||||
new Date(this.getActivityResponse.startTime),
|
||||
|
@ -122,16 +146,13 @@ export default class History extends Component {
|
|||
|
||||
// top level TOTAL client counts for given date range
|
||||
get totalUsageCounts() {
|
||||
return this.selectedNamespace
|
||||
? this.filterByNamespace(this.selectedNamespace)
|
||||
: this.getActivityResponse.total;
|
||||
return this.selectedNamespace ? this.filteredActivity : this.getActivityResponse.total;
|
||||
}
|
||||
|
||||
// total client data for horizontal bar chart in attribution component
|
||||
get totalClientsData() {
|
||||
if (this.selectedNamespace) {
|
||||
let filteredNamespace = this.filterByNamespace(this.selectedNamespace);
|
||||
return filteredNamespace.mounts ? this.filterByNamespace(this.selectedNamespace).mounts : null;
|
||||
return this.filteredActivity?.mounts || null;
|
||||
} else {
|
||||
return this.getActivityResponse?.byNamespace;
|
||||
}
|
||||
|
@ -157,6 +178,7 @@ export default class History extends Component {
|
|||
|
||||
@action
|
||||
async handleClientActivityQuery(month, year, dateType) {
|
||||
this.isEditStartMonthOpen = false;
|
||||
if (dateType === 'cancel') {
|
||||
return;
|
||||
}
|
||||
|
@ -195,6 +217,7 @@ export default class History extends Component {
|
|||
this.storage().setItem(INPUTTED_START_DATE, this.startTimeFromResponse);
|
||||
}
|
||||
this.queriedActivityResponse = response;
|
||||
this.licenseStartIsCurrentMonth = response.isLicenseDateError;
|
||||
// compare if the response startTime comes after the requested startTime. If true throw a warning.
|
||||
// only display if they selected a startTime
|
||||
if (
|
||||
|
@ -209,7 +232,6 @@ export default class History extends Component {
|
|||
this.responseRangeDiffMessage = null;
|
||||
}
|
||||
} catch (e) {
|
||||
// TODO CMB surface API errors when user selects start date after end date
|
||||
return e;
|
||||
} finally {
|
||||
this.isLoadingQuery = false;
|
||||
|
@ -225,22 +247,38 @@ export default class History extends Component {
|
|||
selectNamespace([value]) {
|
||||
// value comes in as [namespace0]
|
||||
this.selectedNamespace = value;
|
||||
if (!value) {
|
||||
// on clear, also make sure auth method is cleared
|
||||
this.selectedAuthMethod = null;
|
||||
} else {
|
||||
// Side effect: set auth namespaces
|
||||
const mounts = this.filteredActivity.mounts?.map((mount) => ({
|
||||
id: mount.label,
|
||||
name: mount.label,
|
||||
}));
|
||||
this.authMethodOptions = mounts;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
setAuthMethod([authMount]) {
|
||||
this.selectedAuthMethod = authMount;
|
||||
}
|
||||
|
||||
// FOR START DATE MODAL
|
||||
@action
|
||||
selectStartMonth(month) {
|
||||
selectStartMonth(month, event) {
|
||||
this.startMonth = month;
|
||||
// disables months if in the future
|
||||
this.disabledYear = this.months.indexOf(month) >= this.currentMonth ? this.currentYear : null;
|
||||
event.close();
|
||||
}
|
||||
|
||||
@action
|
||||
selectStartYear(year) {
|
||||
selectStartYear(year, event) {
|
||||
this.startYear = year;
|
||||
}
|
||||
|
||||
// HELPERS //
|
||||
filterByNamespace(namespace) {
|
||||
return this.getActivityResponse.byNamespace.find((ns) => ns.label === namespace);
|
||||
this.allowedMonthMax = year === this.currentYear ? this.currentMonth : 12;
|
||||
event.close();
|
||||
}
|
||||
|
||||
storage() {
|
||||
|
|
|
@ -12,9 +12,15 @@ import { tracked } from '@glimmer/tracking';
|
|||
* ```
|
||||
* @param {function} handleDateSelection - is the action from the parent that the date picker triggers
|
||||
* @param {string} [name] - optional argument passed from date dropdown to parent function
|
||||
* @param {string} [submitText] - optional argument to change submit button text
|
||||
*/
|
||||
|
||||
export default class DateDropdown extends Component {
|
||||
currentDate = new Date();
|
||||
currentYear = this.currentDate.getFullYear(); // integer of year
|
||||
currentMonth = this.currentDate.getMonth(); // index of month
|
||||
|
||||
@tracked allowedMonthMax = 12;
|
||||
@tracked disabledYear = null;
|
||||
@tracked startMonth = null;
|
||||
@tracked startYear = null;
|
||||
|
||||
|
@ -26,13 +32,18 @@ export default class DateDropdown extends Component {
|
|||
});
|
||||
|
||||
@action
|
||||
selectStartMonth(month) {
|
||||
selectStartMonth(month, event) {
|
||||
this.startMonth = month;
|
||||
// disables months if in the future
|
||||
this.disabledYear = this.months.indexOf(month) >= this.currentMonth ? this.currentYear : null;
|
||||
event.close();
|
||||
}
|
||||
|
||||
@action
|
||||
selectStartYear(year) {
|
||||
selectStartYear(year, event) {
|
||||
this.startYear = year;
|
||||
this.allowedMonthMax = year === this.currentYear ? this.currentMonth : 12;
|
||||
event.close();
|
||||
}
|
||||
|
||||
@action
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -95,10 +95,10 @@ export default Component.extend(FocusOnInsertMixin, {
|
|||
|
||||
handleAutoRotateChange(ttlObj) {
|
||||
if (ttlObj.enabled) {
|
||||
set(this.key, 'autoRotateInterval', ttlObj.goSafeTimeString);
|
||||
set(this.key, 'autoRotatePeriod', ttlObj.goSafeTimeString);
|
||||
this.set('autoRotateInvalid', ttlObj.seconds < 3600);
|
||||
} else {
|
||||
set(this.key, 'autoRotateInterval', 0);
|
||||
set(this.key, 'autoRotatePeriod', 0);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -8,18 +8,13 @@ export default Controller.extend({
|
|||
clusterController: controller('vault.cluster'),
|
||||
namespaceService: service('namespace'),
|
||||
featureFlagService: service('featureFlag'),
|
||||
auth: service(),
|
||||
router: service(),
|
||||
|
||||
queryParams: [{ authMethod: 'with', oidcProvider: 'o' }],
|
||||
|
||||
namespaceQueryParam: alias('clusterController.namespaceQueryParam'),
|
||||
queryParams: [{ authMethod: 'with', oidcProvider: 'o' }],
|
||||
wrappedToken: alias('vaultController.wrappedToken'),
|
||||
redirectTo: alias('vaultController.redirectTo'),
|
||||
managedNamespaceRoot: alias('featureFlagService.managedNamespaceRoot'),
|
||||
|
||||
authMethod: '',
|
||||
oidcProvider: '',
|
||||
redirectTo: alias('vaultController.redirectTo'),
|
||||
managedNamespaceRoot: alias('featureFlagService.managedNamespaceRoot'),
|
||||
|
||||
get managedNamespaceChild() {
|
||||
let fullParam = this.namespaceQueryParam;
|
||||
|
@ -46,39 +41,4 @@ export default Controller.extend({
|
|||
this.namespaceService.setNamespace(value, true);
|
||||
this.set('namespaceQueryParam', value);
|
||||
}).restartable(),
|
||||
|
||||
authSuccess({ isRoot, namespace }) {
|
||||
let transition;
|
||||
if (this.redirectTo) {
|
||||
// here we don't need the namespace because it will be encoded in redirectTo
|
||||
transition = this.router.transitionTo(this.redirectTo);
|
||||
// reset the value on the controller because it's bound here
|
||||
this.set('redirectTo', '');
|
||||
} else {
|
||||
transition = this.router.transitionTo('vault.cluster', { queryParams: { namespace } });
|
||||
}
|
||||
transition.followRedirects().then(() => {
|
||||
if (isRoot) {
|
||||
this.flashMessages.warning(
|
||||
'You have logged in with a root token. As a security precaution, this root token will not be stored by your browser and you will need to re-authenticate after the window is closed or refreshed.'
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
actions: {
|
||||
onAuthResponse(authResponse, backend, data) {
|
||||
const { mfa_requirement } = authResponse;
|
||||
// mfa methods handled by the backend are validated immediately in the auth service
|
||||
// if the user must choose between methods or enter passcodes further action is required
|
||||
if (mfa_requirement) {
|
||||
this.set('mfaAuthData', { mfa_requirement, backend, data });
|
||||
} else {
|
||||
this.authSuccess(authResponse);
|
||||
}
|
||||
},
|
||||
onMfaSuccess(authResponse) {
|
||||
this.authSuccess(authResponse);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
import { helper } from '@ember/component/helper';
|
||||
import { formatDuration, intervalToDuration } from 'date-fns';
|
||||
|
||||
export function duration([time]) {
|
||||
export function duration([time], { removeZero = false }) {
|
||||
// intervalToDuration creates a durationObject that turns the seconds (ex 3600) to respective:
|
||||
// { years: 0, months: 0, days: 0, hours: 1, minutes: 0, seconds: 0 }
|
||||
// then formatDuration returns the filled in keys of the durationObject
|
||||
|
||||
if (removeZero && time === '0') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// time must be in seconds
|
||||
let duration = Number.parseInt(time, 10);
|
||||
if (isNaN(duration)) {
|
||||
|
|
|
@ -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);
|
||||
});
|
|
@ -56,11 +56,11 @@ export default Model.extend({
|
|||
fieldValue: 'id',
|
||||
readOnly: true,
|
||||
}),
|
||||
autoRotateInterval: attr({
|
||||
autoRotatePeriod: attr({
|
||||
defaultValue: '0',
|
||||
defaultShown: 'Key is not automatically rotated',
|
||||
editType: 'ttl',
|
||||
label: 'Auto-rotation interval',
|
||||
label: 'Auto-rotation period',
|
||||
}),
|
||||
deletionAllowed: attr('boolean'),
|
||||
derived: attr('boolean'),
|
||||
|
|
|
@ -8,10 +8,13 @@ export default class HistoryRoute extends Route {
|
|||
try {
|
||||
// on init ONLY make network request if we have a start time from the license
|
||||
// otherwise user needs to manually input
|
||||
// TODO CMB what to return here?
|
||||
return start_time ? await this.store.queryRecord('clients/activity', { start_time }) : {};
|
||||
} catch (e) {
|
||||
return e;
|
||||
// returns 400 when license start date is in the current month
|
||||
if (e.httpStatus === 400) {
|
||||
return { isLicenseDateError: true };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ export default ApplicationSerializer.extend({
|
|||
id: payload.id,
|
||||
data: {
|
||||
...payload.data,
|
||||
enabled: payload.data.enabled.includes('enable') ? 'On' : 'Off',
|
||||
enabled: payload.data.enabled?.includes('enable') ? 'On' : 'Off',
|
||||
},
|
||||
};
|
||||
return this._super(store, primaryModelClass, normalizedPayload, id, requestType);
|
||||
|
|
|
@ -50,12 +50,12 @@ export default RESTSerializer.extend({
|
|||
const min_decryption_version = snapshot.attr('minDecryptionVersion');
|
||||
const min_encryption_version = snapshot.attr('minEncryptionVersion');
|
||||
const deletion_allowed = snapshot.attr('deletionAllowed');
|
||||
const auto_rotate_interval = snapshot.attr('autoRotateInterval');
|
||||
const auto_rotate_period = snapshot.attr('autoRotatePeriod');
|
||||
return {
|
||||
min_decryption_version,
|
||||
min_encryption_version,
|
||||
deletion_allowed,
|
||||
auto_rotate_interval,
|
||||
auto_rotate_period,
|
||||
};
|
||||
} else {
|
||||
snapshot.id = snapshot.attr('name');
|
||||
|
|
|
@ -3,7 +3,6 @@ import { resolve, reject } from 'rsvp';
|
|||
import { assign } from '@ember/polyfills';
|
||||
import { isArray } from '@ember/array';
|
||||
import { computed, get } from '@ember/object';
|
||||
import { capitalize } from '@ember/string';
|
||||
|
||||
import fetch from 'fetch';
|
||||
import { getOwner } from '@ember/application';
|
||||
|
@ -15,10 +14,9 @@ import { task, timeout } from 'ember-concurrency';
|
|||
const TOKEN_SEPARATOR = '☃';
|
||||
const TOKEN_PREFIX = 'vault-';
|
||||
const ROOT_PREFIX = '_root_';
|
||||
const TOTP_NOT_CONFIGURED = 'TOTP mfa required but not configured';
|
||||
const BACKENDS = supportedAuthBackends();
|
||||
|
||||
export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX, TOTP_NOT_CONFIGURED };
|
||||
export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX };
|
||||
|
||||
export default Service.extend({
|
||||
permissions: service(),
|
||||
|
@ -26,8 +24,6 @@ export default Service.extend({
|
|||
IDLE_TIMEOUT: 3 * 60e3,
|
||||
expirationCalcTS: null,
|
||||
isRenewing: false,
|
||||
mfaErrors: null,
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
this.checkForRootToken();
|
||||
|
@ -326,98 +322,16 @@ export default Service.extend({
|
|||
});
|
||||
},
|
||||
|
||||
_parseMfaResponse(mfa_requirement) {
|
||||
// mfa_requirement response comes back in a shape that is not easy to work with
|
||||
// convert to array of objects and add necessary properties to satisfy the view
|
||||
if (mfa_requirement) {
|
||||
const { mfa_request_id, mfa_constraints } = mfa_requirement;
|
||||
let requiresAction; // if multiple constraints or methods or passcode input is needed further action will be required
|
||||
const constraints = [];
|
||||
for (let key in mfa_constraints) {
|
||||
const methods = mfa_constraints[key].any;
|
||||
const isMulti = methods.length > 1;
|
||||
if (isMulti || methods.findBy('uses_passcode')) {
|
||||
requiresAction = true;
|
||||
}
|
||||
// friendly label for display in MfaForm
|
||||
methods.forEach((m) => {
|
||||
const typeFormatted = m.type === 'totp' ? m.type.toUpperCase() : capitalize(m.type);
|
||||
m.label = `${typeFormatted} ${m.uses_passcode ? 'passcode' : 'push notification'}`;
|
||||
});
|
||||
constraints.push({
|
||||
name: key,
|
||||
methods,
|
||||
selectedMethod: isMulti ? null : methods[0],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
mfa_requirement: { mfa_request_id, mfa_constraints: constraints },
|
||||
requiresAction,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
|
||||
async authenticate(/*{clusterId, backend, data}*/) {
|
||||
const [options] = arguments;
|
||||
const adapter = this.clusterAdapter();
|
||||
let resp;
|
||||
|
||||
try {
|
||||
resp = await adapter.authenticate(options);
|
||||
} catch (e) {
|
||||
// TODO: check for totp not configured mfa error before throwing
|
||||
const errors = this.handleError(e);
|
||||
// stubbing error - verify once API is finalized
|
||||
if (errors.includes(TOTP_NOT_CONFIGURED)) {
|
||||
this.set('mfaErrors', errors);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const { mfa_requirement, requiresAction } = this._parseMfaResponse(resp.auth?.mfa_requirement);
|
||||
if (mfa_requirement) {
|
||||
if (requiresAction) {
|
||||
return { mfa_requirement };
|
||||
}
|
||||
// silently make request to validate endpoint when passcode is not required
|
||||
try {
|
||||
resp = await adapter.mfaValidate(mfa_requirement);
|
||||
} catch (e) {
|
||||
// it's not clear in the auth-form component whether mfa validation is taking place for non-totp method
|
||||
// since mfa errors display a screen rather than flash message handle separately
|
||||
this.set('mfaErrors', this.handleError(e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return this.authSuccess(options, resp.auth || resp.data);
|
||||
},
|
||||
|
||||
async totpValidate({ mfa_requirement, ...options }) {
|
||||
const resp = await this.clusterAdapter().mfaValidate(mfa_requirement);
|
||||
return this.authSuccess(options, resp.auth || resp.data);
|
||||
},
|
||||
|
||||
async authSuccess(options, response) {
|
||||
const authData = await this.persistAuthData(options, response, this.namespaceService.path);
|
||||
let resp = await adapter.authenticate(options);
|
||||
let authData = await this.persistAuthData(options, resp.auth || resp.data, this.namespaceService.path);
|
||||
await this.permissions.getPaths.perform();
|
||||
return authData;
|
||||
},
|
||||
|
||||
handleError(e) {
|
||||
if (e.errors) {
|
||||
return e.errors.map((error) => {
|
||||
if (error.detail) {
|
||||
return error.detail;
|
||||
}
|
||||
return error;
|
||||
});
|
||||
}
|
||||
return [e];
|
||||
},
|
||||
|
||||
getAuthType() {
|
||||
if (!this.authData) return;
|
||||
return this.authData.backend.type;
|
||||
|
|
|
@ -51,7 +51,3 @@
|
|||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-blue {
|
||||
color: $blue;
|
||||
}
|
||||
|
|
|
@ -54,7 +54,7 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12);
|
|||
background-color: $color;
|
||||
color: $color-invert;
|
||||
|
||||
&:hover,
|
||||
&:hover:not([disabled]),
|
||||
&.is-hovered {
|
||||
background-color: darken($color, 5%);
|
||||
border-color: darken($color, 5%);
|
||||
|
@ -237,11 +237,3 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12);
|
|||
padding: $size-8;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
|
@ -19,9 +19,6 @@
|
|||
.is-borderless {
|
||||
border: none !important;
|
||||
}
|
||||
.is-box-shadowless {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.is-relative {
|
||||
position: relative;
|
||||
}
|
||||
|
@ -191,9 +188,6 @@
|
|||
.has-top-margin-xl {
|
||||
margin-top: $spacing-xl;
|
||||
}
|
||||
.has-top-margin-xxl {
|
||||
margin-top: $spacing-xxl;
|
||||
}
|
||||
.has-border-bottom-light {
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid $grey-light;
|
||||
|
@ -210,9 +204,7 @@ ul.bullet {
|
|||
.has-text-semibold {
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
.is-v-centered {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.has-text-grey-400 {
|
||||
color: $ui-gray-400;
|
||||
}
|
||||
|
|
|
@ -32,7 +32,20 @@
|
|||
@onChange={{this.selectNamespace}}
|
||||
@placeholder={{"Filter by namespace"}}
|
||||
@displayInherit={{true}}
|
||||
class="is-marginless"
|
||||
/>
|
||||
{{#if this.selectedNamespace}}
|
||||
<SearchSelect
|
||||
@id="auth-method-search-select"
|
||||
@options={{this.authMethodOptions}}
|
||||
@selectLimit="1"
|
||||
@disallowNewItems={{true}}
|
||||
@fallbackComponent="input-search"
|
||||
@onChange={{this.setAuthMethod}}
|
||||
@placeholder={{"Filter by auth method"}}
|
||||
@displayInherit={{true}}
|
||||
/>
|
||||
{{/if}}
|
||||
</ToolbarFilters>
|
||||
</Toolbar>
|
||||
</div>
|
||||
|
|
|
@ -14,13 +14,22 @@
|
|||
Edit
|
||||
</button>
|
||||
{{else}}
|
||||
<DateDropdown @handleDateSelection={{this.handleClientActivityQuery}} @name={{"startTime"}} />
|
||||
<DateDropdown @handleDateSelection={{this.handleClientActivityQuery}} @name={{"startTime"}} @submitText="Save" />
|
||||
{{/if}}
|
||||
</div>
|
||||
<p class="is-8 has-text-grey has-bottom-margin-xl">
|
||||
{{this.versionText.description}}
|
||||
</p>
|
||||
{{#if (eq @model.config.queriesAvailable false)}}
|
||||
{{#if this.licenseStartIsCurrentMonth}}
|
||||
<EmptyState
|
||||
@title="No data for this billing period"
|
||||
@subTitle="Your billing period has just begun, so there is no data yet. Data will be available here on the first of next month."
|
||||
@message="To view data from a previous billing period, you can enter your previous billing start date."
|
||||
@bottomBorder={{true}}
|
||||
>
|
||||
<DateDropdown @handleDateSelection={{this.handleClientActivityQuery}} @name={{"startTime"}} @submitText="View" />
|
||||
</EmptyState>
|
||||
{{else if (eq @model.config.queriesAvailable false)}}
|
||||
{{#if (eq @model.config.enabled "On")}}
|
||||
<EmptyState
|
||||
@title={{concat "No monthly history " (if this.noActivityDate "from ") this.noActivityDate}}
|
||||
|
@ -74,6 +83,19 @@
|
|||
@onChange={{this.selectNamespace}}
|
||||
@placeholder={{"Filter by namespace"}}
|
||||
@displayInherit={{true}}
|
||||
class="is-marginless"
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if this.selectedNamespace}}
|
||||
<SearchSelect
|
||||
@id="auth-method-search-select"
|
||||
@options={{this.authMethodOptions}}
|
||||
@selectLimit="1"
|
||||
@disallowNewItems={{true}}
|
||||
@fallbackComponent="input-search"
|
||||
@onChange={{this.setAuthMethod}}
|
||||
@placeholder={{"Filter by auth method"}}
|
||||
@displayInherit={{true}}
|
||||
/>
|
||||
{{/if}}
|
||||
</ToolbarFilters>
|
||||
|
@ -125,8 +147,10 @@
|
|||
<EmptyState @title="No data received" @message="No data exists for that query period. Try searching again." />
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{else}}
|
||||
{{else if (or (not @model.startTimeFromLicense) (not this.startTimeFromResponse))}}
|
||||
<EmptyState @title={{this.versionText.title}} @message={{this.versionText.message}} />
|
||||
{{else}}
|
||||
<EmptyState @title="No data received" @message="No data exists for that query period. Try searching again." />
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
|
@ -155,11 +179,12 @@
|
|||
<D.Content @defaultClass="popup-menu-content is-wide">
|
||||
<nav class="box menu scroll">
|
||||
<ul class="menu-list">
|
||||
{{#each this.months as |month|}}
|
||||
{{#each this.months as |month index|}}
|
||||
<button
|
||||
type="button"
|
||||
class="link"
|
||||
{{on "click" (queue (fn this.selectStartMonth month) (action D.actions.close))}}
|
||||
class="button link"
|
||||
disabled={{if (lt index this.allowedMonthMax) false true}}
|
||||
{{on "click" (fn this.selectStartMonth month D.actions)}}
|
||||
>
|
||||
{{month}}
|
||||
</button>
|
||||
|
@ -183,8 +208,9 @@
|
|||
{{#each this.years as |year|}}
|
||||
<button
|
||||
type="button"
|
||||
class="link"
|
||||
{{on "click" (queue (fn this.selectStartYear year) (action D.actions.close))}}
|
||||
class="button link"
|
||||
disabled={{if (eq year this.disabledYear) true false}}
|
||||
{{on "click" (fn this.selectStartYear year D.actions)}}
|
||||
>
|
||||
{{year}}
|
||||
</button>
|
||||
|
@ -199,22 +225,12 @@
|
|||
<button
|
||||
type="button"
|
||||
class="button is-primary"
|
||||
onclick={{queue
|
||||
(action (mut this.isEditStartMonthOpen) false)
|
||||
(action "handleClientActivityQuery" this.startMonth this.startYear "startTime")
|
||||
}}
|
||||
disabled={{if (and this.startMonth this.startYear) false true}}
|
||||
disabled={{or (if (and this.startMonth this.startYear) false true)}}
|
||||
{{on "click" (fn this.handleClientActivityQuery this.startMonth this.startYear "startTime")}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-secondary"
|
||||
{{on
|
||||
"click"
|
||||
(queue (action (mut this.isEditStartMonthOpen) false) (fn this.handleClientActivityQuery 0 0 "cancel"))
|
||||
}}
|
||||
>
|
||||
<button type="button" class="button is-secondary" {{on "click" (fn this.handleClientActivityQuery 0 0 "cancel")}}>
|
||||
Cancel
|
||||
</button>
|
||||
</footer>
|
||||
|
|
|
@ -10,11 +10,12 @@
|
|||
<D.Content @defaultClass="popup-menu-content is-wide">
|
||||
<nav class="box menu scroll">
|
||||
<ul class="menu-list">
|
||||
{{#each this.months as |month|}}
|
||||
{{#each this.months as |month index|}}
|
||||
<button
|
||||
type="button"
|
||||
class="link"
|
||||
{{on "click" (queue (fn this.selectStartMonth month) (action D.actions.close))}}
|
||||
class="button link"
|
||||
disabled={{if (lt index this.allowedMonthMax) false true}}
|
||||
{{on "click" (fn this.selectStartMonth month D.actions)}}
|
||||
>
|
||||
{{month}}
|
||||
</button>
|
||||
|
@ -36,7 +37,12 @@
|
|||
<nav class="box menu">
|
||||
<ul class="menu-list">
|
||||
{{#each this.years as |year|}}
|
||||
<button type="button" class="link" {{on "click" (queue (fn this.selectStartYear year) (action D.actions.close))}}>
|
||||
<button
|
||||
type="button"
|
||||
class="button link"
|
||||
disabled={{if (eq year this.disabledYear) true false}}
|
||||
{{on "click" (fn this.selectStartYear year D.actions)}}
|
||||
>
|
||||
{{year}}
|
||||
</button>
|
||||
{{/each}}
|
||||
|
@ -50,5 +56,5 @@
|
|||
disabled={{if (and this.startMonth this.startYear) false true}}
|
||||
{{on "click" this.saveDateSelection}}
|
||||
>
|
||||
Save
|
||||
{{or @submitText "Submit"}}
|
||||
</button>
|
|
@ -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>
|
|
@ -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>
|
|
@ -10,26 +10,21 @@
|
|||
</div>
|
||||
</Nav.items>
|
||||
</NavHeader>
|
||||
{{! bypass UiWizard and container styling }}
|
||||
{{#if this.hasAltContent}}
|
||||
{{yield (hash altContent=(component "splash-page/splash-content"))}}
|
||||
{{else}}
|
||||
<UiWizard>
|
||||
<div class="splash-page-container section is-flex-v-centered-tablet is-flex-1 is-fullwidth">
|
||||
<div class="columns is-centered is-gapless is-fullwidth">
|
||||
<div class="column is-4-desktop is-6-tablet">
|
||||
<div class="splash-page-header">
|
||||
{{yield (hash header=(component "splash-page/splash-header"))}}
|
||||
</div>
|
||||
<div class="splash-page-sub-header">
|
||||
{{yield (hash sub-header=(component "splash-page/splash-header"))}}
|
||||
</div>
|
||||
<div class="login-form box is-paddingless is-relative">
|
||||
{{yield (hash content=(component "splash-page/splash-content"))}}
|
||||
</div>
|
||||
{{yield (hash footer=(component "splash-page/splash-content"))}}
|
||||
<UiWizard>
|
||||
<div class="splash-page-container section is-flex-v-centered-tablet is-flex-1 is-fullwidth">
|
||||
<div class="columns is-centered is-gapless is-fullwidth">
|
||||
<div class="column is-4-desktop is-6-tablet">
|
||||
<div class="splash-page-header">
|
||||
{{yield (hash header=(component "splash-page/splash-header"))}}
|
||||
</div>
|
||||
<div class="splash-page-sub-header">
|
||||
{{yield (hash sub-header=(component "splash-page/splash-header"))}}
|
||||
</div>
|
||||
<div class="login-form box is-paddingless is-relative">
|
||||
{{yield (hash content=(component "splash-page/splash-content"))}}
|
||||
</div>
|
||||
{{yield (hash footer=(component "splash-page/splash-content"))}}
|
||||
</div>
|
||||
</div>
|
||||
</UiWizard>
|
||||
{{/if}}
|
||||
</div>
|
||||
</UiWizard>
|
|
@ -10,7 +10,7 @@
|
|||
<TtlPicker2
|
||||
@initialValue="1h"
|
||||
@initialEnabled={{false}}
|
||||
@label="Auto-rotation interval"
|
||||
@label="Auto-rotation period"
|
||||
@helperTextDisabled="Key will never be automatically rotated"
|
||||
@helperTextEnabled="Key will be automatically rotated every"
|
||||
@onChange={{@handleAutoRotateChange}}
|
||||
|
|
|
@ -18,9 +18,9 @@
|
|||
</div>
|
||||
<div class="field">
|
||||
<TtlPicker2
|
||||
@initialValue={{or @key.autoRotateInterval "1h"}}
|
||||
@initialEnabled={{not (eq @key.autoRotateInterval "0s")}}
|
||||
@label="Auto-rotation interval"
|
||||
@initialValue={{or @key.autoRotatePeriod "1h"}}
|
||||
@initialEnabled={{not (eq @key.autoRotatePeriod "0s")}}
|
||||
@label="Auto-rotation period"
|
||||
@helperTextDisabled="Key will never be automatically rotated"
|
||||
@helperTextEnabled="Key will be automatically rotated every"
|
||||
@onChange={{@handleAutoRotateChange}}
|
||||
|
|
|
@ -171,8 +171,8 @@
|
|||
{{else}}
|
||||
<InfoTableRow @label="Type" @value={{@key.type}} />
|
||||
<InfoTableRow
|
||||
@label="Auto-rotation interval"
|
||||
@value={{or (format-ttl @key.autoRotateInterval removeZero=true) "Key will not be automatically rotated"}}
|
||||
@label="Auto-rotation period"
|
||||
@value={{or (format-duration @key.autoRotatePeriod removeZero=true) "Key will not be automatically rotated"}}
|
||||
/>
|
||||
<InfoTableRow @label="Deletion allowed" @value={{stringify @key.deletionAllowed}} />
|
||||
|
||||
|
|
|
@ -1,101 +1,84 @@
|
|||
<SplashPage @hasAltContent={{this.auth.mfaErrors}} as |Page|>
|
||||
<Page.altContent>
|
||||
<MfaError @onClose={{fn (mut this.mfaAuthData) null}} />
|
||||
</Page.altContent>
|
||||
<SplashPage as |Page|>
|
||||
<Page.header>
|
||||
{{#if this.oidcProvider}}
|
||||
<div class="box is-shadowless is-flex-v-centered" data-test-auth-logo>
|
||||
<LogoEdition aria-label="Sign in with Hashicorp Vault" />
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="is-flex-row">
|
||||
{{#if this.mfaAuthData}}
|
||||
<button type="button" class="icon-button" {{on "click" (fn (mut this.mfaAuthData) null)}}>
|
||||
<Icon @name="arrow-left" @size="24" aria-label="Back to login" class="icon-blue" />
|
||||
</button>
|
||||
{{/if}}
|
||||
<h1 class="title is-3">
|
||||
{{if this.mfaAuthData "Authenticate" "Sign in to Vault"}}
|
||||
</h1>
|
||||
</div>
|
||||
<h1 class="title is-3">
|
||||
Sign in to Vault
|
||||
</h1>
|
||||
{{/if}}
|
||||
</Page.header>
|
||||
{{#unless this.mfaAuthData}}
|
||||
{{#if this.managedNamespaceRoot}}
|
||||
<Page.sub-header>
|
||||
<Toolbar>
|
||||
<div class="toolbar-namespace-picker" data-test-managed-namespace-toolbar>
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label">
|
||||
<label class="is-label" for="namespace">Namespace</label>
|
||||
</div>
|
||||
<div class="field-label">
|
||||
<span class="has-text-grey" data-test-managed-namespace-root>/{{this.managedNamespaceRoot}}</span>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<input
|
||||
value={{this.managedNamespaceChild}}
|
||||
placeholder="/ (Default)"
|
||||
oninput={{perform this.updateManagedNamespace value="target.value"}}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
name="namespace"
|
||||
id="namespace"
|
||||
class="input"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Toolbar>
|
||||
</Page.sub-header>
|
||||
{{else if (has-feature "Namespaces")}}
|
||||
<Page.sub-header>
|
||||
<Toolbar class="toolbar-namespace-picker">
|
||||
<div class="field is-horizontal" data-test-namespace-toolbar>
|
||||
<div class="field-label is-normal">
|
||||
{{#if this.managedNamespaceRoot}}
|
||||
<Page.sub-header>
|
||||
<Toolbar>
|
||||
<div class="toolbar-namespace-picker" data-test-managed-namespace-toolbar>
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label">
|
||||
<label class="is-label" for="namespace">Namespace</label>
|
||||
</div>
|
||||
<div class="field-label">
|
||||
<span class="has-text-grey" data-test-managed-namespace-root>/{{this.managedNamespaceRoot}}</span>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<input
|
||||
value={{this.namespaceQueryParam}}
|
||||
placeholder="/ (Root)"
|
||||
oninput={{perform this.updateNamespace value="target.value"}}
|
||||
value={{this.managedNamespaceChild}}
|
||||
placeholder="/ (Default)"
|
||||
oninput={{perform this.updateManagedNamespace value="target.value"}}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
name="namespace"
|
||||
id="namespace"
|
||||
class="input"
|
||||
type="text"
|
||||
disabled={{this.oidcProvider}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Toolbar>
|
||||
</Page.sub-header>
|
||||
{{/if}}
|
||||
{{/unless}}
|
||||
</div>
|
||||
</Toolbar>
|
||||
</Page.sub-header>
|
||||
{{else if (has-feature "Namespaces")}}
|
||||
<Page.sub-header>
|
||||
<Toolbar class="toolbar-namespace-picker">
|
||||
<div class="field is-horizontal" data-test-namespace-toolbar>
|
||||
<div class="field-label is-normal">
|
||||
<label class="is-label" for="namespace">Namespace</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<input
|
||||
value={{this.namespaceQueryParam}}
|
||||
placeholder="/ (Root)"
|
||||
oninput={{perform this.updateNamespace value="target.value"}}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
name="namespace"
|
||||
id="namespace"
|
||||
class="input"
|
||||
type="text"
|
||||
disabled={{this.oidcProvider}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Toolbar>
|
||||
</Page.sub-header>
|
||||
{{/if}}
|
||||
<Page.content>
|
||||
{{#if this.mfaAuthData}}
|
||||
<MfaForm @clusterId={{this.model.id}} @authData={{this.mfaAuthData}} @onSuccess={{action "onMfaSuccess"}} />
|
||||
{{else}}
|
||||
<AuthForm
|
||||
@wrappedToken={{this.wrappedToken}}
|
||||
@cluster={{this.model}}
|
||||
@namespace={{this.namespaceQueryParam}}
|
||||
@redirectTo={{this.redirectTo}}
|
||||
@selectedAuth={{this.authMethod}}
|
||||
@onSuccess={{action "onAuthResponse"}}
|
||||
/>
|
||||
{{/if}}
|
||||
<AuthForm
|
||||
@wrappedToken={{this.wrappedToken}}
|
||||
@cluster={{this.model}}
|
||||
@namespace={{this.namespaceQueryParam}}
|
||||
@redirectTo={{this.redirectTo}}
|
||||
@selectedAuth={{this.authMethod}}
|
||||
/>
|
||||
</Page.content>
|
||||
<Page.footer>
|
||||
<div class="has-short-padding">
|
||||
|
|
|
@ -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}}
|
|
@ -10,16 +10,15 @@ import layout from '../templates/components/select';
|
|||
* <Select @label='Date Range' @options={{[{ value: 'berry', label: 'Berry' }]}} @onChange={{onChange}}/>
|
||||
* ```
|
||||
*
|
||||
* @param {string} [label=null] - The label for the select element.
|
||||
* @param {Array} [options=null] - A list of items that the user will select from. This can be an array of strings or objects.
|
||||
* @param {string} [selectedValue=null] - The currently selected item. Can also be used to set the default selected item. This should correspond to the `value` of one of the `<option>`s.
|
||||
* @param {string} [name = null] - The name of the select, used for the test selector.
|
||||
* @param {string} [valueAttribute = value]- When `options` is an array objects, the key to check for when assigning the option elements value.
|
||||
* @param {string} [labelAttribute = label] - When `options` is an array objects, the key to check for when assigning the option elements' inner text.
|
||||
* @param {boolean} [isInline = false] - Whether or not the select should be displayed as inline-block or block.
|
||||
* @param {boolean} [isFullwidth = false] - Whether or not the select should take up the full width of the parent element.
|
||||
* @param {boolean} [noDefault = false] - shows Select One with empty value as first option
|
||||
* @param {Func} [onChange] - The action to take once the user has selected an item. This method will be passed the `value` of the select.
|
||||
* @param label=null {String} - The label for the select element.
|
||||
* @param options=null {Array} - A list of items that the user will select from. This can be an array of strings or objects.
|
||||
* @param [selectedValue=null] {String} - The currently selected item. Can also be used to set the default selected item. This should correspond to the `value` of one of the `<option>`s.
|
||||
* @param [name=null] {String} - The name of the select, used for the test selector.
|
||||
* @param [valueAttribute=value] {String} - When `options` is an array objects, the key to check for when assigning the option elements value.
|
||||
* @param [labelAttribute=label] {String} - When `options` is an array objects, the key to check for when assigning the option elements' inner text.
|
||||
* @param [isInline=false] {Bool} - Whether or not the select should be displayed as inline-block or block.
|
||||
* @param [isFullwidth=false] {Bool} - Whether or not the select should take up the full width of the parent element.
|
||||
* @param onChange=null {Func} - The action to take once the user has selected an item. This method will be passed the `value` of the select.
|
||||
*/
|
||||
|
||||
export default Component.extend({
|
||||
|
@ -33,6 +32,5 @@ export default Component.extend({
|
|||
labelAttribute: 'label',
|
||||
isInline: false,
|
||||
isFullwidth: false,
|
||||
noDefault: false,
|
||||
onChange() {},
|
||||
});
|
||||
|
|
|
@ -11,11 +11,6 @@
|
|||
onchange={{action this.onChange value="target.value"}}
|
||||
data-test-select={{this.name}}
|
||||
>
|
||||
{{#if this.noDefault}}
|
||||
<option value="">
|
||||
Select one
|
||||
</option>
|
||||
{{/if}}
|
||||
{{#each this.options as |op|}}
|
||||
<option
|
||||
value={{or (get op this.valueAttribute) op}}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
});
|
|
@ -20,10 +20,15 @@ export default function (server) {
|
|||
};
|
||||
});
|
||||
|
||||
server.get('sys/internal/counters/config', function (db) {
|
||||
server.get('sys/internal/counters/config', function () {
|
||||
return {
|
||||
request_id: '00001',
|
||||
data: db['clients/configs'].first(),
|
||||
data: {
|
||||
default_report_months: 12,
|
||||
enabled: 'default-enable',
|
||||
queries_available: true,
|
||||
retention_months: 24,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -5573,6 +5578,24 @@ export default function (server) {
|
|||
non_entity_tokens: 15,
|
||||
clients: 100,
|
||||
},
|
||||
mounts: [
|
||||
{
|
||||
path: 'auth/method/uMGBU',
|
||||
counts: {
|
||||
clients: 35,
|
||||
entity_clients: 20,
|
||||
non_entity_clients: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'auth/method/woiej',
|
||||
counts: {
|
||||
clients: 35,
|
||||
entity_clients: 20,
|
||||
non_entity_clients: 15,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace_id: 'RxD81',
|
||||
|
@ -5582,6 +5605,24 @@ export default function (server) {
|
|||
non_entity_tokens: 20,
|
||||
clients: 55,
|
||||
},
|
||||
mounts: [
|
||||
{
|
||||
path: 'auth/method/ABCD1',
|
||||
counts: {
|
||||
clients: 35,
|
||||
entity_clients: 20,
|
||||
non_entity_clients: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'auth/method/ABCD2',
|
||||
counts: {
|
||||
clients: 35,
|
||||
entity_clients: 20,
|
||||
non_entity_clients: 15,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace_id: 'root',
|
||||
|
@ -5591,6 +5632,24 @@ export default function (server) {
|
|||
non_entity_tokens: 8,
|
||||
clients: 20,
|
||||
},
|
||||
mounts: [
|
||||
{
|
||||
path: 'auth/method/XYZZ2',
|
||||
counts: {
|
||||
clients: 35,
|
||||
entity_clients: 20,
|
||||
non_entity_clients: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'auth/method/XYZZ1',
|
||||
counts: {
|
||||
clients: 35,
|
||||
entity_clients: 20,
|
||||
non_entity_clients: 15,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
distinct_entities: 132,
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
// add all handlers here
|
||||
// individual lookup done in mirage config
|
||||
import base from './base';
|
||||
import mfa from './mfa';
|
||||
import activity from './activity';
|
||||
|
||||
export { base, activity, mfa };
|
||||
export { base, activity };
|
||||
|
|
|
@ -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'] });
|
||||
}
|
||||
});
|
||||
}
|
|
@ -3,21 +3,25 @@
|
|||
## AuthForm
|
||||
The `AuthForm` is used to sign users into Vault.
|
||||
|
||||
**Params**
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| wrappedToken | <code>string</code> | The auth method that is currently selected in the dropdown. |
|
||||
| cluster | <code>object</code> | The auth method that is currently selected in the dropdown. This corresponds to an Ember Model. |
|
||||
| namespace- | <code>string</code> | The currently active namespace. |
|
||||
| selectedAuth | <code>string</code> | The auth method that is currently selected in the dropdown. |
|
||||
| onSuccess | <code>function</code> | Fired on auth success |
|
||||
| Param | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| wrappedToken | <code>String</code> | <code></code> | The auth method that is currently selected in the dropdown. |
|
||||
| cluster | <code>Object</code> | <code></code> | The auth method that is currently selected in the dropdown. This corresponds to an Ember Model. |
|
||||
| namespace | <code>String</code> | <code></code> | The currently active namespace. |
|
||||
| redirectTo | <code>String</code> | <code></code> | The name of the route to redirect to. |
|
||||
| selectedAuth | <code>String</code> | <code></code> | The auth method that is currently selected in the dropdown. |
|
||||
|
||||
**Example**
|
||||
|
||||
```js
|
||||
// All properties are passed in via query params.
|
||||
<AuthForm @wrappedToken={{wrappedToken}} @cluster={{model}} @namespace={{namespaceQueryParam}} @selectedAuth={{authMethod}} @onSuccess={{action this.onSuccess}} />```
|
||||
<AuthForm
|
||||
@wrappedToken={{wrappedToken}}
|
||||
@cluster={{model}}
|
||||
@namespace={{namespaceQueryParam}}
|
||||
@redirectTo={{redirectTo}}
|
||||
@selectedAuth={{authMethod}}/>```
|
||||
|
||||
**See**
|
||||
|
||||
|
|
|
@ -110,10 +110,16 @@ module('Acceptance | auth', function (hooks) {
|
|||
assert.dom('[data-test-allow-expiration]').doesNotExist('hides beacon when the api is used again');
|
||||
});
|
||||
|
||||
test('it shows the push notification warning after submit', async function (assert) {
|
||||
test('it shows the push notification warning only for okta auth method after submit', async function (assert) {
|
||||
await visit('/vault/auth');
|
||||
await component.selectMethod('token');
|
||||
await click('[data-test-auth-submit]');
|
||||
assert
|
||||
.dom('[data-test-auth-message="push"]')
|
||||
.doesNotExist('message is not shown for other authentication methods');
|
||||
|
||||
await component.selectMethod('okta');
|
||||
await click('[data-test-auth-submit]');
|
||||
assert.dom('[data-test-auth-message="push"]').exists('shows push notification message');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -18,7 +18,6 @@ const authService = Service.extend({
|
|||
async authenticate() {
|
||||
return fetch('http://localhost:2000');
|
||||
},
|
||||
handleError() {},
|
||||
setLastFetch() {},
|
||||
});
|
||||
|
||||
|
@ -26,7 +25,6 @@ const workingAuthService = Service.extend({
|
|||
authenticate() {
|
||||
return resolve({});
|
||||
},
|
||||
handleError() {},
|
||||
setLastFetch() {},
|
||||
});
|
||||
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -6,6 +6,14 @@ import { hbs } from 'ember-cli-htmlbars';
|
|||
module('Integration | Helper | format-ttl', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it does not fail if no input', async function (assert) {
|
||||
this.set('inputValue', '');
|
||||
|
||||
await render(hbs`{{format-ttl inputValue}}`);
|
||||
|
||||
assert.equal(this.element.textContent.trim(), '');
|
||||
});
|
||||
|
||||
test('it renders the input if no match found', async function (assert) {
|
||||
this.set('inputValue', '1234');
|
||||
|
||||
|
|
|
@ -10,8 +10,6 @@ export default create({
|
|||
// make sure we're always logged out and logged back in
|
||||
await this.logout();
|
||||
await settled();
|
||||
// clear local storage to ensure we have a clean state
|
||||
window.localStorage.clear();
|
||||
await this.visit({ with: 'token' });
|
||||
await settled();
|
||||
if (token) {
|
||||
|
|
|
@ -424,10 +424,15 @@ func (c *Core) taintCredEntry(ctx context.Context, path string, updateStorage bo
|
|||
c.authLock.Lock()
|
||||
defer c.authLock.Unlock()
|
||||
|
||||
ns, err := namespace.FromContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Taint the entry from the auth table
|
||||
// We do this on the original since setting the taint operates
|
||||
// on the entries which a shallow clone shares anyways
|
||||
entry, err := c.auth.setTaint(ctx, strings.TrimPrefix(path, credentialRoutePrefix), true, mountStateUnmounting)
|
||||
entry, err := c.auth.setTaint(ns.ID, strings.TrimPrefix(path, credentialRoutePrefix), true, mountStateUnmounting)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -311,6 +311,10 @@ type Core struct {
|
|||
// change underneath a calling function
|
||||
mountsLock sync.RWMutex
|
||||
|
||||
// mountMigrationTracker tracks past and ongoing remount operations
|
||||
// against their migration ids
|
||||
mountMigrationTracker *sync.Map
|
||||
|
||||
// auth is loaded after unseal since it is a protected
|
||||
// configuration
|
||||
auth *MountTable
|
||||
|
@ -855,6 +859,7 @@ func CreateCore(conf *CoreConfig) (*Core, error) {
|
|||
disableAutopilot: conf.DisableAutopilot,
|
||||
enableResponseHeaderHostname: conf.EnableResponseHeaderHostname,
|
||||
enableResponseHeaderRaftNodeID: conf.EnableResponseHeaderRaftNodeID,
|
||||
mountMigrationTracker: &sync.Map{},
|
||||
disableSSCTokens: conf.DisableSSCTokens,
|
||||
}
|
||||
c.standbyStopCh.Store(make(chan struct{}))
|
||||
|
|
|
@ -178,7 +178,7 @@ func NewSystemBackend(core *Core, logger log.Logger) *SystemBackend {
|
|||
b.Backend.Paths = append(b.Backend.Paths, b.capabilitiesPaths()...)
|
||||
b.Backend.Paths = append(b.Backend.Paths, b.internalPaths()...)
|
||||
b.Backend.Paths = append(b.Backend.Paths, b.pprofPaths()...)
|
||||
b.Backend.Paths = append(b.Backend.Paths, b.remountPath())
|
||||
b.Backend.Paths = append(b.Backend.Paths, b.remountPaths()...)
|
||||
b.Backend.Paths = append(b.Backend.Paths, b.metricsPath())
|
||||
b.Backend.Paths = append(b.Backend.Paths, b.monitorPath())
|
||||
b.Backend.Paths = append(b.Backend.Paths, b.inFlightRequestPath())
|
||||
|
@ -1199,11 +1199,33 @@ func (b *SystemBackend) handleRemount(ctx context.Context, req *logical.Request,
|
|||
logical.ErrInvalidRequest
|
||||
}
|
||||
|
||||
if err = validateMountPath(toPath); err != nil {
|
||||
return handleError(fmt.Errorf("'to' %v", err))
|
||||
fromPathDetails := b.Core.splitNamespaceAndMountFromPath(ns.Path, fromPath)
|
||||
toPathDetails := b.Core.splitNamespaceAndMountFromPath(ns.Path, toPath)
|
||||
|
||||
if err = validateMountPath(toPathDetails.MountPath); err != nil {
|
||||
return handleError(fmt.Errorf("invalid destination mount: %v", err))
|
||||
}
|
||||
|
||||
entry := b.Core.router.MatchingMountEntry(ctx, fromPath)
|
||||
// Prevent target and source mounts from being in a protected path
|
||||
for _, p := range protectedMounts {
|
||||
if strings.HasPrefix(fromPathDetails.MountPath, p) {
|
||||
return handleError(fmt.Errorf("cannot remount %q", fromPathDetails.MountPath))
|
||||
}
|
||||
|
||||
if strings.HasPrefix(toPathDetails.MountPath, p) {
|
||||
return handleError(fmt.Errorf("cannot remount to destination %+v", toPathDetails.MountPath))
|
||||
}
|
||||
}
|
||||
|
||||
entry := b.Core.router.MatchingMountEntry(ctx, sanitizePath(fromPath))
|
||||
|
||||
if entry == nil {
|
||||
return handleError(fmt.Errorf("no matching mount at %q", sanitizePath(fromPath)))
|
||||
}
|
||||
|
||||
if match := b.Core.router.MatchingMount(ctx, toPath); match != "" {
|
||||
return handleError(fmt.Errorf("existing mount at %q", match))
|
||||
}
|
||||
// If we are a performance secondary cluster we should forward the request
|
||||
// to the primary. We fail early here since the view in use isn't marked as
|
||||
// readonly
|
||||
|
@ -1211,31 +1233,76 @@ func (b *SystemBackend) handleRemount(ctx context.Context, req *logical.Request,
|
|||
return nil, logical.ErrReadOnly
|
||||
}
|
||||
|
||||
migrationID, err := b.Core.createMigrationStatus(fromPathDetails, toPathDetails)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Error creating migration status %+v", err)
|
||||
}
|
||||
// Start up a goroutine to handle the remount operations, and return early to the caller
|
||||
go func(migrationID string) {
|
||||
b.Core.stateLock.RLock()
|
||||
defer b.Core.stateLock.RUnlock()
|
||||
|
||||
logger := b.Core.Logger().Named("mounts.migration").With("migration_id", migrationID, "namespace", ns.Path, "to_path", toPath, "from_path", fromPath)
|
||||
|
||||
var err error
|
||||
if !strings.Contains(fromPath, "auth") {
|
||||
err = b.moveSecretsEngine(ns, logger, migrationID, entry.ViewPath(), fromPathDetails, toPathDetails)
|
||||
} else {
|
||||
logger.Error("Remount is unsupported for the source mount", "err", err)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Error("remount failed", "error", err)
|
||||
if err := b.Core.setMigrationStatus(migrationID, MigrationFailureStatus); err != nil {
|
||||
logger.Error("Setting migration status failed", "error", err, "target_status", MigrationFailureStatus)
|
||||
}
|
||||
}
|
||||
}(migrationID)
|
||||
|
||||
resp := &logical.Response{
|
||||
Data: map[string]interface{}{
|
||||
"migration_id": migrationID,
|
||||
},
|
||||
}
|
||||
resp.AddWarning("Mount move has been queued. Progress will be reported in Vault's server log, tagged with the returned migration_id")
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// moveSecretsEngine carries out a remount operation on the secrets engine, updating the migration status as required
|
||||
// It is expected to be called asynchronously outside of a request context, hence it creates a context derived from the active one
|
||||
// and intermittently checks to see if it is still open.
|
||||
func (b *SystemBackend) moveSecretsEngine(ns *namespace.Namespace, logger log.Logger, migrationID, viewPath string, fromPathDetails, toPathDetails namespace.MountPathDetails) error {
|
||||
logger.Info("Starting to update the mount table and revoke leases")
|
||||
revokeCtx := namespace.ContextWithNamespace(b.Core.activeContext, ns)
|
||||
// Attempt remount
|
||||
if err := b.Core.remount(ctx, fromPath, toPath, !b.Core.perfStandby); err != nil {
|
||||
b.Backend.Logger().Error("remount failed", "from_path", fromPath, "to_path", toPath, "error", err)
|
||||
return handleError(err)
|
||||
if err := b.Core.remountSecretsEngine(revokeCtx, fromPathDetails, toPathDetails, !b.Core.perfStandby); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the view path if available
|
||||
var viewPath string
|
||||
if entry != nil {
|
||||
viewPath = entry.ViewPath()
|
||||
if err := revokeCtx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info("Removing the source mount from filtered paths on secondaries")
|
||||
// Remove from filtered mounts and restart evaluation process
|
||||
if err := b.Core.removePathFromFilteredPaths(ctx, ns.Path+fromPath, viewPath); err != nil {
|
||||
b.Backend.Logger().Error("filtered path removal failed", fromPath, "error", err)
|
||||
return handleError(err)
|
||||
if err := b.Core.removePathFromFilteredPaths(revokeCtx, fromPathDetails.GetFullPath(), viewPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update quotas with the new path
|
||||
if err := b.Core.quotaManager.HandleRemount(ctx, ns.Path, sanitizePath(fromPath), sanitizePath(toPath)); err != nil {
|
||||
b.Core.logger.Error("failed to update quotas after remount", "ns_path", ns.Path, "from_path", fromPath, "to_path", toPath, "error", err)
|
||||
return handleError(err)
|
||||
if err := revokeCtx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
logger.Info("Updating quotas associated with the source mount")
|
||||
// Update quotas with the new path and namespace
|
||||
if err := b.Core.quotaManager.HandleRemount(revokeCtx, fromPathDetails, toPathDetails); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := b.Core.setMigrationStatus(migrationID, MigrationSuccessStatus); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("Completed mount move operations")
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAuthTuneRead is used to get config settings on a auth path
|
||||
|
@ -1249,6 +1316,34 @@ func (b *SystemBackend) handleAuthTuneRead(ctx context.Context, req *logical.Req
|
|||
return b.handleTuneReadCommon(ctx, "auth/"+path)
|
||||
}
|
||||
|
||||
func (b *SystemBackend) handleRemountStatusCheck(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||
repState := b.Core.ReplicationState()
|
||||
|
||||
migrationID := data.Get("migration_id").(string)
|
||||
if migrationID == "" {
|
||||
return logical.ErrorResponse(
|
||||
"migrationID must be specified"),
|
||||
logical.ErrInvalidRequest
|
||||
}
|
||||
|
||||
migrationInfo := b.Core.readMigrationStatus(migrationID)
|
||||
if migrationInfo == nil {
|
||||
// If the migration info is not found and this is a perf secondary
|
||||
// forward the request to the primary cluster
|
||||
if repState.HasState(consts.ReplicationPerformanceSecondary) {
|
||||
return nil, logical.ErrReadOnly
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
resp := &logical.Response{
|
||||
Data: map[string]interface{}{
|
||||
"migration_id": migrationID,
|
||||
"migration_info": migrationInfo,
|
||||
},
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// handleMountTuneRead is used to get config settings on a backend
|
||||
func (b *SystemBackend) handleMountTuneRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||
path := data.Get("path").(string)
|
||||
|
@ -4519,7 +4614,7 @@ in the plugin catalog.`,
|
|||
},
|
||||
|
||||
"remount": {
|
||||
"Move the mount point of an already-mounted backend.",
|
||||
"Move the mount point of an already-mounted backend, within or across namespaces",
|
||||
`
|
||||
This path responds to the following HTTP methods.
|
||||
|
||||
|
@ -4528,6 +4623,15 @@ This path responds to the following HTTP methods.
|
|||
`,
|
||||
},
|
||||
|
||||
"remount-status": {
|
||||
"Check the status of a mount move operation",
|
||||
`
|
||||
This path responds to the following HTTP methods.
|
||||
GET /sys/remount/status/:migration_id
|
||||
Check the status of a mount move operation for the given migration_id
|
||||
`,
|
||||
},
|
||||
|
||||
"auth_tune": {
|
||||
"Tune the configuration parameters for an auth path.",
|
||||
`Read and write the 'default-lease-ttl' and 'max-lease-ttl' values of
|
||||
|
|
|
@ -1308,27 +1308,50 @@ func (b *SystemBackend) leasePaths() []*framework.Path {
|
|||
}
|
||||
}
|
||||
|
||||
func (b *SystemBackend) remountPath() *framework.Path {
|
||||
return &framework.Path{
|
||||
Pattern: "remount",
|
||||
func (b *SystemBackend) remountPaths() []*framework.Path {
|
||||
return []*framework.Path{
|
||||
{
|
||||
Pattern: "remount",
|
||||
|
||||
Fields: map[string]*framework.FieldSchema{
|
||||
"from": {
|
||||
Type: framework.TypeString,
|
||||
Description: "The previous mount point.",
|
||||
Fields: map[string]*framework.FieldSchema{
|
||||
"from": {
|
||||
Type: framework.TypeString,
|
||||
Description: "The previous mount point.",
|
||||
},
|
||||
"to": {
|
||||
Type: framework.TypeString,
|
||||
Description: "The new mount point.",
|
||||
},
|
||||
},
|
||||
"to": {
|
||||
Type: framework.TypeString,
|
||||
Description: "The new mount point.",
|
||||
|
||||
Operations: map[logical.Operation]framework.OperationHandler{
|
||||
logical.UpdateOperation: &framework.PathOperation{
|
||||
Callback: b.handleRemount,
|
||||
Summary: "Initiate a mount migration",
|
||||
},
|
||||
},
|
||||
HelpSynopsis: strings.TrimSpace(sysHelp["remount"][0]),
|
||||
HelpDescription: strings.TrimSpace(sysHelp["remount"][1]),
|
||||
},
|
||||
{
|
||||
Pattern: "remount/status/(?P<migration_id>.+?)$",
|
||||
|
||||
Callbacks: map[logical.Operation]framework.OperationFunc{
|
||||
logical.UpdateOperation: b.handleRemount,
|
||||
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]),
|
||||
},
|
||||
|
||||
HelpSynopsis: strings.TrimSpace(sysHelp["remount"][0]),
|
||||
HelpDescription: strings.TrimSpace(sysHelp["remount"][1]),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -212,10 +212,10 @@ func (b *SystemBackend) handleRateLimitQuotasUpdate() framework.OperationFunc {
|
|||
case quota == nil:
|
||||
quota = quotas.NewRateLimitQuota(name, ns.Path, mountPath, rate, interval, blockInterval)
|
||||
default:
|
||||
rlq := quota.(*quotas.RateLimitQuota)
|
||||
// Re-inserting the already indexed object in memdb might cause problems.
|
||||
// So, clone the object. See https://github.com/hashicorp/go-memdb/issues/76.
|
||||
rlq = rlq.Clone()
|
||||
clonedQuota := quota.Clone()
|
||||
rlq := clonedQuota.(*quotas.RateLimitQuota)
|
||||
rlq.NamespacePath = ns.Path
|
||||
rlq.MountPath = mountPath
|
||||
rlq.Rate = rate
|
||||
|
|
|
@ -691,12 +691,18 @@ func TestSystemBackend_remount(t *testing.T) {
|
|||
req.Data["to"] = "foo"
|
||||
req.Data["config"] = structs.Map(MountConfig{})
|
||||
resp, err := b.HandleRequest(namespace.RootContext(nil), req)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if resp != nil {
|
||||
t.Fatalf("bad: %v", resp)
|
||||
}
|
||||
RetryUntil(t, 5*time.Second, func() error {
|
||||
req = logical.TestRequest(t, logical.ReadOperation, fmt.Sprintf("remount/status/%s", resp.Data["migration_id"]))
|
||||
resp, err = b.HandleRequest(namespace.RootContext(nil), req)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
migrationInfo := resp.Data["migration_info"].(*MountMigrationInfo)
|
||||
if migrationInfo.MigrationStatus != MigrationSuccessStatus.String() {
|
||||
return fmt.Errorf("Expected migration status to be successful, got %q", migrationInfo.MigrationStatus)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestSystemBackend_remount_invalid(t *testing.T) {
|
||||
|
@ -710,8 +716,8 @@ func TestSystemBackend_remount_invalid(t *testing.T) {
|
|||
if err != logical.ErrInvalidRequest {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if resp.Data["error"] != `no matching mount at "unknown/"` {
|
||||
t.Fatalf("bad: %v", resp)
|
||||
if !strings.Contains(resp.Data["error"].(string), "no matching mount at \"unknown/\"") {
|
||||
t.Fatalf("Found unexpected error %q", resp.Data["error"].(string))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -725,8 +731,8 @@ func TestSystemBackend_remount_system(t *testing.T) {
|
|||
if err != logical.ErrInvalidRequest {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if resp.Data["error"] != `cannot remount "sys/"` {
|
||||
t.Fatalf("bad: %v", resp)
|
||||
if !strings.Contains(resp.Data["error"].(string), "cannot remount \"sys/\"") {
|
||||
t.Fatalf("Found unexpected error %q", resp.Data["error"].(string))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -741,7 +747,7 @@ func TestSystemBackend_remount_clean(t *testing.T) {
|
|||
if err != logical.ErrInvalidRequest {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if resp.Data["error"] != `'to' path 'foo//bar' does not match cleaned path 'foo/bar'` {
|
||||
if resp.Data["error"] != `invalid destination mount: path 'foo//bar/' does not match cleaned path 'foo/bar/'` {
|
||||
t.Fatalf("bad: %v", resp)
|
||||
}
|
||||
}
|
||||
|
@ -757,7 +763,7 @@ func TestSystemBackend_remount_nonPrintable(t *testing.T) {
|
|||
if err != logical.ErrInvalidRequest {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if resp.Data["error"] != `'to' path cannot contain non-printable characters` {
|
||||
if resp.Data["error"] != `invalid destination mount: path cannot contain non-printable characters` {
|
||||
t.Fatalf("bad: %v", resp)
|
||||
}
|
||||
}
|
||||
|
|
199
vault/mount.go
199
vault/mount.go
|
@ -126,6 +126,32 @@ type MountTable struct {
|
|||
Entries []*MountEntry `json:"entries"`
|
||||
}
|
||||
|
||||
type MountMigrationStatus int
|
||||
|
||||
const (
|
||||
MigrationInProgressStatus MountMigrationStatus = iota
|
||||
MigrationSuccessStatus
|
||||
MigrationFailureStatus
|
||||
)
|
||||
|
||||
func (m MountMigrationStatus) String() string {
|
||||
switch m {
|
||||
case MigrationInProgressStatus:
|
||||
return "in-progress"
|
||||
case MigrationSuccessStatus:
|
||||
return "success"
|
||||
case MigrationFailureStatus:
|
||||
return "failure"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
type MountMigrationInfo struct {
|
||||
SourceMount string `json:"source_mount"`
|
||||
TargetMount string `json:"target_mount"`
|
||||
MigrationStatus string `json:"status"`
|
||||
}
|
||||
|
||||
// tableMetrics is responsible for setting gauge metrics for
|
||||
// mount table storage sizes (in bytes) and mount table num
|
||||
// entries. It does this via setGaugeWithLabels. It then
|
||||
|
@ -195,14 +221,10 @@ func (t *MountTable) shallowClone() *MountTable {
|
|||
|
||||
// setTaint is used to set the taint on given entry Accepts either the mount
|
||||
// entry's path or namespace + path, i.e. <ns-path>/secret/ or <ns-path>/token/
|
||||
func (t *MountTable) setTaint(ctx context.Context, path string, tainted bool, mountState string) (*MountEntry, error) {
|
||||
func (t *MountTable) setTaint(nsID, path string, tainted bool, mountState string) (*MountEntry, error) {
|
||||
n := len(t.Entries)
|
||||
ns, err := namespace.FromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
if entry := t.Entries[i]; entry.Path == path && entry.Namespace().ID == ns.ID {
|
||||
if entry := t.Entries[i]; entry.Path == path && entry.Namespace().ID == nsID {
|
||||
t.Entries[i].Tainted = tainted
|
||||
t.Entries[i].MountState = mountState
|
||||
return t.Entries[i], nil
|
||||
|
@ -662,7 +684,7 @@ func (c *Core) unmountInternal(ctx context.Context, path string, updateStorage b
|
|||
entry := c.router.MatchingMountEntry(ctx, path)
|
||||
|
||||
// Mark the entry as tainted
|
||||
if err := c.taintMountEntry(ctx, path, updateStorage, true); err != nil {
|
||||
if err := c.taintMountEntry(ctx, ns.ID, path, updateStorage, true); err != nil {
|
||||
c.logger.Error("failed to taint mount entry for path being unmounted", "error", err, "path", path)
|
||||
return err
|
||||
}
|
||||
|
@ -780,7 +802,7 @@ func (c *Core) removeMountEntry(ctx context.Context, path string, updateStorage
|
|||
}
|
||||
|
||||
// taintMountEntry is used to mark an entry in the mount table as tainted
|
||||
func (c *Core) taintMountEntry(ctx context.Context, path string, updateStorage, unmounting bool) error {
|
||||
func (c *Core) taintMountEntry(ctx context.Context, nsID, mountPath string, updateStorage, unmounting bool) error {
|
||||
c.mountsLock.Lock()
|
||||
defer c.mountsLock.Unlock()
|
||||
|
||||
|
@ -791,12 +813,12 @@ func (c *Core) taintMountEntry(ctx context.Context, path string, updateStorage,
|
|||
|
||||
// As modifying the taint of an entry affects shallow clones,
|
||||
// we simply use the original
|
||||
entry, err := c.mounts.setTaint(ctx, path, true, mountState)
|
||||
entry, err := c.mounts.setTaint(nsID, mountPath, true, mountState)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry == nil {
|
||||
c.logger.Error("nil entry found tainting entry in mounts table", "path", path)
|
||||
c.logger.Error("nil entry found tainting entry in mounts table", "path", mountPath)
|
||||
return logical.CodedError(500, "failed to taint entry in mounts table")
|
||||
}
|
||||
|
||||
|
@ -846,99 +868,90 @@ func (c *Core) remountForceInternal(ctx context.Context, path string, updateStor
|
|||
return nil
|
||||
}
|
||||
|
||||
// Remount is used to remount a path at a new mount point.
|
||||
func (c *Core) remount(ctx context.Context, src, dst string, updateStorage bool) error {
|
||||
func (c *Core) remountSecretsEngineCurrentNamespace(ctx context.Context, src, dst string, updateStorage bool) error {
|
||||
ns, err := namespace.FromContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure we end the path in a slash
|
||||
if !strings.HasSuffix(src, "/") {
|
||||
src += "/"
|
||||
}
|
||||
if !strings.HasSuffix(dst, "/") {
|
||||
dst += "/"
|
||||
}
|
||||
srcPathDetails := c.splitNamespaceAndMountFromPath(ns.Path, src)
|
||||
dstPathDetails := c.splitNamespaceAndMountFromPath(ns.Path, dst)
|
||||
return c.remountSecretsEngine(ctx, srcPathDetails, dstPathDetails, updateStorage)
|
||||
}
|
||||
|
||||
// Prevent protected paths from being remounted
|
||||
for _, p := range protectedMounts {
|
||||
if strings.HasPrefix(src, p) {
|
||||
return fmt.Errorf("cannot remount %q", src)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify exact match of the route
|
||||
srcMatch := c.router.MatchingMountEntry(ctx, src)
|
||||
if srcMatch == nil {
|
||||
return fmt.Errorf("no matching mount at %q", src)
|
||||
}
|
||||
if srcMatch.NamespaceID != ns.ID {
|
||||
return fmt.Errorf("source mount in a different namespace than request")
|
||||
}
|
||||
|
||||
if err := verifyNamespace(c, ns, &MountEntry{Path: dst}); err != nil {
|
||||
// remountSecretsEngine is used to remount a path at a new mount point.
|
||||
func (c *Core) remountSecretsEngine(ctx context.Context, src, dst namespace.MountPathDetails, updateStorage bool) error {
|
||||
ns, err := namespace.FromContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if match := c.router.MatchingMount(ctx, dst); match != "" {
|
||||
// Prevent protected paths from being remounted, or target mounts being in protected paths
|
||||
for _, p := range protectedMounts {
|
||||
if strings.HasPrefix(src.MountPath, p) {
|
||||
return fmt.Errorf("cannot remount %q", src.MountPath)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(dst.MountPath, p) {
|
||||
return fmt.Errorf("cannot remount to destination %+v", dst)
|
||||
}
|
||||
}
|
||||
|
||||
srcRelativePath := src.GetRelativePath(ns)
|
||||
dstRelativePath := dst.GetRelativePath(ns)
|
||||
|
||||
// Verify exact match of the route
|
||||
srcMatch := c.router.MatchingMountEntry(ctx, srcRelativePath)
|
||||
if srcMatch == nil {
|
||||
return fmt.Errorf("no matching mount at %+v", src)
|
||||
}
|
||||
|
||||
if match := c.router.MatchingMount(ctx, dstRelativePath); match != "" {
|
||||
return fmt.Errorf("existing mount at %q", match)
|
||||
}
|
||||
|
||||
// Mark the entry as tainted
|
||||
if err := c.taintMountEntry(ctx, src, updateStorage, false); err != nil {
|
||||
if err := c.taintMountEntry(ctx, src.Namespace.ID, src.MountPath, updateStorage, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Taint the router path to prevent routing
|
||||
if err := c.router.Taint(ctx, src); err != nil {
|
||||
if err := c.router.Taint(ctx, srcRelativePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !c.IsDRSecondary() {
|
||||
// Invoke the rollback manager a final time
|
||||
rCtx := namespace.ContextWithNamespace(c.activeContext, ns)
|
||||
if c.rollback != nil {
|
||||
if err := c.rollback.Rollback(rCtx, src); err != nil {
|
||||
if c.rollback != nil && c.router.MatchingBackend(ctx, srcRelativePath) != nil {
|
||||
if err := c.rollback.Rollback(rCtx, srcRelativePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if entry := c.router.MatchingMountEntry(ctx, src); entry == nil {
|
||||
return fmt.Errorf("no matching mount at %q", src)
|
||||
}
|
||||
|
||||
revokeCtx := namespace.ContextWithNamespace(ctx, src.Namespace)
|
||||
// Revoke all the dynamic keys
|
||||
if err := c.expiration.RevokePrefix(rCtx, src, true); err != nil {
|
||||
if err := c.expiration.RevokePrefix(revokeCtx, src.MountPath, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
c.mountsLock.Lock()
|
||||
if match := c.router.MatchingMount(ctx, dst); match != "" {
|
||||
if match := c.router.MatchingMount(ctx, dstRelativePath); match != "" {
|
||||
c.mountsLock.Unlock()
|
||||
return fmt.Errorf("existing mount at %q", match)
|
||||
}
|
||||
var entry *MountEntry
|
||||
for _, mountEntry := range c.mounts.Entries {
|
||||
if mountEntry.Path == src && mountEntry.NamespaceID == ns.ID {
|
||||
entry = mountEntry
|
||||
entry.Path = dst
|
||||
entry.Tainted = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
c.mountsLock.Unlock()
|
||||
c.logger.Error("failed to find entry in mounts table")
|
||||
return logical.CodedError(500, "failed to find entry in mounts table")
|
||||
}
|
||||
srcMatch.Tainted = false
|
||||
srcMatch.NamespaceID = dst.Namespace.ID
|
||||
srcMatch.namespace = dst.Namespace
|
||||
srcPath := srcMatch.Path
|
||||
srcMatch.Path = dst.MountPath
|
||||
|
||||
// Update the mount table
|
||||
if err := c.persistMounts(ctx, c.mounts, &entry.Local); err != nil {
|
||||
entry.Path = src
|
||||
entry.Tainted = true
|
||||
if err := c.persistMounts(ctx, c.mounts, &srcMatch.Local); err != nil {
|
||||
srcMatch.Path = srcPath
|
||||
srcMatch.Tainted = true
|
||||
c.mountsLock.Unlock()
|
||||
if err == logical.ErrReadOnly && c.perfStandby {
|
||||
return err
|
||||
|
@ -949,23 +962,37 @@ func (c *Core) remount(ctx context.Context, src, dst string, updateStorage bool)
|
|||
}
|
||||
|
||||
// Remount the backend
|
||||
if err := c.router.Remount(ctx, src, dst); err != nil {
|
||||
if err := c.router.Remount(ctx, srcRelativePath, dstRelativePath); err != nil {
|
||||
c.mountsLock.Unlock()
|
||||
return err
|
||||
}
|
||||
c.mountsLock.Unlock()
|
||||
|
||||
// Un-taint the path
|
||||
if err := c.router.Untaint(ctx, dst); err != nil {
|
||||
if err := c.router.Untaint(ctx, dstRelativePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.logger.IsInfo() {
|
||||
c.logger.Info("successful remount", "old_path", src, "new_path", dst)
|
||||
}
|
||||
c.logger.Info("successful remount", "old_path", src, "new_path", dst)
|
||||
return nil
|
||||
}
|
||||
|
||||
// From an input path that has a relative namespace heirarchy followed by a mount point, return the full
|
||||
// namespace of the mount point, along with the mount point without the namespace related prefix.
|
||||
// For example, in a heirarchy ns1/ns2/ns3/secret-mount, when currNs is ns1 and path is ns2/ns3/secret-mount,
|
||||
// this returns the namespace object for ns1/ns2/ns3/, and the string "secret-mount"
|
||||
func (c *Core) splitNamespaceAndMountFromPath(currNs, path string) namespace.MountPathDetails {
|
||||
fullPath := currNs + path
|
||||
fullNs := c.namespaceByPath(fullPath)
|
||||
|
||||
mountPath := strings.TrimPrefix(fullPath, fullNs.Path)
|
||||
|
||||
return namespace.MountPathDetails{
|
||||
Namespace: fullNs,
|
||||
MountPath: sanitizePath(mountPath),
|
||||
}
|
||||
}
|
||||
|
||||
// loadMounts is invoked as part of postUnseal to load the mount table
|
||||
func (c *Core) loadMounts(ctx context.Context) error {
|
||||
// Load the existing mount table
|
||||
|
@ -1580,3 +1607,37 @@ func (c *Core) setCoreBackend(entry *MountEntry, backend logical.Backend, view *
|
|||
c.identityStore = backend.(*IdentityStore)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Core) createMigrationStatus(from, to namespace.MountPathDetails) (string, error) {
|
||||
migrationID, err := uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error generating uuid for mount move invocation: %w", err)
|
||||
}
|
||||
migrationInfo := MountMigrationInfo{
|
||||
SourceMount: from.Namespace.Path + from.MountPath,
|
||||
TargetMount: to.Namespace.Path + to.MountPath,
|
||||
MigrationStatus: MigrationInProgressStatus.String(),
|
||||
}
|
||||
c.mountMigrationTracker.Store(migrationID, migrationInfo)
|
||||
return migrationID, nil
|
||||
}
|
||||
|
||||
func (c *Core) setMigrationStatus(migrationID string, migrationStatus MountMigrationStatus) error {
|
||||
migrationInfoRaw, ok := c.mountMigrationTracker.Load(migrationID)
|
||||
if !ok {
|
||||
return fmt.Errorf("Migration Tracker entry missing for ID %s", migrationID)
|
||||
}
|
||||
migrationInfo := migrationInfoRaw.(MountMigrationInfo)
|
||||
migrationInfo.MigrationStatus = migrationStatus.String()
|
||||
c.mountMigrationTracker.Store(migrationID, migrationInfo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Core) readMigrationStatus(migrationID string) *MountMigrationInfo {
|
||||
migrationInfoRaw, ok := c.mountMigrationTracker.Load(migrationID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
migrationInfo := migrationInfoRaw.(MountMigrationInfo)
|
||||
return &migrationInfo
|
||||
}
|
||||
|
|
|
@ -476,7 +476,7 @@ func TestCore_RemountConcurrent(t *testing.T) {
|
|||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := c2.remount(namespace.RootContext(nil), "test1", "foo", true)
|
||||
err := c2.remountSecretsEngineCurrentNamespace(namespace.RootContext(nil), "test1", "foo", true)
|
||||
if err != nil {
|
||||
t.Logf("err: %v", err)
|
||||
}
|
||||
|
@ -485,7 +485,7 @@ func TestCore_RemountConcurrent(t *testing.T) {
|
|||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := c2.remount(namespace.RootContext(nil), "test2", "foo", true)
|
||||
err := c2.remountSecretsEngineCurrentNamespace(namespace.RootContext(nil), "test2", "foo", true)
|
||||
if err != nil {
|
||||
t.Logf("err: %v", err)
|
||||
}
|
||||
|
@ -504,7 +504,7 @@ func TestCore_RemountConcurrent(t *testing.T) {
|
|||
|
||||
func TestCore_Remount(t *testing.T) {
|
||||
c, keys, _ := TestCoreUnsealed(t)
|
||||
err := c.remount(namespace.RootContext(nil), "secret", "foo", true)
|
||||
err := c.remountSecretsEngineCurrentNamespace(namespace.RootContext(nil), "secret", "foo", true)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
|
@ -612,7 +612,7 @@ func TestCore_Remount_Cleanup(t *testing.T) {
|
|||
}
|
||||
|
||||
// Remount, this should cleanup
|
||||
if err := c.remount(namespace.RootContext(nil), "test/", "new/", true); err != nil {
|
||||
if err := c.remountSecretsEngineCurrentNamespace(namespace.RootContext(nil), "test/", "new/", true); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
|
||||
|
@ -641,7 +641,7 @@ func TestCore_Remount_Cleanup(t *testing.T) {
|
|||
|
||||
func TestCore_Remount_Protected(t *testing.T) {
|
||||
c, _, _ := TestCoreUnsealed(t)
|
||||
err := c.remount(namespace.RootContext(nil), "sys", "foo", true)
|
||||
err := c.remountSecretsEngineCurrentNamespace(namespace.RootContext(nil), "sys", "foo", true)
|
||||
if err.Error() != `cannot remount "sys/"` {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
log "github.com/hashicorp/go-hclog"
|
||||
"github.com/hashicorp/go-memdb"
|
||||
"github.com/hashicorp/vault/helper/metricsutil"
|
||||
"github.com/hashicorp/vault/helper/namespace"
|
||||
"github.com/hashicorp/vault/sdk/helper/pathmanager"
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
)
|
||||
|
@ -183,8 +184,11 @@ type Quota interface {
|
|||
// rule is deleted.
|
||||
close(context.Context) error
|
||||
|
||||
// handleRemount takes in the new mount path in the quota
|
||||
handleRemount(string)
|
||||
// Clone creates a clone of the calling quota
|
||||
Clone() Quota
|
||||
|
||||
// handleRemount updates the mount and namesapce paths of the quota
|
||||
handleRemount(string, string)
|
||||
}
|
||||
|
||||
// Response holds information about the result of the Allow() call. The response
|
||||
|
@ -268,17 +272,41 @@ func (m *Manager) SetQuota(ctx context.Context, qType string, quota Quota, loadi
|
|||
return m.setQuotaLocked(ctx, qType, quota, loading)
|
||||
}
|
||||
|
||||
// setQuotaLocked adds or updates a quota rule, modifying the db as well as
|
||||
// any runtime elements such as goroutines.
|
||||
// It should be called with the write lock held.
|
||||
// setQuotaLocked creates a transaction, passes it into setQuotaLockedWithTxn and manages its lifecycle
|
||||
// along with updating lease quota counts
|
||||
func (m *Manager) setQuotaLocked(ctx context.Context, qType string, quota Quota, loading bool) error {
|
||||
txn := m.db.Txn(true)
|
||||
defer txn.Abort()
|
||||
|
||||
err := m.setQuotaLockedWithTxn(ctx, qType, quota, loading, txn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if loading {
|
||||
txn.Commit()
|
||||
return nil
|
||||
}
|
||||
|
||||
// For the lease count type, recompute the counters
|
||||
if !loading && qType == TypeLeaseCount.String() {
|
||||
if err := m.recomputeLeaseCounts(ctx, txn); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
txn.Commit()
|
||||
return nil
|
||||
}
|
||||
|
||||
// setQuotaLockedWithTxn adds or updates a quota rule, modifying the db as well as
|
||||
// any runtime elements such as goroutines, using the transaction passed in
|
||||
// It should be called with the write lock held.
|
||||
func (m *Manager) setQuotaLockedWithTxn(ctx context.Context, qType string, quota Quota, loading bool, txn *memdb.Txn) error {
|
||||
if qType == TypeLeaseCount.String() {
|
||||
m.setIsPerfStandby(quota)
|
||||
}
|
||||
|
||||
txn := m.db.Txn(true)
|
||||
defer txn.Abort()
|
||||
|
||||
raw, err := txn.First(qType, indexID, quota.quotaID())
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -306,19 +334,6 @@ func (m *Manager) setQuotaLocked(ctx context.Context, qType string, quota Quota,
|
|||
return err
|
||||
}
|
||||
|
||||
if loading {
|
||||
txn.Commit()
|
||||
return nil
|
||||
}
|
||||
|
||||
// For the lease count type, recompute the counters
|
||||
if !loading && qType == TypeLeaseCount.String() {
|
||||
if err := m.recomputeLeaseCounts(ctx, txn); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
txn.Commit()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -937,23 +952,30 @@ func QuotaStoragePath(quotaType, name string) string {
|
|||
|
||||
// HandleRemount updates the quota subsystem about the remount operation that
|
||||
// took place. Quota manager will trigger the quota specific updates including
|
||||
// the mount path update..
|
||||
func (m *Manager) HandleRemount(ctx context.Context, nsPath, fromPath, toPath string) error {
|
||||
// the mount path update and the namespace update
|
||||
func (m *Manager) HandleRemount(ctx context.Context, from, to namespace.MountPathDetails) error {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
// Grab a write transaction, as we want to save the updated quota in memdb
|
||||
txn := m.db.Txn(true)
|
||||
defer txn.Abort()
|
||||
|
||||
// nsPath would have been made non-empty during insertion. Use non-empty value
|
||||
// quota namespace would have been made non-empty during insertion. Use non-empty value
|
||||
// during query as well.
|
||||
if nsPath == "" {
|
||||
nsPath = "root"
|
||||
fromNs := from.Namespace.Path
|
||||
if fromNs == "" {
|
||||
fromNs = namespace.RootNamespaceID
|
||||
}
|
||||
|
||||
toNs := to.Namespace.Path
|
||||
if toNs == "" {
|
||||
toNs = namespace.RootNamespaceID
|
||||
}
|
||||
|
||||
idx := indexNamespaceMount
|
||||
leaseQuotaUpdated := false
|
||||
args := []interface{}{nsPath, fromPath}
|
||||
args := []interface{}{fromNs, from.MountPath}
|
||||
for _, quotaType := range quotaTypes() {
|
||||
iter, err := txn.Get(quotaType, idx, args...)
|
||||
if err != nil {
|
||||
|
@ -961,7 +983,11 @@ func (m *Manager) HandleRemount(ctx context.Context, nsPath, fromPath, toPath st
|
|||
}
|
||||
for raw := iter.Next(); raw != nil; raw = iter.Next() {
|
||||
quota := raw.(Quota)
|
||||
quota.handleRemount(toPath)
|
||||
|
||||
// Clone the object and update it
|
||||
clonedQuota := quota.Clone()
|
||||
clonedQuota.handleRemount(to.MountPath, toNs)
|
||||
// Update both underlying storage and memdb with the quota change
|
||||
entry, err := logical.StorageEntryJSON(QuotaStoragePath(quotaType, quota.QuotaName()), quota)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -969,6 +995,9 @@ func (m *Manager) HandleRemount(ctx context.Context, nsPath, fromPath, toPath st
|
|||
if err := m.storage.Put(ctx, entry); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.setQuotaLockedWithTxn(ctx, quotaType, clonedQuota, false, txn); err != nil {
|
||||
return err
|
||||
}
|
||||
if quotaType == TypeLeaseCount.String() {
|
||||
leaseQuotaUpdated = true
|
||||
}
|
||||
|
|
|
@ -101,7 +101,7 @@ func NewRateLimitQuota(name, nsPath, mountPath string, rate float64, interval, b
|
|||
}
|
||||
}
|
||||
|
||||
func (q *RateLimitQuota) Clone() *RateLimitQuota {
|
||||
func (q *RateLimitQuota) Clone() Quota {
|
||||
rlq := &RateLimitQuota{
|
||||
ID: q.ID,
|
||||
Name: q.Name,
|
||||
|
@ -337,6 +337,7 @@ func (rlq *RateLimitQuota) close(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (rlq *RateLimitQuota) handleRemount(toPath string) {
|
||||
rlq.MountPath = toPath
|
||||
func (rlq *RateLimitQuota) handleRemount(mountpath, nspath string) {
|
||||
rlq.MountPath = mountpath
|
||||
rlq.NamespacePath = nspath
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ func TestQuotas_MountPathOverwrite(t *testing.T) {
|
|||
|
||||
quota := NewRateLimitQuota("tq", "", "kv1/", 10, time.Second, 0)
|
||||
require.NoError(t, qm.SetQuota(context.Background(), TypeRateLimit.String(), quota, false))
|
||||
quota = quota.Clone()
|
||||
quota = quota.Clone().(*RateLimitQuota)
|
||||
quota.MountPath = "kv2/"
|
||||
require.NoError(t, qm.SetQuota(context.Background(), TypeRateLimit.String(), quota, false))
|
||||
|
||||
|
|
|
@ -60,6 +60,10 @@ func (l LeaseCountQuota) close(_ context.Context) error {
|
|||
panic("implement me")
|
||||
}
|
||||
|
||||
func (l LeaseCountQuota) handleRemount(s string) {
|
||||
func (l LeaseCountQuota) Clone() Quota {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (l LeaseCountQuota) handleRemount(mountPath, nsPath string) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
|
|
@ -64,7 +64,7 @@ values set here cannot be changed after key creation.
|
|||
- `rsa-3072` - RSA with bit size of 3072 (asymmetric)
|
||||
- `rsa-4096` - RSA with bit size of 4096 (asymmetric)
|
||||
|
||||
- `auto_rotate_interval` `(duration: "0", optional)` – The interval at which
|
||||
- `auto_rotate_period` `(duration: "0", optional)` – The period at which
|
||||
this key should be rotated automatically. Setting this to "0" (the default)
|
||||
will disable automatic key rotation. This value cannot be shorter than one
|
||||
hour.
|
||||
|
@ -232,10 +232,10 @@ are returned during a read operation on the named key.)
|
|||
- `allow_plaintext_backup` `(bool: false)` - If set, enables taking backup of
|
||||
named key in the plaintext format. Once set, this cannot be disabled.
|
||||
|
||||
- `auto_rotate_interval` `(duration: "", optional)` – The interval at which this
|
||||
- `auto_rotate_period` `(duration: "", optional)` – The period at which this
|
||||
key should be rotated automatically. Setting this to "0" will disable automatic
|
||||
key rotation. This value cannot be shorter than one hour. When no value is
|
||||
provided, the interval remains unchanged.
|
||||
provided, the period remains unchanged.
|
||||
|
||||
### Sample Payload
|
||||
|
||||
|
|
|
@ -128,7 +128,8 @@ Plugin authors who wish to have their plugins listed may file a submission via a
|
|||
|
||||
- [Jenkins](https://plugins.jenkins.io/hashicorp-vault-plugin)
|
||||
- [Terraform Enterprise/Terraform Cloud](https://github.com/gitrgoliveira/vault-plugin-auth-tfe)
|
||||
|
||||
- [SSH](https://github.com/42wim/vault-plugin-auth-ssh)
|
||||
|
||||
### Secrets
|
||||
|
||||
- [AWS Cognito](https://github.com/WealthWizardsEngineering/vault-plugin-secrets-cognito)
|
||||
|
|
Loading…
Reference in New Issue