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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 193 | 193 | case domain.ScopeTypeRepository: | |
| 194 | 194 | allowedActions := make([]string, 0, len(reqScope.Actions)) | |
| 195 | 195 | for _, action := range reqScope.Actions { | |
| 196 | - | if hasGrantedRegistryAccess(grantedScopes, reqScope.Name, action) { | |
| 196 | + | if domain.ScopesGrantRegistryAccess(grantedScopes, reqScope.Name, action) { | |
| 197 | 197 | allowedActions = append(allowedActions, action) | |
| 198 | 198 | } | |
| 199 | 199 | } | |
| @@ -211,7 +211,7 @@ func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []stri | |||
| 211 | 211 | case domain.ScopeTypeAdmin: | |
| 212 | 212 | allowedActions := make([]string, 0, len(reqScope.Actions)) | |
| 213 | 213 | for _, action := range reqScope.Actions { | |
| 214 | - | if hasGrantedAdminAccess(grantedScopes, reqScope.Name, action) { | |
| 214 | + | if domain.ScopesGrantAdminAccess(grantedScopes, reqScope.Name, action) { | |
| 215 | 215 | allowedActions = append(allowedActions, action) | |
| 216 | 216 | } | |
| 217 | 217 | } | |
| @@ -237,53 +237,6 @@ func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []stri | |||
| 237 | 237 | 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 | 240 | // parseRequestedScopes extracts and validates scope parameters from the request. | |
| 288 | 241 | // Per Docker Registry v2 auth spec, scope format is: repository:name:actions | |
| 289 | 242 | // 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 effective | 237 | 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:actions | 241 | // 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:pull | 242 | // 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. | |||
| 249 | 249 | return true | |
| 250 | 250 | } | |
| 251 | 251 | ||
| 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 | |
| 278 | 260 | } | |
| 279 | 261 | ||
| 280 | 262 | log.Debug(). | |
| @@ -249,32 +249,14 @@ func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap. | |||||
| 249 | return true | 249 | return true | ||
| 250 | } | 250 | } | ||
| 251 | 251 | ||||
| 252 | - | // Check if any token scope grants access | 252 | + | // 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 compatibility | 254 | + | 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 { | |||
| 218 | 218 | ||
| 219 | 219 | // HasAdminAccess checks if a list of scope strings grants admin access to the resource and action. | |
| 220 | 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 | 243 | scope, err := ParseScope(scopeStr) | |
| 223 | 244 | if err != nil { | |
| 224 | 245 | continue | |
| 225 | 246 | } | |
| 226 | - | ||
| 227 | - | if !scope.IsAdminScope() { | |
| 247 | + | if scope.Type != ScopeTypeRepository { | |
| 228 | 248 | 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 | 268 | adminScope, err := ParseAdminScope(scopeStr) | |
| 232 | 269 | if err != nil { | |
| 233 | 270 | continue | |
| 234 | 271 | } | |
| 235 | - | ||
| 236 | 272 | if adminScope.CanAccess(resource, action) { | |
| 237 | 273 | return true | |
| 238 | 274 | } | |
| @@ -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 | continue | 245 | continue | ||
| 225 | } | 246 | } | ||
| 226 | - | 247 | + | if scope.Type != ScopeTypeRepository { | |
| 227 | - | if !scope.IsAdminScope() { | |||
| 228 | continue | 248 | 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 | continue | 270 | continue | ||
| 234 | } | 271 | } | ||
| 235 | - | ||||
| 236 | if adminScope.CanAccess(resource, action) { | 272 | if adminScope.CanAccess(resource, action) { | ||
| 237 | return true | 273 | return true | ||
| 238 | } | 274 | } | ||
M internal/domain/auth_test.go +197 -0
| @@ -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 | 525 | func TestParseScope_RoundTrip(t *testing.T) { | |
| 329 | 526 | // Test that parsing and stringifying produces the same result | |
| 330 | 527 | 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 result | 526 | // Test that parsing and stringifying produces the same result | ||
| 330 | inputs := []string{ | 527 | inputs := []string{ | ||