From a5d06b095d1f0ea96d8e96bc1daa12dd96f57f90 Mon Sep 17 00:00:00 2001 From: Ilija Matoski Date: Sat, 24 Aug 2024 10:46:34 +0200 Subject: [PATCH] feat: allow dynamic naming of GitLab tokens using the name property --- README.md | 32 +++++++++++ entry_role.go | 8 +-- name_tpl.go | 51 ++++++++++++++++++ name_tpl_rand_string_test.go | 28 ++++++++++ name_tpl_test.go | 96 +++++++++++++++++++++++++++++++++ name_tpl_unix_timestamp_test.go | 32 +++++++++++ path_role.go | 8 ++- path_token_role.go | 12 +++-- 8 files changed, 257 insertions(+), 10 deletions(-) create mode 100644 name_tpl.go create mode 100644 name_tpl_rand_string_test.go create mode 100644 name_tpl_test.go create mode 100644 name_tpl_unix_timestamp_test.go diff --git a/README.md b/README.md index 045a322..a428e95 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,38 @@ The current authentication model requires providing Vault with a Gitlab Token. | token_type | yes | n/a | no | Access token type | | gitlab_revokes_token | no | no | no | Gitlab revokes the token when it's time. Vault will not revoke the token when the lease expires | +#### name + +When generating a token, you have control over the token's name by using templating. The name is constructed using Go's [text/template](https://pkg.go.dev/text/template), which allows for dynamic generation of names based on available data. You can refer to Go's [text/template](https://pkg.go.dev/text/template#hdr-Examples) documentation for examples and guidance on how to use it effectively. + +**Important**: GitLab does not permit duplicate token names. If your template doesn't ensure unique names, token generation will fail. + +Here are some examples of effective token name templates: + +* `vault-generated-{{ .token_type }}-access-token-{{ randHexString 4 }}` +* `{{ .role_name }}-{{ .token_type }}-{{ randHexString 4 }}` + +##### Data + +The following data points can be used within your token name template. These are derived from the role for which the token is being generated: + +* path +* ttl +* access_level +* scopes +* token_type +* gitlab_revokes_token +* unix_timestamp_utc + +##### Functions + +You can also use the following functions within your template: + +* `randHexString(bytes int) string` - Generates a random hexadecimal string with the specified number of bytes. +* `stringsJoin((elems []string, sep string) string` - joins a list of `elems` strings with a `sep` +* `yesNoBool(in bool) string` - just return `yes` if `in` is true otherwise it returns `no` +* `timeNowFormat(layout string) string` - layout is a go time format string layout + #### ttl Depending on `gitlab_revokes_token` the TTL will change. diff --git a/entry_role.go b/entry_role.go index 7589a63..33d37c6 100644 --- a/entry_role.go +++ b/entry_role.go @@ -8,7 +8,7 @@ import ( "github.com/hashicorp/vault/sdk/logical" ) -type entryRole struct { +type EntryRole struct { RoleName string `json:"role_name" structs:"role_name" mapstructure:"role_name"` TTL time.Duration `json:"ttl" structs:"ttl" mapstructure:"ttl"` Path string `json:"path" structs:"path" mapstructure:"path"` @@ -19,7 +19,7 @@ type entryRole struct { GitlabRevokesTokens bool `json:"gitlab_revokes_token" structs:"gitlab_revokes_token" mapstructure:"gitlab_revokes_token"` } -func (e entryRole) LogicalResponseData() map[string]any { +func (e EntryRole) LogicalResponseData() map[string]any { return map[string]any{ "role_name": e.RoleName, "path": e.Path, @@ -32,7 +32,7 @@ func (e entryRole) LogicalResponseData() map[string]any { } } -func getRole(ctx context.Context, name string, s logical.Storage) (*entryRole, error) { +func getRole(ctx context.Context, name string, s logical.Storage) (*EntryRole, error) { entry, err := s.Get(ctx, fmt.Sprintf("%s/%s", PathRoleStorage, name)) if err != nil { return nil, err @@ -42,7 +42,7 @@ func getRole(ctx context.Context, name string, s logical.Storage) (*entryRole, e return nil, nil } - role := new(entryRole) + role := new(EntryRole) if err := entry.DecodeJSON(role); err != nil { return nil, err } diff --git a/name_tpl.go b/name_tpl.go new file mode 100644 index 0000000..931eb18 --- /dev/null +++ b/name_tpl.go @@ -0,0 +1,51 @@ +package gitlab + +import ( + "crypto/rand" + "fmt" + "strings" + "text/template" + "time" + _ "unsafe" +) + +func yesNoBool(in bool) string { + if in { + return "yes" + } + return "no" +} +func randHexString(bytes int) string { + buf := make([]byte, bytes) + _, _ = rand.Read(buf) + return fmt.Sprintf("%x", buf) +} + +func timeNowFormat(layout string) string { + return time.Now().UTC().Format(layout) +} + +var tplFuncMap = template.FuncMap{ + "randHexString": randHexString, + "stringsJoin": strings.Join, + "yesNoBool": yesNoBool, + "timeNowFormat": timeNowFormat, +} + +func TokenName(role *EntryRole) (name string, err error) { + if role == nil { + return "", fmt.Errorf("role: %w", ErrNilValue) + } + var tpl *template.Template + tpl, err = template.New("name").Funcs(tplFuncMap).Parse(role.Name) + if err != nil { + return "", err + } + buf := new(strings.Builder) + var data = role.LogicalResponseData() + data["unix_timestamp_utc"] = time.Now().UTC().Unix() + delete(data, "name") + err = tpl.Execute(buf, data) + name = buf.String() + return name, err +} diff --git a/name_tpl_rand_string_test.go b/name_tpl_rand_string_test.go new file mode 100644 index 0000000..468a7fb --- /dev/null +++ b/name_tpl_rand_string_test.go @@ -0,0 +1,28 @@ +package gitlab_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + g "github.com/ilijamt/vault-plugin-secrets-gitlab" +) + +func TestTokenNameGenerator_RandString(t *testing.T) { + val, err := g.TokenName( + &g.EntryRole{ + RoleName: "test", + TTL: time.Hour, + Path: "/path", + Name: "{{ randHexString 8 }}", + Scopes: []string{g.TokenScopeApi.String()}, + AccessLevel: g.AccessLevelNoPermissions, + TokenType: g.TokenTypePersonal, + GitlabRevokesTokens: false, + }, + ) + require.NoError(t, err) + require.NotEmpty(t, val) + require.Len(t, val, 16) +} diff --git a/name_tpl_test.go b/name_tpl_test.go new file mode 100644 index 0000000..aef26a3 --- /dev/null +++ b/name_tpl_test.go @@ -0,0 +1,96 @@ +package gitlab_test + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + g "github.com/ilijamt/vault-plugin-secrets-gitlab" +) + +func TestTokenNameGenerator(t *testing.T) { + var tests = []struct { + in *g.EntryRole + outVal string + outErr bool + }{ + {nil, "", true}, + + // invalid template + { + &g.EntryRole{ + RoleName: "test", + TTL: time.Hour, + Path: "/path", + Name: "{{ .role_name", + Scopes: []string{g.TokenScopeApi.String()}, + AccessLevel: g.AccessLevelNoPermissions, + TokenType: g.TokenTypePersonal, + GitlabRevokesTokens: true, + }, + "", + true, + }, + + // combination template + { + &g.EntryRole{ + RoleName: "test", + TTL: time.Hour, + Path: "/path", + Name: "{{ .role_name }}-{{ .token_type }}-access-token-{{ yesNoBool .gitlab_revokes_token }}", + Scopes: []string{g.TokenScopeApi.String()}, + AccessLevel: g.AccessLevelNoPermissions, + TokenType: g.TokenTypePersonal, + GitlabRevokesTokens: true, + }, + "test-personal-access-token-yes", + false, + }, + + // with stringsJoin + { + &g.EntryRole{ + RoleName: "test", + TTL: time.Hour, + Path: "/path", + Name: "{{ .role_name }}-{{ .token_type }}-{{ stringsJoin .scopes \"-\" }}-{{ yesNoBool .gitlab_revokes_token }}", + Scopes: []string{g.TokenScopeApi.String(), g.TokenScopeSudo.String()}, + AccessLevel: g.AccessLevelNoPermissions, + TokenType: g.TokenTypePersonal, + GitlabRevokesTokens: false, + }, + "test-personal-api-sudo-no", + false, + }, + + // with timeNowFormat + { + &g.EntryRole{ + RoleName: "test", + TTL: time.Hour, + Path: "/path", + Name: "{{ .role_name }}-{{ .token_type }}-{{ timeNowFormat \"2006-01\" }}", + Scopes: []string{g.TokenScopeApi.String(), g.TokenScopeSudo.String()}, + AccessLevel: g.AccessLevelNoPermissions, + TokenType: g.TokenTypePersonal, + GitlabRevokesTokens: false, + }, + fmt.Sprintf("test-personal-%d-%02d", time.Now().UTC().Year(), time.Now().UTC().Month()), + false, + }, + } + + for _, tst := range tests { + t.Logf("TokenName(%v)", tst.in) + val, err := g.TokenName(tst.in) + assert.Equal(t, tst.outVal, val) + if tst.outErr { + assert.Error(t, err, tst.outErr) + } else { + assert.NoError(t, err) + } + } +} diff --git a/name_tpl_unix_timestamp_test.go b/name_tpl_unix_timestamp_test.go new file mode 100644 index 0000000..4dde7a0 --- /dev/null +++ b/name_tpl_unix_timestamp_test.go @@ -0,0 +1,32 @@ +package gitlab_test + +import ( + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" + + g "github.com/ilijamt/vault-plugin-secrets-gitlab" +) + +func TestTokenNameGenerator_UnixTimeStamp(t *testing.T) { + now := time.Now().UTC().Unix() + val, err := g.TokenName( + &g.EntryRole{ + RoleName: "test", + TTL: time.Hour, + Path: "/path", + Name: "{{ .unix_timestamp_utc }}", + Scopes: []string{g.TokenScopeApi.String()}, + AccessLevel: g.AccessLevelNoPermissions, + TokenType: g.TokenTypePersonal, + GitlabRevokesTokens: false, + }, + ) + require.NoError(t, err) + require.NotEmpty(t, val) + i, err := strconv.ParseInt(val, 10, 64) + require.NoError(t, err) + require.GreaterOrEqual(t, i, now) +} diff --git a/path_role.go b/path_role.go index b51f423..c130c18 100644 --- a/path_role.go +++ b/path_role.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "strings" + "text/template" "time" "github.com/hashicorp/go-multierror" @@ -213,7 +214,7 @@ func (b *Backend) pathRolesWrite(ctx context.Context, req *logical.Request, data tokenType, _ = TokenTypeParse(data.Get("token_type").(string)) accessLevel, _ = AccessLevelParse(data.Get("access_level").(string)) - var role = entryRole{ + var role = EntryRole{ RoleName: roleName, TTL: time.Duration(data.Get("ttl").(int)) * time.Second, Path: data.Get("path").(string), @@ -224,6 +225,11 @@ func (b *Backend) pathRolesWrite(ctx context.Context, req *logical.Request, data GitlabRevokesTokens: data.Get("gitlab_revokes_token").(bool), } + // validate name of the entry role + if _, e := template.New("name").Funcs(tplFuncMap).Parse(role.Name); e != nil { + err = multierror.Append(err, fmt.Errorf("invalid template %s for name: %w", role.Name, e)) + } + // validate token type if !slices.Contains(validTokenTypes, tokenType.String()) { err = multierror.Append(err, fmt.Errorf("token_type='%s', should be one of %v: %w", data.Get("token_type").(string), validTokenTypes, ErrFieldInvalidValue)) diff --git a/path_token_role.go b/path_token_role.go index 1c8f78b..8a3f5fc 100644 --- a/path_token_role.go +++ b/path_token_role.go @@ -2,7 +2,6 @@ package gitlab import ( "context" - "crypto/rand" "fmt" "net/http" "strconv" @@ -34,7 +33,7 @@ var ( func (b *Backend) pathTokenRoleCreate(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { var resp *logical.Response var err error - var role *entryRole + var role *EntryRole var roleName string if roleName = data.Get("role_name").(string); roleName == "" { @@ -56,13 +55,16 @@ func (b *Backend) pathTokenRoleCreate(ctx context.Context, req *logical.Request, b.Logger().Debug("Creating token for role", "role_name", roleName, "token_type", role.TokenType.String()) defer b.Logger().Debug("Created token for role", "role_name", roleName, "token_type", role.TokenType.String()) - buf := make([]byte, 4) - _, _ = rand.Read(buf) + var name string var token *EntryToken - var name = strings.ToLower(fmt.Sprintf("vault-generated-%s-access-token-%x", role.TokenType.String(), buf)) var expiresAt time.Time var startTime = time.Now().UTC() + name, err = TokenName(role) + if err != nil { + return nil, fmt.Errorf("error generating token name: %w", err) + } + var client Client var gitlabRevokesTokens = role.GitlabRevokesTokens var vaultRevokesTokens = !role.GitlabRevokesTokens