diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java index e72ed56b62..ce6c2fcaed 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/trustedoidcissuer/JdbcTrustedOidcIssuerService.java @@ -53,7 +53,6 @@ public class JdbcTrustedOidcIssuerService implements TrustedOidcIssuerService { private static final TrustedOidcIssuerServiceMessages LOG = MessagesFactory.get(TrustedOidcIssuerServiceMessages.class); - private final AtomicBoolean initialized = new AtomicBoolean(false); private final Lock initLock = new ReentrantLock(true); diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResource.java new file mode 100644 index 0000000000..f088021310 --- /dev/null +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResource.java @@ -0,0 +1,227 @@ +/* + * 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.knox.gateway.service.knoxidf; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.knox.gateway.audit.api.Action; +import org.apache.knox.gateway.audit.api.ActionOutcome; +import org.apache.knox.gateway.audit.api.AuditServiceFactory; +import org.apache.knox.gateway.audit.api.Auditor; +import org.apache.knox.gateway.audit.api.ResourceType; +import org.apache.knox.gateway.audit.log4j.audit.AuditConstants; +import org.apache.knox.gateway.services.GatewayServices; +import org.apache.knox.gateway.services.ServiceType; +import org.apache.commons.lang3.StringUtils; +import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.TrustedOidcIssuer; +import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.TrustedOidcIssuerService; +import org.apache.knox.gateway.util.JsonUtils; + +import javax.annotation.PostConstruct; +import javax.servlet.ServletContext; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.security.Principal; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.BASE_RESORCE_PATH; + +@Path(TrustedOidcIssuersResource.RESOURCE_PATH) +@Produces(MediaType.APPLICATION_JSON) +public class TrustedOidcIssuersResource { + + static final String RESOURCE_PATH = BASE_RESORCE_PATH + "/admin/trusted-oidc-issuers"; + + // Non-final and package-private to allow test injection of a mock Auditor. + static Auditor auditor = AuditServiceFactory.getAuditService() + .getAuditor(AuditConstants.DEFAULT_AUDITOR_NAME, + AuditConstants.KNOX_SERVICE_NAME, AuditConstants.KNOX_COMPONENT_NAME); + + @Context + private ServletContext servletContext; + + @Context + private HttpServletRequest request; + + private TrustedOidcIssuerService trustedIssuers; + + @PostConstruct + public void init() { + final GatewayServices services = (GatewayServices) + servletContext.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE); + trustedIssuers = services.getService(ServiceType.TRUSTED_OIDC_ISSUER_SERVICE); + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + public Response registerIssuer(String body) { + String issuerUrl = "INVALID_REQUEST"; + final String operatorId = getOperatorId(); + String outcome = ActionOutcome.FAILURE; + + try { + final Map parsed; + try { + parsed = new ObjectMapper().readValue(body, new TypeReference>() {}); + } catch (IOException e) { + return errorResponse(Response.Status.BAD_REQUEST, "invalid_request", "Malformed JSON body"); + } + + final String rawUrl = (String) parsed.get("issuerUrl"); + issuerUrl = (rawUrl != null && !rawUrl.isEmpty()) ? rawUrl : "UNKNOWN_ISSUER"; + + if (rawUrl == null || rawUrl.isEmpty()) { + return errorResponse(Response.Status.BAD_REQUEST, "invalid_request", "issuerUrl is required"); + } + if (!isHttpsUrl(rawUrl)) { + return errorResponse(Response.Status.BAD_REQUEST, "invalid_request", + "issuerUrl must use HTTPS scheme"); + } + if (trustedIssuers.isTrusted(rawUrl)) { + return errorResponse(Response.Status.CONFLICT, "issuer_exists", + "Issuer already registered: " + rawUrl); + } + + final boolean dynamicJwks = Boolean.TRUE.equals(parsed.get("dynamicJwks")); + final String clusterName = (String) parsed.get("clusterName"); + + trustedIssuers.register(new TrustedOidcIssuer(rawUrl, dynamicJwks, clusterName, + Instant.now(), operatorId)); + outcome = ActionOutcome.SUCCESS; + return Response.status(Response.Status.CREATED).build(); + } catch (RuntimeException e) { + return errorResponse(Response.Status.INTERNAL_SERVER_ERROR, "storage_error", + "Failed to register issuer"); + } finally { + auditor.audit(Action.DELEGATION_LIFECYCLE, issuerUrl, ResourceType.TRUSTED_ISSUER, + outcome, "event_type=issuer_registered performed_by=" + auditLabel(operatorId)); + } + } + + @DELETE + public Response removeIssuer(@QueryParam("issuerUrl") String issuerUrl) { + final String operatorId = getOperatorId(); + final String auditIssuerUrl = StringUtils.isBlank(issuerUrl) ? "UNKNOWN_ISSUER" : issuerUrl; + String outcome = ActionOutcome.FAILURE; + + try { + if (StringUtils.isBlank(issuerUrl)) { + return errorResponse(Response.Status.BAD_REQUEST, "invalid_request", + "issuerUrl query parameter is required"); + } + + // deregister is idempotent at the service layer: it returns silently if the issuer is + // not registered. Admins deleting a non-existent issuer receive the same 204 and audit + // event as a successful delete — there is no separate 404 path at this layer. + trustedIssuers.deregister(issuerUrl); + outcome = ActionOutcome.SUCCESS; + return Response.noContent().build(); + } catch (RuntimeException e) { + return errorResponse(Response.Status.INTERNAL_SERVER_ERROR, "storage_error", + "Failed to remove issuer"); + } finally { + auditor.audit(Action.DELEGATION_LIFECYCLE, auditIssuerUrl, ResourceType.TRUSTED_ISSUER, + outcome, "event_type=issuer_removed performed_by=" + auditLabel(operatorId)); + } + } + + @GET + public Response listIssuers() { + final List> result = trustedIssuers.list().stream() + .map(this::issuerToMap) + .collect(Collectors.toList()); + return Response.ok(JsonUtils.renderAsJsonString(result)).build(); + } + + @POST + @Path("/refresh-jwks") + public Response refreshJwksUri(@QueryParam("issuerUrl") String issuerUrl) { + final String operatorId = getOperatorId(); + final String auditIssuerUrl = StringUtils.isBlank(issuerUrl) ? "UNKNOWN_ISSUER" : issuerUrl; + String outcome = ActionOutcome.FAILURE; + + try { + if (StringUtils.isBlank(issuerUrl)) { + return errorResponse(Response.Status.BAD_REQUEST, "invalid_request", + "issuerUrl query parameter is required"); + } + + // No-op at the service layer if the issuer is not registered or not configured for + // dynamic JWKS; still returns 204 so the caller does not need to check existence first. + trustedIssuers.refreshJwksUri(issuerUrl); + outcome = ActionOutcome.SUCCESS; + return Response.noContent().build(); + } catch (RuntimeException e) { + return errorResponse(Response.Status.INTERNAL_SERVER_ERROR, "storage_error", + "Failed to refresh JWKS URI"); + } finally { + auditor.audit(Action.DELEGATION_LIFECYCLE, auditIssuerUrl, ResourceType.TRUSTED_ISSUER, + outcome, "event_type=issuer_jwks_refreshed performed_by=" + auditLabel(operatorId)); + } + } + + private String getOperatorId() { + final Principal principal = request.getUserPrincipal(); + return principal != null ? principal.getName() : null; + } + + private static String auditLabel(String operatorId) { + return operatorId != null ? operatorId : "ANONYMOUS"; + } + + private Map issuerToMap(TrustedOidcIssuer issuer) { + final Map map = new LinkedHashMap<>(); + map.put("issuerUrl", issuer.getIssuerUrl()); + map.put("dynamicJwks", issuer.isDynamicJwks()); + map.put("clusterName", issuer.getClusterName()); + map.put("registeredAt", + issuer.getRegisteredAt() != null ? issuer.getRegisteredAt().toString() : null); + map.put("registeredBy", issuer.getRegisteredBy()); + return map; + } + + private static boolean isHttpsUrl(String url) { + try { + return "https".equalsIgnoreCase(new URI(url).getScheme()); + } catch (URISyntaxException e) { + return false; + } + } + + private static Response errorResponse(Response.Status status, String error, String description) { + final Map body = new LinkedHashMap<>(); + body.put("error", error); + body.put("error_description", description); + return Response.status(status).entity(JsonUtils.renderAsJsonString(body)).build(); + } +} diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributor.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributor.java new file mode 100644 index 0000000000..81f357e09f --- /dev/null +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributor.java @@ -0,0 +1,48 @@ +/* + * 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.knox.gateway.service.knoxidf.deploy; + +import org.apache.knox.gateway.jersey.JerseyServiceDeploymentContributorBase; + +/** + * Deployment contributor for the KNOXIDF_ADMIN service role, which hosts the + * Knox IDF admin REST API. This contributor initially registers + * {@link org.apache.knox.gateway.service.knoxidf.TrustedOidcIssuersResource}; + * it will be extended in a later task to also register DelegationAdminResource. + */ +public class KnoxIDFAdminServiceDeploymentContributor extends JerseyServiceDeploymentContributorBase { + + @Override + public String getRole() { + return "KNOXIDF_ADMIN"; + } + + @Override + public String getName() { + return "KNOXIDF_ADMIN"; + } + + @Override + protected String[] getPackages() { + return new String[] { "org.apache.knox.gateway.service.knoxidf" }; + } + + @Override + protected String[] getPatterns() { + return new String[] { "knoxidf/api/**?**" }; + } +} diff --git a/gateway-service-knoxidf/src/main/resources/META-INF/services/org.apache.knox.gateway.deploy.ServiceDeploymentContributor b/gateway-service-knoxidf/src/main/resources/META-INF/services/org.apache.knox.gateway.deploy.ServiceDeploymentContributor index 49fc687f12..1fca75abb8 100644 --- a/gateway-service-knoxidf/src/main/resources/META-INF/services/org.apache.knox.gateway.deploy.ServiceDeploymentContributor +++ b/gateway-service-knoxidf/src/main/resources/META-INF/services/org.apache.knox.gateway.deploy.ServiceDeploymentContributor @@ -16,3 +16,4 @@ # limitations under the License. ########################################################################## org.apache.knox.gateway.service.knoxidf.deploy.KnoxIDFServiceDeploymentContributor +org.apache.knox.gateway.service.knoxidf.deploy.KnoxIDFAdminServiceDeploymentContributor diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResourceTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResourceTest.java new file mode 100644 index 0000000000..e082d3255d --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TrustedOidcIssuersResourceTest.java @@ -0,0 +1,551 @@ +/* + * 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.knox.gateway.service.knoxidf; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.knox.gateway.audit.api.Action; +import org.apache.knox.gateway.audit.api.ActionOutcome; +import org.apache.knox.gateway.audit.api.Auditor; +import org.apache.knox.gateway.audit.api.ResourceType; +import org.apache.knox.gateway.services.GatewayServices; +import org.apache.knox.gateway.services.ServiceType; +import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.TrustedOidcIssuer; +import org.apache.knox.gateway.services.knoxidf.trustedoidcissuer.TrustedOidcIssuerService; +import org.easymock.Capture; +import org.easymock.EasyMock; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import javax.servlet.ServletContext; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.Response; +import java.lang.reflect.Field; +import java.security.Principal; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class TrustedOidcIssuersResourceTest { + + private static final String ISSUER_A = "https://issuer-a.example.com"; + private static final String ISSUER_B = "https://issuer-b.example.com"; + private static final String OPERATOR = "admin"; + + // Capture the real static Auditor so @After can restore it. + private static final Auditor ORIGINAL_AUDITOR = TrustedOidcIssuersResource.auditor; + + private TrustedOidcIssuersResource resource; + private TrustedOidcIssuerService mockService; + private Auditor mockAuditor; + + @Before + public void setUp() throws Exception { + mockService = EasyMock.createMock(TrustedOidcIssuerService.class); + mockAuditor = EasyMock.createMock(Auditor.class); + TrustedOidcIssuersResource.auditor = mockAuditor; + resource = buildResource(buildPrincipal(OPERATOR)); + } + + @After + public void tearDown() { + TrustedOidcIssuersResource.auditor = ORIGINAL_AUDITOR; + } + + // --------------------------------------------------------------------------- + // POST /register + // --------------------------------------------------------------------------- + + @Test + public void testRegisterIssuer() { + EasyMock.expect(mockService.isTrusted(ISSUER_A)).andReturn(false).once(); + final Capture capturedIssuer = EasyMock.newCapture(); + mockService.register(EasyMock.capture(capturedIssuer)); + EasyMock.expectLastCall().once(); + final Capture auditMsg = EasyMock.newCapture(); + mockAuditor.audit( + EasyMock.eq(Action.DELEGATION_LIFECYCLE), EasyMock.eq(ISSUER_A), + EasyMock.eq(ResourceType.TRUSTED_ISSUER), EasyMock.eq(ActionOutcome.SUCCESS), + EasyMock.capture(auditMsg)); + EasyMock.expectLastCall().once(); + EasyMock.replay(mockService, mockAuditor); + + final Response response = resource.registerIssuer(buildRegisterBody(ISSUER_A, false, null)); + + assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + assertEquals(ISSUER_A, capturedIssuer.getValue().getIssuerUrl()); + assertFalse(capturedIssuer.getValue().isDynamicJwks()); + assertNull(capturedIssuer.getValue().getClusterName()); + assertEquals(OPERATOR, capturedIssuer.getValue().getRegisteredBy()); + assertNotNull(capturedIssuer.getValue().getRegisteredAt()); + assertTrue(auditMsg.getValue().contains("event_type=issuer_registered")); + assertTrue(auditMsg.getValue().contains("performed_by=" + OPERATOR)); + EasyMock.verify(mockService, mockAuditor); + } + + @Test + public void testRegisterWithClusterNameAndDynamicJwks() { + EasyMock.expect(mockService.isTrusted(ISSUER_A)).andReturn(false).once(); + final Capture capturedIssuer = EasyMock.newCapture(); + mockService.register(EasyMock.capture(capturedIssuer)); + EasyMock.expectLastCall().once(); + expectAudit(ISSUER_A, ActionOutcome.SUCCESS, "issuer_registered"); + EasyMock.replay(mockService, mockAuditor); + + final Response response = resource.registerIssuer( + buildRegisterBody(ISSUER_A, true, "production-cluster")); + + assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + assertTrue(capturedIssuer.getValue().isDynamicJwks()); + assertEquals("production-cluster", capturedIssuer.getValue().getClusterName()); + EasyMock.verify(mockService, mockAuditor); + } + + @Test + public void testRegisterNonHttpsUrl() { + final String httpUrl = "http://insecure.example.com"; + // No service calls expected; audit fires with the non-HTTPS URL as resource name. + expectAudit(httpUrl, ActionOutcome.FAILURE, "issuer_registered"); + EasyMock.replay(mockService, mockAuditor); + + final Response response = resource.registerIssuer(buildRegisterBody(httpUrl, false, null)); + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertErrorField(response, "invalid_request"); + EasyMock.verify(mockService, mockAuditor); + } + + @Test + public void testRegisterMissingIssuerUrl() { + // issuerUrl field absent from JSON → sentinel UNKNOWN_ISSUER used as audit resource name. + expectAudit("UNKNOWN_ISSUER", ActionOutcome.FAILURE, "issuer_registered"); + EasyMock.replay(mockService, mockAuditor); + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), + resource.registerIssuer("{\"dynamicJwks\":false}").getStatus()); + EasyMock.verify(mockService, mockAuditor); + } + + @Test + public void testRegisterEmptyIssuerUrl() { + // Empty string issuerUrl → same sentinel UNKNOWN_ISSUER as null case. + expectAudit("UNKNOWN_ISSUER", ActionOutcome.FAILURE, "issuer_registered"); + EasyMock.replay(mockService, mockAuditor); + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), + resource.registerIssuer("{\"issuerUrl\":\"\",\"dynamicJwks\":false}").getStatus()); + EasyMock.verify(mockService, mockAuditor); + } + + @Test + public void testRegisterInvalidJson() { + // JSON parse failure before URL is known → sentinel INVALID_REQUEST as audit resource name. + expectAudit("INVALID_REQUEST", ActionOutcome.FAILURE, "issuer_registered"); + EasyMock.replay(mockService, mockAuditor); + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), + resource.registerIssuer("{ not valid json }").getStatus()); + EasyMock.verify(mockService, mockAuditor); + } + + @Test + public void testDuplicateIssuer() { + EasyMock.expect(mockService.isTrusted(ISSUER_A)).andReturn(true).once(); + expectAudit(ISSUER_A, ActionOutcome.FAILURE, "issuer_registered"); + EasyMock.replay(mockService, mockAuditor); + + final Response response = resource.registerIssuer(buildRegisterBody(ISSUER_A, false, null)); + + assertEquals(Response.Status.CONFLICT.getStatusCode(), response.getStatus()); + assertErrorField(response, "issuer_exists"); + EasyMock.verify(mockService, mockAuditor); + } + + @Test + public void testRegisterNullPrincipal() throws Exception { + final TrustedOidcIssuersResource res = buildResource(null); + EasyMock.expect(mockService.isTrusted(ISSUER_A)).andReturn(false).once(); + final Capture capturedIssuer = EasyMock.newCapture(); + mockService.register(EasyMock.capture(capturedIssuer)); + EasyMock.expectLastCall().once(); + mockAuditor.audit( + EasyMock.eq(Action.DELEGATION_LIFECYCLE), EasyMock.eq(ISSUER_A), + EasyMock.eq(ResourceType.TRUSTED_ISSUER), EasyMock.eq(ActionOutcome.SUCCESS), + EasyMock.contains("performed_by=ANONYMOUS")); + EasyMock.expectLastCall().once(); + EasyMock.replay(mockService, mockAuditor); + + assertEquals(Response.Status.CREATED.getStatusCode(), + res.registerIssuer(buildRegisterBody(ISSUER_A, false, null)).getStatus()); + assertNull(capturedIssuer.getValue().getRegisteredBy()); + EasyMock.verify(mockService, mockAuditor); + } + + @Test + public void testRemoveNullPrincipalAuditsAnonymous() throws Exception { + final TrustedOidcIssuersResource res = buildResource(null); + mockService.deregister(ISSUER_A); + EasyMock.expectLastCall().once(); + mockAuditor.audit( + EasyMock.eq(Action.DELEGATION_LIFECYCLE), EasyMock.eq(ISSUER_A), + EasyMock.eq(ResourceType.TRUSTED_ISSUER), EasyMock.eq(ActionOutcome.SUCCESS), + EasyMock.contains("performed_by=ANONYMOUS")); + EasyMock.expectLastCall().once(); + EasyMock.replay(mockService, mockAuditor); + + assertEquals(Response.Status.NO_CONTENT.getStatusCode(), + res.removeIssuer(ISSUER_A).getStatus()); + EasyMock.verify(mockService, mockAuditor); + } + + @Test + public void testRefreshJwksNullPrincipalAuditsAnonymous() throws Exception { + final TrustedOidcIssuersResource res = buildResource(null); + mockService.refreshJwksUri(ISSUER_A); + EasyMock.expectLastCall().once(); + mockAuditor.audit( + EasyMock.eq(Action.DELEGATION_LIFECYCLE), EasyMock.eq(ISSUER_A), + EasyMock.eq(ResourceType.TRUSTED_ISSUER), EasyMock.eq(ActionOutcome.SUCCESS), + EasyMock.contains("performed_by=ANONYMOUS")); + EasyMock.expectLastCall().once(); + EasyMock.replay(mockService, mockAuditor); + + assertEquals(Response.Status.NO_CONTENT.getStatusCode(), + res.refreshJwksUri(ISSUER_A).getStatus()); + EasyMock.verify(mockService, mockAuditor); + } + + @Test + public void testAuditRegisterStorageFailure() { + EasyMock.expect(mockService.isTrusted(ISSUER_A)).andReturn(false).once(); + mockService.register(EasyMock.anyObject(TrustedOidcIssuer.class)); + EasyMock.expectLastCall().andThrow(new RuntimeException("DB error")).once(); + expectAudit(ISSUER_A, ActionOutcome.FAILURE, "issuer_registered"); + EasyMock.replay(mockService, mockAuditor); + + assertEquals(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), + resource.registerIssuer(buildRegisterBody(ISSUER_A, false, null)).getStatus()); + EasyMock.verify(mockService, mockAuditor); + } + + // --------------------------------------------------------------------------- + // DELETE / + // --------------------------------------------------------------------------- + + @Test + public void testRemoveRegisteredIssuer() { + mockService.deregister(ISSUER_A); + EasyMock.expectLastCall().once(); + final Capture auditMsg = EasyMock.newCapture(); + mockAuditor.audit( + EasyMock.eq(Action.DELEGATION_LIFECYCLE), EasyMock.eq(ISSUER_A), + EasyMock.eq(ResourceType.TRUSTED_ISSUER), EasyMock.eq(ActionOutcome.SUCCESS), + EasyMock.capture(auditMsg)); + EasyMock.expectLastCall().once(); + EasyMock.replay(mockService, mockAuditor); + + assertEquals(Response.Status.NO_CONTENT.getStatusCode(), + resource.removeIssuer(ISSUER_A).getStatus()); + assertTrue(auditMsg.getValue().contains("event_type=issuer_removed")); + assertTrue(auditMsg.getValue().contains("performed_by=" + OPERATOR)); + EasyMock.verify(mockService, mockAuditor); + } + + @Test + public void testRemoveMissingIssuerUrl() { + // Null models a request where ?issuerUrl= was omitted entirely (JAX-RS injects null). + expectAudit("UNKNOWN_ISSUER", ActionOutcome.FAILURE, "issuer_removed"); + EasyMock.replay(mockService, mockAuditor); + + final Response response = resource.removeIssuer(null); + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertErrorField(response, "invalid_request"); + EasyMock.verify(mockService, mockAuditor); + } + + @Test + public void testRemoveEmptyIssuerUrl() { + // Empty string models ?issuerUrl= with no value. + expectAudit("UNKNOWN_ISSUER", ActionOutcome.FAILURE, "issuer_removed"); + EasyMock.replay(mockService, mockAuditor); + + final Response response = resource.removeIssuer(""); + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertErrorField(response, "invalid_request"); + EasyMock.verify(mockService, mockAuditor); + } + + @Test + public void testRemoveWhitespaceIssuerUrl() { + // Whitespace-only is not a valid HTTPS URL; fail fast rather than propagating to the service. + expectAudit("UNKNOWN_ISSUER", ActionOutcome.FAILURE, "issuer_removed"); + EasyMock.replay(mockService, mockAuditor); + + final Response response = resource.removeIssuer(" "); + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertErrorField(response, "invalid_request"); + EasyMock.verify(mockService, mockAuditor); + } + + @Test + public void testAuditRemoveStorageFailure() { + mockService.deregister(ISSUER_A); + EasyMock.expectLastCall().andThrow(new RuntimeException("DB error")).once(); + expectAudit(ISSUER_A, ActionOutcome.FAILURE, "issuer_removed"); + EasyMock.replay(mockService, mockAuditor); + + assertEquals(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), + resource.removeIssuer(ISSUER_A).getStatus()); + EasyMock.verify(mockService, mockAuditor); + } + + + // --------------------------------------------------------------------------- + // POST /refresh-jwks + // --------------------------------------------------------------------------- + + @Test + public void testRefreshJwksUri() { + mockService.refreshJwksUri(ISSUER_A); + EasyMock.expectLastCall().once(); + final Capture auditMsg = EasyMock.newCapture(); + mockAuditor.audit( + EasyMock.eq(Action.DELEGATION_LIFECYCLE), EasyMock.eq(ISSUER_A), + EasyMock.eq(ResourceType.TRUSTED_ISSUER), EasyMock.eq(ActionOutcome.SUCCESS), + EasyMock.capture(auditMsg)); + EasyMock.expectLastCall().once(); + EasyMock.replay(mockService, mockAuditor); + + assertEquals(Response.Status.NO_CONTENT.getStatusCode(), + resource.refreshJwksUri(ISSUER_A).getStatus()); + assertTrue(auditMsg.getValue().contains("event_type=issuer_jwks_refreshed")); + assertTrue(auditMsg.getValue().contains("performed_by=" + OPERATOR)); + EasyMock.verify(mockService, mockAuditor); + } + + @Test + public void testRefreshMissingIssuerUrl() { + expectAudit("UNKNOWN_ISSUER", ActionOutcome.FAILURE, "issuer_jwks_refreshed"); + EasyMock.replay(mockService, mockAuditor); + + final Response response = resource.refreshJwksUri(null); + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertErrorField(response, "invalid_request"); + EasyMock.verify(mockService, mockAuditor); + } + + @Test + public void testRefreshEmptyIssuerUrl() { + expectAudit("UNKNOWN_ISSUER", ActionOutcome.FAILURE, "issuer_jwks_refreshed"); + EasyMock.replay(mockService, mockAuditor); + + final Response response = resource.refreshJwksUri(""); + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertErrorField(response, "invalid_request"); + EasyMock.verify(mockService, mockAuditor); + } + + @Test + public void testRefreshWhitespaceIssuerUrl() { + expectAudit("UNKNOWN_ISSUER", ActionOutcome.FAILURE, "issuer_jwks_refreshed"); + EasyMock.replay(mockService, mockAuditor); + + final Response response = resource.refreshJwksUri(" "); + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertErrorField(response, "invalid_request"); + EasyMock.verify(mockService, mockAuditor); + } + + @Test + public void testRefreshJwksUriAuditsStorageFailure() { + mockService.refreshJwksUri(ISSUER_A); + EasyMock.expectLastCall().andThrow(new RuntimeException("Cache error")).once(); + expectAudit(ISSUER_A, ActionOutcome.FAILURE, "issuer_jwks_refreshed"); + EasyMock.replay(mockService, mockAuditor); + + assertEquals(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), + resource.refreshJwksUri(ISSUER_A).getStatus()); + EasyMock.verify(mockService, mockAuditor); + } + + + // --------------------------------------------------------------------------- + // GET / + // --------------------------------------------------------------------------- + + @Test + public void testListIssuers() throws Exception { + final Instant now = Instant.now(); + final TrustedOidcIssuer issuerA = new TrustedOidcIssuer(ISSUER_A, true, "cluster-a", + now, OPERATOR); + final TrustedOidcIssuer issuerB = new TrustedOidcIssuer(ISSUER_B, false, null, + now, null); + EasyMock.expect(mockService.list()).andReturn(Arrays.asList(issuerA, issuerB)).once(); + // listIssuers does not audit; no expectations set on mockAuditor. + EasyMock.replay(mockService, mockAuditor); + + final Response response = resource.listIssuers(); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + final List> body = parseJsonList(response.getEntity().toString()); + assertEquals(2, body.size()); + + final Map a = findByIssuerUrl(body, ISSUER_A); + assertEquals(true, a.get("dynamicJwks")); + assertEquals("cluster-a", a.get("clusterName")); + assertNotNull(a.get("registeredAt")); + assertEquals(OPERATOR, a.get("registeredBy")); + + final Map b = findByIssuerUrl(body, ISSUER_B); + assertEquals(false, b.get("dynamicJwks")); + assertNull(b.get("clusterName")); + assertNull(b.get("registeredBy")); + assertNotNull(b.get("registeredAt")); + + EasyMock.verify(mockService, mockAuditor); + } + + @Test + public void testListReturnsEmptyArray() throws Exception { + EasyMock.expect(mockService.list()).andReturn(Collections.emptyList()).once(); + EasyMock.replay(mockService, mockAuditor); + + final Response response = resource.listIssuers(); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertTrue(parseJsonList(response.getEntity().toString()).isEmpty()); + EasyMock.verify(mockService, mockAuditor); + } + + // --------------------------------------------------------------------------- + // @PostConstruct wiring + // --------------------------------------------------------------------------- + + @Test + public void testInitWiresServiceFromGatewayServices() throws Exception { + final TrustedOidcIssuerService svc = EasyMock.createNiceMock(TrustedOidcIssuerService.class); + EasyMock.replay(svc); + + final GatewayServices gws = EasyMock.createNiceMock(GatewayServices.class); + EasyMock.expect(gws.getService(ServiceType.TRUSTED_OIDC_ISSUER_SERVICE)).andReturn(svc).once(); + EasyMock.replay(gws); + + final ServletContext ctx = EasyMock.createNiceMock(ServletContext.class); + EasyMock.expect(ctx.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE)).andReturn(gws).once(); + EasyMock.replay(ctx); + + final TrustedOidcIssuersResource res = new TrustedOidcIssuersResource(); + injectField(res, "servletContext", ctx); + injectField(res, "request", buildRequest(buildPrincipal(OPERATOR))); + res.init(); + + EasyMock.verify(gws, ctx); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private TrustedOidcIssuersResource buildResource(Principal principal) throws Exception { + final TrustedOidcIssuersResource res = new TrustedOidcIssuersResource(); + injectField(res, "request", buildRequest(principal)); + injectField(res, "trustedIssuers", mockService); + return res; + } + + private HttpServletRequest buildRequest(Principal principal) { + final HttpServletRequest req = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(req.getUserPrincipal()).andReturn(principal).anyTimes(); + EasyMock.replay(req); + return req; + } + + private Principal buildPrincipal(String name) { + if (name == null) { + return null; + } + final Principal p = EasyMock.createNiceMock(Principal.class); + EasyMock.expect(p.getName()).andReturn(name).anyTimes(); + EasyMock.replay(p); + return p; + } + + private void expectAudit(String issuerUrl, String outcome, String eventType) { + mockAuditor.audit( + EasyMock.eq(Action.DELEGATION_LIFECYCLE), + EasyMock.eq(issuerUrl), + EasyMock.eq(ResourceType.TRUSTED_ISSUER), + EasyMock.eq(outcome), + EasyMock.contains(eventType)); + EasyMock.expectLastCall().once(); + } + + private static String buildRegisterBody(String issuerUrl, boolean dynamicJwks, + String clusterName) { + final StringBuilder sb = new StringBuilder("{"); + if (issuerUrl != null) { + sb.append("\"issuerUrl\":\"").append(issuerUrl).append("\","); + } + sb.append("\"dynamicJwks\":").append(dynamicJwks); + if (clusterName != null) { + sb.append(",\"clusterName\":\"").append(clusterName).append("\""); + } + sb.append("}"); + return sb.toString(); + } + + private static void assertErrorField(Response response, String expectedError) { + assertNotNull(response.getEntity()); + final String body = response.getEntity().toString(); + assertFalse("Error body must not be empty", body.isEmpty()); + assertTrue("Expected error field '" + expectedError + "' in: " + body, + body.contains(expectedError)); + } + + private static List> parseJsonList(String json) throws Exception { + return new ObjectMapper().readValue(json, new TypeReference>>() {}); + } + + private static Map findByIssuerUrl(List> list, + String issuerUrl) { + return list.stream() + .filter(m -> issuerUrl.equals(m.get("issuerUrl"))) + .findFirst() + .orElseThrow(() -> new AssertionError("Issuer not found: " + issuerUrl)); + } + + private static void injectField(Object target, String fieldName, Object value) throws Exception { + final Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributorTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributorTest.java new file mode 100644 index 0000000000..5868da3668 --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/deploy/KnoxIDFAdminServiceDeploymentContributorTest.java @@ -0,0 +1,144 @@ +/* + * 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.knox.gateway.service.knoxidf.deploy; + +import org.apache.knox.gateway.deploy.DeploymentContext; +import org.apache.knox.gateway.deploy.ServiceDeploymentContributor; +import org.apache.knox.gateway.descriptor.FilterParamDescriptor; +import org.apache.knox.gateway.descriptor.GatewayDescriptor; +import org.apache.knox.gateway.descriptor.ResourceDescriptor; +import org.apache.knox.gateway.topology.Service; +import org.apache.knox.gateway.topology.Topology; +import org.easymock.Capture; +import org.easymock.EasyMock; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.ServiceLoader; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Unit tests for {@link KnoxIDFAdminServiceDeploymentContributor}. + * + * Tests verify: (1) the Service SPI registration (ServiceLoader discovery); + * (2) the values returned by the contributor's property methods (role, name, + * packages, patterns); and (3) that {@code contributeService()} correctly wires + * the gateway descriptor resource with the right role and pattern — verifies + * that a deployed KNOXIDF_ADMIN service role produces a resource descriptor + * with the correct role and URL pattern, as required for Knox gateway routing. + */ +public class KnoxIDFAdminServiceDeploymentContributorTest { + + @Test + public void testRoleAndName() { + final KnoxIDFAdminServiceDeploymentContributor c = + new KnoxIDFAdminServiceDeploymentContributor(); + assertEquals("KNOXIDF_ADMIN", c.getRole()); + assertEquals("KNOXIDF_ADMIN", c.getName()); + } + + @Test + public void testPackages() { + final KnoxIDFAdminServiceDeploymentContributor c = + new KnoxIDFAdminServiceDeploymentContributor(); + final String[] packages = c.getPackages(); + assertNotNull(packages); + assertTrue("Expected org.apache.knox.gateway.service.knoxidf in packages", + Arrays.asList(packages).contains("org.apache.knox.gateway.service.knoxidf")); + } + + @Test + public void testPatterns() { + final KnoxIDFAdminServiceDeploymentContributor c = + new KnoxIDFAdminServiceDeploymentContributor(); + final String[] patterns = c.getPatterns(); + assertNotNull(patterns); + // Same prefix as KnoxIDFServiceDeploymentContributor so both roles can coexist in a single + // topology; Jersey disambiguates by @Path annotation. + assertTrue("Expected knoxidf/api/**?** in patterns", + Arrays.asList(patterns).contains("knoxidf/api/**?**")); + } + + @Test + public void testServiceLoaderDiscovery() { + for (ServiceDeploymentContributor c : + ServiceLoader.load(ServiceDeploymentContributor.class)) { + if (c instanceof KnoxIDFAdminServiceDeploymentContributor) { + assertEquals("KNOXIDF_ADMIN", c.getRole()); + assertEquals("KNOXIDF_ADMIN", c.getName()); + return; + } + } + fail("KnoxIDFAdminServiceDeploymentContributor not discoverable via ServiceLoader"); + } + + /** + * Verifies that {@code contributeService()} sets the correct service role and URL pattern + * on the gateway resource descriptor. This exercises the inherited + * {@code JerseyServiceDeploymentContributorBase.contributeService()} with the concrete + * values from {@code getPackages()} and {@code getPatterns()}. + */ + @Test + public void testContributeService() throws Exception { + final KnoxIDFAdminServiceDeploymentContributor contributor = + new KnoxIDFAdminServiceDeploymentContributor(); + + // Mock FilterParamDescriptor for the jersey.config.server.provider.packages param. + final FilterParamDescriptor param = EasyMock.createNiceMock(FilterParamDescriptor.class); + EasyMock.expect(param.name(EasyMock.anyString())).andReturn(param).anyTimes(); + EasyMock.expect(param.value(EasyMock.anyString())).andReturn(param).anyTimes(); + EasyMock.replay(param); + + // Capture role and pattern set on the resource descriptor. + final Capture capturedRole = EasyMock.newCapture(); + final Capture capturedPattern = EasyMock.newCapture(); + final ResourceDescriptor resource = EasyMock.createNiceMock(ResourceDescriptor.class); + EasyMock.expect(resource.role(EasyMock.capture(capturedRole))).andReturn(resource).anyTimes(); + EasyMock.expect(resource.pattern(EasyMock.capture(capturedPattern))) + .andReturn(resource).anyTimes(); + EasyMock.expect(resource.createFilterParam()).andReturn(param).anyTimes(); + EasyMock.expect(resource.filters()).andReturn(Collections.emptyList()).anyTimes(); + EasyMock.replay(resource); + + final GatewayDescriptor descriptor = EasyMock.createNiceMock(GatewayDescriptor.class); + EasyMock.expect(descriptor.addResource()).andReturn(resource).anyTimes(); + EasyMock.replay(descriptor); + + // Use an empty topology so the base-class addXxxFilter calls are no-ops, isolating + // the test to this contributor's specific contributions (role and pattern)" + final Topology topology = new Topology(); + + final DeploymentContext context = EasyMock.createNiceMock(DeploymentContext.class); + EasyMock.expect(context.getGatewayDescriptor()).andReturn(descriptor).anyTimes(); + EasyMock.expect(context.getTopology()).andReturn(topology).anyTimes(); + EasyMock.replay(context); + + final Service service = new Service(); + service.setRole("KNOXIDF_ADMIN"); + service.setName("KNOXIDF_ADMIN"); + + contributor.contributeService(context, service); + + assertEquals("KNOXIDF_ADMIN", capturedRole.getValue()); + assertEquals("knoxidf/api/**?**", capturedPattern.getValue()); + } +} diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/audit/api/Action.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/audit/api/Action.java index 7057d56b07..3a77f60bbe 100644 --- a/gateway-util-common/src/main/java/org/apache/knox/gateway/audit/api/Action.java +++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/audit/api/Action.java @@ -31,5 +31,6 @@ private Action() { public static final String DISPATCH = "dispatch"; public static final String ACCESS = "access"; public static final String WEBSHELL = "webshell"; + public static final String DELEGATION_LIFECYCLE = "delegation-lifecycle"; } diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/audit/api/ResourceType.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/audit/api/ResourceType.java index a9eb211868..7c06240564 100644 --- a/gateway-util-common/src/main/java/org/apache/knox/gateway/audit/api/ResourceType.java +++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/audit/api/ResourceType.java @@ -25,5 +25,6 @@ private ResourceType() { public static final String TOPOLOGY = "topology"; public static final String PRINCIPAL = "principal"; public static final String PROCESS = "process"; + public static final String TRUSTED_ISSUER = "trusted-issuer"; } diff --git a/pom.xml b/pom.xml index c0cd5a64ea..962be0f7a6 100644 --- a/pom.xml +++ b/pom.xml @@ -259,6 +259,7 @@ 2.2.8 4.1.135.Final 10.9.1 + 11.37.2 v22.20.0 11.37.2 4.12.0