The method only re-encodes the payload; the original header and signature
+ * parts are preserved unchanged. Because the Cedarling adapter is configured
+ * with {@code jwtSigValidation(false)} the modified payload will still pass
+ * validation inside the policy engine.
+ *
+ *
These tests exercise the Cedarling policy engine end-to-end using real JWT access
+ * tokens and a real policy store loaded from {@code src/test/resources/test-policy-store}.
+ *
+ *
The dependency on {@code CedarlingAuthorizationService} is intentionally removed:
+ * the test wires the native {@link CedarlingAdapter} directly, so it validates the
+ * engine behaviour without any service-layer indirection.
+ *
+ *
tokens = Map.of(ACCESS_TOKEN_KEY, accessToken);
+ try {
+ JSONObject resourceObject = new JSONObject(Optional.ofNullable(
+ buildResource(permId, path)).map(Map.class::cast).orElse(Collections.emptyMap()));
+
+ JSONObject contextObject = new JSONObject(Optional.ofNullable(
+ buildContext()).map(Map.class::cast).orElse(Collections.emptyMap()));
+
+ MultiIssuerAuthorizeResult res = cedarlingAdapter.authorizeMultiIssuer(tokens, action, resourceObject, contextObject);
+
+ if (res == null) {
+ log.error("Authorization response is empty for request with tokens: {}, action: {}, resource: {}, context: {}",
+ tokens, action, resourceObject, contextObject);
+ return false;
+ }
+
+ String requestId = res.getRequestId();
+ log.info("Authorization workload decision {} for requestId {}, tokens: {}, action: {}, resource: {}, context: {}",
+ res.getDecision(), requestId, tokens, action, resourceObject, contextObject);
+
+ return res.getDecision();
+ } catch (Exception e) {
+ log.error("Authorization call failed for permId={} path={}: {}", permId, path, e.getMessage(), e);
+ return false;
+ }
+ }
+
+ /**
+ * Constructs the Cedar resource map exactly as
+ * {@code CedarlingProtectionService.getCedarlingResource()} does at runtime.
+ *
+ * The numeric {@code id} inside {@code cedar_entity_mapping} is derived from
+ * {@link CedarlingPermission#hashCode()}, matching production behaviour exactly.
+ *
+ * @param permId the permission ID ({@code @ProtectedCedarlingApi.id()})
+ * @param path the endpoint path ({@code @ProtectedCedarlingApi.path()})
+ * @return resource map ready for the native authorize call
+ */
+ private static Map buildResource(String permId, String path) {
+ Map resource = new HashMap<>();
+ resource.put("cedar_entity_mapping",
+ Map.of("entity_type", RESOURCE_TYPE, "id", permId));
+ resource.put("url",
+ Map.of("host", "", "path", path, "protocol", ""));
+ resource.put("header", Collections.emptyMap());
+
+ return resource;
+ }
+
+ /**
+ * Returns an empty context map – mirrors {@code CedarlingProtectionService.getCedarlingContext()}.
+ */
+ private static Map buildContext() {
+ return Collections.emptyMap();
+ }
+}
\ No newline at end of file
diff --git a/jans-lock/lock-server/cedarling/src/test/java/io/jans/lock/cedarling/service/CedarlingAuthorizationServiceIntegrationTest.java b/jans-lock/lock-server/cedarling/src/test/java/io/jans/lock/cedarling/service/CedarlingAuthorizationServiceIntegrationTest.java
new file mode 100644
index 00000000000..cc4de34ab44
--- /dev/null
+++ b/jans-lock/lock-server/cedarling/src/test/java/io/jans/lock/cedarling/service/CedarlingAuthorizationServiceIntegrationTest.java
@@ -0,0 +1,332 @@
+/*
+ * Janssen Project software is available under the Apache License (2004). See http://www.apache.org/licenses/ for full text.
+ *
+ * Copyright (c) 2026, Janssen Project
+ */
+package io.jans.lock.cedarling.service;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.jans.lock.cedarling.BaseCedarlingTest;
+import io.jans.lock.cedarling.model.CedarlingPermission;
+import io.jans.lock.model.config.AppConfiguration;
+import io.jans.lock.model.config.cedarling.CedarlingConfiguration;
+import io.jans.lock.model.config.cedarling.CedarlingPolicyConfiguration;
+import io.jans.lock.model.config.cedarling.LogLevel;
+import io.jans.lock.model.config.cedarling.LogType;
+
+/**
+ * Integration tests for {@link CedarlingAuthorizationService}.
+ *
+ * These tests exercise the Cedarling policy engine end-to-end using real JWT access
+ * tokens and a real policy store loaded from {@code src/test/resources/lock_policy_store.json}.
+ *
+ *
Token matrix
+ *
+ * ┌────────┬──────────────────────────────────────────────────────────────┬──────────────────────────────┐
+ * │ Token │ OAuth scopes │ Allowed endpoints │
+ * ├────────┼──────────────────────────────────────────────────────────────┼──────────────────────────────┤
+ * │ JWT 1 │ lock/health.write, lock/telemetry.write, lock/log.write │ /log, /health, /telemetry │
+ * │ JWT 2 │ lock/health.write │ /health only │
+ * │ JWT 3 │ lock/log.write │ /log only │
+ * └────────┴──────────────────────────────────────────────────────────────┴──────────────────────────────┘
+ *
+ *
+ * JWT expiry: The tokens are real tokens whose {@code exp} claim is in
+ * the past. Before each test run the payloads are patched (base64-decode → update exp →
+ * base64-encode) to a value 1 hour in the future. Because the adapter is initialised with
+ * {@code jwtSigValidation(false)} the modified signature is still accepted by Cedarling.
+ *
+ *
Prerequisites: The native Cedarling UniFFI library must be on the
+ * dynamic-library path ({@code LD_LIBRARY_PATH} / {@code java.library.path}).
+ *
+ * @author Yuriy Movchan Date: 12/05/2026
+ */
+@TestInstance(Lifecycle.PER_CLASS)
+@DisplayName("CedarlingAuthorizationService – Integration Tests")
+class CedarlingAuthorizationServiceIntegrationTest extends BaseCedarlingTest {
+
+ // ─── Raw JWT strings (exp claims are patched at runtime) ────────────────────
+
+ /**
+ * JWT 1 – contains all three scopes:
+ * {@code health.write}, {@code telemetry.write}, {@code log.write}.
+ */
+ private static final String RAW_JWT_1 =
+ "eyJraWQiOiJjb25uZWN0XzNjMzJjMTkzLTY5ZjYtNDBkYS1iNmQyLTE3ODY0YmJkYzU1MV9zaWdfcnMyNTYiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9"
+ + ".eyJhdWQiOiIwZTk5NDAzMC1lZTg4LTRmMTQtYWUwOC04YjBkYTEwNDQwMjkiLCJzdWIiOiIwZTk5NDAzMC1lZTg4LTRmMTQtYWUwOC04YjBkYTEwNDQwMjkiLCJ4NXQjUzI1NiI6IiIsIm5iZiI6MTc3ODYxNDA2Niwic2NvcGUiOlsiaHR0cHM6Ly9qYW5zLmlvL29hdXRoL2xvY2svaGVhbHRoLndyaXRlIiwiaHR0cHM6Ly9qYW5zLmlvL29hdXRoL2xvY2svdGVsZW1ldHJ5LndyaXRlIiwiaHR0cHM6Ly9qYW5zLmlvL29hdXRoL2xvY2svbG9nLndyaXRlIl0sImlzcyI6Imh0dHBzOi8vamFucy1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8iLCJ0b2tlbl90eXBlIjoiQmVhcmVyIiwiZXhwIjoxNzc4NjE0MzY2LCJpYXQiOjE3Nzg2MTQwNjYsImNsaWVudF9pZCI6IjBlOTk0MDMwLWVlODgtNGYxNC1hZTA4LThiMGRhMTA0NDAyOSIsImp0aSI6IkhNdjluYnBiUkRLRTAzNVRUTkgwT3ciLCJzdGF0dXMiOnsic3RhdHVzX2xpc3QiOnsiaWR4Ijo0MDAsInVyaSI6Imh0dHBzOi8vamFucy1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8vamFucy1hdXRoL3Jlc3R2MS9zdGF0dXNfbGlzdCJ9fX0"
+ + ".bNXIL4f1lqvLoS49iMSZJORD2mJ9MWYCM5a8nyAiLqKy_fEqvqb-g1X6SgVeS2dJ9aFV-KRrfcjl0zSSQq6mBn-1pAostlMgV-lkOBi7rCbJUMwmdN7Bv7Op8EyuD44_4hHRYhAXOXYv1CcjkyXtv-A9gDxNjHvhHVvpjaizcIMXVRrPxTTQgZF7r7n0t13La2E0vOxzzsgcWQjJukAY8HYybtoRL4JFswBIWPcgET9Btg9mZghDMlvs0yiLVQfiGUZYcmxCCEQinjtutKgONP0Gv6xVMdsXMUpgXGZi6PCiEaEWButMwBauc9RJWEHbd7C4muKoAQ6_tFNuS_eoRw";
+
+ /**
+ * JWT 2 – contains only {@code health.write} scope.
+ */
+ private static final String RAW_JWT_2 =
+ "eyJraWQiOiJjb25uZWN0XzNjMzJjMTkzLTY5ZjYtNDBkYS1iNmQyLTE3ODY0YmJkYzU1MV9zaWdfcnMyNTYiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9"
+ + ".eyJhdWQiOiIwZTk5NDAzMC1lZTg4LTRmMTQtYWUwOC04YjBkYTEwNDQwMjkiLCJzdWIiOiIwZTk5NDAzMC1lZTg4LTRmMTQtYWUwOC04YjBkYTEwNDQwMjkiLCJ4NXQjUzI1NiI6IiIsIm5iZiI6MTc3ODYxNDE2MSwic2NvcGUiOlsiaHR0cHM6Ly9qYW5zLmlvL29hdXRoL2xvY2svaGVhbHRoLndyaXRlIl0sImlzcyI6Imh0dHBzOi8vamFucy1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8iLCJ0b2tlbl90eXBlIjoiQmVhcmVyIiwiZXhwIjoxNzc4NjE0NDYxLCJpYXQiOjE3Nzg2MTQxNjEsImNsaWVudF9pZCI6IjBlOTk0MDMwLWVlODgtNGYxNC1hZTA4LThiMGRhMTA0NDAyOSIsImp0aSI6InRVMGVQb0haU0RPSjJ5Z0EyZFRnc3ciLCJzdGF0dXMiOnsic3RhdHVzX2xpc3QiOnsiaWR4Ijo0MDEsInVyaSI6Imh0dHBzOi8vamFucy1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8vamFucy1hdXRoL3Jlc3R2MS9zdGF0dXNfbGlzdCJ9fX0"
+ + ".iIN4rKz4wnGgijhNWJqY_RqYNx_7zT0hdevnU6wqRwiLp3rQG3c4ouv8P6X4CbiaxERzABbrjsS-4JcW2H2oLpAsuJGhJtr-HExe3iLs_OQ2_4NDwo0k2KJ5e_zGP6Wykr6mQ8WhvGIfURk1aLirLCsegKhH1b26tSp6i8z7z-etNLwGjVPDfw6vV01kYJ0_O_tSf0HuLkGTPf34ld86CUNbPf2cE9Q4uqX_3xVTtMW0ffmOhDo8Qs2dL96xs8O6ah-Rvp6UVjcD4A1qbVImN6USE70nEndmtDR_rvfsCBiL-htkgChTDZymceTcOn00NOvWB2I00rvSy7FdWwNAFQ";
+
+ /**
+ * JWT 3 – contains only {@code log.write} scope.
+ */
+ private static final String RAW_JWT_3 =
+ "eyJraWQiOiJjb25uZWN0XzNjMzJjMTkzLTY5ZjYtNDBkYS1iNmQyLTE3ODY0YmJkYzU1MV9zaWdfcnMyNTYiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9"
+ + ".eyJhdWQiOiIwZTk5NDAzMC1lZTg4LTRmMTQtYWUwOC04YjBkYTEwNDQwMjkiLCJzdWIiOiIwZTk5NDAzMC1lZTg4LTRmMTQtYWUwOC04YjBkYTEwNDQwMjkiLCJ4NXQjUzI1NiI6IiIsIm5iZiI6MTc3ODYxNzc2MSwic2NvcGUiOlsiaHR0cHM6Ly9qYW5zLmlvL29hdXRoL2xvY2svbG9nLndyaXRlIl0sImlzcyI6Imh0dHBzOi8vamFucy1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8iLCJ0b2tlbl90eXBlIjoiQmVhcmVyIiwiZXhwIjoxNzc4NjE4MDYxLCJpYXQiOjE3Nzg2MTc3NjEsImNsaWVudF9pZCI6IjBlOTk0MDMwLWVlODgtNGYxNC1hZTA4LThiMGRhMTA0NDAyOSIsImp0aSI6IjVxUWRHYWl4VEgybkQ5Z0Y2WDFPaXciLCJzdGF0dXMiOnsic3RhdHVzX2xpc3QiOnsiaWR4Ijo1MDAsInVyaSI6Imh0dHBzOi8vamFucy1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8vamFucy1hdXRoL3Jlc3R2MS9zdGF0dXNfbGlzdCJ9fX0"
+ + ".Q7xieptgb5r9eXqjI5BCSDv_ITtzZXbsXoyqcjsYw0PonF6z3c5XjiSPPrXVUU9dY_HQUrd4ib3U7oIQrKtfXcjJ2pMNuTZ0vPRCcZM_XqqbV3IewUbztabDKDNpK0pSaNZy9V1SslHjW_vQoVDnclJL-w2usyXlMVnFub92GV3ldBZ9cB4UYVRovrzG_UxCa8FI-WkikYoET-vIiHbS5yP3EXlRKwP2pWwhHKwhAC7sjbnYW8ApgYVAmvAnWqwPcaY_Bl-UobDHGBr0b0FhLtMIZvGevo1KdQE5dJwiflZOgiUZiYJU9uJ-tklD2gd5Pq-7g1-DW9Fvsmo2WVDcHw";
+
+ // ─── @ProtectedCedarlingApi annotation parameter constants ──────────────────
+
+ /** Cedar action that maps to HTTP POST requests. */
+ private static final String ACTION_POST = "Jans::Action::\"POST\"";
+
+ /** Cedar entity type for HTTP requests (note: trailing quote is intentional,
+ * it matches the value used in the real @ProtectedCedarlingApi annotations). */
+ private static final String RESOURCE_TYPE = "Jans::HTTP_Request";
+
+ // Permission IDs – must match the {@code id} attribute of each annotation
+ private static final String ID_LOG = "lock_audit_log_write";
+ private static final String ID_HEALTH = "lock_audit_health_write";
+ private static final String ID_TELEMETRY = "lock_audit_telemetry_write";
+
+ // Endpoint paths – must match the {@code path} attribute of each annotation
+ private static final String PATH_LOG = "/audit/log/bulk";
+ private static final String PATH_HEALTH = "/audit/health/bulk";
+ private static final String PATH_TELEMETRY = "/audit/health/bulk";
+
+ /**
+ * The token map key expected by Cedarling's native authorize input.
+ * Taken verbatim from {@code CedarlingAuthorizationService.CEDARLING_JANS_ACCESS_TOKEN}.
+ */
+ private static final String ACCESS_TOKEN_KEY = CedarlingAuthorizationService.CEDARLING_JANS_ACCESS_TOKEN;
+
+ // ─── Service under test + runtime-patched tokens ────────────────────────────
+
+ private CedarlingAuthorizationService authService;
+
+ /**
+ * JWT tokens with their {@code exp} claims patched to one hour in the future.
+ * Patched once before all tests to keep the value stable across the entire run.
+ */
+ private String jwt1;
+ private String jwt2;
+ private String jwt3;
+
+ // ─── Lifecycle ──────────────────────────────────────────────────────────────
+
+ /**
+ * Performs one-time initialisation before any test in this class runs:
+ *
+ * - Loads {@code lock_policy_store.json} from the test classpath.
+ * - Creates a fully wired {@link CedarlingAuthorizationService} via field
+ * injection (bypassing CDI) and calls its {@code @PostConstruct} method.
+ * - Patches the {@code exp} claims of all three JWT tokens.
+ *
+ */
+ @BeforeAll
+ void setUpServiceAndTokens() throws Exception {
+ // ── 1. Load policy store from test resources ──────────────────────────
+ String currentDir = System.getProperty("user.dir");
+ String policyStoreFn = currentDir + "/target/test-classes/test-policy-store";
+
+ // ── 2. Build mocked CDI dependencies ─────────────────────────────────
+ Logger log = LoggerFactory.getLogger(CedarlingAuthorizationService.class);
+ AppConfiguration appConfiguration = mock(AppConfiguration.class);
+ CedarlingPolicyConfiguration policyConfig = mock(CedarlingPolicyConfiguration.class);
+ CedarlingConfiguration cedarConf = mock(CedarlingConfiguration.class);
+
+ when(cedarConf.isEnabled()).thenReturn(true);
+ // Use null for log-related settings; BootstrapConfig treats them as "disabled" / default
+ when(cedarConf.getLogType()).thenReturn(LogType.STD_OUT);
+ when(cedarConf.getLogLevel()).thenReturn(LogLevel.TRACE);
+ when(appConfiguration.getCedarlingConfiguration()).thenReturn(cedarConf);
+
+ // ── 3. Wire the service manually (CDI is not available in unit tests) ─
+ authService = new CedarlingAuthorizationService();
+ injectField(authService, "log", log);
+ injectField(authService, "appConfiguration", appConfiguration);
+ injectField(authService, "policyConfiguration", policyConfig);
+ injectField(authService, "policyStoreLocalFn", policyStoreFn);
+
+ // Trigger @PostConstruct – initialises CedarlingAdapter with the real policy
+ authService.init();
+
+ // ── 4. Patch exp claims so the tokens are not expired during the test run
+ jwt1 = withFutureExp(RAW_JWT_1);
+ jwt2 = withFutureExp(RAW_JWT_2);
+ jwt3 = withFutureExp(RAW_JWT_3);
+ }
+
+ /** Calls the adapter's {@code close()} after the entire test class completes. */
+ @AfterAll
+ void tearDown() {
+ if (authService != null) {
+ authService.destroy();
+ }
+ }
+
+ // ===========================================================================
+ // JWT 1 – scopes: health.write + telemetry.write + log.write
+ // Expected: all three endpoints are ALLOWED
+ // ===========================================================================
+ @Nested
+ @DisplayName("JWT 1 (health.write + telemetry.write + log.write)")
+ class Jwt1Tests {
+
+ @Test
+ @DisplayName("POST /audit/log/bulk → ALLOWED (log.write scope present)")
+ void jwt1_logEndpoint_isAllowed() {
+ assertTrue(
+ authorize(jwt1, ACTION_POST, ID_LOG, PATH_LOG),
+ "JWT 1 must be authorized for /audit/log/bulk");
+ }
+
+ @Test
+ @DisplayName("POST /audit/health/bulk → ALLOWED (health.write scope present)")
+ void jwt1_healthEndpoint_isAllowed() {
+ assertTrue(
+ authorize(jwt1, ACTION_POST, ID_HEALTH, PATH_HEALTH),
+ "JWT 1 must be authorized for /audit/health/bulk");
+ }
+
+ @Test
+ @DisplayName("POST /audit/telemetry → ALLOWED (telemetry.write scope present)")
+ void jwt1_telemetryEndpoint_isAllowed() {
+ assertTrue(
+ authorize(jwt1, ACTION_POST, ID_TELEMETRY, PATH_TELEMETRY),
+ "JWT 1 must be authorized for /audit/telemetry");
+ }
+ }
+
+ // ===========================================================================
+ // JWT 2 – scopes: health.write only
+ // Expected: /health endpoint ALLOWED; /log and /telemetry endpoints DENIED
+ // ===========================================================================
+ @Nested
+ @DisplayName("JWT 2 (health.write only)")
+ class Jwt2Tests {
+
+ @Test
+ @DisplayName("POST /audit/health/bulk → ALLOWED (health.write scope present)")
+ void jwt2_healthEndpoint_isAllowed() {
+ assertTrue(
+ authorize(jwt2, ACTION_POST, ID_HEALTH, PATH_HEALTH),
+ "JWT 2 must be authorized for /audit/health/bulk");
+ }
+
+ @Test
+ @DisplayName("POST /audit/log/bulk → DENIED (log.write scope absent)")
+ void jwt2_logEndpoint_isDenied() {
+ assertFalse(
+ authorize(jwt2, ACTION_POST, ID_LOG, PATH_LOG),
+ "JWT 2 must NOT be authorized for /audit/log/bulk (missing log.write)");
+ }
+
+ @Test
+ @DisplayName("POST /audit/telemetry → DENIED (telemetry.write scope absent)")
+ void jwt2_telemetryEndpoint_isDenied() {
+ assertFalse(
+ authorize(jwt2, ACTION_POST, ID_TELEMETRY, PATH_TELEMETRY),
+ "JWT 2 must NOT be authorized for /audit/telemetry (missing telemetry.write)");
+ }
+ }
+
+ // ===========================================================================
+ // JWT 3 – scopes: log.write only
+ // Expected: /log endpoint ALLOWED; /health and /telemetry endpoints DENIED
+ // ===========================================================================
+ @Nested
+ @DisplayName("JWT 3 (log.write only)")
+ class Jwt3Tests {
+
+ @Test
+ @DisplayName("POST /audit/log/bulk → ALLOWED (log.write scope present)")
+ void jwt3_logEndpoint_isAllowed() {
+ assertTrue(
+ authorize(jwt3, ACTION_POST, ID_LOG, PATH_LOG),
+ "JWT 3 must be authorized for /audit/log/bulk");
+ }
+
+ @Test
+ @DisplayName("POST /audit/health/bulk → DENIED (health.write scope absent)")
+ void jwt3_healthEndpoint_isDenied() {
+ assertFalse(
+ authorize(jwt3, ACTION_POST, ID_HEALTH, PATH_HEALTH),
+ "JWT 3 must NOT be authorized for /audit/health/bulk (missing health.write)");
+ }
+
+ @Test
+ @DisplayName("POST /audit/telemetry → DENIED (telemetry.write scope absent)")
+ void jwt3_telemetryEndpoint_isDenied() {
+ assertFalse(
+ authorize(jwt3, ACTION_POST, ID_TELEMETRY, PATH_TELEMETRY),
+ "JWT 3 must NOT be authorized for /audit/telemetry (missing telemetry.write)");
+ }
+ }
+
+ // ===========================================================================
+ // Helpers
+ // ===========================================================================
+
+ /**
+ * Calls {@link CedarlingAuthorizationService#authorize} with a resource map
+ * built the same way as {@code CedarlingProtectionService.getCedarlingResource()}.
+ *
+ * @param accessToken the JWT access token (after exp patching)
+ * @param action Cedar action string (e.g. {@code Jans::Action::"POST"})
+ * @param permId permission id from {@code @ProtectedCedarlingApi.id()}
+ * @param path endpoint path from {@code @ProtectedCedarlingApi.path()}
+ * @return {@code true} when Cedarling grants the request
+ */
+ private boolean authorize(String accessToken, String action, String permId, String path) {
+ Map tokens = Map.of(ACCESS_TOKEN_KEY, accessToken);
+
+ return authService.authorize(tokens, action, buildResource(permId, path), buildContext());
+ }
+
+ /**
+ * Constructs the Cedar resource map that mirrors what
+ * {@code CedarlingProtectionService.getCedarlingResource()} produces at runtime.
+ *
+ * The numeric {@code id} inside {@code cedar_entity_mapping} is derived from the
+ * {@link CedarlingPermission#hashCode()} of a permission built with the supplied
+ * arguments, matching the production code exactly.
+ *
+ * @param permId the permission ID ({@code @ProtectedCedarlingApi.id()})
+ * @param path the endpoint path ({@code @ProtectedCedarlingApi.path()})
+ * @return resource map ready for {@code CedarlingAuthorizationService.authorize()}
+ */
+ private static Map buildResource(String permId, String path) {
+ Map resource = new HashMap<>();
+ resource.put("cedar_entity_mapping",
+ Map.of("entity_type", RESOURCE_TYPE, "id", permId));
+ resource.put("url",
+ Map.of("host", "", "path", path, "protocol", ""));
+ resource.put("header", Collections.emptyMap());
+
+ return resource;
+ }
+
+ /**
+ * Returns an empty context map. The production code also sends an empty map,
+ * so we match that behaviour here.
+ */
+ private static Map buildContext() {
+ return Collections.emptyMap();
+ }
+}
\ No newline at end of file
diff --git a/jans-lock/lock-server/cedarling/src/test/java/io/jans/lock/cedarling/service/CedarlingProtectionServiceTest.java b/jans-lock/lock-server/cedarling/src/test/java/io/jans/lock/cedarling/service/CedarlingProtectionServiceTest.java
new file mode 100644
index 00000000000..d1b9ee88c1b
--- /dev/null
+++ b/jans-lock/lock-server/cedarling/src/test/java/io/jans/lock/cedarling/service/CedarlingProtectionServiceTest.java
@@ -0,0 +1,694 @@
+/*
+ * Janssen Project software is available under the Apache License (2004). See http://www.apache.org/licenses/ for full text.
+ *
+ * Copyright (c) 2026, Janssen Project
+ */
+
+package io.jans.lock.cedarling.service;
+
+import static jakarta.ws.rs.core.Response.Status.FORBIDDEN;
+import static jakarta.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR;
+import static jakarta.ws.rs.core.Response.Status.UNAUTHORIZED;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.lang.reflect.Field;
+import java.net.URL;
+import java.util.Map;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedConstruction;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.slf4j.Logger;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import io.jans.as.client.OpenIdConfigurationResponse;
+import io.jans.as.model.crypto.AuthCryptoProvider;
+import io.jans.as.model.crypto.signature.SignatureAlgorithm;
+import io.jans.as.model.exception.InvalidJwtException;
+import io.jans.as.model.jwt.Jwt;
+import io.jans.as.model.jwt.JwtClaimName;
+import io.jans.as.model.jwt.JwtClaims;
+import io.jans.as.model.jwt.JwtHeader;
+import io.jans.lock.cedarling.service.security.api.ProtectedCedarlingApi;
+import io.jans.lock.model.config.AppConfiguration;
+import jakarta.ws.rs.container.ResourceInfo;
+import jakarta.ws.rs.core.Response;
+
+/**
+ * Unit tests for {@link CedarlingProtectionService#processAuthorization}.
+ *
+ * The method under test implements a multi-step authorization filter:
+ *
+ * - Presence check – bearer token must be present.
+ * - Permission resolution – at least one {@code @ProtectedCedarlingApi} annotation
+ * must be found on the JAX-RS resource class, method, or its interfaces.
+ * - JWT validation – token must be parseable as a JWT.
+ * - Issuer check – JWT {@code iss} claim must match the OIDC discovery document.
+ * - Expiry check – JWT {@code exp} claim must be in the future.
+ * - Algorithm check – HMAC-family algorithms are rejected.
+ * - Signature verification – cryptographic signature must be valid.
+ * - Cedarling policy evaluation – the Cedarling engine must grant access
+ * for every declared permission.
+ *
+ *
+ * A {@code null} return value from {@code processAuthorization} means
+ * "pass-through" – the JAX-RS filter will allow the request to continue.
+ *
+ * @author Author Date: 12/05/2026
+ */
+@ExtendWith(MockitoExtension.class)
+class CedarlingProtectionServiceTest {
+
+ // ─── Constants ──────────────────────────────────────────────────────────────
+
+ private static final String TEST_ISSUER = "https://idp.example.com";
+ private static final String TEST_JWKS_URI = "https://idp.example.com/jwks";
+ /** Minimal three-part placeholder used wherever a JWT string is required. */
+ private static final String DUMMY_JWT = "header.payload.signature";
+
+ // ─── Mocks & SUT ────────────────────────────────────────────────────────────
+
+ @Mock private Logger log;
+ @Mock private AppConfiguration appConfiguration;
+ @Mock private CedarlingAuthorizationService authorizationService;
+ /** Injected as a mock so that JWKS HTTP calls can be stubbed without networking. */
+ @Mock private ObjectMapper mapper;
+
+ @InjectMocks
+ private CedarlingProtectionService service;
+
+ // ─── JAX-RS Resource Fixtures ───────────────────────────────────────────────
+
+ /**
+ * A plain resource class that carries no {@code @ProtectedCedarlingApi}
+ * annotation and implements no interfaces. Used to simulate "empty
+ * permissions" scenarios.
+ */
+ static class PlainResource {
+ public void plainMethod() { /* no-op */ }
+ }
+
+ /**
+ * An interface-level {@code @ProtectedCedarlingApi} annotation.
+ * The service resolves permissions by walking the resource class's
+ * interface hierarchy, so placing the annotation here is the most
+ * common real-world pattern.
+ */
+ @ProtectedCedarlingApi(
+ action = "Jans::Action::\"POST\"",
+ resource = "Jans::HTTP_Request",
+ id = "lock_audit_log_write",
+ path = "/audit/log/bulk"
+ )
+ interface SecuredInterface {
+ void securedMethod();
+ }
+
+ /** Resource class that inherits its permission from {@link SecuredInterface}. */
+ static class SecuredResource implements SecuredInterface {
+ @Override
+ public void securedMethod() { /* no-op */ }
+ }
+
+ /**
+ * A resource class that has a {@code @ProtectedCedarlingApi} on the class
+ * itself AND on the method, producing two permissions. Used to verify
+ * short-circuit behaviour when the first permission is denied.
+ */
+ @ProtectedCedarlingApi(
+ action = "Jans::Action::\"POST\"",
+ resource = "Jans::HTTP_Request",
+ id = "lock_audit_health_write",
+ path = "/audit/health/bulk"
+ )
+ static class MultiPermissionResource {
+ @ProtectedCedarlingApi(
+ action = "Jans::Action::\"POST\"",
+ resource = "Jans::HTTP_Request",
+ id = "lock_audit_log_write",
+ path = "/audit/log/bulk"
+ )
+ public void multiMethod() { /* no-op */ }
+ }
+
+ // ─── Setup ──────────────────────────────────────────────────────────────────
+
+ /**
+ * Injects the fields that are normally initialised in the {@code @PostConstruct}
+ * lifecycle method. We bypass the real {@code init()} to avoid network calls
+ * during unit tests.
+ */
+ @BeforeEach
+ void setUp() throws Exception {
+ // Inject the mocked ObjectMapper (avoids real HTTP for JWKS fetches)
+ injectField("mapper", mapper);
+
+ // Build a minimal OpenID configuration stub
+ OpenIdConfigurationResponse oidcConfig = mock(OpenIdConfigurationResponse.class);
+ lenient().when(oidcConfig.getIssuer()).thenReturn(TEST_ISSUER);
+ lenient().when(oidcConfig.getJwksUri()).thenReturn(TEST_JWKS_URI);
+ injectField("oidcConfig", oidcConfig);
+ }
+
+ // ===========================================================================
+ // 1. Missing / blank bearer token → 401 Unauthorized
+ // ===========================================================================
+
+ @Nested
+ @DisplayName("Bearer token presence check")
+ class BearerTokenPresenceTests {
+
+ @Test
+ @DisplayName("null token returns 401")
+ void nullToken_returns401() {
+ Response response = service.processAuthorization(null, mock(ResourceInfo.class));
+
+ assertStatus(UNAUTHORIZED, response);
+ }
+
+ @Test
+ @DisplayName("empty string token returns 401")
+ void emptyToken_returns401() {
+ Response response = service.processAuthorization("", mock(ResourceInfo.class));
+
+ assertStatus(UNAUTHORIZED, response);
+ }
+ }
+
+ // ===========================================================================
+ // 2. No @ProtectedCedarlingApi annotation resolved → 500 Internal Server Error
+ // ===========================================================================
+
+ @Nested
+ @DisplayName("Permission annotation resolution")
+ class PermissionResolutionTests {
+
+ @Test
+ @DisplayName("resource with no annotation on class, method, or interfaces returns 500")
+ void noAnnotation_returns500() {
+ // PlainResource has no @ProtectedCedarlingApi and no interfaces,
+ // so the permissions list stays empty.
+ Response response = service.processAuthorization(
+ "Bearer some-token-value",
+ mockResourceInfo(PlainResource.class, "plainMethod"));
+
+ assertStatus(INTERNAL_SERVER_ERROR, response);
+ assertNotNull(response.getEntity(), "Error detail should be present in response body");
+ }
+ }
+
+ // ===========================================================================
+ // 3. Token is not a valid JWT → 403 Forbidden
+ // ===========================================================================
+
+ @Nested
+ @DisplayName("JWT parsing")
+ class JwtParsingTests {
+
+ @Test
+ @DisplayName("plain text token (not JWT-encoded) returns 403")
+ void plainTextToken_returns403() {
+ // Jwt.parse() will throw InvalidJwtException for a non-JWT string,
+ // causing tokenAsJwt() to return null.
+ Response response = service.processAuthorization(
+ "Bearer i-am-not-a-jwt",
+ mockResourceInfo(SecuredResource.class, "securedMethod"));
+
+ assertStatus(FORBIDDEN, response);
+ assertTrue(
+ response.getEntity().toString().contains("JWT"),
+ "Response body should mention JWT");
+ }
+
+ @Test
+ @DisplayName("random base64 string that looks like JWT parts returns 403")
+ void malformedBase64Token_returns403() {
+ Response response = service.processAuthorization(
+ "Bearer aaa.bbb.ccc",
+ mockResourceInfo(SecuredResource.class, "securedMethod"));
+
+ assertStatus(FORBIDDEN, response);
+ }
+ }
+
+ // ===========================================================================
+ // 4. JWT issuer mismatch → 403 Forbidden
+ // ===========================================================================
+
+ @Nested
+ @DisplayName("JWT issuer validation")
+ class IssuerValidationTests {
+
+ @Test
+ @DisplayName("JWT with wrong issuer returns 403")
+ void wrongIssuer_returns403() throws Exception {
+ try (MockedStatic jwtStatic = mockStatic(Jwt.class)) {
+ Jwt jwt = buildMockJwt("https://untrusted-idp.example.com", futureEpoch(), SignatureAlgorithm.RS256);
+ jwtStatic.when(() -> Jwt.parse(anyString())).thenReturn(jwt);
+
+ Response response = service.processAuthorization(
+ "Bearer " + DUMMY_JWT,
+ mockResourceInfo(SecuredResource.class, "securedMethod"));
+
+ assertStatus(FORBIDDEN, response);
+ assertTrue(
+ response.getEntity().toString().contains("issuer"),
+ "Response body should mention 'issuer'");
+ }
+ }
+ }
+
+ // ===========================================================================
+ // 5. Expired JWT → 403 Forbidden
+ // ===========================================================================
+
+ @Nested
+ @DisplayName("JWT expiry validation")
+ class ExpiryValidationTests {
+
+ @Test
+ @DisplayName("expired JWT returns 403")
+ void expiredJwt_returns403() throws Exception {
+ try (MockedStatic jwtStatic = mockStatic(Jwt.class)) {
+ Jwt jwt = buildMockJwt(TEST_ISSUER, pastEpoch(), SignatureAlgorithm.RS256);
+ jwtStatic.when(() -> Jwt.parse(anyString())).thenReturn(jwt);
+
+ Response response = service.processAuthorization(
+ "Bearer " + DUMMY_JWT,
+ mockResourceInfo(SecuredResource.class, "securedMethod"));
+
+ assertStatus(FORBIDDEN, response);
+ assertTrue(
+ response.getEntity().toString().contains("Expired"),
+ "Response body should mention expiry");
+ }
+ }
+
+ @Test
+ @DisplayName("JWT with exp claim absent (defaults to epoch 0) returns 403")
+ void missingExpClaim_returns403() throws Exception {
+ try (MockedStatic jwtStatic = mockStatic(Jwt.class)) {
+ // When getClaimAsInteger returns null, the service defaults to 0
+ // (epoch 0 = 1970-01-01), so the token is immediately expired.
+ Jwt jwt = buildMockJwtNullExp(TEST_ISSUER, SignatureAlgorithm.RS256);
+ jwtStatic.when(() -> Jwt.parse(anyString())).thenReturn(jwt);
+
+ Response response = service.processAuthorization(
+ "Bearer " + DUMMY_JWT,
+ mockResourceInfo(SecuredResource.class, "securedMethod"));
+
+ assertStatus(FORBIDDEN, response);
+ }
+ }
+ }
+
+ // ===========================================================================
+ // 6. HMAC-family algorithm → 500 Internal Server Error
+ // ===========================================================================
+
+ @Nested
+ @DisplayName("Signing algorithm validation")
+ class AlgorithmValidationTests {
+
+ @Test
+ @DisplayName("HS256 (HMAC) algorithm returns 500 with HMAC message")
+ void hmacAlgorithmHS256_returns500() throws Exception {
+ try (MockedStatic jwtStatic = mockStatic(Jwt.class)) {
+ Jwt jwt = buildMockJwt(TEST_ISSUER, futureEpoch(), SignatureAlgorithm.HS256);
+ jwtStatic.when(() -> Jwt.parse(anyString())).thenReturn(jwt);
+
+ Response response = service.processAuthorization(
+ "Bearer " + DUMMY_JWT,
+ mockResourceInfo(SecuredResource.class, "securedMethod"));
+
+ assertStatus(INTERNAL_SERVER_ERROR, response);
+ assertTrue(
+ response.getEntity().toString().contains("HMAC"),
+ "Response body should explain that HMAC is not allowed");
+ }
+ }
+
+ @Test
+ @DisplayName("HS384 (HMAC) algorithm also returns 500")
+ void hmacAlgorithmHS384_returns500() throws Exception {
+ try (MockedStatic jwtStatic = mockStatic(Jwt.class)) {
+ Jwt jwt = buildMockJwt(TEST_ISSUER, futureEpoch(), SignatureAlgorithm.HS384);
+ jwtStatic.when(() -> Jwt.parse(anyString())).thenReturn(jwt);
+
+ Response response = service.processAuthorization(
+ "Bearer " + DUMMY_JWT,
+ mockResourceInfo(SecuredResource.class, "securedMethod"));
+
+ assertStatus(INTERNAL_SERVER_ERROR, response);
+ }
+ }
+ }
+
+ // ===========================================================================
+ // 7. Signature verification fails → 403 Forbidden
+ // ===========================================================================
+
+ @Nested
+ @DisplayName("JWT signature verification")
+ class SignatureVerificationTests {
+
+ @Test
+ @DisplayName("invalid signature returns 403")
+ void invalidSignature_returns403() throws Exception {
+ try (MockedStatic jwtStatic = mockStatic(Jwt.class);
+ MockedConstruction cryptoMock = mockConstruction(
+ AuthCryptoProvider.class,
+ (mock, ctx) -> when(
+ mock.verifySignature(any(), any(), any(), any(), any(), any()))
+ .thenReturn(false))) {
+
+ Jwt jwt = buildMockJwt(TEST_ISSUER, futureEpoch(), SignatureAlgorithm.RS256);
+ jwtStatic.when(() -> Jwt.parse(anyString())).thenReturn(jwt);
+ when(mapper.readValue(any(URL.class), eq(Map.class))).thenReturn(Map.of());
+
+ Response response = service.processAuthorization(
+ "Bearer " + DUMMY_JWT,
+ mockResourceInfo(SecuredResource.class, "securedMethod"));
+
+ assertStatus(FORBIDDEN, response);
+ }
+ }
+ }
+
+ // ===========================================================================
+ // 8. Cedarling authorization decisions
+ // ===========================================================================
+
+ @Nested
+ @DisplayName("Cedarling policy evaluation")
+ class CedarlingAuthorizationTests {
+
+ @Test
+ @DisplayName("valid JWT + Cedarling grants access → null (pass-through)")
+ void validJwtAndCedarlingGrants_returnsNull() throws Exception {
+ try (MockedStatic jwtStatic = mockStatic(Jwt.class);
+ MockedConstruction cryptoMock = mockConstruction(
+ AuthCryptoProvider.class,
+ (mock, ctx) -> when(
+ mock.verifySignature(any(), any(), any(), any(), any(), any()))
+ .thenReturn(true))) {
+
+ Jwt jwt = buildMockJwt(TEST_ISSUER, futureEpoch(), SignatureAlgorithm.RS256);
+ jwtStatic.when(() -> Jwt.parse(anyString())).thenReturn(jwt);
+ when(mapper.readValue(any(URL.class), eq(Map.class))).thenReturn(Map.of());
+ when(authorizationService.authorize(anyMap(), anyString(), anyMap(), anyMap()))
+ .thenReturn(true);
+
+ Response response = service.processAuthorization(
+ "Bearer " + DUMMY_JWT,
+ mockResourceInfo(SecuredResource.class, "securedMethod"));
+
+ // null is the "proceed" signal for the JAX-RS filter
+ assertNull(response, "Authorized request must return null (pass-through)");
+ }
+ }
+
+ @Test
+ @DisplayName("valid JWT + Cedarling denies access → 403")
+ void validJwtAndCedarlingDenies_returns403() throws Exception {
+ try (MockedStatic jwtStatic = mockStatic(Jwt.class);
+ MockedConstruction cryptoMock = mockConstruction(
+ AuthCryptoProvider.class,
+ (mock, ctx) -> when(
+ mock.verifySignature(any(), any(), any(), any(), any(), any()))
+ .thenReturn(true))) {
+
+ Jwt jwt = buildMockJwt(TEST_ISSUER, futureEpoch(), SignatureAlgorithm.RS256);
+ jwtStatic.when(() -> Jwt.parse(anyString())).thenReturn(jwt);
+ when(mapper.readValue(any(URL.class), eq(Map.class))).thenReturn(Map.of());
+ when(authorizationService.authorize(anyMap(), anyString(), anyMap(), anyMap()))
+ .thenReturn(false);
+
+ Response response = service.processAuthorization(
+ "Bearer " + DUMMY_JWT,
+ mockResourceInfo(SecuredResource.class, "securedMethod"));
+
+ assertStatus(FORBIDDEN, response);
+ }
+ }
+
+ /**
+ * When a resource has multiple permissions (class-level + method-level) and the
+ * first call to {@code authorizationService.authorize()} returns {@code false},
+ * the service must short-circuit and not evaluate further permissions.
+ */
+ @Test
+ @DisplayName("first permission denied short-circuits remaining checks")
+ void multiplePermissions_firstDenied_doesNotCheckRemainder() throws Exception {
+ try (MockedStatic jwtStatic = mockStatic(Jwt.class);
+ MockedConstruction cryptoMock = mockConstruction(
+ AuthCryptoProvider.class,
+ (mock, ctx) -> when(
+ mock.verifySignature(any(), any(), any(), any(), any(), any()))
+ .thenReturn(true))) {
+
+ Jwt jwt = buildMockJwt(TEST_ISSUER, futureEpoch(), SignatureAlgorithm.RS256);
+ jwtStatic.when(() -> Jwt.parse(anyString())).thenReturn(jwt);
+ when(mapper.readValue(any(URL.class), eq(Map.class))).thenReturn(Map.of());
+
+ // First call is denied; second should never be reached
+ when(authorizationService.authorize(anyMap(), anyString(), anyMap(), anyMap()))
+ .thenReturn(false);
+
+ Response response = service.processAuthorization(
+ "Bearer " + DUMMY_JWT,
+ mockResourceInfo(MultiPermissionResource.class, "multiMethod"));
+
+ assertStatus(FORBIDDEN, response);
+ // MultiPermissionResource has 2 permissions; the service must stop after
+ // the first denied result — so authorize() is called exactly once.
+ verify(authorizationService, times(1))
+ .authorize(anyMap(), anyString(), anyMap(), anyMap());
+ }
+ }
+
+ @Test
+ @DisplayName("both permissions granted returns null")
+ void multiplePermissions_allGranted_returnsNull() throws Exception {
+ try (MockedStatic jwtStatic = mockStatic(Jwt.class);
+ MockedConstruction cryptoMock = mockConstruction(
+ AuthCryptoProvider.class,
+ (mock, ctx) -> when(
+ mock.verifySignature(any(), any(), any(), any(), any(), any()))
+ .thenReturn(true))) {
+
+ Jwt jwt = buildMockJwt(TEST_ISSUER, futureEpoch(), SignatureAlgorithm.RS256);
+ jwtStatic.when(() -> Jwt.parse(anyString())).thenReturn(jwt);
+ when(mapper.readValue(any(URL.class), eq(Map.class))).thenReturn(Map.of());
+ when(authorizationService.authorize(anyMap(), anyString(), anyMap(), anyMap()))
+ .thenReturn(true);
+
+ Response response = service.processAuthorization(
+ "Bearer " + DUMMY_JWT,
+ mockResourceInfo(MultiPermissionResource.class, "multiMethod"));
+
+ assertNull(response, "All permissions granted must return null (pass-through)");
+ // Exactly 2 permissions → authorize() called twice
+ verify(authorizationService, times(2))
+ .authorize(anyMap(), anyString(), anyMap(), anyMap());
+ }
+ }
+ }
+
+ // ===========================================================================
+ // 9. Unexpected runtime exception → 500 Internal Server Error
+ // ===========================================================================
+
+ @Nested
+ @DisplayName("Exception handling")
+ class ExceptionHandlingTests {
+
+ @Test
+ @DisplayName("unexpected exception returns 500 with exception message")
+ void unexpectedException_returns500() throws Exception {
+ try (MockedStatic jwtStatic = mockStatic(Jwt.class)) {
+ jwtStatic.when(() -> Jwt.parse(anyString()))
+ .thenThrow(new RuntimeException("Unexpected adapter failure"));
+
+ Response response = service.processAuthorization(
+ "Bearer " + DUMMY_JWT,
+ mockResourceInfo(SecuredResource.class, "securedMethod"));
+
+ assertStatus(INTERNAL_SERVER_ERROR, response);
+ assertEquals(
+ "Unexpected adapter failure",
+ response.getEntity().toString(),
+ "Exception message must be surfaced in the response body");
+ }
+ }
+
+ @Test
+ @DisplayName("JWKS fetch failure returns 500")
+ void jwksFetchFailure_returns500() throws Exception {
+ try (MockedStatic jwtStatic = mockStatic(Jwt.class)) {
+ Jwt jwt = buildMockJwt(TEST_ISSUER, futureEpoch(), SignatureAlgorithm.RS256);
+ jwtStatic.when(() -> Jwt.parse(anyString())).thenReturn(jwt);
+
+ // Simulate an I/O error when fetching the JWKS endpoint
+ when(mapper.readValue(any(URL.class), eq(Map.class)))
+ .thenThrow(new java.io.IOException("Connection refused"));
+
+ Response response = service.processAuthorization(
+ "Bearer " + DUMMY_JWT,
+ mockResourceInfo(SecuredResource.class, "securedMethod"));
+
+ assertStatus(INTERNAL_SERVER_ERROR, response);
+ }
+ }
+ }
+
+ // ===========================================================================
+ // Helper methods
+ // ===========================================================================
+
+ /**
+ * Injects a value into a private field of the service under test using reflection.
+ *
+ * @param fieldName the exact name of the private field
+ * @param value the value to inject
+ */
+ private void injectField(String fieldName, Object value) throws Exception {
+ Field field = CedarlingProtectionService.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(service, value);
+ }
+
+ /**
+ * Creates a {@link ResourceInfo} mock that returns the specified class and method.
+ *
+ * @param resourceClass the JAX-RS resource class to return
+ * @param methodName the public method name to return
+ * @return a configured {@link ResourceInfo} mock
+ */
+ @SuppressWarnings("unchecked")
+ private static ResourceInfo mockResourceInfo(Class> resourceClass, String methodName) {
+ ResourceInfo ri = mock(ResourceInfo.class);
+ try {
+ when(ri.getResourceClass()).thenReturn((Class) resourceClass);
+ when(ri.getResourceMethod()).thenReturn(resourceClass.getMethod(methodName));
+ } catch (NoSuchMethodException e) {
+ fail("Test fixture error – method not found: " + resourceClass.getSimpleName() + "#" + methodName);
+ }
+ return ri;
+ }
+
+ /**
+ * Asserts that the {@link Response} has the expected HTTP status code.
+ *
+ * @param expected expected status
+ * @param actual actual response (must not be null)
+ */
+ private static void assertStatus(Response.Status expected, Response actual) {
+ assertNotNull(actual, "Expected a response but got null (pass-through)");
+ assertEquals(
+ expected.getStatusCode(),
+ actual.getStatus(),
+ String.format("Expected HTTP %d (%s) but got %d",
+ expected.getStatusCode(), expected, actual.getStatus()));
+ }
+
+ /**
+ * Builds a fully mocked {@link Jwt} with the specified issuer, expiry, and
+ * signing algorithm. All fields needed by the service under test are stubbed.
+ *
+ * @param issuer value for the {@code iss} JWT claim
+ * @param expEpoch value for the {@code exp} JWT claim (epoch seconds)
+ * @param algorithm signing algorithm to report via the JWT header
+ * @return a mocked {@link Jwt}
+ * @throws InvalidJwtException
+ */
+ private static Jwt buildMockJwt(String issuer, int expEpoch, SignatureAlgorithm algorithm) throws InvalidJwtException {
+ Jwt jwt = mock(Jwt.class);
+
+ // Stub JWT header
+ JwtHeader header = mock(JwtHeader.class);
+ lenient().when(header.getSignatureAlgorithm()).thenReturn(algorithm);
+ lenient().when(header.getKeyId()).thenReturn("test-key-id");
+ lenient().when(jwt.getHeader()).thenReturn(header);
+
+ // Stub JWT claims
+ JwtClaims claims = mock(JwtClaims.class);
+ lenient().when(claims.getClaimAsString(JwtClaimName.ISSUER)).thenReturn(issuer);
+ lenient().when(claims.getClaimAsInteger(JwtClaimName.EXPIRATION_TIME)).thenReturn(expEpoch);
+ lenient().when(jwt.getClaims()).thenReturn(claims);
+
+ // Stub signing material (needed for signature verification)
+ lenient().when(jwt.getSigningInput()).thenReturn("signing.input");
+ lenient().when(jwt.getEncodedSignature()).thenReturn("encoded-signature");
+
+ return jwt;
+ }
+
+ /**
+ * Same as {@link #buildMockJwt} but returns {@code null} for the {@code exp}
+ * claim, which the service defaults to epoch 0 (i.e. immediately expired).
+ *
+ * @param issuer value for the {@code iss} JWT claim
+ * @param algorithm signing algorithm to report via the JWT header
+ * @return a mocked {@link Jwt} with a null {@code exp} claim
+ * @throws InvalidJwtException
+ */
+ private static Jwt buildMockJwtNullExp(String issuer, SignatureAlgorithm algorithm) throws InvalidJwtException {
+ Jwt jwt = mock(Jwt.class);
+
+ JwtHeader header = mock(JwtHeader.class);
+ lenient().when(header.getSignatureAlgorithm()).thenReturn(algorithm);
+ lenient().when(header.getKeyId()).thenReturn("test-key-id");
+ lenient().when(jwt.getHeader()).thenReturn(header);
+
+ JwtClaims claims = mock(JwtClaims.class);
+ lenient().when(claims.getClaimAsString(JwtClaimName.ISSUER)).thenReturn(issuer);
+ // null triggers Optional.orElse(0) in the service → token is expired
+ lenient().when(claims.getClaimAsInteger(JwtClaimName.EXPIRATION_TIME)).thenReturn(null);
+ lenient().when(jwt.getClaims()).thenReturn(claims);
+
+ lenient().when(jwt.getSigningInput()).thenReturn("signing.input");
+ lenient().when(jwt.getEncodedSignature()).thenReturn("encoded-signature");
+
+ return jwt;
+ }
+
+ /**
+ * Returns an {@code exp} value (epoch seconds) 1 hour in the future.
+ * Tokens with this value are not yet expired.
+ */
+ private static int futureEpoch() {
+ return (int) (System.currentTimeMillis() / 1000) + 3600;
+ }
+
+ /**
+ * Returns an {@code exp} value (epoch seconds) 1 hour in the past.
+ * Tokens with this value are expired.
+ */
+ private static int pastEpoch() {
+ return (int) (System.currentTimeMillis() / 1000) - 3600;
+ }
+}
\ No newline at end of file
diff --git a/jans-lock/lock-server/cedarling/src/test/java/io/jans/lock/cedarling/telemetry/BaseWireMockGrpcTest.java b/jans-lock/lock-server/cedarling/src/test/java/io/jans/lock/cedarling/telemetry/BaseWireMockGrpcTest.java
new file mode 100644
index 00000000000..d91e5100503
--- /dev/null
+++ b/jans-lock/lock-server/cedarling/src/test/java/io/jans/lock/cedarling/telemetry/BaseWireMockGrpcTest.java
@@ -0,0 +1,157 @@
+/*
+ * Janssen Project software is available under the Apache License (2004). See http://www.apache.org/licenses/ for full text.
+ *
+ * Copyright (c) 2026, Janssen Project
+ */
+package io.jans.lock.cedarling.telemetry;
+
+import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
+import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
+import io.grpc.ManagedChannel;
+import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
+import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
+import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
+import io.grpc.netty.shaded.io.netty.handler.ssl.util.InsecureTrustManagerFactory;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.wiremock.grpc.GrpcExtensionFactory;
+
+/**
+ * Base class for gRPC integration tests that need a WireMock HTTPS server
+ * with the gRPC extension enabled.
+ *
+ * What this class provides
+ *
+ * - A {@link WireMockExtension} with both a dynamic HTTP port (for the admin
+ * API / stub registration) and a dynamic HTTPS port (for gRPC over H2).
+ * Using dynamic ports avoids collisions on shared CI runners.
+ * - The server root directory is set to {@code src/test/resources/wiremock}.
+ * The gRPC extension scans the {@code grpc/} sub-directory for binary
+ * protobuf descriptor files ({@code *.dsc} / {@code *.desc}) that it
+ * uses to transcode proto<->JSON. The descriptor must be pre-generated
+ * by the {@code protoc-jar-maven-plugin} during {@code generate-test-resources}
+ * (see the project POM).
+ * - A {@link ManagedChannel} connected to the WireMock HTTPS port, created
+ * once per test class in {@link #setUpGrpc()} and shut down in
+ * {@link #tearDownGrpc()}. The channel uses an insecure SSL context so
+ * that WireMock's auto-generated self-signed certificate is accepted without
+ * importing it into the JVM trust store.
+ *
+ *
+ * Usage
+ * {@code
+ * @TestInstance(Lifecycle.PER_CLASS)
+ * class MyGrpcTest extends BaseWireMockGrpcTest {
+ *
+ * private WireMockGrpcService grpcService;
+ *
+ * @BeforeEach
+ * void registerStubs() {
+ * grpcService = new WireMockGrpcService(
+ * new WireMock(wireMockServer.getPort()),
+ * "com.example.MyService");
+ * grpcService.stubFor(method("MyMethod").willReturn(json("{}")));
+ * }
+ * }
+ * }
+ *
+ * Lifecycle note: {@link BeforeAll @BeforeAll} /
+ * {@link AfterAll @AfterAll} methods here are non-static. Concrete
+ * subclasses must be annotated with
+ * {@code @TestInstance(Lifecycle.PER_CLASS)} for JUnit 5 to call them.
+ * Alternatively, override them as {@code @BeforeEach} / {@code @AfterEach}.
+ */
+public abstract class BaseWireMockGrpcTest {
+
+ /**
+ * WireMock server with:
+ *
+ * - a dynamic HTTP port – used by {@code WireMockGrpcService} / admin API;
+ * - a dynamic HTTPS port – used by gRPC clients over TLS/H2;
+ * - {@code src/test/resources/wiremock} as root directory so the gRPC
+ * extension can locate {@code grpc/*.dsc} descriptor files;
+ * - {@link GrpcExtensionFactory} registered to enable gRPC support.
+ *
+ */
+ @RegisterExtension
+ protected static WireMockExtension wireMockServer = WireMockExtension.newInstance()
+ .options(WireMockConfiguration.wireMockConfig()
+ .dynamicPort() // random HTTP port – no CI port collisions
+ .dynamicHttpsPort() // random TLS port – auto-generates self-signed cert
+ .withRootDirectory("src/test/resources/wiremock")
+ .extensions(new GrpcExtensionFactory()))
+ .build();
+
+ /**
+ * gRPC {@link ManagedChannel} connected to the WireMock HTTPS port.
+ * Available to subclasses for creating blocking or async stubs when
+ * direct channel access is needed.
+ */
+ protected ManagedChannel grpcChannel;
+
+ /**
+ * Builds the gRPC channel once before any test method runs.
+ *
+ * The channel is created with {@link NettyChannelBuilder} (from
+ * {@code grpc-netty-shaded}) and an {@link InsecureTrustManagerFactory}
+ * so that WireMock's self-signed TLS certificate is accepted without any
+ * additional trust-store configuration.
+ *
+ * @throws Exception if the SSL context cannot be built (should never happen
+ * with InsecureTrustManagerFactory)
+ */
+ @BeforeAll
+ public void setUpGrpc() throws Exception {
+ // InsecureTrustManagerFactory accepts any server certificate.
+ // Safe for test environments; never use in production.
+ SslContext sslContext = GrpcSslContexts.forClient()
+ .trustManager(InsecureTrustManagerFactory.INSTANCE)
+ .build();
+
+ grpcChannel = NettyChannelBuilder
+ .forAddress("localhost", wireMockServer.getHttpsPort())
+ .sslContext(sslContext)
+ .build();
+ }
+
+ /**
+ * Shuts down the gRPC channel after all tests in the class have finished
+ * to prevent thread/socket leaks.
+ */
+ @AfterAll
+ public void tearDownGrpc() {
+ if (grpcChannel != null && !grpcChannel.isShutdown()) {
+ grpcChannel.shutdownNow();
+ }
+ }
+
+ /** Returns the shared gRPC channel for use in subclasses. */
+ protected ManagedChannel getGrpcChannel() {
+ return grpcChannel;
+ }
+
+ /**
+ * Returns the dynamic HTTP port of the WireMock server.
+ * Use this port when constructing a {@code WireMockGrpcService}:
+ *
{@code
+ * new WireMockGrpcService(new WireMock(getPort()), "com.example.MyService")
+ * }
+ */
+ protected int getPort() {
+ return wireMockServer.getPort();
+ }
+
+ /**
+ * Returns the dynamic HTTPS port of the WireMock server.
+ * gRPC clients must connect to this port.
+ */
+ protected int getHttpsPort() {
+ return wireMockServer.getHttpsPort();
+ }
+
+ /** Returns the WireMock server instance for stub registration and request verification. */
+ protected WireMockExtension getWireMockServer() {
+ return wireMockServer;
+ }
+}
\ No newline at end of file
diff --git a/jans-lock/lock-server/cedarling/src/test/java/io/jans/lock/cedarling/telemetry/BaseWireMockHttpTest.java b/jans-lock/lock-server/cedarling/src/test/java/io/jans/lock/cedarling/telemetry/BaseWireMockHttpTest.java
new file mode 100644
index 00000000000..bf4cfd1b1de
--- /dev/null
+++ b/jans-lock/lock-server/cedarling/src/test/java/io/jans/lock/cedarling/telemetry/BaseWireMockHttpTest.java
@@ -0,0 +1,87 @@
+/*
+ * Janssen Project software is available under the Apache License (2004). See http://www.apache.org/licenses/ for full text.
+ *
+ * Copyright (c) 2026, Janssen Project
+ */
+
+package io.jans.lock.cedarling.telemetry;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
+import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
+
+import io.restassured.RestAssured;
+import io.restassured.config.SSLConfig;
+
+/**
+ * Base test class that provides a pre-configured WireMock server running over
+ * HTTPS for use by telemetry integration tests.
+ *
+ * What this class provides
+ *
+ * - A {@link WireMockExtension} registered as a JUnit 5 extension. The
+ * server starts on a dynamic (randomly assigned) HTTPS port, so
+ * tests never clash on CI runners that share a port pool.
+ * - A {@link BeforeEach} hook that configures REST Assured to target the
+ * WireMock server and to bypass strict TLS validation (WireMock uses a
+ * self-signed certificate).
+ *
+ *
+ * Usage
+ * {@code
+ * class MyTest extends BaseWireMockHttpTest {
+ *
+ * @Test
+ * void example() {
+ * wireMockServer.stubFor(get("/ping").willReturn(ok()));
+ * RestAssured.get("/ping").then().statusCode(200);
+ * }
+ * }
+ * }
+ *
+ * The {@link org.junit.jupiter.api.BeforeAll @BeforeAll}
+ * lifecycle method in this class is non-static. JUnit 5 requires the concrete
+ * test class to be annotated with
+ * {@code @TestInstance(Lifecycle.PER_CLASS)} for non-static {@code @BeforeAll}
+ * methods to be recognised; subclasses that omit that annotation must override
+ * {@code setUp()} as a {@code @BeforeEach} method instead.
+ *
+ * @author Yuriy Movchan Date: 12/05/2026
+ * */
+public abstract class BaseWireMockHttpTest {
+
+ /**
+ * WireMock server started with a dynamically assigned HTTPS port and an
+ * auto-generated self-signed TLS certificate.
+ *
+ *
Declared {@code protected static} so that subclasses annotated with
+ * {@code @TestInstance(PER_CLASS)} as well as the default per-method
+ * lifecycle can both access the single server instance.
+ */
+ @RegisterExtension
+ protected static WireMockExtension wireMockServer = WireMockExtension.newInstance()
+ .options(WireMockConfiguration.wireMockConfig()
+ .dynamicHttpsPort()) // auto-generates self-signed TLS cert
+ .build();
+
+ /**
+ * Configures REST Assured before every test:
+ *
+ * - Sets the base URI to {@code https://localhost}.
+ * - Sets the port to the dynamically assigned HTTPS port.
+ * - Enables relaxed HTTPS validation so WireMock's self-signed
+ * certificate is accepted without importing it into a trust store.
+ *
+ */
+ @BeforeAll
+ public void setUp() {
+ RestAssured.baseURI = "https://localhost";
+ RestAssured.port = wireMockServer.getHttpsPort();
+
+ RestAssured.config = RestAssured.config()
+ .sslConfig(SSLConfig.sslConfig().relaxedHTTPSValidation());
+ }
+}
\ No newline at end of file
diff --git a/jans-lock/lock-server/cedarling/src/test/java/io/jans/lock/cedarling/telemetry/CedarlingGrpcTelemetryIntegrationTest.java b/jans-lock/lock-server/cedarling/src/test/java/io/jans/lock/cedarling/telemetry/CedarlingGrpcTelemetryIntegrationTest.java
new file mode 100644
index 00000000000..4c8375d9195
--- /dev/null
+++ b/jans-lock/lock-server/cedarling/src/test/java/io/jans/lock/cedarling/telemetry/CedarlingGrpcTelemetryIntegrationTest.java
@@ -0,0 +1,938 @@
+/*
+ * Janssen Project software is available under the Apache License (2004). See http://www.apache.org/licenses/ for full text.
+ *
+ * Copyright (c) 2026, Janssen Project
+ */
+package io.jans.lock.cedarling.telemetry;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.wiremock.grpc.dsl.WireMockGrpc.json;
+import static org.wiremock.grpc.dsl.WireMockGrpc.method;
+
+import java.lang.reflect.Field;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.core.config.Configurator;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.wiremock.grpc.dsl.WireMockGrpcService;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.github.tomakehurst.wiremock.client.WireMock;
+import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
+import com.github.tomakehurst.wiremock.verification.LoggedRequest;
+
+import io.jans.lock.cedarling.config.BootstrapConfig;
+import io.jans.lock.cedarling.service.CedarlingAuthorizationService;
+import io.jans.lock.model.config.AppConfiguration;
+import io.jans.lock.model.config.cedarling.CedarlingConfiguration;
+import io.jans.lock.model.config.cedarling.CedarlingPolicyConfiguration;
+import io.jans.lock.model.config.cedarling.LockTransport;
+import io.jans.lock.model.config.cedarling.LogLevel;
+import io.jans.lock.model.config.cedarling.LogType;
+
+/**
+ * Integration test that verifies Cedarling pushes health, log, and telemetry audit data to a Lock server over gRPC. A WireMock HTTPS server with the gRPC
+ * extension enabled (inherited from {@link BaseWireMockGrpcTest}) acts as the Lock server so no real network or external process is required.
+ *
+ * Test structure
+ *
+ * - Setup – WireMock stubs the {@code /.well-known} discovery document (HTTPS) and all six gRPC audit methods ({@code Process*} /
+ * {@code ProcessBulk*}). Cedarling is initialised with {@code CEDARLING_LOCK=enabled}, gRPC transport, and a short telemetry interval
+ * ({@value #TELEMETRY_INTERVAL_SEC} s).
+ * - Round 1 – 5 authorisation calls (4 ALLOW + 1 DENY). The test waits up to {@value #WAIT_TIMEOUT_SEC} s for any gRPC audit call to arrive,
+ * captures the payloads for every method, and verifies all required proto fields.
+ * - Reset – WireMock's request journal is cleared.
+ * - Round 2 – 7 authorisation calls (4 ALLOW + 3 DENY). Same verification cycle.
+ * - Cross-round comparison – health status must remain {@code "running"}; {@code evaluation_requests_count} must be ≥ the Round 1
+ * value.
+ *
+ *
+ * gRPC URL mapping
+ *
+ * WireMock gRPC maps incoming calls to HTTP POST requests whose path is {@code //}. For {@code audit.proto} the
+ * fully-qualified service name is {@value #AUDIT_SERVICE_FQSN}, so {@code ProcessHealth} is matched at {@code /io.jans.lock.audit.AuditService/ProcessHealth}.
+ *
+ * Proto JSON field layout
+ *
+ *
+ * // Single-entry methods: { "entry": { "service": "...", ... } }
+ * // Bulk methods: { "entries": [ { ... }, { ... } ] }
+ *
+ *
+ * Token matrix
+ *
+ *
+ * ┌────────┬────────────────────────────────────────────────────┬──────────────────┐
+ * │ Token │ OAuth scopes │ Allowed endpoints│
+ * ├────────┼────────────────────────────────────────────────────┼──────────────────┤
+ * │ JWT 1 │ health.write + telemetry.write + log.write │ all three │
+ * │ JWT 2 │ health.write only │ /health only │
+ * │ JWT 3 │ log.write only │ /log only │
+ * └────────┴────────────────────────────────────────────────────┴──────────────────┘
+ *
+ *
+ * @see BaseWireMockGrpcTest
+ * @see CedarlingAuthorizationService
+ *
+ * @author Yuriy Movchan Date: 12/05/2026
+ */
+@TestInstance(Lifecycle.PER_CLASS)
+@DisplayName("Cedarling gRPC Telemetry – Integration Tests")
+public class CedarlingGrpcTelemetryIntegrationTest extends BaseWireMockGrpcTest {
+
+ static {
+ Configurator.setRootLevel(Level.INFO);
+ }
+
+ private static final Logger log = LoggerFactory.getLogger(CedarlingGrpcTelemetryIntegrationTest.class);
+
+ private static final boolean DUMP_CAPTURED_REQUEST = true;
+
+ // ─── WireMock endpoint paths ──────────────────────────────────────────────
+
+ private static final String WELL_KNOWN_PATH = "/.well-known/lock-server-configuration";
+
+ // ─── gRPC service / method names ─────────────────────────────────────────
+
+ /**
+ * Fully-qualified gRPC service name derived from audit.proto:
+ *
+ *
+ * package io.jans.lock.audit;
+ * service AuditService { ... }
+ *
+ *
+ * WireMock gRPC maps every call to an HTTP POST path of the form {@code //}.
+ */
+ private static final String AUDIT_SERVICE_FQSN = "io.jans.lock.audit.AuditService";
+
+ /** Simple method names – must match the rpc declarations in audit.proto exactly. */
+ private static final String METHOD_HEALTH = "ProcessHealth";
+ private static final String METHOD_BULK_HEALTH = "ProcessBulkHealth";
+ private static final String METHOD_LOG = "ProcessLog";
+ private static final String METHOD_BULK_LOG = "ProcessBulkLog";
+ private static final String METHOD_TELEMETRY = "ProcessTelemetry";
+ private static final String METHOD_BULK_TELEMETRY = "ProcessBulkTelemetry";
+
+ /** All six gRPC audit methods – used for bulk stub registration and capture. */
+ private static final List ALL_GRPC_METHODS = List.of(METHOD_HEALTH, METHOD_BULK_HEALTH, METHOD_LOG, METHOD_BULK_LOG, METHOD_TELEMETRY, METHOD_BULK_TELEMETRY);
+
+ // ─── Raw JWT strings – exp claims are patched at runtime ─────────────────
+
+ /** JWT 1 – scopes: {@code health.write}, {@code telemetry.write}, {@code log.write}. */
+ private static final String RAW_JWT_1 = "eyJraWQiOiJjb25uZWN0XzNjMzJjMTkzLTY5ZjYtNDBkYS1iNmQyLTE3ODY0YmJkYzU1MV9zaWdfcnMyNTYiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9"
+ + ".eyJhdWQiOiIwZTk5NDAzMC1lZTg4LTRmMTQtYWUwOC04YjBkYTEwNDQwMjkiLCJzdWIiOiIwZTk5NDAzMC1lZTg4LTRmMTQtYWUwOC04YjBkYTEwNDQwMjkiLC"
+ + "J4NXQjUzI1NiI6IiIsIm5iZiI6MTc3ODYxNDA2Niwic2NvcGUiOlsiaHR0cHM6Ly9qYW5zLmlvL29hdXRoL2xvY2svaGVhbHRoLndyaXRlIiwiaHR0cHM6Ly9qYW"
+ + "5zLmlvL29hdXRoL2xvY2svdGVsZW1ldHJ5LndyaXRlIiwiaHR0cHM6Ly9qYW5zLmlvL29hdXRoL2xvY2svbG9nLndyaXRlIl0sImlzcyI6Imh0dHBzOi8vamFucy"
+ + "1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8iLCJ0b2tlbl90eXBlIjoiQmVhcmVyIiwiZXhwIjoxNzc4NjE0MzY2LCJpYXQiOjE3Nzg2MTQwNjYsImNsaWVudF"
+ + "9pZCI6IjBlOTk0MDMwLWVlODgtNGYxNC1hZTA4LThiMGRhMTA0NDAyOSIsImp0aSI6IkhNdjluYnBiUkRLRTAzNVRUTkgwT3ciLCJzdGF0dXMiOnsic3RhdHVzX2"
+ + "xpc3QiOnsiaWR4Ijo0MDAsInVyaSI6Imh0dHBzOi8vamFucy1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8vamFucy1hdXRoL3Jlc3R2MS9zdGF0dXNfbGlzdC" + "J9fX0"
+ + ".bNXIL4f1lqvLoS49iMSZJORD2mJ9MWYCM5a8nyAiLqKy_fEqvqb-g1X6SgVeS2dJ9aFV-KRrfcjl0zSSQq6mBn-1pAostlMgV-lkOBi7rCbJUMwmdN7Bv7Op8E"
+ + "yuD44_4hHRYhAXOXYv1CcjkyXtv-A9gDxNjHvhHVvpjaizcIMXVRrPxTTQgZF7r7n0t13La2E0vOxzzsgcWQjJukAY8HYybtoRL4JFswBIWPcgET9Btg9mZghDMl"
+ + "vs0yiLVQfiGUZYcmxCCEQinjtutKgONP0Gv6xVMdsXMUpgXGZi6PCiEaEWButMwBauc9RJWEHbd7C4muKoAQ6_tFNuS_eoRw";
+
+ /** JWT 2 – scope: {@code health.write} only. */
+ private static final String RAW_JWT_2 = "eyJraWQiOiJjb25uZWN0XzNjMzJjMTkzLTY5ZjYtNDBkYS1iNmQyLTE3ODY0YmJkYzU1MV9zaWdfcnMyNTYiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9"
+ + ".eyJhdWQiOiIwZTk5NDAzMC1lZTg4LTRmMTQtYWUwOC04YjBkYTEwNDQwMjkiLCJzdWIiOiIwZTk5NDAzMC1lZTg4LTRmMTQtYWUwOC04YjBkYTEwNDQwMjkiLC"
+ + "J4NXQjUzI1NiI6IiIsIm5iZiI6MTc3ODYxNDE2MSwic2NvcGUiOlsiaHR0cHM6Ly9qYW5zLmlvL29hdXRoL2xvY2svaGVhbHRoLndyaXRlIl0sImlzcyI6Imh0dH"
+ + "BzOi8vamFucy1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8iLCJ0b2tlbl90eXBlIjoiQmVhcmVyIiwiZXhwIjoxNzc4NjE0NDYxLCJpYXQiOjE3Nzg2MTQxNj"
+ + "EsImNsaWVudF9pZCI6IjBlOTk0MDMwLWVlODgtNGYxNC1hZTA4LThiMGRhMTA0NDAyOSIsImp0aSI6InRVMGVQb0haU0RPSjJ5Z0EyZFRnc3ciLCJzdGF0dXMiOn"
+ + "sic3RhdHVzX2xpc3QiOnsiaWR4Ijo0MDEsInVyaSI6Imh0dHBzOi8vamFucy1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8vamFucy1hdXRoL3Jlc3R2MS9zdG" + "F0dXNfbGlzdCJ9fX0"
+ + ".iIN4rKz4wnGgijhNWJqY_RqYNx_7zT0hdevnU6wqRwiLp3rQG3c4ouv8P6X4CbiaxERzABbrjsS-4JcW2H2oLpAsuJGhJtr-HExe3iLs_OQ2_4NDwo0k2KJ5e_"
+ + "zGP6Wykr6mQ8WhvGIfURk1aLirLCsegKhH1b26tSp6i8z7z-etNLwGjVPDfw6vV01kYJ0_O_tSf0HuLkGTPf34ld86CUNbPf2cE9Q4uqX_3xVTtMW0ffmOhDo8Qs"
+ + "2dL96xs8O6ah-Rvp6UVjcD4A1qbVImN6USE70nEndmtDR_rvfsCBiL-htkgChTDZymceTcOn00NOvWB2I00rvSy7FdWwNAFQ";
+
+ /** JWT 3 – scope: {@code log.write} only. */
+ private static final String RAW_JWT_3 = "eyJraWQiOiJjb25uZWN0XzNjMzJjMTkzLTY5ZjYtNDBkYS1iNmQyLTE3ODY0YmJkYzU1MV9zaWdfcnMyNTYiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9"
+ + ".eyJhdWQiOiIwZTk5NDAzMC1lZTg4LTRmMTQtYWUwOC04YjBkYTEwNDQwMjkiLCJzdWIiOiIwZTk5NDAzMC1lZTg4LTRmMTQtYWUwOC04YjBkYTEwNDQwMjkiLC"
+ + "J4NXQjUzI1NiI6IiIsIm5iZiI6MTc3ODYxNzc2MSwic2NvcGUiOlsiaHR0cHM6Ly9qYW5zLmlvL29hdXRoL2xvY2svbG9nLndyaXRlIl0sImlzcyI6Imh0dHBzOi"
+ + "8vamFucy1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8iLCJ0b2tlbl90eXBlIjoiQmVhcmVyIiwiZXhwIjoxNzc4NjE4MDYxLCJpYXQiOjE3Nzg2MTc3NjEsIm"
+ + "NsaWVudF9pZCI6IjBlOTk0MDMwLWVlODgtNGYxNC1hZTA4LThiMGRhMTA0NDAyOSIsImp0aSI6IjVxUWRHYWl4VEgybkQ5Z0Y2WDFPaXciLCJzdGF0dXMiOnsic3"
+ + "RhdHVzX2xpc3QiOnsiaWR4Ijo1MDAsInVyaSI6Imh0dHBzOi8vamFucy1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8vamFucy1hdXRoL3Jlc3R2MS9zdGF0dX" + "NfbGlzdCJ9fX0"
+ + ".Q7xieptgb5r9eXqjI5BCSDv_ITtzZXbsXoyqcjsYw0PonF6z3c5XjiSPPrXVUU9dY_HQUrd4ib3U7oIQrKtfXcjJ2pMNuTZ0vPRCcZM_XqqbV3IewUbztabDKD"
+ + "NpK0pSaNZy9V1SslHjW_vQoVDnclJL-w2usyXlMVnFub92GV3ldBZ9cB4UYVRovrzG_UxCa8FI-WkikYoET-vIiHbS5yP3EXlRKwP2pWwhHKwhAC7sjbnYW8ApgY"
+ + "VAmvAnWqwPcaY_Bl-UobDHGBr0b0FhLtMIZvGevo1KdQE5dJwiflZOgiUZiYJU9uJ-tklD2gd5Pq-7g1-DW9Fvsmo2WVDcHw";
+
+ /** Lock endpoints access token – grants all three audit scopes. */
+ private static final String RAW_LOCK_ACCESS_TOKEN_JWT = "eyJraWQiOiJjb25uZWN0X2RjNzViZWZjLWU4N2QtNDMyZi1hOWExLTczYjE0YzJhNjUyMl9zaWdfcnMyNTYiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9"
+ + ".eyJhdWQiOiJmNDUwMTQ1Zi1hYjgyLTRiY2UtOTdjZi02MjQ2YjFjNmIxYTYiLCJzdWIiOiJmNDUwMTQ1Zi1hYjgyLTRiY2UtOTdjZi02MjQ2YjFjNmIxYTYiLC"
+ + "J4NXQjUzI1NiI6IiIsIm5iZiI6MTc3OTk1ODYyNCwic2NvcGUiOlsiaHR0cHM6Ly9qYW5zLmlvL29hdXRoL2xvY2svaGVhbHRoLndyaXRlIiwiaHR0cHM6Ly9qYW"
+ + "5zLmlvL29hdXRoL2xvY2svdGVsZW1ldHJ5LndyaXRlIiwiaHR0cHM6Ly9qYW5zLmlvL29hdXRoL2xvY2svbG9nLndyaXRlIl0sImlzcyI6Imh0dHBzOi8vamFucy"
+ + "1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8iLCJ0b2tlbl90eXBlIjoiQmVhcmVyIiwiZXhwIjoxNzc5OTU4OTI0LCJpYXQiOjE3Nzk5NTg2MjQsImNsaWVudF"
+ + "9pZCI6ImY0NTAxNDVmLWFiODItNGJjZS05N2NmLTYyNDZiMWM2YjFhNiIsImp0aSI6IjNOU2ZLM1hQU25XZDlNWDFDNlFhT1EiLCJzdGF0dXMiOnsic3RhdHVzX2"
+ + "xpc3QiOnsiaWR4Ijo0MDEsInVyaSI6Imh0dHBzOi8vamFucy1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8vamFucy1hdXRoL3Jlc3R2MS9zdGF0dXNfbGlzdC" + "J9fX0"
+ + ".YSMkLZIm0JzIIiuShrXbZwLT5Bm58nBpz2fcaGEP4zyyDE0Te1T3WKmJZRsyRNPN3QgIwa9b02C-lGTqUJ9YkylTolLitJHgy7aVhfestGYTZ_r0gvfcGYVF8h"
+ + "zsk5k11U-hb9SGbZOXOvuis998fCXolG-UUaYj7VGjU8xreGLgmEx7Otmfpi2bjenQ0DGFjo82XAVzgqO7gwGT-5zohBQ8uNQcKKASGj4g2NtVcjXqmxBS9huI7e"
+ + "dAJxFPlZ5J7gghfJAIARDLm8UrYgNEHqVdQPDOrAdnIOE5n0I4oJad5fl5luyKSNmd6sL4hi82OR7Ldig3XIjxyHQ7VJYZhA";
+
+ // ─── Cedar action / resource constants ───────────────────────────────────
+
+ private static final String ACTION_POST = "Jans::Action::\"POST\"";
+ private static final String RESOURCE_TYPE = "Jans::HTTP_Request";
+
+ private static final String ID_LOG = "lock_audit_log_write";
+ private static final String ID_HEALTH = "lock_audit_health_write";
+ private static final String ID_TELEMETRY = "lock_audit_telemetry_write";
+
+ private static final String PATH_LOG = "/audit/log/bulk";
+ private static final String PATH_HEALTH = "/audit/health/bulk";
+ private static final String PATH_TELEMETRY = "/audit/telemetry/bulk";
+
+ private static final String ACCESS_TOKEN_KEY = CedarlingAuthorizationService.CEDARLING_JANS_ACCESS_TOKEN;
+
+ // ─── Timing ──────────────────────────────────────────────────────────────
+
+ /** Cedarling telemetry/health push interval in seconds. */
+ private static final int TELEMETRY_INTERVAL_SEC = 5;
+
+ /** Maximum time to wait for at least one gRPC audit payload per round. */
+ private static final int WAIT_TIMEOUT_SEC = 30;
+ private static final Duration WAIT_TIMEOUT = Duration.ofSeconds(WAIT_TIMEOUT_SEC);
+
+ // ─── Test state ───────────────────────────────────────────────────────────
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+ private CedarlingAuthorizationService authService;
+
+ /**
+ * WireMockGrpcService wraps the WireMock admin API and provides the {@code stubFor(method(...).willReturn(json(...)))} DSL used to register gRPC stubs.
+ *
+ * It is lazily created on the first call to {@link #configureGrpcAuditStubs()} because at field-initialisation time the WireMock server may not yet have an
+ * assigned port.
+ */
+ private WireMockGrpcService grpcAuditService;
+
+ private String jwt1, jwt2, jwt3;
+
+ /**
+ * Guards one-time Cedarling initialisation inside {@link #registerStubs()}. WireMock clears stubs in its own {@code beforeEach} which runs before any
+ * {@code @BeforeEach} in the test class, so Cedarling must be started here (after stubs are up) rather than in {@code @BeforeAll}.
+ */
+ private boolean serviceInitialized = false;
+
+ // ─── Lifecycle ────────────────────────────────────────────────────────────
+
+ /**
+ * Registers WireMock stubs and, on the very first call, initialises Cedarling.
+ *
+ *
+ * Execution order per test method:
+ * @BeforeAll setUpGrpc() – gRPC channel created once (base class)
+ * WireMockExtension.beforeEach() – wipes all stub mappings
+ * @BeforeEach registerStubs() – stubs re-registered; Cedarling init on first call
+ *
+ */
+ @BeforeEach
+ void registerStubs() throws Exception {
+ configureWellKnownEndpoint();
+ configureGrpcAuditStubs();
+ log.info("WireMock stubs registered (well-known + {} gRPC audit methods)", ALL_GRPC_METHODS.size());
+
+ if (!serviceInitialized) {
+ initAuthService();
+ serviceInitialized = true;
+ log.info("WireMock HTTP port: {} | HTTPS port: {} | telemetry interval: {}s | wait timeout: {}s",
+ wireMockServer.getPort(), wireMockServer.getHttpsPort(), TELEMETRY_INTERVAL_SEC, WAIT_TIMEOUT_SEC);
+ }
+ }
+
+ /** Shuts down Cedarling and the gRPC channel after all tests in the class. */
+ @AfterAll
+ void tearDown() {
+ if (serviceInitialized) {
+ authService.destroy();
+ log.info("Cedarling service destroyed");
+ }
+ // gRPC channel shutdown is handled by BaseWireMockGrpcTest.tearDownGrpc()
+ }
+
+ // ─── Tests ────────────────────────────────────────────────────────────────
+
+ @Nested
+ @DisplayName("Two-round gRPC telemetry lifecycle")
+ class TwoRoundGrpcTelemetryLifecycle {
+
+ /**
+ * Main gRPC telemetry lifecycle test.
+ *
+ *
+ * Executes two batches of authorisation calls separated by a WireMock request-journal reset, then verifies:
+ *
+ * - All required proto fields are present in every captured payload.
+ * - Health status is {@code "running"} in both rounds.
+ * - {@code evaluation_requests_count} is ≥ the Round 1 value in Round 2.
+ * - Log entries carry correct {@code decision_result} values.
+ *
+ */
+ @Test
+ @DisplayName("gRPC telemetry accumulates correctly across two authorisation rounds")
+ void grpcTelemetryAccumulatesAcrossRounds() throws Exception {
+
+ // ════════════════════════════ ROUND 1 ════════════════════════════
+ log.info("=== ROUND 1 – 5 authorisation calls (4 ALLOW + 1 DENY) ===");
+ int round1AuthCalls = executeRound1Authorizations();
+
+ // Wait for data to arrive at least at the primary telemetry endpoint before waiting the full timeout.
+ awaitDuration(Duration.ofSeconds(TELEMETRY_INTERVAL_SEC + 2));
+
+ // Wait for the telemetry push triggered by round-1 evaluations
+ Map> r1Captured = awaitAndCapture("R1", WAIT_TIMEOUT);
+
+ List r1Health = getMethod(r1Captured, "R1 health", METHOD_HEALTH);
+ List r1BulkHealth = getMethod(r1Captured, "R1 bulk-health", METHOD_BULK_HEALTH);
+ List r1Log = getMethod(r1Captured, "R1 log", METHOD_LOG);
+ List r1BulkLog = getMethod(r1Captured, "R1 bulk-log", METHOD_BULK_LOG);
+ List r1Telemetry = getMethod(r1Captured, "R1 telemetry", METHOD_TELEMETRY);
+ List r1BulkTelemetry = getMethod(r1Captured, "R1 bulk-telemetry", METHOD_BULK_TELEMETRY);
+
+ log.info("Round 1 received – health={}, bulkHealth={}, log={}, bulkLog={}, telemetry={}, bulkTelemetry={}",
+ r1Health.size(), r1BulkHealth.size(), r1Log.size(), r1BulkLog.size(),
+ r1Telemetry.size(), r1BulkTelemetry.size());
+
+ // Structural and value verification
+ verifyHealthPayloads(r1Health, "Round 1");
+ verifyHealthBulkPayloads(r1BulkHealth, "Round 1 (bulk)", -1);
+ verifyLogPayloads(r1Log, "Round 1");
+ verifyLogBulkPayloads(r1BulkLog, "Round 1 (bulk)", round1AuthCalls);
+ verifyTelemetryPayloads(r1Telemetry, "Round 1");
+ verifyTelemetryBulkPayloads(r1BulkTelemetry, "Round 1", -1);
+
+ // Capture round 1's latest evaluationRequestsCount for comparison
+ long r1EvalCount = latestEntryLong(r1Telemetry, "evaluation_requests_count");
+ log.info("Round 1 – evaluation_requests_count = {}", r1EvalCount);
+
+ // Reset request journal – Round 2 starts clean
+ wireMockServer.resetRequests();
+ log.info("--- WireMock request journal reset – starting Round 2 ---");
+
+ // ════════════════════════════ ROUND 2 ════════════════════════════
+ log.info("=== ROUND 2 – 7 authorisation calls (4 ALLOW + 3 DENY) ===");
+ int round2AuthCalls = executeRound2Authorizations();
+
+ // Wait for data to arrive at least at the primary telemetry endpoint before waiting the full timeout.
+ awaitDuration(Duration.ofSeconds(TELEMETRY_INTERVAL_SEC + 2));
+
+ // Wait for the telemetry push triggered by round-2 evaluations
+ Map> r2Captured = awaitAndCapture("R2", WAIT_TIMEOUT);
+
+ List r2Health = getMethod(r2Captured, "R2 health", METHOD_HEALTH);
+ List r2BulkHealth = getMethod(r2Captured, "R2 bulk-health", METHOD_BULK_HEALTH);
+ List r2Log = getMethod(r2Captured, "R2 log", METHOD_LOG);
+ List r2BulkLog = getMethod(r2Captured, "R2 bulk-log", METHOD_BULK_LOG);
+ List r2Telemetry = getMethod(r2Captured, "R2 telemetry", METHOD_TELEMETRY);
+ List r2BulkTelemetry = getMethod(r2Captured, "R2 bulk-telemetry", METHOD_BULK_TELEMETRY);
+
+ log.info("Round 2 received – health={}, bulkHealth={}, log={}, bulkLog={}, telemetry={}, bulkTelemetry={}",
+ r2Health.size(), r2BulkHealth.size(), r2Log.size(), r2BulkLog.size(),
+ r2Telemetry.size(), r2BulkTelemetry.size());
+
+ verifyHealthPayloads(r2Health, "Round 2");
+ verifyHealthBulkPayloads(r2BulkHealth, "Round 2 (bulk)", -1);
+ verifyLogPayloads(r2Log, "Round 2");
+ verifyLogBulkPayloads(r2BulkLog, "Round 2 (bulk)", round2AuthCalls);
+ verifyTelemetryPayloads(r2Telemetry, "Round 2");
+ verifyTelemetryBulkPayloads(r2BulkTelemetry, "Round 2", -1);
+
+ // ════════════════════════ CROSS-ROUND COMPARISON ═════════════════
+ compareTelemetryAcrossRounds(r1EvalCount, r1Telemetry, r2Telemetry);
+ compareHealthAcrossRounds(r1Health, r2Health);
+
+ log.info("✅ All gRPC telemetry assertions passed across both rounds.");
+ }
+ }
+
+ // ─── Authorisation rounds ─────────────────────────────────────────────────
+
+ /**
+ * Round 1: JWT 1 (all scopes) + JWT 2 (health only) – 5 calls, 4 ALLOW + 1 DENY.
+ *
+ * @return total number of authorisation calls made
+ */
+ private int executeRound1Authorizations() {
+ // JWT 1 – all three endpoints allowed
+ assertTrue(authorize(jwt1, ACTION_POST, ID_LOG, PATH_LOG), "R1: JWT1 must be allowed for /log");
+ assertTrue(authorize(jwt1, ACTION_POST, ID_HEALTH, PATH_HEALTH), "R1: JWT1 must be allowed for /health");
+ assertTrue(authorize(jwt1, ACTION_POST, ID_TELEMETRY, PATH_TELEMETRY), "R1: JWT1 must be allowed for /telemetry");
+
+ // JWT 2 – health allowed, log denied
+ assertTrue(authorize(jwt2, ACTION_POST, ID_HEALTH, PATH_HEALTH), "R1: JWT2 must be allowed for /health");
+ assertFalse(authorize(jwt2, ACTION_POST, ID_LOG, PATH_LOG), "R1: JWT2 must be denied for /log (missing log.write)");
+ return 5; // 4 ALLOW + 1 DENY
+ }
+
+ /**
+ * Round 2: JWT 3 (log only) + JWT 2 (health only) + JWT 1 – 7 calls, 4 ALLOW + 3 DENY.
+ *
+ * @return total number of authorisation calls made
+ */
+ private int executeRound2Authorizations() {
+ // JWT 3 – log allowed, health and telemetry denied
+ assertTrue(authorize(jwt3, ACTION_POST, ID_LOG, PATH_LOG), "R2: JWT3 must be allowed for /log");
+ assertFalse(authorize(jwt3, ACTION_POST, ID_HEALTH, PATH_HEALTH), "R2: JWT3 must be denied for /health (missing health.write)");
+ assertFalse(authorize(jwt3, ACTION_POST, ID_TELEMETRY, PATH_TELEMETRY), "R2: JWT3 must be denied for /telemetry (missing telemetry.write)");
+
+ // JWT 2 – health allowed, telemetry denied
+ assertTrue(authorize(jwt2, ACTION_POST, ID_HEALTH, PATH_HEALTH), "R2: JWT2 must be allowed for /health");
+ assertFalse(authorize(jwt2, ACTION_POST, ID_TELEMETRY, PATH_TELEMETRY), "R2: JWT2 must be denied for /telemetry (missing telemetry.write)");
+
+ // JWT 1 – all allowed
+ assertTrue(authorize(jwt1, ACTION_POST, ID_LOG, PATH_LOG), "R2: JWT1 must be allowed for /log");
+ assertTrue(authorize(jwt1, ACTION_POST, ID_HEALTH, PATH_HEALTH), "R2: JWT1 must be allowed for /health");
+ return 7; // 4 ALLOW + 3 DENY
+ }
+
+ // ─── Payload verification ─────────────────────────────────────────────────
+
+ /**
+ * Verifies structural fields and value assertions for every health payload.
+ *
+ *
+ * Required fields: {@code service}, {@code status}, {@code node_name}.
+ * Value assertions: {@code service == "Lock Server - Test Edition"}, {@code status == "running"}.
+ */
+ private void verifyHealthPayloads(List payloads, String label) {
+ if (payloads.isEmpty()) {
+ log.warn("[{}] No health payloads received – skipping field checks", label);
+ return;
+ }
+ for (int i = 0; i < payloads.size(); i++) {
+ verifyHealthEntry(label + " health[" + i + "]", payloads.get(i).path("entry"));
+ }
+ log.info("[{}] {} health payload(s) verified", label, payloads.size());
+ }
+
+ private void verifyHealthBulkPayloads(List payloads, String label, int minExpectedEntries) {
+ if (payloads.isEmpty()) {
+ log.warn("⚠️ [{}] No bulk-health payloads received – skipping field checks", label);
+ return;
+ }
+ int count = 0;
+ for (int i = 0; i < payloads.size(); i++) {
+ JsonNode entries = payloads.get(i).path("entries");
+ assertTrue(entries.isArray(), label + " health[" + i + "]: 'entries' must be an array, got: " + entries.getNodeType());
+ for (int j = 0; j < entries.size(); j++) {
+ verifyHealthEntry(label + " health[" + i + "][" + j + "]", entries.get(j));
+ count++;
+ }
+ }
+ if (minExpectedEntries >= 0) {
+ assertTrue(count >= minExpectedEntries, label + ": expected at least " + minExpectedEntries + " health entries but got " + count);
+ }
+ log.info("[{}] ✅ {} health entry/entries verified", label, count);
+ }
+
+ private void verifyHealthEntry(String ctx, JsonNode entry) {
+ // ── Required fields ──
+ assertTrue(entry.hasNonNull("service"), ctx + ": missing 'service'");
+ assertTrue(entry.hasNonNull("status"), ctx + ": missing 'status'");
+ assertTrue(entry.hasNonNull("nodeName"), ctx + ": missing 'node_name'");
+
+ // ── Value assertions ──
+ assertEquals("Lock Server - Test Edition", entry.get("service").asText(), ctx + ": service must be 'Lock Server - Test Edition'");
+ assertEquals("running", entry.get("status").asText(), ctx + ": status must be 'running'");
+
+ // node_name must be present and non-blank
+ assertFalse(entry.get("nodeName").asText("").isBlank(), ctx + ": node_name must not be blank");
+ }
+
+ /**
+ * Verifies structural fields for every log (audit) payload.
+ *
+ *
+ * Required fields: {@code clientId}, {@code principalId}, {@code decisionResult}, {@code action}.
+ *
+ *
+ * @param payloads parsed request bodies
+ * @param label test label for assertion messages
+ */
+ private void verifyLogPayloads(List payloads, String label) {
+ if (payloads.isEmpty()) {
+ log.warn("[{}] No log payloads received – skipping field checks", label);
+ return;
+ }
+ for (int i = 0; i < payloads.size(); i++) {
+ verifyLogEntry(label + " log[" + i + "]", payloads.get(i).path("entry"));
+ }
+ log.info("[{}] {} log payload(s) verified", label, payloads.size());
+ }
+
+ private void verifyLogBulkPayloads(List payloads, String label, int minExpectedEntries) {
+ if (payloads.isEmpty()) {
+ log.warn("⚠️ [{}] No bulk-log payloads received – skipping field checks", label);
+ return;
+ }
+ int count = 0;
+ for (int i = 0; i < payloads.size(); i++) {
+ JsonNode entries = payloads.get(i).path("entries");
+ assertTrue(entries.isArray(), label + " log[" + i + "]: 'entries' must be an array, got: " + entries.getNodeType());
+ for (int j = 0; j < entries.size(); j++) {
+ verifyLogEntry(label + " log[" + i + "][" + j + "]", entries.get(j));
+ count++;
+ }
+ }
+ if (minExpectedEntries >= 0) {
+ assertTrue(count >= minExpectedEntries, label + ": expected at least " + minExpectedEntries + " log entries but got " + count);
+ }
+ log.info("[{}] ✅ {} log entry/entries verified", label, count);
+ }
+
+ private void verifyLogEntry(String ctx, JsonNode entry) {
+ // ── Required fields ──
+ assertTrue(entry.hasNonNull("client_id"), ctx + ": missing 'client_id'");
+ assertTrue(entry.hasNonNull("principal_id"), ctx + ": missing 'principal_id'");
+ assertTrue(entry.hasNonNull("decision_result"), ctx + ": missing 'decision_result'");
+ assertTrue(entry.hasNonNull("action"), ctx + ": missing 'action'");
+
+ // ── Value assertions ──
+ String decision = entry.get("decision_result").asText();
+ assertTrue("ALLOW".equalsIgnoreCase(decision) || "DENY".equalsIgnoreCase(decision), ctx + ": decision_result must be 'ALLOW' or 'DENY', got: " + decision);
+
+ // action must follow the Cedar action URI format: Namespace::Action::"name"
+ String action = entry.get("action").asText();
+ assertTrue(action.matches(".+::Action::\"[^\"]+\""), ctx + ": action must match Cedar format '::Action::\"\"', got: " + action);
+
+ // clientId must be present and non-blank
+ assertFalse(entry.get("client_id").asText("").isBlank(), ctx + ": client_id must not be blank");
+ }
+
+ /**
+ * Verifies structural fields and value assertions for every telemetry payload.
+ *
+ *
+ * Required fields: {@code service}, {@code status}, {@code evaluation_requests_count},
+ * {@code avg_policy_evaluation_time_ns} (snake_case – proto JSON serialisation).
+ */
+ private void verifyTelemetryPayloads(List payloads, String label) {
+ if (payloads.isEmpty()) {
+ log.warn("[{}] No telemetry payloads received – skipping field checks", label);
+ return;
+ }
+ for (int i = 0; i < payloads.size(); i++) {
+ verifyTelemetryEntry(label + " telemetry[" + i + "]", payloads.get(i).path("entry"));
+ }
+ log.info("[{}] {} telemetry payload(s) verified", label, payloads.size());
+ }
+
+ private void verifyTelemetryBulkPayloads(List payloads, String label, int minExpectedEntries) {
+ if (payloads.isEmpty()) {
+ log.warn("⚠️ [{}] No bulk-telemetry payloads received – skipping field checks", label);
+ return;
+ }
+ int count = 0;
+ for (int i = 0; i < payloads.size(); i++) {
+ JsonNode entries = payloads.get(i).path("entries");
+ assertTrue(entries.isArray(), label + " telemetry[" + i + "]: 'entries' must be an array, got: " + entries.getNodeType());
+ for (int j = 0; j < entries.size(); j++) {
+ verifyTelemetryEntry(label + " telemetry[" + i + "][" + j + "]", entries.get(j));
+ count++;
+ }
+ }
+ if (minExpectedEntries >= 0) {
+ assertTrue(count >= minExpectedEntries, label + ": expected at least " + minExpectedEntries + " telemetry entries but got " + count);
+ }
+ log.info("[{}] ✅ {} telemetry entry/entries verified", label, count);
+ }
+
+ private void verifyTelemetryEntry(String ctx, JsonNode entry) {
+ assertTrue(entry.hasNonNull("service"), ctx + ": missing 'service'");
+ assertTrue(entry.hasNonNull("status"), ctx + ": missing 'status'");
+ assertTrue(entry.hasNonNull("evaluation_requests_count"), ctx + ": missing 'evaluation_requests_count'");
+ assertTrue(entry.hasNonNull("avg_policy_evaluation_time_ns"), ctx + ": missing 'avg_policy_evaluation_time_ns'");
+
+ assertEquals("cedarling", entry.get("service").asText(), ctx + ": service must be 'cedarling'");
+
+ long evalCount = entry.get("evaluation_requests_count").asLong();
+ assertTrue(evalCount >= 0, ctx + ": evaluation_requests_count must be >= 0, got " + evalCount);
+
+ long avgEvalNs = entry.get("avg_policy_evaluation_time_ns").asLong(-1L);
+ assertTrue(avgEvalNs > 0, ctx + ": avg_policy_evaluation_time_ns must be > 0, got " + avgEvalNs);
+ }
+
+ // ─── Cross-round comparison ────────────────────────────────────────────────
+
+ /**
+ * Compares telemetry counters from Round 1 vs Round 2.
+ *
+ *
+ * - {@code evaluationRequestsCount} in Round 2 must be ≥ the Round 1 value because Cedarling accumulates this counter globally.
+ * - The {@code service} name must be identical across rounds.
+ * - Round 2 must contain at least as many telemetry reports as Round 1.
+ *
+ *
+ * @param r1EvalCount already extracted from the last round-1 telemetry entry
+ * @param round1 Round 1 telemetry payloads
+ * @param round2 Round 2 telemetry payloads
+ */
+ private void compareTelemetryAcrossRounds(long r1EvalCount, List round1, List round2) {
+ if (round1.isEmpty() || round2.isEmpty()) {
+ log.warn("⚠️ Cannot compare telemetry rounds – empty data (r1={}, r2={})", round1.size(), round2.size());
+ return;
+ }
+ long r2EvalCount = latestEntryLong(round2, "evaluation_requests_count");
+ log.info("evaluation_requests_count – Round 1 (last) = {} | Round 2 (last) = {}", r1EvalCount, r2EvalCount);
+
+ // Cedarling accumulates evaluations globally; Round 2 counter must not decrease
+ assertTrue(r2EvalCount >= r1EvalCount, "evaluation_requests_count must not decrease between rounds: R1=" + r1EvalCount + ", R2=" + r2EvalCount);
+
+ // Service name is invariant
+ String r1Service = latestEntryString(round1, "service");
+ String r2Service = latestEntryString(round2, "service");
+ assertEquals(r1Service, r2Service, "service name must be consistent across rounds");
+
+ // Round 2 had more authorization calls – the difference should be visible
+ long delta = r2EvalCount - r1EvalCount;
+ log.info("evaluationRequestsCount delta (R2 – R1) = {}", delta);
+ // NOTE: delta could be 0 if Cedarling resets the counter per telemetry interval
+ // rather than accumulating globally. The ≥ assertion above is the safe guard.
+ log.info("✅ Cross-round gRPC telemetry comparison passed (delta={})", r2EvalCount - r1EvalCount);
+ }
+
+ /**
+ * Verifies that the Cedarling process remains healthy across both rounds regardless of DENY decisions during authorization.
+ */
+ private void compareHealthAcrossRounds(List round1, List round2) {
+ if (round1.isEmpty() || round2.isEmpty()) {
+ log.warn("⚠️ Cannot compare health rounds – empty data (r1={}, r2={})", round1.size(), round2.size());
+ return;
+ }
+ String r1Status = latestEntryString(round1, "status");
+ String r2Status = latestEntryString(round2, "status");
+
+ assertEquals("running", r1Status, "Round 1 health status must be 'running'");
+ assertEquals("running", r2Status, "Round 2 health status must be 'running'");
+
+ log.info("✅ Health status is 'running' in both rounds");
+ }
+
+ // ─── gRPC request capture utilities ──────────────────────────────────────
+
+ /**
+ * Polls WireMock until at least one gRPC audit call arrives (on any method) or {@code timeout} elapses, then returns all captured request bodies grouped by
+ * method name.
+ *
+ * @param label descriptive label for log output
+ * @param timeout maximum time to wait
+ * @return map from gRPC method name to list of parsed JSON bodies; empty map if nothing arrived within the timeout
+ */
+ private Map> awaitAndCapture(String label, Duration timeout) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + timeout.toMillis();
+ while (System.currentTimeMillis() < deadline) {
+ Map> captured = captureAllMethods();
+ if (!captured.isEmpty()) {
+ String summary = captured.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue().size()).collect(Collectors.joining(", ", "{", "}"));
+ log.info("[{}] gRPC requests captured: {}", label, summary);
+
+ if (DUMP_CAPTURED_REQUEST) {
+ captured.forEach((m, nodes) -> {
+ log.debug("Captured {} request(s) via gRPC {}:", nodes.size(), m);
+ for (int i = 0; i < nodes.size(); i++) {
+ log.debug(" [{}][{}]: {}", m, i, nodes.get(i).toPrettyString());
+ }
+ });
+ }
+ return captured;
+ }
+ Thread.sleep(500);
+ }
+ log.warn("[{}] Timeout ({}s) – no gRPC audit requests arrived", label, timeout.toSeconds());
+ return Collections.emptyMap();
+ }
+
+ /**
+ * Queries the WireMock request journal for all recorded gRPC calls and returns their decoded JSON bodies grouped by method name.
+ *
+ *
+ * WireMock gRPC maps every incoming call to an HTTP POST at the path {@code //}. For {@code audit.proto} that means
+ * {@code /io.jans.lock.audit.AuditService/ProcessHealth} etc. The gRPC extension decodes the proto binary payload to JSON before recording it in the journal,
+ * so {@link LoggedRequest#getBodyAsString()} returns a plain JSON string that Jackson can parse directly.
+ *
+ * @return map keyed by simple method name (e.g. {@code "ProcessHealth"})
+ */
+ private Map> captureAllMethods() {
+ Map> result = new HashMap<>();
+
+ for (String method : ALL_GRPC_METHODS) {
+ // FIX: WireMock gRPC uses the fully-qualified path, not just the method name.
+ // Pattern: /./
+ String grpcUrlPath = "/" + AUDIT_SERVICE_FQSN + "/" + method;
+
+ List logged = wireMockServer.findAll(postRequestedFor(urlEqualTo(grpcUrlPath)));
+
+ if (logged.isEmpty()) {
+ continue;
+ }
+
+ List nodes = logged.stream().map(request -> {
+ try {
+ // The WireMock gRPC extension transcodes the proto binary to JSON
+ // using the descriptor file, so the body is always valid JSON.
+ return objectMapper.readTree(request.getBodyAsString());
+ } catch (Exception ex) {
+ log.warn("Could not parse gRPC body for method {}: {}", method, ex.getMessage());
+ return null;
+ }
+ }).filter(Objects::nonNull).collect(Collectors.toList());
+
+ if (!nodes.isEmpty()) {
+ result.put(method, nodes);
+ }
+ }
+ return result;
+ }
+
+ private void awaitDuration(Duration d) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + d.toMillis();
+ while (System.currentTimeMillis() < deadline) {
+ Thread.sleep(500);
+ }
+ }
+
+ /**
+ * Retrieves captured payloads for a specific gRPC method from the map produced by {@link #awaitAndCapture}.
+ */
+ private List getMethod(Map> captured, String label, String method) {
+ List nodes = captured.getOrDefault(method, Collections.emptyList());
+ log.info("[{}] {} request(s) captured via gRPC {}", label, nodes.size(), method);
+ return nodes;
+ }
+
+ // ─── Field extraction helpers ─────────────────────────────────────────────
+
+ private long latestEntryLong(List nodes, String field) {
+ if (nodes.isEmpty())
+ return 0L;
+ return nodes.get(nodes.size() - 1).path("entry").path(field).asLong(0L);
+ }
+
+ private String latestEntryString(List nodes, String field) {
+ if (nodes.isEmpty())
+ return "";
+ return nodes.get(nodes.size() - 1).path("entry").path(field).asText("");
+ }
+
+ // ─── WireMock stub configuration ─────────────────────────────────────────
+
+ /**
+ * Stubs the Lock server discovery document on the plain HTTP port.
+ *
+ *
+ * Why HTTP and not HTTPS? WireMock auto-generates a self-signed TLS certificate with CN={@code tom akehurst}. Cedarling's gRPC transport uses
+ * Tonic (Rust), which validates the peer certificate via rustls. Even with {@code lockAcceptInvalidCerts=true} Tonic rejects the cert with
+ * {@code invalid peer certificate: UnknownIssuer} because the CA is not trusted – that flag only controls HTTP connections, not the Tonic gRPC layer.
+ *
+ *
+ * WireMock's HTTP connector listens with {@code (http/1.1, h2c)} – it supports HTTP/2 cleartext, so gRPC over h2c works without any certificate at all.
+ * Pointing {@code lockServerConfigurationUri} at the plain HTTP port causes Cedarling to derive the gRPC target from an {@code http://} base URL and connect
+ * via h2c, bypassing TLS entirely and eliminating the UnknownIssuer failure.
+ */
+ private void configureWellKnownEndpoint() {
+ int port = wireMockServer.getPort(); // plain HTTP / h2c port – no TLS
+ log.info("Configuring well-known stub: http://localhost:{}{}", port, WELL_KNOWN_PATH);
+
+ String body = """
+ {
+ "version": "1.0",
+ "issuer": "http://localhost:%d",
+ "audit": {
+ "health_endpoint": "http://localhost:%d/jans-lock/api/v1/audit/health",
+ "log_endpoint": "http://localhost:%d/jans-lock/api/v1/audit/log",
+ "telemetry_endpoint": "http://localhost:%d/jans-lock/api/v1/audit/telemetry"
+ }
+ }
+ """.formatted(port, port, port, port);
+
+ wireMockServer.stubFor(get(urlEqualTo(WELL_KNOWN_PATH)).willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(body)));
+ }
+
+ /**
+ * Registers WireMock stubs for all six gRPC audit methods defined in {@link #ALL_GRPC_METHODS} using the official WireMock gRPC DSL.
+ *
+ *
+ * Why {@link WireMockGrpcService}? {@code WireMockGrpcService.stubFor()} uses WireMock's admin REST API (HTTP port) to register gRPC-aware
+ * stubs. The previous code called {@code wireMockServer.addStubMapping(method(...).willReturn(...).build(ok))} which is incorrect for two reasons:
+ *
+ * - {@code GrpcStubMappingBuilder.build()} accepts no arguments – the single-arg overload does not exist and causes a compile error.
+ * - Registering a gRPC stub through {@code wireMockServer.addStubMapping()} bypasses the gRPC extension's URL/header enrichment, so WireMock cannot match
+ * incoming gRPC calls against the stub.
+ *
+ *
+ *
+ * The {@link WireMockGrpcService} is lazily initialised here (not as a field initialiser) because the dynamic HTTP port is not yet assigned when the class is
+ * loaded.
+ *
+ *
+ * WireMock 4 constructor note: {@code new WireMock(String, int)} was removed in WireMock 4. The correct way to obtain a client from a
+ * {@link WireMockExtension} is via {@code wireMockServer.getRuntimeInfo().getWireMock()}, which returns a pre-configured {@link WireMock} instance pointing at
+ * the correct host and HTTP port.
+ */
+ private void configureGrpcAuditStubs() {
+ // Lazily create the service wrapper.
+ // getRuntimeInfo().getWireMock() is the WireMock 4-compatible way to obtain
+ // a WireMock client from a WireMockExtension (new WireMock(String,int)
+ // constructor was removed in WireMock 4).
+ if (grpcAuditService == null) {
+ grpcAuditService = new WireMockGrpcService(wireMockServer.getRuntimeInfo().getWireMock(), AUDIT_SERVICE_FQSN);
+ }
+
+ String ok = """
+ {"success": true, "message": "Audit data processed successfully"}
+ """;
+
+ for (String methodName : ALL_GRPC_METHODS) {
+ // method() is statically imported from WireMockGrpc.
+ // json() is statically imported from WireMockGrpc.
+ grpcAuditService.stubFor(method(methodName).willReturn(json(ok)));
+ }
+ }
+
+ // ─── Cedarling service initialisation ────────────────────────────────────
+
+ /**
+ * Builds a fully-wired {@link CedarlingAuthorizationService} (no CDI container in tests) with Lock telemetry enabled, gRPC audit transport, and targeting the
+ * WireMock HTTPS server.
+ */
+ private void initAuthService() throws Exception {
+ Logger svcLog = LoggerFactory.getLogger(CedarlingAuthorizationService.class);
+ AppConfiguration appConfig = mock(AppConfiguration.class);
+ CedarlingPolicyConfiguration policyConfig = mock(CedarlingPolicyConfiguration.class);
+ CedarlingConfiguration cedarConf = mock(CedarlingConfiguration.class);
+
+ when(cedarConf.isEnabled()).thenReturn(true);
+ when(cedarConf.getLogType()).thenReturn(LogType.STD_OUT);
+ when(cedarConf.getLogLevel()).thenReturn(LogLevel.TRACE);
+ when(appConfig.getCedarlingConfiguration()).thenReturn(cedarConf);
+
+ String policyStoreFn = System.getProperty("user.dir") + "/target/test-classes/test-policy-store";
+ // Use the plain HTTP port so Cedarling's Tonic gRPC client connects via h2c
+ // (HTTP/2 cleartext) instead of TLS. WireMock's HTTP connector advertises h2c,
+ // so gRPC works without a certificate. See configureWellKnownEndpoint() for details.
+ String lockServerUri = "http://localhost:" + wireMockServer.getPort() + WELL_KNOWN_PATH;
+ String lockAccessToken = withFutureExp(RAW_LOCK_ACCESS_TOKEN_JWT);
+
+ authService = new CedarlingAuthorizationService() {
+ @Override
+ protected BootstrapConfig prepareBootstrapConfig(CedarlingConfiguration cedarConf) {
+ // JWT signature and status validation are disabled for tests;
+ // only the exp claim is patched to a future value.
+ BootstrapConfig bootstrapConfig = BootstrapConfig.builder().applicationName("Lock Server - Test Edition").policyStoreLocalFn(policyStoreFn).jwtStatusValidation(false)
+ .jwtSigValidation(false).logType(LogType.STD_OUT).logLevel(LogLevel.TRACE)
+ // Lock / telemetry settings
+ .lock(true).lockServerConfigurationUri(lockServerUri).lockLockTransport(LockTransport.GRPC).lockAcceptInvalidCerts(true).lockDynamicConfiguration(true)
+ .lockHealthInterval(TELEMETRY_INTERVAL_SEC).lockTelemetryInterval(TELEMETRY_INTERVAL_SEC).lockListenSse(false).lockAccessTokenJwt(lockAccessToken).build();
+
+ log.info("Cedarling bootstrap configuration: {}", bootstrapConfig.toJsonConfig());
+ return bootstrapConfig;
+ }
+ };
+
+ when(cedarConf.isEnabled()).thenReturn(true);
+
+ injectField(authService, "log", svcLog);
+ injectField(authService, "appConfiguration", appConfig);
+ injectField(authService, "policyConfiguration", policyConfig);
+ injectField(authService, "policyStoreLocalFn", policyStoreFn);
+
+ authService.init();
+
+ // Patch exp claims so tokens are valid during the entire test run
+ jwt1 = withFutureExp(RAW_JWT_1);
+ jwt2 = withFutureExp(RAW_JWT_2);
+ jwt3 = withFutureExp(RAW_JWT_3);
+
+ log.info("Cedarling service initialised (gRPC transport) – lock URI: {}", lockServerUri);
+ }
+
+ // ─── Authorisation helper ─────────────────────────────────────────────────
+
+ private boolean authorize(String token, String action, String permId, String path) {
+ Map tokens = Map.of(ACCESS_TOKEN_KEY, token);
+ return authService.authorize(tokens, action, buildResource(permId, path), buildContext());
+ }
+
+ private static Map buildResource(String permId, String path) {
+ Map resource = new HashMap<>();
+ resource.put("cedar_entity_mapping", Map.of("entity_type", RESOURCE_TYPE, "id", permId));
+ resource.put("url", Map.of("host", "", "path", path, "protocol", ""));
+ resource.put("header", Collections.emptyMap());
+ return resource;
+ }
+
+ private static Map buildContext() {
+ return Collections.emptyMap();
+ }
+
+ // ─── JWT exp patching ─────────────────────────────────────────────────────
+
+ /**
+ * Replaces the {@code exp} claim in the JWT payload with {@code now + 1 hour}. The original signature is kept but becomes stale; Cedarling accepts it because
+ * the adapter is initialised with {@code jwtSigValidation(false)}.
+ *
+ * @param rawJwt original JWT string (header.payload.signature)
+ * @return patched JWT with a future {@code exp}
+ */
+ static String withFutureExp(String rawJwt) {
+ String[] parts = rawJwt.split("\\.");
+ if (parts.length != 3) {
+ throw new IllegalArgumentException("Not a 3-part JWT: " + rawJwt);
+ }
+ String payloadJson = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8);
+
+ long futureExp = (System.currentTimeMillis() / 1000L) + 3600L;
+ String patched = payloadJson.replaceAll("\"exp\"\\s*:\\s*\\d+", "\"exp\":" + futureExp);
+
+ String encodedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString(patched.getBytes(StandardCharsets.UTF_8));
+
+ return parts[0] + "." + encodedPayload + "." + parts[2];
+ }
+
+ // ─── Reflection utility ───────────────────────────────────────────────────
+
+ /**
+ * Sets {@code fieldName} on {@code target} using reflection, walking up the class hierarchy until the field is found.
+ *
+ * @param target object to mutate
+ * @param fieldName field name
+ * @param value value to set
+ * @throws NoSuchFieldException if the field is not found in any superclass
+ */
+ static void injectField(Object target, String fieldName, Object value) throws Exception {
+ Class> cls = target.getClass();
+ while (cls != null) {
+ try {
+ Field field = cls.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(target, value);
+ return;
+ } catch (NoSuchFieldException ignored) {
+ cls = cls.getSuperclass();
+ }
+ }
+ throw new NoSuchFieldException("Field '" + fieldName + "' not found in " + target.getClass().getName());
+ }
+}
\ No newline at end of file
diff --git a/jans-lock/lock-server/cedarling/src/test/java/io/jans/lock/cedarling/telemetry/CedarlingTelemetryIntegrationTest.java b/jans-lock/lock-server/cedarling/src/test/java/io/jans/lock/cedarling/telemetry/CedarlingTelemetryIntegrationTest.java
new file mode 100644
index 00000000000..7d3ce8e0bd1
--- /dev/null
+++ b/jans-lock/lock-server/cedarling/src/test/java/io/jans/lock/cedarling/telemetry/CedarlingTelemetryIntegrationTest.java
@@ -0,0 +1,890 @@
+/*
+ * Janssen Project software is available under the Apache License (2004). See http://www.apache.org/licenses/ for full text.
+ *
+ * Copyright (c) 2026, Janssen Project
+ */
+package io.jans.lock.cedarling.telemetry;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.lang.reflect.Field;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.core.config.Configurator;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.github.tomakehurst.wiremock.verification.LoggedRequest;
+
+import io.jans.lock.cedarling.config.BootstrapConfig;
+import io.jans.lock.cedarling.service.CedarlingAuthorizationService;
+import io.jans.lock.model.config.AppConfiguration;
+import io.jans.lock.model.config.cedarling.CedarlingConfiguration;
+import io.jans.lock.model.config.cedarling.CedarlingPolicyConfiguration;
+import io.jans.lock.model.config.cedarling.LogLevel;
+import io.jans.lock.model.config.cedarling.LogType;
+
+/**
+ * Integration test that verifies Cedarling pushes health, log, and telemetry audit data to a Lock server. A WireMock HTTPS server (inherited from
+ * {@link BaseWireMockHttpTest}) acts as the Lock server, so no real network or external process is required.
+ *
+ * Test structure
+ *
+ * - Setup – WireMock stubs the {@code /.well-known} discovery document and all audit endpoints. Cedarling is initialised with
+ * {@code CEDARLING_LOCK=enabled} and a short telemetry interval ({@value #TELEMETRY_INTERVAL_SEC} s) so data arrives quickly.
+ * - Round 1 – 5 authorization calls (4 ALLOW + 1 DENY). The test waits up to {@value #WAIT_TIMEOUT_SEC} seconds for telemetry, captures the
+ * payloads, and verifies all required fields.
+ * - Reset – WireMock's request journal is cleared to give Round 2 a clean baseline.
+ * - Round 2 – 7 authorization calls (4 ALLOW + 3 DENY). Same capture / verification cycle.
+ * - Cross-round comparison – health status must remain {@code "ok"}; {@code evaluationRequestsCount} must be ≥ the Round 1 value (Cedarling
+ * accumulates it globally).
+ *
+ *
+ * Token matrix
+ *
+ *
+ * ┌────────┬────────────────────────────────────────────────────┬──────────────────┐
+ * │ Token │ OAuth scopes │ Allowed endpoints│
+ * ├────────┼────────────────────────────────────────────────────┼──────────────────┤
+ * │ JWT 1 │ health.write + telemetry.write + log.write │ all three │
+ * │ JWT 2 │ health.write only │ /health only │
+ * │ JWT 3 │ log.write only │ /log only │
+ * └────────┴────────────────────────────────────────────────────┴──────────────────┘
+ *
+ *
+ *
+ * Prerequisites: The native Cedarling UniFFI library must be available on the dynamic-library path ({@code LD_LIBRARY_PATH} /
+ * {@code java.library.path}).
+ *
+ * @see BaseWireMockHttpTest
+ * @see CedarlingAuthorizationService
+ *
+ * @author Yuriy Movchan Date: 12/05/2026
+ */
+@TestInstance(Lifecycle.PER_CLASS)
+@DisplayName("Cedarling Telemetry – Integration Tests")
+public class CedarlingTelemetryIntegrationTest extends BaseWireMockHttpTest {
+
+ static {
+ Configurator.setRootLevel(Level.INFO);
+ }
+
+ private static final Logger log = LoggerFactory.getLogger(CedarlingTelemetryIntegrationTest.class);
+
+ private static final boolean DUMP_CAPTURED_REQUEST = true;
+
+ // ─── WireMock endpoint paths ─────────────────────────────────────────────
+
+ private static final String WELL_KNOWN_PATH = "/.well-known/lock-server-configuration";
+ private static final String HEALTH_PATH = "/jans-lock/api/v1/audit/health";
+ private static final String LOG_PATH = "/jans-lock/api/v1/audit/log";
+ private static final String TELEMETRY_PATH = "/jans-lock/api/v1/audit/telemetry";
+ private static final String BULK_HEALTH_PATH = HEALTH_PATH + "/bulk";
+ private static final String BULK_LOG_PATH = LOG_PATH + "/bulk";
+ private static final String BULK_TELEMETRY_PATH = TELEMETRY_PATH + "/bulk";
+
+ // ─── Raw JWT strings – exp claims are patched at runtime ─────────────────
+
+ /**
+ * JWT 1 – scopes: {@code health.write}, {@code telemetry.write}, {@code log.write}.
+ */
+ private static final String RAW_JWT_1 = "eyJraWQiOiJjb25uZWN0XzNjMzJjMTkzLTY5ZjYtNDBkYS1iNmQyLTE3ODY0YmJkYzU1MV9zaWdfcnMyNTYiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9"
+ + ".eyJhdWQiOiIwZTk5NDAzMC1lZTg4LTRmMTQtYWUwOC04YjBkYTEwNDQwMjkiLCJzdWIiOiIwZTk5NDAzMC1lZTg4LTRmMTQtYWUwOC04YjBkYTEwNDQwMjkiLC"
+ + "J4NXQjUzI1NiI6IiIsIm5iZiI6MTc3ODYxNDA2Niwic2NvcGUiOlsiaHR0cHM6Ly9qYW5zLmlvL29hdXRoL2xvY2svaGVhbHRoLndyaXRlIiwiaHR0cHM6Ly9qYW"
+ + "5zLmlvL29hdXRoL2xvY2svdGVsZW1ldHJ5LndyaXRlIiwiaHR0cHM6Ly9qYW5zLmlvL29hdXRoL2xvY2svbG9nLndyaXRlIl0sImlzcyI6Imh0dHBzOi8vamFucy"
+ + "1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8iLCJ0b2tlbl90eXBlIjoiQmVhcmVyIiwiZXhwIjoxNzc4NjE0MzY2LCJpYXQiOjE3Nzg2MTQwNjYsImNsaWVudF"
+ + "9pZCI6IjBlOTk0MDMwLWVlODgtNGYxNC1hZTA4LThiMGRhMTA0NDAyOSIsImp0aSI6IkhNdjluYnBiUkRLRTAzNVRUTkgwT3ciLCJzdGF0dXMiOnsic3RhdHVzX2"
+ + "xpc3QiOnsiaWR4Ijo0MDAsInVyaSI6Imh0dHBzOi8vamFucy1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8vamFucy1hdXRoL3Jlc3R2MS9zdGF0dXNfbGlzdCJ9fX0"
+ + ".bNXIL4f1lqvLoS49iMSZJORD2mJ9MWYCM5a8nyAiLqKy_fEqvqb-g1X6SgVeS2dJ9aFV-KRrfcjl0zSSQq6mBn-1pAostlMgV-lkOBi7rCbJUMwmdN7Bv7Op8E"
+ + "yuD44_4hHRYhAXOXYv1CcjkyXtv-A9gDxNjHvhHVvpjaizcIMXVRrPxTTQgZF7r7n0t13La2E0vOxzzsgcWQjJukAY8HYybtoRL4JFswBIWPcgET9Btg9mZghDMl"
+ + "vs0yiLVQfiGUZYcmxCCEQinjtutKgONP0Gv6xVMdsXMUpgXGZi6PCiEaEWButMwBauc9RJWEHbd7C4muKoAQ6_tFNuS_eoRw";
+
+ /** JWT 2 – scope: {@code health.write} only. */
+ private static final String RAW_JWT_2 = "eyJraWQiOiJjb25uZWN0XzNjMzJjMTkzLTY5ZjYtNDBkYS1iNmQyLTE3ODY0YmJkYzU1MV9zaWdfcnMyNTYiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9"
+ + ".eyJhdWQiOiIwZTk5NDAzMC1lZTg4LTRmMTQtYWUwOC04YjBkYTEwNDQwMjkiLCJzdWIiOiIwZTk5NDAzMC1lZTg4LTRmMTQtYWUwOC04YjBkYTEwNDQwMjkiLC"
+ + "J4NXQjUzI1NiI6IiIsIm5iZiI6MTc3ODYxNDE2MSwic2NvcGUiOlsiaHR0cHM6Ly9qYW5zLmlvL29hdXRoL2xvY2svaGVhbHRoLndyaXRlIl0sImlzcyI6Imh0dH"
+ + "BzOi8vamFucy1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8iLCJ0b2tlbl90eXBlIjoiQmVhcmVyIiwiZXhwIjoxNzc4NjE0NDYxLCJpYXQiOjE3Nzg2MTQxNj"
+ + "EsImNsaWVudF9pZCI6IjBlOTk0MDMwLWVlODgtNGYxNC1hZTA4LThiMGRhMTA0NDAyOSIsImp0aSI6InRVMGVQb0haU0RPSjJ5Z0EyZFRnc3ciLCJzdGF0dXMiOn"
+ + "sic3RhdHVzX2xpc3QiOnsiaWR4Ijo0MDEsInVyaSI6Imh0dHBzOi8vamFucy1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8vamFucy1hdXRoL3Jlc3R2MS9zdGF0dXNfbGlzdCJ9fX0"
+ + ".iIN4rKz4wnGgijhNWJqY_RqYNx_7zT0hdevnU6wqRwiLp3rQG3c4ouv8P6X4CbiaxERzABbrjsS-4JcW2H2oLpAsuJGhJtr-HExe3iLs_OQ2_4NDwo0k2KJ5e_"
+ + "zGP6Wykr6mQ8WhvGIfURk1aLirLCsegKhH1b26tSp6i8z7z-etNLwGjVPDfw6vV01kYJ0_O_tSf0HuLkGTPf34ld86CUNbPf2cE9Q4uqX_3xVTtMW0ffmOhDo8Qs"
+ + "2dL96xs8O6ah-Rvp6UVjcD4A1qbVImN6USE70nEndmtDR_rvfsCBiL-htkgChTDZymceTcOn00NOvWB2I00rvSy7FdWwNAFQ";
+
+ /** JWT 3 – scope: {@code log.write} only. */
+ private static final String RAW_JWT_3 = "eyJraWQiOiJjb25uZWN0XzNjMzJjMTkzLTY5ZjYtNDBkYS1iNmQyLTE3ODY0YmJkYzU1MV9zaWdfcnMyNTYiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9"
+ + ".eyJhdWQiOiIwZTk5NDAzMC1lZTg4LTRmMTQtYWUwOC04YjBkYTEwNDQwMjkiLCJzdWIiOiIwZTk5NDAzMC1lZTg4LTRmMTQtYWUwOC04YjBkYTEwNDQwMjkiLC"
+ + "J4NXQjUzI1NiI6IiIsIm5iZiI6MTc3ODYxNzc2MSwic2NvcGUiOlsiaHR0cHM6Ly9qYW5zLmlvL29hdXRoL2xvY2svbG9nLndyaXRlIl0sImlzcyI6Imh0dHBzOi"
+ + "8vamFucy1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8iLCJ0b2tlbl90eXBlIjoiQmVhcmVyIiwiZXhwIjoxNzc4NjE4MDYxLCJpYXQiOjE3Nzg2MTc3NjEsIm"
+ + "NsaWVudF9pZCI6IjBlOTk0MDMwLWVlODgtNGYxNC1hZTA4LThiMGRhMTA0NDAyOSIsImp0aSI6IjVxUWRHYWl4VEgybkQ5Z0Y2WDFPaXciLCJzdGF0dXMiOnsic3"
+ + "RhdHVzX2xpc3QiOnsiaWR4Ijo1MDAsInVyaSI6Imh0dHBzOi8vamFucy1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8vamFucy1hdXRoL3Jlc3R2MS9zdGF0dXNfbGlzdCJ9fX0"
+ + ".Q7xieptgb5r9eXqjI5BCSDv_ITtzZXbsXoyqcjsYw0PonF6z3c5XjiSPPrXVUU9dY_HQUrd4ib3U7oIQrKtfXcjJ2pMNuTZ0vPRCcZM_XqqbV3IewUbztabDKD"
+ + "NpK0pSaNZy9V1SslHjW_vQoVDnclJL-w2usyXlMVnFub92GV3ldBZ9cB4UYVRovrzG_UxCa8FI-WkikYoET-vIiHbS5yP3EXlRKwP2pWwhHKwhAC7sjbnYW8ApgY"
+ + "VAmvAnWqwPcaY_Bl-UobDHGBr0b0FhLtMIZvGevo1KdQE5dJwiflZOgiUZiYJU9uJ-tklD2gd5Pq-7g1-DW9Fvsmo2WVDcHw";
+
+ /** Lock endpoints access token – grants all three audit scopes. */
+ private static final String RAW_LOCK_ACCESS_TOKEN_JWT = "eyJraWQiOiJjb25uZWN0X2RjNzViZWZjLWU4N2QtNDMyZi1hOWExLTczYjE0YzJhNjUyMl9zaWdfcnMyNTYiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9"
+ + ".eyJhdWQiOiJmNDUwMTQ1Zi1hYjgyLTRiY2UtOTdjZi02MjQ2YjFjNmIxYTYiLCJzdWIiOiJmNDUwMTQ1Zi1hYjgyLTRiY2UtOTdjZi02MjQ2YjFjNmIxYTYiLC"
+ + "J4NXQjUzI1NiI6IiIsIm5iZiI6MTc3OTk1ODYyNCwic2NvcGUiOlsiaHR0cHM6Ly9qYW5zLmlvL29hdXRoL2xvY2svaGVhbHRoLndyaXRlIiwiaHR0cHM6Ly9qYW"
+ + "5zLmlvL29hdXRoL2xvY2svdGVsZW1ldHJ5LndyaXRlIiwiaHR0cHM6Ly9qYW5zLmlvL29hdXRoL2xvY2svbG9nLndyaXRlIl0sImlzcyI6Imh0dHBzOi8vamFucy"
+ + "1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8iLCJ0b2tlbl90eXBlIjoiQmVhcmVyIiwiZXhwIjoxNzc5OTU4OTI0LCJpYXQiOjE3Nzk5NTg2MjQsImNsaWVudF"
+ + "9pZCI6ImY0NTAxNDVmLWFiODItNGJjZS05N2NmLTYyNDZiMWM2YjFhNiIsImp0aSI6IjNOU2ZLM1hQU25XZDlNWDFDNlFhT1EiLCJzdGF0dXMiOnsic3RhdHVzX2"
+ + "xpc3QiOnsiaWR4Ijo0MDEsInVyaSI6Imh0dHBzOi8vamFucy1teXNxbC1sb2NrLXNlcnZlci5qYW5zLmluZm8vamFucy1hdXRoL3Jlc3R2MS9zdGF0dXNfbGlzdCJ9fX0"
+ + ".YSMkLZIm0JzIIiuShrXbZwLT5Bm58nBpz2fcaGEP4zyyDE0Te1T3WKmJZRsyRNPN3QgIwa9b02C-lGTqUJ9YkylTolLitJHgy7aVhfestGYTZ_r0gvfcGYVF8h"
+ + "zsk5k11U-hb9SGbZOXOvuis998fCXolG-UUaYj7VGjU8xreGLgmEx7Otmfpi2bjenQ0DGFjo82XAVzgqO7gwGT-5zohBQ8uNQcKKASGj4g2NtVcjXqmxBS9huI7e"
+ + "dAJxFPlZ5J7gghfJAIARDLm8UrYgNEHqVdQPDOrAdnIOE5n0I4oJad5fl5luyKSNmd6sL4hi82OR7Ldig3XIjxyHQ7VJYZhA";
+
+ // ─── Cedar action / resource constants ───────────────────────────────────
+
+ private static final String ACTION_POST = "Jans::Action::\"POST\"";
+ private static final String RESOURCE_TYPE = "Jans::HTTP_Request";
+
+ private static final String ID_LOG = "lock_audit_log_write";
+ private static final String ID_HEALTH = "lock_audit_health_write";
+ private static final String ID_TELEMETRY = "lock_audit_telemetry_write";
+
+ private static final String PATH_LOG = "/audit/log/bulk";
+ private static final String PATH_HEALTH = "/audit/health/bulk";
+ private static final String PATH_TELEMETRY = "/audit/telemetry/bulk";
+
+ private static final String ACCESS_TOKEN_KEY = CedarlingAuthorizationService.CEDARLING_JANS_ACCESS_TOKEN;
+
+ // ─── Timing ──────────────────────────────────────────────────────────────
+
+ /** Cedarling telemetry/health push interval in seconds. */
+ private static final int TELEMETRY_INTERVAL_SEC = 5;
+
+ /** Maximum time to wait for at least one HTTP audit payload per round. */
+ private static final int WAIT_TIMEOUT_SEC = 30;
+ private static final Duration WAIT_TIMEOUT = Duration.ofSeconds(WAIT_TIMEOUT_SEC);
+
+ // ─── Test state ───────────────────────────────────────────────────────────
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+ private CedarlingAuthorizationService authService;
+ private String jwt1, jwt2, jwt3;
+
+ /**
+ * Guards one-time Cedarling initialisation inside {@link #registerStubs()}. WireMock stubs must exist before {@code initCedarlingService()} is called
+ * (Cedarling fetches the well-known document during startup), so the service cannot be initialised in {@code @BeforeAll} – at that point the
+ * {@link com.github.tomakehurst.wiremock.junit5.WireMockExtension} has not yet registered any stubs. The flag ensures initialisation happens exactly once, on
+ * the first {@code @BeforeEach} invocation.
+ */
+ private boolean serviceInitialized = false;
+
+ // ─── Lifecycle ───────────────────────────────────────────────────────────
+
+ /**
+ * Registers WireMock stubs and, on the very first call, initialises the Cedarling service.
+ *
+ *
Why everything lives here and not in {@code @BeforeAll}
+ *
+ * {@link com.github.tomakehurst.wiremock.junit5.WireMockExtension} resets all stub mappings in its own {@code beforeEach} callback, which executes
+ * after {@code @BeforeAll} but before any {@code @BeforeEach} defined in the test class. The execution order for each test
+ * method is therefore:
+ *
+ *
+ * @BeforeAll setUpAll() – runs once, but stubs aren't registered yet
+ * WireMockExtension.beforeEach() – wipes all stub mappings
+ * @BeforeEach registerStubs() – stubs registered here, safe to call Cedarling init
+ *
+ *
+ * Cedarling fetches the well-known discovery document during {@code initCedarlingService()}, so the WireMock stubs must be present before that call.
+ * The {@code serviceInitialized} flag ensures the expensive init runs exactly once while the stub registration runs before every test.
+ */
+ @BeforeEach
+ void registerStubs() throws Exception {
+ configureWellKnownEndpoint();
+ configureAuditEndpoints();
+ log.info("WireMock stubs registered (well-known + 6 audit endpoints)");
+
+ if (!serviceInitialized) {
+ initAuthService();
+ serviceInitialized = true;
+ log.info("WireMock HTTPS port: {} | telemetry interval: {}s | wait timeout: {}s", wireMockServer.getHttpsPort(), TELEMETRY_INTERVAL_SEC, WAIT_TIMEOUT_SEC);
+ }
+ }
+
+ /** Shuts down the Cedarling adapter after the class is done. */
+ @AfterAll
+ void tearDown() {
+ if (serviceInitialized) {
+ authService.destroy();
+ log.info("Cedarling service destroyed");
+ }
+ }
+
+ // ─── Tests ───────────────────────────────────────────────────────────────
+
+ @Nested
+ @DisplayName("Two-round telemetry lifecycle")
+ class TwoRoundTelemetryLifecycle {
+
+ /**
+ * Main telemetry lifecycle test.
+ *
+ *
+ * Executes two batches of authorisation calls separated by a WireMock request-journal reset, then verifies:
+ *
+ * - All required proto fields are present in every captured payload.
+ * - Health status is {@code "running"} in both rounds.
+ * - {@code evaluation_requests_count} is ≥ the Round 1 value in Round 2.
+ * - Log entries carry correct {@code decision_result} values.
+ *
+ */
+ @Test
+ @DisplayName("Telemetry accumulates correctly across two authorization rounds")
+ void telemetryAccumulatesAcrossRounds() throws Exception {
+
+ // ════════════════════════════ ROUND 1 ════════════════════════════
+ log.info("=== ROUND 1 – 5 authorisation calls (4 ALLOW + 1 DENY) ===");
+ int round1AuthCalls = executeRound1Authorizations();
+
+ // Wait for data to arrive at least at the primary telemetry endpoint before waiting the full timeout.
+ awaitDuration(Duration.ofSeconds(TELEMETRY_INTERVAL_SEC + 2));
+
+ // Wait for the telemetry push triggered by round-1 evaluations
+ Map> round1CapturedRequests = awaitAndCapture("R1", WAIT_TIMEOUT);
+
+ List r1Telemetry = findCaptured(round1CapturedRequests, "R1 telemetry", TELEMETRY_PATH);
+ List r1BulkTelemetry = findCaptured(round1CapturedRequests, "R1 bulk-telemetry", BULK_TELEMETRY_PATH);
+ List r1Health = findCaptured(round1CapturedRequests, "R1 health", HEALTH_PATH);
+ List r1BulkHealth = findCaptured(round1CapturedRequests, "R1 bulk-health", BULK_HEALTH_PATH);
+ List r1Log = findCaptured(round1CapturedRequests, "R1 log", LOG_PATH);
+ List r1BulkLog = findCaptured(round1CapturedRequests, "R1 bulk-log", BULK_LOG_PATH);
+
+ log.info("Round 1 received – telemetry={}, health={}, log={}, bulkTel={}, bulkHealth={}, bulkLog={}",
+ r1Telemetry.size(), r1Health.size(), r1Log.size(),
+ r1BulkTelemetry.size(), r1BulkHealth.size(), r1BulkLog.size());
+
+ // Structural and value verification
+ verifyHealthPayloads(r1Health, "Round 1");
+ verifyHealthBulkPayloads(r1BulkHealth, "Round 1 (bulk)", -1);
+ verifyLogPayloads(r1Log, "Round 1");
+ verifyLogBulkPayloads(r1BulkLog, "Round 1 (bulk)", round1AuthCalls);
+ verifyTelemetryPayloads(r1Telemetry, "Round 1");
+ verifyTelemetryBulkPayloads(r1BulkTelemetry, "Round 1", -1);
+
+ // Capture round 1's latest evaluationRequestsCount for comparison
+ long r1EvalCount = latestLong(r1Telemetry, "evaluationRequestsCount");
+ log.info("Round 1 – evaluationRequestsCount = {}", r1EvalCount);
+
+ // Reset request journal – Round 2 starts clean
+ wireMockServer.resetRequests();
+ log.info("--- WireMock request journal reset – starting Round 2 ---");
+
+ // ════════════════════════════ ROUND 2 ════════════════════════════
+ log.info("=== ROUND 2 – 7 authorisation calls (4 ALLOW + 3 DENY) ===");
+ int round2AuthCalls = executeRound2Authorizations();
+
+ // Wait for data to arrive at least at the primary telemetry endpoint before waiting the full timeout.
+ awaitDuration(Duration.ofSeconds(TELEMETRY_INTERVAL_SEC + 2));
+
+ // Wait for the telemetry push triggered by round-2 evaluations
+ Map> round2CapturedRequests = awaitAndCapture("R2", WAIT_TIMEOUT);
+
+ List r2Telemetry = findCaptured(round2CapturedRequests, "R2 telemetry", TELEMETRY_PATH);
+ List r2BulkTelemetry = findCaptured(round2CapturedRequests, "R2 bulk-telemetry", BULK_TELEMETRY_PATH);
+ List r2Health = findCaptured(round2CapturedRequests, "R2 health", HEALTH_PATH);
+ List r2BulkHealth = findCaptured(round2CapturedRequests, "R2 bulk-health", BULK_HEALTH_PATH);
+ List r2Log = findCaptured(round2CapturedRequests, "R2 log", LOG_PATH);
+ List r2BulkLog = findCaptured(round2CapturedRequests, "R2 bulk-log", BULK_LOG_PATH);
+
+ log.info("Round 2 received – telemetry={}, health={}, log={}, bulkTel={}, bulkHealth={}, bulkLog={}",
+ r2Telemetry.size(), r2Health.size(), r2Log.size(),
+ r2BulkTelemetry.size(), r2BulkHealth.size(), r2BulkLog.size());
+
+ verifyHealthPayloads(r2Health, "Round 2");
+ verifyHealthBulkPayloads(r2BulkHealth, "Round 2 (bulk)", -1);
+ verifyLogPayloads(r2Log, "Round 2");
+ verifyLogBulkPayloads(r2BulkLog, "Round 2 (bulk)", round2AuthCalls);
+ verifyTelemetryPayloads(r2Telemetry, "Round 2");
+ verifyTelemetryBulkPayloads(r2BulkTelemetry, "Round 2", -1);
+
+ // ════════════════════════ CROSS-ROUND COMPARISON ═════════════════
+ compareTelemetryAcrossRounds(r1EvalCount, r1Telemetry, r2Telemetry);
+ compareHealthAcrossRounds(r1Health, r2Health);
+
+ log.info("✅ All HTTP telemetry assertions passed across both rounds.");
+ }
+ }
+
+ // ─── Authorisation rounds ─────────────────────────────────────────────────
+
+ /**
+ * Round 1: JWT 1 (all scopes) + JWT 2 (health only) – 5 calls, 4 ALLOW + 1 DENY.
+ *
+ * @return total number of authorisation calls made
+ */
+ private int executeRound1Authorizations() {
+ // JWT 1 – all three endpoints allowed
+ assertTrue(authorize(jwt1, ACTION_POST, ID_LOG, PATH_LOG), "R1: JWT1 must be allowed for /log");
+ assertTrue(authorize(jwt1, ACTION_POST, ID_HEALTH, PATH_HEALTH), "R1: JWT1 must be allowed for /health");
+ assertTrue(authorize(jwt1, ACTION_POST, ID_TELEMETRY, PATH_TELEMETRY), "R1: JWT1 must be allowed for /telemetry");
+
+ // JWT 2 – health allowed, log denied
+ assertTrue(authorize(jwt2, ACTION_POST, ID_HEALTH, PATH_HEALTH), "R1: JWT2 must be allowed for /health");
+ assertFalse(authorize(jwt2, ACTION_POST, ID_LOG, PATH_LOG), "R1: JWT2 must be denied for /log (missing log.write)");
+
+ return 5; // 4 ALLOW + 1 DENY
+ }
+
+ /**
+ * Round 2: JWT 3 (log only) + JWT 2 (health only) + JWT 1 – 7 calls, 4 ALLOW + 3 DENY.
+ *
+ * @return total number of authorisation calls made
+ */
+ private int executeRound2Authorizations() {
+ // JWT 3 – log allowed, health and telemetry denied
+ assertTrue(authorize(jwt3, ACTION_POST, ID_LOG, PATH_LOG), "R2: JWT3 must be allowed for /log");
+ assertFalse(authorize(jwt3, ACTION_POST, ID_HEALTH, PATH_HEALTH), "R2: JWT3 must be denied for /health (missing health.write)");
+ assertFalse(authorize(jwt3, ACTION_POST, ID_TELEMETRY, PATH_TELEMETRY), "R2: JWT3 must be denied for /telemetry (missing telemetry.write)");
+
+ // JWT 2 – health allowed, telemetry denied
+ assertTrue(authorize(jwt2, ACTION_POST, ID_HEALTH, PATH_HEALTH), "R2: JWT2 must be allowed for /health");
+ assertFalse(authorize(jwt2, ACTION_POST, ID_TELEMETRY, PATH_TELEMETRY), "R2: JWT2 must be denied for /telemetry (missing telemetry.write)");
+
+ // JWT 1 – all allowed
+ assertTrue(authorize(jwt1, ACTION_POST, ID_LOG, PATH_LOG), "R2: JWT1 must be allowed for /log");
+ assertTrue(authorize(jwt1, ACTION_POST, ID_HEALTH, PATH_HEALTH), "R2: JWT1 must be allowed for /health");
+
+ return 7; // 4 ALLOW + 3 DENY
+ }
+
+ // ─── Payload verification ─────────────────────────────────────────────────
+
+ /**
+ * Verifies structural fields and value assertions for every health payload.
+ *
+ *
+ * Required fields: {@code service}, {@code status}, {@code node_name}.
+ * Value assertions: {@code service == "Lock Server - Test Edition"}, {@code status == "running"}.
+ */
+ private void verifyHealthPayloads(List payloads, String label) {
+ if (payloads.isEmpty()) {
+ log.warn("[{}] No health payloads received – skipping field checks", label);
+ return;
+ }
+ for (int i = 0; i < payloads.size(); i++) {
+ JsonNode node = payloads.get(i);
+ String ctx = label + " health[" + i + "]";
+
+ verifyHealthPayload(ctx, node);
+ }
+ log.info("[{}] {} health payload(s) verified", label, payloads.size());
+ }
+
+ private void verifyHealthBulkPayloads(List payloads, String label, int minExpectedEntries) {
+ if (payloads.isEmpty()) {
+ log.warn("⚠️ [{}] No bulk-health payloads received – skipping field checks", label);
+ return;
+ }
+
+ int count = 0;
+ for (int i = 0; i < payloads.size(); i++) {
+ JsonNode nodeArray = payloads.get(i);
+
+ assertTrue(nodeArray.isArray(), "Bulk Health response should be an array of health payloads, but got: " + nodeArray.getNodeType());
+
+ for (int j = 0; j < nodeArray.size(); j++) {
+ String ctx = label + " health[" + i + "][" + j + "]";
+ JsonNode node = nodeArray.get(j);
+
+ verifyHealthPayload(ctx, node);
+ count++;
+ }
+ }
+ if (minExpectedEntries >= 0) {
+ assertTrue(count >= minExpectedEntries, label + ": expected at least " + minExpectedEntries + " health entries but got " + count);
+ }
+ log.info("[{}] ✅ {} health payloads verified", label, count);
+ }
+
+ private void verifyHealthPayload(String ctx, JsonNode node) {
+ // ── Required fields ──
+ assertTrue(node.hasNonNull("service"), ctx + ": missing 'service'");
+ assertTrue(node.hasNonNull("status"), ctx + ": missing 'status'");
+ assertTrue(node.hasNonNull("node_name"), ctx + ": missing 'node_name'");
+
+ // ── Value assertions ──
+ assertEquals("Lock Server - Test Edition", node.get("service").asText(), ctx + ": service must be 'Lock Server - Test Edition'");
+ assertEquals("running", node.get("status").asText(), ctx + ": status must be 'running'");
+
+ // node_name must be present and non-blank
+ assertFalse(node.get("node_name").asText("").isBlank(), ctx + ": node_name must not be blank");
+ }
+
+ /**
+ * Verifies structural fields for every log (audit) payload.
+ *
+ *
+ * Required fields: {@code clientId}, {@code principalId}, {@code decisionResult}, {@code action}.
+ *
+ *
+ * @param payloads parsed request bodies
+ * @param label test label for assertion messages
+ */
+ private void verifyLogPayloads(List payloads, String label) {
+ if (payloads.isEmpty()) {
+ log.warn("[{}] No log payloads received – skipping field checks", label);
+ return;
+ }
+ for (int i = 0; i < payloads.size(); i++) {
+ // HTTP bodies are the log entry object directly (no "entry" wrapper)
+ verifyLogPayload(label + " log[" + i + "]", payloads.get(i));
+ }
+ log.info("[{}] {} log payload(s) verified", label, payloads.size());
+ }
+
+ private void verifyLogBulkPayloads(List payloads, String label, int minExpectedEntries) {
+ if (payloads.isEmpty()) {
+ log.warn("⚠️ [{}] No bulk-log payloads received – skipping field checks", label);
+ return;
+ }
+
+ int count = 0;
+ for (int i = 0; i < payloads.size(); i++) {
+ JsonNode nodeArray = payloads.get(i);
+
+ assertTrue(nodeArray.isArray(), "Bulk Log response should be an array of log payloads, but got: " + nodeArray.getNodeType());
+
+ for (int j = 0; j < nodeArray.size(); j++) {
+ String ctx = label + " log[" + i + "][" + j + "]";
+ JsonNode node = nodeArray.get(j);
+
+ verifyLogPayload(ctx, node);
+ count++;
+ }
+ }
+ if (minExpectedEntries >= 0) {
+ assertTrue(count >= minExpectedEntries, label + ": expected at least " + minExpectedEntries + " log entries but got " + count);
+ }
+ log.info("[{}] ✅ {} log payload(s) verified", label, count);
+ }
+
+ private void verifyLogPayload(String ctx, JsonNode node) {
+ // ── Required fields ──
+ assertTrue(node.hasNonNull("clientId"), ctx + ": missing 'clientId'");
+ assertTrue(node.hasNonNull("principalId"), ctx + ": missing 'principalId'");
+ assertTrue(node.hasNonNull("decisionResult"), ctx + ": missing 'decisionResult'");
+ assertTrue(node.hasNonNull("action"), ctx + ": missing 'action'");
+
+ // ── Value assertions ──
+ String decision = node.get("decisionResult").asText();
+ assertTrue("ALLOW".equalsIgnoreCase(decision) || "DENY".equalsIgnoreCase(decision), ctx + ": decisionResult must be 'ALLOW' or 'DENY', got: " + decision);
+
+ // action must follow the Cedar action URI format: Namespace::Action::"name"
+ String action = node.get("action").asText();
+ assertTrue(action.matches(".+::Action::\"[^\"]+\""), ctx + ": action must match Cedar format '::Action::\"\"', got: " + action);
+
+ // clientId must be present and non-blank
+ assertFalse(node.get("clientId").asText("").isBlank(), ctx + ": clientId must not be blank");
+ }
+
+ /**
+ * Verifies structural fields and value assertions for every telemetry payload.
+ *
+ *
+ * Required fields: {@code service}, {@code status}, {@code evaluationRequestsCount},
+ * {@code avgPolicyEvaluationTimeNs} (camelCase – HTTP JSON serialisation).
+ */
+ private void verifyTelemetryPayloads(List payloads, String label) {
+ if (payloads.isEmpty()) {
+ log.warn("[{}] No telemetry payloads received – skipping field checks", label);
+ return;
+ }
+ for (int i = 0; i < payloads.size(); i++) {
+ JsonNode node = payloads.get(i);
+ String ctx = label + " telemetry[" + i + "]";
+
+ verifyTelemetryPayload(ctx, node);
+ }
+ log.info("[{}] {} telemetry payload(s) verified", label, payloads.size());
+ }
+
+ private void verifyTelemetryBulkPayloads(List payloads, String label, int minExpectedEntries) {
+ if (payloads.isEmpty()) {
+ log.warn("⚠️ [{}] No telemetry payloads received – skipping field checks", label);
+ return;
+ }
+
+ int count = 0;
+ for (int i = 0; i < payloads.size(); i++) {
+ JsonNode nodeArray = payloads.get(i);
+
+ assertTrue(nodeArray.isArray(), "Bulk Telemetry response should be an array of telemetry payloads, but got: " + nodeArray.getNodeType());
+
+ for (int j = 0; j < nodeArray.size(); j++) {
+ String ctx = label + " telemetry[" + i + "][" + j + "]";
+ JsonNode node = nodeArray.get(j);
+
+ verifyTelemetryPayload(ctx, node);
+ count++;
+ }
+ }
+ if (minExpectedEntries >= 0) {
+ assertTrue(count >= minExpectedEntries, label + ": expected at least " + minExpectedEntries + " telemetry entries but got " + count);
+ }
+ log.info("[{}] ✅ {} telemetry payloads verified", label, count);
+ }
+
+ private void verifyTelemetryPayload(String ctx, JsonNode node) {
+ // ── Required fields ──
+ assertTrue(node.hasNonNull("service"), ctx + ": missing 'service'");
+ assertTrue(node.hasNonNull("status"), ctx + ": missing 'status'");
+ assertTrue(node.hasNonNull("evaluationRequestsCount"), ctx + ": missing 'evaluationRequestsCount'");
+ assertTrue(node.hasNonNull("avgPolicyEvaluationTimeNs"), ctx + ": missing 'avgPolicyEvaluationTimeNs'");
+
+ // ── Value assertions ──
+ assertEquals("cedarling", node.get("service").asText(), ctx + ": service must be 'cedarling'");
+
+ long evalCount = node.get("evaluationRequestsCount").asLong();
+ assertTrue(evalCount >= 0, ctx + ": evaluationRequestsCount must be >= 0, got " + evalCount);
+
+ // Average evaluation time must be strictly positive once any evaluation has occurred
+ long avgEvalNs = node.get("avgPolicyEvaluationTimeNs").asLong(-1L);
+ assertTrue(avgEvalNs > 0, ctx + ": avgPolicyEvaluationTimeNs must be > 0, got " + avgEvalNs);
+ }
+
+ // ─── Cross-round comparison ────────────────────────────────────────────────
+
+ /**
+ * Compares telemetry counters from Round 1 vs Round 2.
+ *
+ *
+ * - {@code evaluationRequestsCount} in Round 2 must be ≥ the Round 1 value because Cedarling accumulates this counter globally.
+ * - The {@code service} name must be identical across rounds.
+ * - Round 2 must contain at least as many telemetry reports as Round 1.
+ *
+ *
+ * @param r1EvalCount already extracted from the last round-1 telemetry entry
+ * @param round1 Round 1 telemetry payloads
+ * @param round2 Round 2 telemetry payloads
+ */
+ private void compareTelemetryAcrossRounds(long r1EvalCount, List round1, List round2) {
+ if (round1.isEmpty() || round2.isEmpty()) {
+ log.warn("⚠️ Cannot compare telemetry rounds – empty data (r1={}, r2={})", round1.size(), round2.size());
+ return;
+ }
+
+ long r2EvalCount = latestLong(round2, "evaluationRequestsCount");
+ log.info("evaluationRequestsCount — Round 1 (last) = {} | Round 2 (last) = {}", r1EvalCount, r2EvalCount);
+
+ // Cedarling accumulates evaluations globally; Round 2 counter must not decrease
+ assertTrue(r2EvalCount >= r1EvalCount, "evaluationRequestsCount must not decrease between rounds: R1=" + r1EvalCount + ", R2=" + r2EvalCount);
+
+ // Service name is invariant
+ String r1Service = latestString(round1, "service");
+ String r2Service = latestString(round2, "service");
+ assertEquals(r1Service, r2Service, "service name must be consistent across rounds");
+
+ // Round 2 had more authorization calls – the difference should be visible
+ long delta = r2EvalCount - r1EvalCount;
+ log.info("evaluationRequestsCount delta (R2 – R1) = {}", delta);
+ // NOTE: delta could be 0 if Cedarling resets the counter per telemetry interval
+ // rather than accumulating globally. The ≥ assertion above is the safe guard.
+
+ log.info("✅ Cross-round HTTP telemetry comparison passed (delta={})", r2EvalCount - r1EvalCount);
+ }
+
+ /**
+ * Verifies that the Cedarling process remains healthy across both rounds regardless of DENY decisions during authorization.
+ */
+ private void compareHealthAcrossRounds(List round1, List round2) {
+ if (round1.isEmpty() || round2.isEmpty()) {
+ log.warn("⚠️ Cannot compare health rounds – empty data (r1={}, r2={})", round1.size(), round2.size());
+ return;
+ }
+ String r1Status = latestString(round1, "status");
+ String r2Status = latestString(round2, "status");
+
+ assertEquals("running", r1Status, "Round 1 health status must be 'running'");
+ assertEquals("running", r2Status, "Round 2 health status must be 'running'");
+
+ log.info("✅ Health status is 'running' in both rounds");
+ }
+
+ // ─── gRPC request capture utilities ──────────────────────────────────────
+ public List findCaptured(Map> capturedRequests, String label, String endpointPath) {
+
+ List captured = capturedRequests.entrySet().stream()
+ .filter(entry -> entry.getKey() != null && entry.getKey().endsWith(endpointPath))
+ .map(Map.Entry::getValue).flatMap(List::stream)
+ .collect(Collectors.toList());
+ log.info("[{}] {} request(s) captured at {}", label, captured.size(), endpointPath);
+ return captured;
+ }
+
+ /**
+ * Polls WireMock for POST requests at {@code endpointPath} until at least one arrives or {@code timeout} elapses, then returns all captured bodies as parsed
+ * {@link JsonNode}s.
+ *
+ * @param label descriptive label for log output
+ * @param endpointPath WireMock path to watch
+ * @param timeout maximum time to wait
+ * @return list of parsed JSON bodies; empty if nothing arrived in time
+ */
+ private Map> awaitAndCapture(String label, Duration timeout) throws InterruptedException {
+
+ long deadline = System.currentTimeMillis() + timeout.toMillis();
+ while (System.currentTimeMillis() < deadline) {
+ List requests = wireMockServer.findAll(postRequestedFor(anyUrl()));
+ if (!requests.isEmpty()) {
+ Map> parsed = parseRequestBodies(requests);
+ String logDump = parsed.entrySet().stream().map(entry -> entry.getKey() + " = " + entry.getValue().size()).collect(Collectors.joining(", ", "{", "}"));
+
+ log.info("Requests captured summary: {}", logDump);
+
+ if (DUMP_CAPTURED_REQUEST) {
+ parsed.forEach((path, nodes) -> {
+ log.info("Captured {} request(s) at {}:", nodes.size(), path);
+ for (int i = 0; i < nodes.size(); i++) {
+ log.info(" [{}][{}]: {}", path, i, nodes.get(i).toPrettyString());
+ }
+ });
+ }
+ return parsed;
+ }
+ Thread.sleep(500);
+ }
+ log.warn("[{}] Timeout ({}s) – no requests arrived", label, timeout.toSeconds());
+ return Collections.emptyMap();
+ }
+
+ private void awaitDuration(Duration timeout) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + timeout.toMillis();
+ while (System.currentTimeMillis() < deadline) {
+ Thread.sleep(500);
+ }
+ }
+
+ private Map> parseRequestBodies(List requests) {
+ return requests.stream().map(r -> {
+ try {
+ // Extract clean path (ignoring query parameters)
+ String fullPath = new URI(r.getUrl()).getPath();
+ String contextPath = fullPath.startsWith("/") ? "/" + fullPath.substring(1) : fullPath;
+ // Parse body
+ JsonNode node = objectMapper.readTree(r.getBodyAsString());
+
+ return new AbstractMap.SimpleEntry<>(contextPath, node);
+ } catch (Exception ex) {
+ log.warn("Could not parse request body or URL: {}", r.getUrl(), ex);
+ return null;
+ }
+ })
+ // Remove requests that failed to parse
+ .filter(Objects::nonNull)
+ // Group by the path (key) and collect the JsonNodes (value) into a List
+ .collect(Collectors.groupingBy(Map.Entry::getKey, Collectors.mapping(Map.Entry::getValue, Collectors.toList())));
+ }
+
+ /** Extracts a {@code long} field from the last node in {@code nodes}. */
+ private long latestLong(List nodes, String field) {
+ if (nodes.isEmpty())
+ return 0L;
+ return nodes.get(nodes.size() - 1).path(field).asLong(0L);
+ }
+
+ /** Extracts a {@code String} field from the last node in {@code nodes}. */
+ private String latestString(List nodes, String field) {
+ if (nodes.isEmpty())
+ return "";
+ return nodes.get(nodes.size() - 1).path(field).asText("");
+ }
+
+ private List iteratorToList(Iterator it) {
+ List list = new ArrayList<>();
+ it.forEachRemaining(list::add);
+ return list;
+ }
+
+ // ─── WireMock stub configuration ─────────────────────────────────────────
+
+ /**
+ * Stubs the Lock server discovery document with endpoint URIs that all point back to the WireMock HTTPS port.
+ */
+ private void configureWellKnownEndpoint() {
+ int port = wireMockServer.getHttpsPort();
+
+ String fullUrl = "https://localhost:" + port + WELL_KNOWN_PATH;
+ log.info("Configuring WireMock well-known endpoint stub at: {}", fullUrl);
+
+ String body = """
+ {
+ "version": "1.0",
+ "issuer": "https://localhost:%d",
+ "audit": {
+ "health_endpoint": "https://localhost:%d/jans-lock/api/v1/audit/health",
+ "log_endpoint": "https://localhost:%d/jans-lock/api/v1/audit/log",
+ "telemetry_endpoint": "https://localhost:%d/jans-lock/api/v1/audit/telemetry"
+ }
+ }
+ """.formatted(port, port, port, port);
+
+ wireMockServer.stubFor(get(urlEqualTo(WELL_KNOWN_PATH)).willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(body)));
+ }
+
+ /** Stubs all six audit endpoints (single + bulk) to return HTTP 200 OK. */
+ private void configureAuditEndpoints() {
+ String ok = """
+ {"success": true, "message": "Audit data processed successfully"}
+ """;
+ List.of(HEALTH_PATH, BULK_HEALTH_PATH, LOG_PATH, BULK_LOG_PATH, TELEMETRY_PATH, BULK_TELEMETRY_PATH)
+ .forEach(path -> wireMockServer.stubFor(post(urlEqualTo(path)).willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(ok))));
+ }
+
+ // ─── Cedarling service initialisation ────────────────────────────────────
+
+ /**
+ * Builds a fully-wired {@link CedarlingAuthorizationService} (no CDI container available in tests) with Lock telemetry enabled and pointing at the WireMock
+ * HTTPS server.
+ */
+ private void initAuthService() throws Exception {
+ Logger svcLog = LoggerFactory.getLogger(CedarlingAuthorizationService.class);
+ AppConfiguration appConfig = mock(AppConfiguration.class);
+ CedarlingPolicyConfiguration policyConfig = mock(CedarlingPolicyConfiguration.class);
+ CedarlingConfiguration cedarConf = mock(CedarlingConfiguration.class);
+
+ when(cedarConf.isEnabled()).thenReturn(true);
+ when(cedarConf.getLogType()).thenReturn(LogType.STD_OUT);
+ when(cedarConf.getLogLevel()).thenReturn(LogLevel.TRACE);
+ when(appConfig.getCedarlingConfiguration()).thenReturn(cedarConf);
+
+ String policyStoreFn = System.getProperty("user.dir") + "/target/test-classes/test-policy-store";
+ // Point Cedarling at the WireMock discovery document
+ String lockServerUri = "https://localhost:" + wireMockServer.getHttpsPort() + WELL_KNOWN_PATH;
+ String lockAccessTokenJwt = withFutureExp(RAW_LOCK_ACCESS_TOKEN_JWT);
+
+ authService = new CedarlingAuthorizationService() {
+ @Override
+ protected BootstrapConfig prepareBootstrapConfig(CedarlingConfiguration cedarConf) {
+ // Delegate to the standard builder; JWT validation is disabled for tests
+ BootstrapConfig bootstrapConfig = BootstrapConfig.builder().applicationName("Lock Server - Test Edition")
+ .policyStoreLocalFn(System.getProperty("user.dir") + "/target/test-classes/test-policy-store").jwtStatusValidation(false).jwtSigValidation(false).logType(LogType.STD_OUT)
+ .logLevel(LogLevel.TRACE)
+ // Lock / telemetry
+ .lock(true).lockAcceptInvalidCerts(true).lockServerConfigurationUri(lockServerUri).lockDynamicConfiguration(true).lockHealthInterval(TELEMETRY_INTERVAL_SEC)
+ .lockTelemetryInterval(TELEMETRY_INTERVAL_SEC).lockListenSse(false).lockAccessTokenJwt(lockAccessTokenJwt).build();
+
+ log.info("Cedarling bootstrap configuration: {}", bootstrapConfig.toJsonConfig());
+ return bootstrapConfig;
+ }
+ };
+
+ when(cedarConf.isEnabled()).thenReturn(true);
+
+ injectField(authService, "log", svcLog);
+ injectField(authService, "appConfiguration", appConfig);
+ injectField(authService, "policyConfiguration", policyConfig);
+ injectField(authService, "policyStoreLocalFn", policyStoreFn);
+
+ authService.init();
+
+ // Patch exp claims so tokens are valid during the entire test run
+ jwt1 = withFutureExp(RAW_JWT_1);
+ jwt2 = withFutureExp(RAW_JWT_2);
+ jwt3 = withFutureExp(RAW_JWT_3);
+
+ log.info("Cedarling service initialised – lock URI: {}", lockServerUri);
+ }
+
+ // ─── Authorisation helper ─────────────────────────────────────────────────
+
+ private boolean authorize(String token, String action, String permId, String path) {
+ Map tokens = Map.of(ACCESS_TOKEN_KEY, token);
+ return authService.authorize(tokens, action, buildResource(permId, path), buildContext());
+ }
+
+ private static Map buildResource(String permId, String path) {
+ Map resource = new HashMap<>();
+ resource.put("cedar_entity_mapping", Map.of("entity_type", RESOURCE_TYPE, "id", permId));
+ resource.put("url", Map.of("host", "", "path", path, "protocol", ""));
+ resource.put("header", Collections.emptyMap());
+ return resource;
+ }
+
+ private static Map buildContext() {
+ return Collections.emptyMap();
+ }
+
+ // ─── JWT exp patching ─────────────────────────────────────────────────────
+
+ /**
+ * Replaces the {@code exp} claim in the JWT payload with {@code now + 1 hour}. The original signature is kept but becomes stale; Cedarling accepts it because
+ * the adapter is initialised with {@code jwtSigValidation(false)}.
+ *
+ * @param rawJwt original JWT string (header.payload.signature)
+ * @return patched JWT with a future {@code exp}
+ */
+ static String withFutureExp(String rawJwt) {
+ String[] parts = rawJwt.split("\\.");
+ if (parts.length != 3) {
+ throw new IllegalArgumentException("Not a 3-part JWT: " + rawJwt);
+ }
+ String payloadJson = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8);
+
+ long futureExp = (System.currentTimeMillis() / 1000L) + 3600L;
+ String patched = payloadJson.replaceAll("\"exp\"\\s*:\\s*\\d+", "\"exp\":" + futureExp);
+
+ String encodedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString(patched.getBytes(StandardCharsets.UTF_8));
+
+ return parts[0] + "." + encodedPayload + "." + parts[2];
+ }
+
+ // ─── Reflection utility ───────────────────────────────────────────────────
+
+ /**
+ * Sets {@code fieldName} on {@code target} using reflection, walking up the class hierarchy until the field is found.
+ *
+ * @param target object to mutate
+ * @param fieldName field name
+ * @param value value to set
+ * @throws NoSuchFieldException if the field is not found in any superclass
+ */
+ static void injectField(Object target, String fieldName, Object value) throws Exception {
+ Class> cls = target.getClass();
+ while (cls != null) {
+ try {
+ Field field = cls.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(target, value);
+ return;
+ } catch (NoSuchFieldException ignored) {
+ cls = cls.getSuperclass();
+ }
+ }
+ throw new NoSuchFieldException("Field '" + fieldName + "' not found in " + target.getClass().getName());
+ }
+}
\ No newline at end of file
diff --git a/jans-lock/lock-server/cedarling/src/test/resources/grpc/audit.proto b/jans-lock/lock-server/cedarling/src/test/resources/grpc/audit.proto
new file mode 100644
index 00000000000..2bee038ef11
--- /dev/null
+++ b/jans-lock/lock-server/cedarling/src/test/resources/grpc/audit.proto
@@ -0,0 +1,93 @@
+syntax = "proto3";
+
+package io.jans.lock.audit;
+
+option java_multiple_files = true;
+option java_package = "io.jans.lock.model.audit.grpc";
+option java_outer_classname = "AuditProto";
+
+import "google/protobuf/timestamp.proto";
+
+// Health Entry Message
+message HealthEntry {
+ google.protobuf.Timestamp creation_date = 1;
+ google.protobuf.Timestamp event_time = 2;
+ string service = 3;
+ string node_name = 4;
+ string status = 5;
+ map engine_status = 6;
+}
+
+message HealthRequest {
+ HealthEntry entry = 1;
+}
+
+message BulkHealthRequest {
+ repeated HealthEntry entries = 1;
+}
+
+// Log Entry Message
+message LogEntry {
+ google.protobuf.Timestamp creation_date = 1;
+ google.protobuf.Timestamp event_time = 2;
+ string service = 3;
+ string node_name = 4;
+ string event_type = 5;
+ string severity_level = 6;
+ string action = 7;
+ string decision_result = 8;
+ string requested_resource = 9;
+ string principal_id = 10;
+ string client_id = 11;
+ string jti = 12;
+ map context_information = 13;
+}
+
+message LogRequest {
+ LogEntry entry = 1;
+}
+
+message BulkLogRequest {
+ repeated LogEntry entries = 1;
+}
+
+// Telemetry Entry Message
+message TelemetryEntry {
+ google.protobuf.Timestamp creation_date = 1;
+ google.protobuf.Timestamp event_time = 2;
+ string service = 3;
+ string node_name = 4;
+ string status = 5;
+ int64 last_policy_load_size = 6;
+ int64 policy_success_load_counter = 7;
+ int64 policy_failed_load_counter = 8;
+ int64 last_policy_evaluation_time_ns = 9;
+ int64 avg_policy_evaluation_time_ns = 10;
+ int64 memory_usage = 11;
+ int64 evaluation_requests_count = 12;
+ map policy_stats = 13;
+}
+
+message TelemetryRequest {
+ TelemetryEntry entry = 1;
+}
+
+message BulkTelemetryRequest {
+ repeated TelemetryEntry entries = 1;
+}
+
+// Common Response
+message AuditResponse {
+ bool success = 1;
+ string message = 2;
+}
+
+// Audit Service
+service AuditService {
+ rpc ProcessHealth(HealthRequest) returns (AuditResponse);
+ rpc ProcessBulkHealth(BulkHealthRequest) returns (AuditResponse);
+ rpc ProcessLog(LogRequest) returns (AuditResponse);
+ rpc ProcessBulkLog(BulkLogRequest) returns (AuditResponse);
+ rpc ProcessTelemetry(TelemetryRequest) returns (AuditResponse);
+ rpc ProcessBulkTelemetry(BulkTelemetryRequest) returns (AuditResponse);
+}
\ No newline at end of file
diff --git a/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/metadata.json b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/metadata.json
new file mode 100644
index 00000000000..09dfbd85c5d
--- /dev/null
+++ b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/metadata.json
@@ -0,0 +1,10 @@
+{
+ "cedar_version": "4.4.0",
+ "policy_store": {
+ "id": "a1bf93115de86de760ee0bea1d529b521489e5a11747",
+ "name": "JansLock",
+ "description": "Policy store for Jans Lock endpoints protection",
+ "version": "1.0.0",
+ "created_date": "2026-05-18T17:22:39Z"
+ }
+}
diff --git a/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/audit/health_bulk_write.cedar b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/audit/health_bulk_write.cedar
new file mode 100644
index 00000000000..c268bbe46bf
--- /dev/null
+++ b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/audit/health_bulk_write.cedar
@@ -0,0 +1,12 @@
+@id("lock_audit_health_bulk_write")
+permit(
+ principal,
+ action in [Jans::Action::"POST"],
+ resource == Jans::HTTP_Request::"lock_audit_health_bulk_write"
+)
+when {
+context has tokens &&
+ context.tokens has jans_access_token &&
+ context.tokens.jans_access_token has scope &&
+ context.tokens.jans_access_token.scope.contains("https://jans.io/oauth/lock/health.write")
+};
\ No newline at end of file
diff --git a/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/audit/health_write.cedar b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/audit/health_write.cedar
new file mode 100644
index 00000000000..4e15f2dee6a
--- /dev/null
+++ b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/audit/health_write.cedar
@@ -0,0 +1,12 @@
+@id("lock_audit_health_write")
+permit(
+ principal,
+ action in [Jans::Action::"POST"],
+ resource == Jans::HTTP_Request::"lock_audit_health_write"
+)
+when {
+context has tokens &&
+ context.tokens has jans_access_token &&
+ context.tokens.jans_access_token has scope &&
+ context.tokens.jans_access_token.scope.contains("https://jans.io/oauth/lock/health.write")
+};
\ No newline at end of file
diff --git a/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/audit/log_bulk_write.cedar b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/audit/log_bulk_write.cedar
new file mode 100644
index 00000000000..0e1c3e9d9e1
--- /dev/null
+++ b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/audit/log_bulk_write.cedar
@@ -0,0 +1,12 @@
+@id("lock_audit_log_bulk_write")
+permit(
+ principal,
+ action in [Jans::Action::"POST"],
+ resource == Jans::HTTP_Request::"lock_audit_log_bulk_write"
+)
+when {
+context has tokens &&
+ context.tokens has jans_access_token &&
+ context.tokens.jans_access_token has scope &&
+ context.tokens.jans_access_token.scope.contains("https://jans.io/oauth/lock/log.write")
+};
\ No newline at end of file
diff --git a/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/audit/log_write.cedar b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/audit/log_write.cedar
new file mode 100644
index 00000000000..c19014bec4d
--- /dev/null
+++ b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/audit/log_write.cedar
@@ -0,0 +1,12 @@
+@id("lock_audit_log_write")
+permit(
+ principal,
+ action in [Jans::Action::"POST"],
+ resource == Jans::HTTP_Request::"lock_audit_log_write"
+)
+when {
+context has tokens &&
+ context.tokens has jans_access_token &&
+ context.tokens.jans_access_token has scope &&
+ context.tokens.jans_access_token.scope.contains("https://jans.io/oauth/lock/log.write")
+};
\ No newline at end of file
diff --git a/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/audit/telemetry_bulk_write.cedar b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/audit/telemetry_bulk_write.cedar
new file mode 100644
index 00000000000..81788cc8895
--- /dev/null
+++ b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/audit/telemetry_bulk_write.cedar
@@ -0,0 +1,12 @@
+@id("lock_audit_telemetry_bulk_write")
+permit(
+ principal,
+ action in [Jans::Action::"POST"],
+ resource == Jans::HTTP_Request::"lock_audit_telemetry_bulk_write"
+)
+when {
+context has tokens &&
+ context.tokens has jans_access_token &&
+ context.tokens.jans_access_token has scope &&
+ context.tokens.jans_access_token.scope.contains("https://jans.io/oauth/lock/telemetry.write")
+};
\ No newline at end of file
diff --git a/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/audit/telemetry_write.cedar b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/audit/telemetry_write.cedar
new file mode 100644
index 00000000000..db5469874de
--- /dev/null
+++ b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/audit/telemetry_write.cedar
@@ -0,0 +1,12 @@
+@id("lock_audit_telemetry_write")
+permit(
+ principal,
+ action in [Jans::Action::"POST"],
+ resource == Jans::HTTP_Request::"lock_audit_telemetry_write"
+)
+when {
+context has tokens &&
+ context.tokens has jans_access_token &&
+ context.tokens.jans_access_token has scope &&
+ context.tokens.jans_access_token.scope.contains("https://jans.io/oauth/lock/telemetry.write")
+};
\ No newline at end of file
diff --git a/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/policy/policy_get.cedar b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/policy/policy_get.cedar
new file mode 100644
index 00000000000..393ab56d437
--- /dev/null
+++ b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/policy/policy_get.cedar
@@ -0,0 +1,12 @@
+@id("lock_policy_get_by_id")
+permit(
+ principal,
+ action in [Jans::Action::"GET"],
+ resource == Jans::HTTP_Request::"lock_policy_get_by_id"
+)
+when {
+context has tokens &&
+ context.tokens has jans_access_token &&
+ context.tokens.jans_access_token has scope &&
+ context.tokens.jans_access_token.scope.contains("https://jans.io/oauth/lock/policy.readonly")
+};
\ No newline at end of file
diff --git a/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/policy/policy_list.cedar b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/policy/policy_list.cedar
new file mode 100644
index 00000000000..29628c5d64e
--- /dev/null
+++ b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/policy/policy_list.cedar
@@ -0,0 +1,12 @@
+@id("lock_policy_list")
+permit(
+ principal,
+ action in [Jans::Action::"GET"],
+ resource == Jans::HTTP_Request::"lock_policy_list"
+)
+when {
+context has tokens &&
+ context.tokens has jans_access_token &&
+ context.tokens.jans_access_token has scope &&
+ context.tokens.jans_access_token.scope.contains("https://jans.io/oauth/lock/policy.readonly")
+};
\ No newline at end of file
diff --git a/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/stat/stat_query.cedar b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/stat/stat_query.cedar
new file mode 100644
index 00000000000..525b7baaa71
--- /dev/null
+++ b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/policies/stat/stat_query.cedar
@@ -0,0 +1,12 @@
+@id("lock_stat_query")
+permit(
+ principal,
+ action in [Jans::Action::"Search"],
+ resource == Jans::HTTP_Request::"lock_stat_query"
+)
+when {
+context has tokens &&
+ context.tokens has jans_access_token &&
+ context.tokens.jans_access_token has scope &&
+ context.tokens.jans_access_token.scope.contains("https://jans.io/oauth/lock/stat.readonly")
+};
\ No newline at end of file
diff --git a/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/schema.cedarschema b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/schema.cedarschema
new file mode 100644
index 00000000000..8942908d688
--- /dev/null
+++ b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/schema.cedarschema
@@ -0,0 +1,177 @@
+namespace Jans {
+ // ****** TYPES ******
+ type Url = {
+ host: String,
+ path: String,
+ protocol: String
+ };
+ type email_address = {
+ domain: String,
+ uid: String
+ };
+ type Context = {
+ network?: String,
+ network_type?: String,
+ user_agent?: String,
+ operating_system?: String,
+ device_health?: Set,
+ current_time?: Long,
+ geolocation?: Set,
+ fraud_indicators?: Set,
+ tokens?: TokensContext,
+ };
+ type TokensContext = {
+ jans_access_token?: Access_token,
+ total_token_count: Long,
+ };
+
+ // ****** Entities ******
+ entity Role;
+ entity User in [Role] = {
+ email?: email_address,
+ phone_number?: String,
+ role: Set,
+ sub: String,
+ "username"?: String,
+ id_token?: id_token,
+ userinfo_token?: Userinfo_token,
+ };
+ entity Workload = {
+ client_id: String,
+ iss?: TrustedIssuer,
+ name?: String,
+ rp_id?: String,
+ spiffe_id?: String,
+ access_token?: Access_token,
+ };
+ entity Access_token = {
+ token_type?: String,
+ validated_at?: Long,
+ aud?: String,
+ exp?: Long,
+ iat?: Long,
+ iss?: TrustedIssuer,
+ jti?: String,
+ nbf?: Long,
+ scope?: Set
+ } tags Set;
+ entity id_token = {
+ token_type?: String,
+ validated_at?: Long,
+ acr?: String,
+ amr?: Set,
+ aud?: Set,
+ azp?: String,
+ birthdate?: String,
+ email?: email_address,
+ exp?: Long,
+ iat?: Long,
+ iss?: TrustedIssuer,
+ jti?: String,
+ name?: String,
+ phone_number?: String,
+ role?: Set,
+ sub?: String
+ } tags Set;
+ entity Userinfo_token = {
+ token_type?: String,
+ validated_at?: Long,
+ aud?: String,
+ birthdate?: String,
+ email?: email_address,
+ exp?: Long,
+ iat?: Long,
+ iss?: TrustedIssuer,
+ jti?: String,
+ name?: String,
+ phone_number?: String,
+ role?: Set,
+ sub?: String
+ } tags Set;
+ entity HTTP_Request = {
+ "header": {
+ "Accept"?: String
+ },
+ "url": Url
+ };
+ entity TrustedIssuer = {
+ issuer_entity_id: Url
+ };
+ entity Application = {
+ app_id: String,
+ name: String,
+ url: Url
+ };
+
+ // ****** Actions ******
+ action Compare appliesTo {
+ principal: [User, Workload],
+ resource: [Application],
+ context: Context
+ };
+ action Execute appliesTo {
+ principal: [User, Workload],
+ resource: [Application],
+ context: Context
+ };
+ action Monitor appliesTo {
+ principal: [User, Workload],
+ resource: [Application],
+ context: Context
+ };
+ action Read appliesTo {
+ principal: [User, Workload],
+ resource: [Application],
+ context: Context
+ };
+ action Search appliesTo {
+ principal: [User, Workload],
+ resource: [Application],
+ context: Context
+ };
+ action Share appliesTo {
+ principal: [User, Workload],
+ resource: [Application],
+ context: Context
+ };
+ action Tag appliesTo {
+ principal: [User, Workload],
+ resource: [Application],
+ context: Context
+ };
+ action Write appliesTo {
+ principal: [User, Workload],
+ resource: [Application],
+ context: Context
+ };
+ action GET appliesTo {
+ principal: [Workload],
+ resource: [HTTP_Request],
+ context: Context
+ };
+ action POST appliesTo {
+ principal: [Workload],
+ resource: [HTTP_Request],
+ context: Context
+ };
+ action PUT appliesTo {
+ principal: [Workload],
+ resource: [HTTP_Request],
+ context: Context
+ };
+ action DELETE appliesTo {
+ principal: [Workload],
+ resource: [HTTP_Request],
+ context: Context
+ };
+ action HEAD appliesTo {
+ principal: [Workload],
+ resource: [HTTP_Request],
+ context: Context
+ };
+ action PATCH appliesTo {
+ principal: [Workload],
+ resource: [HTTP_Request],
+ context: Context
+ };
+}
diff --git a/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/trusted-issuers/jans-issuer.json b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/trusted-issuers/jans-issuer.json
new file mode 100644
index 00000000000..7395bdb9978
--- /dev/null
+++ b/jans-lock/lock-server/cedarling/src/test/resources/test-policy-store/trusted-issuers/jans-issuer.json
@@ -0,0 +1,16 @@
+{
+ "name": "Jans",
+ "description": "Jans Lock Issuer",
+ "configuration_endpoint": "https://jans-mysql-lock-server.jans.info/.well-known/openid-configuration",
+ "token_metadata": {
+ "access_token": {
+ "trusted": true,
+ "entity_type_name": "Jans::Access_token",
+ "token_id": "jti",
+ "required_claims": [
+ "jti"
+ ],
+ "principal_mapping": ["Jans::Workload"]
+ }
+ }
+}
diff --git a/jans-lock/lock-server/cedarling/src/test/resources/wiremock/grpc/audit.desc b/jans-lock/lock-server/cedarling/src/test/resources/wiremock/grpc/audit.desc
new file mode 100644
index 00000000000..f75385e5bc8
--- /dev/null
+++ b/jans-lock/lock-server/cedarling/src/test/resources/wiremock/grpc/audit.desc
@@ -0,0 +1,85 @@
+
+
+google/protobuf/timestamp.protogoogle.protobuf";
+ Timestamp
+seconds (Rseconds
+nanos (RnanosB~
+com.google.protobufBTimestampProtoPZ+github.com/golang/protobuf/ptypes/timestampGPBGoogle.Protobuf.WellKnownTypesbproto3
+
+audit.protoio.jans.lock.auditgoogle/protobuf/timestamp.proto"
+HealthEntry?
+
creation_date (2.google.protobuf.TimestampRcreationDate9
+
+event_time (2.google.protobuf.TimestampR eventTime
+service ( Rservice
+ node_name ( RnodeName
+status ( RstatusV
+
engine_status (21.io.jans.lock.audit.HealthEntry.EngineStatusEntryRengineStatus?
+EngineStatusEntry
+key ( Rkey
+value ( Rvalue:8"F
+
HealthRequest5
+entry (2.io.jans.lock.audit.HealthEntryRentry"N
+BulkHealthRequest9
+entries (2.io.jans.lock.audit.HealthEntryRentries"
+LogEntry?
+
creation_date (2.google.protobuf.TimestampRcreationDate9
+
+event_time (2.google.protobuf.TimestampR eventTime
+service ( Rservice
+ node_name ( RnodeName
+
+event_type ( R eventType%
+severity_level ( R
severityLevel
+action ( Raction'
+decision_result ( RdecisionResult-
+requested_resource ( RrequestedResource!
+principal_id
+ ( RprincipalId
+ client_id ( RclientId
+jti ( Rjtie
+context_information
(24.io.jans.lock.audit.LogEntry.ContextInformationEntryRcontextInformationE
+ContextInformationEntry
+key ( Rkey
+value ( Rvalue:8"@
+
+LogRequest2
+entry (2.io.jans.lock.audit.LogEntryRentry"H
+BulkLogRequest6
+entries (2.io.jans.lock.audit.LogEntryRentries"
+TelemetryEntry?
+
creation_date (2.google.protobuf.TimestampRcreationDate9
+
+event_time (2.google.protobuf.TimestampR eventTime
+service ( Rservice
+ node_name ( RnodeName
+status ( Rstatus1
+last_policy_load_size (RlastPolicyLoadSize=
+policy_success_load_counter (RpolicySuccessLoadCounter;
+policy_failed_load_counter (RpolicyFailedLoadCounterB
+last_policy_evaluation_time_ns (RlastPolicyEvaluationTimeNs@
+avg_policy_evaluation_time_ns
+ (RavgPolicyEvaluationTimeNs!
+memory_usage (RmemoryUsage:
+evaluation_requests_count (RevaluationRequestsCountV
+policy_stats
(23.io.jans.lock.audit.TelemetryEntry.PolicyStatsEntryRpolicyStats>
+PolicyStatsEntry
+key ( Rkey
+value (Rvalue:8"L
+TelemetryRequest8
+entry (2".io.jans.lock.audit.TelemetryEntryRentry"T
+BulkTelemetryRequest<
+entries (2".io.jans.lock.audit.TelemetryEntryRentries"C
+
AuditResponse
+success (Rsuccess
+message ( Rmessage2
+AuditServiceU
+
ProcessHealth!.io.jans.lock.audit.HealthRequest!.io.jans.lock.audit.AuditResponse]
+ProcessBulkHealth%.io.jans.lock.audit.BulkHealthRequest!.io.jans.lock.audit.AuditResponseO
+
+ProcessLog.io.jans.lock.audit.LogRequest!.io.jans.lock.audit.AuditResponseW
+ProcessBulkLog".io.jans.lock.audit.BulkLogRequest!.io.jans.lock.audit.AuditResponse[
+ProcessTelemetry$.io.jans.lock.audit.TelemetryRequest!.io.jans.lock.audit.AuditResponsec
+ProcessBulkTelemetry(.io.jans.lock.audit.BulkTelemetryRequest!.io.jans.lock.audit.AuditResponseB-
+io.jans.lock.model.audit.grpcB
+AuditProtoPbproto3
\ No newline at end of file
diff --git a/jans-lock/lock-server/model/src/main/java/io/jans/lock/model/config/cedarling/LockTransport.java b/jans-lock/lock-server/model/src/main/java/io/jans/lock/model/config/cedarling/LockTransport.java
new file mode 100644
index 00000000000..e0388e06b22
--- /dev/null
+++ b/jans-lock/lock-server/model/src/main/java/io/jans/lock/model/config/cedarling/LockTransport.java
@@ -0,0 +1,26 @@
+/*
+ * Janssen Project software is available under the Apache License (2004). See http://www.apache.org/licenses/ for full text.
+ *
+ * Copyright (c) 2025, Janssen Project
+ */
+
+package io.jans.lock.model.config.cedarling;
+
+/**
+ *
+ * @author Yuriy Movchan Date: 10/08/2022
+ */
+public enum LockTransport {
+ REST("rest"),
+ GRPC("grpc");
+
+ private final String type;
+
+ private LockTransport(String type) {
+ this.type = type;
+ }
+
+ public String getType() {
+ return type;
+ }
+}
\ No newline at end of file
diff --git a/jans-lock/lock-server/pom.xml b/jans-lock/lock-server/pom.xml
index 939532e42d6..69c198595af 100644
--- a/jans-lock/lock-server/pom.xml
+++ b/jans-lock/lock-server/pom.xml
@@ -59,16 +59,16 @@
maven central
https://repo1.maven.org/maven2
-
- github
- GitHub Packages
- https://maven.pkg.github.com/JanssenProject/jans
-
jans
Janssen project repository
https://maven.jans.io/maven
+
+ github
+ GitHub Packages
+ https://maven.pkg.github.com/JanssenProject/jans
+
bouncycastle
Bouncy Castle
diff --git a/jans-lock/lock-server/server/pom.xml b/jans-lock/lock-server/server/pom.xml
index 3384e66e282..700ddd1e1da 100644
--- a/jans-lock/lock-server/server/pom.xml
+++ b/jans-lock/lock-server/server/pom.xml
@@ -208,11 +208,6 @@
mockito-core
test
-
- org.mockito
- mockito-inline
- test
-
org.mockito
mockito-junit-jupiter
diff --git a/jans-lock/lock-server/service/pom.xml b/jans-lock/lock-server/service/pom.xml
index fb8814bc039..8b53e9e7924 100644
--- a/jans-lock/lock-server/service/pom.xml
+++ b/jans-lock/lock-server/service/pom.xml
@@ -285,11 +285,6 @@
mockito-core
test
-
- org.mockito
- mockito-inline
- test
-
org.mockito
mockito-junit-jupiter
diff --git a/jans-lock/lock-server/service/src/main/java/io/jans/lock/service/ws/rs/app/ResteasyInitializer.java b/jans-lock/lock-server/service/src/main/java/io/jans/lock/service/ws/rs/app/ResteasyInitializer.java
index 7dcfd99c758..7a1a1e760f7 100644
--- a/jans-lock/lock-server/service/src/main/java/io/jans/lock/service/ws/rs/app/ResteasyInitializer.java
+++ b/jans-lock/lock-server/service/src/main/java/io/jans/lock/service/ws/rs/app/ResteasyInitializer.java
@@ -56,7 +56,7 @@
version = "OAS Version"),
tags = { @Tag(name = "Lock - Server Configuration"), @Tag(name = "Lock - Stat"), @Tag(name = "Lock - Audit Health"),
- @Tag(name = "Lock - Audit Log"), @Tag(name = "Lock - Audit Telemetry"), @Tag(name = "Lock - SSE"),
+ @Tag(name = "Lock - Audit Log"), @Tag(name = "Lock - Audit Telemetry"),
},
servers = { @Server(url = "https://jans.local.io", description = "The Jans server") })
diff --git a/jans-lock/lock-server/service/src/main/java/io/jans/lock/service/ws/rs/audit/AuditRestWebService.java b/jans-lock/lock-server/service/src/main/java/io/jans/lock/service/ws/rs/audit/AuditRestWebService.java
index e901d4c5555..43a5dd04571 100644
--- a/jans-lock/lock-server/service/src/main/java/io/jans/lock/service/ws/rs/audit/AuditRestWebService.java
+++ b/jans-lock/lock-server/service/src/main/java/io/jans/lock/service/ws/rs/audit/AuditRestWebService.java
@@ -83,7 +83,7 @@ Response processHealthRequest(HealthEntry healthEntry, @Context HttpServletReque
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@ProtectedApi(scopes = { ApiAccessConstants.LOCK_HEALTH_WRITE_ACCESS }, grpcMethodName = "ProcessBulkHealth")
- @ProtectedCedarlingApi(action = "Jans::Action::\"POST\"", resource = "Jans::HTTP_Request", id="lock_audit_health_write", path="/audit/health/bulk")
+ @ProtectedCedarlingApi(action = "Jans::Action::\"POST\"", resource = "Jans::HTTP_Request", id="lock_audit_health_bulk_write", path="/audit/health/bulk")
Response processBulkHealthRequest(List healthEntries, @Context HttpServletRequest request,
@Context SecurityContext sec);
@@ -119,7 +119,7 @@ Response processLogRequest(LogEntry logEntry, @Context HttpServletRequest reques
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@ProtectedApi(scopes = { ApiAccessConstants.LOCK_LOG_WRITE_ACCESS }, grpcMethodName = "ProcessBulkLog")
- @ProtectedCedarlingApi(action = "Jans::Action::\"POST\"", resource = "Jans::HTTP_Request", id="lock_audit_log_write", path="/audit/log/bulk")
+ @ProtectedCedarlingApi(action = "Jans::Action::\"POST\"", resource = "Jans::HTTP_Request", id="lock_audit_log_bulk_write", path="/audit/log/bulk")
Response processBulkLogRequest(List logEntries, @Context HttpServletRequest request,
@Context SecurityContext sec);
@@ -155,7 +155,7 @@ Response processTelemetryRequest(TelemetryEntry telemetryEntry, @Context HttpSer
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@ProtectedApi(scopes = { ApiAccessConstants.LOCK_TELEMETRY_WRITE_ACCESS }, grpcMethodName = "ProcessBulkTelemetry")
- @ProtectedCedarlingApi(action = "Jans::Action::\"POST\"", resource = "Jans::HTTP_Request", id="lock_audit_telemetry_write", path="/audit/telemetry/bulk")
+ @ProtectedCedarlingApi(action = "Jans::Action::\"POST\"", resource = "Jans::HTTP_Request", id="lock_audit_telemetry_bulk_write", path="/audit/telemetry/bulk")
Response processBulkTelemetryRequest(List telemetryEntries, @Context HttpServletRequest request,
@Context SecurityContext sec);
diff --git a/jans-lock/lock-server/service/src/main/java/io/jans/lock/service/ws/rs/stat/StatRestWebService.java b/jans-lock/lock-server/service/src/main/java/io/jans/lock/service/ws/rs/stat/StatRestWebService.java
index da5c7734985..f51ad842ed4 100644
--- a/jans-lock/lock-server/service/src/main/java/io/jans/lock/service/ws/rs/stat/StatRestWebService.java
+++ b/jans-lock/lock-server/service/src/main/java/io/jans/lock/service/ws/rs/stat/StatRestWebService.java
@@ -43,7 +43,7 @@ public interface StatRestWebService {
@ApiResponse(responseCode = "500", description = "InternalServerError", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = LockApiError.class, description = "InternalServerError"))), })
@GET
@ProtectedApi(scopes = { ApiAccessConstants.LOCK_STAT_READ_ACCESS })
- @ProtectedCedarlingApi(action = "Jans::Action::\"GET\"", resource = "Jans::HTTP_Request", id="lock_stat_query", path="/internal/stat")
+ @ProtectedCedarlingApi(action = "Jans::Action::\"Search\"", resource = "Jans::HTTP_Request", id="lock_stat_query", path="/internal/stat")
@Produces(MediaType.APPLICATION_JSON)
public Response statGet(@Context HttpServletRequest request, @QueryParam("month") String months, @QueryParam("start-month") String startMonth,
@QueryParam("end-month") String endMonth, @QueryParam("format") String format);
@@ -59,7 +59,7 @@ public Response statGet(@Context HttpServletRequest request, @QueryParam("month"
@ApiResponse(responseCode = "500", description = "InternalServerError", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = LockApiError.class, description = "InternalServerError"))), })
@POST
@ProtectedApi(scopes = { ApiAccessConstants.LOCK_STAT_READ_ACCESS })
- @ProtectedCedarlingApi(action = "Jans::Action::\"POST\"", resource = "Jans::HTTP_Request", id="lock_stat_query", path="/internal/stat")
+ @ProtectedCedarlingApi(action = "Jans::Action::\"Search\"", resource = "Jans::HTTP_Request", id="lock_stat_query", path="/internal/stat")
@Produces(MediaType.APPLICATION_JSON)
public Response statPost(@Context HttpServletRequest request, @FormParam("month") String months, @FormParam("start-month") String startMonth,
@FormParam("end-month") String endMonth, @FormParam("format") String format);