Skip to content

Commit

Permalink
feat: allow username_template option during config
Browse files Browse the repository at this point in the history
Also: make "updates" to configuration not have to supply all params.
  • Loading branch information
TJM committed Mar 13, 2023
1 parent 6811bde commit 18014b2
Show file tree
Hide file tree
Showing 12 changed files with 434 additions and 119 deletions.
13 changes: 10 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,18 @@ clean:
fmt:
go fmt $$(go list ./...)

setup: disable enable
setup: disable enable admin testrole

admin:
vault write artifactory/config/admin url=$(ARTIFACTORY_URL) access_token=$(JFROG_ACCESS_TOKEN)
vault read artifactory/config/admin
vault write artifactory/roles/test scope="$(ARTIFACTORY_SCOPE)" username="test-user" max_ttl=3h default_ttl=2h
vault write -f artifactory/config/rotate
vault read artifactory/config/admin

testrole:
vault write artifactory/roles/test scope="$(ARTIFACTORY_SCOPE)" max_ttl=3h default_ttl=2h
vault read artifactory/roles/test
vault read artifactory/token/test

artifactory: $(ARTIFACTORY_ENV)

Expand All @@ -56,4 +63,4 @@ stop_artifactory:
source $(ARTIFACTORY_ENV) && docker stop $$ARTIFACTORY_CONTAINER_ID
rm -f $(ARTIFACTORY_ENV)

.PHONY: build clean fmt start disable enable test setup artifactory stop_artifactory
.PHONY: build clean fmt start disable enable test setup admin testrole artifactory stop_artifactory
111 changes: 68 additions & 43 deletions artifactory.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,14 @@ import (

jwt "github.com/golang-jwt/jwt/v4"
"github.com/hashicorp/go-version"
"github.com/hashicorp/vault/sdk/helper/template"
"github.com/hashicorp/vault/sdk/logical"
)

const (
defaultUserNameTemplate string = `{{ printf "v-%s-%s" (.RoleName | truncate 24) (random 8) }}` // Docs indicate max length is 256
)

var ErrIncompatibleVersion = errors.New("incompatible version")

func (b *backend) revokeToken(config adminConfiguration, secret logical.Secret) error {
Expand All @@ -30,12 +35,6 @@ func (b *backend) revokeToken(config adminConfiguration, secret logical.Secret)
values := url.Values{}
values.Set("token", accessToken)

newAccessReq, err := b.getSystemStatus(config)
if err != nil {
b.Backend.Logger().Warn("could not get artifactory version", "err", err)
return err
}

u, err := url.Parse(config.ArtifactoryURL)
if err != nil {
b.Backend.Logger().Warn("could not parse artifactory url", "url", u, "err", err)
Expand All @@ -44,7 +43,7 @@ func (b *backend) revokeToken(config adminConfiguration, secret logical.Secret)

var resp *http.Response

if newAccessReq {
if b.useNewAccessAPI() {
resp, err = b.performArtifactoryDelete(config, "/access/api/v1/tokens/"+tokenId)
if err != nil {
b.Backend.Logger().Warn("error deleting access token", "tokenId", tokenId, "response", resp, "err", err)
Expand Down Expand Up @@ -76,6 +75,9 @@ func (b *backend) createToken(config adminConfiguration, role artifactoryRole) (
}

values.Set("username", role.Username)
if len(role.Username) == 0 {
return nil, fmt.Errorf("empty username not allowed, possibly a template error")
}
values.Set("scope", role.Scope)

// A refreshable access token gets replaced by a new access token, which is not
Expand All @@ -89,13 +91,7 @@ func (b *backend) createToken(config adminConfiguration, role artifactoryRole) (
// but the token is still usable even after it's deleted. See RTFACT-15293.
values.Set("expires_in", "0") // never expires

forceRevoke, err := b.supportForceRevocable(config)
if err != nil {
b.Backend.Logger().Warn("could not get artifactory version", "err", err)
return nil, err
}

if forceRevoke && role.MaxTTL > 0 {
if b.supportForceRevocable() && role.MaxTTL > 0 {
expiresIn := strconv.FormatFloat(role.MaxTTL.Seconds(), 'f', -1, 64)
b.Backend.Logger().Debug("Setting expires_in and force_revocable", "expires_in", expiresIn)
values.Set("expires_in", expiresIn)
Expand All @@ -106,12 +102,6 @@ func (b *backend) createToken(config adminConfiguration, role artifactoryRole) (
values.Set("audience", role.Audience)
}

newAccessReq, err := b.getSystemStatus(config)
if err != nil {
b.Backend.Logger().Warn("could not get artifactory version", "err", err)
return nil, err
}

u, err := url.Parse(config.ArtifactoryURL)
if err != nil {
b.Backend.Logger().Warn("could not parse artifactory url", "url", u, "err", err)
Expand All @@ -120,7 +110,7 @@ func (b *backend) createToken(config adminConfiguration, role artifactoryRole) (

path := ""

if newAccessReq {
if b.useNewAccessAPI() {
path = "/access/api/v1/tokens"
} else {
path = u.Path + "/api/security/token"
Expand Down Expand Up @@ -152,20 +142,19 @@ func (b *backend) createToken(config adminConfiguration, role artifactoryRole) (
// supportForceRevocable verifies whether or not the Artifactory version is 7.50.3 or higher.
// The access API changes in v7.50.3 to support force_revocable to allow us to set the expiration for the tokens.
// REF: https://www.jfrog.com/confluence/display/JFROG/JFrog+Platform+REST+API#JFrogPlatformRESTAPI-CreateToken
func (b *backend) supportForceRevocable(config adminConfiguration) (bool, error) {
return b.checkVersion(config, "7.50.3")
func (b *backend) supportForceRevocable() bool {
return b.checkVersion("7.50.3")
}

// getSystemStatus verifies whether or not the Artifactory version is 7.21.1 or higher.
// useNewAccessAPI verifies whether or not the Artifactory version is 7.21.1 or higher.
// The access API changed in v7.21.1
// REF: https://www.jfrog.com/confluence/display/JFROG/Artifactory+REST+API#ArtifactoryRESTAPI-AccessTokens
func (b *backend) getSystemStatus(config adminConfiguration) (bool, error) {
return b.checkVersion(config, "7.21.1")
func (b *backend) useNewAccessAPI() bool {
return b.checkVersion("7.21.1")
}

// checkVersion will return a boolean and error to check compatibilty before making an API call
// -- This was formerly "checkSystemStatus" but that was hard-coded, that method now calls this one
func (b *backend) checkVersion(config adminConfiguration, ver string) (compatible bool, err error) {
// getVersion will fetch the current Artifactory version and store it in the backend
func (b *backend) getVersion(config adminConfiguration) (err error) {
resp, err := b.performArtifactoryGet(config, "/artifactory/api/system/version")
if err != nil {
b.Backend.Logger().Warn("error making system version request", "response", resp, "err", err)
Expand All @@ -176,18 +165,25 @@ func (b *backend) checkVersion(config adminConfiguration, ver string) (compatibl

if resp.StatusCode != http.StatusOK {
b.Backend.Logger().Warn("got non-200 status code", "statusCode", resp.StatusCode)
return compatible, fmt.Errorf("could not get the sytem version: HTTP response %v", resp.StatusCode)
return fmt.Errorf("could not get the system version: HTTP response %v", resp.StatusCode)
}

var systemVersion systemVersionResponse
if err = json.NewDecoder(resp.Body).Decode(&systemVersion); err != nil {
b.Backend.Logger().Warn("could not parse system version response", "response", resp, "err", err)
return
}
b.version = systemVersion.Version
return
}

v1, err := version.NewVersion(systemVersion.Version)
// checkVersion will return a boolean and error to check compatibility before making an API call
// -- This was formerly "checkSystemStatus" but that was hard-coded, that method now calls this one
func (b *backend) checkVersion(ver string) (compatible bool) {

v1, err := version.NewVersion(b.version)
if err != nil {
b.Backend.Logger().Warn("could not parse Artifactory system version", "ver", systemVersion.Version, "err", err)
b.Backend.Logger().Warn("could not parse Artifactory system version", "ver", b.version, "err", err)
return
}

Expand Down Expand Up @@ -245,8 +241,15 @@ func (b *backend) parseJWT(config adminConfiguration, token string) (jwtToken *j
return
}

type TokenInfo struct {
TokenID string `json:"token_id"`
Scope string `json:"scope"`
Username string `json:"username"`
Expires int64 `json:"expires"`
}

// getTokenInfo will parse the provided token to return useful information about it
func (b *backend) getTokenInfo(config adminConfiguration, token string) (info map[string]string, err error) {
func (b *backend) getTokenInfo(config adminConfiguration, token string) (info *TokenInfo, err error) {
// Parse Current Token (to get tokenID/scope)
jwtToken, err := b.parseJWT(config, token)
if err != nil {
Expand All @@ -260,24 +263,34 @@ func (b *backend) getTokenInfo(config adminConfiguration, token string) (info ma

sub := strings.Split(claims["sub"].(string), "/") // sub -> subject (jfac@01fr1x1h805xmg0t17xhqr1v7a/users/admin)

info = map[string]string{
"TokenID": claims["jti"].(string), // jti -> JFrog Token ID
"Scope": claims["scp"].(string), // scp -> scope
"Username": sub[len(sub)-1], // last element of subject
info = &TokenInfo{
TokenID: claims["jti"].(string), // jti -> JFrog Token ID
Scope: claims["scp"].(string), // scp -> scope
Username: sub[len(sub)-1], // last element of subject
}

// exp -> expires at (unixtime) - may not be present
switch exp := claims["exp"].(type) {
case int64:
info.Expires = exp
case float64:
info.Expires = int64(exp) // close enough this should be int64 anyhow
case json.Number:
v, err := exp.Int64()
if err != nil {
b.Backend.Logger().Warn("error parsing token exp as json.Number", "err", err)
}
info.Expires = v
}

return
}

// getRootCert will return the Artifactory access root certificate's public key, for validating token signatures
func (b *backend) getRootCert(config adminConfiguration) (cert *x509.Certificate, err error) {
// Verify Artifactory version is at 7.12.0 or higher, prior versions will not work
// REF: https://www.jfrog.com/confluence/display/JFROG/Artifactory+REST+API#ArtifactoryRESTAPI-GetRootCertificate
compatible, err := b.checkVersion(config, "7.12.0")
if err != nil {
b.Backend.Logger().Warn("could not get artifactory version", "err", err)
return
}
if !compatible {
if !b.checkVersion("7.12.0") {
return cert, ErrIncompatibleVersion
}

Expand Down Expand Up @@ -426,3 +439,15 @@ func parseURLWithDefaultPort(rawUrl string) (*url.URL, error) {

return urlParsed, nil
}

func testUsernameTemplate(testTemplate string) (up template.StringTemplate, err error) {
up, err = template.NewTemplate(template.Template(testTemplate))
if err != nil {
return up, fmt.Errorf("username_template initialization error: %w", err)
}
_, err = up.Generate(UsernameMetadata{})
if err != nil {
return up, fmt.Errorf("username_template failed to generate username: %w", err)
}
return
}
51 changes: 47 additions & 4 deletions backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,23 @@ import (
"sync"

"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/helper/template"
"github.com/hashicorp/vault/sdk/logical"
)

type backend struct {
*framework.Backend
configMutex sync.RWMutex
rolesMutex sync.RWMutex
httpClient *http.Client
configMutex sync.RWMutex
rolesMutex sync.RWMutex
httpClient *http.Client
usernameProducer template.StringTemplate
version string
}

// UsernameMetadata defines the metadata that a user_template can use to dynamically create user account in Artifactory
type UsernameMetadata struct {
DisplayName string
RoleName string
}

// Factory configures and returns Artifactory secrets backends.
Expand All @@ -40,14 +49,21 @@ func Backend(_ *logical.BackendConfig) (*backend, error) {
httpClient: http.DefaultClient,
}

up, err := testUsernameTemplate(defaultUserNameTemplate)
if err != nil {
return nil, err
}
b.usernameProducer = up

b.Backend = &framework.Backend{
Help: strings.TrimSpace(artifactoryHelp),

PathsSpecial: &logical.Paths{
SealWrapStorage: []string{"config/admin"},
},

BackendType: logical.TypeLogical,
BackendType: logical.TypeLogical,
InitializeFunc: b.initialize,
}
b.Backend.Secrets = append(b.Backend.Secrets, b.secretAccessToken())
b.Backend.Paths = append(b.Backend.Paths,
Expand All @@ -60,6 +76,33 @@ func Backend(_ *logical.BackendConfig) (*backend, error) {
return b, nil
}

// initialize will initialize the backend configuration
func (b *backend) initialize(ctx context.Context, req *logical.InitializationRequest) error {
config, err := b.fetchAdminConfiguration(ctx, req.Storage)
if err != nil {
return err
}

if config == nil {
return nil
}

err = b.getVersion(*config)
if err != nil {
return err
}

if len(config.UsernameTemplate) != 0 {
up, err := testUsernameTemplate(config.UsernameTemplate)
if err != nil {
return err
}
b.usernameProducer = up
}

return nil
}

// fetchAdminConfiguration will return nil,nil if there's no configuration
func (b *backend) fetchAdminConfiguration(ctx context.Context, storage logical.Storage) (*adminConfiguration, error) {
var config adminConfiguration
Expand Down
Loading

0 comments on commit 18014b2

Please sign in to comment.