Skip to content

Commit 22f29b0

Browse files
feat: make admin elevation scope configurable (#5816)
* feat: make admin elevation scope configurable The admin elevation in the oauth2 auth libs (ownership bypass in AuthorizationServiceImpl and the participant-context existence-skip in ServicePrincipalAuthenticationFilter) was hardcoded to `management-api:admin`. Add a `String... adminScopes` constructor to both (the no-arg / single-arg constructors keep the `management-api:admin` default), so downstream APIs with their own scope namespace (e.g. IdentityHub's `identity-api:admin` / `issuer-admin-api:admin`) can confer elevation within their namespace. Fully backward-compatible. Signed-off-by: Paul Latzelsperger <paul.latzelsperger@beardyinc.com> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * update version file --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e9bb30b commit 22f29b0

6 files changed

Lines changed: 117 additions & 6 deletions

File tree

extensions/common/api/management-api-configuration/src/main/resources/management-api-version.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
{
1515
"version": "5.0.0-beta",
1616
"urlPath": "/v5beta",
17-
"lastUpdated": "2026-06-11T09:00:00Z",
17+
"lastUpdated": "2026-06-11T16:00:00Z",
1818
"maturity": "beta"
1919
}
2020
]

extensions/common/auth/auth-authentication-oauth2-lib/src/main/java/org/eclipse/edc/api/authentication/filter/ServicePrincipalAuthenticationFilter.java

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@
2020
import jakarta.ws.rs.container.ContainerRequestFilter;
2121
import jakarta.ws.rs.core.Response;
2222
import jakarta.ws.rs.core.SecurityContext;
23+
import org.eclipse.edc.api.auth.spi.ManagementApiScopes;
2324
import org.eclipse.edc.api.auth.spi.ParticipantPrincipal;
2425
import org.eclipse.edc.api.auth.spi.ScopeMatcher;
2526
import org.eclipse.edc.participantcontext.spi.service.ParticipantContextService;
2627
import org.eclipse.edc.spi.iam.ClaimToken;
2728
import org.eclipse.edc.spi.result.Result;
2829

2930
import java.security.Principal;
31+
import java.util.List;
3032

3133
import static org.eclipse.edc.api.authentication.filter.Constants.REQUEST_PROPERTY_CLAIMS;
3234
import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.SCOPE;
@@ -42,9 +44,22 @@ public class ServicePrincipalAuthenticationFilter implements ContainerRequestFil
4244

4345
private final ParticipantContextService participantContextService;
4446
private final ScopeMatcher scopeMatcher = new ScopeMatcher();
47+
private final List<String> adminScopes;
4548

4649
public ServicePrincipalAuthenticationFilter(ParticipantContextService participantContextService) {
50+
this(participantContextService, ManagementApiScopes.ADMIN);
51+
}
52+
53+
/**
54+
* Creates a filter whose admin elevation is conferred by the supplied scopes.
55+
*
56+
* @param adminScopes the scopes that convey admin elevation. A token whose granted scope satisfies any of these is
57+
* treated as an (elevated) service account, so its {@code sub} need not reference an existing
58+
* participant context. Defaults to {@link ManagementApiScopes#ADMIN}.
59+
*/
60+
public ServicePrincipalAuthenticationFilter(ParticipantContextService participantContextService, String... adminScopes) {
4761
this.participantContextService = participantContextService;
62+
this.adminScopes = List.of(adminScopes);
4863
}
4964

5065
@Override
@@ -102,7 +117,7 @@ public String getAuthenticationScheme() {
102117
*/
103118
private Result<Void> isAuthorized(String scope, String subject) {
104119

105-
if (scopeMatcher.isAdmin(scope)) {
120+
if (isAdmin(scope)) {
106121
return Result.success();
107122
}
108123
if (subject == null) {
@@ -113,4 +128,8 @@ private Result<Void> isAuthorized(String scope, String subject) {
113128
}
114129
return Result.success();
115130
}
131+
132+
private boolean isAdmin(String grantedScopes) {
133+
return adminScopes.stream().anyMatch(adminScope -> scopeMatcher.isSatisfiedBy(adminScope, grantedScopes));
134+
}
116135
}

extensions/common/auth/auth-authentication-oauth2-lib/src/test/java/org/eclipse/edc/api/authentication/filter/ServicePrincipalAuthenticationFilterTest.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,38 @@ void filter_adminSubjectNotParticipantContext_isAllowed() {
100100
verify(request).setSecurityContext(argThat(sc -> sc.getUserPrincipal() instanceof ParticipantPrincipal));
101101
}
102102

103+
@Test
104+
void filter_customAdminScope_skipsExistenceCheck() {
105+
// a filter configured with a custom admin scope treats that scope (not management-api:admin) as elevated
106+
var customFilter = new ServicePrincipalAuthenticationFilter(participantContextService, "identity-api:admin");
107+
when(participantContextService.getParticipantContext(anyString())).thenReturn(ServiceResult.notFound("not a participant context"));
108+
var request = mock(ContainerRequestContext.class);
109+
when(request.getProperty(REQUEST_PROPERTY_CLAIMS)).thenReturn(ClaimToken.Builder.newInstance()
110+
.claim(SCOPE, "identity-api:admin")
111+
.claim(SUBJECT, "some-service-account")
112+
.build());
113+
114+
customFilter.filter(request);
115+
116+
verify(request).setSecurityContext(argThat(sc -> sc.getUserPrincipal() instanceof ParticipantPrincipal));
117+
}
118+
119+
@Test
120+
void filter_customAdminScope_managementAdminNotElevated() {
121+
// with a custom admin scope configured, a management-api:admin token is not elevated, so its subject must exist
122+
var customFilter = new ServicePrincipalAuthenticationFilter(participantContextService, "identity-api:admin");
123+
when(participantContextService.getParticipantContext(anyString())).thenReturn(ServiceResult.notFound("not a participant context"));
124+
var request = mock(ContainerRequestContext.class);
125+
when(request.getProperty(REQUEST_PROPERTY_CLAIMS)).thenReturn(ClaimToken.Builder.newInstance()
126+
.claim(SCOPE, ManagementApiScopes.ADMIN)
127+
.claim(SUBJECT, "some-service-account")
128+
.build());
129+
130+
customFilter.filter(request);
131+
132+
verify(request).abortWith(argThat(response -> response.getStatus() == Response.Status.UNAUTHORIZED.getStatusCode()));
133+
}
134+
103135
@Test
104136
void filter_claimsNotPresent() {
105137
var request = mock(ContainerRequestContext.class);

extensions/common/auth/auth-authorization-oauth2-lib/src/main/java/org/eclipse/edc/api/authorization/service/AuthorizationServiceImpl.java

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,37 @@
1616

1717
import jakarta.ws.rs.core.SecurityContext;
1818
import org.eclipse.edc.api.auth.spi.AuthorizationService;
19+
import org.eclipse.edc.api.auth.spi.ManagementApiScopes;
1920
import org.eclipse.edc.api.auth.spi.ParticipantPrincipal;
2021
import org.eclipse.edc.api.auth.spi.ScopeMatcher;
2122
import org.eclipse.edc.participantcontext.spi.types.ParticipantResource;
2223
import org.eclipse.edc.spi.result.ServiceResult;
2324

2425
import java.util.HashMap;
26+
import java.util.List;
2527
import java.util.Map;
2628
import java.util.Objects;
2729
import java.util.function.BiFunction;
2830

2931
public class AuthorizationServiceImpl implements AuthorizationService {
3032
private final Map<Class<?>, BiFunction<String, String, ParticipantResource>> resourceLookupFunctions = new HashMap<>();
3133
private final ScopeMatcher scopeMatcher = new ScopeMatcher();
34+
private final List<String> adminScopes;
35+
36+
public AuthorizationServiceImpl() {
37+
this(ManagementApiScopes.ADMIN);
38+
}
39+
40+
/**
41+
* Creates a service whose cross-participant (admin) elevation is conferred by the supplied scopes.
42+
*
43+
* @param adminScopes the scopes that convey cross-participant (admin) elevation. A principal whose granted scope
44+
* satisfies any of these bypasses the resource-ownership check. Defaults to
45+
* {@link ManagementApiScopes#ADMIN} when the no-arg constructor is used.
46+
*/
47+
public AuthorizationServiceImpl(String... adminScopes) {
48+
this.adminScopes = List.of(adminScopes);
49+
}
3250

3351
@Override
3452
public ServiceResult<Void> authorize(SecurityContext securityContext, String resourceOwnerId, String resourceId, Class<? extends ParticipantResource> resourceClass) {
@@ -49,8 +67,8 @@ public ServiceResult<Void> authorize(SecurityContext securityContext, String res
4967
return ServiceResult.notFound("No Resource of type '%s' with ID '%s' was found for owner '%s'.".formatted(resourceClass, resourceId, resourceOwnerId));
5068
}
5169

52-
// a principal holding the admin scope is elevated and may access resources across participant contexts
53-
if (principal instanceof ParticipantPrincipal participantPrincipal && scopeMatcher.isAdmin(participantPrincipal.scope())) {
70+
// a principal holding an admin scope is elevated and may access resources across participant contexts
71+
if (principal instanceof ParticipantPrincipal participantPrincipal && isAdmin(participantPrincipal.scope())) {
5472
return ServiceResult.success();
5573
}
5674

@@ -63,6 +81,10 @@ public ServiceResult<Void> authorize(SecurityContext securityContext, String res
6381

6482
}
6583

84+
private boolean isAdmin(String grantedScopes) {
85+
return adminScopes.stream().anyMatch(adminScope -> scopeMatcher.isSatisfiedBy(adminScope, grantedScopes));
86+
}
87+
6688
@Override
6789
public void addLookupFunction(Class<?> resourceClass, BiFunction<String, String, ParticipantResource> lookupFunction) {
6890
resourceLookupFunctions.put(resourceClass, lookupFunction);

extensions/common/auth/auth-authorization-oauth2-lib/src/test/java/org/eclipse/edc/api/authorization/service/AuthorizationServiceImplTest.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,42 @@ public String getParticipantContextId() {
123123
.satisfies(f -> assertThat(f.getReason()).isEqualTo(ServiceFailure.Reason.UNAUTHORIZED));
124124
}
125125

126+
@Test
127+
void authorize_whenCustomAdminScope_bypassesOwnership() {
128+
var service = new AuthorizationServiceImpl("identity-api:admin");
129+
service.addLookupFunction(TestResource.class, (owner, id) -> new AbstractParticipantResource() {
130+
@Override
131+
public String getParticipantContextId() {
132+
return "owner-id";
133+
}
134+
});
135+
var principal = new ParticipantPrincipal("a-different-principal", "identity-api:admin");
136+
var securityContext = mock(SecurityContext.class);
137+
when(securityContext.getUserPrincipal()).thenReturn(principal);
138+
139+
assertThat(service.authorize(securityContext, "owner-id", "test-resource-id", TestResource.class))
140+
.isSucceeded();
141+
}
142+
143+
@Test
144+
void authorize_whenCustomAdminScope_managementAdminDoesNotElevate() {
145+
// with a custom admin scope configured, the default management-api:admin no longer elevates
146+
var service = new AuthorizationServiceImpl("identity-api:admin");
147+
service.addLookupFunction(TestResource.class, (owner, id) -> new AbstractParticipantResource() {
148+
@Override
149+
public String getParticipantContextId() {
150+
return "owner-id";
151+
}
152+
});
153+
var principal = new ParticipantPrincipal("a-different-principal", "management-api:admin");
154+
var securityContext = mock(SecurityContext.class);
155+
when(securityContext.getUserPrincipal()).thenReturn(principal);
156+
157+
assertThat(service.authorize(securityContext, "owner-id", "test-resource-id", TestResource.class))
158+
.isFailed()
159+
.satisfies(f -> assertThat(f.getReason()).isEqualTo(ServiceFailure.Reason.UNAUTHORIZED));
160+
}
161+
126162
@Test
127163
void authorize_whenNoOwner() {
128164
authorizationService.addLookupFunction(TestResource.class, (owner, id) -> new AbstractParticipantResource() {

spi/common/auth-spi/src/main/java/org/eclipse/edc/api/auth/spi/ScopeMatcher.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
package org.eclipse.edc.api.auth.spi;
1616

17+
import org.eclipse.edc.spi.result.AbstractResult;
18+
1719
import java.util.Arrays;
1820
import java.util.List;
1921

@@ -54,8 +56,8 @@ private List<Scope> parse(String grantedScopes) {
5456
return Arrays.stream(grantedScopes.trim().split(SCOPE_SEPARATOR))
5557
.filter(s -> !s.isBlank())
5658
.map(Scope::parse)
57-
.filter(result -> result.succeeded())
58-
.map(result -> result.getContent())
59+
.filter(AbstractResult::succeeded)
60+
.map(AbstractResult::getContent)
5961
.toList();
6062
}
6163
}

0 commit comments

Comments
 (0)