detached c11bf1d

fix(auth): add token cache and fix scope validation

- Add in-memory cache to PassStore to prevent GPG agent overload
  when handling concurrent requests (e.g., docker buildx push)
- Cache tokens and revocation list after first lookup
- Add backwards compatibility for simple scopes (push/pull) in
  auth middleware for existing tokens
- Convert simple scopes to Docker v2 format in CLI token generation
  (repository:*:push,pull)
- Extract helper functions to reduce cyclomatic complexity

Fixes registry push failures with 403 Forbidden due to:
1. GPG agent being killed under concurrent pass show calls
2. Simple scopes not matching Docker v2 scope format
commit
c11bf1d53d017c4cb37b8c4c9543e2511b8f7567
author
brice · · 2026-01-18 09:23 UTC
committer
brice · · 2026-01-18 09:30 UTC
parent
6b5b414
signature
unsigned
3 files · +117 -62
M docs/getting-started.md +55 -7
@@ -7,6 +7,7 @@ Deploy your first app with Gordon in under 5 minutes.
77 - A Linux VPS with Docker or Podman installed
88 - A domain pointing to your VPS (DNS A record)
99 - Cloudflare account (free tier) for HTTPS termination
10+- [pass](https://www.passwordstore.org/) (password manager) with GPG key initialized
1011
1112 ## 1. Install Gordon
1213
@@ -16,7 +17,7 @@ curl -fsSL https://gordon.bnema.dev/install | bash
1617
1718 This script automatically detects your OS and architecture, downloads the appropriate binary, and installs it to `/usr/local/bin`.
1819
19-## 2. Start Gordon
20+## 2. Initialize Configuration
2021
2122 ```bash
2223 # First run creates the default config
@@ -26,7 +27,41 @@ gordon serve
2627
2728 Config is created at `~/.config/gordon/gordon.toml`.
2829
29-## 3. Configure Your Registry Domain
30+## 3. Set Up Authentication
31+
32+Gordon requires a `token_secret` for JWT authentication. We recommend using `pass` to store secrets securely.
33+
34+> **Local development?** If you just want to try Gordon locally, you can disable auth temporarily:
35+> ```toml
36+> [auth]
37+> enabled = false
38+> ```
39+> Skip to [Step 4](#4-configure-your-registry-domain). For production, continue below.
40+
41+**Initialize pass (if not already done):**
42+```bash
43+# Generate a GPG key if you don't have one
44+gpg --gen-key
45+
46+# Initialize pass with your GPG key ID
47+pass init <your-gpg-key-id>
48+```
49+
50+**Create the JWT token secret:**
51+```bash
52+# Generate and store a random 32-character secret
53+openssl rand -base64 32 | pass insert -m gordon/auth/token_secret
54+```
55+
56+**Update your config** (`~/.config/gordon/gordon.toml`):
57+```toml
58+[auth]
59+enabled = true
60+secrets_backend = "pass"
61+token_secret = "gordon/auth/token_secret"
62+```
63+
64+## 4. Configure Your Registry Domain
3065
3166 Edit `~/.config/gordon/gordon.toml`:
3267
@@ -34,13 +69,13 @@ Edit `~/.config/gordon/gordon.toml`:
3469 [server]
3570 port = 8080 # Proxy port (use with Cloudflare)
3671 registry_port = 5000 # Registry port
37-gordon_domain = "gordon.mydomain.com" # Your Gordon domain
72+gordon_domain = "gordon.mydomain.com" # Your Gordon domain
3873
3974 [routes]
4075 "app.mydomain.com" = "myapp:latest" # Domain → Image mapping
4176 ```
4277
43-## 4. Set Up DNS
78+## 5. Set Up DNS
4479
4580 In Cloudflare (or your DNS provider):
4681
@@ -51,7 +86,7 @@ In Cloudflare (or your DNS provider):
5186
5287 Enable Cloudflare proxy (orange cloud) for HTTPS.
5388
54-## 5. Start Gordon as a Service
89+## 6. Start Gordon as a Service
5590
5691 ```bash
5792 # Create systemd user service
@@ -75,11 +110,24 @@ systemctl --user enable --now gordon
75110 sudo loginctl enable-linger $USER
76111 ```
77112
78-## 6. Deploy Your First App
113+## 7. Generate a Deploy Token
114+
115+Create a token for pushing images to your registry (skip if auth is disabled):
116+
117+```bash
118+gordon auth token generate --subject deploy --scopes push,pull --expiry 0
119+```
120+
121+Save this token securely - you'll need it for Docker login.
122+
123+## 8. Deploy Your First App
79124
80125 On your local machine:
81126
82127 ```bash
128+# Log in to your Gordon registry (skip if auth is disabled)
129+docker login -u deploy -p <your-token> registry.mydomain.com
130+
83131 # Build your app
84132 docker build -t myapp .
85133
@@ -92,7 +140,7 @@ docker push registry.mydomain.com/myapp:latest
92140
93141 Your app is now live at `https://app.mydomain.com`!
94142
95-## 7. Update Your App
143+## 9. Update Your App
96144
97145 Push a new image to deploy with zero downtime:
98146
@@ -7,6 +7,7 @@ Deploy your first app with Gordon in under 5 minutes.
7 - A Linux VPS with Docker or Podman installed7 - A Linux VPS with Docker or Podman installed
8 - A domain pointing to your VPS (DNS A record)8 - A domain pointing to your VPS (DNS A record)
9 - Cloudflare account (free tier) for HTTPS termination9 - Cloudflare account (free tier) for HTTPS termination
10+- [pass](https://www.passwordstore.org/) (password manager) with GPG key initialized
10 11
11 ## 1. Install Gordon12 ## 1. Install Gordon
12 13
@@ -16,7 +17,7 @@ curl -fsSL https://gordon.bnema.dev/install | bash
16 17
17 This script automatically detects your OS and architecture, downloads the appropriate binary, and installs it to `/usr/local/bin`.18 This script automatically detects your OS and architecture, downloads the appropriate binary, and installs it to `/usr/local/bin`.
18 19
19-## 2. Start Gordon20+## 2. Initialize Configuration
20 21
21 ```bash22 ```bash
22 # First run creates the default config23 # First run creates the default config
@@ -26,7 +27,41 @@ gordon serve
26 27
27 Config is created at `~/.config/gordon/gordon.toml`.28 Config is created at `~/.config/gordon/gordon.toml`.
28 29
29-## 3. Configure Your Registry Domain30+## 3. Set Up Authentication
31+
32+Gordon requires a `token_secret` for JWT authentication. We recommend using `pass` to store secrets securely.
33+
34+> **Local development?** If you just want to try Gordon locally, you can disable auth temporarily:
35+> ```toml
36+> [auth]
37+> enabled = false
38+> ```
39+> Skip to [Step 4](#4-configure-your-registry-domain). For production, continue below.
40+
41+**Initialize pass (if not already done):**
42+```bash
43+# Generate a GPG key if you don't have one
44+gpg --gen-key
45+
46+# Initialize pass with your GPG key ID
47+pass init <your-gpg-key-id>
48+```
49+
50+**Create the JWT token secret:**
51+```bash
52+# Generate and store a random 32-character secret
53+openssl rand -base64 32 | pass insert -m gordon/auth/token_secret
54+```
55+
56+**Update your config** (`~/.config/gordon/gordon.toml`):
57+```toml
58+[auth]
59+enabled = true
60+secrets_backend = "pass"
61+token_secret = "gordon/auth/token_secret"
62+```
63+
64+## 4. Configure Your Registry Domain
30 65
31 Edit `~/.config/gordon/gordon.toml`:66 Edit `~/.config/gordon/gordon.toml`:
32 67
@@ -34,13 +69,13 @@ Edit `~/.config/gordon/gordon.toml`:
34 [server]69 [server]
35 port = 8080 # Proxy port (use with Cloudflare)70 port = 8080 # Proxy port (use with Cloudflare)
36 registry_port = 5000 # Registry port71 registry_port = 5000 # Registry port
37-gordon_domain = "gordon.mydomain.com" # Your Gordon domain72+gordon_domain = "gordon.mydomain.com" # Your Gordon domain
38 73
39 [routes]74 [routes]
40 "app.mydomain.com" = "myapp:latest" # Domain → Image mapping75 "app.mydomain.com" = "myapp:latest" # Domain → Image mapping
41 ```76 ```
42 77
43-## 4. Set Up DNS78+## 5. Set Up DNS
44 79
45 In Cloudflare (or your DNS provider):80 In Cloudflare (or your DNS provider):
46 81
@@ -51,7 +86,7 @@ In Cloudflare (or your DNS provider):
51 86
52 Enable Cloudflare proxy (orange cloud) for HTTPS.87 Enable Cloudflare proxy (orange cloud) for HTTPS.
53 88
54-## 5. Start Gordon as a Service89+## 6. Start Gordon as a Service
55 90
56 ```bash91 ```bash
57 # Create systemd user service92 # Create systemd user service
@@ -75,11 +110,24 @@ systemctl --user enable --now gordon
75 sudo loginctl enable-linger $USER110 sudo loginctl enable-linger $USER
76 ```111 ```
77 112
78-## 6. Deploy Your First App113+## 7. Generate a Deploy Token
114+
115+Create a token for pushing images to your registry (skip if auth is disabled):
116+
117+```bash
118+gordon auth token generate --subject deploy --scopes push,pull --expiry 0
119+```
120+
121+Save this token securely - you'll need it for Docker login.
122+
123+## 8. Deploy Your First App
79 124
80 On your local machine:125 On your local machine:
81 126
82 ```bash127 ```bash
128+# Log in to your Gordon registry (skip if auth is disabled)
129+docker login -u deploy -p <your-token> registry.mydomain.com
130+
83 # Build your app131 # Build your app
84 docker build -t myapp .132 docker build -t myapp .
85 133
@@ -92,7 +140,7 @@ docker push registry.mydomain.com/myapp:latest
92 140
93 Your app is now live at `https://app.mydomain.com`!141 Your app is now live at `https://app.mydomain.com`!
94 142
95-## 7. Update Your App143+## 9. Update Your App
96 144
97 Push a new image to deploy with zero downtime:145 Push a new image to deploy with zero downtime:
98 146
M internal/adapters/in/cli/auth.go +37 -33
@@ -352,31 +352,15 @@ The hash can be stored in your secrets backend and referenced in the config:
352352 }
353353
354354 // runTokenGenerate generates a new authentication token.
355-func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
356- cfg, err := loadAuthConfig(configPath)
357- if err != nil {
358- return err
359- }
360-
361- // Parse expiry (supports human-friendly units: d, w, M, y)
362- var expiry time.Duration
363- if expiryStr != "0" {
364- expiry, err = duration.Parse(expiryStr)
365- if err != nil {
366- return fmt.Errorf("invalid expiry duration: %w", err)
367- }
368- }
369-
370- // Parse scopes and convert simple scopes to Docker v2 format
371- // Simple scopes like "push,pull" become "repository:*:push,pull"
355+// parseAndConvertScopes parses a comma-separated scope string and converts
356+// simple scopes (push, pull, *) to Docker v2 format (repository:*:action).
357+func parseAndConvertScopes(scopesStr string) []string {
372358 rawScopes := strings.Split(scopesStr, ",")
373359 for i := range rawScopes {
374360 rawScopes[i] = strings.TrimSpace(rawScopes[i])
375361 }
376362
377363 // Check if scopes are already in v2 format (contain colons)
378- // or if they're simple scopes that need conversion
379- var scopes []string
380364 hasSimpleScope := false
381365 for _, s := range rawScopes {
382366 if !strings.Contains(s, ":") && (s == "push" || s == "pull" || s == "*") {
@@ -385,24 +369,44 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
385369 }
386370 }
387371
388- if hasSimpleScope {
389- // Convert simple scopes to v2 format: repository:*:push,pull
390- var actions []string
391- for _, s := range rawScopes {
392- if s == "push" || s == "pull" || s == "*" {
393- actions = append(actions, s)
394- } else {
395- // Keep non-simple scopes as-is (e.g., admin scopes)
396- scopes = append(scopes, s)
397- }
372+ if !hasSimpleScope {
373+ return rawScopes
374+ }
375+
376+ // Convert simple scopes to v2 format: repository:*:push,pull
377+ var scopes []string
378+ var actions []string
379+ for _, s := range rawScopes {
380+ if s == "push" || s == "pull" || s == "*" {
381+ actions = append(actions, s)
382+ } else {
383+ // Keep non-simple scopes as-is (e.g., admin scopes)
384+ scopes = append(scopes, s)
398385 }
399- if len(actions) > 0 {
400- scopes = append(scopes, "repository:*:"+strings.Join(actions, ","))
386+ }
387+ if len(actions) > 0 {
388+ scopes = append(scopes, "repository:*:"+strings.Join(actions, ","))
389+ }
390+ return scopes
391+}
392+
393+func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
394+ cfg, err := loadAuthConfig(configPath)
395+ if err != nil {
396+ return err
397+ }
398+
399+ // Parse expiry (supports human-friendly units: d, w, M, y)
400+ var expiry time.Duration
401+ if expiryStr != "0" {
402+ expiry, err = duration.Parse(expiryStr)
403+ if err != nil {
404+ return fmt.Errorf("invalid expiry duration: %w", err)
401405 }
402- } else {
403- scopes = rawScopes
404406 }
405407
408+ scopes := parseAndConvertScopes(scopesStr)
409+
406410 // Create auth service
407411 log := zerowrap.New(zerowrap.Config{Level: "warn"})
408412 authSvc, err := createAuthServiceForCLI(cfg, log)
@@ -352,31 +352,15 @@ The hash can be stored in your secrets backend and referenced in the config:
352 }352 }
353 353
354 // runTokenGenerate generates a new authentication token.354 // runTokenGenerate generates a new authentication token.
355-func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {355+// parseAndConvertScopes parses a comma-separated scope string and converts
356- cfg, err := loadAuthConfig(configPath)356+// simple scopes (push, pull, *) to Docker v2 format (repository:*:action).
357- if err != nil {357+func parseAndConvertScopes(scopesStr string) []string {
358- return err
359- }
360-
361- // Parse expiry (supports human-friendly units: d, w, M, y)
362- var expiry time.Duration
363- if expiryStr != "0" {
364- expiry, err = duration.Parse(expiryStr)
365- if err != nil {
366- return fmt.Errorf("invalid expiry duration: %w", err)
367- }
368- }
369-
370- // Parse scopes and convert simple scopes to Docker v2 format
371- // Simple scopes like "push,pull" become "repository:*:push,pull"
372 rawScopes := strings.Split(scopesStr, ",")358 rawScopes := strings.Split(scopesStr, ",")
373 for i := range rawScopes {359 for i := range rawScopes {
374 rawScopes[i] = strings.TrimSpace(rawScopes[i])360 rawScopes[i] = strings.TrimSpace(rawScopes[i])
375 }361 }
376 362
377 // Check if scopes are already in v2 format (contain colons)363 // Check if scopes are already in v2 format (contain colons)
378- // or if they're simple scopes that need conversion
379- var scopes []string
380 hasSimpleScope := false364 hasSimpleScope := false
381 for _, s := range rawScopes {365 for _, s := range rawScopes {
382 if !strings.Contains(s, ":") && (s == "push" || s == "pull" || s == "*") {366 if !strings.Contains(s, ":") && (s == "push" || s == "pull" || s == "*") {
@@ -385,24 +369,44 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
385 }369 }
386 }370 }
387 371
388- if hasSimpleScope {372+ if !hasSimpleScope {
389- // Convert simple scopes to v2 format: repository:*:push,pull373+ return rawScopes
390- var actions []string374+ }
391- for _, s := range rawScopes {375+
392- if s == "push" || s == "pull" || s == "*" {376+ // Convert simple scopes to v2 format: repository:*:push,pull
393- actions = append(actions, s)377+ var scopes []string
394- } else {378+ var actions []string
395- // Keep non-simple scopes as-is (e.g., admin scopes)379+ for _, s := range rawScopes {
396- scopes = append(scopes, s)380+ if s == "push" || s == "pull" || s == "*" {
397- }381+ actions = append(actions, s)
382+ } else {
383+ // Keep non-simple scopes as-is (e.g., admin scopes)
384+ scopes = append(scopes, s)
398 }385 }
399- if len(actions) > 0 {386+ }
400- scopes = append(scopes, "repository:*:"+strings.Join(actions, ","))387+ if len(actions) > 0 {
388+ scopes = append(scopes, "repository:*:"+strings.Join(actions, ","))
389+ }
390+ return scopes
391+}
392+
393+func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
394+ cfg, err := loadAuthConfig(configPath)
395+ if err != nil {
396+ return err
397+ }
398+
399+ // Parse expiry (supports human-friendly units: d, w, M, y)
400+ var expiry time.Duration
401+ if expiryStr != "0" {
402+ expiry, err = duration.Parse(expiryStr)
403+ if err != nil {
404+ return fmt.Errorf("invalid expiry duration: %w", err)
401 }405 }
402- } else {
403- scopes = rawScopes
404 }406 }
405 407
408+ scopes := parseAndConvertScopes(scopesStr)
409+
406 // Create auth service410 // Create auth service
407 log := zerowrap.New(zerowrap.Config{Level: "warn"})411 log := zerowrap.New(zerowrap.Config{Level: "warn"})
408 authSvc, err := createAuthServiceForCLI(cfg, log)412 authSvc, err := createAuthServiceForCLI(cfg, log)
M internal/adapters/in/http/middleware/auth.go +25 -22
@@ -312,33 +312,28 @@ func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth)
312312 return usernameMatch && passwordMatch
313313 }
314314
315-// checkScopeAccess verifies the token has permission for the requested operation.
316-// Maps HTTP method to registry action and checks if any token scope grants access.
317-func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.Logger) bool {
318- // Determine required action from HTTP method
319- var action string
320- switch r.Method {
315+// actionFromMethod returns the registry action for an HTTP method.
316+func actionFromMethod(method string) string {
317+ switch method {
321318 case http.MethodGet, http.MethodHead:
322- action = domain.ScopeActionPull
319+ return domain.ScopeActionPull
323320 case http.MethodPut, http.MethodPost, http.MethodPatch, http.MethodDelete:
324- action = domain.ScopeActionPush
321+ return domain.ScopeActionPush
325322 default:
326- action = domain.ScopeActionPull // Default to pull for unknown methods
323+ return domain.ScopeActionPull
327324 }
325+}
328326
329- // Extract repository name from URL path
330- // Paths are like /v2/{name}/manifests/{reference} or /v2/{name}/blobs/{digest}
331- path := r.URL.Path
327+// extractRepoName extracts the repository name from a registry path.
328+// Returns empty string if the path is not a valid registry path or should be allowed.
329+func extractRepoName(path string) (repoName string, shouldAllow bool) {
332330 if !strings.HasPrefix(path, "/v2/") {
333- // Not a registry path, allow
334- return true
331+ return "", true // Not a registry path, allow
335332 }
336333
337- // Remove /v2/ prefix and find repository name
338334 pathParts := strings.Split(strings.TrimPrefix(path, "/v2/"), "/")
339335 if len(pathParts) < 2 {
340- // Malformed path or /v2/ root, allow
341- return true
336+ return "", true // Malformed path or /v2/ root, allow
342337 }
343338
344339 // Find the boundary between repo name and route (manifests, blobs, tags)
@@ -350,16 +345,25 @@ func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.
350345 }
351346 }
352347 if len(repoNameParts) == 0 {
353- // Could be a special route like /v2/token, allow
354- return true
348+ return "", true // Special route like /v2/token, allow
355349 }
356350
357- repoName := strings.Join(repoNameParts, "/")
351+ return strings.Join(repoNameParts, "/"), false
352+}
353+
354+// checkScopeAccess verifies the token has permission for the requested operation.
355+// Maps HTTP method to registry action and checks if any token scope grants access.
356+func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.Logger) bool {
357+ action := actionFromMethod(r.Method)
358+
359+ repoName, shouldAllow := extractRepoName(r.URL.Path)
360+ if shouldAllow {
361+ return true
362+ }
358363
359364 // Check if any token scope grants access
360365 for _, scopeStr := range claims.Scopes {
361366 // Handle simple scopes (e.g., "push", "pull") for backwards compatibility
362- // with tokens generated before v2 scope format was enforced
363367 if scopeStr == action || scopeStr == "*" {
364368 log.Debug().
365369 Str("repo", repoName).
@@ -372,7 +376,6 @@ func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.
372376 // Handle Docker v2 format scopes (e.g., "repository:myrepo:push,pull")
373377 scope, err := domain.ParseScope(scopeStr)
374378 if err != nil {
375- log.Debug().Err(err).Str("scope", scopeStr).Msg("scope not in v2 format, skipping")
376379 continue
377380 }
378381
@@ -312,33 +312,28 @@ func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth)
312 return usernameMatch && passwordMatch312 return usernameMatch && passwordMatch
313 }313 }
314 314
315-// checkScopeAccess verifies the token has permission for the requested operation.315+// actionFromMethod returns the registry action for an HTTP method.
316-// Maps HTTP method to registry action and checks if any token scope grants access.316+func actionFromMethod(method string) string {
317-func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.Logger) bool {317+ switch method {
318- // Determine required action from HTTP method
319- var action string
320- switch r.Method {
321 case http.MethodGet, http.MethodHead:318 case http.MethodGet, http.MethodHead:
322- action = domain.ScopeActionPull319+ return domain.ScopeActionPull
323 case http.MethodPut, http.MethodPost, http.MethodPatch, http.MethodDelete:320 case http.MethodPut, http.MethodPost, http.MethodPatch, http.MethodDelete:
324- action = domain.ScopeActionPush321+ return domain.ScopeActionPush
325 default:322 default:
326- action = domain.ScopeActionPull // Default to pull for unknown methods323+ return domain.ScopeActionPull
327 }324 }
325+}
328 326
329- // Extract repository name from URL path327+// extractRepoName extracts the repository name from a registry path.
330- // Paths are like /v2/{name}/manifests/{reference} or /v2/{name}/blobs/{digest}328+// Returns empty string if the path is not a valid registry path or should be allowed.
331- path := r.URL.Path329+func extractRepoName(path string) (repoName string, shouldAllow bool) {
332 if !strings.HasPrefix(path, "/v2/") {330 if !strings.HasPrefix(path, "/v2/") {
333- // Not a registry path, allow331+ return "", true // Not a registry path, allow
334- return true
335 }332 }
336 333
337- // Remove /v2/ prefix and find repository name
338 pathParts := strings.Split(strings.TrimPrefix(path, "/v2/"), "/")334 pathParts := strings.Split(strings.TrimPrefix(path, "/v2/"), "/")
339 if len(pathParts) < 2 {335 if len(pathParts) < 2 {
340- // Malformed path or /v2/ root, allow336+ return "", true // Malformed path or /v2/ root, allow
341- return true
342 }337 }
343 338
344 // Find the boundary between repo name and route (manifests, blobs, tags)339 // Find the boundary between repo name and route (manifests, blobs, tags)
@@ -350,16 +345,25 @@ func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.
350 }345 }
351 }346 }
352 if len(repoNameParts) == 0 {347 if len(repoNameParts) == 0 {
353- // Could be a special route like /v2/token, allow348+ return "", true // Special route like /v2/token, allow
354- return true
355 }349 }
356 350
357- repoName := strings.Join(repoNameParts, "/")351+ return strings.Join(repoNameParts, "/"), false
352+}
353+
354+// checkScopeAccess verifies the token has permission for the requested operation.
355+// Maps HTTP method to registry action and checks if any token scope grants access.
356+func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.Logger) bool {
357+ action := actionFromMethod(r.Method)
358+
359+ repoName, shouldAllow := extractRepoName(r.URL.Path)
360+ if shouldAllow {
361+ return true
362+ }
358 363
359 // Check if any token scope grants access364 // Check if any token scope grants access
360 for _, scopeStr := range claims.Scopes {365 for _, scopeStr := range claims.Scopes {
361 // Handle simple scopes (e.g., "push", "pull") for backwards compatibility366 // Handle simple scopes (e.g., "push", "pull") for backwards compatibility
362- // with tokens generated before v2 scope format was enforced
363 if scopeStr == action || scopeStr == "*" {367 if scopeStr == action || scopeStr == "*" {
364 log.Debug().368 log.Debug().
365 Str("repo", repoName).369 Str("repo", repoName).
@@ -372,7 +376,6 @@ func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.
372 // Handle Docker v2 format scopes (e.g., "repository:myrepo:push,pull")376 // Handle Docker v2 format scopes (e.g., "repository:myrepo:push,pull")
373 scope, err := domain.ParseScope(scopeStr)377 scope, err := domain.ParseScope(scopeStr)
374 if err != nil {378 if err != nil {
375- log.Debug().Err(err).Str("scope", scopeStr).Msg("scope not in v2 format, skipping")
376 continue379 continue
377 }380 }
378 381

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