Skip to content

Commit 202cad1

Browse files
Copilotjonesbusy
andauthored
Support hierarchical credentials on AuthStore (per namespace, per repository etc) (#588)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jonesbusy <825750+jonesbusy@users.noreply.github.com>
1 parent 2941219 commit 202cad1

2 files changed

Lines changed: 114 additions & 6 deletions

File tree

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

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -209,22 +209,45 @@ public static Config load(List<ConfigFile> configFiles) throws OrasException {
209209

210210
/**
211211
* Retrieves the {@code Credential} associated with the specified containerRef.
212+
* Implements hierarchical credential lookup from most-specific to least-specific.
213+
* For example, "my-registry.local/namespace/user/image:latest" is looked up as:
214+
* <ol>
215+
* <li>my-registry.local/namespace/user/image</li>
216+
* <li>my-registry.local/namespace/user</li>
217+
* <li>my-registry.local/namespace</li>
218+
* <li>my-registry.local</li>
219+
* </ol>
212220
*
213221
* @param containerRef The containerRef whose credential is to be retrieved.
214222
* @return The {@code Credential} associated with the containerRef, or {@code null} if no credential is found.
215223
*/
216224
public @Nullable Credential getCredential(ContainerRef containerRef) throws OrasException {
217225
String registry = containerRef.getRegistry();
218226

219-
LOG.debug("Looking for credentials for registry '{}'", registry);
227+
// Start at the most specific key: registry/namespace/repository (or registry/repository)
228+
String key = registry + "/" + containerRef.getFullRepository();
220229

221-
// Check direct credential first
222-
Credential cred = credentialStore.get(registry);
223-
if (cred != null) {
224-
return cred;
230+
LOG.debug("Looking for credentials for containerRef starting at key '{}'", key);
231+
232+
// Iterate from most-specific to least-specific, stopping when only the registry remains
233+
while (!key.equals(registry)) {
234+
Credential cred = credentialStore.get(key);
235+
if (cred != null) {
236+
LOG.debug("Found credential for key '{}'", key);
237+
return cred;
238+
}
239+
// Remove the last path segment and continue with the less specific key
240+
key = key.substring(0, key.lastIndexOf('/'));
225241
}
226242

227-
// Then, try credential helper
243+
// Check the registry-only key
244+
Credential registryCred = credentialStore.get(key);
245+
if (registryCred != null) {
246+
LOG.debug("Found credential for registry '{}'", key);
247+
return registryCred;
248+
}
249+
250+
// Try credential helper scoped to the registry
228251
String helperSuffix = credentialHelperStore.get(registry);
229252
if (helperSuffix != null) {
230253
try {
@@ -234,6 +257,7 @@ public static Config load(List<ConfigFile> configFiles) throws OrasException {
234257
LOG.warn("Failed to get credential from helper for registry {}: {}", registry, e.getMessage());
235258
}
236259
}
260+
237261
// Finally, try all-registries helper
238262
helperSuffix = credentialHelperStore.get(ALL_REGISTRIES_HELPER);
239263
if (helperSuffix != null) {

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

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,90 @@ void testShouldReadCredentialsFromPodManConfig() throws Exception {
272272
});
273273
}
274274

275+
// language=json
276+
public static final String SAMPLE_HIERARCHICAL_CONFIG =
277+
"""
278+
{
279+
"auths": {
280+
"my-registry.local/namespace/user/image": {
281+
"auth": "dXNlcjE6cGFzczE="
282+
},
283+
"my-registry.local/namespace": {
284+
"auth": "dXNlcjM6cGFzczM="
285+
},
286+
"my-registry.local": {
287+
"auth": "dXNlcjI6cGFzczI="
288+
}
289+
}
290+
}
291+
""";
292+
293+
@Test
294+
void testHierarchicalCredentialLookupMostSpecific() throws Exception {
295+
Path configFile = tempDir.resolve("hierarchical-config.json");
296+
Files.writeString(configFile, SAMPLE_HIERARCHICAL_CONFIG);
297+
AuthStore store = AuthStore.newStore(List.of(configFile));
298+
299+
// Most specific key: my-registry.local/namespace/user/image
300+
AuthStore.Credential credential =
301+
store.get(ContainerRef.parse("my-registry.local/namespace/user/image:latest"));
302+
assertNotNull(credential);
303+
assertEquals("user1", credential.username());
304+
assertEquals("pass1", credential.password());
305+
}
306+
307+
@Test
308+
void testHierarchicalCredentialLookupNamespaceOnly() throws Exception {
309+
Path configFile = tempDir.resolve("hierarchical-config.json");
310+
Files.writeString(configFile, SAMPLE_HIERARCHICAL_CONFIG);
311+
AuthStore store = AuthStore.newStore(List.of(configFile));
312+
313+
// Credential stored at namespace level: my-registry.local/namespace
314+
// Image under that namespace but not exact-matched should fall back to namespace credential
315+
AuthStore.Credential credential =
316+
store.get(ContainerRef.parse("my-registry.local/namespace/other-image:latest"));
317+
assertNotNull(credential);
318+
assertEquals("user3", credential.username());
319+
assertEquals("pass3", credential.password());
320+
}
321+
322+
@Test
323+
void testHierarchicalCredentialLookupFallsBackToRegistry() throws Exception {
324+
Path configFile = tempDir.resolve("hierarchical-config.json");
325+
Files.writeString(configFile, SAMPLE_HIERARCHICAL_CONFIG);
326+
AuthStore store = AuthStore.newStore(List.of(configFile));
327+
328+
// Different image under the same registry falls back to registry-level credential
329+
AuthStore.Credential credential = store.get(ContainerRef.parse("my-registry.local/other/repo:latest"));
330+
assertNotNull(credential);
331+
assertEquals("user2", credential.username());
332+
assertEquals("pass2", credential.password());
333+
}
334+
335+
@Test
336+
void testHierarchicalCredentialLookupRegistryOnly() throws Exception {
337+
Path configFile = tempDir.resolve("hierarchical-config.json");
338+
Files.writeString(configFile, SAMPLE_HIERARCHICAL_CONFIG);
339+
AuthStore store = AuthStore.newStore(List.of(configFile));
340+
341+
// Image without namespace falls back to registry-level credential
342+
AuthStore.Credential credential = store.get(ContainerRef.parse("my-registry.local/image:latest"));
343+
assertNotNull(credential);
344+
assertEquals("user2", credential.username());
345+
assertEquals("pass2", credential.password());
346+
}
347+
348+
@Test
349+
void testHierarchicalCredentialLookupNoMatch() throws Exception {
350+
Path configFile = tempDir.resolve("hierarchical-config.json");
351+
Files.writeString(configFile, SAMPLE_HIERARCHICAL_CONFIG);
352+
AuthStore store = AuthStore.newStore(List.of(configFile));
353+
354+
// Unknown registry returns null
355+
AuthStore.Credential credential = store.get(ContainerRef.parse("unknown-registry.local/foo/bar:latest"));
356+
assertNull(credential);
357+
}
358+
275359
@Test
276360
void testWithoutXdgRuntimeDir() throws Exception {
277361
new EnvironmentVariables().remove("XDG_RUNTIME_DIR").execute(() -> {

0 commit comments

Comments
 (0)