This is an automated email from the ASF dual-hosted git repository.
yasithdev pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/airavata.git
The following commit(s) were added to refs/heads/master by this push:
new a8ce2ce516 feat(auth): verify access tokens and enforce gateway-admin
roles server-side (#688)
a8ce2ce516 is described below
commit a8ce2ce51613a183d88a7cd9c3561c1402806397
Author: Yasith Jayawardana <[email protected]>
AuthorDate: Sat Jun 13 23:36:29 2026 -0400
feat(auth): verify access tokens and enforce gateway-admin roles
server-side (#688)
The server previously trusted the bearer token without verifying its
signature
and enforced no coarse admin check, relying entirely on the portal. Verify
the
Keycloak access token (RS256 against the realm JWKS, exp, issuer) at the
shared
AuthTokenExtractor seam and extract realm_access.roles into the
RequestContext;
roles are only trusted from a signature-verified token, so a forged token
resolves to a non-admin. Add an AdminAccess guard (admin-rw / admin-ro) and
apply
it to the gateway-wide admin operations: IAM user listing, experiment
statistics,
and getExperimentByAdmin. Per-entity sharing ACLs are unchanged.
Also fixes GrpcStatusMapper, which only matched 'AuthorizationException'
and so
mapped the 63 ServiceAuthorizationException sites to INTERNAL instead of
PERMISSION_DENIED.
---
.../research/service/ExperimentService.java | 3 +
.../research/service/ExperimentServiceTest.java | 12 +--
.../java/org/apache/airavata/config/Constants.java | 6 ++
.../org/apache/airavata/config/RequestContext.java | 27 +++++
.../org/apache/airavata/config/UserContext.java | 10 ++
.../apache/airavata/grpc/GrpcRequestContext.java | 3 +-
.../org/apache/airavata/grpc/GrpcStatusMapper.java | 2 +-
.../java/org/apache/airavata/util/AdminAccess.java | 47 +++++++++
airavata-server/pom.xml | 5 +
.../server/grpc/config/AuthTokenExtractor.java | 8 +-
.../server/grpc/config/HttpAuthDecorator.java | 5 +-
.../airavata/server/grpc/config/JwtVerifier.java | 114 +++++++++++++++++++++
.../server/grpc/services/IamAdminGrpcService.java | 2 +
13 files changed, 231 insertions(+), 13 deletions(-)
diff --git
a/airavata-api/research-service/src/main/java/org/apache/airavata/research/service/ExperimentService.java
b/airavata-api/research-service/src/main/java/org/apache/airavata/research/service/ExperimentService.java
index 11922e3c1a..fba7a666f7 100644
---
a/airavata-api/research-service/src/main/java/org/apache/airavata/research/service/ExperimentService.java
+++
b/airavata-api/research-service/src/main/java/org/apache/airavata/research/service/ExperimentService.java
@@ -55,6 +55,7 @@ import org.apache.airavata.model.workspace.proto.Project;
import org.apache.airavata.sharing.registry.models.proto.EntitySearchField;
import org.apache.airavata.sharing.registry.models.proto.SearchCondition;
import org.apache.airavata.sharing.registry.models.proto.SearchCriteria;
+import org.apache.airavata.util.AdminAccess;
import org.apache.airavata.util.SharingHelper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -192,6 +193,7 @@ public class ExperimentService {
}
public ExperimentModel getExperimentByAdmin(RequestContext ctx, String
experimentId) throws ServiceException {
+ AdminAccess.requireAdminOrReadOnly(ctx);
try {
ExperimentModel experiment =
experimentRegistry.getExperiment(experimentId);
if (ctx.getGatewayId().equals(experiment.getGatewayId())) {
@@ -406,6 +408,7 @@ public class ExperimentService {
int limit,
int offset)
throws ServiceException {
+ AdminAccess.requireAdminOrReadOnly(ctx);
try {
return experimentRegistry.getExperimentStatistics(
gatewayId, fromTime, toTime, userName, applicationName,
resourceHostName, null, limit, offset);
diff --git
a/airavata-api/research-service/src/test/java/org/apache/airavata/research/service/ExperimentServiceTest.java
b/airavata-api/research-service/src/test/java/org/apache/airavata/research/service/ExperimentServiceTest.java
index 4b30f56b96..3f0a129404 100644
---
a/airavata-api/research-service/src/test/java/org/apache/airavata/research/service/ExperimentServiceTest.java
+++
b/airavata-api/research-service/src/test/java/org/apache/airavata/research/service/ExperimentServiceTest.java
@@ -83,13 +83,13 @@ class ExperimentServiceTest {
.thenReturn(true);
experimentService = new ExperimentService(
- experimentRegistry,
- appCatalogRegistry,
- projectRegistry,
- sharingHandler,
- java.util.Optional.empty());
+ experimentRegistry, appCatalogRegistry, projectRegistry,
sharingHandler, java.util.Optional.empty());
ctx = new RequestContext(
- "testUser", "testGateway", "token123", Map.of("userName",
"testUser", "gatewayId", "testGateway"));
+ "testUser",
+ "testGateway",
+ "token123",
+ Map.of("userName", "testUser", "gatewayId", "testGateway"),
+ java.util.List.of("admin-rw"));
}
@Test
diff --git
a/airavata-api/src/main/java/org/apache/airavata/config/Constants.java
b/airavata-api/src/main/java/org/apache/airavata/config/Constants.java
index 74f13efba1..eeb1f52dba 100644
--- a/airavata-api/src/main/java/org/apache/airavata/config/Constants.java
+++ b/airavata-api/src/main/java/org/apache/airavata/config/Constants.java
@@ -46,6 +46,12 @@ public final class Constants {
public static final String USER_NAME = "userName";
public static final String GATEWAY_ID = "gatewayID";
public static final String EMAIL = "email";
+ // Server-derived (from the verified access token), CSV-encoded; never
client-asserted.
+ public static final String REALM_ROLES = "realmRoles";
+
+ // Keycloak realm roles that map to the coarse gateway-admin capabilities.
+ public static final String ROLE_GATEWAY_ADMIN = "admin-rw";
+ public static final String ROLE_READ_ONLY_ADMIN = "admin-ro";
public static final String ENABLE_STREAMING_TRANSFER =
"enable.streaming.transfer";
}
diff --git
a/airavata-api/src/main/java/org/apache/airavata/config/RequestContext.java
b/airavata-api/src/main/java/org/apache/airavata/config/RequestContext.java
index 9b463702c3..d4a9965c3b 100644
--- a/airavata-api/src/main/java/org/apache/airavata/config/RequestContext.java
+++ b/airavata-api/src/main/java/org/apache/airavata/config/RequestContext.java
@@ -20,6 +20,7 @@
package org.apache.airavata.config;
import java.util.Collections;
+import java.util.List;
import java.util.Map;
public class RequestContext {
@@ -28,12 +29,19 @@ public class RequestContext {
private final String gatewayId;
private final String accessToken;
private final Map<String, String> claims;
+ private final List<String> roles;
public RequestContext(String userId, String gatewayId, String accessToken,
Map<String, String> claims) {
+ this(userId, gatewayId, accessToken, claims, List.of());
+ }
+
+ public RequestContext(
+ String userId, String gatewayId, String accessToken, Map<String,
String> claims, List<String> roles) {
this.userId = userId;
this.gatewayId = gatewayId;
this.accessToken = accessToken;
this.claims = Collections.unmodifiableMap(claims);
+ this.roles = roles == null ? List.of() : List.copyOf(roles);
}
public String getUserId() {
@@ -51,4 +59,23 @@ public class RequestContext {
public Map<String, String> getClaims() {
return claims;
}
+
+ /** Realm roles from the verified access token. */
+ public List<String> getRoles() {
+ return roles;
+ }
+
+ public boolean hasRole(String role) {
+ return roles.contains(role);
+ }
+
+ /** True when the caller holds the read-write gateway-admin role ({@code
admin-rw}). */
+ public boolean isGatewayAdmin() {
+ return hasRole(Constants.ROLE_GATEWAY_ADMIN);
+ }
+
+ /** True when the caller holds the read-only gateway-admin role ({@code
admin-ro}). */
+ public boolean isReadOnlyGatewayAdmin() {
+ return hasRole(Constants.ROLE_READ_ONLY_ADMIN);
+ }
}
diff --git
a/airavata-api/src/main/java/org/apache/airavata/config/UserContext.java
b/airavata-api/src/main/java/org/apache/airavata/config/UserContext.java
index cce84cb07b..d46805ac80 100644
--- a/airavata-api/src/main/java/org/apache/airavata/config/UserContext.java
+++ b/airavata-api/src/main/java/org/apache/airavata/config/UserContext.java
@@ -19,6 +19,7 @@
*/
package org.apache.airavata.config;
+import java.util.List;
import java.util.Map;
import org.apache.airavata.model.security.proto.AuthzToken;
import org.apache.airavata.model.user.proto.UserProfile;
@@ -60,6 +61,15 @@ public class UserContext {
return claims != null ? claims.get(Constants.GATEWAY_ID) : null;
}
+ /** Realm roles derived from the verified access token (CSV in the claims
map); empty if none/unverified. */
+ public static List<String> roles() {
+ var token = authzToken();
+ if (token == null) return List.of();
+ String csv = token.getClaimsMapMap().get(Constants.REALM_ROLES);
+ if (csv == null || csv.isBlank()) return List.of();
+ return List.of(csv.split(","));
+ }
+
public static boolean isAuthenticated() {
return authzToken() != null;
}
diff --git
a/airavata-api/src/main/java/org/apache/airavata/grpc/GrpcRequestContext.java
b/airavata-api/src/main/java/org/apache/airavata/grpc/GrpcRequestContext.java
index 4a171a5c3f..6ea224f475 100644
---
a/airavata-api/src/main/java/org/apache/airavata/grpc/GrpcRequestContext.java
+++
b/airavata-api/src/main/java/org/apache/airavata/grpc/GrpcRequestContext.java
@@ -38,6 +38,7 @@ public final class GrpcRequestContext {
throw new IllegalStateException("No AuthzToken found in
UserContext");
}
Map<String, String> claims = token.getClaimsMapMap();
- return new RequestContext(UserContext.userId(),
UserContext.gatewayId(), token.getAccessToken(), claims);
+ return new RequestContext(
+ UserContext.userId(), UserContext.gatewayId(),
token.getAccessToken(), claims, UserContext.roles());
}
}
diff --git
a/airavata-api/src/main/java/org/apache/airavata/grpc/GrpcStatusMapper.java
b/airavata-api/src/main/java/org/apache/airavata/grpc/GrpcStatusMapper.java
index 5dee614d2b..327790f015 100644
--- a/airavata-api/src/main/java/org/apache/airavata/grpc/GrpcStatusMapper.java
+++ b/airavata-api/src/main/java/org/apache/airavata/grpc/GrpcStatusMapper.java
@@ -39,7 +39,7 @@ public final class GrpcStatusMapper {
Status status =
switch (className) {
case "EntityNotFoundException" -> Status.NOT_FOUND;
- case "AuthorizationException" -> Status.PERMISSION_DENIED;
+ case "AuthorizationException",
"ServiceAuthorizationException" -> Status.PERMISSION_DENIED;
case "AuthenticationException" -> Status.UNAUTHENTICATED;
case "ValidationException" -> Status.INVALID_ARGUMENT;
case "DuplicateEntityException" -> Status.ALREADY_EXISTS;
diff --git
a/airavata-api/src/main/java/org/apache/airavata/util/AdminAccess.java
b/airavata-api/src/main/java/org/apache/airavata/util/AdminAccess.java
new file mode 100644
index 0000000000..4ede14dbdf
--- /dev/null
+++ b/airavata-api/src/main/java/org/apache/airavata/util/AdminAccess.java
@@ -0,0 +1,47 @@
+/**
+*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+package org.apache.airavata.util;
+
+import org.apache.airavata.config.RequestContext;
+import org.apache.airavata.exception.ServiceAuthorizationException;
+
+/**
+ * Coarse gateway-admin authorization checks for service beans, keyed on the
caller's verified realm roles
+ * ({@code admin-rw} / {@code admin-ro}). This guards gateway-wide admin
operations; per-entity access stays in
+ * the sharing registry ({@link SharingHelper#userHasAccess}).
+ */
+public final class AdminAccess {
+
+ private AdminAccess() {}
+
+ /** Requires the read-write gateway-admin role; throws PERMISSION_DENIED
otherwise. */
+ public static void requireGatewayAdmin(RequestContext ctx) throws
ServiceAuthorizationException {
+ if (ctx == null || !ctx.isGatewayAdmin()) {
+ throw new ServiceAuthorizationException("Operation requires the
gateway admin (admin-rw) role");
+ }
+ }
+
+ /** Requires the read-write or read-only gateway-admin role; throws
PERMISSION_DENIED otherwise. */
+ public static void requireAdminOrReadOnly(RequestContext ctx) throws
ServiceAuthorizationException {
+ if (ctx == null || !(ctx.isGatewayAdmin() ||
ctx.isReadOnlyGatewayAdmin())) {
+ throw new ServiceAuthorizationException("Operation requires a
gateway admin (admin-rw or admin-ro) role");
+ }
+ }
+}
diff --git a/airavata-server/pom.xml b/airavata-server/pom.xml
index 803ab74528..0d76222780 100644
--- a/airavata-server/pom.xml
+++ b/airavata-server/pom.xml
@@ -155,6 +155,11 @@ under the License.
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
+ <dependency>
+ <groupId>com.nimbusds</groupId>
+ <artifactId>nimbus-jose-jwt</artifactId>
+ <version>9.40</version>
+ </dependency>
</dependencies>
<build>
diff --git
a/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/AuthTokenExtractor.java
b/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/AuthTokenExtractor.java
index b1edae9af7..811ff78459 100644
---
a/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/AuthTokenExtractor.java
+++
b/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/AuthTokenExtractor.java
@@ -23,6 +23,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.HashMap;
import java.util.Map;
+import org.apache.airavata.config.Constants;
import org.apache.airavata.model.security.proto.AuthzToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -59,8 +60,13 @@ public final class AuthTokenExtractor {
return new HashMap<>();
}
- /** Builds an AuthzToken from the access token and (possibly augmented)
claims map. */
+ /**
+ * Builds an AuthzToken from the access token and (possibly augmented)
claims map. The caller's realm roles
+ * are derived from the verified access token (not the client-asserted
{@code x-claims}) and written into the
+ * claims map under {@link Constants#REALM_ROLES} as a CSV, overwriting
any client-supplied value.
+ */
public static AuthzToken buildAuthzToken(String accessToken, Map<String,
String> claimsMap) {
+ claimsMap.put(Constants.REALM_ROLES, String.join(",",
JwtVerifier.verifyAndExtractRoles(accessToken)));
return AuthzToken.newBuilder()
.setAccessToken(accessToken)
.putAllClaimsMap(claimsMap)
diff --git
a/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/HttpAuthDecorator.java
b/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/HttpAuthDecorator.java
index 352e0b6f7d..f59021d776 100644
---
a/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/HttpAuthDecorator.java
+++
b/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/HttpAuthDecorator.java
@@ -60,10 +60,7 @@ public class HttpAuthDecorator implements
DecoratingHttpServiceFunction {
}
}
- AuthzToken authzToken = AuthzToken.newBuilder()
- .setAccessToken(accessToken)
- .putAllClaimsMap(claimsMap)
- .build();
+ AuthzToken authzToken =
AuthTokenExtractor.buildAuthzToken(accessToken, claimsMap);
UserContext.setAuthzToken(authzToken);
try {
diff --git
a/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/JwtVerifier.java
b/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/JwtVerifier.java
new file mode 100644
index 0000000000..684f646db3
--- /dev/null
+++
b/airavata-server/src/main/java/org/apache/airavata/server/grpc/config/JwtVerifier.java
@@ -0,0 +1,114 @@
+/**
+*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+package org.apache.airavata.server.grpc.config;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
+import com.nimbusds.jose.proc.JWSVerificationKeySelector;
+import com.nimbusds.jose.proc.SecurityContext;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
+import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
+import com.nimbusds.jwt.proc.DefaultJWTProcessor;
+import java.net.URI;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Verifies a Keycloak access token (RS256 signature against the realm JWKS,
{@code exp}, and issuer) and
+ * extracts {@code realm_access.roles}. The issuer is taken from the token's
own {@code iss} claim, and a JWKS
+ * processor is cached per issuer so multi-tenant (multi-realm) tokens are
each verified against their own realm.
+ *
+ * <p>Verification is fail-closed but non-rejecting: a missing, malformed,
expired, or unverifiable token simply
+ * yields no roles (logged), so the caller resolves to a non-admin. Roles are
therefore only ever trusted when
+ * they come from a signature-verified token — a forged token cannot assert
admin.
+ */
+public final class JwtVerifier {
+
+ private static final Logger log =
LoggerFactory.getLogger(JwtVerifier.class);
+ private static final ObjectMapper objectMapper = new ObjectMapper();
+ private static final Map<String,
ConfigurableJWTProcessor<SecurityContext>> PROCESSORS = new
ConcurrentHashMap<>();
+
+ private JwtVerifier() {}
+
+ /** Verifies the token and returns its realm roles, or an empty list if
verification fails. */
+ public static List<String> verifyAndExtractRoles(String accessToken) {
+ if (accessToken == null || accessToken.isBlank()) {
+ return List.of();
+ }
+ try {
+ String issuer = unverifiedIssuer(accessToken);
+ if (issuer == null) {
+ log.warn("Access token has no issuer claim; no realm roles
extracted");
+ return List.of();
+ }
+ JWTClaimsSet claims = processorFor(issuer).process(accessToken,
null);
+ Map<String, Object> realmAccess =
claims.getJSONObjectClaim("realm_access");
+ if (realmAccess == null || !(realmAccess.get("roles") instanceof
List<?> roles)) {
+ return List.of();
+ }
+ List<String> result =
roles.stream().map(String::valueOf).collect(Collectors.toList());
+ log.debug("Verified realm roles from {}: {}", issuer, result);
+ return result;
+ } catch (Exception e) {
+ log.warn("JWT verification failed; no realm roles extracted: {}",
e.getMessage());
+ return List.of();
+ }
+ }
+
+ /** Reads the {@code iss} claim from the token payload without verifying
the signature. */
+ private static String unverifiedIssuer(String token) throws Exception {
+ String[] parts = token.split("\\.");
+ if (parts.length < 2) {
+ return null;
+ }
+ Map<String, Object> payload =
+
objectMapper.readValue(Base64.getUrlDecoder().decode(parts[1]), new
TypeReference<>() {});
+ Object iss = payload.get("iss");
+ return iss == null ? null : iss.toString();
+ }
+
+ private static ConfigurableJWTProcessor<SecurityContext>
processorFor(String issuer) {
+ return PROCESSORS.computeIfAbsent(issuer, iss -> {
+ try {
+ JWKSource<SecurityContext> jwkSource = JWKSourceBuilder.create(
+ URI.create(iss +
"/protocol/openid-connect/certs")
+ .toURL())
+ .build();
+ ConfigurableJWTProcessor<SecurityContext> processor = new
DefaultJWTProcessor<>();
+ processor.setJWSKeySelector(new
JWSVerificationKeySelector<>(JWSAlgorithm.RS256, jwkSource));
+ processor.setJWTClaimsSetVerifier(new
DefaultJWTClaimsVerifier<>(
+ new JWTClaimsSet.Builder().issuer(iss).build(),
Set.of("exp")));
+ return processor;
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to build JWT processor
for issuer " + iss, e);
+ }
+ });
+ }
+}
diff --git
a/airavata-server/src/main/java/org/apache/airavata/server/grpc/services/IamAdminGrpcService.java
b/airavata-server/src/main/java/org/apache/airavata/server/grpc/services/IamAdminGrpcService.java
index 818ada0db9..71d08473e8 100644
---
a/airavata-server/src/main/java/org/apache/airavata/server/grpc/services/IamAdminGrpcService.java
+++
b/airavata-server/src/main/java/org/apache/airavata/server/grpc/services/IamAdminGrpcService.java
@@ -32,6 +32,7 @@ import
org.apache.airavata.iam.service.TenantManagementKeycloakImpl;
import org.apache.airavata.model.credential.store.proto.PasswordCredential;
import org.apache.airavata.model.user.proto.UserProfile;
import org.apache.airavata.model.workspace.proto.Gateway;
+import org.apache.airavata.util.AdminAccess;
import org.springframework.stereotype.Component;
@Component
@@ -146,6 +147,7 @@ public class IamAdminGrpcService extends
IamAdminServiceGrpc.IamAdminServiceImpl
public void getUsers(GetIamUsersRequest request,
StreamObserver<GetIamUsersResponse> observer) {
try {
RequestContext ctx = GrpcRequestContext.current();
+ AdminAccess.requireAdminOrReadOnly(ctx);
List<UserProfile> users = tenantManager.getUsers(
ctx.getAccessToken(),
ctx.getGatewayId(),