oras.land/oras-go/v2@v2.5.1-0.20240520045656-aef90e4d04c4/registry/remote/auth/scope.go (about)

     1  /*
     2  Copyright The ORAS Authors.
     3  Licensed under the Apache License, Version 2.0 (the "License");
     4  you may not use this file except in compliance with the License.
     5  You may obtain a copy of the License at
     6  
     7  http://www.apache.org/licenses/LICENSE-2.0
     8  
     9  Unless required by applicable law or agreed to in writing, software
    10  distributed under the License is distributed on an "AS IS" BASIS,
    11  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  See the License for the specific language governing permissions and
    13  limitations under the License.
    14  */
    15  
    16  package auth
    17  
    18  import (
    19  	"context"
    20  	"slices"
    21  	"strings"
    22  
    23  	"oras.land/oras-go/v2/registry"
    24  )
    25  
    26  // Actions used in scopes.
    27  // Reference: https://docs.docker.com/registry/spec/auth/scope/
    28  const (
    29  	// ActionPull represents generic read access for resources of the repository
    30  	// type.
    31  	ActionPull = "pull"
    32  
    33  	// ActionPush represents generic write access for resources of the
    34  	// repository type.
    35  	ActionPush = "push"
    36  
    37  	// ActionDelete represents the delete permission for resources of the
    38  	// repository type.
    39  	ActionDelete = "delete"
    40  )
    41  
    42  // ScopeRegistryCatalog is the scope for registry catalog access.
    43  const ScopeRegistryCatalog = "registry:catalog:*"
    44  
    45  // ScopeRepository returns a repository scope with given actions.
    46  // Reference: https://docs.docker.com/registry/spec/auth/scope/
    47  func ScopeRepository(repository string, actions ...string) string {
    48  	actions = cleanActions(actions)
    49  	if repository == "" || len(actions) == 0 {
    50  		return ""
    51  	}
    52  	return strings.Join([]string{
    53  		"repository",
    54  		repository,
    55  		strings.Join(actions, ","),
    56  	}, ":")
    57  }
    58  
    59  // AppendRepositoryScope returns a new context containing scope hints for the
    60  // auth client to fetch bearer tokens with the given actions on the repository.
    61  // If called multiple times, the new scopes will be appended to the existing
    62  // scopes. The resulted scopes are de-duplicated.
    63  //
    64  // For example, uploading blob to the repository "hello-world" does HEAD request
    65  // first then POST and PUT. The HEAD request will return a challenge for scope
    66  // `repository:hello-world:pull`, and the auth client will fetch a token for
    67  // that challenge. Later, the POST request will return a challenge for scope
    68  // `repository:hello-world:push`, and the auth client will fetch a token for
    69  // that challenge again. By invoking AppendRepositoryScope with the actions
    70  // [ActionPull] and [ActionPush] for the repository `hello-world`,
    71  // the auth client with cache is hinted to fetch a token via a single token
    72  // fetch request for all the HEAD, POST, PUT requests.
    73  func AppendRepositoryScope(ctx context.Context, ref registry.Reference, actions ...string) context.Context {
    74  	if len(actions) == 0 {
    75  		return ctx
    76  	}
    77  	scope := ScopeRepository(ref.Repository, actions...)
    78  	return AppendScopesForHost(ctx, ref.Host(), scope)
    79  }
    80  
    81  // scopesContextKey is the context key for scopes.
    82  type scopesContextKey struct{}
    83  
    84  // WithScopes returns a context with scopes added. Scopes are de-duplicated.
    85  // Scopes are used as hints for the auth client to fetch bearer tokens with
    86  // larger scopes.
    87  //
    88  // For example, uploading blob to the repository "hello-world" does HEAD request
    89  // first then POST and PUT. The HEAD request will return a challenge for scope
    90  // `repository:hello-world:pull`, and the auth client will fetch a token for
    91  // that challenge. Later, the POST request will return a challenge for scope
    92  // `repository:hello-world:push`, and the auth client will fetch a token for
    93  // that challenge again. By invoking WithScopes with the scope
    94  // `repository:hello-world:pull,push`, the auth client with cache is hinted to
    95  // fetch a token via a single token fetch request for all the HEAD, POST, PUT
    96  // requests.
    97  //
    98  // Passing an empty list of scopes will virtually remove the scope hints in the
    99  // context.
   100  //
   101  // Reference: https://docs.docker.com/registry/spec/auth/scope/
   102  func WithScopes(ctx context.Context, scopes ...string) context.Context {
   103  	scopes = CleanScopes(scopes)
   104  	return context.WithValue(ctx, scopesContextKey{}, scopes)
   105  }
   106  
   107  // AppendScopes appends additional scopes to the existing scopes in the context
   108  // and returns a new context. The resulted scopes are de-duplicated.
   109  // The append operation does modify the existing scope in the context passed in.
   110  func AppendScopes(ctx context.Context, scopes ...string) context.Context {
   111  	if len(scopes) == 0 {
   112  		return ctx
   113  	}
   114  	return WithScopes(ctx, append(GetScopes(ctx), scopes...)...)
   115  }
   116  
   117  // GetScopes returns the scopes in the context.
   118  func GetScopes(ctx context.Context) []string {
   119  	if scopes, ok := ctx.Value(scopesContextKey{}).([]string); ok {
   120  		return slices.Clone(scopes)
   121  	}
   122  	return nil
   123  }
   124  
   125  // scopesForHostContextKey is the context key for per-host scopes.
   126  type scopesForHostContextKey string
   127  
   128  // WithScopesForHost returns a context with per-host scopes added.
   129  // Scopes are de-duplicated.
   130  // Scopes are used as hints for the auth client to fetch bearer tokens with
   131  // larger scopes.
   132  //
   133  // For example, uploading blob to the repository "hello-world" does HEAD request
   134  // first then POST and PUT. The HEAD request will return a challenge for scope
   135  // `repository:hello-world:pull`, and the auth client will fetch a token for
   136  // that challenge. Later, the POST request will return a challenge for scope
   137  // `repository:hello-world:push`, and the auth client will fetch a token for
   138  // that challenge again. By invoking WithScopesForHost with the scope
   139  // `repository:hello-world:pull,push`, the auth client with cache is hinted to
   140  // fetch a token via a single token fetch request for all the HEAD, POST, PUT
   141  // requests.
   142  //
   143  // Passing an empty list of scopes will virtually remove the scope hints in the
   144  // context for the given host.
   145  //
   146  // Reference: https://docs.docker.com/registry/spec/auth/scope/
   147  func WithScopesForHost(ctx context.Context, host string, scopes ...string) context.Context {
   148  	scopes = CleanScopes(scopes)
   149  	return context.WithValue(ctx, scopesForHostContextKey(host), scopes)
   150  }
   151  
   152  // AppendScopesForHost appends additional scopes to the existing scopes
   153  // in the context for the given host and returns a new context.
   154  // The resulted scopes are de-duplicated.
   155  // The append operation does modify the existing scope in the context passed in.
   156  func AppendScopesForHost(ctx context.Context, host string, scopes ...string) context.Context {
   157  	if len(scopes) == 0 {
   158  		return ctx
   159  	}
   160  	oldScopes := GetScopesForHost(ctx, host)
   161  	return WithScopesForHost(ctx, host, append(oldScopes, scopes...)...)
   162  }
   163  
   164  // GetScopesForHost returns the scopes in the context for the given host,
   165  // excluding global scopes added by [WithScopes] and [AppendScopes].
   166  func GetScopesForHost(ctx context.Context, host string) []string {
   167  	if scopes, ok := ctx.Value(scopesForHostContextKey(host)).([]string); ok {
   168  		return slices.Clone(scopes)
   169  	}
   170  	return nil
   171  }
   172  
   173  // GetAllScopesForHost returns the scopes in the context for the given host,
   174  // including global scopes added by [WithScopes] and [AppendScopes].
   175  func GetAllScopesForHost(ctx context.Context, host string) []string {
   176  	scopes := GetScopesForHost(ctx, host)
   177  	globalScopes := GetScopes(ctx)
   178  
   179  	if len(scopes) == 0 {
   180  		return globalScopes
   181  	}
   182  	if len(globalScopes) == 0 {
   183  		return scopes
   184  	}
   185  	// re-clean the scopes
   186  	allScopes := append(scopes, globalScopes...)
   187  	return CleanScopes(allScopes)
   188  }
   189  
   190  // CleanScopes merges and sort the actions in ascending order if the scopes have
   191  // the same resource type and name. The final scopes are sorted in ascending
   192  // order. In other words, the scopes passed in are de-duplicated and sorted.
   193  // Therefore, the output of this function is deterministic.
   194  //
   195  // If there is a wildcard `*` in the action, other actions in the same resource
   196  // type and name are ignored.
   197  func CleanScopes(scopes []string) []string {
   198  	// fast paths
   199  	switch len(scopes) {
   200  	case 0:
   201  		return nil
   202  	case 1:
   203  		scope := scopes[0]
   204  		i := strings.LastIndex(scope, ":")
   205  		if i == -1 {
   206  			return []string{scope}
   207  		}
   208  		actionList := strings.Split(scope[i+1:], ",")
   209  		actionList = cleanActions(actionList)
   210  		if len(actionList) == 0 {
   211  			return nil
   212  		}
   213  		actions := strings.Join(actionList, ",")
   214  		scope = scope[:i+1] + actions
   215  		return []string{scope}
   216  	}
   217  
   218  	// slow path
   219  	var result []string
   220  
   221  	// merge recognizable scopes
   222  	resourceTypes := make(map[string]map[string]map[string]struct{})
   223  	for _, scope := range scopes {
   224  		// extract resource type
   225  		i := strings.Index(scope, ":")
   226  		if i == -1 {
   227  			result = append(result, scope)
   228  			continue
   229  		}
   230  		resourceType := scope[:i]
   231  
   232  		// extract resource name and actions
   233  		rest := scope[i+1:]
   234  		i = strings.LastIndex(rest, ":")
   235  		if i == -1 {
   236  			result = append(result, scope)
   237  			continue
   238  		}
   239  		resourceName := rest[:i]
   240  		actions := rest[i+1:]
   241  		if actions == "" {
   242  			// drop scope since no action found
   243  			continue
   244  		}
   245  
   246  		// add to the intermediate map for de-duplication
   247  		namedActions := resourceTypes[resourceType]
   248  		if namedActions == nil {
   249  			namedActions = make(map[string]map[string]struct{})
   250  			resourceTypes[resourceType] = namedActions
   251  		}
   252  		actionSet := namedActions[resourceName]
   253  		if actionSet == nil {
   254  			actionSet = make(map[string]struct{})
   255  			namedActions[resourceName] = actionSet
   256  		}
   257  		for _, action := range strings.Split(actions, ",") {
   258  			if action != "" {
   259  				actionSet[action] = struct{}{}
   260  			}
   261  		}
   262  	}
   263  
   264  	// reconstruct scopes
   265  	for resourceType, namedActions := range resourceTypes {
   266  		for resourceName, actionSet := range namedActions {
   267  			if len(actionSet) == 0 {
   268  				continue
   269  			}
   270  			var actions []string
   271  			for action := range actionSet {
   272  				if action == "*" {
   273  					actions = []string{"*"}
   274  					break
   275  				}
   276  				actions = append(actions, action)
   277  			}
   278  			slices.Sort(actions)
   279  			scope := resourceType + ":" + resourceName + ":" + strings.Join(actions, ",")
   280  			result = append(result, scope)
   281  		}
   282  	}
   283  
   284  	// sort and return
   285  	slices.Sort(result)
   286  	return result
   287  }
   288  
   289  // cleanActions removes the duplicated actions and sort in ascending order.
   290  // If there is a wildcard `*` in the action, other actions are ignored.
   291  func cleanActions(actions []string) []string {
   292  	// fast paths
   293  	switch len(actions) {
   294  	case 0:
   295  		return nil
   296  	case 1:
   297  		if actions[0] == "" {
   298  			return nil
   299  		}
   300  		return actions
   301  	}
   302  
   303  	// slow path
   304  	slices.Sort(actions)
   305  	n := 0
   306  	for i := 0; i < len(actions); i++ {
   307  		if actions[i] == "*" {
   308  			return []string{"*"}
   309  		}
   310  		if actions[i] != actions[n] {
   311  			n++
   312  			if n != i {
   313  				actions[n] = actions[i]
   314  			}
   315  		}
   316  	}
   317  	n++
   318  	if actions[0] == "" {
   319  		if n == 1 {
   320  			return nil
   321  		}
   322  		return actions[1:n]
   323  	}
   324  	return actions[:n]
   325  }