cuelabs.dev/go/oci/ociregistry@v0.0.0-20240906074133-82eb438dd565/ociauth/scope.go (about)

     1  package ociauth
     2  
     3  import (
     4  	"math/bits"
     5  	"slices"
     6  	"strings"
     7  )
     8  
     9  // knownAction represents an action that we know about
    10  // and use a more efficient internal representation for.
    11  type knownAction byte
    12  
    13  const (
    14  	unknownAction knownAction = iota
    15  	// Note: ordered by lexical string representation.
    16  	pullAction
    17  	pushAction
    18  	numActions
    19  )
    20  
    21  const (
    22  	// Known resource types.
    23  	TypeRepository = "repository"
    24  	TypeRegistry   = "registry"
    25  
    26  	// Known action types.
    27  	ActionPull = "pull"
    28  	ActionPush = "push"
    29  )
    30  
    31  func (a knownAction) String() string {
    32  	switch a {
    33  	case pullAction:
    34  		return ActionPull
    35  	case pushAction:
    36  		return ActionPush
    37  	default:
    38  		return "unknown"
    39  	}
    40  }
    41  
    42  // CatalogScope defines the resource scope used to allow
    43  // listing all the items in a registry.
    44  var CatalogScope = ResourceScope{
    45  	ResourceType: TypeRegistry,
    46  	Resource:     "catalog",
    47  	Action:       "*",
    48  }
    49  
    50  // ResourceScope defines a component of an authorization scope
    51  // associated with a single resource and action only.
    52  // See [Scope] for a way of combining multiple ResourceScopes
    53  // into a single value.
    54  type ResourceScope struct {
    55  	// ResourceType holds the type of resource the scope refers to.
    56  	// Known values for this include TypeRegistry and TypeRepository.
    57  	// When a scope does not conform to the standard resourceType:resource:actions
    58  	// syntax, ResourceType will hold the entire scope.
    59  	ResourceType string
    60  
    61  	// Resource names the resource the scope pertains to.
    62  	// For resource type TypeRepository, this will be the name of the repository.
    63  	Resource string
    64  
    65  	// Action names an action that can be performed on the resource.
    66  	// This is usually ActionPush or ActionPull.
    67  	Action string
    68  }
    69  
    70  func (rs1 ResourceScope) Equal(rs2 ResourceScope) bool {
    71  	return rs1.Compare(rs2) == 0
    72  }
    73  
    74  // Compare returns -1, 0 or 1 depending on whether
    75  // rs1 compares less than, equal, or greater than, rs2.
    76  //
    77  // In most to least precedence, the fields are compared in the order
    78  // ResourceType, Resource, Action.
    79  func (rs1 ResourceScope) Compare(rs2 ResourceScope) int {
    80  	if c := strings.Compare(rs1.ResourceType, rs2.ResourceType); c != 0 {
    81  		return c
    82  	}
    83  	if c := strings.Compare(rs1.Resource, rs2.Resource); c != 0 {
    84  		return c
    85  	}
    86  	return strings.Compare(rs1.Action, rs2.Action)
    87  }
    88  
    89  func (rs ResourceScope) isKnown() bool {
    90  	switch rs.ResourceType {
    91  	case TypeRepository:
    92  		return parseKnownAction(rs.Action) != unknownAction
    93  	case TypeRegistry:
    94  		return rs == CatalogScope
    95  	}
    96  	return false
    97  }
    98  
    99  // Scope holds a set of [ResourceScope] values. The zero value
   100  // represents the empty set.
   101  type Scope struct {
   102  	// original holds the original string from which
   103  	// this Scope was parsed. This maintains the string
   104  	// representation unchanged as far as possible.
   105  	original string
   106  
   107  	// unlimited holds whether this scope is considered to include all
   108  	// other scopes.
   109  	unlimited bool
   110  
   111  	// repositories holds all the repositories that the scope
   112  	// refers to. An empty repository name implies a CatalogScope
   113  	// entry. The elements of this are maintained in sorted order.
   114  	repositories []string
   115  
   116  	// actions holds an element for each element in repositories
   117  	// defining the set of allowed actions for that repository
   118  	// as a bitmask of 1<<knownAction bytes.
   119  	// For CatalogScope, this is 1<<pullAction so that
   120  	// the bit count reflects the number of resource scopes.
   121  	actions []byte
   122  
   123  	// others holds actions that don't fit into
   124  	// the above categories. These may or may not be repository-scoped:
   125  	// we just store them here verbatim.
   126  	others []ResourceScope
   127  }
   128  
   129  // ParseScope parses a scope as defined in the [Docker distribution spec].
   130  //
   131  // For scopes that don't fit that syntax, it returns a Scope with
   132  // the ResourceType field set to the whole string.
   133  //
   134  // [Docker distribution spec]: https://distribution.github.io/distribution/spec/auth/scope/
   135  func ParseScope(s string) Scope {
   136  	fields := strings.Fields(s)
   137  	rscopes := make([]ResourceScope, 0, len(fields))
   138  	for _, f := range fields {
   139  		parts := strings.Split(f, ":")
   140  		if len(parts) != 3 {
   141  			rscopes = append(rscopes, ResourceScope{
   142  				ResourceType: f,
   143  			})
   144  			continue
   145  		}
   146  		for _, action := range strings.Split(parts[2], ",") {
   147  			rscopes = append(rscopes, ResourceScope{
   148  				ResourceType: parts[0],
   149  				Resource:     parts[1],
   150  				Action:       action,
   151  			})
   152  		}
   153  	}
   154  	scope := NewScope(rscopes...)
   155  	scope.original = s
   156  	return scope
   157  }
   158  
   159  // NewScope returns a Scope value that holds the set of everything in rss.
   160  func NewScope(rss ...ResourceScope) Scope {
   161  	// TODO it might well be worth special-casing the single element scope case.
   162  	slices.SortFunc(rss, ResourceScope.Compare)
   163  	rss = slices.Compact(rss)
   164  	var s Scope
   165  	for _, rs := range rss {
   166  		if !rs.isKnown() {
   167  			s.others = append(s.others, rs)
   168  			continue
   169  		}
   170  		if rs.ResourceType == TypeRegistry {
   171  			// CatalogScope
   172  			s.repositories = append(s.repositories, "")
   173  			s.actions = append(s.actions, 1<<pullAction)
   174  			continue
   175  		}
   176  		actionMask := byte(1 << parseKnownAction(rs.Action))
   177  		if i := len(s.repositories); i > 0 && s.repositories[i-1] == rs.Resource {
   178  			s.actions[i-1] |= actionMask
   179  		} else {
   180  			s.repositories = append(s.repositories, rs.Resource)
   181  			s.actions = append(s.actions, actionMask)
   182  		}
   183  	}
   184  	slices.SortFunc(s.others, ResourceScope.Compare)
   185  	s.others = slices.Compact(s.others)
   186  	return s
   187  }
   188  
   189  // Len returns the number of ResourceScopes in the scope set.
   190  // It panics if the scope is unlimited.
   191  func (s Scope) Len() int {
   192  	if s.IsUnlimited() {
   193  		panic("Len called on unlimited scope")
   194  	}
   195  	n := len(s.others)
   196  	for _, b := range s.actions {
   197  		n += bits.OnesCount8(b)
   198  	}
   199  	return n
   200  }
   201  
   202  // UnlimitedScope returns a scope that contains all other
   203  // scopes. This is not representable in the docker scope syntax,
   204  // but it's useful to represent the scope of tokens that can
   205  // be used for arbitrary access.
   206  func UnlimitedScope() Scope {
   207  	return Scope{
   208  		unlimited: true,
   209  	}
   210  }
   211  
   212  // IsUnlimited reports whether s is unlimited in scope.
   213  func (s Scope) IsUnlimited() bool {
   214  	return s.unlimited
   215  }
   216  
   217  // IsEmpty reports whether the scope holds the empty set.
   218  func (s Scope) IsEmpty() bool {
   219  	return len(s.repositories) == 0 &&
   220  		len(s.others) == 0 &&
   221  		!s.unlimited
   222  }
   223  
   224  // Iter returns an iterator over all the individual scopes that are
   225  // part of s. The items will be produced according to [Scope.Compare]
   226  // ordering.
   227  //
   228  // The unlimited scope does not yield any scopes.
   229  func (s Scope) Iter() func(yield func(ResourceScope) bool) {
   230  	return func(yield0 func(ResourceScope) bool) {
   231  		if s.unlimited {
   232  			return
   233  		}
   234  		others := s.others
   235  		yield := func(scope ResourceScope) bool {
   236  			// Yield any scopes from others that are ready to
   237  			// be produced, thus preserving ordering of all
   238  			// values in the iterator.
   239  			for len(others) > 0 && others[0].Compare(scope) < 0 {
   240  				if !yield0(others[0]) {
   241  					return false
   242  				}
   243  				others = others[1:]
   244  			}
   245  			return yield0(scope)
   246  		}
   247  		for i, repo := range s.repositories {
   248  			if repo == "" {
   249  				if !yield(CatalogScope) {
   250  					return
   251  				}
   252  				continue
   253  			}
   254  			acts := s.actions[i]
   255  			for k := knownAction(0); k < numActions; k++ {
   256  				if acts&(1<<k) == 0 {
   257  					continue
   258  				}
   259  				rscope := ResourceScope{
   260  					ResourceType: TypeRepository,
   261  					Resource:     repo,
   262  					Action:       k.String(),
   263  				}
   264  				if !yield(rscope) {
   265  					return
   266  				}
   267  			}
   268  		}
   269  		// Send any scopes in others that haven't already been sent.
   270  		for _, rscope := range others {
   271  			if !yield0(rscope) {
   272  				return
   273  			}
   274  		}
   275  	}
   276  }
   277  
   278  // Union returns a scope consisting of all the resource scopes from
   279  // both s1 and s2. If the result is the same as s1, its
   280  // string representation will also be the same as s1.
   281  func (s1 Scope) Union(s2 Scope) Scope {
   282  	if s1.IsUnlimited() || s2.IsUnlimited() {
   283  		return UnlimitedScope()
   284  	}
   285  	// Cheap test that we can return the original unchanged.
   286  	if s2.IsEmpty() || s1.Equal(s2) {
   287  		return s1
   288  	}
   289  	r := Scope{
   290  		repositories: make([]string, 0, len(s1.repositories)+len(s2.repositories)),
   291  		actions:      make([]byte, 0, len(s1.repositories)+len(s2.repositories)),
   292  		others:       make([]ResourceScope, 0, len(s1.others)+len(s2.others)),
   293  	}
   294  	i1, i2 := 0, 0
   295  	for i1 < len(s1.repositories) && i2 < len(s2.repositories) {
   296  		repo1, repo2 := s1.repositories[i1], s2.repositories[i2]
   297  
   298  		switch strings.Compare(repo1, repo2) {
   299  		case 0:
   300  			r.repositories = append(r.repositories, repo1)
   301  			r.actions = append(r.actions, s1.actions[i1]|s2.actions[i2])
   302  			i1++
   303  			i2++
   304  		case -1:
   305  			r.repositories = append(r.repositories, s1.repositories[i1])
   306  			r.actions = append(r.actions, s1.actions[i1])
   307  			i1++
   308  		case 1:
   309  			r.repositories = append(r.repositories, s2.repositories[i2])
   310  			r.actions = append(r.actions, s2.actions[i2])
   311  			i2++
   312  		default:
   313  			panic("unreachable")
   314  		}
   315  	}
   316  	switch {
   317  	case i1 < len(s1.repositories):
   318  		r.repositories = append(r.repositories, s1.repositories[i1:]...)
   319  		r.actions = append(r.actions, s1.actions[i1:]...)
   320  	case i2 < len(s2.repositories):
   321  		r.repositories = append(r.repositories, s2.repositories[i2:]...)
   322  		r.actions = append(r.actions, s2.actions[i2:]...)
   323  	}
   324  	i1, i2 = 0, 0
   325  	for i1 < len(s1.others) && i2 < len(s2.others) {
   326  		a1, a2 := s1.others[i1], s2.others[i2]
   327  		switch a1.Compare(a2) {
   328  		case 0:
   329  			r.others = append(r.others, a1)
   330  			i1++
   331  			i2++
   332  		case -1:
   333  			r.others = append(r.others, a1)
   334  			i1++
   335  		case 1:
   336  			r.others = append(r.others, a2)
   337  			i2++
   338  		}
   339  	}
   340  	switch {
   341  	case i1 < len(s1.others):
   342  		r.others = append(r.others, s1.others[i1:]...)
   343  	case i2 < len(s2.others):
   344  		r.others = append(r.others, s2.others[i2:]...)
   345  	}
   346  	if r.Equal(s1) {
   347  		// Maintain the string representation.
   348  		return s1
   349  	}
   350  	return r
   351  }
   352  
   353  func (s Scope) Holds(r ResourceScope) bool {
   354  	if s.IsUnlimited() {
   355  		return true
   356  	}
   357  	if r == CatalogScope {
   358  		_, ok := slices.BinarySearch(s.repositories, "")
   359  		return ok
   360  	}
   361  	if r.ResourceType == TypeRepository {
   362  		if action := parseKnownAction(r.Action); action != unknownAction {
   363  			// It's a known action on a repository.
   364  			i, ok := slices.BinarySearch(s.repositories, r.Resource)
   365  			if !ok {
   366  				return false
   367  			}
   368  			return s.actions[i]&(1<<action) != 0
   369  		}
   370  	}
   371  	// We're either searching for an unknown resource type or
   372  	// an unknown action on a repository. In any case,
   373  	// we'll find the result in s.other.
   374  	_, ok := slices.BinarySearchFunc(s.others, r, ResourceScope.Compare)
   375  	return ok
   376  }
   377  
   378  // Contains reports whether s1 is a (non-strict) superset of s2.
   379  func (s1 Scope) Contains(s2 Scope) bool {
   380  	if s1.IsUnlimited() {
   381  		return true
   382  	}
   383  	if s2.IsUnlimited() {
   384  		return false
   385  	}
   386  	i1 := 0
   387  outer1:
   388  	for i2, repo2 := range s2.repositories {
   389  		for i1 < len(s1.repositories) {
   390  			switch repo1 := s1.repositories[i1]; strings.Compare(repo1, repo2) {
   391  			case 1:
   392  				// repo2 definitely doesn't exist in s1.
   393  				return false
   394  			case 0:
   395  				if (s1.actions[i1] & s2.actions[i2]) != s2.actions[i2] {
   396  					// s2's actions for this repo aren't in s1.
   397  					return false
   398  				}
   399  				i1++
   400  				continue outer1
   401  			case -1:
   402  				i1++
   403  				// continue looking through s1 for repo2.
   404  			}
   405  		}
   406  		// We ran out of repositories in s1 to look for.
   407  		return false
   408  	}
   409  	i1 = 0
   410  outer2:
   411  	for _, sc2 := range s2.others {
   412  		for i1 < len(s1.others) {
   413  			sc1 := s1.others[i1]
   414  			switch sc1.Compare(sc2) {
   415  			case 1:
   416  				return false
   417  			case 0:
   418  				i1++
   419  				continue outer2
   420  			case -1:
   421  				i1++
   422  			}
   423  		}
   424  		return false
   425  	}
   426  	return true
   427  }
   428  
   429  func (s1 Scope) Equal(s2 Scope) bool {
   430  	return s1.IsUnlimited() == s2.IsUnlimited() &&
   431  		slices.Equal(s1.repositories, s2.repositories) &&
   432  		slices.Equal(s1.actions, s2.actions) &&
   433  		slices.Equal(s1.others, s2.others)
   434  }
   435  
   436  // Canonical returns s with the same contents
   437  // but with its string form made canonical (the
   438  // default is to mirror exactly the string that it was
   439  // created with).
   440  func (s Scope) Canonical() Scope {
   441  	s.original = ""
   442  	return s
   443  }
   444  
   445  // String returns the string representation of the scope, as suitable
   446  // for passing to the token refresh "scopes" attribute.
   447  func (s Scope) String() string {
   448  	if s.IsUnlimited() {
   449  		// There's no official representation of this, but
   450  		// we shouldn't be passing an unlimited scope
   451  		// as a scopes attribute anyway.
   452  		return "*"
   453  	}
   454  	if s.original != "" || s.IsEmpty() {
   455  		return s.original
   456  	}
   457  	var buf strings.Builder
   458  	var prev ResourceScope
   459  	// TODO use range when we can use range-over-func.
   460  	s.Iter()(func(s ResourceScope) bool {
   461  		prev0 := prev
   462  		prev = s
   463  		if s.ResourceType == TypeRepository && prev0.ResourceType == TypeRepository && s.Resource == prev0.Resource {
   464  			buf.WriteByte(',')
   465  			buf.WriteString(s.Action)
   466  			return true
   467  		}
   468  		if buf.Len() > 0 {
   469  			buf.WriteByte(' ')
   470  		}
   471  		buf.WriteString(s.ResourceType)
   472  		if s.Resource != "" || s.Action != "" {
   473  			buf.WriteByte(':')
   474  			buf.WriteString(s.Resource)
   475  			buf.WriteByte(':')
   476  			buf.WriteString(s.Action)
   477  		}
   478  		return true
   479  	})
   480  	return buf.String()
   481  }
   482  
   483  func parseKnownAction(s string) knownAction {
   484  	switch s {
   485  	case ActionPull:
   486  		return pullAction
   487  	case ActionPush:
   488  		return pushAction
   489  	default:
   490  		return unknownAction
   491  	}
   492  }