mirror of
				https://github.com/optim-enterprises-bv/vault.git
				synced 2025-10-29 17:52:32 +00:00 
			
		
		
		
	| @@ -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 | ||||
| ``` | ||||
							
								
								
									
										3
									
								
								changelog/14067.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								changelog/14067.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| ```release-note:improvement | ||||
| api: Define constants for X-Vault-Forward and X-Vault-Inconsistent headers | ||||
| ``` | ||||
							
								
								
									
										3
									
								
								changelog/14107.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								changelog/14107.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -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"> | ||||
|   | ||||
							
								
								
									
										29
									
								
								ui/app/templates/vault/cluster/clients/error.hbs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								ui/app/templates/vault/cluster/clients/error.hbs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| {{#if (eq @model.httpStatus 404)}} | ||||
|   <NotFound @model={{this.model}} /> | ||||
| {{else}} | ||||
|   <EmptyState | ||||
|     @title={{if (eq @model.httpStatus 403) "You are not authorized" "Error"}} | ||||
|     @subTitle={{concat "Error " @model.httpStatus}} | ||||
|     @icon="skip" | ||||
|   > | ||||
|     {{#if (eq @model.httpStatus 403)}} | ||||
|       <p> | ||||
|         You must be granted permissions to view this page. Ask your administrator if you think you should have access to the | ||||
|         <code>{{@model.path}}</code> | ||||
|         endpoint. | ||||
|       </p> | ||||
|     {{else}} | ||||
|       <ul> | ||||
|         {{#if @model.message}} | ||||
|           <li>{{@model.message}}</li> | ||||
|         {{/if}} | ||||
|         <hr /> | ||||
|         {{#each @model.errors as |error|}} | ||||
|           <li> | ||||
|             {{error}} | ||||
|           </li> | ||||
|         {{/each}} | ||||
|       </ul> | ||||
|     {{/if}} | ||||
|   </EmptyState> | ||||
| {{/if}} | ||||
| @@ -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) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Jordan Reimer
					Jordan Reimer