[VAULT-1969] Add support for custom IAM usernames based on templates (#12066)
* add ability to customize IAM usernames based on templates * add changelog * remove unnecessary logs * patch: add test for readConfig * patch: add default STS Template * patch: remove unnecessary if cases * patch: add regex checks in username test * patch: update genUsername to return an error instead of warnings * patch: separate tests for default and custom templates * patch: return truncate warning from genUsername and trigger a 400 response on errors * patch: truncate midString to 42 chars in default template * docs: add new username_template field to aws docs
This commit is contained in:
parent
4a9669a1bc
commit
859b60cafc
|
@ -8,6 +8,8 @@ import (
|
||||||
"github.com/hashicorp/vault/sdk/logical"
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultUserNameTemplate = `{{ printf "vault-%s-%s-%s" (printf "%s-%s" (.DisplayName) (.PolicyName) | truncate 42) (unix_time) (random 20) | truncate 64 }}`
|
||||||
|
|
||||||
func pathConfigRoot(b *backend) *framework.Path {
|
func pathConfigRoot(b *backend) *framework.Path {
|
||||||
return &framework.Path{
|
return &framework.Path{
|
||||||
Pattern: "config/root",
|
Pattern: "config/root",
|
||||||
|
@ -39,6 +41,10 @@ func pathConfigRoot(b *backend) *framework.Path {
|
||||||
Default: aws.UseServiceDefaultRetries,
|
Default: aws.UseServiceDefaultRetries,
|
||||||
Description: "Maximum number of retries for recoverable exceptions of AWS APIs",
|
Description: "Maximum number of retries for recoverable exceptions of AWS APIs",
|
||||||
},
|
},
|
||||||
|
"username_template": {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: "Template to generate custom IAM usernames",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
Callbacks: map[logical.Operation]framework.OperationFunc{
|
Callbacks: map[logical.Operation]framework.OperationFunc{
|
||||||
|
@ -75,6 +81,7 @@ func (b *backend) pathConfigRootRead(ctx context.Context, req *logical.Request,
|
||||||
"iam_endpoint": config.IAMEndpoint,
|
"iam_endpoint": config.IAMEndpoint,
|
||||||
"sts_endpoint": config.STSEndpoint,
|
"sts_endpoint": config.STSEndpoint,
|
||||||
"max_retries": config.MaxRetries,
|
"max_retries": config.MaxRetries,
|
||||||
|
"username_template": config.UsernameTemplate,
|
||||||
}
|
}
|
||||||
return &logical.Response{
|
return &logical.Response{
|
||||||
Data: configData,
|
Data: configData,
|
||||||
|
@ -86,6 +93,10 @@ func (b *backend) pathConfigRootWrite(ctx context.Context, req *logical.Request,
|
||||||
iamendpoint := data.Get("iam_endpoint").(string)
|
iamendpoint := data.Get("iam_endpoint").(string)
|
||||||
stsendpoint := data.Get("sts_endpoint").(string)
|
stsendpoint := data.Get("sts_endpoint").(string)
|
||||||
maxretries := data.Get("max_retries").(int)
|
maxretries := data.Get("max_retries").(int)
|
||||||
|
usernameTemplate := data.Get("username_template").(string)
|
||||||
|
if usernameTemplate == "" {
|
||||||
|
usernameTemplate = defaultUserNameTemplate
|
||||||
|
}
|
||||||
|
|
||||||
b.clientMutex.Lock()
|
b.clientMutex.Lock()
|
||||||
defer b.clientMutex.Unlock()
|
defer b.clientMutex.Unlock()
|
||||||
|
@ -97,6 +108,7 @@ func (b *backend) pathConfigRootWrite(ctx context.Context, req *logical.Request,
|
||||||
STSEndpoint: stsendpoint,
|
STSEndpoint: stsendpoint,
|
||||||
Region: region,
|
Region: region,
|
||||||
MaxRetries: maxretries,
|
MaxRetries: maxretries,
|
||||||
|
UsernameTemplate: usernameTemplate,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -121,6 +133,7 @@ type rootConfig struct {
|
||||||
STSEndpoint string `json:"sts_endpoint"`
|
STSEndpoint string `json:"sts_endpoint"`
|
||||||
Region string `json:"region"`
|
Region string `json:"region"`
|
||||||
MaxRetries int `json:"max_retries"`
|
MaxRetries int `json:"max_retries"`
|
||||||
|
UsernameTemplate string `json:"username_template"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathConfigRootHelpSyn = `
|
const pathConfigRootHelpSyn = `
|
||||||
|
|
|
@ -24,6 +24,7 @@ func TestBackend_PathConfigRoot(t *testing.T) {
|
||||||
"iam_endpoint": "https://iam.amazonaws.com",
|
"iam_endpoint": "https://iam.amazonaws.com",
|
||||||
"sts_endpoint": "https://sts.us-west-2.amazonaws.com",
|
"sts_endpoint": "https://sts.us-west-2.amazonaws.com",
|
||||||
"max_retries": 10,
|
"max_retries": 10,
|
||||||
|
"username_template": defaultUserNameTemplate,
|
||||||
}
|
}
|
||||||
|
|
||||||
configReq := &logical.Request{
|
configReq := &logical.Request{
|
||||||
|
|
|
@ -3,12 +3,12 @@ package aws
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hashicorp/vault/sdk/framework"
|
"github.com/hashicorp/vault/sdk/framework"
|
||||||
"github.com/hashicorp/vault/sdk/helper/awsutil"
|
"github.com/hashicorp/vault/sdk/helper/awsutil"
|
||||||
|
"github.com/hashicorp/vault/sdk/helper/template"
|
||||||
"github.com/hashicorp/vault/sdk/logical"
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
|
@ -17,7 +17,11 @@ import (
|
||||||
"github.com/hashicorp/errwrap"
|
"github.com/hashicorp/errwrap"
|
||||||
)
|
)
|
||||||
|
|
||||||
const secretAccessKeyType = "access_keys"
|
const (
|
||||||
|
secretAccessKeyType = "access_keys"
|
||||||
|
storageKey = "config/root"
|
||||||
|
defaultSTSTemplate = `{{ printf "vault-%d-%d" (unix_time) (random 20) | truncate 32 }}`
|
||||||
|
)
|
||||||
|
|
||||||
func secretAccessKeys(b *backend) *framework.Secret {
|
func secretAccessKeys(b *backend) *framework.Secret {
|
||||||
return &framework.Secret{
|
return &framework.Secret{
|
||||||
|
@ -43,26 +47,45 @@ func secretAccessKeys(b *backend) *framework.Secret {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func genUsername(displayName, policyName, userType string) (ret string, warning string) {
|
func genUsername(displayName, policyName, userType, usernameTemplate string) (ret string, warning string, err error) {
|
||||||
var midString string
|
|
||||||
|
|
||||||
switch userType {
|
switch userType {
|
||||||
case "iam_user":
|
case "iam_user":
|
||||||
// IAM users are capped at 64 chars; this leaves, after the beginning and
|
// IAM users are capped at 64 chars; this leaves, after the beginning and
|
||||||
// end added below, 42 chars to play with.
|
// end added below, 42 chars to play with.
|
||||||
midString = fmt.Sprintf("%s-%s-",
|
up, err := template.NewTemplate(template.Template(usernameTemplate))
|
||||||
normalizeDisplayName(displayName),
|
if err != nil {
|
||||||
normalizeDisplayName(policyName))
|
return "", "", fmt.Errorf("unable to initialize username template: %w", err)
|
||||||
if len(midString) > 42 {
|
}
|
||||||
midString = midString[0:42]
|
|
||||||
warning = "the calling token display name/IAM policy name were truncated to fit into IAM username length limits"
|
um := UsernameMetadata{
|
||||||
|
DisplayName: normalizeDisplayName(displayName),
|
||||||
|
PolicyName: normalizeDisplayName(policyName),
|
||||||
|
}
|
||||||
|
|
||||||
|
ret, err = up.Generate(um)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to generate username: %w", err)
|
||||||
|
}
|
||||||
|
// To prevent template from exceeding IAM length limits
|
||||||
|
if len(ret) > 64 {
|
||||||
|
ret = ret[0:64]
|
||||||
|
warning = "the calling token display name/IAM policy name were truncated to 64 characters to fit within IAM username length limits"
|
||||||
}
|
}
|
||||||
case "sts":
|
case "sts":
|
||||||
// Capped at 32 chars, which leaves only a couple of characters to play
|
// Capped at 32 chars, which leaves only a couple of characters to play
|
||||||
// with, so don't insert display name or policy name at all
|
// with, so don't insert display name or policy name at all
|
||||||
|
up, err := template.NewTemplate(template.Template(usernameTemplate))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("unable to initialize username template: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ret = fmt.Sprintf("vault-%s%d-%d", midString, time.Now().Unix(), rand.Int31n(10000))
|
um := UsernameMetadata{}
|
||||||
|
ret, err = up.Generate(um)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to generate username: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -90,7 +113,11 @@ func (b *backend) getFederationToken(ctx context.Context, s logical.Storage,
|
||||||
return logical.ErrorResponse(err.Error()), nil
|
return logical.ErrorResponse(err.Error()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
username, usernameWarning := genUsername(displayName, policyName, "sts")
|
username, usernameWarning, usernameError := genUsername(displayName, policyName, "sts", defaultSTSTemplate)
|
||||||
|
// Send a 400 to Framework.OperationFunc Handler
|
||||||
|
if usernameError != nil {
|
||||||
|
return nil, usernameError
|
||||||
|
}
|
||||||
|
|
||||||
getTokenInput := &sts.GetFederationTokenInput{
|
getTokenInput := &sts.GetFederationTokenInput{
|
||||||
Name: aws.String(username),
|
Name: aws.String(username),
|
||||||
|
@ -165,15 +192,27 @@ func (b *backend) assumeRole(ctx context.Context, s logical.Storage,
|
||||||
return logical.ErrorResponse(err.Error()), nil
|
return logical.ErrorResponse(err.Error()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config, err := readConfig(ctx, s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to read configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set as defaultUsernameTemplate if not provided
|
||||||
|
usernameTemplate := config.UsernameTemplate
|
||||||
|
if usernameTemplate == "" {
|
||||||
|
usernameTemplate = defaultUserNameTemplate
|
||||||
|
}
|
||||||
|
|
||||||
roleSessionNameWarning := ""
|
roleSessionNameWarning := ""
|
||||||
|
var roleSessionNameError error
|
||||||
if roleSessionName == "" {
|
if roleSessionName == "" {
|
||||||
roleSessionName, roleSessionNameWarning = genUsername(displayName, roleName, "iam_user")
|
roleSessionName, roleSessionNameWarning, roleSessionNameError = genUsername(displayName, roleName, "iam_user", usernameTemplate)
|
||||||
|
// Send a 400 to Framework.OperationFunc Handler
|
||||||
|
if roleSessionNameError != nil {
|
||||||
|
return nil, roleSessionNameError
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
roleSessionName = normalizeDisplayName(roleSessionName)
|
roleSessionName = normalizeDisplayName(roleSessionName)
|
||||||
if len(roleSessionName) > 64 {
|
|
||||||
roleSessionName = roleSessionName[0:64]
|
|
||||||
roleSessionNameWarning = "the role session name was truncated to 64 characters to fit within IAM session name length limits"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assumeRoleInput := &sts.AssumeRoleInput{
|
assumeRoleInput := &sts.AssumeRoleInput{
|
||||||
|
@ -216,6 +255,22 @@ func (b *backend) assumeRole(ctx context.Context, s logical.Storage,
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readConfig(ctx context.Context, storage logical.Storage) (rootConfig, error) {
|
||||||
|
entry, err := storage.Get(ctx, storageKey)
|
||||||
|
if err != nil {
|
||||||
|
return rootConfig{}, err
|
||||||
|
}
|
||||||
|
if entry == nil {
|
||||||
|
return rootConfig{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var connConfig rootConfig
|
||||||
|
if err := entry.DecodeJSON(&connConfig); err != nil {
|
||||||
|
return rootConfig{}, err
|
||||||
|
}
|
||||||
|
return connConfig, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (b *backend) secretAccessKeysCreate(
|
func (b *backend) secretAccessKeysCreate(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
s logical.Storage,
|
s logical.Storage,
|
||||||
|
@ -226,7 +281,22 @@ func (b *backend) secretAccessKeysCreate(
|
||||||
return logical.ErrorResponse(err.Error()), nil
|
return logical.ErrorResponse(err.Error()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
username, usernameWarning := genUsername(displayName, policyName, "iam_user")
|
config, err := readConfig(ctx, s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to read configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set as defaultUsernameTemplate if not provided
|
||||||
|
usernameTemplate := config.UsernameTemplate
|
||||||
|
if usernameTemplate == "" {
|
||||||
|
usernameTemplate = defaultUserNameTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
username, usernameWarning, usernameError := genUsername(displayName, policyName, "iam_user", usernameTemplate)
|
||||||
|
// Send a 400 to Framework.OperationFunc Handler
|
||||||
|
if usernameError != nil {
|
||||||
|
return nil, usernameError
|
||||||
|
}
|
||||||
|
|
||||||
// Write to the WAL that this user will be created. We do this before
|
// Write to the WAL that this user will be created. We do this before
|
||||||
// the user is created because if switch the order then the WAL put
|
// the user is created because if switch the order then the WAL put
|
||||||
|
@ -435,3 +505,8 @@ func convertPolicyARNs(policyARNs []string) []*sts.PolicyDescriptorType {
|
||||||
}
|
}
|
||||||
return retval
|
return retval
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UsernameMetadata struct {
|
||||||
|
DisplayName string
|
||||||
|
PolicyName string
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,12 @@
|
||||||
package aws
|
package aws
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNormalizeDisplayName_NormRequired(t *testing.T) {
|
func TestNormalizeDisplayName_NormRequired(t *testing.T) {
|
||||||
|
@ -40,3 +45,143 @@ func TestNormalizeDisplayName_NormNotRequired(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGenUsername(t *testing.T) {
|
||||||
|
|
||||||
|
testUsername, warning, err := genUsername("name1", "policy1", "iam_user", `{{ printf "vault-%s-%s-%s-%s" (.DisplayName) (.PolicyName) (unix_time) (random 20) | truncate 64 }}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected no err; got %s",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedUsernameRegex := `^vault-name1-policy1-[0-9]+-[a-zA-Z0-9]+`
|
||||||
|
require.Regexp(t, expectedUsernameRegex, testUsername)
|
||||||
|
// IAM usernames are capped at 64 characters
|
||||||
|
if len(testUsername) > 64 {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected IAM username to be of length 64, got %d",
|
||||||
|
len(testUsername),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
testUsername, warning, err = genUsername(
|
||||||
|
"this---is---a---very---long---name",
|
||||||
|
"long------policy------name",
|
||||||
|
"iam_user",
|
||||||
|
`{{ printf "%s-%s-%s-%s" (.DisplayName) (.PolicyName) (unix_time) (random 20) }}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if warning == "" || !strings.Contains(warning, "calling token display name/IAM policy name were truncated to 64 characters") {
|
||||||
|
t.Fatalf("expected a truncate warning; received empty string")
|
||||||
|
}
|
||||||
|
if len(testUsername) != 64 {
|
||||||
|
t.Fatalf("expected a username cap at 64 chars; got length: %d", len(testUsername))
|
||||||
|
}
|
||||||
|
|
||||||
|
testUsername, warning, err = genUsername("name1", "policy1", "sts", defaultSTSTemplate)
|
||||||
|
if strings.Contains(testUsername, "name1") || strings.Contains(testUsername, "policy1") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected sts username to not contain display name or policy name; got %s",
|
||||||
|
testUsername,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// STS usernames are capped at 64 characters
|
||||||
|
if len(testUsername) > 32 {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected sts username to be under 32 chars; got %s of length %d",
|
||||||
|
testUsername,
|
||||||
|
len(testUsername),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadConfig_DefaultTemplate(t *testing.T) {
|
||||||
|
config := logical.TestBackendConfig()
|
||||||
|
config.StorageView = &logical.InmemStorage{}
|
||||||
|
b := Backend()
|
||||||
|
if err := b.Setup(context.Background(), config); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testTemplate := ""
|
||||||
|
configData := map[string]interface{}{
|
||||||
|
"connection_uri": "test_uri",
|
||||||
|
"username": "guest",
|
||||||
|
"password": "guest",
|
||||||
|
"username_template": testTemplate,
|
||||||
|
}
|
||||||
|
configReq := &logical.Request{
|
||||||
|
Operation: logical.UpdateOperation,
|
||||||
|
Path: "config/root",
|
||||||
|
Storage: config.StorageView,
|
||||||
|
Data: configData,
|
||||||
|
}
|
||||||
|
resp, err := b.HandleRequest(context.Background(), configReq)
|
||||||
|
if err != nil || (resp != nil && resp.IsError()) {
|
||||||
|
t.Fatalf("bad: resp: %#v\nerr:%s", resp, err)
|
||||||
|
}
|
||||||
|
if resp != nil {
|
||||||
|
t.Fatal("expected a nil response")
|
||||||
|
}
|
||||||
|
|
||||||
|
configResult, err := readConfig(context.Background(), config.StorageView)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected err to be nil; got %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No template provided, config set to defaultUsernameTemplate
|
||||||
|
if configResult.UsernameTemplate != defaultUserNameTemplate {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected template %s; got %s",
|
||||||
|
defaultUserNameTemplate,
|
||||||
|
configResult.UsernameTemplate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadConfig_CustomTemplate(t *testing.T) {
|
||||||
|
config := logical.TestBackendConfig()
|
||||||
|
config.StorageView = &logical.InmemStorage{}
|
||||||
|
b := Backend()
|
||||||
|
if err := b.Setup(context.Background(), config); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testTemplate := "`foo-{{ .DisplayName }}`"
|
||||||
|
configData := map[string]interface{}{
|
||||||
|
"connection_uri": "test_uri",
|
||||||
|
"username": "guest",
|
||||||
|
"password": "guest",
|
||||||
|
"username_template": testTemplate,
|
||||||
|
}
|
||||||
|
configReq := &logical.Request{
|
||||||
|
Operation: logical.UpdateOperation,
|
||||||
|
Path: "config/root",
|
||||||
|
Storage: config.StorageView,
|
||||||
|
Data: configData,
|
||||||
|
}
|
||||||
|
resp, err := b.HandleRequest(context.Background(), configReq)
|
||||||
|
if err != nil || (resp != nil && resp.IsError()) {
|
||||||
|
t.Fatalf("bad: resp: %#v\nerr:%s", resp, err)
|
||||||
|
}
|
||||||
|
if resp != nil {
|
||||||
|
t.Fatal("expected a nil response")
|
||||||
|
}
|
||||||
|
|
||||||
|
configResult, err := readConfig(context.Background(), config.StorageView)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected err to be nil; got %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if configResult.UsernameTemplate != testTemplate {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected template %s; got %s",
|
||||||
|
testTemplate,
|
||||||
|
configResult.UsernameTemplate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
```release-note:feature
|
||||||
|
secrets/aws: add support for custom IAM usernames
|
||||||
|
```
|
|
@ -58,6 +58,9 @@ valid AWS credentials with proper permissions.
|
||||||
|
|
||||||
- `sts_endpoint` `(string: <optional>)` – Specifies a custom HTTP STS endpoint to use.
|
- `sts_endpoint` `(string: <optional>)` – Specifies a custom HTTP STS endpoint to use.
|
||||||
|
|
||||||
|
- `username_template` `(string: <optional>)` - [Template](/docs/concepts/username-templating) describing how
|
||||||
|
dynamic usernames are generated. IAM usernames are capped at 64 characters. Longer usernames will be truncated.
|
||||||
|
|
||||||
### Sample Payload
|
### Sample Payload
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
Loading…
Reference in New Issue