detached 8443ba4

feat: add --repo flag to token generate for repo-specific scoping

commit
8443ba47c454dbb913b1d2d47dbc3aa5a9d55b03
author
brice · · 2026-03-19 04:54 UTC
parent
c9f8ad6
signature
unsigned
2 files · +48 -8
M internal/adapters/in/cli/auth.go +15 -8
@@ -74,6 +74,7 @@ func newTokenGenerateCmd() *cobra.Command {
7474 scopes string
7575 expiry string
7676 configPath string
77+ repo string
7778 )
7879
7980 cmd := &cobra.Command{
@@ -101,10 +102,15 @@ Admin scopes (for remote CLI access):
101102 admin:routes:read Read-only routes access
102103 admin:status:read Read-only status access
103104
105+Repository scoping:
106+ --repo myapp Scope to specific repository
107+ --repo "*" All repositories (default)
108+
104109 Combined examples:
105- --scopes "push,pull" Registry only (default)
106- --scopes "admin:*:*" Admin CLI only
107- --scopes "push,pull,admin:*:*" Registry + full admin
110+ --scopes "push,pull" All repos, registry only (default)
111+ --scopes "push" --repo myapp Push to myapp only
112+ --scopes "push,pull,admin:routes:read" Minimum CI scope (all repos)
113+ --scopes "push,admin:routes:read" --repo app Minimum CI scope (specific repo)
108114
109115 EXPIRY:
110116
@@ -115,7 +121,7 @@ Supports human-friendly durations:
115121 Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d
116122 Use --expiry=0 for a token that never expires (useful for CI).`,
117123 RunE: func(cmd *cobra.Command, args []string) error {
118- return runTokenGenerate(subject, scopes, expiry, configPath)
124+ return runTokenGenerate(subject, scopes, expiry, configPath, repo)
119125 },
120126 }
121127
@@ -123,6 +129,7 @@ Use --expiry=0 for a token that never expires (useful for CI).`,
123129 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
124130 cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")
125131 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
132+ cmd.Flags().StringVar(&repo, "repo", "*", "Repository to scope the token to (default: * for all repositories)")
126133
127134 _ = cmd.MarkFlagRequired("subject")
128135
@@ -434,7 +441,7 @@ The hash can be stored in your secrets backend and referenced in the config:
434441 // runTokenGenerate generates a new authentication token.
435442 // parseAndConvertScopes parses a comma-separated scope string and converts
436443 // simple scopes (push, pull, *) to Docker v2 format (repository:*:action).
437-func parseAndConvertScopes(scopesStr string) []string {
444+func parseAndConvertScopes(scopesStr, repo string) []string {
438445 rawScopes := strings.Split(scopesStr, ",")
439446 for i := range rawScopes {
440447 rawScopes[i] = strings.TrimSpace(rawScopes[i])
@@ -465,12 +472,12 @@ func parseAndConvertScopes(scopesStr string) []string {
465472 }
466473 }
467474 if len(actions) > 0 {
468- scopes = append(scopes, "repository:*:"+strings.Join(actions, ","))
475+ scopes = append(scopes, "repository:"+repo+":"+strings.Join(actions, ","))
469476 }
470477 return scopes
471478 }
472479
473-func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
480+func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) error {
474481 cfg, err := loadAuthConfig(configPath)
475482 if err != nil {
476483 return err
@@ -485,7 +492,7 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
485492 }
486493 }
487494
488- scopes := parseAndConvertScopes(scopesStr)
495+ scopes := parseAndConvertScopes(scopesStr, repo)
489496
490497 // Create auth service
491498 log := zerowrap.New(zerowrap.Config{Level: "warn"})
@@ -74,6 +74,7 @@ func newTokenGenerateCmd() *cobra.Command {
74 scopes string74 scopes string
75 expiry string75 expiry string
76 configPath string76 configPath string
77+ repo string
77 )78 )
78 79
79 cmd := &cobra.Command{80 cmd := &cobra.Command{
@@ -101,10 +102,15 @@ Admin scopes (for remote CLI access):
101 admin:routes:read Read-only routes access102 admin:routes:read Read-only routes access
102 admin:status:read Read-only status access103 admin:status:read Read-only status access
103 104
105+Repository scoping:
106+ --repo myapp Scope to specific repository
107+ --repo "*" All repositories (default)
108+
104 Combined examples:109 Combined examples:
105- --scopes "push,pull" Registry only (default)110+ --scopes "push,pull" All repos, registry only (default)
106- --scopes "admin:*:*" Admin CLI only111+ --scopes "push" --repo myapp Push to myapp only
107- --scopes "push,pull,admin:*:*" Registry + full admin112+ --scopes "push,pull,admin:routes:read" Minimum CI scope (all repos)
113+ --scopes "push,admin:routes:read" --repo app Minimum CI scope (specific repo)
108 114
109 EXPIRY:115 EXPIRY:
110 116
@@ -115,7 +121,7 @@ Supports human-friendly durations:
115 Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d121 Examples: 1y, 30d, 2w, 6M, 1y6M, 2w3d
116 Use --expiry=0 for a token that never expires (useful for CI).`,122 Use --expiry=0 for a token that never expires (useful for CI).`,
117 RunE: func(cmd *cobra.Command, args []string) error {123 RunE: func(cmd *cobra.Command, args []string) error {
118- return runTokenGenerate(subject, scopes, expiry, configPath)124+ return runTokenGenerate(subject, scopes, expiry, configPath, repo)
119 },125 },
120 }126 }
121 127
@@ -123,6 +129,7 @@ Use --expiry=0 for a token that never expires (useful for CI).`,
123 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")129 cmd.Flags().StringVar(&scopes, "scopes", "push,pull", "Comma-separated list of scopes")
124 cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")130 cmd.Flags().StringVar(&expiry, "expiry", "30d", "Token expiry duration (e.g., 1y, 30d, 2w, 24h, 0 for never)")
125 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")131 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
132+ cmd.Flags().StringVar(&repo, "repo", "*", "Repository to scope the token to (default: * for all repositories)")
126 133
127 _ = cmd.MarkFlagRequired("subject")134 _ = cmd.MarkFlagRequired("subject")
128 135
@@ -434,7 +441,7 @@ The hash can be stored in your secrets backend and referenced in the config:
434 // runTokenGenerate generates a new authentication token.441 // runTokenGenerate generates a new authentication token.
435 // parseAndConvertScopes parses a comma-separated scope string and converts442 // parseAndConvertScopes parses a comma-separated scope string and converts
436 // simple scopes (push, pull, *) to Docker v2 format (repository:*:action).443 // simple scopes (push, pull, *) to Docker v2 format (repository:*:action).
437-func parseAndConvertScopes(scopesStr string) []string {444+func parseAndConvertScopes(scopesStr, repo string) []string {
438 rawScopes := strings.Split(scopesStr, ",")445 rawScopes := strings.Split(scopesStr, ",")
439 for i := range rawScopes {446 for i := range rawScopes {
440 rawScopes[i] = strings.TrimSpace(rawScopes[i])447 rawScopes[i] = strings.TrimSpace(rawScopes[i])
@@ -465,12 +472,12 @@ func parseAndConvertScopes(scopesStr string) []string {
465 }472 }
466 }473 }
467 if len(actions) > 0 {474 if len(actions) > 0 {
468- scopes = append(scopes, "repository:*:"+strings.Join(actions, ","))475+ scopes = append(scopes, "repository:"+repo+":"+strings.Join(actions, ","))
469 }476 }
470 return scopes477 return scopes
471 }478 }
472 479
473-func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {480+func runTokenGenerate(subject, scopesStr, expiryStr, configPath, repo string) error {
474 cfg, err := loadAuthConfig(configPath)481 cfg, err := loadAuthConfig(configPath)
475 if err != nil {482 if err != nil {
476 return err483 return err
@@ -485,7 +492,7 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
485 }492 }
486 }493 }
487 494
488- scopes := parseAndConvertScopes(scopesStr)495+ scopes := parseAndConvertScopes(scopesStr, repo)
489 496
490 // Create auth service497 // Create auth service
491 log := zerowrap.New(zerowrap.Config{Level: "warn"})498 log := zerowrap.New(zerowrap.Config{Level: "warn"})
A internal/adapters/in/cli/auth_scopes_test.go +33 -0
@@ -0,0 +1,33 @@
1+package cli
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestParseAndConvertScopes_DefaultWildcard(t *testing.T) {
10+ got := parseAndConvertScopes("push,pull", "*")
11+ assert.Equal(t, []string{"repository:*:push,pull"}, got)
12+}
13+
14+func TestParseAndConvertScopes_SpecificRepo(t *testing.T) {
15+ got := parseAndConvertScopes("push,pull", "myapp")
16+ assert.Equal(t, []string{"repository:myapp:push,pull"}, got)
17+}
18+
19+func TestParseAndConvertScopes_SpecificRepoPushOnly(t *testing.T) {
20+ got := parseAndConvertScopes("push", "myapp")
21+ assert.Equal(t, []string{"repository:myapp:push"}, got)
22+}
23+
24+func TestParseAndConvertScopes_MixedWithAdminScopes(t *testing.T) {
25+ got := parseAndConvertScopes("push,pull,admin:routes:read", "myapp")
26+ assert.ElementsMatch(t, []string{"repository:myapp:push,pull", "admin:routes:read"}, got)
27+}
28+
29+func TestParseAndConvertScopes_AlreadyV2Format_RepoIgnored(t *testing.T) {
30+ // When scopes are already in v2 format, --repo is ignored (scopes returned as-is)
31+ got := parseAndConvertScopes("repository:specific:push", "*")
32+ assert.Equal(t, []string{"repository:specific:push"}, got)
33+}
@@ -0,0 +1,33 @@
1+package cli
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestParseAndConvertScopes_DefaultWildcard(t *testing.T) {
10+ got := parseAndConvertScopes("push,pull", "*")
11+ assert.Equal(t, []string{"repository:*:push,pull"}, got)
12+}
13+
14+func TestParseAndConvertScopes_SpecificRepo(t *testing.T) {
15+ got := parseAndConvertScopes("push,pull", "myapp")
16+ assert.Equal(t, []string{"repository:myapp:push,pull"}, got)
17+}
18+
19+func TestParseAndConvertScopes_SpecificRepoPushOnly(t *testing.T) {
20+ got := parseAndConvertScopes("push", "myapp")
21+ assert.Equal(t, []string{"repository:myapp:push"}, got)
22+}
23+
24+func TestParseAndConvertScopes_MixedWithAdminScopes(t *testing.T) {
25+ got := parseAndConvertScopes("push,pull,admin:routes:read", "myapp")
26+ assert.ElementsMatch(t, []string{"repository:myapp:push,pull", "admin:routes:read"}, got)
27+}
28+
29+func TestParseAndConvertScopes_AlreadyV2Format_RepoIgnored(t *testing.T) {
30+ // When scopes are already in v2 format, --repo is ignored (scopes returned as-is)
31+ got := parseAndConvertScopes("repository:specific:push", "*")
32+ assert.Equal(t, []string{"repository:specific:push"}, got)
33+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close