detached 175d6d6

refactor(auth): move scope matching helpers to domain layer

Extract hasGrantedRegistryAccess and hasGrantedAdminAccess from the HTTP
auth handler into domain.ScopesGrantRegistryAccess and
domain.ScopesGrantAdminAccess. Both the handler (token exchange) and
middleware (request authorization) now delegate to the domain functions,
eliminating duplicated scope iteration logic.
commit
175d6d644ee7ed68fcee67bddb44d6bc98757eea
author
brice · · 2026-03-22 07:20 UTC
committer
brice · · 2026-03-22 17:35 UTC
parent
a98b865
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +247 -79
M internal/adapters/in/http/auth/handler.go +2 -49
@@ -193,7 +193,7 @@ func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []stri
193193 case domain.ScopeTypeRepository:
194194 allowedActions := make([]string, 0, len(reqScope.Actions))
195195 for _, action := range reqScope.Actions {
196- if hasGrantedRegistryAccess(grantedScopes, reqScope.Name, action) {
196+ if domain.ScopesGrantRegistryAccess(grantedScopes, reqScope.Name, action) {
197197 allowedActions = append(allowedActions, action)
198198 }
199199 }
@@ -211,7 +211,7 @@ func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []stri
211211 case domain.ScopeTypeAdmin:
212212 allowedActions := make([]string, 0, len(reqScope.Actions))
213213 for _, action := range reqScope.Actions {
214- if hasGrantedAdminAccess(grantedScopes, reqScope.Name, action) {
214+ if domain.ScopesGrantAdminAccess(grantedScopes, reqScope.Name, action) {
215215 allowedActions = append(allowedActions, action)
216216 }
217217 }
@@ -237,53 +237,6 @@ func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []stri
237237 return effective
238238 }
239239
240-func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) bool {
241- for _, grantedScopeStr := range grantedScopes {
242- scopeStr := strings.TrimSpace(grantedScopeStr)
243- switch scopeStr {
244- case domain.ScopeActionAll:
245- return true
246- case domain.ScopeActionPull, domain.ScopeActionPush:
247- if scopeStr == action {
248- return true
249- }
250- }
251-
252- scope, err := domain.ParseScope(scopeStr)
253- if err != nil {
254- continue
255- }
256- if scope.Type != domain.ScopeTypeRepository {
257- continue
258- }
259- if scope.CanAccess(repoName, action) {
260- return true
261- }
262- }
263-
264- return false
265-}
266-
267-func hasGrantedAdminAccess(grantedScopes []string, resource, action string) bool {
268- for _, grantedScopeStr := range grantedScopes {
269- scopeStr := strings.TrimSpace(grantedScopeStr)
270- if scopeStr == domain.ScopeActionAll {
271- return true
272- }
273-
274- adminScope, err := domain.ParseAdminScope(scopeStr)
275- if err != nil {
276- continue
277- }
278-
279- if adminScope.CanAccess(resource, action) {
280- return true
281- }
282- }
283-
284- return false
285-}
286-
287240 // parseRequestedScopes extracts and validates scope parameters from the request.
288241 // Per Docker Registry v2 auth spec, scope format is: repository:name:actions
289242 // Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull
@@ -193,7 +193,7 @@ func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []stri
193 case domain.ScopeTypeRepository:193 case domain.ScopeTypeRepository:
194 allowedActions := make([]string, 0, len(reqScope.Actions))194 allowedActions := make([]string, 0, len(reqScope.Actions))
195 for _, action := range reqScope.Actions {195 for _, action := range reqScope.Actions {
196- if hasGrantedRegistryAccess(grantedScopes, reqScope.Name, action) {196+ if domain.ScopesGrantRegistryAccess(grantedScopes, reqScope.Name, action) {
197 allowedActions = append(allowedActions, action)197 allowedActions = append(allowedActions, action)
198 }198 }
199 }199 }
@@ -211,7 +211,7 @@ func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []stri
211 case domain.ScopeTypeAdmin:211 case domain.ScopeTypeAdmin:
212 allowedActions := make([]string, 0, len(reqScope.Actions))212 allowedActions := make([]string, 0, len(reqScope.Actions))
213 for _, action := range reqScope.Actions {213 for _, action := range reqScope.Actions {
214- if hasGrantedAdminAccess(grantedScopes, reqScope.Name, action) {214+ if domain.ScopesGrantAdminAccess(grantedScopes, reqScope.Name, action) {
215 allowedActions = append(allowedActions, action)215 allowedActions = append(allowedActions, action)
216 }216 }
217 }217 }
@@ -237,53 +237,6 @@ func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []stri
237 return effective237 return effective
238 }238 }
239 239
240-func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) bool {
241- for _, grantedScopeStr := range grantedScopes {
242- scopeStr := strings.TrimSpace(grantedScopeStr)
243- switch scopeStr {
244- case domain.ScopeActionAll:
245- return true
246- case domain.ScopeActionPull, domain.ScopeActionPush:
247- if scopeStr == action {
248- return true
249- }
250- }
251-
252- scope, err := domain.ParseScope(scopeStr)
253- if err != nil {
254- continue
255- }
256- if scope.Type != domain.ScopeTypeRepository {
257- continue
258- }
259- if scope.CanAccess(repoName, action) {
260- return true
261- }
262- }
263-
264- return false
265-}
266-
267-func hasGrantedAdminAccess(grantedScopes []string, resource, action string) bool {
268- for _, grantedScopeStr := range grantedScopes {
269- scopeStr := strings.TrimSpace(grantedScopeStr)
270- if scopeStr == domain.ScopeActionAll {
271- return true
272- }
273-
274- adminScope, err := domain.ParseAdminScope(scopeStr)
275- if err != nil {
276- continue
277- }
278-
279- if adminScope.CanAccess(resource, action) {
280- return true
281- }
282- }
283-
284- return false
285-}
286-
287 // parseRequestedScopes extracts and validates scope parameters from the request.240 // parseRequestedScopes extracts and validates scope parameters from the request.
288 // Per Docker Registry v2 auth spec, scope format is: repository:name:actions241 // Per Docker Registry v2 auth spec, scope format is: repository:name:actions
289 // Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull242 // Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull
M internal/adapters/in/http/middleware/auth.go +8 -26
@@ -249,32 +249,14 @@ func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.
249249 return true
250250 }
251251
252- // Check if any token scope grants access
253- for _, scopeStr := range claims.Scopes {
254- // Handle simple scopes (e.g., "push", "pull") for backwards compatibility
255- if scopeStr == action || scopeStr == "*" {
256- log.Debug().
257- Str("repo", repoName).
258- Str("action", action).
259- Str("scope", scopeStr).
260- Msg("simple scope access granted")
261- return true
262- }
263-
264- // Handle Docker v2 format scopes (e.g., "repository:myrepo:push,pull")
265- scope, err := domain.ParseScope(scopeStr)
266- if err != nil {
267- continue
268- }
269-
270- if scope.CanAccess(repoName, action) {
271- log.Debug().
272- Str("repo", repoName).
273- Str("action", action).
274- Str("scope", scopeStr).
275- Msg("scope access granted")
276- return true
277- }
252+ // Delegate matching to domain layer
253+ if domain.ScopesGrantRegistryAccess(claims.Scopes, repoName, action) {
254+ log.Debug().
255+ Str("repo", repoName).
256+ Str("action", action).
257+ Strs("scopes", claims.Scopes).
258+ Msg("scope access granted")
259+ return true
278260 }
279261
280262 log.Debug().
@@ -249,32 +249,14 @@ func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.
249 return true249 return true
250 }250 }
251 251
252- // Check if any token scope grants access252+ // Delegate matching to domain layer
253- for _, scopeStr := range claims.Scopes {253+ if domain.ScopesGrantRegistryAccess(claims.Scopes, repoName, action) {
254- // Handle simple scopes (e.g., "push", "pull") for backwards compatibility254+ log.Debug().
255- if scopeStr == action || scopeStr == "*" {255+ Str("repo", repoName).
256- log.Debug().256+ Str("action", action).
257- Str("repo", repoName).257+ Strs("scopes", claims.Scopes).
258- Str("action", action).258+ Msg("scope access granted")
259- Str("scope", scopeStr).259+ return true
260- Msg("simple scope access granted")
261- return true
262- }
263-
264- // Handle Docker v2 format scopes (e.g., "repository:myrepo:push,pull")
265- scope, err := domain.ParseScope(scopeStr)
266- if err != nil {
267- continue
268- }
269-
270- if scope.CanAccess(repoName, action) {
271- log.Debug().
272- Str("repo", repoName).
273- Str("action", action).
274- Str("scope", scopeStr).
275- Msg("scope access granted")
276- return true
277- }
278 }260 }
279 261
280 log.Debug().262 log.Debug().
M internal/domain/auth.go +40 -4
@@ -218,21 +218,57 @@ func (s *AdminScope) String() string {
218218
219219 // HasAdminAccess checks if a list of scope strings grants admin access to the resource and action.
220220 func HasAdminAccess(scopes []string, resource, action string) bool {
221- for _, scopeStr := range scopes {
221+ return ScopesGrantAdminAccess(scopes, resource, action)
222+}
223+
224+// ScopesGrantRegistryAccess reports whether any of the granted scope strings
225+// authorise the given action on the named repository.
226+// It handles simple shorthand scopes ("pull", "push", "*") for backwards
227+// compatibility as well as full Docker v2 format scopes (repository:name:actions).
228+func ScopesGrantRegistryAccess(grantedScopes []string, repoName, action string) bool {
229+ for _, raw := range grantedScopes {
230+ scopeStr := strings.TrimSpace(raw)
231+
232+ // Shorthand scopes: "*", "pull", "push"
233+ switch scopeStr {
234+ case ScopeActionAll:
235+ return true
236+ case ScopeActionPull, ScopeActionPush:
237+ if scopeStr == action {
238+ return true
239+ }
240+ }
241+
242+ // Full Docker v2 format: repository:name:actions
222243 scope, err := ParseScope(scopeStr)
223244 if err != nil {
224245 continue
225246 }
226-
227- if !scope.IsAdminScope() {
247+ if scope.Type != ScopeTypeRepository {
228248 continue
229249 }
250+ if scope.CanAccess(repoName, action) {
251+ return true
252+ }
253+ }
254+ return false
255+}
256+
257+// ScopesGrantAdminAccess reports whether any of the granted scope strings
258+// authorise the given action on the named admin resource.
259+// It handles the wildcard shorthand ("*") as well as full admin scope format
260+// (admin:resource:actions).
261+func ScopesGrantAdminAccess(grantedScopes []string, resource, action string) bool {
262+ for _, raw := range grantedScopes {
263+ scopeStr := strings.TrimSpace(raw)
264+ if scopeStr == ScopeActionAll {
265+ return true
266+ }
230267
231268 adminScope, err := ParseAdminScope(scopeStr)
232269 if err != nil {
233270 continue
234271 }
235-
236272 if adminScope.CanAccess(resource, action) {
237273 return true
238274 }
@@ -218,21 +218,57 @@ func (s *AdminScope) String() string {
218 218
219 // HasAdminAccess checks if a list of scope strings grants admin access to the resource and action.219 // HasAdminAccess checks if a list of scope strings grants admin access to the resource and action.
220 func HasAdminAccess(scopes []string, resource, action string) bool {220 func HasAdminAccess(scopes []string, resource, action string) bool {
221- for _, scopeStr := range scopes {221+ return ScopesGrantAdminAccess(scopes, resource, action)
222+}
223+
224+// ScopesGrantRegistryAccess reports whether any of the granted scope strings
225+// authorise the given action on the named repository.
226+// It handles simple shorthand scopes ("pull", "push", "*") for backwards
227+// compatibility as well as full Docker v2 format scopes (repository:name:actions).
228+func ScopesGrantRegistryAccess(grantedScopes []string, repoName, action string) bool {
229+ for _, raw := range grantedScopes {
230+ scopeStr := strings.TrimSpace(raw)
231+
232+ // Shorthand scopes: "*", "pull", "push"
233+ switch scopeStr {
234+ case ScopeActionAll:
235+ return true
236+ case ScopeActionPull, ScopeActionPush:
237+ if scopeStr == action {
238+ return true
239+ }
240+ }
241+
242+ // Full Docker v2 format: repository:name:actions
222 scope, err := ParseScope(scopeStr)243 scope, err := ParseScope(scopeStr)
223 if err != nil {244 if err != nil {
224 continue245 continue
225 }246 }
226-247+ if scope.Type != ScopeTypeRepository {
227- if !scope.IsAdminScope() {
228 continue248 continue
229 }249 }
250+ if scope.CanAccess(repoName, action) {
251+ return true
252+ }
253+ }
254+ return false
255+}
256+
257+// ScopesGrantAdminAccess reports whether any of the granted scope strings
258+// authorise the given action on the named admin resource.
259+// It handles the wildcard shorthand ("*") as well as full admin scope format
260+// (admin:resource:actions).
261+func ScopesGrantAdminAccess(grantedScopes []string, resource, action string) bool {
262+ for _, raw := range grantedScopes {
263+ scopeStr := strings.TrimSpace(raw)
264+ if scopeStr == ScopeActionAll {
265+ return true
266+ }
230 267
231 adminScope, err := ParseAdminScope(scopeStr)268 adminScope, err := ParseAdminScope(scopeStr)
232 if err != nil {269 if err != nil {
233 continue270 continue
234 }271 }
235-
236 if adminScope.CanAccess(resource, action) {272 if adminScope.CanAccess(resource, action) {
237 return true273 return true
238 }274 }
M internal/domain/auth_test.go +197 -0
@@ -325,6 +325,203 @@ func TestScope_String(t *testing.T) {
325325 }
326326 }
327327
328+func TestScopesGrantRegistryAccess(t *testing.T) {
329+ tests := []struct {
330+ name string
331+ granted []string
332+ repoName string
333+ action string
334+ want bool
335+ }{
336+ {
337+ name: "wildcard shorthand grants any action",
338+ granted: []string{"*"},
339+ repoName: "myrepo",
340+ action: "push",
341+ want: true,
342+ },
343+ {
344+ name: "pull shorthand grants pull",
345+ granted: []string{"pull"},
346+ repoName: "myrepo",
347+ action: "pull",
348+ want: true,
349+ },
350+ {
351+ name: "pull shorthand does not grant push",
352+ granted: []string{"pull"},
353+ repoName: "myrepo",
354+ action: "push",
355+ want: false,
356+ },
357+ {
358+ name: "push shorthand grants push",
359+ granted: []string{"push"},
360+ repoName: "myrepo",
361+ action: "push",
362+ want: true,
363+ },
364+ {
365+ name: "full scope grants matching repo and action",
366+ granted: []string{"repository:myrepo:push,pull"},
367+ repoName: "myrepo",
368+ action: "pull",
369+ want: true,
370+ },
371+ {
372+ name: "full scope does not grant different repo",
373+ granted: []string{"repository:myrepo:push,pull"},
374+ repoName: "other",
375+ action: "pull",
376+ want: false,
377+ },
378+ {
379+ name: "wildcard repo scope grants any repo",
380+ granted: []string{"repository:*:pull"},
381+ repoName: "anything",
382+ action: "pull",
383+ want: true,
384+ },
385+ {
386+ name: "org wildcard scope grants repo in org",
387+ granted: []string{"repository:myorg/*:push"},
388+ repoName: "myorg/app",
389+ action: "push",
390+ want: true,
391+ },
392+ {
393+ name: "org wildcard scope rejects repo outside org",
394+ granted: []string{"repository:myorg/*:push"},
395+ repoName: "other/app",
396+ action: "push",
397+ want: false,
398+ },
399+ {
400+ name: "admin scope is ignored for registry access",
401+ granted: []string{"admin:routes:read"},
402+ repoName: "myrepo",
403+ action: "pull",
404+ want: false,
405+ },
406+ {
407+ name: "no granted scopes",
408+ granted: []string{},
409+ repoName: "myrepo",
410+ action: "pull",
411+ want: false,
412+ },
413+ {
414+ name: "whitespace around scope is trimmed",
415+ granted: []string{" repository:myrepo:pull "},
416+ repoName: "myrepo",
417+ action: "pull",
418+ want: true,
419+ },
420+ {
421+ name: "invalid scope string is skipped",
422+ granted: []string{"garbage", "repository:myrepo:pull"},
423+ repoName: "myrepo",
424+ action: "pull",
425+ want: true,
426+ },
427+ }
428+
429+ for _, tt := range tests {
430+ t.Run(tt.name, func(t *testing.T) {
431+ got := ScopesGrantRegistryAccess(tt.granted, tt.repoName, tt.action)
432+ assert.Equal(t, tt.want, got)
433+ })
434+ }
435+}
436+
437+func TestScopesGrantAdminAccess(t *testing.T) {
438+ tests := []struct {
439+ name string
440+ granted []string
441+ resource string
442+ action string
443+ want bool
444+ }{
445+ {
446+ name: "wildcard shorthand grants any admin access",
447+ granted: []string{"*"},
448+ resource: "routes",
449+ action: "read",
450+ want: true,
451+ },
452+ {
453+ name: "exact admin scope grants matching resource and action",
454+ granted: []string{"admin:routes:read"},
455+ resource: "routes",
456+ action: "read",
457+ want: true,
458+ },
459+ {
460+ name: "exact admin scope denies wrong action",
461+ granted: []string{"admin:routes:read"},
462+ resource: "routes",
463+ action: "write",
464+ want: false,
465+ },
466+ {
467+ name: "exact admin scope denies wrong resource",
468+ granted: []string{"admin:routes:read"},
469+ resource: "secrets",
470+ action: "read",
471+ want: false,
472+ },
473+ {
474+ name: "admin wildcard resource grants any resource",
475+ granted: []string{"admin:*:read"},
476+ resource: "secrets",
477+ action: "read",
478+ want: true,
479+ },
480+ {
481+ name: "admin wildcard action grants any action",
482+ granted: []string{"admin:routes:*"},
483+ resource: "routes",
484+ action: "write",
485+ want: true,
486+ },
487+ {
488+ name: "full admin wildcard grants everything",
489+ granted: []string{"admin:*:*"},
490+ resource: "config",
491+ action: "write",
492+ want: true,
493+ },
494+ {
495+ name: "repository scope is ignored for admin access",
496+ granted: []string{"repository:myrepo:push"},
497+ resource: "routes",
498+ action: "read",
499+ want: false,
500+ },
501+ {
502+ name: "no granted scopes",
503+ granted: []string{},
504+ resource: "routes",
505+ action: "read",
506+ want: false,
507+ },
508+ {
509+ name: "whitespace around scope is trimmed",
510+ granted: []string{" admin:routes:read "},
511+ resource: "routes",
512+ action: "read",
513+ want: true,
514+ },
515+ }
516+
517+ for _, tt := range tests {
518+ t.Run(tt.name, func(t *testing.T) {
519+ got := ScopesGrantAdminAccess(tt.granted, tt.resource, tt.action)
520+ assert.Equal(t, tt.want, got)
521+ })
522+ }
523+}
524+
328525 func TestParseScope_RoundTrip(t *testing.T) {
329526 // Test that parsing and stringifying produces the same result
330527 inputs := []string{
@@ -325,6 +325,203 @@ func TestScope_String(t *testing.T) {
325 }325 }
326 }326 }
327 327
328+func TestScopesGrantRegistryAccess(t *testing.T) {
329+ tests := []struct {
330+ name string
331+ granted []string
332+ repoName string
333+ action string
334+ want bool
335+ }{
336+ {
337+ name: "wildcard shorthand grants any action",
338+ granted: []string{"*"},
339+ repoName: "myrepo",
340+ action: "push",
341+ want: true,
342+ },
343+ {
344+ name: "pull shorthand grants pull",
345+ granted: []string{"pull"},
346+ repoName: "myrepo",
347+ action: "pull",
348+ want: true,
349+ },
350+ {
351+ name: "pull shorthand does not grant push",
352+ granted: []string{"pull"},
353+ repoName: "myrepo",
354+ action: "push",
355+ want: false,
356+ },
357+ {
358+ name: "push shorthand grants push",
359+ granted: []string{"push"},
360+ repoName: "myrepo",
361+ action: "push",
362+ want: true,
363+ },
364+ {
365+ name: "full scope grants matching repo and action",
366+ granted: []string{"repository:myrepo:push,pull"},
367+ repoName: "myrepo",
368+ action: "pull",
369+ want: true,
370+ },
371+ {
372+ name: "full scope does not grant different repo",
373+ granted: []string{"repository:myrepo:push,pull"},
374+ repoName: "other",
375+ action: "pull",
376+ want: false,
377+ },
378+ {
379+ name: "wildcard repo scope grants any repo",
380+ granted: []string{"repository:*:pull"},
381+ repoName: "anything",
382+ action: "pull",
383+ want: true,
384+ },
385+ {
386+ name: "org wildcard scope grants repo in org",
387+ granted: []string{"repository:myorg/*:push"},
388+ repoName: "myorg/app",
389+ action: "push",
390+ want: true,
391+ },
392+ {
393+ name: "org wildcard scope rejects repo outside org",
394+ granted: []string{"repository:myorg/*:push"},
395+ repoName: "other/app",
396+ action: "push",
397+ want: false,
398+ },
399+ {
400+ name: "admin scope is ignored for registry access",
401+ granted: []string{"admin:routes:read"},
402+ repoName: "myrepo",
403+ action: "pull",
404+ want: false,
405+ },
406+ {
407+ name: "no granted scopes",
408+ granted: []string{},
409+ repoName: "myrepo",
410+ action: "pull",
411+ want: false,
412+ },
413+ {
414+ name: "whitespace around scope is trimmed",
415+ granted: []string{" repository:myrepo:pull "},
416+ repoName: "myrepo",
417+ action: "pull",
418+ want: true,
419+ },
420+ {
421+ name: "invalid scope string is skipped",
422+ granted: []string{"garbage", "repository:myrepo:pull"},
423+ repoName: "myrepo",
424+ action: "pull",
425+ want: true,
426+ },
427+ }
428+
429+ for _, tt := range tests {
430+ t.Run(tt.name, func(t *testing.T) {
431+ got := ScopesGrantRegistryAccess(tt.granted, tt.repoName, tt.action)
432+ assert.Equal(t, tt.want, got)
433+ })
434+ }
435+}
436+
437+func TestScopesGrantAdminAccess(t *testing.T) {
438+ tests := []struct {
439+ name string
440+ granted []string
441+ resource string
442+ action string
443+ want bool
444+ }{
445+ {
446+ name: "wildcard shorthand grants any admin access",
447+ granted: []string{"*"},
448+ resource: "routes",
449+ action: "read",
450+ want: true,
451+ },
452+ {
453+ name: "exact admin scope grants matching resource and action",
454+ granted: []string{"admin:routes:read"},
455+ resource: "routes",
456+ action: "read",
457+ want: true,
458+ },
459+ {
460+ name: "exact admin scope denies wrong action",
461+ granted: []string{"admin:routes:read"},
462+ resource: "routes",
463+ action: "write",
464+ want: false,
465+ },
466+ {
467+ name: "exact admin scope denies wrong resource",
468+ granted: []string{"admin:routes:read"},
469+ resource: "secrets",
470+ action: "read",
471+ want: false,
472+ },
473+ {
474+ name: "admin wildcard resource grants any resource",
475+ granted: []string{"admin:*:read"},
476+ resource: "secrets",
477+ action: "read",
478+ want: true,
479+ },
480+ {
481+ name: "admin wildcard action grants any action",
482+ granted: []string{"admin:routes:*"},
483+ resource: "routes",
484+ action: "write",
485+ want: true,
486+ },
487+ {
488+ name: "full admin wildcard grants everything",
489+ granted: []string{"admin:*:*"},
490+ resource: "config",
491+ action: "write",
492+ want: true,
493+ },
494+ {
495+ name: "repository scope is ignored for admin access",
496+ granted: []string{"repository:myrepo:push"},
497+ resource: "routes",
498+ action: "read",
499+ want: false,
500+ },
501+ {
502+ name: "no granted scopes",
503+ granted: []string{},
504+ resource: "routes",
505+ action: "read",
506+ want: false,
507+ },
508+ {
509+ name: "whitespace around scope is trimmed",
510+ granted: []string{" admin:routes:read "},
511+ resource: "routes",
512+ action: "read",
513+ want: true,
514+ },
515+ }
516+
517+ for _, tt := range tests {
518+ t.Run(tt.name, func(t *testing.T) {
519+ got := ScopesGrantAdminAccess(tt.granted, tt.resource, tt.action)
520+ assert.Equal(t, tt.want, got)
521+ })
522+ }
523+}
524+
328 func TestParseScope_RoundTrip(t *testing.T) {525 func TestParseScope_RoundTrip(t *testing.T) {
329 // Test that parsing and stringifying produces the same result526 // Test that parsing and stringifying produces the same result
330 inputs := []string{527 inputs := []string{

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 8

Tags 106

↑↓ moveenter openesc close