From b93ce28a6d75e080cfa59323f13e7cbb65ed4b28 Mon Sep 17 00:00:00 2001 From: Alex Hung Date: Thu, 15 Feb 2024 13:49:43 -0800 Subject: [PATCH 1/8] Clean up code and move paths to constants --- artifactory.go | 3 +-- backend.go | 23 +---------------------- path_config.go | 29 ++++++++++++++++++++++++++--- path_config_rotate.go | 2 +- path_config_rotate_test.go | 2 +- path_config_test.go | 22 +++++++++++----------- path_roles.go | 16 +++++++++------- test_utils.go | 8 ++++---- 8 files changed, 54 insertions(+), 51 deletions(-) diff --git a/artifactory.go b/artifactory.go index 6ead920..a093d4a 100644 --- a/artifactory.go +++ b/artifactory.go @@ -310,7 +310,7 @@ func (b *backend) getTokenInfo(config adminConfiguration, token string) (info *T // 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 + // REF: https://jfrog.com/help/r/jfrog-rest-apis/get-root-certificate if !b.checkVersion("7.12.0") { return cert, ErrIncompatibleVersion } @@ -329,7 +329,6 @@ func (b *backend) getRootCert(config adminConfiguration) (cert *x509.Certificate } body, err := io.ReadAll(resp.Body) - // body, err := ioutil.ReadAll(resp.Body) Go.1.15 and earlier if err != nil { b.Logger().Error("error reading root cert response body", "err", err) return diff --git a/backend.go b/backend.go index 5f5f55f..b2cd51f 100644 --- a/backend.go +++ b/backend.go @@ -63,7 +63,7 @@ func Backend(_ *logical.BackendConfig) (*backend, error) { RunningVersion: Version, PathsSpecial: &logical.Paths{ - SealWrapStorage: []string{"config/admin"}, + SealWrapStorage: []string{configAdminPath}, }, BackendType: logical.TypeLogical, @@ -142,27 +142,6 @@ func (b *backend) reset() { b.httpClient = 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. ` diff --git a/path_config.go b/path_config.go index 3bc7fa9..04ec9dc 100644 --- a/path_config.go +++ b/path_config.go @@ -10,9 +10,11 @@ import ( "github.com/hashicorp/vault/sdk/logical" ) +const configAdminPath = "config/admin" + func (b *backend) pathConfig() *framework.Path { return &framework.Path{ - Pattern: "config/admin", + Pattern: configAdminPath, Fields: map[string]*framework.FieldSchema{ "access_token": { Type: framework.TypeString, @@ -83,6 +85,27 @@ type adminConfiguration struct { BypassArtifactoryTLSVerification bool `json:"bypass_artifactory_tls_verification,omitempty"` } +// 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, configAdminPath) + 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 +} + func (b *backend) pathConfigUpdate(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { b.configMutex.Lock() defer b.configMutex.Unlock() @@ -139,7 +162,7 @@ func (b *backend) pathConfigUpdate(ctx context.Context, req *logical.Request, da return logical.ErrorResponse("Unable to get Artifactory Version. Check url and access_token fields. TLS connection verification with Artifactory can be skipped by setting bypass_artifactory_tls_verification field to 'true'"), err } - entry, err := logical.StorageEntryJSON("config/admin", config) + entry, err := logical.StorageEntryJSON(configAdminPath, config) if err != nil { return nil, err } @@ -167,7 +190,7 @@ func (b *backend) pathConfigDelete(ctx context.Context, req *logical.Request, _ go b.sendUsage(*config, "pathConfigDelete") - if err := req.Storage.Delete(ctx, "config/admin"); err != nil { + if err := req.Storage.Delete(ctx, configAdminPath); err != nil { return nil, err } diff --git a/path_config_rotate.go b/path_config_rotate.go index afd4ae7..ade9731 100644 --- a/path_config_rotate.go +++ b/path_config_rotate.go @@ -86,7 +86,7 @@ func (b *backend) pathConfigRotateWrite(ctx context.Context, req *logical.Reques config.AccessToken = resp.AccessToken // Save new config - entry, err := logical.StorageEntryJSON("config/admin", config) + entry, err := logical.StorageEntryJSON(configAdminPath, config) if err != nil { return nil, err } diff --git a/path_config_rotate_test.go b/path_config_rotate_test.go index 9652c71..e412370 100644 --- a/path_config_rotate_test.go +++ b/path_config_rotate_test.go @@ -82,7 +82,7 @@ func (e *accTestEnv) PathConfigRotateCreateTokenErr(t *testing.T) { func (e *accTestEnv) PathConfigRotateBadAccessToken(t *testing.T) { // Forcibly set a bad token - entry, err := logical.StorageEntryJSON("config/admin", adminConfiguration{ + entry, err := logical.StorageEntryJSON(configAdminPath, adminConfiguration{ AccessToken: "bogus.token", ArtifactoryURL: e.URL, }) diff --git a/path_config_test.go b/path_config_test.go index 72fcbfc..4e2b54f 100644 --- a/path_config_test.go +++ b/path_config_test.go @@ -32,7 +32,7 @@ func TestAcceptanceBackend_PathConfig(t *testing.T) { } func (e *accTestEnv) PathConfigReadUnconfigured(t *testing.T) { - resp, err := e.read("config/admin") + resp, err := e.read(configAdminPath) assert.Contains(t, resp.Data["error"], "backend not configured") assert.NoError(t, err) } @@ -73,7 +73,7 @@ func (e *accTestEnv) pathConfigUpdateBooleanField(t *testing.T, fieldName string assert.Equal(t, false, data[fieldName]) // Fail Tests - resp, err := e.update("config/admin", testData{ + resp, err := e.update(configAdminPath, testData{ fieldName: "Sure, why not", }) assert.NotNil(t, resp) @@ -90,7 +90,7 @@ func (e *accTestEnv) PathConfigUpdateUsernameTemplate(t *testing.T) { assert.Equal(t, data["username_template"], usernameTemplate) // Bad Template - resp, err := e.update("config/admin", testData{ + resp, err := e.update(configAdminPath, testData{ "username_template": "bad_{{ .somethingInvalid }}_testing {{", }) assert.NotNil(t, resp) @@ -101,7 +101,7 @@ func (e *accTestEnv) PathConfigUpdateUsernameTemplate(t *testing.T) { // most of these were covered by unit tests, but we want test coverage for acceptance func (e *accTestEnv) PathConfigUpdateErrors(t *testing.T) { // Access Token Required - resp, err := e.update("config/admin", testData{ + resp, err := e.update(configAdminPath, testData{ "url": e.URL, }) assert.NoError(t, err) @@ -109,7 +109,7 @@ func (e *accTestEnv) PathConfigUpdateErrors(t *testing.T) { assert.True(t, resp.IsError()) assert.Contains(t, resp.Error().Error(), "access_token") // URL Required - resp, err = e.update("config/admin", testData{ + resp, err = e.update(configAdminPath, testData{ "access_token": "test-access-token", }) assert.NoError(t, err) @@ -117,7 +117,7 @@ func (e *accTestEnv) PathConfigUpdateErrors(t *testing.T) { assert.True(t, resp.IsError()) assert.Contains(t, resp.Error().Error(), "url") // Bad Token - resp, err = e.update("config/admin", testData{ + resp, err = e.update(configAdminPath, testData{ "access_token": "test-access-token", "url": e.URL, }) @@ -129,14 +129,14 @@ func (e *accTestEnv) PathConfigUpdateErrors(t *testing.T) { func (e *accTestEnv) PathConfigReadBadAccessToken(t *testing.T) { // Forcibly set a bad token - entry, err := logical.StorageEntryJSON("config/admin", adminConfiguration{ + entry, err := logical.StorageEntryJSON(configAdminPath, adminConfiguration{ AccessToken: "bogus.token", ArtifactoryURL: e.URL, }) assert.NoError(t, err) err = e.Storage.Put(e.Context, entry) assert.NoError(t, err) - resp, err := e.read("config/admin") + resp, err := e.read(configAdminPath) assert.NoError(t, err) assert.NotNil(t, resp) @@ -152,7 +152,7 @@ func TestBackend_AccessTokenRequired(t *testing.T) { resp, err := b.HandleRequest(context.Background(), &logical.Request{ Operation: logical.UpdateOperation, - Path: "config/admin", + Path: configAdminPath, Storage: config.StorageView, Data: adminConfig, }) @@ -172,7 +172,7 @@ func TestBackend_URLRequired(t *testing.T) { resp, err := b.HandleRequest(context.Background(), &logical.Request{ Operation: logical.UpdateOperation, - Path: "config/admin", + Path: configAdminPath, Storage: config.StorageView, Data: adminConfig, }) @@ -206,7 +206,7 @@ func TestBackend_AccessTokenAsSHA256(t *testing.T) { resp, err := b.HandleRequest(context.Background(), &logical.Request{ Operation: logical.ReadOperation, - Path: "config/admin", + Path: configAdminPath, Storage: config.StorageView, }) diff --git a/path_roles.go b/path_roles.go index a6244d6..1e81497 100644 --- a/path_roles.go +++ b/path_roles.go @@ -8,9 +8,11 @@ import ( "github.com/hashicorp/vault/sdk/logical" ) +const rolePath = "roles/" + func (b *backend) pathListRoles() *framework.Path { return &framework.Path{ - Pattern: "roles/?$", + Pattern: rolePath + "?$", Operations: map[logical.Operation]framework.OperationHandler{ logical.ListOperation: &framework.PathOperation{ Callback: b.pathRoleList, @@ -22,7 +24,7 @@ func (b *backend) pathListRoles() *framework.Path { func (b *backend) pathRoles() *framework.Path { return &framework.Path{ - Pattern: "roles/" + framework.GenericNameWithAtRegex("role"), + Pattern: rolePath + framework.GenericNameWithAtRegex("role"), Fields: map[string]*framework.FieldSchema{ "role": { Type: framework.TypeString, @@ -98,13 +100,14 @@ type artifactoryRole struct { IncludeReferenceToken bool `json:"include_reference_token"` DefaultTTL time.Duration `json:"default_ttl,omitempty"` MaxTTL time.Duration `json:"max_ttl,omitempty"` + RefreshToken string `json:"-"` } 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/") + entries, err := req.Storage.List(ctx, rolePath) if err != nil { return nil, err } @@ -130,7 +133,6 @@ func (b *backend) pathRoleWrite(ctx context.Context, req *logical.Request, data go b.sendUsage(*config, "pathRoleWrite") roleName := data.Get("role").(string) - if roleName == "" { return logical.ErrorResponse("missing role"), nil } @@ -186,7 +188,7 @@ func (b *backend) pathRoleWrite(ctx context.Context, req *logical.Request, data return logical.ErrorResponse("missing scope"), nil } - entry, err := logical.StorageEntryJSON("roles/"+roleName, role) + entry, err := logical.StorageEntryJSON(rolePath+roleName, role) if err != nil { return nil, err } @@ -237,7 +239,7 @@ func (b *backend) pathRoleRead(ctx context.Context, req *logical.Request, data * func (b *backend) Role(ctx context.Context, storage logical.Storage, roleName string) (*artifactoryRole, error) { - entry, err := storage.Get(ctx, "roles/"+roleName) + entry, err := storage.Get(ctx, rolePath+roleName) if err != nil { return nil, err } @@ -295,7 +297,7 @@ func (b *backend) pathRoleDelete(ctx context.Context, req *logical.Request, data go b.sendUsage(*config, "pathRoleDelete") - err = req.Storage.Delete(ctx, "roles/"+data.Get("role").(string)) + err = req.Storage.Delete(ctx, rolePath+data.Get("role").(string)) if err != nil { return nil, err } diff --git a/test_utils.go b/test_utils.go index ac2012d..015fab8 100644 --- a/test_utils.go +++ b/test_utils.go @@ -120,14 +120,14 @@ func (e *accTestEnv) UpdatePathConfig(t *testing.T) { // UpdateConfigAdmin will send a POST/PUT to the /config/admin endpoint with testData (vault write artifactory/config/admin) func (e *accTestEnv) UpdateConfigAdmin(t *testing.T, data testData) { - resp, err := e.update("config/admin", data) + resp, err := e.update(configAdminPath, data) assert.NoError(t, err) assert.Nil(t, resp) } // UpdateConfigAdmin will send a POST/PUT to the /config/user_token endpoint with testData (vault write artifactory/config/user_token) func (e *accTestEnv) UpdateConfigUserToken(t *testing.T, data testData) { - resp, err := e.update("config/user_token", data) + resp, err := e.update(configUserTokenPath, data) assert.NoError(t, err) assert.Nil(t, resp) } @@ -163,7 +163,7 @@ func (e *accTestEnv) DeletePathConfig(t *testing.T) { func (e *accTestEnv) DeleteConfigAdmin(t *testing.T) { resp, err := e.Backend.HandleRequest(e.Context, &logical.Request{ Operation: logical.DeleteOperation, - Path: "config/admin", + Path: configAdminPath, Storage: e.Storage, }) @@ -464,7 +464,7 @@ func configuredBackend(t *testing.T, adminConfig map[string]interface{}) (*backe _, err := b.HandleRequest(context.Background(), &logical.Request{ Operation: logical.UpdateOperation, - Path: "config/admin", + Path: configAdminPath, Storage: config.StorageView, Data: adminConfig, }) From ee1242902ebb4d39de69c80e0036cbb829b5e010 Mon Sep 17 00:00:00 2001 From: Alex Hung Date: Thu, 15 Feb 2024 14:10:38 -0800 Subject: [PATCH 2/8] Add optional username path and field to user token config Store user config base on path Add support for using refresh token to renew an existing access token --- artifactory.go | 4 ++- path_config_user_token.go | 65 +++++++++++++++++++++++++++++---------- path_user_token_create.go | 42 +++++++++++++++++-------- test_utils.go | 8 ++--- ttl_test.go | 4 +-- 5 files changed, 88 insertions(+), 35 deletions(-) diff --git a/artifactory.go b/artifactory.go index a093d4a..40af1d3 100644 --- a/artifactory.go +++ b/artifactory.go @@ -84,6 +84,7 @@ type CreateTokenRequest struct { Audience string `json:"audience,omitempty"` ForceRevocable bool `json:"force_revocable,omitempty"` IncludeReferenceToken bool `json:"include_reference_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` } func (b *backend) CreateToken(config adminConfiguration, role artifactoryRole) (*createTokenResponse, error) { @@ -95,9 +96,10 @@ func (b *backend) CreateToken(config adminConfiguration, role artifactoryRole) ( Description: role.Description, Refreshable: role.Refreshable, IncludeReferenceToken: role.IncludeReferenceToken, + RefreshToken: role.RefreshToken, } - if len(request.Username) == 0 { + if request.GrantType == "client_credentials" && len(request.Username) == 0 { return nil, fmt.Errorf("empty username not allowed, possibly a template error") } diff --git a/path_config_user_token.go b/path_config_user_token.go index 9f1e4ae..d1d558c 100644 --- a/path_config_user_token.go +++ b/path_config_user_token.go @@ -10,13 +10,19 @@ import ( "github.com/hashicorp/vault/sdk/logical" ) +const configUserTokenPath = "config/user_token" + func (b *backend) pathConfigUserToken() *framework.Path { return &framework.Path{ - Pattern: "config/user_token", + Pattern: fmt.Sprintf("%s(?:/%s)?", configUserTokenPath, framework.GenericNameWithAtRegex("username")), Fields: map[string]*framework.FieldSchema{ + "username": { + Type: framework.TypeString, + Description: `Optional. The username of the user. If not specified, the configuration will apply to *all* users.`, + }, "access_token": { Type: framework.TypeString, - Description: "User identity token to access Artifactory", + Description: "Optional. User identity token to access Artifactory. If `username` is not set then this token will be used for *all* users.", }, "audience": { Type: framework.TypeString, @@ -53,15 +59,15 @@ func (b *backend) pathConfigUserToken() *framework.Path { Operations: map[logical.Operation]framework.OperationHandler{ logical.UpdateOperation: &framework.PathOperation{ Callback: b.pathConfigUserTokenUpdate, - Summary: "Configure the Artifactory secrets backend.", + Summary: "Configure the Artifactory secrets configuration for user token.", }, logical.ReadOperation: &framework.PathOperation{ Callback: b.pathConfigUserTokenRead, - Summary: "Examine the Artifactory secrets configuration.", + Summary: "Examine the Artifactory secrets configuration for user token.", }, }, HelpSynopsis: `Configuration for issuing user tokens.`, - HelpDescription: `Configures default values for the user_token/ path.`, + HelpDescription: `Configures default values for the user_token/ path. The optional 'username' field allows the configuration to be set for each username.`, } } @@ -77,11 +83,17 @@ type userTokenConfiguration struct { } // fetchAdminConfiguration will return nil,nil if there's no configuration -func (b *backend) fetchUserTokenConfiguration(ctx context.Context, storage logical.Storage) (*userTokenConfiguration, error) { +func (b *backend) fetchUserTokenConfiguration(ctx context.Context, storage logical.Storage, username string) (*userTokenConfiguration, error) { var config userTokenConfiguration + // If username is not empty, then append to the path to fetch username specific configuration + path := configUserTokenPath + if len(username) > 0 { + path = fmt.Sprintf("%s/%s", path, username) + } + // Read in the backend configuration - entry, err := storage.Get(ctx, "config/user_token") + entry, err := storage.Get(ctx, path) if err != nil { return nil, err } @@ -101,18 +113,23 @@ func (b *backend) pathConfigUserTokenUpdate(ctx context.Context, req *logical.Re b.configMutex.Lock() defer b.configMutex.Unlock() - config, err := b.fetchAdminConfiguration(ctx, req.Storage) + adminConfig, err := b.fetchAdminConfiguration(ctx, req.Storage) if err != nil { return nil, err } - if config == nil { - config = &adminConfiguration{} + if adminConfig == nil { + adminConfig = &adminConfiguration{} } - go b.sendUsage(*config, "pathConfigUserTokenUpdate") + go b.sendUsage(*adminConfig, "pathConfigUserTokenUpdate") - userTokenConfig, err := b.fetchUserTokenConfiguration(ctx, req.Storage) + username := "" + if val, ok := data.GetOk("username"); ok { + username = val.(string) + } + + userTokenConfig, err := b.fetchUserTokenConfiguration(ctx, req.Storage, username) if err != nil { return nil, err } @@ -149,7 +166,13 @@ func (b *backend) pathConfigUserTokenUpdate(ctx context.Context, req *logical.Re userTokenConfig.DefaultDescription = val.(string) } - entry, err := logical.StorageEntryJSON("config/user_token", userTokenConfig) + // If username is not empty, then append to the path to fetch username specific configuration + path := configUserTokenPath + if len(username) > 0 { + path = fmt.Sprintf("%s/%s", path, username) + } + + entry, err := logical.StorageEntryJSON(path, userTokenConfig) if err != nil { return nil, err } @@ -162,7 +185,7 @@ func (b *backend) pathConfigUserTokenUpdate(ctx context.Context, req *logical.Re return nil, nil } -func (b *backend) pathConfigUserTokenRead(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { +func (b *backend) pathConfigUserTokenRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { b.configMutex.RLock() defer b.configMutex.RUnlock() @@ -177,7 +200,12 @@ func (b *backend) pathConfigUserTokenRead(ctx context.Context, req *logical.Requ go b.sendUsage(*adminConfig, "pathConfigUserTokenRead") - userTokenConfig, err := b.fetchUserTokenConfiguration(ctx, req.Storage) + username := "" + if val, ok := data.GetOk("username"); ok { + username = val.(string) + } + + userTokenConfig, err := b.fetchUserTokenConfiguration(ctx, req.Storage, username) if err != nil { return nil, err } @@ -195,8 +223,13 @@ func (b *backend) pathConfigUserTokenRead(ctx context.Context, req *logical.Requ "default_description": userTokenConfig.DefaultDescription, } + accessToken := adminConfig.AccessToken + if len(userTokenConfig.AccessToken) > 0 { + accessToken = userTokenConfig.AccessToken + } + // Optionally include token info if it parses properly - token, err := b.getTokenInfo(*adminConfig, adminConfig.AccessToken) + token, err := b.getTokenInfo(*adminConfig, accessToken) if err != nil { b.Logger().Warn("Error parsing AccessToken", "err", err.Error()) } else { diff --git a/path_user_token_create.go b/path_user_token_create.go index 14b7540..6ddd985 100644 --- a/path_user_token_create.go +++ b/path_user_token_create.go @@ -24,7 +24,7 @@ func (b *backend) pathUserTokenCreate() *framework.Path { "refreshable": { Type: framework.TypeBool, Default: false, - Description: `Optional. Defaults to 'false'. 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. Set this to 'true' only if your usage requires this. See the JFrog Artifactory documentation on "Generating Refreshable Tokens" (https://jfrog.com/help/r/jfrog-platform-administration-documentation/generating-refreshable-tokens) for a full and up to date description.`, + Description: `Optional. Defaults to 'false'. 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. Set this to 'true' only if your usage requires this. See the JFrog Artifactory documentation on "Generating Refreshable Tokens" (https://jfrog.com/help/r/jfrog-platform-administration-documentation/generating-refreshable-tokens) for a full and up to date description.`, }, "include_reference_token": { Type: framework.TypeBool, @@ -44,6 +44,10 @@ func (b *backend) pathUserTokenCreate() *framework.Path { Type: framework.TypeDurationSecond, Description: `Optional. Override the default TTL when issuing this access token. Cappaed at the smallest maximum TTL (system, mount, backend, request).`, }, + "refresh_token": { + Type: framework.TypeString, + Description: "Refresh token for an existing access token. When specified, this will be used to refresh the existing access token.", + }, }, Operations: map[logical.Operation]framework.OperationHandler{ logical.ReadOperation: &framework.PathOperation{ @@ -70,7 +74,9 @@ func (b *backend) pathUserTokenCreatePerform(ctx context.Context, req *logical.R go b.sendUsage(*adminConfig, "pathUserTokenCreatePerform") - userTokenConfig, err := b.fetchUserTokenConfiguration(ctx, req.Storage) + username := data.Get("username").(string) + + userTokenConfig, err := b.fetchUserTokenConfiguration(ctx, req.Storage, username) if err != nil { return nil, err } @@ -84,18 +90,30 @@ func (b *backend) pathUserTokenCreatePerform(ctx context.Context, req *logical.R adminConfig.UseExpiringTokens = value.(bool) } - role := artifactoryRole{ - GrantType: "client_credentials", - Username: data.Get("username").(string), - Scope: "applied-permissions/user", - MaxTTL: b.Backend.System().MaxLeaseTTL(), - Audience: userTokenConfig.Audience, - Refreshable: userTokenConfig.Refreshable, - IncludeReferenceToken: userTokenConfig.IncludeReferenceToken, - Description: userTokenConfig.DefaultDescription, + var role artifactoryRole + + refreshToken := "" + if value, ok := data.GetOk("refresh_token"); ok { + refreshToken = value.(string) } - b.Logger().Debug("pathUserTokenCreatePerform", "role.Description", role.Description) + if len(refreshToken) > 0 { + role = artifactoryRole{ + GrantType: "refresh_token", + RefreshToken: refreshToken, + } + } else { + role = artifactoryRole{ + GrantType: "client_credentials", + Username: username, + Scope: "applied-permissions/user", + MaxTTL: b.Backend.System().MaxLeaseTTL(), + Audience: userTokenConfig.Audience, + Refreshable: userTokenConfig.Refreshable, + IncludeReferenceToken: userTokenConfig.IncludeReferenceToken, + Description: userTokenConfig.DefaultDescription, + } + } if userTokenConfig.MaxTTL != 0 && userTokenConfig.MaxTTL < role.MaxTTL { role.MaxTTL = userTokenConfig.MaxTTL diff --git a/test_utils.go b/test_utils.go index 015fab8..494059e 100644 --- a/test_utils.go +++ b/test_utils.go @@ -138,7 +138,7 @@ func (e *accTestEnv) ReadPathConfig(t *testing.T) { // ReadConfigAdmin will send a GET to the /config/admin endpoint (vault read artifactory/config/admin) func (e *accTestEnv) ReadConfigAdmin(t *testing.T) testData { - resp, err := e.read("config/admin") + resp, err := e.read(configAdminPath) assert.NoError(t, err) assert.NotNil(t, resp) @@ -148,7 +148,7 @@ func (e *accTestEnv) ReadConfigAdmin(t *testing.T) testData { // ReadConfigUserToken will send a GET to the /config/user_token endpoint (vault read artifactory/config/user_token) func (e *accTestEnv) ReadConfigUserToken(t *testing.T) testData { - resp, err := e.read("config/user_token") + resp, err := e.read(configUserTokenPath) assert.NoError(t, err) assert.NotNil(t, resp) @@ -271,7 +271,7 @@ func (e *accTestEnv) CreatePathToken(t *testing.T) { func (e *accTestEnv) CreatePathUserToken(t *testing.T) { resp, err := e.Backend.HandleRequest(context.Background(), &logical.Request{ Operation: logical.UpdateOperation, - Path: "config/user_token", + Path: configUserTokenPath, Storage: e.Storage, Data: map[string]interface{}{ "default_description": "foo", @@ -305,7 +305,7 @@ func (e *accTestEnv) CreatePathUserToken(t *testing.T) { func (e *accTestEnv) CreatePathUserToken_overrides(t *testing.T) { resp, err := e.Backend.HandleRequest(context.Background(), &logical.Request{ Operation: logical.UpdateOperation, - Path: "config/user_token", + Path: configUserTokenPath, Storage: e.Storage, Data: map[string]interface{}{ "default_description": "foo", diff --git a/ttl_test.go b/ttl_test.go index 695bb3b..57b8d03 100644 --- a/ttl_test.go +++ b/ttl_test.go @@ -155,7 +155,7 @@ func TestBackend_NoUserTokensMaxTTLUsesSystemMaxTTL(t *testing.T) { func SetUserTokenMaxTTL(t *testing.T, b *backend, storage logical.Storage, max_ttl time.Duration) { resp, err := b.HandleRequest(context.Background(), &logical.Request{ Operation: logical.UpdateOperation, - Path: "config/user_token", + Path: configUserTokenPath, Storage: storage, Data: map[string]interface{}{ "max_ttl": max_ttl, @@ -350,7 +350,7 @@ func TestBackend_UserTokenDefaultTTL(t *testing.T) { resp, err := b.HandleRequest(context.Background(), &logical.Request{ Operation: logical.UpdateOperation, - Path: "config/user_token", + Path: configUserTokenPath, Storage: config.StorageView, Data: map[string]interface{}{ "default_ttl": 42 * time.Minute, From 4203a57be7a5d62d7be4f60e5ac1b69d214dc083 Mon Sep 17 00:00:00 2001 From: Alex Hung Date: Tue, 20 Feb 2024 15:59:00 -0800 Subject: [PATCH 3/8] Add refresh_token field to user token config Add check for expired token error Add refresh token attempt then retry token creation --- artifactory.go | 56 ++++++++++++++++++++++++++++++++-- go.mod | 2 ++ go.sum | 4 +++ path_config_user_token.go | 46 ++++++++++++++++++++-------- path_user_token_create.go | 63 ++++++++++++++++++++++----------------- 5 files changed, 128 insertions(+), 43 deletions(-) diff --git a/artifactory.go b/artifactory.go index 40af1d3..83113d8 100644 --- a/artifactory.go +++ b/artifactory.go @@ -11,16 +11,21 @@ import ( "net" "net/http" "net/url" + "regexp" "strings" + "time" 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" + "github.com/samber/lo" ) const ( - defaultUserNameTemplate string = `{{ printf "v-%s-%s" (.RoleName | truncate 24) (random 8) }}` // Docs indicate max length is 256 + defaultUserNameTemplate string = `{{ printf "v-%s-%s" (.RoleName | truncate 24) (random 8) }}` // Docs indicate max length is 256 + grantTypeClientCredentials string = "client_credentials" + grantTypeRefreshToken string = "refresh_token" ) var ErrIncompatibleVersion = errors.New("incompatible version") @@ -87,6 +92,16 @@ type CreateTokenRequest struct { RefreshToken string `json:"refresh_token,omitempty"` } +type createTokenErrorResponse struct { + Errors []errorResponse `json:"errors"` +} + +type TokenExpiredError struct{} + +func (e *TokenExpiredError) Error() string { + return "token has expired" +} + func (b *backend) CreateToken(config adminConfiguration, role artifactoryRole) (*createTokenResponse, error) { request := CreateTokenRequest{ GrantType: role.GrantType, @@ -99,6 +114,23 @@ func (b *backend) CreateToken(config adminConfiguration, role artifactoryRole) ( RefreshToken: role.RefreshToken, } + return b.createToken(config, role.MaxTTL, request) +} + +func (b *backend) RefreshToken(config adminConfiguration, role artifactoryRole) (*createTokenResponse, error) { + if role.RefreshToken == "" { + return nil, fmt.Errorf("no refresh token supplied.") + } + + request := CreateTokenRequest{ + GrantType: grantTypeRefreshToken, + RefreshToken: role.RefreshToken, + } + + return b.createToken(config, role.MaxTTL, request) +} + +func (b *backend) createToken(config adminConfiguration, maxTTL time.Duration, request CreateTokenRequest) (*createTokenResponse, error) { if request.GrantType == "client_credentials" && len(request.Username) == 0 { return nil, fmt.Errorf("empty username not allowed, possibly a template error") } @@ -109,8 +141,8 @@ func (b *backend) CreateToken(config adminConfiguration, role artifactoryRole) ( // 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()) + if config.UseExpiringTokens && b.supportForceRevocable() && maxTTL > 0 { + request.ExpiresIn = int64(maxTTL.Seconds()) request.ForceRevocable = true } @@ -145,6 +177,24 @@ func (b *backend) CreateToken(config adminConfiguration, role artifactoryRole) ( if resp.StatusCode != http.StatusOK { e := fmt.Errorf("could not create access token: HTTP response %v", resp.StatusCode) + if resp.StatusCode == http.StatusUnauthorized { + var errResp createTokenErrorResponse + err := json.NewDecoder(resp.Body).Decode(&errResp) + if err != nil { + b.Logger().Error("could not parse error response", "response", resp, "err", err) + return nil, fmt.Errorf("could not create access token. Err: %v", err) + } + + errMessages := lo.Reduce(errResp.Errors, func(agg string, e errorResponse, _ int) string { + return fmt.Sprintf("%s, %s", agg, e.Message) + }, "") + + expiredTokenRe := regexp.MustCompile(`.*Invalid token, expired.*`) + if expiredTokenRe.MatchString(errMessages) { + return nil, &TokenExpiredError{} + } + } + body, err := io.ReadAll(resp.Body) if err != nil { b.Logger().Error("createToken could not read error response body", "err", err) diff --git a/go.mod b/go.mod index 87cc05e..024c7db 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/hashicorp/vault/api v1.12.0 github.com/hashicorp/vault/sdk v0.11.0 github.com/jarcoal/httpmock v1.3.1 + github.com/samber/lo v1.39.0 github.com/stretchr/testify v1.8.4 ) @@ -65,6 +66,7 @@ require ( github.com/ryanuber/go-glob v1.0.0 // indirect go.uber.org/atomic v1.10.0 // indirect golang.org/x/crypto v0.17.0 // indirect + golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect golang.org/x/mod v0.11.0 // indirect golang.org/x/net v0.17.0 // indirect golang.org/x/sys v0.15.0 // indirect diff --git a/go.sum b/go.sum index fa636f4..ec144b9 100644 --- a/go.sum +++ b/go.sum @@ -228,6 +228,8 @@ github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4 github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= +github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -254,6 +256,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM= +golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= diff --git a/path_config_user_token.go b/path_config_user_token.go index d1d558c..5db3642 100644 --- a/path_config_user_token.go +++ b/path_config_user_token.go @@ -24,6 +24,10 @@ func (b *backend) pathConfigUserToken() *framework.Path { Type: framework.TypeString, Description: "Optional. User identity token to access Artifactory. If `username` is not set then this token will be used for *all* users.", }, + "refresh_token": { + Type: framework.TypeString, + Description: "Optional. Refresh token for the user access token. If `username` is not set then this token will be used for *all* users.", + }, "audience": { Type: framework.TypeString, Description: `Optional. See the JFrog Artifactory REST documentation on "Create Token" for a full and up to date description.`, @@ -73,6 +77,7 @@ func (b *backend) pathConfigUserToken() *framework.Path { type userTokenConfiguration struct { AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` Audience string `json:"audience,omitempty"` Refreshable bool `json:"refreshable,omitempty"` IncludeReferenceToken bool `json:"include_reference_token,omitempty"` @@ -93,6 +98,7 @@ func (b *backend) fetchUserTokenConfiguration(ctx context.Context, storage logic } // Read in the backend configuration + b.Logger().Info("fetching user token configuration from %s", path) entry, err := storage.Get(ctx, path) if err != nil { return nil, err @@ -109,6 +115,27 @@ func (b *backend) fetchUserTokenConfiguration(ctx context.Context, storage logic return &config, nil } +func (b *backend) storeUserTokenConfiguration(ctx context.Context, req *logical.Request, username string, userTokenConfig *userTokenConfiguration) error { + // If username is not empty, then append to the path to fetch username specific configuration + path := configUserTokenPath + if len(username) > 0 { + path = fmt.Sprintf("%s/%s", path, username) + } + + entry, err := logical.StorageEntryJSON(path, userTokenConfig) + if err != nil { + return err + } + + b.Logger().Info("saving user token configuration to %s", path) + err = req.Storage.Put(ctx, entry) + if err != nil { + return err + } + + return nil +} + func (b *backend) pathConfigUserTokenUpdate(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { b.configMutex.Lock() defer b.configMutex.Unlock() @@ -138,6 +165,10 @@ func (b *backend) pathConfigUserTokenUpdate(ctx context.Context, req *logical.Re userTokenConfig.AccessToken = val.(string) } + if val, ok := data.GetOk("refresh_token"); ok { + userTokenConfig.RefreshToken = val.(string) + } + if val, ok := data.GetOk("audience"); ok { userTokenConfig.Audience = val.(string) } @@ -166,18 +197,7 @@ func (b *backend) pathConfigUserTokenUpdate(ctx context.Context, req *logical.Re userTokenConfig.DefaultDescription = val.(string) } - // If username is not empty, then append to the path to fetch username specific configuration - path := configUserTokenPath - if len(username) > 0 { - path = fmt.Sprintf("%s/%s", path, username) - } - - entry, err := logical.StorageEntryJSON(path, userTokenConfig) - if err != nil { - return nil, err - } - - err = req.Storage.Put(ctx, entry) + err = b.storeUserTokenConfiguration(ctx, req, username, userTokenConfig) if err != nil { return nil, err } @@ -211,9 +231,11 @@ func (b *backend) pathConfigUserTokenRead(ctx context.Context, req *logical.Requ } accessTokenHash := sha256.Sum256([]byte(userTokenConfig.AccessToken)) + refreshTokenHash := sha256.Sum256([]byte(userTokenConfig.RefreshToken)) configMap := map[string]interface{}{ "access_token_sha256": fmt.Sprintf("%x", accessTokenHash[:]), + "refresh_token_sha256": fmt.Sprintf("%x", refreshTokenHash[:]), "audience": userTokenConfig.Audience, "refreshable": userTokenConfig.Refreshable, "include_reference_token": userTokenConfig.IncludeReferenceToken, diff --git a/path_user_token_create.go b/path_user_token_create.go index 6ddd985..7d8c875 100644 --- a/path_user_token_create.go +++ b/path_user_token_create.go @@ -2,6 +2,7 @@ package artifactory import ( "context" + "fmt" "time" "github.com/hashicorp/vault/sdk/framework" @@ -44,10 +45,6 @@ func (b *backend) pathUserTokenCreate() *framework.Path { Type: framework.TypeDurationSecond, Description: `Optional. Override the default TTL when issuing this access token. Cappaed at the smallest maximum TTL (system, mount, backend, request).`, }, - "refresh_token": { - Type: framework.TypeString, - Description: "Refresh token for an existing access token. When specified, this will be used to refresh the existing access token.", - }, }, Operations: map[logical.Operation]framework.OperationHandler{ logical.ReadOperation: &framework.PathOperation{ @@ -90,29 +87,16 @@ func (b *backend) pathUserTokenCreatePerform(ctx context.Context, req *logical.R adminConfig.UseExpiringTokens = value.(bool) } - var role artifactoryRole - - refreshToken := "" - if value, ok := data.GetOk("refresh_token"); ok { - refreshToken = value.(string) - } - - if len(refreshToken) > 0 { - role = artifactoryRole{ - GrantType: "refresh_token", - RefreshToken: refreshToken, - } - } else { - role = artifactoryRole{ - GrantType: "client_credentials", - Username: username, - Scope: "applied-permissions/user", - MaxTTL: b.Backend.System().MaxLeaseTTL(), - Audience: userTokenConfig.Audience, - Refreshable: userTokenConfig.Refreshable, - IncludeReferenceToken: userTokenConfig.IncludeReferenceToken, - Description: userTokenConfig.DefaultDescription, - } + role := artifactoryRole{ + GrantType: grantTypeClientCredentials, + Username: username, + Scope: "applied-permissions/user", + MaxTTL: b.Backend.System().MaxLeaseTTL(), + Audience: userTokenConfig.Audience, + Refreshable: userTokenConfig.Refreshable, + IncludeReferenceToken: userTokenConfig.IncludeReferenceToken, + Description: userTokenConfig.DefaultDescription, + RefreshToken: userTokenConfig.RefreshToken, } if userTokenConfig.MaxTTL != 0 && userTokenConfig.MaxTTL < role.MaxTTL { @@ -157,7 +141,30 @@ func (b *backend) pathUserTokenCreatePerform(ctx context.Context, req *logical.R resp, err := b.CreateToken(*adminConfig, role) if err != nil { - return nil, err + if _, ok := err.(*TokenExpiredError); ok { + b.Logger().Info("access token expired. Attempt to refresh using the refresh token.") + refreshResp, err := b.RefreshToken(*adminConfig, role) + if err != nil { + return nil, fmt.Errorf("failed to refresh access token. err: %v", err) + } + b.Logger().Info("access token refresh successful") + + userTokenConfig.AccessToken = refreshResp.AccessToken + userTokenConfig.RefreshToken = refreshResp.RefreshToken + b.storeUserTokenConfiguration(ctx, req, username, userTokenConfig) + + adminConfig.AccessToken = userTokenConfig.AccessToken + role.RefreshToken = userTokenConfig.RefreshToken + + // try again after token was refreshed + b.Logger().Info("attempt to create user token again after access token refresh") + resp, err = b.CreateToken(*adminConfig, role) + if err != nil { + return nil, err + } + } else { + return nil, err + } } response := b.Secret(SecretArtifactoryAccessTokenType).Response(map[string]interface{}{ From 007403eac20b11475107dbacc93aedd87831d786 Mon Sep 17 00:00:00 2001 From: Alex Hung Date: Thu, 22 Feb 2024 11:41:23 -0800 Subject: [PATCH 4/8] DRY the configuration between admin and user Add 'url' and 'bypass_artifactory_tls_verification' fields to user token config. --- artifactory.go | 35 ++++++++++++--------- backend.go | 6 ++-- path_config.go | 19 +++++------- path_config_rotate.go | 8 ++--- path_config_rotate_test.go | 6 ++-- path_config_test.go | 6 ++-- path_config_user_token.go | 62 ++++++++++++++++++++++++++------------ path_roles.go | 6 ++-- path_token_create.go | 4 +-- path_user_token_create.go | 30 ++++++++++-------- secret_access_token.go | 2 +- test_utils.go | 10 +++--- ttl_test.go | 12 +++++--- 13 files changed, 122 insertions(+), 84 deletions(-) diff --git a/artifactory.go b/artifactory.go index 83113d8..18db120 100644 --- a/artifactory.go +++ b/artifactory.go @@ -30,13 +30,20 @@ const ( var ErrIncompatibleVersion = errors.New("incompatible version") +type baseConfiguration struct { + AccessToken string `json:"access_token"` + ArtifactoryURL string `json:"artifactory_url"` + UseExpiringTokens bool `json:"use_expiring_tokens,omitempty"` + BypassArtifactoryTLSVerification bool `json:"bypass_artifactory_tls_verification,omitempty"` +} + type errorResponse struct { Code string `json:"code"` Message string `json:"message"` Detail string `json:"detail"` } -func (b *backend) RevokeToken(config adminConfiguration, secret logical.Secret) error { +func (b *backend) RevokeToken(config baseConfiguration, secret logical.Secret) error { tokenId := secret.InternalData["token_id"].(string) u, err := url.Parse(config.ArtifactoryURL) if err != nil { @@ -102,7 +109,7 @@ func (e *TokenExpiredError) Error() string { return "token has expired" } -func (b *backend) CreateToken(config adminConfiguration, role artifactoryRole) (*createTokenResponse, error) { +func (b *backend) CreateToken(config baseConfiguration, role artifactoryRole) (*createTokenResponse, error) { request := CreateTokenRequest{ GrantType: role.GrantType, Username: role.Username, @@ -117,9 +124,9 @@ func (b *backend) CreateToken(config adminConfiguration, role artifactoryRole) ( return b.createToken(config, role.MaxTTL, request) } -func (b *backend) RefreshToken(config adminConfiguration, role artifactoryRole) (*createTokenResponse, error) { +func (b *backend) RefreshToken(config baseConfiguration, role artifactoryRole) (*createTokenResponse, error) { if role.RefreshToken == "" { - return nil, fmt.Errorf("no refresh token supplied.") + return nil, fmt.Errorf("no refresh token supplied") } request := CreateTokenRequest{ @@ -130,7 +137,7 @@ func (b *backend) RefreshToken(config adminConfiguration, role artifactoryRole) return b.createToken(config, role.MaxTTL, request) } -func (b *backend) createToken(config adminConfiguration, maxTTL time.Duration, request CreateTokenRequest) (*createTokenResponse, error) { +func (b *backend) createToken(config baseConfiguration, maxTTL time.Duration, request CreateTokenRequest) (*createTokenResponse, error) { if request.GrantType == "client_credentials" && len(request.Username) == 0 { return nil, fmt.Errorf("empty username not allowed, possibly a template error") } @@ -228,7 +235,7 @@ func (b *backend) useNewAccessAPI() bool { } // getVersion will fetch the current Artifactory version and store it in the backend -func (b *backend) getVersion(config adminConfiguration) (err error) { +func (b *backend) getVersion(config baseConfiguration) (err error) { resp, err := b.performArtifactoryGet(config, "/artifactory/api/system/version") if err != nil { b.Logger().Error("error making system version request", "response", resp, "err", err) @@ -274,7 +281,7 @@ func (b *backend) checkVersion(ver string) (compatible bool) { } // 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) { +func (b *backend) parseJWT(config baseConfiguration, token string) (jwtToken *jwt.Token, err error) { validate := true cert, err := b.getRootCert(config) @@ -322,7 +329,7 @@ type TokenInfo struct { } // getTokenInfo will parse the provided token to return useful information about it -func (b *backend) getTokenInfo(config adminConfiguration, token string) (info *TokenInfo, err error) { +func (b *backend) getTokenInfo(config baseConfiguration, token string) (info *TokenInfo, err error) { // Parse Current Token (to get tokenID/scope) jwtToken, err := b.parseJWT(config, token) if err != nil { @@ -360,7 +367,7 @@ func (b *backend) getTokenInfo(config adminConfiguration, token string) (info *T } // 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) { +func (b *backend) getRootCert(config baseConfiguration) (cert *x509.Certificate, err error) { // Verify Artifactory version is at 7.12.0 or higher, prior versions will not work // REF: https://jfrog.com/help/r/jfrog-rest-apis/get-root-certificate if !b.checkVersion("7.12.0") { @@ -411,7 +418,7 @@ type Usage struct { Features []Feature `json:"features"` } -func (b *backend) sendUsage(config adminConfiguration, featureId string) { +func (b *backend) sendUsage(config baseConfiguration, featureId string) { features := []Feature{ { FeatureId: featureId, @@ -439,7 +446,7 @@ func (b *backend) sendUsage(config adminConfiguration, featureId string) { defer resp.Body.Close() } -func (b *backend) performArtifactoryGet(config adminConfiguration, path string) (*http.Response, error) { +func (b *backend) performArtifactoryGet(config baseConfiguration, path string) (*http.Response, error) { u, err := parseURLWithDefaultPort(config.ArtifactoryURL) if err != nil { return nil, err @@ -460,7 +467,7 @@ func (b *backend) performArtifactoryGet(config adminConfiguration, path string) } // performArtifactoryPost will HTTP POST values to the Artifactory API. -func (b *backend) performArtifactoryPost(config adminConfiguration, path string, values url.Values) (*http.Response, error) { +func (b *backend) performArtifactoryPost(config baseConfiguration, path string, values url.Values) (*http.Response, error) { u, err := parseURLWithDefaultPort(config.ArtifactoryURL) if err != nil { return nil, err @@ -482,7 +489,7 @@ func (b *backend) performArtifactoryPost(config adminConfiguration, path string, } // performArtifactoryPost will HTTP POST data to the Artifactory API. -func (b *backend) performArtifactoryPostWithJSON(config adminConfiguration, path string, postData []byte) (*http.Response, error) { +func (b *backend) performArtifactoryPostWithJSON(config baseConfiguration, path string, postData []byte) (*http.Response, error) { u, err := parseURLWithDefaultPort(config.ArtifactoryURL) if err != nil { return nil, err @@ -506,7 +513,7 @@ func (b *backend) performArtifactoryPostWithJSON(config adminConfiguration, path // 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) { +func (b *backend) performArtifactoryDelete(config baseConfiguration, path string) (*http.Response, error) { u, err := parseURLWithDefaultPort(config.ArtifactoryURL) if err != nil { diff --git a/backend.go b/backend.go index b2cd51f..c323147 100644 --- a/backend.go +++ b/backend.go @@ -94,9 +94,9 @@ func (b *backend) initialize(ctx context.Context, req *logical.InitializationReq return nil } - b.InitializeHttpClient(config) + b.InitializeHttpClient(&config.baseConfiguration) - err = b.getVersion(*config) + err = b.getVersion(config.baseConfiguration) if err != nil { return err } @@ -112,7 +112,7 @@ func (b *backend) initialize(ctx context.Context, req *logical.InitializationReq return nil } -func (b *backend) InitializeHttpClient(config *adminConfiguration) { +func (b *backend) InitializeHttpClient(config *baseConfiguration) { if config.BypassArtifactoryTLSVerification { tr := &http.Transport{ TLSClientConfig: &tls.Config{ diff --git a/path_config.go b/path_config.go index 04ec9dc..03c967c 100644 --- a/path_config.go +++ b/path_config.go @@ -78,11 +78,8 @@ No renewals or new tokens will be issued if the backend configuration (config/ad } 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"` - BypassArtifactoryTLSVerification bool `json:"bypass_artifactory_tls_verification,omitempty"` + baseConfiguration + UsernameTemplate string `json:"username_template,omitempty"` } // fetchAdminConfiguration will return nil,nil if there's no configuration @@ -153,11 +150,11 @@ func (b *backend) pathConfigUpdate(ctx context.Context, req *logical.Request, da return logical.ErrorResponse("url is required"), nil } - b.InitializeHttpClient(config) + b.InitializeHttpClient(&config.baseConfiguration) - go b.sendUsage(*config, "pathConfigRotateUpdate") + go b.sendUsage(config.baseConfiguration, "pathConfigRotateUpdate") - err = b.getVersion(*config) + err = b.getVersion(config.baseConfiguration) if err != nil { return logical.ErrorResponse("Unable to get Artifactory Version. Check url and access_token fields. TLS connection verification with Artifactory can be skipped by setting bypass_artifactory_tls_verification field to 'true'"), err } @@ -188,7 +185,7 @@ func (b *backend) pathConfigDelete(ctx context.Context, req *logical.Request, _ return logical.ErrorResponse("backend not configured"), nil } - go b.sendUsage(*config, "pathConfigDelete") + go b.sendUsage(config.baseConfiguration, "pathConfigDelete") if err := req.Storage.Delete(ctx, configAdminPath); err != nil { return nil, err @@ -210,7 +207,7 @@ func (b *backend) pathConfigRead(ctx context.Context, req *logical.Request, _ *f return logical.ErrorResponse("backend not configured"), nil } - go b.sendUsage(*config, "pathConfigRead") + go b.sendUsage(config.baseConfiguration, "pathConfigRead") // I'm not sure if I should be returning the access token, so I'll hash it. accessTokenHash := sha256.Sum256([]byte(config.AccessToken)) @@ -229,7 +226,7 @@ func (b *backend) pathConfigRead(ctx context.Context, req *logical.Request, _ *f } // Optionally include token info if it parses properly - token, err := b.getTokenInfo(*config, config.AccessToken) + token, err := b.getTokenInfo(config.baseConfiguration, config.AccessToken) if err != nil { b.Logger().Warn("Error parsing AccessToken: " + err.Error()) } else { diff --git a/path_config_rotate.go b/path_config_rotate.go index ade9731..afa316c 100644 --- a/path_config_rotate.go +++ b/path_config_rotate.go @@ -44,12 +44,12 @@ func (b *backend) pathConfigRotateWrite(ctx context.Context, req *logical.Reques return logical.ErrorResponse("backend not configured"), nil } - go b.sendUsage(*config, "pathConfigRotateWrite") + go b.sendUsage(config.baseConfiguration, "pathConfigRotateWrite") oldAccessToken := config.AccessToken // Parse Current Token (to get tokenID/scope) - token, err := b.getTokenInfo(*config, oldAccessToken) + token, err := b.getTokenInfo(config.baseConfiguration, oldAccessToken) if err != nil { return logical.ErrorResponse("error parsing existing access token: " + err.Error()), err } @@ -77,7 +77,7 @@ func (b *backend) pathConfigRotateWrite(ctx context.Context, req *logical.Reques } // Create a new token - resp, err := b.CreateToken(*config, *role) + resp, err := b.CreateToken(config.baseConfiguration, *role) if err != nil { return logical.ErrorResponse("error creating new access token"), err } @@ -103,7 +103,7 @@ func (b *backend) pathConfigRotateWrite(ctx context.Context, req *logical.Reques "token_id": token.TokenID, }, } - err = b.RevokeToken(*config, oldSecret) + err = b.RevokeToken(config.baseConfiguration, oldSecret) if err != nil { return logical.ErrorResponse("error revoking existing access token %s", token.TokenID), err } diff --git a/path_config_rotate_test.go b/path_config_rotate_test.go index e412370..81f8cde 100644 --- a/path_config_rotate_test.go +++ b/path_config_rotate_test.go @@ -83,8 +83,10 @@ func (e *accTestEnv) PathConfigRotateCreateTokenErr(t *testing.T) { func (e *accTestEnv) PathConfigRotateBadAccessToken(t *testing.T) { // Forcibly set a bad token entry, err := logical.StorageEntryJSON(configAdminPath, adminConfiguration{ - AccessToken: "bogus.token", - ArtifactoryURL: e.URL, + baseConfiguration: baseConfiguration{ + AccessToken: "bogus.token", + ArtifactoryURL: e.URL, + }, }) assert.NoError(t, err) err = e.Storage.Put(e.Context, entry) diff --git a/path_config_test.go b/path_config_test.go index 4e2b54f..cdb2623 100644 --- a/path_config_test.go +++ b/path_config_test.go @@ -130,8 +130,10 @@ func (e *accTestEnv) PathConfigUpdateErrors(t *testing.T) { func (e *accTestEnv) PathConfigReadBadAccessToken(t *testing.T) { // Forcibly set a bad token entry, err := logical.StorageEntryJSON(configAdminPath, adminConfiguration{ - AccessToken: "bogus.token", - ArtifactoryURL: e.URL, + baseConfiguration: baseConfiguration{ + AccessToken: "bogus.token", + ArtifactoryURL: e.URL, + }, }) assert.NoError(t, err) err = e.Storage.Put(e.Context, entry) diff --git a/path_config_user_token.go b/path_config_user_token.go index 5db3642..d41a660 100644 --- a/path_config_user_token.go +++ b/path_config_user_token.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "fmt" + "strings" "time" "github.com/hashicorp/vault/sdk/framework" @@ -20,6 +21,10 @@ func (b *backend) pathConfigUserToken() *framework.Path { Type: framework.TypeString, Description: `Optional. The username of the user. If not specified, the configuration will apply to *all* users.`, }, + "url": { + Type: framework.TypeString, + Description: "Optional. Address of the Artifactory instance", + }, "access_token": { Type: framework.TypeString, Description: "Optional. User identity token to access Artifactory. If `username` is not set then this token will be used for *all* users.", @@ -59,6 +64,11 @@ func (b *backend) pathConfigUserToken() *framework.Path { Type: framework.TypeString, Description: `Optional. Default token description to set in Artifactory for issued user access tokens.`, }, + "bypass_artifactory_tls_verification": { + Type: framework.TypeBool, + Default: false, + Description: "Optional. Bypass certification verification for TLS connection with Artifactory. Default to `false`.", + }, }, Operations: map[logical.Operation]framework.OperationHandler{ logical.UpdateOperation: &framework.PathOperation{ @@ -76,12 +86,11 @@ func (b *backend) pathConfigUserToken() *framework.Path { } type userTokenConfiguration struct { - AccessToken string `json:"access_token"` + baseConfiguration RefreshToken string `json:"refresh_token"` Audience string `json:"audience,omitempty"` Refreshable bool `json:"refreshable,omitempty"` IncludeReferenceToken bool `json:"include_reference_token,omitempty"` - UseExpiringTokens bool `json:"use_expiring_tokens,omitempty"` DefaultTTL time.Duration `json:"default_ttl,omitempty"` MaxTTL time.Duration `json:"max_ttl,omitempty"` DefaultDescription string `json:"default_description,omitempty"` @@ -93,7 +102,7 @@ func (b *backend) fetchUserTokenConfiguration(ctx context.Context, storage logic // If username is not empty, then append to the path to fetch username specific configuration path := configUserTokenPath - if len(username) > 0 { + if len(username) > 0 && !strings.HasSuffix(path, username) { path = fmt.Sprintf("%s/%s", path, username) } @@ -118,7 +127,7 @@ func (b *backend) fetchUserTokenConfiguration(ctx context.Context, storage logic func (b *backend) storeUserTokenConfiguration(ctx context.Context, req *logical.Request, username string, userTokenConfig *userTokenConfiguration) error { // If username is not empty, then append to the path to fetch username specific configuration path := configUserTokenPath - if len(username) > 0 { + if len(username) > 0 && !strings.HasSuffix(path, username) { path = fmt.Sprintf("%s/%s", path, username) } @@ -145,12 +154,6 @@ func (b *backend) pathConfigUserTokenUpdate(ctx context.Context, req *logical.Re return nil, err } - if adminConfig == nil { - adminConfig = &adminConfiguration{} - } - - go b.sendUsage(*adminConfig, "pathConfigUserTokenUpdate") - username := "" if val, ok := data.GetOk("username"); ok { username = val.(string) @@ -161,6 +164,16 @@ func (b *backend) pathConfigUserTokenUpdate(ctx context.Context, req *logical.Re return nil, err } + if adminConfig == nil { + adminConfig = &adminConfiguration{} + } + + go b.sendUsage(adminConfig.baseConfiguration, "pathConfigUserTokenUpdate") + + if val, ok := data.GetOk("url"); ok { + userTokenConfig.ArtifactoryURL = val.(string) + } + if val, ok := data.GetOk("access_token"); ok { userTokenConfig.AccessToken = val.(string) } @@ -197,6 +210,10 @@ func (b *backend) pathConfigUserTokenUpdate(ctx context.Context, req *logical.Re userTokenConfig.DefaultDescription = val.(string) } + if val, ok := data.GetOk("bypass_artifactory_tls_verification"); ok { + userTokenConfig.BypassArtifactoryTLSVerification = val.(bool) + } + err = b.storeUserTokenConfiguration(ctx, req, username, userTokenConfig) if err != nil { return nil, err @@ -209,17 +226,17 @@ func (b *backend) pathConfigUserTokenRead(ctx context.Context, req *logical.Requ b.configMutex.RLock() defer b.configMutex.RUnlock() + baseConfig := baseConfiguration{} + adminConfig, err := b.fetchAdminConfiguration(ctx, req.Storage) if err != nil { return nil, err } - if adminConfig == nil { - return logical.ErrorResponse("backend not configured"), nil + if adminConfig != nil { + baseConfig = adminConfig.baseConfiguration } - go b.sendUsage(*adminConfig, "pathConfigUserTokenRead") - username := "" if val, ok := data.GetOk("username"); ok { username = val.(string) @@ -230,6 +247,16 @@ func (b *backend) pathConfigUserTokenRead(ctx context.Context, req *logical.Requ return nil, err } + if userTokenConfig.baseConfiguration.ArtifactoryURL != "" { + baseConfig.ArtifactoryURL = userTokenConfig.baseConfiguration.ArtifactoryURL + } + + if userTokenConfig.baseConfiguration.AccessToken != "" { + baseConfig.AccessToken = userTokenConfig.baseConfiguration.AccessToken + } + + go b.sendUsage(baseConfig, "pathConfigUserTokenRead") + accessTokenHash := sha256.Sum256([]byte(userTokenConfig.AccessToken)) refreshTokenHash := sha256.Sum256([]byte(userTokenConfig.RefreshToken)) @@ -245,13 +272,8 @@ func (b *backend) pathConfigUserTokenRead(ctx context.Context, req *logical.Requ "default_description": userTokenConfig.DefaultDescription, } - accessToken := adminConfig.AccessToken - if len(userTokenConfig.AccessToken) > 0 { - accessToken = userTokenConfig.AccessToken - } - // Optionally include token info if it parses properly - token, err := b.getTokenInfo(*adminConfig, accessToken) + token, err := b.getTokenInfo(baseConfig, baseConfig.AccessToken) if err != nil { b.Logger().Warn("Error parsing AccessToken", "err", err.Error()) } else { diff --git a/path_roles.go b/path_roles.go index 1e81497..6997be7 100644 --- a/path_roles.go +++ b/path_roles.go @@ -130,7 +130,7 @@ func (b *backend) pathRoleWrite(ctx context.Context, req *logical.Request, data return logical.ErrorResponse("backend not configured"), nil } - go b.sendUsage(*config, "pathRoleWrite") + go b.sendUsage(config.baseConfiguration, "pathRoleWrite") roleName := data.Get("role").(string) if roleName == "" { @@ -215,7 +215,7 @@ func (b *backend) pathRoleRead(ctx context.Context, req *logical.Request, data * return logical.ErrorResponse("backend not configured"), nil } - go b.sendUsage(*config, "pathRoleRead") + go b.sendUsage(config.baseConfiguration, "pathRoleRead") roleName := data.Get("role").(string) @@ -295,7 +295,7 @@ func (b *backend) pathRoleDelete(ctx context.Context, req *logical.Request, data return logical.ErrorResponse("backend not configured"), nil } - go b.sendUsage(*config, "pathRoleDelete") + go b.sendUsage(config.baseConfiguration, "pathRoleDelete") err = req.Storage.Delete(ctx, rolePath+data.Get("role").(string)) if err != nil { diff --git a/path_token_create.go b/path_token_create.go index ae89e61..7a58989 100644 --- a/path_token_create.go +++ b/path_token_create.go @@ -71,7 +71,7 @@ func (b *backend) pathTokenCreatePerform(ctx context.Context, req *logical.Reque return logical.ErrorResponse("backend not configured"), nil } - go b.sendUsage(*config, "pathTokenCreatePerform") + go b.sendUsage(config.baseConfiguration, "pathTokenCreatePerform") // Read in the requested role roleName := data.Get("role").(string) @@ -115,7 +115,7 @@ func (b *backend) pathTokenCreatePerform(ctx context.Context, req *logical.Reque ttl = role.MaxTTL } - resp, err := b.CreateToken(*config, *role) + resp, err := b.CreateToken(config.baseConfiguration, *role) if err != nil { return nil, err } diff --git a/path_user_token_create.go b/path_user_token_create.go index 7d8c875..3a68501 100644 --- a/path_user_token_create.go +++ b/path_user_token_create.go @@ -60,17 +60,17 @@ func (b *backend) pathUserTokenCreatePerform(ctx context.Context, req *logical.R b.configMutex.RLock() defer b.configMutex.RUnlock() + baseConfig := baseConfiguration{} + adminConfig, err := b.fetchAdminConfiguration(ctx, req.Storage) if err != nil { return nil, err } - if adminConfig == nil { - return logical.ErrorResponse("backend not configured"), nil + if adminConfig != nil { + baseConfig = adminConfig.baseConfiguration } - go b.sendUsage(*adminConfig, "pathUserTokenCreatePerform") - username := data.Get("username").(string) userTokenConfig, err := b.fetchUserTokenConfiguration(ctx, req.Storage, username) @@ -78,13 +78,19 @@ func (b *backend) pathUserTokenCreatePerform(ctx context.Context, req *logical.R return nil, err } - if len(userTokenConfig.AccessToken) > 0 { - adminConfig.AccessToken = userTokenConfig.AccessToken + if userTokenConfig.baseConfiguration.ArtifactoryURL != "" { + baseConfig.ArtifactoryURL = userTokenConfig.baseConfiguration.ArtifactoryURL } - adminConfig.UseExpiringTokens = userTokenConfig.UseExpiringTokens + if userTokenConfig.baseConfiguration.AccessToken != "" { + baseConfig.AccessToken = userTokenConfig.baseConfiguration.AccessToken + } + + go b.sendUsage(baseConfig, "pathUserTokenCreatePerform") + + baseConfig.UseExpiringTokens = userTokenConfig.UseExpiringTokens if value, ok := data.GetOk("use_expiring_tokens"); ok { - adminConfig.UseExpiringTokens = value.(bool) + baseConfig.UseExpiringTokens = value.(bool) } role := artifactoryRole{ @@ -139,11 +145,11 @@ func (b *backend) pathUserTokenCreatePerform(ctx context.Context, req *logical.R role.Description = value.(string) } - resp, err := b.CreateToken(*adminConfig, role) + resp, err := b.CreateToken(baseConfig, role) if err != nil { if _, ok := err.(*TokenExpiredError); ok { b.Logger().Info("access token expired. Attempt to refresh using the refresh token.") - refreshResp, err := b.RefreshToken(*adminConfig, role) + refreshResp, err := b.RefreshToken(baseConfig, role) if err != nil { return nil, fmt.Errorf("failed to refresh access token. err: %v", err) } @@ -153,12 +159,12 @@ func (b *backend) pathUserTokenCreatePerform(ctx context.Context, req *logical.R userTokenConfig.RefreshToken = refreshResp.RefreshToken b.storeUserTokenConfiguration(ctx, req, username, userTokenConfig) - adminConfig.AccessToken = userTokenConfig.AccessToken + baseConfig.AccessToken = userTokenConfig.AccessToken role.RefreshToken = userTokenConfig.RefreshToken // try again after token was refreshed b.Logger().Info("attempt to create user token again after access token refresh") - resp, err = b.CreateToken(*adminConfig, role) + resp, err = b.CreateToken(baseConfig, role) if err != nil { return nil, err } diff --git a/secret_access_token.go b/secret_access_token.go index 905ab30..8e1b838 100644 --- a/secret_access_token.go +++ b/secret_access_token.go @@ -84,7 +84,7 @@ func (b *backend) secretAccessTokenRevoke(ctx context.Context, req *logical.Requ return logical.ErrorResponse("backend not configured"), nil } - if err := b.RevokeToken(*config, *req.Secret); err != nil { + if err := b.RevokeToken(config.baseConfiguration, *req.Secret); err != nil { return nil, err } diff --git a/test_utils.go b/test_utils.go index 494059e..460bcc9 100644 --- a/test_utils.go +++ b/test_utils.go @@ -32,7 +32,7 @@ type testData map[string]interface{} // 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{ + config := baseConfiguration{ AccessToken: e.AccessToken, ArtifactoryURL: e.URL, } @@ -61,7 +61,7 @@ func (e *accTestEnv) createNewTestToken(t *testing.T) (string, string) { // createNewNonAdminTestToken creates a new "user" token using the one from test environment // primarily used to fail tests func (e *accTestEnv) createNewNonAdminTestToken(t *testing.T) (string, string) { - config := adminConfiguration{ + config := baseConfiguration{ AccessToken: e.AccessToken, ArtifactoryURL: e.URL, } @@ -88,7 +88,7 @@ func (e *accTestEnv) createNewNonAdminTestToken(t *testing.T) (string, string) { } func (e *accTestEnv) revokeTestToken(t *testing.T, accessToken string, tokenID string) { - config := adminConfiguration{ + config := baseConfiguration{ AccessToken: e.AccessToken, ArtifactoryURL: e.URL, } @@ -271,7 +271,7 @@ func (e *accTestEnv) CreatePathToken(t *testing.T) { func (e *accTestEnv) CreatePathUserToken(t *testing.T) { resp, err := e.Backend.HandleRequest(context.Background(), &logical.Request{ Operation: logical.UpdateOperation, - Path: configUserTokenPath, + Path: configUserTokenPath + "/admin", Storage: e.Storage, Data: map[string]interface{}{ "default_description": "foo", @@ -460,7 +460,7 @@ func makeBackend(t *testing.T) (*backend, *logical.BackendConfig) { func configuredBackend(t *testing.T, adminConfig map[string]interface{}) (*backend, *logical.BackendConfig) { b, config := makeBackend(t) - b.InitializeHttpClient(&adminConfiguration{}) + b.InitializeHttpClient(&baseConfiguration{}) _, err := b.HandleRequest(context.Background(), &logical.Request{ Operation: logical.UpdateOperation, diff --git a/ttl_test.go b/ttl_test.go index 57b8d03..7552b9f 100644 --- a/ttl_test.go +++ b/ttl_test.go @@ -152,10 +152,10 @@ func TestBackend_NoUserTokensMaxTTLUsesSystemMaxTTL(t *testing.T) { assert.EqualValues(t, config.System.MaxLeaseTTL(), resp.Secret.MaxTTL) } -func SetUserTokenMaxTTL(t *testing.T, b *backend, storage logical.Storage, max_ttl time.Duration) { +func SetUserTokenMaxTTL(t *testing.T, b *backend, storage logical.Storage, path string, max_ttl time.Duration) { resp, err := b.HandleRequest(context.Background(), &logical.Request{ Operation: logical.UpdateOperation, - Path: configUserTokenPath, + Path: path, Storage: storage, Data: map[string]interface{}{ "max_ttl": max_ttl, @@ -182,9 +182,10 @@ func TestBackend_UserTokenConfigMaxTTLUseSystem(t *testing.T) { "url": "http://myserver.com:80", }) + configPath := configUserTokenPath + "/admin" backend_max_ttl := b.System().MaxLeaseTTL() user_token_config_ttl := backend_max_ttl + 1*time.Minute - SetUserTokenMaxTTL(t, b, config.StorageView, user_token_config_ttl) + SetUserTokenMaxTTL(t, b, config.StorageView, configPath, user_token_config_ttl) resp, err := b.HandleRequest(context.Background(), &logical.Request{ Operation: logical.ReadOperation, @@ -215,9 +216,10 @@ func TestBackend_UserTokenConfigMaxTTLUseConfigMaxTTL(t *testing.T) { "url": "http://myserver.com:80", }) + configPath := configUserTokenPath + "/admin" backend_max_ttl := b.System().MaxLeaseTTL() user_token_config_ttl := backend_max_ttl - 1*time.Minute - SetUserTokenMaxTTL(t, b, config.StorageView, user_token_config_ttl) + SetUserTokenMaxTTL(t, b, config.StorageView, configPath, user_token_config_ttl) resp, err := b.HandleRequest(context.Background(), &logical.Request{ Operation: logical.ReadOperation, @@ -350,7 +352,7 @@ func TestBackend_UserTokenDefaultTTL(t *testing.T) { resp, err := b.HandleRequest(context.Background(), &logical.Request{ Operation: logical.UpdateOperation, - Path: configUserTokenPath, + Path: configUserTokenPath + "/admin", Storage: config.StorageView, Data: map[string]interface{}{ "default_ttl": 42 * time.Minute, From bd746d2c6f272e5b6cb7a0684f8df8c48517f497 Mon Sep 17 00:00:00 2001 From: Alex Hung Date: Thu, 22 Feb 2024 14:42:38 -0800 Subject: [PATCH 5/8] Change how token expires_in is set via Role object Now use an ephemeral field in Role object 'ExpiresIn' instead of 'MaxTTL'. User token now use TTL (with heuristics) for token expires_in --- Makefile | 4 +-- artifactory.go | 10 +++---- path_config.go | 2 +- path_config_user_token.go | 51 +++++++-------------------------- path_roles.go | 1 + path_token_create.go | 46 +++++++++++++++++++++--------- path_token_create_test.go | 17 +++++++++++ path_user_token_create.go | 60 ++++++++++++++++++++++++--------------- test_utils.go | 42 +++++++++++++++++++++++---- ttl_test.go | 4 +-- 10 files changed, 145 insertions(+), 92 deletions(-) diff --git a/Makefile b/Makefile index 5c0f5b3..0466c29 100644 --- a/Makefile +++ b/Makefile @@ -53,12 +53,12 @@ upgrade: build register vault plugin reload -plugin=$(PLUGIN_NAME) test: - go test -v ./... + go test -v -count=1 ./... acceptance: export VAULT_ACC=true && \ export JFROG_ACCESS_TOKEN=$(JFROG_ACCESS_TOKEN) && \ - go test -run TestAcceptance -cover -coverprofile=coverage.txt -v -p 1 -timeout 5m ./... + go test -run TestAcceptance -cover -coverprofile=coverage.txt -v -p 1 -count=1 -timeout 5m ./... alltests: export VAULT_ACC=true && \ diff --git a/artifactory.go b/artifactory.go index 18db120..6bba29c 100644 --- a/artifactory.go +++ b/artifactory.go @@ -121,7 +121,7 @@ func (b *backend) CreateToken(config baseConfiguration, role artifactoryRole) (* RefreshToken: role.RefreshToken, } - return b.createToken(config, role.MaxTTL, request) + return b.createToken(config, role.ExpiresIn, request) } func (b *backend) RefreshToken(config baseConfiguration, role artifactoryRole) (*createTokenResponse, error) { @@ -134,10 +134,10 @@ func (b *backend) RefreshToken(config baseConfiguration, role artifactoryRole) ( RefreshToken: role.RefreshToken, } - return b.createToken(config, role.MaxTTL, request) + return b.createToken(config, role.ExpiresIn, request) } -func (b *backend) createToken(config baseConfiguration, maxTTL time.Duration, request CreateTokenRequest) (*createTokenResponse, error) { +func (b *backend) createToken(config baseConfiguration, expiresIn time.Duration, request CreateTokenRequest) (*createTokenResponse, error) { if request.GrantType == "client_credentials" && len(request.Username) == 0 { return nil, fmt.Errorf("empty username not allowed, possibly a template error") } @@ -148,8 +148,8 @@ func (b *backend) createToken(config baseConfiguration, maxTTL time.Duration, re // but the token is still usable even after it's deleted. See RTFACT-15293. request.ExpiresIn = 0 // never expires - if config.UseExpiringTokens && b.supportForceRevocable() && maxTTL > 0 { - request.ExpiresIn = int64(maxTTL.Seconds()) + if config.UseExpiringTokens && b.supportForceRevocable() && expiresIn > 0 { + request.ExpiresIn = int64(expiresIn.Seconds()) request.ForceRevocable = true } diff --git a/path_config.go b/path_config.go index 03c967c..46493d7 100644 --- a/path_config.go +++ b/path_config.go @@ -228,7 +228,7 @@ func (b *backend) pathConfigRead(ctx context.Context, req *logical.Request, _ *f // Optionally include token info if it parses properly token, err := b.getTokenInfo(config.baseConfiguration, config.AccessToken) if err != nil { - b.Logger().Warn("Error parsing AccessToken: " + err.Error()) + b.Logger().Warn("Error parsing AccessToken", "err", err.Error()) } else { configMap["token_id"] = token.TokenID configMap["username"] = token.Username diff --git a/path_config_user_token.go b/path_config_user_token.go index d41a660..333b395 100644 --- a/path_config_user_token.go +++ b/path_config_user_token.go @@ -21,10 +21,6 @@ func (b *backend) pathConfigUserToken() *framework.Path { Type: framework.TypeString, Description: `Optional. The username of the user. If not specified, the configuration will apply to *all* users.`, }, - "url": { - Type: framework.TypeString, - Description: "Optional. Address of the Artifactory instance", - }, "access_token": { Type: framework.TypeString, Description: "Optional. User identity token to access Artifactory. If `username` is not set then this token will be used for *all* users.", @@ -64,11 +60,6 @@ func (b *backend) pathConfigUserToken() *framework.Path { Type: framework.TypeString, Description: `Optional. Default token description to set in Artifactory for issued user access tokens.`, }, - "bypass_artifactory_tls_verification": { - Type: framework.TypeBool, - Default: false, - Description: "Optional. Bypass certification verification for TLS connection with Artifactory. Default to `false`.", - }, }, Operations: map[logical.Operation]framework.OperationHandler{ logical.UpdateOperation: &framework.PathOperation{ @@ -107,7 +98,7 @@ func (b *backend) fetchUserTokenConfiguration(ctx context.Context, storage logic } // Read in the backend configuration - b.Logger().Info("fetching user token configuration from %s", path) + b.Logger().Info("fetching user token configuration", "path", path) entry, err := storage.Get(ctx, path) if err != nil { return nil, err @@ -136,7 +127,7 @@ func (b *backend) storeUserTokenConfiguration(ctx context.Context, req *logical. return err } - b.Logger().Info("saving user token configuration to %s", path) + b.Logger().Info("saving user token configuration", "path", path) err = req.Storage.Put(ctx, entry) if err != nil { return err @@ -154,6 +145,10 @@ func (b *backend) pathConfigUserTokenUpdate(ctx context.Context, req *logical.Re return nil, err } + if adminConfig != nil { + go b.sendUsage(adminConfig.baseConfiguration, "pathConfigUserTokenUpdate") + } + username := "" if val, ok := data.GetOk("username"); ok { username = val.(string) @@ -164,16 +159,6 @@ func (b *backend) pathConfigUserTokenUpdate(ctx context.Context, req *logical.Re return nil, err } - if adminConfig == nil { - adminConfig = &adminConfiguration{} - } - - go b.sendUsage(adminConfig.baseConfiguration, "pathConfigUserTokenUpdate") - - if val, ok := data.GetOk("url"); ok { - userTokenConfig.ArtifactoryURL = val.(string) - } - if val, ok := data.GetOk("access_token"); ok { userTokenConfig.AccessToken = val.(string) } @@ -210,10 +195,6 @@ func (b *backend) pathConfigUserTokenUpdate(ctx context.Context, req *logical.Re userTokenConfig.DefaultDescription = val.(string) } - if val, ok := data.GetOk("bypass_artifactory_tls_verification"); ok { - userTokenConfig.BypassArtifactoryTLSVerification = val.(bool) - } - err = b.storeUserTokenConfiguration(ctx, req, username, userTokenConfig) if err != nil { return nil, err @@ -226,17 +207,17 @@ func (b *backend) pathConfigUserTokenRead(ctx context.Context, req *logical.Requ b.configMutex.RLock() defer b.configMutex.RUnlock() - baseConfig := baseConfiguration{} - adminConfig, err := b.fetchAdminConfiguration(ctx, req.Storage) if err != nil { return nil, err } - if adminConfig != nil { - baseConfig = adminConfig.baseConfiguration + if adminConfig == nil { + return logical.ErrorResponse("backend not configured"), nil } + go b.sendUsage(adminConfig.baseConfiguration, "pathConfigUserTokenRead") + username := "" if val, ok := data.GetOk("username"); ok { username = val.(string) @@ -247,16 +228,6 @@ func (b *backend) pathConfigUserTokenRead(ctx context.Context, req *logical.Requ return nil, err } - if userTokenConfig.baseConfiguration.ArtifactoryURL != "" { - baseConfig.ArtifactoryURL = userTokenConfig.baseConfiguration.ArtifactoryURL - } - - if userTokenConfig.baseConfiguration.AccessToken != "" { - baseConfig.AccessToken = userTokenConfig.baseConfiguration.AccessToken - } - - go b.sendUsage(baseConfig, "pathConfigUserTokenRead") - accessTokenHash := sha256.Sum256([]byte(userTokenConfig.AccessToken)) refreshTokenHash := sha256.Sum256([]byte(userTokenConfig.RefreshToken)) @@ -273,7 +244,7 @@ func (b *backend) pathConfigUserTokenRead(ctx context.Context, req *logical.Requ } // Optionally include token info if it parses properly - token, err := b.getTokenInfo(baseConfig, baseConfig.AccessToken) + token, err := b.getTokenInfo(adminConfig.baseConfiguration, userTokenConfig.AccessToken) if err != nil { b.Logger().Warn("Error parsing AccessToken", "err", err.Error()) } else { diff --git a/path_roles.go b/path_roles.go index 6997be7..4163202 100644 --- a/path_roles.go +++ b/path_roles.go @@ -101,6 +101,7 @@ type artifactoryRole struct { DefaultTTL time.Duration `json:"default_ttl,omitempty"` MaxTTL time.Duration `json:"max_ttl,omitempty"` RefreshToken string `json:"-"` + ExpiresIn time.Duration `json:"-"` } func (b *backend) pathRoleList(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { diff --git a/path_token_create.go b/path_token_create.go index 7a58989..0324b7b 100644 --- a/path_token_create.go +++ b/path_token_create.go @@ -82,7 +82,7 @@ func (b *backend) pathTokenCreatePerform(ctx context.Context, req *logical.Reque } if role == nil { - return logical.ErrorResponse("no such role"), nil + return logical.ErrorResponse("no such role: %s", roleName), nil } // Define username for token by template if a static one is not set @@ -96,23 +96,43 @@ func (b *backend) pathTokenCreatePerform(ctx context.Context, req *logical.Reque } } - var ttl time.Duration - if value, ok := data.GetOk("ttl"); ok { + maxLeaseTTL := b.Backend.System().MaxLeaseTTL() + b.Logger().Debug("initialize maxLeaseTTL to system value", "maxLeaseTTL", maxLeaseTTL) + + if value, ok := data.GetOk("max_ttl"); ok && value.(int) > 0 { + b.Logger().Debug("max_ttl is set", "max_ttl", value) + maxTTL := time.Second * time.Duration(value.(int)) + + // use override max TTL if set and less than maxLeaseTTL + if maxTTL > 0 || maxTTL < maxLeaseTTL { + maxLeaseTTL = maxTTL + } + } else if role.MaxTTL > 0 || role.MaxTTL < maxLeaseTTL { + b.Logger().Debug("using role MaxTTL", "role.MaxTTL", role.MaxTTL) + maxLeaseTTL = role.MaxTTL + } + b.Logger().Debug("Max lease TTL (sec)", "maxLeaseTTL", maxLeaseTTL) + + ttl := b.Backend.System().DefaultLeaseTTL() + if value, ok := data.GetOk("ttl"); ok && value.(int) > 0 { + b.Logger().Debug("ttl is set", "ttl", value) ttl = time.Second * time.Duration(value.(int)) - } else { + } else if role.DefaultTTL != 0 { + b.Logger().Debug("using role DefaultTTL", "role.DefaultTTL", role.DefaultTTL) 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 + // cap ttl to maxLeaseTTL + if ttl > maxLeaseTTL { + b.Logger().Debug("ttl is longer than maxLeaseTTL", "ttl", ttl, "maxLeaseTTL", maxLeaseTTL) + ttl = maxLeaseTTL } + b.Logger().Debug("TTL (sec)", "ttl", ttl) - if role.MaxTTL > 0 && ttl > role.MaxTTL { - ttl = role.MaxTTL + // Set the role.ExpiresIn based on maxLeaseTTL if use_expiring_tokens is set to tru in config + // - This value will be passed to createToken and used as expires_in for versions of Artifactory 7.50.3 or higher + if config.UseExpiringTokens { + role.ExpiresIn = maxLeaseTTL } resp, err := b.CreateToken(config.baseConfiguration, *role) @@ -141,7 +161,7 @@ func (b *backend) pathTokenCreatePerform(ctx context.Context, req *logical.Reque }) response.Secret.TTL = ttl - response.Secret.MaxTTL = role.MaxTTL + response.Secret.MaxTTL = maxLeaseTTL return response, nil } diff --git a/path_token_create_test.go b/path_token_create_test.go index b3e8a98..5dae166 100644 --- a/path_token_create_test.go +++ b/path_token_create_test.go @@ -20,3 +20,20 @@ func TestAcceptanceBackend_PathTokenCreate(t *testing.T) { t.Run("delete role", accTestEnv.DeletePathRole) t.Run("cleanup backend", accTestEnv.DeletePathConfig) } + +func TestAcceptanceBackend_PathTokenCreate_overrides(t *testing.T) { + if !runAcceptanceTests { + t.SkipNow() + } + + accTestEnv, err := newAcceptanceTestEnv() + if err != nil { + t.Fatal(err) + } + + t.Run("configure backend", accTestEnv.UpdatePathConfig) + t.Run("create role", accTestEnv.CreatePathRole) + t.Run("create token for role", accTestEnv.CreatePathToken_overrides) + t.Run("delete role", accTestEnv.DeletePathRole) + t.Run("cleanup backend", accTestEnv.DeletePathConfig) +} diff --git a/path_user_token_create.go b/path_user_token_create.go index 3a68501..6c24d9a 100644 --- a/path_user_token_create.go +++ b/path_user_token_create.go @@ -9,9 +9,11 @@ import ( "github.com/hashicorp/vault/sdk/logical" ) +const createUserTokenPath = "user_token/" + func (b *backend) pathUserTokenCreate() *framework.Path { return &framework.Path{ - Pattern: "user_token/" + framework.GenericNameWithAtRegex("username"), + Pattern: createUserTokenPath + framework.GenericNameWithAtRegex("username"), Fields: map[string]*framework.FieldSchema{ "username": { Type: framework.TypeString, @@ -67,10 +69,14 @@ func (b *backend) pathUserTokenCreatePerform(ctx context.Context, req *logical.R return nil, err } - if adminConfig != nil { - baseConfig = adminConfig.baseConfiguration + if adminConfig == nil { + return logical.ErrorResponse("backend not configured"), nil } + go b.sendUsage(adminConfig.baseConfiguration, "pathUserTokenCreatePerform") + + baseConfig = adminConfig.baseConfiguration + username := data.Get("username").(string) userTokenConfig, err := b.fetchUserTokenConfiguration(ctx, req.Storage, username) @@ -78,16 +84,10 @@ func (b *backend) pathUserTokenCreatePerform(ctx context.Context, req *logical.R return nil, err } - if userTokenConfig.baseConfiguration.ArtifactoryURL != "" { - baseConfig.ArtifactoryURL = userTokenConfig.baseConfiguration.ArtifactoryURL - } - if userTokenConfig.baseConfiguration.AccessToken != "" { baseConfig.AccessToken = userTokenConfig.baseConfiguration.AccessToken } - go b.sendUsage(baseConfig, "pathUserTokenCreatePerform") - baseConfig.UseExpiringTokens = userTokenConfig.UseExpiringTokens if value, ok := data.GetOk("use_expiring_tokens"); ok { baseConfig.UseExpiringTokens = value.(bool) @@ -97,7 +97,6 @@ func (b *backend) pathUserTokenCreatePerform(ctx context.Context, req *logical.R GrantType: grantTypeClientCredentials, Username: username, Scope: "applied-permissions/user", - MaxTTL: b.Backend.System().MaxLeaseTTL(), Audience: userTokenConfig.Audience, Refreshable: userTokenConfig.Refreshable, IncludeReferenceToken: userTokenConfig.IncludeReferenceToken, @@ -105,28 +104,43 @@ func (b *backend) pathUserTokenCreatePerform(ctx context.Context, req *logical.R RefreshToken: userTokenConfig.RefreshToken, } - if userTokenConfig.MaxTTL != 0 && userTokenConfig.MaxTTL < role.MaxTTL { - role.MaxTTL = userTokenConfig.MaxTTL - } + maxLeaseTTL := b.Backend.System().MaxLeaseTTL() + b.Logger().Debug("initialize maxLeaseTTL to system value", "maxLeaseTTL", maxLeaseTTL) - if value, ok := data.GetOk("max_ttl"); ok { + if value, ok := data.GetOk("max_ttl"); ok && value.(int) > 0 { + b.Logger().Debug("max_ttl is set", "max_ttl", value) maxTTL := time.Second * time.Duration(value.(int)) - if maxTTL != 0 && maxTTL < role.MaxTTL { - role.MaxTTL = maxTTL + + // use override max TTL if set and is less than maxLeaseTTL + if maxTTL != 0 && maxTTL < maxLeaseTTL { + maxLeaseTTL = maxTTL } + } else if userTokenConfig.MaxTTL > 0 && userTokenConfig.MaxTTL < maxLeaseTTL { + b.Logger().Debug("using user token config MaxTTL", "userTokenConfig.MaxTTL", userTokenConfig.MaxTTL) + // use max TTL from user config if set and is less than system max lease TTL + maxLeaseTTL = userTokenConfig.MaxTTL } + b.Logger().Debug("Max lease TTL (sec)", "maxLeaseTTL", maxLeaseTTL) - var ttl time.Duration - if value, ok := data.GetOk("ttl"); ok { + ttl := b.Backend.System().DefaultLeaseTTL() + if value, ok := data.GetOk("ttl"); ok && value.(int) > 0 { + b.Logger().Debug("ttl is set", "ttl", value) ttl = time.Second * time.Duration(value.(int)) } else if userTokenConfig.DefaultTTL != 0 { + b.Logger().Debug("using user config DefaultTTL", "userTokenConfig.DefaultTTL", userTokenConfig.DefaultTTL) ttl = userTokenConfig.DefaultTTL - } else { - ttl = b.Backend.System().DefaultLeaseTTL() } - if role.MaxTTL > 0 && ttl > role.MaxTTL { - ttl = role.MaxTTL + // cap ttl to maxLeaseTTL + if maxLeaseTTL > 0 && ttl > maxLeaseTTL { + b.Logger().Debug("ttl is longer than maxLeaseTTL", "ttl", ttl, "maxLeaseTTL", maxLeaseTTL) + ttl = maxLeaseTTL + } + b.Logger().Debug("TTL (sec)", "ttl", ttl) + + // now ttl is determined, we set role.ExpiresIn so this value so expirable token has the correct expiration + if baseConfig.UseExpiringTokens { + role.ExpiresIn = ttl } if value, ok := data.GetOk("refreshable"); ok { @@ -193,7 +207,7 @@ func (b *backend) pathUserTokenCreatePerform(ctx context.Context, req *logical.R }) response.Secret.TTL = ttl - response.Secret.MaxTTL = role.MaxTTL + response.Secret.MaxTTL = maxLeaseTTL return response, nil } diff --git a/test_utils.go b/test_utils.go index 460bcc9..44d4f49 100644 --- a/test_utils.go +++ b/test_utils.go @@ -268,6 +268,32 @@ func (e *accTestEnv) CreatePathToken(t *testing.T) { assert.NotEmpty(t, resp.Data["reference_token"]) } +func (e *accTestEnv) CreatePathToken_overrides(t *testing.T) { + e.update(configAdminPath, map[string]interface{}{ + "use_expiring_tokens": true, + }) + + resp, err := e.Backend.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.ReadOperation, + Path: "token/test-role", + Storage: e.Storage, + Data: map[string]interface{}{ + "max_ttl": 60, + }, + }) + + 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"]) + assert.NotEmpty(t, resp.Data["refresh_token"]) + assert.NotEmpty(t, resp.Data["reference_token"]) + assert.Equal(t, 60, resp.Data["expires_in"]) +} + func (e *accTestEnv) CreatePathUserToken(t *testing.T) { resp, err := e.Backend.HandleRequest(context.Background(), &logical.Request{ Operation: logical.UpdateOperation, @@ -278,6 +304,7 @@ func (e *accTestEnv) CreatePathUserToken(t *testing.T) { "refreshable": true, "include_reference_token": true, "use_expiring_tokens": true, + "default_ttl": 60, }, }) @@ -286,7 +313,7 @@ func (e *accTestEnv) CreatePathUserToken(t *testing.T) { resp, err = e.Backend.HandleRequest(context.Background(), &logical.Request{ Operation: logical.ReadOperation, - Path: "user_token/admin", + Path: createUserTokenPath + "admin", Storage: e.Storage, }) @@ -297,7 +324,7 @@ func (e *accTestEnv) CreatePathUserToken(t *testing.T) { assert.Equal(t, "admin", resp.Data["username"]) assert.Equal(t, "applied-permissions/user", resp.Data["scope"]) assert.Equal(t, "foo", resp.Data["description"]) - assert.NotEmpty(t, resp.Data["expires_in"]) + assert.Equal(t, 60, resp.Data["expires_in"]) assert.NotEmpty(t, resp.Data["refresh_token"]) assert.NotEmpty(t, resp.Data["reference_token"]) } @@ -305,10 +332,11 @@ func (e *accTestEnv) CreatePathUserToken(t *testing.T) { func (e *accTestEnv) CreatePathUserToken_overrides(t *testing.T) { resp, err := e.Backend.HandleRequest(context.Background(), &logical.Request{ Operation: logical.UpdateOperation, - Path: configUserTokenPath, + Path: configUserTokenPath + "/admin", Storage: e.Storage, Data: map[string]interface{}{ "default_description": "foo", + "default_ttl": 600, }, }) @@ -317,7 +345,7 @@ func (e *accTestEnv) CreatePathUserToken_overrides(t *testing.T) { resp, err = e.Backend.HandleRequest(context.Background(), &logical.Request{ Operation: logical.ReadOperation, - Path: "user_token/admin", + Path: createUserTokenPath + "admin", Storage: e.Storage, Data: map[string]interface{}{ "description": "buffalo", @@ -334,7 +362,7 @@ func (e *accTestEnv) CreatePathUserToken_overrides(t *testing.T) { assert.Equal(t, "admin", resp.Data["username"]) assert.Equal(t, "applied-permissions/user", resp.Data["scope"]) assert.Equal(t, "buffalo", resp.Data["description"]) - assert.NotEmpty(t, resp.Data["expires_in"]) + assert.Equal(t, 600, resp.Data["expires_in"]) assert.NotEmpty(t, resp.Data["refresh_token"]) assert.NotEmpty(t, resp.Data["reference_token"]) } @@ -353,7 +381,8 @@ func newAcceptanceTestEnv() (*accTestEnv, error) { conf := &logical.BackendConfig{ System: &logical.StaticSystemView{ - MaxLeaseTTLVal: time.Duration(2592000) * time.Second, // 30 days + DefaultLeaseTTLVal: 24 * time.Hour, // 1 day + MaxLeaseTTLVal: 30 * 24 * time.Hour, // 30 days }, Logger: logging.NewVaultLogger(log.Debug), } @@ -460,6 +489,7 @@ func makeBackend(t *testing.T) (*backend, *logical.BackendConfig) { func configuredBackend(t *testing.T, adminConfig map[string]interface{}) (*backend, *logical.BackendConfig) { b, config := makeBackend(t) + t.Logf("b.System().MaxLeaseTTL(): %v\n", b.System().MaxLeaseTTL()) b.InitializeHttpClient(&baseConfiguration{}) _, err := b.HandleRequest(context.Background(), &logical.Request{ diff --git a/ttl_test.go b/ttl_test.go index 7552b9f..20d7bbe 100644 --- a/ttl_test.go +++ b/ttl_test.go @@ -31,8 +31,8 @@ func TestBackend_NoRoleMaxTTLUsesSystemMaxTTL(t *testing.T) { "scope": "api:* member-of-groups:readers", "token_type": "Bearer", "refresh_token": "fgsfgsdugh8dgu9s8gy9hsg..." - } - `)) + }`), + ) b, config := configuredBackend(t, map[string]interface{}{ "access_token": "test-access-token", From 680ac0b2d86b2a2c226dba70d8e3ea9ab3c24075 Mon Sep 17 00:00:00 2001 From: Alex Hung Date: Thu, 22 Feb 2024 15:02:43 -0800 Subject: [PATCH 6/8] Move BypassArtifactoryTLSVerification field back to adminConfiguration --- artifactory.go | 1 - backend.go | 4 ++-- path_config.go | 5 +++-- path_config_user_token.go | 17 +++++++++++++---- path_token_create.go | 2 +- test_utils.go | 26 +++++++++++++++----------- 6 files changed, 34 insertions(+), 21 deletions(-) diff --git a/artifactory.go b/artifactory.go index 6bba29c..29fafb9 100644 --- a/artifactory.go +++ b/artifactory.go @@ -34,7 +34,6 @@ type baseConfiguration struct { AccessToken string `json:"access_token"` ArtifactoryURL string `json:"artifactory_url"` UseExpiringTokens bool `json:"use_expiring_tokens,omitempty"` - BypassArtifactoryTLSVerification bool `json:"bypass_artifactory_tls_verification,omitempty"` } type errorResponse struct { diff --git a/backend.go b/backend.go index c323147..3982184 100644 --- a/backend.go +++ b/backend.go @@ -94,7 +94,7 @@ func (b *backend) initialize(ctx context.Context, req *logical.InitializationReq return nil } - b.InitializeHttpClient(&config.baseConfiguration) + b.InitializeHttpClient(config) err = b.getVersion(config.baseConfiguration) if err != nil { @@ -112,7 +112,7 @@ func (b *backend) initialize(ctx context.Context, req *logical.InitializationReq return nil } -func (b *backend) InitializeHttpClient(config *baseConfiguration) { +func (b *backend) InitializeHttpClient(config *adminConfiguration) { if config.BypassArtifactoryTLSVerification { tr := &http.Transport{ TLSClientConfig: &tls.Config{ diff --git a/path_config.go b/path_config.go index 46493d7..038737a 100644 --- a/path_config.go +++ b/path_config.go @@ -79,7 +79,8 @@ No renewals or new tokens will be issued if the backend configuration (config/ad type adminConfiguration struct { baseConfiguration - UsernameTemplate string `json:"username_template,omitempty"` + UsernameTemplate string `json:"username_template,omitempty"` + BypassArtifactoryTLSVerification bool `json:"bypass_artifactory_tls_verification,omitempty"` } // fetchAdminConfiguration will return nil,nil if there's no configuration @@ -150,7 +151,7 @@ func (b *backend) pathConfigUpdate(ctx context.Context, req *logical.Request, da return logical.ErrorResponse("url is required"), nil } - b.InitializeHttpClient(&config.baseConfiguration) + b.InitializeHttpClient(config) go b.sendUsage(config.baseConfiguration, "pathConfigRotateUpdate") diff --git a/path_config_user_token.go b/path_config_user_token.go index 333b395..af148da 100644 --- a/path_config_user_token.go +++ b/path_config_user_token.go @@ -89,8 +89,6 @@ type userTokenConfiguration struct { // fetchAdminConfiguration will return nil,nil if there's no configuration func (b *backend) fetchUserTokenConfiguration(ctx context.Context, storage logical.Storage, username string) (*userTokenConfiguration, error) { - var config userTokenConfiguration - // If username is not empty, then append to the path to fetch username specific configuration path := configUserTokenPath if len(username) > 0 && !strings.HasSuffix(path, username) { @@ -108,6 +106,7 @@ func (b *backend) fetchUserTokenConfiguration(ctx context.Context, storage logic return &userTokenConfiguration{}, nil } + var config userTokenConfiguration if err := entry.DecodeJSON(&config); err != nil { return nil, err } @@ -145,10 +144,12 @@ func (b *backend) pathConfigUserTokenUpdate(ctx context.Context, req *logical.Re return nil, err } - if adminConfig != nil { - go b.sendUsage(adminConfig.baseConfiguration, "pathConfigUserTokenUpdate") + if adminConfig == nil { + return logical.ErrorResponse("backend not configured"), nil } + go b.sendUsage(adminConfig.baseConfiguration, "pathConfigUserTokenUpdate") + username := "" if val, ok := data.GetOk("username"); ok { username = val.(string) @@ -159,8 +160,14 @@ func (b *backend) pathConfigUserTokenUpdate(ctx context.Context, req *logical.Re return nil, err } + if userTokenConfig.ArtifactoryURL == "" { + userTokenConfig.ArtifactoryURL = adminConfig.ArtifactoryURL + } + if val, ok := data.GetOk("access_token"); ok { userTokenConfig.AccessToken = val.(string) + } else { + userTokenConfig.AccessToken = adminConfig.AccessToken } if val, ok := data.GetOk("refresh_token"); ok { @@ -181,6 +188,8 @@ func (b *backend) pathConfigUserTokenUpdate(ctx context.Context, req *logical.Re if val, ok := data.GetOk("use_expiring_tokens"); ok { userTokenConfig.UseExpiringTokens = val.(bool) + } else { + userTokenConfig.UseExpiringTokens = adminConfig.UseExpiringTokens } if val, ok := data.GetOk("default_ttl"); ok { diff --git a/path_token_create.go b/path_token_create.go index 0324b7b..7a1d46b 100644 --- a/path_token_create.go +++ b/path_token_create.go @@ -107,7 +107,7 @@ func (b *backend) pathTokenCreatePerform(ctx context.Context, req *logical.Reque if maxTTL > 0 || maxTTL < maxLeaseTTL { maxLeaseTTL = maxTTL } - } else if role.MaxTTL > 0 || role.MaxTTL < maxLeaseTTL { + } else if role.MaxTTL > 0 && role.MaxTTL < maxLeaseTTL { b.Logger().Debug("using role MaxTTL", "role.MaxTTL", role.MaxTTL) maxLeaseTTL = role.MaxTTL } diff --git a/test_utils.go b/test_utils.go index 44d4f49..e15457d 100644 --- a/test_utils.go +++ b/test_utils.go @@ -32,9 +32,11 @@ type testData map[string]interface{} // 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 := baseConfiguration{ - AccessToken: e.AccessToken, - ArtifactoryURL: e.URL, + config := adminConfiguration{ + baseConfiguration: baseConfiguration{ + AccessToken: e.AccessToken, + ArtifactoryURL: e.URL, + }, } role := artifactoryRole{ @@ -45,12 +47,12 @@ func (e *accTestEnv) createNewTestToken(t *testing.T) (string, string) { e.Backend.(*backend).InitializeHttpClient(&config) - err := e.Backend.(*backend).getVersion(config) + err := e.Backend.(*backend).getVersion(config.baseConfiguration) if err != nil { t.Fatal(err) } - resp, err := e.Backend.(*backend).CreateToken(config, role) + resp, err := e.Backend.(*backend).CreateToken(config.baseConfiguration, role) if err != nil { t.Fatal(err) } @@ -61,9 +63,11 @@ func (e *accTestEnv) createNewTestToken(t *testing.T) (string, string) { // createNewNonAdminTestToken creates a new "user" token using the one from test environment // primarily used to fail tests func (e *accTestEnv) createNewNonAdminTestToken(t *testing.T) (string, string) { - config := baseConfiguration{ - AccessToken: e.AccessToken, - ArtifactoryURL: e.URL, + config := adminConfiguration{ + baseConfiguration: baseConfiguration{ + AccessToken: e.AccessToken, + ArtifactoryURL: e.URL, + }, } role := artifactoryRole{ @@ -74,12 +78,12 @@ func (e *accTestEnv) createNewNonAdminTestToken(t *testing.T) (string, string) { e.Backend.(*backend).InitializeHttpClient(&config) - err := e.Backend.(*backend).getVersion(config) + err := e.Backend.(*backend).getVersion(config.baseConfiguration) if err != nil { t.Fatal(err) } - resp, err := e.Backend.(*backend).CreateToken(config, role) + resp, err := e.Backend.(*backend).CreateToken(config.baseConfiguration, role) if err != nil { t.Fatal(err) } @@ -490,7 +494,7 @@ func configuredBackend(t *testing.T, adminConfig map[string]interface{}) (*backe b, config := makeBackend(t) t.Logf("b.System().MaxLeaseTTL(): %v\n", b.System().MaxLeaseTTL()) - b.InitializeHttpClient(&baseConfiguration{}) + b.InitializeHttpClient(&adminConfiguration{}) _, err := b.HandleRequest(context.Background(), &logical.Request{ Operation: logical.UpdateOperation, From 48ee68060ff9cc909e84a48624d73c3b3580d2f1 Mon Sep 17 00:00:00 2001 From: Alex Hung Date: Fri, 23 Feb 2024 11:45:37 -0800 Subject: [PATCH 7/8] Update README with reference documentation for all paths Update path descriptions and field descriptions. --- README.md | 193 +++++++++++++++++++++++++++++++++++++- path_config_user_token.go | 2 +- path_roles.go | 2 +- path_user_token_create.go | 2 +- 4 files changed, 193 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 15cd294..8765791 100644 --- a/README.md +++ b/README.md @@ -272,7 +272,7 @@ Create a role (scope for artifactory >= 7.21.1) ```sh vault write artifactory/roles/jenkins \ scope="applied-permissions/groups:automation " \ - default_ttl=1h max_ttl=3h + default_ttl=3600 max_ttl=10800 ``` Also supports `grant_type=[Optional, default: "client_credentials"]`, and `audience=[Optional, default: *@*]` see [JFrog documentation][artifactory-create-token]. @@ -338,7 +338,7 @@ path "artifactory/user_token/{{identity.entity.aliases.azure-ad-oidc.metadata.up } ``` -Default values for the token's `access_token`, `description`, `ttl`, `max_ttl`, `audience`, `refreshable`, `include_reference_token`, and `use_expiring_tokens` may be configured at the `/artifactory/config/user_token` path. +Default values for the token's `access_token`, `description`, `ttl`, `max_ttl`, `audience`, `refreshable`, `include_reference_token`, and `use_expiring_tokens` may be configured at the `/artifactory/config/user_token` or `/artifactory/config/user_token/` path. `access_token` field allows the use of user's identity token in place of the admin access token from the `/artifactory/config/admin` path, enabling creating access token scoped to that user only. @@ -347,7 +347,10 @@ TTL rules follow Vault's [general cases](https://developer.hashicorp.com/vault/d Example Token Configuration: ```console -vault write artifactory/config/user_token default_description="Generated by Vault" max_ttl=604800 default_ttl=86400 +vault write artifactory/config/user_token \ + default_description="Generated by Vault" \ + max_ttl=604800 \ + default_ttl=86400 ``` ```console @@ -382,6 +385,190 @@ token_id 3c6b2e63-87dc-4d26-9698-ffdfb282a6ee username admin ``` +## References + +### Admin Config + +| Command | Path | +| ------- | ---- | +| write | artifactory/config/admin | +| read | artifactory/config/admin | +| delete | artifactory/config/admin | + +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 `/artifactory/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 enough permissions 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. + +An optional `bypass_artifactory_tls_verification` parameter will enable bypassing the TLS connection verification with Artifactory. + +No renewals or new tokens will be issued if the backend configuration (config/admin) is deleted. + +#### Parameters + +* `url` (string) - Address of the Artifactory instance, e.g. https://my.jfrog.io +* `access_token` (stirng) - Administrator token to access Artifactory +* `username_template` (string) - Optional. Vault Username Template for dynamically generating usernames. +* `use_expiring_tokens` (boolean) - Optional. If Artifactory version >= 7.50.3, set `expires_in` to `max_ttl` (admin token) or `ttl` (user token) and `force_revocable = true`. Default to `false`. +* `bypass_artifactory_tls_verification` (boolean) - Optional. Bypass certification verification for TLS connection with Artifactory. Default to `false`. + +#### Example + +```console +vault write artifactory/config/admin url=$JFROG_URL \ + access_token=$JFROG_ACCESS_TOKEN \ + username_template="v_{{.DisplayName}}_{{.RoleName}}_{{random 10}}_{{unix_time}}" \ + use_expiring_tokens=true \ + bypass_artifactory_tls_verification=true +``` + +### User Token Config + +| Command | Path | +| ------- | ---- | +| write | artifactory/user_config | +| read | artifactory/user_config | +| write | artifactory/user_config/:username | +| read | artifactory/user_config/:username | + +Configures default values for the `user_token/:user-name` path. The optional `username` field allows the configuration to be set for specific username. + +#### Parameters + +* `access_token` (stirng) - Optional. User identity token to access Artifactory. If `username` is not set then this token will be used for *all* users. +* `refresh_token` (string) - Optional. Refresh token for the user access token. If `username` is not set then this token will be used for *all* users. +* `audience` (string) - Optional. See the JFrog Platform REST documentation on [Create Token](https://jfrog.com/help/r/jfrog-rest-apis/create-token) for a full and up to date description. Service ID must begin with valid JFrog service type. Options: jfrt, jfxr, jfpip, jfds, jfmc, jfac, jfevt, jfmd, jfcon, or *. For instructions to retrieve the Artifactory Service ID see this [documentation](https://jfrog.com/help/r/jfrog-rest-apis/get-service-id) +* `refreshable` (boolean) - Optional. 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. Set this to `true` only if your usage requires this. See the JFrog Platform documentation on [Generating Refreshable Tokens](https://jfrog.com/help/r/jfrog-platform-administration-documentation/generating-refreshable-tokens) for a full and up to date description. Defaults to `false`. +* `include_reference_token` (boolean) - Optional. Generate a Reference Token (alias to Access Token) in addition to the full token (available from Artifactory 7.38.10). A reference token is a shorter, 64-character string, which can be used as a bearer token, a password, or with the `X-JFrog-Art-Api`header. Note: Using the reference token might have performance implications over a full length token. Defaults to `false`. +* `use_expiring_tokens` (boolean) - Optional. If Artifactory version >= 7.50.3, set `expires_in` to `ttl` and `force_revocable = true`. Defaults to `false`. +* `default_ttl` (int64) - Optional. Default TTL for issued user access tokens. If unset, uses the backend's `default_ttl`. Cannot exceed `max_ttl`. +* `default_description` (string) - Optional. Default token description to set in Artifactory for issued user access tokens. + +#### Examples + +```console +# Set user token configuration for ALL users +vault write artifactory/config/user_token \ + access_token="eyJ2Z...3sT9r6nA" \ + refresh_token="4ab...471" \ + default_ttl=60s + +vault read artifactory/config/user_token + +# Set user token configuration for 'myuser' user +vault write artifactory/config/user_token/myuser \ + access_token="eyJ2Z...3sT9r6nA" \ + refresh_token="4ab...471" \ + audience="jfrt@* jfxr@*" + +vault read artifactory/config/user_token/myuser + +vault delete artifactory/config/user_token/myuser +``` + +### Role + +| Command | Path | +| ------- | ---- | +| write | artifactory/role/:rolename | +| patch | artifactory/role/:rolename | +| read | artifactory/role/:rolename | +| delete | artifactory/role/:rolename | + +#### Parameters + +* `grant_type` (stirng) - Optional. Defaults to `client_credentials` when creating the access token. You likely don't need to change this. +* `username` (string) - 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` (string) - Space-delimited list. See the JFrog Artifactory REST documentation on ["Create Token"](https://jfrog.com/help/r/jfrog-rest-apis/create-token) for a full and up to date description. +* `refreshable` (boolean) - Optional. 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. Set this to `true` only if your usage requires this. See the JFrog Platform documentation on [Generating Refreshable Tokens](https://jfrog.com/help/r/jfrog-platform-administration-documentation/generating-refreshable-tokens) for a full and up to date description. Defaults to `false`. +* `audience` (string) - Optional. See the JFrog Platform REST documentation on [Create Token](https://jfrog.com/help/r/jfrog-rest-apis/create-token) for a full and up to date description. Service ID must begin with valid JFrog service type. Options: jfrt, jfxr, jfpip, jfds, jfmc, jfac, jfevt, jfmd, jfcon, or *. For instructions to retrieve the Artifactory Service ID see this [documentation](https://jfrog.com/help/r/jfrog-rest-apis/get-service-id) +* `include_reference_token` (boolean) - Optional. Generate a Reference Token (alias to Access Token) in addition to the full token (available from Artifactory 7.38.10). A reference token is a shorter, 64-character string, which can be used as a bearer token, a password, or with the `X-JFrog-Art-Api`header. Note: Using the reference token might have performance implications over a full length token. Defaults to `false`. +* `default_ttl` (int64) - Default TTL for issued user access tokens. If unset, uses the backend's `default_ttl`. Cannot exceed `max_ttl`. +* `max_ttl` (int64) - Maximum TTL that an access token can be renewed for. If unset, uses the backend's `max_ttl`. Cannot exceed backend's `max_ttl`. + +#### Examples + +```console +vault write artifactory/roles/test \ + scope="applied-permissions/groups:readers applied-permissions/groups:ci" \ + max_ttl=3h \ + default_ttl=2h + +vault read artifactory/roles/test + +vault delete artifactory/roles/test +``` + +### Admin Token + +| Command | Path | +| ------- | ---- | +| read | artifactory/token/:rolename | + +Create an Artifactory access token using paramters from the specified role. + +#### Parameters + +* `ttl` (int64) - Optional. Override the default TTL when issuing this access token. Cannot exceed smallest (system, backend, role, this request) maximum TTL. +* `max_ttl` (int64) - Optional. Override the maximum TTL for this access token. Cannot exceed smallest (system, backend) maximum TTL. + +#### Examples + +```console +vault read artifactory/token/test \ + ttl=30m \ + max_ttl=1h +``` + +### Rotate Admin Token + +| Command | Path | +| ------- | ---- | +| write | artifactory/config/rotate | + +This will rotate the `access_token` used to access artifactory from this plugin. A new access token is created first then revokes the old access token. + +#### Examples + +```console +vault write artifactory/config/rotate +``` + +### User Token + +| Command | Path | +| ------- | ---- | +| read | artifactory/user_token/:username | + +Provides optional parameters to override default values for the user_token/:username path + +#### Parameters + +* `description` (string) - Optional. Override the token description to set in Artifactory for issued user access tokens. +* `refreshable` (boolean) - Optional. Override the `refreshable` for this access token. Defaults to `false`. +* `include_reference_token` (boolean) - Optional. Override the `include_reference_token` for this access token. Defaults to `false`. +* `use_expiring_tokens` (boolean) - Optional. Override the `use_expiring_tokens` for this access token. If Artifactory version >= 7.50.3, set `expires_in` to `ttl` and `force_revocable = true`. Defaults to `false`. +* `ttl` (int64) - Optional. Override the default TTL when issuing this access token. Cannot exceed smallest (system, backend, role, this request) maximum TTL. +* `max_ttl` (int64) - Optional. Override the maximum TTL for this access token. Cannot exceed smallest (system, backend) maximum TTL. + +#### Examples + +```console +vault read artifactory/user_token/test_user \ + description="Refreshable token for Test user" + refreshable=true \ + include_reference_token=true \ + use_expiring_tokens=true +``` + ## Development ### Local Development Prerequisites diff --git a/path_config_user_token.go b/path_config_user_token.go index af148da..50a20dc 100644 --- a/path_config_user_token.go +++ b/path_config_user_token.go @@ -31,7 +31,7 @@ func (b *backend) pathConfigUserToken() *framework.Path { }, "audience": { Type: framework.TypeString, - Description: `Optional. See the JFrog Artifactory REST documentation on "Create Token" for a full and up to date description.`, + Description: `Optional. See the JFrog Platform REST documentation on "Create Token" for a full and up to date description.`, }, "refreshable": { Type: framework.TypeBool, diff --git a/path_roles.go b/path_roles.go index 4163202..583ed0c 100644 --- a/path_roles.go +++ b/path_roles.go @@ -42,7 +42,7 @@ func (b *backend) pathRoles() *framework.Path { "scope": { Type: framework.TypeString, Required: true, - Description: `Required. Space-delimited list. See the JFrog Artifactory REST documentation on "Create Token" for a full and up to date description.`, + Description: `Required. Space-delimited list. See the JFrog Artifactory REST documentation on "Create Token" (https://jfrog.com/help/r/jfrog-rest-apis/create-token) for a full and up to date description.`, }, "refreshable": { Type: framework.TypeBool, diff --git a/path_user_token_create.go b/path_user_token_create.go index 6c24d9a..2225855 100644 --- a/path_user_token_create.go +++ b/path_user_token_create.go @@ -54,7 +54,7 @@ func (b *backend) pathUserTokenCreate() *framework.Path { }, }, HelpSynopsis: `Create an Artifactory access token for the specified user.`, - HelpDescription: `Provides optional paramter to override default values for the user_token/ path`, + HelpDescription: `Provides optional parameters to override default values for the user_token/ path`, } } From a393f45227e3d13d282750816f14d580378d8141 Mon Sep 17 00:00:00 2001 From: Alex Hung Date: Mon, 26 Feb 2024 16:10:37 -0800 Subject: [PATCH 8/8] Update CHANGELOG --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15cd2b9..3586ee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +## 1.3.0 (Feburary 27, 2023) + +IMPROVEMENTS: + +* Add support for username specific user token configuration path: `config/user_token/` +* Add ability to override `access_token` in `config/user_token/` path. +* Add `refresh_token` field to allow manual refreshing of access token. +* When access token expires, plugin now attempts to get a new access token using the refresh token +* Update README with more details documentation for all paths + +BUG FIXES: + +* Fix `refreshable` and `include_reference_token` parameters not working for user token. Issue: [#154](https://github.com/jfrog/artifactory-secrets-plugin/issues/154) +* Fix `default_ttl`, `max_ttl`, etc. logic and applies to token expiration (when applicable). + +PR: [155](https://github.com/jfrog/vault-plugin-secrets-artifactory/pull/155) + ## 1.2.0 (January 10, 2023) IMPROVEMENTS: