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 }