Skip to content

Commit

Permalink
feat: allow dynamic naming of GitLab tokens using the name property
Browse files Browse the repository at this point in the history
  • Loading branch information
ilijamt committed Aug 24, 2024
1 parent 8942fbd commit a5d06b0
Show file tree
Hide file tree
Showing 8 changed files with 257 additions and 10 deletions.
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions entry_role.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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
}
Expand Down
51 changes: 51 additions & 0 deletions name_tpl.go
Original file line number Diff line number Diff line change
@@ -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
}
28 changes: 28 additions & 0 deletions name_tpl_rand_string_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
96 changes: 96 additions & 0 deletions name_tpl_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
32 changes: 32 additions & 0 deletions name_tpl_unix_timestamp_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
8 changes: 7 additions & 1 deletion path_role.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"strings"
"text/template"
"time"

"github.com/hashicorp/go-multierror"
Expand Down Expand Up @@ -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),
Expand All @@ -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))
Expand Down
12 changes: 7 additions & 5 deletions path_token_role.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package gitlab

import (
"context"
"crypto/rand"
"fmt"
"net/http"
"strconv"
Expand Down Expand Up @@ -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 == "" {
Expand All @@ -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
Expand Down

0 comments on commit a5d06b0

Please sign in to comment.