Skip to content

Commit 3720443

Browse files
feat: allow dynamic paths for roles
1 parent a165498 commit 3720443

16 files changed

+662
-57
lines changed

README.md

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,10 @@ you may or may not be able to access certain paths.
9090
There are some flags we can specify to enable/disable some functionality in the vault plugin.
9191
9292
93-
| Flag | Default value | Description |
94-
|:--------------------------:|:-------------:|:---------------------------------------------------------------------------------------|
95-
| show-config-token | false | Display the token value when reading a config on it's endpoint like `/config/default`. |
96-
| allow-runtime-flags-change | false | Allows you to change the flags at runtime |
93+
| Flag | Default value | Changable during runtime if `allow-runtime-flags-change` is set to `true` | Description |
94+
|:---------------------------------:|:-------------:|:-------------------------------------------------------------------------:|----------------------------------------------------------------------------------------|
95+
| show-config-token | false | true | Display the token value when reading a config on it's endpoint like `/config/default`. |
96+
| allow-runtime-flags-change | false | false | Allows you to change the flags at runtime |
9797
9898
## Security Model
9999
@@ -113,16 +113,17 @@ The current authentication model requires providing Vault with a Gitlab Token.
113113
114114
### Role
115115
116-
| Property | Required | Default value | Sensitive | Description |
117-
|:--------------------:|:--------:|:-------------:|:---------:|:---------------------------------------------------------------------------------------------------------------------|
118-
| path | yes | n/a | no | Project/Group path to create an access token for. If the token type is set to personal then write the username here. |
119-
| name | yes | n/a | no | The name of the access token |
120-
| ttl | yes | n/a | no | The TTL of the token |
121-
| access_level | no/yes | n/a | no | Access level of access token (only required for Group and Project access tokens) |
122-
| scopes | no | [] | no | List of scopes |
123-
| token_type | yes | n/a | no | Access token type |
124-
| gitlab_revokes_token | no | no | no | Gitlab revokes the token when it's time. Vault will not revoke the token when the lease expires |
125-
| config_name | no | default | no | The configuration to use for the role |
116+
| Property | Required | Default value | Sensitive | Description |
117+
|:--------------------:|:--------:|:-------------:|:---------:|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
118+
| path | yes | n/a | no | Project/Group path to create an access token for. If the token type is set to personal then write the username here. If `dynamic_path` is set to true this needs to be a regex. |
119+
| name | yes | n/a | no | The name of the access token |
120+
| ttl | yes | n/a | no | The TTL of the token |
121+
| access_level | no/yes | n/a | no | Access level of access token (only required for Group and Project access tokens) |
122+
| scopes | no | [] | no | List of scopes |
123+
| token_type | yes | n/a | no | Access token type |
124+
| gitlab_revokes_token | no | no | no | Gitlab revokes the token when it's time. Vault will not revoke the token when the lease expires |
125+
| config_name | no | default | no | The configuration to use for the role |
126+
| dynamic_path | no | false | no | If set to true, you will be able to use the regex pattern to match the path from the role path |
126127
127128
#### path
128129
@@ -173,7 +174,7 @@ Here are some examples of effective token name templates:
173174
174175
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:
175176
176-
* path
177+
* path (using this with `dynamic_path` can lead to unexpected results)
177178
* ttl
178179
* access_level
179180
* scopes (csv string ex: api, sudo, read_api)
@@ -330,6 +331,7 @@ $ vault write gitlab/roles/project name='{{ .role_name }}-{{ .token_type }}-{{ r
330331
$ vault write gitlab/roles/group name='{{ .role_name }}-{{ .token_type }}-{{ randHexString 4 }}' path=group/subgroup scopes="read_api" access_level=developer token_type=group ttl=48h
331332
$ vault write gitlab/roles/sa name='{{ .role_name }}-{{ .token_type }}-{{ randHexString 4 }}' path=service_account_00b069cb73a15d0a7ba8cd67a653599c scopes="read_api" token_type=user-service-account ttl=24h
332333
$ vault write gitlab/roles/ga name='{{ .role_name }}-{{ .token_type }}-{{ randHexString 4 }}' path=345/service_account_00b069cb73a15d0a7ba8cd67a653599c scopes="read_api" token_type=group-service-account ttl=24h
334+
$ vault write gitlab/roles/personal-dynamic-path name='{{ .role_name }}-{{ .token_type }}-{{ randHexString 4 }}' path='ilija-.*' dynamic_path=true scopes="read_api" token_type=personal ttl=48h
333335
```
334336
335337
#### User service accounts

internal/model/role/role.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type Role struct {
2121
AccessLevel token.AccessLevel `json:"access_level" structs:"access_level" mapstructure:"access_level,omitempty"`
2222
TokenType token.Type `json:"token_type" structs:"token_type" mapstructure:"token_type"`
2323
GitlabRevokesTokens bool `json:"gitlab_revokes_token" structs:"gitlab_revokes_token" mapstructure:"gitlab_revokes_token"`
24+
DynamicPath bool `json:"dynamic_path" structs:"dynamic_path" mapstructure:"dynamic_path"`
2425
ConfigName string `json:"config_name" structs:"config_name" mapstructure:"config_name"`
2526
}
2627

@@ -39,6 +40,7 @@ func (e Role) LogicalResponseData() map[string]any {
3940
"access_level": e.AccessLevel.String(),
4041
"ttl": int64(e.TTL / time.Second),
4142
"token_type": e.TokenType.String(),
43+
"dynamic_path": e.DynamicPath,
4244
"gitlab_revokes_token": e.GitlabRevokesTokens,
4345
"config_name": e.ConfigName,
4446
}

internal/token/validate_path.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package token
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
7+
"github.com/ilijamt/vault-plugin-secrets-gitlab/internal/utils"
8+
)
9+
10+
var (
11+
allowedSegment = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
12+
invalidPathPrefixes = []string{"-", "_", "."}
13+
invalidPathSuffixes = []string{"-", "_", ".", ".git", ".atom"}
14+
invalidSegmentEdges = []string{"-", "_", "."}
15+
)
16+
17+
/*
18+
IsValidPath validates a path string for a specified tokenType.
19+
20+
Validation rules:
21+
- Each segment can contain only ASCII letters, digits, '_', '-', '.'.
22+
- Path must not start with '-', '_', or '.'.
23+
- Path must not end with '-', '_', '.', '.git' or '.atom'.
24+
- Segment count rules per token type:
25+
-- TypePersonal, TypeUserServiceAccount: exactly 1 segment.
26+
-- TypeGroupServiceAccount: exactly 2 segments.
27+
-- TypeProject, TypeGroup, TypeProjectDeploy, TypeGroupDeploy, TypePipelineProjectTrigger: 1 or more segments.
28+
29+
Returns true if valid, else false.
30+
*/
31+
func IsValidPath(path string, tokenType Type) (valid bool) {
32+
if strings.TrimSpace(path) == "" {
33+
return false
34+
}
35+
36+
if utils.HasAny(path, invalidPathPrefixes, strings.HasPrefix) ||
37+
utils.HasAny(path, invalidPathSuffixes, strings.HasSuffix) {
38+
return false
39+
}
40+
41+
segments := strings.Split(path, "/")
42+
for _, s := range segments {
43+
if s == "" {
44+
return false
45+
}
46+
47+
if !allowedSegment.MatchString(s) ||
48+
utils.HasAny(s, invalidSegmentEdges, strings.HasPrefix) ||
49+
utils.HasAny(s, invalidSegmentEdges, strings.HasSuffix) {
50+
return false
51+
}
52+
}
53+
54+
switch tokenType {
55+
case TypePersonal, TypeUserServiceAccount:
56+
/*
57+
Format of the paths:
58+
- {username}
59+
*/
60+
return len(segments) == 1
61+
62+
case TypeGroupServiceAccount:
63+
/*
64+
Format of the paths:
65+
- {groupId}/{serviceAccountName}
66+
*/
67+
return len(segments) == 2
68+
69+
case TypeProject, TypeGroup, TypeProjectDeploy, TypeGroupDeploy, TypePipelineProjectTrigger:
70+
/*
71+
Format of the paths:
72+
- group/project or group/subgroup/project
73+
- group or group/subgroup
74+
*/
75+
return len(segments) >= 1
76+
}
77+
78+
return true
79+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package token_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
8+
"github.com/ilijamt/vault-plugin-secrets-gitlab/internal/token"
9+
)
10+
11+
func TestIsValidPath(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
path string
15+
tokenType token.Type
16+
valid bool
17+
}{
18+
// Test cases
19+
{"personal access token - dynamic path", "admin-user", token.TypePersonal, true},
20+
{"project access token - dynamic path", "example/example", token.TypeProject, true},
21+
{"group access token - dynamic path", "example", token.TypeGroup, true},
22+
23+
// TypePersonal and TypeUserServiceAccount: single segment
24+
{"single valid - letters", "userone", token.TypePersonal, true},
25+
{"single valid - underscore", "user_one", token.TypeUserServiceAccount, true},
26+
{"single valid - hyphen+dot", "user.one-two", token.TypePersonal, true},
27+
{"single valid - digits", "user2024", token.TypePersonal, true},
28+
{"starts with invalid prefix '-'", "-user", token.TypePersonal, false},
29+
{"starts with invalid prefix '_'", "_user", token.TypeUserServiceAccount, false},
30+
{"starts with invalid prefix '.'", ".user", token.TypePersonal, false},
31+
{"ends with invalid suffix '-'", "user-", token.TypePersonal, false},
32+
{"ends with invalid suffix '_'", "user_", token.TypeUserServiceAccount, false},
33+
{"ends with invalid suffix '.'", "user.", token.TypePersonal, false},
34+
{"ends with invalid suffix '.git'", "user.git", token.TypeUserServiceAccount, false},
35+
{"ends with invalid suffix '.atom'", "user.atom", token.TypePersonal, false},
36+
{"ends with invalid suffix (mixed case)", "user.Atom", token.TypePersonal, true}, // Only lower ".atom" is invalid
37+
{"too many segments", "user/one", token.TypePersonal, false},
38+
{"empty path", "", token.TypePersonal, false},
39+
{"whitespace path", " ", token.TypeUserServiceAccount, false},
40+
41+
// TypeGroupServiceAccount: two segments
42+
{"group SA valid", "group1/account2", token.TypeGroupServiceAccount, true},
43+
{"group SA valid underscore", "group1/_account", token.TypeGroupServiceAccount, false},
44+
{"group SA valid, dot middle", "team.service/acct-2", token.TypeGroupServiceAccount, true},
45+
{"group SA too few segments", "group1", token.TypeGroupServiceAccount, false},
46+
{"group SA too many segments", "g/a/too/many", token.TypeGroupServiceAccount, false},
47+
{"group SA segment starts with invalid", "-group/acct", token.TypeGroupServiceAccount, false},
48+
{"group SA segment ends with invalid", "group/acct-", token.TypeGroupServiceAccount, false},
49+
50+
// TypeProject, TypeGroup, TypeProjectDeploy, TypeGroupDeploy, TypePipelineProjectTrigger types
51+
{"one segment", "myproj", token.TypeProject, true},
52+
{"two segments", "group/proj", token.TypeGroup, true},
53+
{"segments invalid", "grp-1/pro.j2/_b", token.TypeProjectDeploy, false},
54+
{"segments valid", "grp1/pro.j2/b_c", token.TypeGroup, true},
55+
{"forbidden prefix", "-group/project", token.TypeGroupDeploy, false},
56+
{"forbidden suffix", "g1/proj.git", token.TypeProject, false},
57+
{"trailing slash", "g1/", token.TypeProject, false},
58+
{"leading slash", "/g1", token.TypeProjectDeploy, false},
59+
{"double slash (empty segment)", "g1//p2", token.TypeProjectDeploy, false},
60+
{"ends with forbidden segment edge", "g1/g2.", token.TypeProject, false},
61+
{"dots and hyphens", "g1.part-2/project_3.one", token.TypeGroup, true},
62+
63+
// Empty segment from double slash
64+
{"double slash", "foo//bar", token.TypeProject, false},
65+
}
66+
67+
for _, tt := range tests {
68+
assert.Equal(t, tt.valid, token.IsValidPath(tt.path, tt.tokenType))
69+
}
70+
}

internal/utils/has_any.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package utils
2+
3+
// HasAny returns true if pred(s, v) is true for any v in vals.
4+
func HasAny[T any](s T, vals []T, pred func(T, T) bool) bool {
5+
for _, v := range vals {
6+
if pred(s, v) {
7+
return true
8+
}
9+
}
10+
return false
11+
}

internal/utils/has_any_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package utils_test
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
9+
"github.com/ilijamt/vault-plugin-secrets-gitlab/internal/utils"
10+
)
11+
12+
func TestHasAny_String(t *testing.T) {
13+
type args struct {
14+
s string
15+
vals []string
16+
pred func(string, string) bool
17+
}
18+
tests := []struct {
19+
name string
20+
args args
21+
want bool
22+
}{
23+
{
24+
name: "Has prefix, match",
25+
args: args{"foobar", []string{"foo", "bar", "baz"}, strings.HasPrefix},
26+
want: true,
27+
},
28+
{
29+
name: "Has prefix, no match",
30+
args: args{"foobar", []string{"baz", "qux"}, strings.HasPrefix},
31+
want: false,
32+
},
33+
{
34+
name: "Has suffix, match",
35+
args: args{"filename.git", []string{".zip", ".git"}, strings.HasSuffix},
36+
want: true,
37+
},
38+
{
39+
name: "Has suffix, no match",
40+
args: args{"filename.txt", []string{".git", ".zip"}, strings.HasSuffix},
41+
want: false,
42+
},
43+
{
44+
name: "Contains, match",
45+
args: args{"hello", []string{"e", "z"}, strings.Contains},
46+
want: true,
47+
},
48+
{
49+
name: "Empty vals",
50+
args: args{"foo", []string{}, strings.HasPrefix},
51+
want: false,
52+
},
53+
{
54+
name: "Empty s",
55+
args: args{"", []string{"foo"}, strings.HasPrefix},
56+
want: false,
57+
},
58+
}
59+
for _, tt := range tests {
60+
t.Run(tt.name, func(t *testing.T) {
61+
got := utils.HasAny(tt.args.s, tt.args.vals, tt.args.pred)
62+
assert.Equal(t, tt.want, got)
63+
})
64+
}
65+
}
66+
67+
// Example for a generic case, e.g. with int slices
68+
func TestHasAny_Int(t *testing.T) {
69+
isMultiple := func(x, y int) bool { return x%y == 0 }
70+
assert.True(t, utils.HasAny(12, []int{5, 3, 4}, isMultiple)) // 12 % 3 == 0
71+
assert.False(t, utils.HasAny(7, []int{2, 4, 6}, isMultiple))
72+
}

path_flags_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,23 @@ func TestPathFlags(t *testing.T) {
2525
require.NoError(t, err)
2626
require.NotNil(t, resp)
2727
require.NoError(t, resp.Error())
28+
require.True(t, resp.Data["allow_runtime_flags_change"].(bool))
2829
require.False(t, resp.Data["show_config_token"].(bool))
2930

3031
resp, err = b.HandleRequest(ctx, &logical.Request{
3132
Operation: logical.UpdateOperation,
3233
Path: gitlab.PathConfigFlags, Storage: l,
3334
Data: map[string]interface{}{
34-
"show_config_token": "true",
35+
"show_config_token": "true",
36+
"allow_runtime_flags_change": "false",
3537
},
3638
})
3739

3840
require.NoError(t, err)
3941
require.NotNil(t, resp)
4042
require.NoError(t, resp.Error())
4143
require.True(t, resp.Data["show_config_token"].(bool))
44+
require.True(t, resp.Data["allow_runtime_flags_change"].(bool))
4245

4346
events.expectEvents(t, []expectedEvent{
4447
{eventType: "gitlab/flags-write"},

0 commit comments

Comments
 (0)