package artifactory import ( "bytes" "crypto/x509" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "strings" jwt "github.com/golang-jwt/jwt/v4" "github.com/hashicorp/go-version" "github.com/hashicorp/vault/sdk/helper/template" "github.com/hashicorp/vault/sdk/logical" ) const ( defaultUserNameTemplate string = `{{ printf "v-%s-%s" (.RoleName | truncate 24) (random 8) }}` // Docs indicate max length is 256 ) var ErrIncompatibleVersion = errors.New("incompatible version") func (b *backend) RevokeToken(config adminConfiguration, secret logical.Secret) error { accessToken := secret.InternalData["access_token"].(string) tokenId := secret.InternalData["token_id"].(string) values := url.Values{} values.Set("token", accessToken) u, err := url.Parse(config.ArtifactoryURL) if err != nil { b.Backend.Logger().Warn("could not parse artifactory url", "url", u, "err", err) return err } var resp *http.Response if b.useNewAccessAPI() { resp, err = b.performArtifactoryDelete(config, "/access/api/v1/tokens/"+tokenId) if err != nil { b.Backend.Logger().Warn("error deleting access token", "tokenId", tokenId, "response", resp, "err", err) return err } } else { resp, err = b.performArtifactoryPost(config, u.Path+"/api/security/token/revoke", values) if err != nil { b.Backend.Logger().Warn("error deleting token", "tokenId", tokenId, "response", resp, "err", err) return err } } //noinspection GoUnhandledErrorResult defer resp.Body.Close() if resp.StatusCode >= http.StatusBadRequest { bodyBytes, err := io.ReadAll(resp.Body) if err != nil { b.Backend.Logger().Warn("revokeToken could not read bad response body", "response", resp, "err", err) } b.Backend.Logger().Warn("revokeToken got bad http status code", "statusCode", resp.StatusCode, "body", string(bodyBytes)) return fmt.Errorf("could not revoke tokenID: %v - HTTP response %v", tokenId, resp.StatusCode) } return nil } type CreateTokenRequest struct { GrantType string `json:"grant_type,omitempty"` Username string `json:"username,omitempty"` Scope string `json:"scope,omitempty"` ExpiresIn int64 `json:"expires_in"` Refreshable bool `json:"refreshable,omitempty"` Description string `json:"description,omitempty"` Audience string `json:"audience,omitempty"` ForceRevocable bool `json:"force_revocable,omitempty"` } func (b *backend) CreateToken(config adminConfiguration, role artifactoryRole) (*createTokenResponse, error) { request := CreateTokenRequest{ GrantType: role.GrantType, Username: role.Username, Scope: role.Scope, Audience: role.Audience, } if len(request.Username) == 0 { return nil, fmt.Errorf("empty username not allowed, possibly a template error") } // A refreshable access token gets replaced by a new access token, which is not // what a consumer of tokens from this backend would be expecting; instead they'd // likely just request a new token periodically. request.Refreshable = false // Artifactory will not let you revoke a token that has an expiry unless it also meets // criteria that can only be set in its configuration file. The version of Artifactory // I'm testing against will actually delete a token when you ask it to revoke by token_id, // but the token is still usable even after it's deleted. See RTFACT-15293. request.ExpiresIn = 0 // never expires if config.UseExpiringTokens && b.supportForceRevocable() && role.MaxTTL > 0 { request.ExpiresIn = int64(role.MaxTTL.Seconds()) request.ForceRevocable = true } u, err := url.Parse(config.ArtifactoryURL) if err != nil { b.Backend.Logger().Warn("could not parse artifactory url", "url", u, "err", err) return nil, err } path := "" if b.useNewAccessAPI() { path = "/access/api/v1/tokens" } else { path = u.Path + "/api/security/token" } jsonReq, err := json.Marshal(request) if err != nil { return nil, err } resp, err := b.performArtifactoryPostWithJSON(config, path, jsonReq) if err != nil { b.Backend.Logger().Warn("error making token request", "response", resp, "err", err) return nil, err } //noinspection GoUnhandledErrorResult defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, err := io.ReadAll(resp.Body) if err != nil { b.Backend.Logger().Warn("createToken could not read bad response", "response", resp, "err", err) } b.Backend.Logger().Warn("createToken got non-200 status code", "statusCode", resp.StatusCode, "body", string(bodyBytes)) return nil, fmt.Errorf("could not create access token: HTTP response %v", resp.StatusCode) } var createdToken createTokenResponse if err := json.NewDecoder(resp.Body).Decode(&createdToken); err != nil { b.Backend.Logger().Warn("could not parse response", "response", resp, "err", err) return nil, err } return &createdToken, nil } // supportForceRevocable verifies whether or not the Artifactory version is 7.50.3 or higher. // The access API changes in v7.50.3 to support force_revocable to allow us to set the expiration for the tokens. // REF: https://www.jfrog.com/confluence/display/JFROG/JFrog+Platform+REST+API#JFrogPlatformRESTAPI-CreateToken func (b *backend) supportForceRevocable() bool { return b.checkVersion("7.50.3") } // useNewAccessAPI verifies whether or not the Artifactory version is 7.21.1 or higher. // The access API changed in v7.21.1 // REF: https://www.jfrog.com/confluence/display/JFROG/Artifactory+REST+API#ArtifactoryRESTAPI-AccessTokens func (b *backend) useNewAccessAPI() bool { return b.checkVersion("7.21.1") } // getVersion will fetch the current Artifactory version and store it in the backend func (b *backend) getVersion(config adminConfiguration) (err error) { resp, err := b.performArtifactoryGet(config, "/artifactory/api/system/version") if err != nil { b.Backend.Logger().Warn("error making system version request", "response", resp, "err", err) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { b.Backend.Logger().Warn("got non-200 status code", "statusCode", resp.StatusCode) return fmt.Errorf("could not get the system version: HTTP response %v", resp.StatusCode) } var systemVersion systemVersionResponse if err = json.NewDecoder(resp.Body).Decode(&systemVersion); err != nil { b.Backend.Logger().Warn("could not parse system version response", "response", resp, "err", err) return } b.version = systemVersion.Version return } // checkVersion will return a boolean and error to check compatibility before making an API call // -- This was formerly "checkSystemStatus" but that was hard-coded, that method now calls this one func (b *backend) checkVersion(ver string) (compatible bool) { v1, err := version.NewVersion(b.version) if err != nil { b.Backend.Logger().Warn("could not parse Artifactory system version", "ver", b.version, "err", err) return } v2, err := version.NewVersion(ver) if err != nil { b.Backend.Logger().Warn("could not parse provided version", "ver", ver, "err", err) return } if v1.GreaterThanOrEqual(v2) { compatible = true } return } // parseJWT will parse a JWT token string from Artifactory and return a *jwt.Token, err func (b *backend) parseJWT(config adminConfiguration, token string) (jwtToken *jwt.Token, err error) { validate := true cert, err := b.getRootCert(config) if err != nil { if errors.Is(err, ErrIncompatibleVersion) { b.Logger().Warn("outdated artifactory, unable to retrieve root cert, skipping token validation") validate = false } else { b.Logger().Error("error retrieving root cert", "err", err.Error()) return } } // Parse Token if validate { jwtToken, err = jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { return cert.PublicKey, nil }, jwt.WithValidMethods([]string{"RS256"})) if err != nil { return } if !jwtToken.Valid { return } } else { // SKIP Validation // -- NOTE THIS IGNORES THE SIGNATURE, which is probably bad, // but it is artifactory's job to validate the token, right? // p := jwt.Parser{} // token, _, err := p.ParseUnverified(oldAccessToken, jwt.MapClaims{}) jwtToken, err = jwt.Parse(token, nil, jwt.WithoutClaimsValidation()) if err != nil { return } } // If we got here, we should have a jwtToken and nil err return } type TokenInfo struct { TokenID string `json:"token_id"` Scope string `json:"scope"` Username string `json:"username"` Expires int64 `json:"expires"` } // getTokenInfo will parse the provided token to return useful information about it func (b *backend) getTokenInfo(config adminConfiguration, token string) (info *TokenInfo, err error) { // Parse Current Token (to get tokenID/scope) jwtToken, err := b.parseJWT(config, token) if err != nil { return nil, err } claims, ok := jwtToken.Claims.(jwt.MapClaims) if !ok { return nil, errors.New("error parsing claims in AccessToken") } sub := strings.Split(claims["sub"].(string), "/") // sub -> subject (jfac@01fr1x1h805xmg0t17xhqr1v7a/users/admin) info = &TokenInfo{ TokenID: claims["jti"].(string), // jti -> JFrog Token ID Scope: claims["scp"].(string), // scp -> scope Username: sub[len(sub)-1], // last element of subject } // exp -> expires at (unixtime) - may not be present switch exp := claims["exp"].(type) { case int64: info.Expires = exp case float64: info.Expires = int64(exp) // close enough this should be int64 anyhow case json.Number: v, err := exp.Int64() if err != nil { b.Backend.Logger().Warn("error parsing token exp as json.Number", "err", err) } info.Expires = v } return } // getRootCert will return the Artifactory access root certificate's public key, for validating token signatures func (b *backend) getRootCert(config adminConfiguration) (cert *x509.Certificate, err error) { // Verify Artifactory version is at 7.12.0 or higher, prior versions will not work // REF: https://www.jfrog.com/confluence/display/JFROG/Artifactory+REST+API#ArtifactoryRESTAPI-GetRootCertificate if !b.checkVersion("7.12.0") { return cert, ErrIncompatibleVersion } resp, err := b.performArtifactoryGet(config, "/access/api/v1/cert/root") if err != nil { b.Backend.Logger().Warn("error requesting cert/root", "response", resp, "err", err) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { b.Backend.Logger().Warn("got non-200 status code", "statusCode", resp.StatusCode) return cert, fmt.Errorf("could not get the certificate: HTTP response %v", resp.StatusCode) } body, err := io.ReadAll(resp.Body) // body, err := ioutil.ReadAll(resp.Body) Go.1.15 and earlier if err != nil { b.Backend.Logger().Error("error reading root cert response body", "err", err) return } // The certificate is base64 encoded DER binCert := make([]byte, len(body)) n, err := base64.StdEncoding.Decode(binCert, body) if err != nil { b.Backend.Logger().Error("error decoding body", "err", err) return } cert, err = x509.ParseCertificate(binCert[0:n]) if err != nil { b.Backend.Logger().Error("error parsing certificate", "err", err) return } return } func (b *backend) performArtifactoryGet(config adminConfiguration, path string) (*http.Response, error) { u, err := parseURLWithDefaultPort(config.ArtifactoryURL) if err != nil { return nil, err } u.Path = path // replace any path in the URL with the provided path req, err := http.NewRequest(http.MethodGet, u.String(), nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "art-secrets-plugin") req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.AccessToken)) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") return b.httpClient.Do(req) } // performArtifactoryPost will HTTP POST values to the Artifactory API. func (b *backend) performArtifactoryPost(config adminConfiguration, path string, values url.Values) (*http.Response, error) { u, err := parseURLWithDefaultPort(config.ArtifactoryURL) if err != nil { return nil, err } // Replace URL Path u.Path = path req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(values.Encode())) if err != nil { return nil, err } req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.AccessToken)) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") return b.httpClient.Do(req) } // performArtifactoryPost will HTTP POST data to the Artifactory API. func (b *backend) performArtifactoryPostWithJSON(config adminConfiguration, path string, postData []byte) (*http.Response, error) { u, err := parseURLWithDefaultPort(config.ArtifactoryURL) if err != nil { return nil, err } // Replace URL Path u.Path = path postDataBuf := bytes.NewBuffer(postData) req, err := http.NewRequest(http.MethodPost, u.String(), postDataBuf) if err != nil { return nil, err } req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.AccessToken)) req.Header.Add("Content-Type", "application/json") return b.httpClient.Do(req) } // performArtifactoryDelete will HTTP DELETE to the Artifactory API. // The path will be appended to the configured configured URL Path (usually /artifactory) func (b *backend) performArtifactoryDelete(config adminConfiguration, path string) (*http.Response, error) { u, err := parseURLWithDefaultPort(config.ArtifactoryURL) if err != nil { return nil, err } // Replace URL Path u.Path = path req, err := http.NewRequest(http.MethodDelete, u.String(), nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "art-secrets-plugin") req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", config.AccessToken)) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") return b.httpClient.Do(req) } func parseURLWithDefaultPort(rawUrl string) (*url.URL, error) { urlParsed, err := url.ParseRequestURI(rawUrl) if err != nil { return nil, err } if urlParsed.Port() == "" { defaultPort, err := net.LookupPort("tcp", urlParsed.Scheme) if err != nil { return nil, err } urlParsed.Host = fmt.Sprintf("%s:%d", urlParsed.Host, defaultPort) } return urlParsed, nil } func testUsernameTemplate(testTemplate string) (up template.StringTemplate, err error) { up, err = template.NewTemplate(template.Template(testTemplate)) if err != nil { return up, fmt.Errorf("username_template initialization error: %w", err) } _, err = up.Generate(UsernameMetadata{}) if err != nil { return up, fmt.Errorf("username_template failed to generate username: %w", err) } return }
package artifactory import ( "context" "fmt" "net/http" "strings" "sync" "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/helper/template" "github.com/hashicorp/vault/sdk/logical" ) var Version = "v1.0.0" type backend struct { *framework.Backend configMutex sync.RWMutex rolesMutex sync.RWMutex httpClient *http.Client usernameProducer template.StringTemplate version string } // UsernameMetadata defines the metadata that a user_template can use to dynamically create user account in Artifactory type UsernameMetadata struct { DisplayName string RoleName string } // Factory configures and returns Artifactory secrets backends. func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) { if conf == nil { return nil, fmt.Errorf("configuration passed into backend is nil") } b, err := Backend(conf) if err != nil { return nil, err } if err := b.Backend.Setup(ctx, conf); err != nil { return nil, err } return b, nil } func Backend(_ *logical.BackendConfig) (*backend, error) { b := &backend{ httpClient: http.DefaultClient, } up, err := testUsernameTemplate(defaultUserNameTemplate) if err != nil { return nil, err } b.usernameProducer = up b.Backend = &framework.Backend{ Help: strings.TrimSpace(artifactoryHelp), RunningVersion: Version, PathsSpecial: &logical.Paths{ SealWrapStorage: []string{"config/admin"}, }, BackendType: logical.TypeLogical, InitializeFunc: b.initialize, } b.Backend.Secrets = append(b.Backend.Secrets, b.secretAccessToken()) b.Backend.Paths = append(b.Backend.Paths, b.pathListRoles(), b.pathRoles(), b.pathTokenCreate(), b.pathConfig(), b.pathConfigRotate()) return b, nil } // initialize will initialize the backend configuration func (b *backend) initialize(ctx context.Context, req *logical.InitializationRequest) error { config, err := b.fetchAdminConfiguration(ctx, req.Storage) if err != nil { return err } if config == nil { return nil } err = b.getVersion(*config) if err != nil { return err } if len(config.UsernameTemplate) != 0 { up, err := testUsernameTemplate(config.UsernameTemplate) if err != nil { return err } b.usernameProducer = up } return nil } // fetchAdminConfiguration will return nil,nil if there's no configuration func (b *backend) fetchAdminConfiguration(ctx context.Context, storage logical.Storage) (*adminConfiguration, error) { var config adminConfiguration // Read in the backend configuration entry, err := storage.Get(ctx, "config/admin") if err != nil { return nil, err } if entry == nil { return nil, nil } if err := entry.DecodeJSON(&config); err != nil { return nil, err } return &config, nil } const artifactoryHelp = ` The Artifactory secrets backend provides Artifactory access tokens based on configured roles. `
package artifactory import ( "context" "crypto/sha256" "fmt" "strconv" "time" "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/logical" ) func (b *backend) pathConfig() *framework.Path { return &framework.Path{ Pattern: "config/admin", Fields: map[string]*framework.FieldSchema{ "access_token": { Type: framework.TypeString, Required: true, Description: "Administrator token to access Artifactory", }, "url": { Type: framework.TypeString, Required: true, Description: "Address of the Artifactory instance", }, "username_template": { Type: framework.TypeString, Description: "Optional. Vault Username Template for dynamically generating usernames.", }, "use_expiring_tokens": { Type: framework.TypeString, Description: "Optional. If Artifactory version >= 7.50.3, set expires_in to max_ttl and force_revocable.", }, }, Operations: map[logical.Operation]framework.OperationHandler{ logical.UpdateOperation: &framework.PathOperation{ Callback: b.pathConfigUpdate, Summary: "Configure the Artifactory secrets backend.", }, logical.DeleteOperation: &framework.PathOperation{ Callback: b.pathConfigDelete, Summary: "Delete the Artifactory secrets configuration.", }, logical.ReadOperation: &framework.PathOperation{ Callback: b.pathConfigRead, Summary: "Examine the Artifactory secrets configuration.", }, }, HelpSynopsis: `Interact with the Artifactory secrets configuration.`, HelpDescription: ` Configure the parameters used to connect to the Artifactory server integrated with this backend. The two main parameters are "url" which is the absolute URL to the Artifactory server. Note that "/api" is prepended by the individual calls, so do not include it in the URL here. The second is "access_token" which must be an access token powerful enough to generate the other access tokens you'll be using. This value is stored seal wrapped when available. Once set, the access token cannot be retrieved, but the backend will send a sha256 hash of the token so you can compare it to your notes. If the token is a JWT Access Token, it will return additional information such as jfrog_token_id, username and scope. An optional "username_template" parameter will override the built-in default username_template for dynamically generating usernames if a static one is not provided. No renewals or new tokens will be issued if the backend configuration (config/admin) is deleted. `, } } type adminConfiguration struct { AccessToken string `json:"access_token"` ArtifactoryURL string `json:"artifactory_url"` UsernameTemplate string `json:"username_template,omitempty"` UseExpiringTokens bool `json:"use_expiring_tokens,omitempty"` } func (b *backend) pathConfigUpdate(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { b.configMutex.Lock() defer b.configMutex.Unlock() config, err := b.fetchAdminConfiguration(ctx, req.Storage) if err != nil { return nil, err } if config == nil { config = &adminConfiguration{} } if val, ok := data.GetOk("url"); ok { config.ArtifactoryURL = val.(string) config.AccessToken = "" // clear access token if URL changes, requires setting access_token and url together for security reasons } if val, ok := data.GetOk("access_token"); ok { config.AccessToken = val.(string) } if val, ok := data.GetOk("username_template"); ok { config.UsernameTemplate = val.(string) up, err := testUsernameTemplate(config.UsernameTemplate) if err != nil { return logical.ErrorResponse("username_template error"), err } b.usernameProducer = up } if val, ok := data.GetOk("use_expiring_tokens"); ok { b.Logger().Warn("config update use_expiring_tokens", "use_expiring_tokens", val) switch exp := val.(type) { case bool: config.UseExpiringTokens = exp case string: config.UseExpiringTokens, err = strconv.ParseBool(exp) if err != nil { return logical.ErrorResponse("error parsing use_expired_tokens string to bool"), err } } } if config.AccessToken == "" { return logical.ErrorResponse("access_token is required"), nil } if config.ArtifactoryURL == "" { return logical.ErrorResponse("url is required"), nil } err = b.getVersion(*config) if err != nil { return logical.ErrorResponse("Unable to get Artifactory Version, check url and access_token."), err } entry, err := logical.StorageEntryJSON("config/admin", config) if err != nil { return nil, err } err = req.Storage.Put(ctx, entry) if err != nil { return nil, err } return nil, nil } func (b *backend) pathConfigDelete(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { b.configMutex.Lock() defer b.configMutex.Unlock() if err := req.Storage.Delete(ctx, "config/admin"); err != nil { return nil, err } return nil, nil } func (b *backend) pathConfigRead(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { b.configMutex.RLock() defer b.configMutex.RUnlock() config, err := b.fetchAdminConfiguration(ctx, req.Storage) if err != nil { return nil, err } if config == nil { return logical.ErrorResponse("backend not configured"), nil } // I'm not sure if I should be returning the access token, so I'll hash it. accessTokenHash := sha256.Sum256([]byte(config.AccessToken)) configMap := map[string]interface{}{ "access_token_sha256": fmt.Sprintf("%x", accessTokenHash[:]), "url": config.ArtifactoryURL, "version": b.version, } // Optionally include username_template if len(config.UsernameTemplate) > 0 { configMap["username_template"] = config.UsernameTemplate } // Optionally include token info if it parses properly token, err := b.getTokenInfo(*config, config.AccessToken) if err != nil { b.Logger().Warn("Error parsing AccessToken: " + err.Error()) } else { configMap["token_id"] = token.TokenID configMap["username"] = token.Username configMap["scope"] = token.Scope if token.Expires > 0 { configMap["exp"] = token.Expires tm := time.Unix(token.Expires, 0) configMap["expires"] = tm.Local() } } if b.supportForceRevocable() { configMap["use_expiring_tokens"] = config.UseExpiringTokens } return &logical.Response{ Data: configMap, }, nil }
package artifactory import ( "context" "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/logical" ) func (b *backend) pathConfigRotate() *framework.Path { return &framework.Path{ Pattern: "config/rotate", Operations: map[logical.Operation]framework.OperationHandler{ logical.UpdateOperation: &framework.PathOperation{ Callback: b.pathConfigRotateWrite, Summary: "Rotate the Artifactory Admin Token.", }, }, HelpSynopsis: `Rotate the Artifactory Admin Token.`, HelpDescription: ` This will rotate the "access_token" used to access artifactory from this plugin, and remove the old token. `, } } func (b *backend) pathConfigRotateWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { b.configMutex.Lock() defer b.configMutex.Unlock() config, err := b.fetchAdminConfiguration(ctx, req.Storage) if err != nil { return nil, err } if config == nil { return logical.ErrorResponse("backend not configured"), nil } oldAccessToken := config.AccessToken // Parse Current Token (to get tokenID/scope) token, err := b.getTokenInfo(*config, oldAccessToken) if err != nil { return logical.ErrorResponse("error parsing existing AccessToken: " + err.Error()), err } if len(token.Username) == 0 { token.Username = "admin" // default username to admin if not found, not sure if this is needed } b.Logger().Debug("oldToken ID: " + token.TokenID) // Create admin role for the new token role := &artifactoryRole{ Username: token.Username, Scope: token.Scope, } // Create a new token resp, err := b.CreateToken(*config, *role) if err != nil { return logical.ErrorResponse("error creating new token"), err } b.Logger().Debug("newTokenID: " + resp.TokenId) // Set new token config.AccessToken = resp.AccessToken // Save new config entry, err := logical.StorageEntryJSON("config/admin", config) if err != nil { return nil, err } err = req.Storage.Put(ctx, entry) if err != nil { return nil, err } // Invalidate Old Token oldSecret := logical.Secret{ InternalData: map[string]interface{}{ "access_token": oldAccessToken, "token_id": token.TokenID, }, } err = b.RevokeToken(*config, oldSecret) if err != nil { return logical.ErrorResponse("error revoking existing AccessToken"), err } return nil, nil }
package artifactory import ( "context" "time" "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/logical" ) func (b *backend) pathListRoles() *framework.Path { return &framework.Path{ Pattern: "roles/?$", Operations: map[logical.Operation]framework.OperationHandler{ logical.ListOperation: &framework.PathOperation{ Callback: b.pathRoleList, }, }, HelpSynopsis: `List configured roles with this backend.`, } } func (b *backend) pathRoles() *framework.Path { return &framework.Path{ Pattern: "roles/" + framework.GenericNameWithAtRegex("role"), Fields: map[string]*framework.FieldSchema{ "role": { Type: framework.TypeString, Required: true, Description: `The name of the role, must be conform to alphanumeric plus at, dash, and period.`, }, "grant_type": { Type: framework.TypeString, Description: `Optional. Defaults to 'client_credentials' when creating the access token. You likely don't need to change this.'`, }, "username": { Type: framework.TypeString, Description: `Optional. Defaults to using the username_template. The static username for which the access token is created. If the user does not exist, Artifactory will create a transient user. Note that non-administrative access tokens can only create tokens for themselves.`, }, "scope": { Type: framework.TypeString, Required: true, Description: `Required. See the JFrog Artifactory REST documentation on "Create Token" for a full and up to date description.`, }, "audience": { Type: framework.TypeString, Description: `Optional. See the JFrog Artifactory REST documentation on "Create Token" for a full and up to date description.`, }, "default_ttl": { Type: framework.TypeDurationSecond, Description: `Default TTL for issued access tokens. If unset, uses the backend's default_ttl. Cannot exceed max_ttl.`, }, "max_ttl": { Type: framework.TypeDurationSecond, Description: `Maximum TTL that an access token can be renewed for. If unset, uses the backend's max_ttl. Cannot exceed backend's max_ttl.`, }, }, Operations: map[logical.Operation]framework.OperationHandler{ logical.ReadOperation: &framework.PathOperation{ Callback: b.pathRoleRead, Summary: `Read information about the specified role.`, }, logical.CreateOperation: &framework.PathOperation{ Callback: b.pathRoleWrite, Summary: `Write information about the specified role.`, }, logical.UpdateOperation: &framework.PathOperation{ Callback: b.pathRoleWrite, Summary: `Overwrite information about the specified role.`, }, logical.DeleteOperation: &framework.PathOperation{ Callback: b.pathRoleDelete, Summary: `Delete the specified role.`, }, }, HelpSynopsis: `Manage data related to roles used to issue Artifactory access tokens.`, } } type artifactoryRole struct { GrantType string `json:"grant_type,omitempty"` Username string `json:"username,omitempty"` Scope string `json:"scope"` Audience string `json:"audience,omitempty"` DefaultTTL time.Duration `json:"default_ttl,omitempty"` MaxTTL time.Duration `json:"max_ttl,omitempty"` } func (b *backend) pathRoleList(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { b.rolesMutex.RLock() defer b.rolesMutex.RUnlock() entries, err := req.Storage.List(ctx, "roles/") if err != nil { return nil, err } return logical.ListResponse(entries), nil } func (b *backend) pathRoleWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { b.rolesMutex.Lock() b.configMutex.RLock() defer b.configMutex.RUnlock() defer b.rolesMutex.Unlock() config, err := b.fetchAdminConfiguration(ctx, req.Storage) if err != nil { return nil, err } if config == nil { return logical.ErrorResponse("backend not configured"), nil } roleName := data.Get("role").(string) if roleName == "" { return logical.ErrorResponse("missing role"), nil } createOperation := (req.Operation == logical.CreateOperation) role := &artifactoryRole{} if !createOperation { existingRole, err := b.Role(ctx, req.Storage, roleName) if err != nil { return nil, err } if existingRole != nil { role = existingRole } } if value, ok := data.GetOk("grant_type"); ok { role.GrantType = value.(string) } if value, ok := data.GetOk("username"); ok { role.Username = value.(string) } if value, ok := data.GetOk("scope"); ok { role.Scope = value.(string) } if value, ok := data.GetOk("audience"); ok { role.Audience = value.(string) } // Looking at database/path_roles.go, it doesn't do any validation on these values during role creation. if value, ok := data.GetOk("default_ttl"); ok { role.DefaultTTL = time.Duration(value.(int)) * time.Second } if value, ok := data.GetOk("max_ttl"); ok { role.MaxTTL = time.Duration(value.(int)) * time.Second } if role.Scope == "" { return logical.ErrorResponse("missing scope"), nil } entry, err := logical.StorageEntryJSON("roles/"+roleName, role) if err != nil { return nil, err } if err := req.Storage.Put(ctx, entry); err != nil { return nil, err } return nil, nil } func (b *backend) pathRoleRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { b.rolesMutex.RLock() defer b.rolesMutex.RUnlock() roleName := data.Get("role").(string) if roleName == "" { return logical.ErrorResponse("missing role"), nil } role, err := b.Role(ctx, req.Storage, roleName) if err != nil { return nil, err } if role == nil { return nil, nil } return &logical.Response{ Data: b.roleToMap(roleName, *role), }, nil } func (b *backend) Role(ctx context.Context, storage logical.Storage, roleName string) (*artifactoryRole, error) { entry, err := storage.Get(ctx, "roles/"+roleName) if err != nil { return nil, err } if entry == nil { return nil, nil } var role artifactoryRole if err := entry.DecodeJSON(&role); err != nil { return nil, err } return &role, nil } func (b *backend) roleToMap(roleName string, role artifactoryRole) (roleMap map[string]interface{}) { roleMap = map[string]interface{}{ "role": roleName, "scope": role.Scope, "default_ttl": role.DefaultTTL.Seconds(), "max_ttl": role.MaxTTL.Seconds(), } // Optional Attributes if len(role.GrantType) > 0 { roleMap["grant_type"] = role.GrantType } if len(role.Username) > 0 { roleMap["username"] = role.Username } if len(role.Audience) > 0 { roleMap["audience"] = role.Audience } return } func (b *backend) pathRoleDelete(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { b.rolesMutex.Lock() defer b.rolesMutex.Unlock() err := req.Storage.Delete(ctx, "roles/"+data.Get("role").(string)) if err != nil { return nil, err } return nil, nil }
package artifactory import ( "context" "time" "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/logical" ) func (b *backend) pathTokenCreate() *framework.Path { return &framework.Path{ Pattern: "token/" + framework.GenericNameWithAtRegex("role"), Fields: map[string]*framework.FieldSchema{ "role": { Type: framework.TypeString, Description: `Use the configuration of the specified role.`, }, "ttl": { Type: framework.TypeDurationSecond, Description: `Override the default TTL when issuing this access token. Cannot exceed smallest (system, backend, role, this request) maximum TTL.`, }, "max_ttl": { Type: framework.TypeDurationSecond, Description: `Override the maximum TTL for this access token. Cannot exceed smallest (system, backend) maximum TTL.`, }, }, Operations: map[logical.Operation]framework.OperationHandler{ logical.ReadOperation: &framework.PathOperation{ Callback: b.pathTokenCreatePerform, }, }, HelpSynopsis: `Create an Artifactory access token for the specified role.`, } } type systemVersionResponse struct { Version string `json:"version"` Revision string `json:"revision"` } type createTokenResponse struct { TokenId string `json:"token_id"` AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` Scope string `json:"scope"` TokenType string `json:"token_type"` } func (b *backend) pathTokenCreatePerform(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { b.rolesMutex.RLock() b.configMutex.RLock() defer b.configMutex.RUnlock() defer b.rolesMutex.RUnlock() config, err := b.fetchAdminConfiguration(ctx, req.Storage) if err != nil { return nil, err } if config == nil { return logical.ErrorResponse("backend not configured"), nil } // Read in the requested role roleName := data.Get("role").(string) role, err := b.Role(ctx, req.Storage, roleName) if err != nil { return nil, err } if role == nil { return logical.ErrorResponse("no such role"), nil } // Define username for token by template if a static one is not set if len(role.Username) == 0 { role.Username, err = b.usernameProducer.Generate(UsernameMetadata{ RoleName: roleName, DisplayName: req.DisplayName, }) if err != nil { return logical.ErrorResponse("error generating username from template"), err } } var ttl time.Duration if value, ok := data.GetOk("ttl"); ok { ttl = time.Second * time.Duration(value.(int)) } else { ttl = role.DefaultTTL } maxLeaseTTL := b.Backend.System().MaxLeaseTTL() // Set the role.MaxTTL based on maxLeaseTTL // - This value will be passed to createToken and used as expires_in for versions of Artifactory 7.50.3 or higher if role.MaxTTL == 0 || role.MaxTTL > maxLeaseTTL { role.MaxTTL = maxLeaseTTL } if role.MaxTTL > 0 && ttl > role.MaxTTL { ttl = role.MaxTTL } resp, err := b.CreateToken(*config, *role) if err != nil { return nil, err } response := b.Secret(SecretArtifactoryAccessTokenType).Response(map[string]interface{}{ "access_token": resp.AccessToken, "role": roleName, "scope": resp.Scope, "token_id": resp.TokenId, "username": role.Username, }, map[string]interface{}{ "role": roleName, "access_token": resp.AccessToken, "token_id": resp.TokenId, "username": role.Username, }) response.Secret.TTL = ttl response.Secret.MaxTTL = role.MaxTTL return response, nil }
package artifactory import ( "context" "fmt" "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/logical" ) const SecretArtifactoryAccessTokenType = "artifactory_access_token" func (b *backend) secretAccessToken() *framework.Secret { return &framework.Secret{ Type: SecretArtifactoryAccessTokenType, Fields: map[string]*framework.FieldSchema{ "access_token": { Type: framework.TypeString, Description: `Artifactory Access Token`, }, "token_id": { Type: framework.TypeString, Description: `Artifactory Access Token Id`, }, "username": { Type: framework.TypeString, Description: `Artifactory Username for Token ID`, }, }, Renew: b.secretAccessTokenRenew, Revoke: b.secretAccessTokenRevoke, } } func (b *backend) secretAccessTokenRenew(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { resp := &logical.Response{Secret: req.Secret} config, err := b.fetchAdminConfiguration(ctx, req.Storage) if err != nil { return nil, err } if config == nil { return logical.ErrorResponse("backend not configured"), nil } if !req.Secret.Renewable { return nil, fmt.Errorf("lease cannot be renewed") } role, err := b.Role(ctx, req.Storage, req.Secret.InternalData["role"].(string)) if err != nil { return nil, fmt.Errorf("error during renew: could not get role: %q", req.Secret.InternalData["role"]) } if role == nil { return nil, fmt.Errorf("error during renew: could not find role with name: %q", req.Secret.InternalData["role"]) } ttl, warnings, err := framework.CalculateTTL(b.System(), req.Secret.Increment, role.DefaultTTL, 0, role.MaxTTL, req.Secret.MaxTTL, req.Secret.IssueTime) if err != nil { return nil, err } if len(warnings) > 0 { for _, warning := range warnings { resp.AddWarning(warning) } } resp.Secret.TTL = ttl return resp, nil } func (b *backend) secretAccessTokenRevoke(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { config, err := b.fetchAdminConfiguration(ctx, req.Storage) if err != nil { return nil, err } if config == nil { return logical.ErrorResponse("backend not configured"), nil } if err := b.RevokeToken(*config, *req.Secret); err != nil { return nil, err } return nil, nil }
package artifactory import ( "context" "os" "testing" "time" log "github.com/hashicorp/go-hclog" "github.com/hashicorp/vault/sdk/helper/logging" "github.com/hashicorp/vault/sdk/logical" "github.com/stretchr/testify/assert" ) var runAcceptanceTests = os.Getenv("VAULT_ACC") != "" // accTestEnv creates an object to store and track testing environment // resources type accTestEnv struct { AccessToken string URL string Backend logical.Backend Context context.Context Storage logical.Storage } // createNewTestToken creates a new scoped token using the one from test environment // so that the original token won't be revoked by the path config rotate test func (e *accTestEnv) createNewTestToken(t *testing.T) (string, string) { config := adminConfiguration{ AccessToken: e.AccessToken, ArtifactoryURL: e.URL, } role := artifactoryRole{ GrantType: "client_credentials", Username: "admin", Scope: "applied-permissions/admin", } err := e.Backend.(*backend).getVersion(config) if err != nil { t.Fatal(err) } resp, err := e.Backend.(*backend).CreateToken(config, role) if err != nil { t.Fatal(err) } return resp.TokenId, resp.AccessToken } func (e *accTestEnv) revokeTestToken(t *testing.T, accessToken string, tokenID string) { config := adminConfiguration{ AccessToken: e.AccessToken, ArtifactoryURL: e.URL, } err := e.Backend.(*backend).getVersion(config) if err != nil { t.Fatal(err) } secret := logical.Secret{ InternalData: map[string]interface{}{ "access_token": accessToken, "token_id": tokenID, }, } err = e.Backend.(*backend).RevokeToken(config, secret) if err != nil { t.Fatal(err) } } func (e *accTestEnv) UpdatePathConfig(t *testing.T) { resp, err := e.Backend.HandleRequest(context.Background(), &logical.Request{ Operation: logical.UpdateOperation, Path: "config/admin", Storage: e.Storage, Data: map[string]interface{}{ "access_token": e.AccessToken, "url": e.URL, }, }) assert.NoError(t, err) assert.Nil(t, resp) } func (e *accTestEnv) ReadPathConfig(t *testing.T) { resp, err := e.Backend.HandleRequest(context.Background(), &logical.Request{ Operation: logical.ReadOperation, Path: "config/admin", Storage: e.Storage, }) assert.NoError(t, err) assert.NotNil(t, resp) assert.NotEmpty(t, resp.Data["access_token_sha256"]) } func (e *accTestEnv) DeletePathConfig(t *testing.T) { resp, err := e.Backend.HandleRequest(context.Background(), &logical.Request{ Operation: logical.DeleteOperation, Path: "config/admin", Storage: e.Storage, }) assert.NoError(t, err) assert.Nil(t, resp) } func (e *accTestEnv) RotatePathConfig(t *testing.T) { // create new test token tokenID, accessToken := e.createNewTestToken(t) // setup new path configuration resp, err := e.Backend.HandleRequest(context.Background(), &logical.Request{ Operation: logical.UpdateOperation, Path: "config/admin", Storage: e.Storage, Data: map[string]interface{}{ "access_token": accessToken, "url": e.URL, }, }) assert.NoError(t, err) assert.Nil(t, resp) // read back the path configuration and save the access token hash for comparison later resp, err = e.Backend.HandleRequest(context.Background(), &logical.Request{ Operation: logical.ReadOperation, Path: "config/admin", Storage: e.Storage, }) assert.NoError(t, err) assert.NotNil(t, resp) assert.NotEmpty(t, resp.Data["access_token_sha256"]) accessTokenHash := resp.Data["access_token_sha256"] // rotate the path config resp, err = e.Backend.HandleRequest(context.Background(), &logical.Request{ Operation: logical.UpdateOperation, Path: "config/rotate", Storage: e.Storage, }) assert.NoError(t, err) assert.Nil(t, resp) // read back the path configuration and assert access token hash is now different resp, err = e.Backend.HandleRequest(context.Background(), &logical.Request{ Operation: logical.ReadOperation, Path: "config/admin", Storage: e.Storage, }) assert.NoError(t, err) assert.NotNil(t, resp) assert.NotEmpty(t, resp.Data["access_token_sha256"]) assert.NotEqual(t, accessTokenHash, resp.Data["access_token_sha256"]) // clean up path config resp, err = e.Backend.HandleRequest(context.Background(), &logical.Request{ Operation: logical.DeleteOperation, Path: "config/admin", Storage: e.Storage, }) assert.NoError(t, err) assert.Nil(t, resp) // revoke the test token e.revokeTestToken(t, accessToken, tokenID) } func (e *accTestEnv) CreatePathRole(t *testing.T) { roleData := map[string]interface{}{ "role": "test-role", "username": "admin", "scope": "applied-permissions/user", "audience": "*@*", "default_ttl": 30 * time.Minute, "max_ttl": 45 * time.Minute, } resp, err := e.Backend.HandleRequest(context.Background(), &logical.Request{ Operation: logical.UpdateOperation, Path: "roles/test-role", Storage: e.Storage, Data: roleData, }) assert.NoError(t, err) assert.Nil(t, resp) } func (e *accTestEnv) ReadPathRole(t *testing.T) { resp, err := e.Backend.HandleRequest(context.Background(), &logical.Request{ Operation: logical.ReadOperation, Path: "roles/test-role", Storage: e.Storage, }) assert.NotNil(t, resp) assert.NoError(t, err) assert.EqualValues(t, "admin", resp.Data["username"]) assert.EqualValues(t, "applied-permissions/user", resp.Data["scope"]) assert.EqualValues(t, "*@*", resp.Data["audience"]) assert.EqualValues(t, 30*time.Minute.Seconds(), resp.Data["default_ttl"]) assert.EqualValues(t, 45*time.Minute.Seconds(), resp.Data["max_ttl"]) } func (e *accTestEnv) DeletePathRole(t *testing.T) { resp, err := e.Backend.HandleRequest(context.Background(), &logical.Request{ Operation: logical.DeleteOperation, Path: "roles/test-role", Storage: e.Storage, }) assert.NoError(t, err) assert.Nil(t, resp) } func (e *accTestEnv) CreatePathToken(t *testing.T) { resp, err := e.Backend.HandleRequest(context.Background(), &logical.Request{ Operation: logical.ReadOperation, Path: "token/test-role", Storage: e.Storage, }) assert.NoError(t, err) assert.NotNil(t, resp) assert.NotEmpty(t, resp.Data["access_token"]) assert.NotEmpty(t, resp.Data["token_id"]) assert.Equal(t, "admin", resp.Data["username"]) assert.Equal(t, "test-role", resp.Data["role"]) assert.Equal(t, "applied-permissions/user", resp.Data["scope"]) } func newAcceptanceTestEnv() (*accTestEnv, error) { ctx := context.Background() conf := &logical.BackendConfig{ System: &logical.StaticSystemView{}, Logger: logging.NewVaultLogger(log.Debug), } backend, err := Factory(ctx, conf) if err != nil { return nil, err } return &accTestEnv{ AccessToken: os.Getenv("JFROG_ACCESS_TOKEN"), URL: os.Getenv("ARTIFACTORY_URL"), Backend: backend, Context: ctx, Storage: &logical.InmemStorage{}, }, nil } const rootCert string = `MIIDHzCCAgegAwIBAgIQHC4IERZbTl67GGjV8KH04jANBgkqhkiG9w0BAQ` + `sFADA9MTswOQYDVQQDDDJKRnJvZyBUb2tlbiBJc3N1ZXIgamZhY0AwMWc1aGVrNmtiMjk1MjB` + `yYno3MXY5MWN3OTAgFw0yMjA2MTMxNTUxMjdaGA83MDAwMDEwMTAwMDAyN1owPTE7MDkGA1UE` + `AwwySkZyb2cgVG9rZW4gSXNzdWVyIGpmYWNAMDFnNWhlazZrYjI5NTIwcmJ6NzF2OTFjdzkwg` + `gEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCAArmgZSKRHWCOFKQy58EG/4soW93WoH` + `W5KDvuDfoJKkejD9nrdmRsDbw2wyKPfqgsFz63zdOI3mBGLRRUqHxrXQc6UNxWerYuLzfb/rg` + `gby6VzXHPGKft8eiO8w9TNMibf30MY/xFwmHWamECjZ5L9pTc8n1txizEPNW8farqQXXlli2N` + `PymEK/G3xW1QQWfThY5lMqTjvg6DYvB5ZQMbl853S+nsW10rWHSeFpnXFo46kNN5VaoXlJunZ` + `hPk3mm1rLIR6HLLeOPRSTIsVCqwhQbnRV84HZMVQnG9355L1EzbeEZAZjWC4r9hOmtyt4rcuq` + `dnYuGLR3Yw2cZEILKvAgMBAAGjGTAXMBUGBFUdEQEEDTALoAkGBFUdEQECAQAwDQYJKoZIhvc` + `NAQELBQADggEBAHblGVlZR9uyZN7sNpd7zDiVaoCJjuSFwmnEjrRqzMNxqqBixYXAb2LgeFya` + `MqLT0WEEB5v8BQL0FlsKPob9GpzMiLfFxhQGpR5K57nRlN5Qws+XWSCydi0tBAC5mHJea8VZB` + `j9REsFUEtgE7En2BDBRD/4DcM+d0bmyXh7GKYLoMcSEQJ+zpSJ4AwXraKKkcIwqcXMkNZhbMz` + `l/EyhwOsDvBRb1t0VJkrS9s01buqz+gkrPwm5+0+BhLxCfT1PP5DBhs72Pt/1UPOlDLPuf/AB` + `bZoWR2vqNvX+ia1bsAJvx56K1KkRSswhJOPCSWLnPcB/Eh6oWUY0dZQQN+5v6Hm8=` const jwtAccessToken string = ` { "token_id" : "59e39159-19eb-463d-953d-1d6baf567db6", "access_token" : "eyJ2ZXIiOiIyIiwidHlwIjoiSldUIiwiYWxnIjoiUlMyNTYiLCJraW` + `QiOiJxdkhkX3lTNWlPQTlfQ3E5Z3BVSl9WdDBzYVhsTExhdWk2SzFrb291MEJzIn0.eyJ` + `leHQiOiJ7XCJyZXZvY2FibGVcIjpcInRydWVcIn0iLCJzdWIiOiJqZmFjQDAxZzVoZWs2` + `a2IyOTUyMHJiejcxdjkxY3c5XC91c2Vyc1wvYWRtaW4iLCJzY3AiOiJhcHBsaWVkLXBlc` + `m1pc3Npb25zXC9hZG1pbiIsImF1ZCI6IipAKiIsImlzcyI6ImpmYWNAMDFnNWhlazZrYj` + `I5NTIwcmJ6NzF2OTFjdzkiLCJleHAiOjE2ODY3ODA4MjgsImlhdCI6MTY1NTI0NDgyOCw` + `ianRpIjoiNTllMzkxNTktMTllYi00NjNkLTk1M2QtMWQ2YmFmNTY3ZGI2In0.IaWDbYM-` + `NkDA9KVkCHlYMJAOD0CvOH3Hq4t2P3YYm8B6G1MddH46VPKGPySr4st5KmMInfW-lmg6I` + `fXjVarlkJVT8AkiaTBOR7EJFC5kqZ80OHOtYKusIHZx_7aEuDC6f9mijwuxz5ERd7WmYn` + `Jn3hOwLd7_94hScX9gWfmYcT3xZNjTS48BmXOqPyXu-XtfZ9K-X9zQNtHv6j9qFNtwwTf` + `v9GN8wnwTJ-e4xpginFQh-9YETaWUVtvOsm2-VtM5vDsszYtg8FM-Bz3JFNqJTFlvDs75` + `ATmHEjwoCIa7Vzg_GqAgFFRrW3SYwW3GpPyk8vJT9xLmEBBwVUVl2Ngjdw", "expires_in" : 31536000, "scope" : "applied-permissions/admin", "token_type" : "Bearer" }` // Literally https://www.jfrog.com/confluence/display/JFROG/Artifactory+REST+API#ArtifactoryRESTAPI-CreateToken const canonicalAccessToken = `{ "access_token": "eyXsdgbtybbeeyh...", "expires_in": 0, "scope": "api:* member-of-groups:example", "token_type": "Bearer", "refresh_token": "fgsfgsdugh8dgu9s8gy9hsg..." }` const artVersion = `{ "version": "7.19.10", "revision": "71910900", "license": "05179b957028fa9aa1ceb88da6519a245e55b9fc5" }` func makeBackend(t *testing.T) (*backend, *logical.BackendConfig) { config := logical.TestBackendConfig() config.StorageView = &logical.InmemStorage{} b, err := Backend(config) if err != nil { t.Fatal(err) } if err := b.Setup(context.Background(), config); err != nil { t.Fatal(err) } return b, config } func configuredBackend(t *testing.T, adminConfig map[string]interface{}) (*backend, *logical.BackendConfig) { b, config := makeBackend(t) _, err := b.HandleRequest(context.Background(), &logical.Request{ Operation: logical.UpdateOperation, Path: "config/admin", Storage: config.StorageView, Data: adminConfig, }) assert.NoError(t, err) return b, config }