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(),

Reply via email to