Skip to content

Commit 79f879c

Browse files
authored
Global scopes and look for token with broader scope in the cache (#633)
Signed-off-by: Valentin Delaye <jonesbusy@users.noreply.github.com>
1 parent 756891c commit 79f879c

7 files changed

Lines changed: 257 additions & 41 deletions

File tree

src/main/java/land/oras/auth/HttpClient.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -486,11 +486,12 @@ private <T> ResponseWrapper<T> executeRequest(
486486

487487
// Get scope based on method
488488
ContainerRef containerRef = scopes.getContainerRef();
489+
LOG.debug("Scopes are adding registry scopes");
489490
Scopes newScopes =
490491
switch (method) {
491-
case "GET", "HEAD" -> scopes.withNewRegistryScopes(Scope.PULL);
492-
case "POST", "PUT", "PATCH" -> scopes.withNewRegistryScopes(Scope.PUSH);
493-
case "DELETE" -> scopes.withNewRegistryScopes(Scope.DELETE);
492+
case "GET", "HEAD" -> scopes.withAddedRegistryScopes(Scope.PULL);
493+
case "POST", "PUT", "PATCH" -> scopes.withAddedRegistryScopes(Scope.PUSH);
494+
case "DELETE" -> scopes.withAddedRegistryScopes(Scope.DELETE);
494495
default -> throw new OrasException("Unsupported HTTP method: " + method);
495496
};
496497

@@ -500,9 +501,9 @@ private <T> ResponseWrapper<T> executeRequest(
500501
// Check if token is present and reuse auth instead of passing auth provider
501502
TokenResponse cachedToken = TokenCache.get(newScopes);
502503
if (cachedToken == null) {
503-
LOG.trace("No cached token found for scopes: {}", newScopes);
504+
LOG.trace("No token found in cache for scopes: {}", newScopes);
504505
} else {
505-
LOG.trace("Found cached token for scopes: {}", newScopes.withService(cachedToken.service()));
506+
LOG.trace("Found token in cache for scopes: {}", newScopes.withService(cachedToken.service()));
506507
}
507508

508509
// Add authentication header if any (from provider or cached token)

src/main/java/land/oras/auth/Scope.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@
2525
*/
2626
public enum Scope {
2727

28+
/**
29+
* Scope for all actions.
30+
*/
31+
ALL,
32+
2833
/**
2934
* Scope for pulling images.
3035
*/
@@ -45,6 +50,9 @@ public enum Scope {
4550
* @return the lowercase name of the enum
4651
*/
4752
public String toLowerCase() {
53+
if (this == ALL) {
54+
return "*";
55+
}
4856
return name().toLowerCase();
4957
}
5058
}

src/main/java/land/oras/auth/Scopes.java

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,15 +111,53 @@ public Scopes withRegistryScopes(Scope... scopes) {
111111
}
112112

113113
/**
114-
* Return a new copy of the Scopes object with the given scopes
114+
* Return a new copy of the Scopes object with the added scopes
115115
* @param newScopes The scopes to add
116116
* @return A new Scopes object with the given scopes
117117
*/
118-
public Scopes withNewRegistryScopes(Scope... newScopes) {
118+
public Scopes withAddedRegistryScopes(Scope... newScopes) {
119119
return new Scopes(
120120
containerRef, service, ScopeUtils.appendRepositoryScope(this.scopes, containerRef, newScopes));
121121
}
122122

123+
/**
124+
* Return a new copy of the Scopes object with the given scopes
125+
* @param globalScopes The global scopes to add
126+
* @return A new Scopes object with the given scopes
127+
*/
128+
public Scopes withAddedGlobalScopes(String... globalScopes) {
129+
List<String> newScopes = new LinkedList<>(scopes);
130+
newScopes.addAll(List.of(globalScopes));
131+
return new Scopes(
132+
containerRef, service, newScopes.stream().sorted().distinct().toList());
133+
}
134+
135+
/**
136+
* Return scopes that only contains global scopes (i.e. no repository or registry scopes)
137+
* @return A new Scopes object with only global scopes
138+
*/
139+
public Scopes withOnlyGlobalScopes() {
140+
List<String> globalScopes = scopes.stream()
141+
.filter(s -> !s.startsWith("repository:") && !s.startsWith("registry:"))
142+
.sorted()
143+
.distinct()
144+
.toList();
145+
return new Scopes(containerRef, service, globalScopes);
146+
}
147+
148+
/**
149+
* Return scopes that only contains non-global scopes (i.e. only repository or registry scopes)
150+
* @return A new Scopes object with only non-global scopes
151+
*/
152+
public Scopes withoutGlobalScopes() {
153+
List<String> nonGlobalScopes = scopes.stream()
154+
.filter(s -> s.startsWith("repository:") || s.startsWith("registry:"))
155+
.sorted()
156+
.distinct()
157+
.toList();
158+
return new Scopes(containerRef, service, nonGlobalScopes);
159+
}
160+
123161
/**
124162
* Return a new copy of the Scopes object with the given scope
125163
* @param scope The scope to add
@@ -172,6 +210,31 @@ public ContainerRef getContainerRef() {
172210
return containerRef;
173211
}
174212

213+
/**
214+
* Return if these are global scopes (i.e. no repository or registry scopes)
215+
* Typically AWS ECR uses global scopes for authentication, while Docker Hub uses repository scopes.
216+
* @return True if these are global scopes, false otherwise
217+
*/
218+
public boolean isGlobal() {
219+
return scopes.stream().noneMatch(s -> s.startsWith("repository:") || s.startsWith("registry:"));
220+
}
221+
222+
/**
223+
* Return if these scopes include global scopes (i.e. at least one scope that is not a repository or registry scope)
224+
* @return True if these scopes include global scopes, false otherwise
225+
*/
226+
public boolean hasGlobalScopes() {
227+
return scopes.stream().anyMatch(s -> !s.startsWith("repository:") && !s.startsWith("registry:"));
228+
}
229+
230+
/**
231+
* Return if these are pull-only scopes (i.e. all scopes end with ":pull" and there are no global scopes)
232+
* @return True if these are pull-only scopes, false otherwise
233+
*/
234+
public boolean isPullOnly() {
235+
return !isGlobal() && scopes.stream().allMatch(s -> s.endsWith(":pull"));
236+
}
237+
175238
@Override
176239
public boolean equals(Object o) {
177240
if (o == null || getClass() != o.getClass()) return false;

src/main/java/land/oras/auth/TokenCache.java

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,21 @@ public long expireAfterRead(
8989
* @param token The token response to be cached
9090
*/
9191
public static void put(Scopes scopes, HttpClient.TokenResponse token) {
92-
LOG.trace("Caching token for scopes: {}", scopes);
93-
CACHE.put(scopes, token);
94-
if (scopes.getService() != null) {
95-
LOG.trace("Caching service for registry: {}", scopes.getRegistry());
96-
SERVICE_CACHE.put(scopes.getRegistry(), scopes.getService());
92+
Scopes newScopes = scopes.getService() != null ? scopes : scopes.withService(token.service());
93+
LOG.trace("Caching token for scopes: {}", newScopes);
94+
CACHE.put(newScopes, token);
95+
if (newScopes.getService() != null) {
96+
LOG.trace("Caching service '{}' for registry '{}'", newScopes.getService(), newScopes.getRegistry());
97+
SERVICE_CACHE.put(scopes.getRegistry(), newScopes.getService());
98+
}
99+
// Store global scopes if present for future lookups
100+
if (scopes.hasGlobalScopes()) {
101+
Scopes newScopesWithService =
102+
scopes.withService(newScopes.getService()).withOnlyGlobalScopes();
103+
CACHE.put(newScopesWithService, token);
104+
Scopes newScopesWithoutGlobal =
105+
scopes.withService(newScopes.getService()).withoutGlobalScopes();
106+
CACHE.put(newScopesWithoutGlobal, token);
97107
}
98108
}
99109

@@ -103,16 +113,50 @@ public static void put(Scopes scopes, HttpClient.TokenResponse token) {
103113
* @return the token response associated with the scopes, or null if not found or expired
104114
*/
105115
public static HttpClient.@Nullable TokenResponse get(Scopes scopes) {
116+
String service =
117+
scopes.getService() != null ? scopes.getService() : SERVICE_CACHE.getIfPresent(scopes.getRegistry());
106118
HttpClient.TokenResponse token = CACHE.getIfPresent(scopes);
107-
if (token == null) {
108-
// Lookup for specific service
109-
String service = SERVICE_CACHE.getIfPresent(scopes.getRegistry());
110-
if (service == null) {
111-
return null;
119+
if (token != null) {
120+
LOG.trace("Direct cache hit for scopes: {}", scopes);
121+
return token;
122+
}
123+
// Try lookup with service
124+
token = CACHE.getIfPresent(scopes.withService(service));
125+
if (token != null) {
126+
LOG.trace("Cache lookup for scopes: {}, found with service '{}'", scopes, service);
127+
return token;
128+
}
129+
// Check englobing scopes
130+
if (scopes.isPullOnly() && !scopes.hasGlobalScopes()) {
131+
Scopes newScopes = scopes.withService(service).withAddedRegistryScopes(Scope.PUSH); // Just add push scope
132+
token = CACHE.getIfPresent(newScopes);
133+
if (token != null) {
134+
LOG.trace("Cache lookup for scopes: {}, found with push scope", scopes);
135+
return token;
136+
}
137+
newScopes = newScopes.withService(service).withAddedRegistryScopes(Scope.DELETE); // Add delete scope
138+
token = CACHE.getIfPresent(newScopes);
139+
if (token != null) {
140+
LOG.trace("Cache lookup for scopes: {}, found with push and delete scopes", scopes);
141+
return token;
142+
}
143+
newScopes = newScopes.withService(service).withRegistryScopes(Scope.ALL); // All replace all scopes
144+
token = CACHE.getIfPresent(newScopes);
145+
if (token != null) {
146+
LOG.trace("Cache lookup for scopes: {}, found with all scopes", scopes);
147+
return token;
148+
}
149+
return null;
150+
}
151+
if (scopes.hasGlobalScopes()) {
152+
token = CACHE.getIfPresent(scopes.withOnlyGlobalScopes());
153+
if (token != null) {
154+
LOG.trace("Cache lookup for scopes: {}, found with only global scopes", scopes);
155+
return token;
112156
}
113-
return CACHE.getIfPresent(scopes.withService(service));
114157
}
115-
return token;
158+
LOG.trace("Cache miss for scopes: {}", scopes);
159+
return null;
116160
}
117161

118162
/**

src/test/java/land/oras/PublicECRITCase.java

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,13 @@
2222

2323
import static org.junit.jupiter.api.Assertions.*;
2424

25-
import java.nio.file.Files;
2625
import java.nio.file.Path;
2726
import java.util.List;
2827
import org.junit.jupiter.api.Disabled;
2928
import org.junit.jupiter.api.Test;
3029
import org.junit.jupiter.api.io.TempDir;
3130
import org.junit.jupiter.api.parallel.Execution;
3231
import org.junit.jupiter.api.parallel.ExecutionMode;
33-
import uk.org.webcompere.systemstubs.environment.EnvironmentVariables;
3432

3533
@Execution(ExecutionMode.SAME_THREAD)
3634
class PublicECRITCase {
@@ -69,25 +67,22 @@ void shouldRewriteDockerIOToPublicECR() throws Exception {
6967
location = "public.ecr.aws/docker/library"
7068
""";
7169

72-
Files.writeString(homeDir.resolve(".config").resolve("containers").resolve("registries.conf"), config);
73-
74-
new EnvironmentVariables()
75-
.set("HOME", homeDir.toAbsolutePath().toString())
76-
.execute(() -> {
77-
Registry registry = Registry.builder().defaults().build();
78-
ContainerRef containerRef = ContainerRef.parse("docker.io/library/alpine:latest");
79-
assertEquals("public.ecr.aws", containerRef.getEffectiveRegistry(registry));
80-
assertEquals(
81-
"public.ecr.aws/docker/library/alpine:latest",
82-
containerRef.forRegistry(registry).toString());
83-
assertEquals(
84-
"my-proxy/library/alpine:latest",
85-
containerRef
86-
.forRegistry(Registry.builder()
87-
.withRegistry("my-proxy")
88-
.build())
89-
.toString());
90-
});
70+
TestUtils.createRegistriesConfFile(homeDir, config);
71+
72+
TestUtils.withHome(homeDir, () -> {
73+
Registry registry = Registry.builder().defaults().build();
74+
ContainerRef containerRef = ContainerRef.parse("docker.io/library/alpine:latest");
75+
assertEquals("public.ecr.aws", containerRef.getEffectiveRegistry(registry));
76+
assertEquals(
77+
"public.ecr.aws/docker/library/alpine:latest",
78+
containerRef.forRegistry(registry).toString());
79+
assertEquals(
80+
"my-proxy/library/alpine:latest",
81+
containerRef
82+
.forRegistry(
83+
Registry.builder().withRegistry("my-proxy").build())
84+
.toString());
85+
});
9186
}
9287

9388
@Test

src/test/java/land/oras/auth/ScopesTest.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ void shouldBuildScopes() {
4848
assertEquals(
4949
"Scopes{scopes=[repository:library/test:pull], service='docker', registry=localhost:5000}",
5050
scopes.withService("docker").toString());
51+
assertFalse(scopes.isGlobal(), "Scopes should not be global");
52+
assertTrue(
53+
Scopes.empty(containerRef, "aws.public").withNewScope("aws").isGlobal(),
54+
"Scopes with global scope should be global");
55+
assertTrue(scopes.isPullOnly(), "Scopes with only pull scope should be pull-only");
56+
assertFalse(scopes.withRegistryScopes(Scope.PUSH).isPullOnly(), "Should not be pull only scope");
5157

5258
Scopes newScopes = scopes.withRegistryScopes(Scope.PUSH);
5359
assertNotSame(scopes, newScopes, "Scopes should be immutable");
@@ -57,7 +63,7 @@ void shouldBuildScopes() {
5763
assertEquals("localhost:5000", newScopes.getRegistry());
5864
assertEquals("localhost:5000", scopes.withService("localhost:5000").getService());
5965

60-
Scopes newScopes2 = newScopes.withNewRegistryScopes(Scope.PULL);
66+
Scopes newScopes2 = newScopes.withAddedRegistryScopes(Scope.PULL);
6167
assertNotSame(newScopes, newScopes2, "Scopes should be immutable");
6268
assertEquals(1, newScopes2.getScopes().size());
6369
assertEquals("repository:library/test:pull,push", newScopes2.getScopes().get(0));

0 commit comments

Comments
 (0)