github.com/dtroyer-salad/og2/v2@v2.0.0-20240412154159-c47231610877/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 }