You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@knox.apache.org by ha...@apache.org on 2026/09/21 11:39:57 UTC

(knox) branch master updated: KNOX-3477: Capture the RFC 8693 delegation actor-chain in gateway-audit.log (#1419)

This is an automated email from the ASF dual-hosted git repository.

hanicz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/knox.git


The following commit(s) were added to refs/heads/master by this push:
     new 02428a7d5 KNOX-3477: Capture the RFC 8693 delegation actor-chain in gateway-audit.log (#1419)
02428a7d5 is described below

commit 02428a7d5457dabe36af182a0f877e6120b0432d
Author: hanicz <ha...@users.noreply.github.com>
AuthorDate: Mon Sep 21 13:39:52 2026 +0200

    KNOX-3477: Capture the RFC 8693 delegation actor-chain in gateway-audit.log (#1419)
---
 .../federation/jwt/filter/AbstractJWTFilter.java   |   7 +-
 .../jwt/filter/TokenExchangeAuditing.java          |  30 ++--
 .../jwt/filter/TokenExchangeHandler.java           |  14 +-
 .../AuthenticationActorChainAuditTest.java         | 173 +++++++++++++++++++++
 .../jwt/filter/TokenExchangeHandlerTest.java       |   2 +
 .../apache/knox/gateway/security/SubjectUtils.java |  33 ++++
 .../knox/gateway/security/SubjectUtilsTest.java    |  75 +++++++++
 7 files changed, 315 insertions(+), 19 deletions(-)

diff --git a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java
index 27035fa46..bc4a34e11 100644
--- a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java
+++ b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/AbstractJWTFilter.java
@@ -370,7 +370,12 @@ public abstract class AbstractJWTFilter implements Filter {
       auditService.attachContext(context);
       String sourceUri = (String)request.getAttribute( AbstractGatewayFilter.SOURCE_REQUEST_CONTEXT_URL_ATTRIBUTE_NAME );
       if (sourceUri != null) {
-        auditor.audit( Action.AUTHENTICATION , sourceUri, ResourceType.URI, ActionOutcome.SUCCESS );
+        final String actorChain = SubjectUtils.renderActorChain(SubjectUtils.getActorChainPrincipal(subject));
+        if (actorChain.isEmpty()) {
+          auditor.audit( Action.AUTHENTICATION , sourceUri, ResourceType.URI, ActionOutcome.SUCCESS );
+        } else {
+          auditor.audit( Action.AUTHENTICATION , sourceUri, ResourceType.URI, ActionOutcome.SUCCESS, "act_chain=" + actorChain );
+        }
       }
     }
 
diff --git a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeAuditing.java b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeAuditing.java
index bc4f30ca8..b45435be5 100644
--- a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeAuditing.java
+++ b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeAuditing.java
@@ -22,9 +22,12 @@ import org.apache.knox.gateway.audit.api.AuditServiceFactory;
 import org.apache.knox.gateway.audit.api.Auditor;
 import org.apache.knox.gateway.audit.api.ResourceType;
 import org.apache.knox.gateway.audit.log4j.audit.AuditConstants;
+import org.apache.knox.gateway.security.SubjectUtils;
 import org.apache.knox.gateway.services.security.token.impl.JWT;
 
 import java.util.Collections;
+import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 /**
@@ -56,18 +59,18 @@ final class TokenExchangeAuditing {
    *                         mistakes a listed-but-dropped {@code requested_resources} for honored)
    */
   void allowed(ActorIdentity actor, JWT subjectToken, String requestedSubject,
-               Set<String> requestedResources, boolean audiencesHonored, int actChainDepth) {
+               Set<String> requestedResources, boolean audiencesHonored, List<Map<String, Object>> actChain) {
     auditor.audit(Action.TOKEN_EXCHANGE, actor.resourceName(), ResourceType.PRINCIPAL,
         ActionOutcome.SUCCESS, message("token_exchange_allowed", null, actor, subjectToken,
-            requestedSubject, requestedResources, audiencesHonored, actChainDepth));
+            requestedSubject, requestedResources, audiencesHonored, actChain));
   }
 
   /** Audit a policy/validation denial. Nothing is minted, so no audiences are honored. */
   void denied(ActorIdentity actor, JWT subjectToken, String requestedSubject, String denyReason,
-              Set<String> requestedResources, int actChainDepth) {
+              Set<String> requestedResources, List<Map<String, Object>> actChain) {
     auditor.audit(Action.TOKEN_EXCHANGE, actor.resourceName(), ResourceType.PRINCIPAL,
         ActionOutcome.FAILURE, message("token_exchange_denied", "deny_reason=" + denyReason, actor,
-            subjectToken, requestedSubject, requestedResources, false, actChainDepth));
+            subjectToken, requestedSubject, requestedResources, false, actChain));
   }
 
   /**
@@ -75,10 +78,10 @@ final class TokenExchangeAuditing {
    * delegation policy needs is unavailable). Nothing is minted, so no audiences are honored.
    */
   void unavailable(ActorIdentity actor, JWT subjectToken, String requestedSubject, String reason,
-                   Set<String> requestedResources, int actChainDepth) {
+                   Set<String> requestedResources, List<Map<String, Object>> actChain) {
     auditor.audit(Action.TOKEN_EXCHANGE, actor.resourceName(), ResourceType.PRINCIPAL,
         ActionOutcome.UNAVAILABLE, message("token_exchange_unavailable", "reason=" + reason, actor,
-            subjectToken, requestedSubject, requestedResources, false, actChainDepth));
+            subjectToken, requestedSubject, requestedResources, false, actChain));
   }
 
   /**
@@ -103,7 +106,7 @@ final class TokenExchangeAuditing {
     final String resourceName = actor != null ? actor.resourceName() : "unknown";
     auditor.audit(Action.TOKEN_EXCHANGE, resourceName, ResourceType.PRINCIPAL, ActionOutcome.FAILURE,
         message("token_exchange_rejected", "reason=" + reason, actor, subjectToken, requestedSubject,
-            Collections.emptySet(), false, 0));
+            Collections.emptySet(), false, Collections.emptyList()));
   }
 
   /**
@@ -111,8 +114,9 @@ final class TokenExchangeAuditing {
    * value (token_exchange_allowed / token_exchange_denied / token_exchange_unavailable /
    * token_exchange_rejected) and {@code reasonField} is a fully-formed, pre-labeled reason token
    * (e.g. {@code deny_reason=...} or {@code reason=...}) appended verbatim, or null when there is
-   * none. {@code actChainDepth} is the depth of the delegation history arriving on the subject_token
-   * (0 for a headless exchange, whose incoming chain is not propagated).
+   * none. {@code actChain} is the delegation history arriving on the subject_token, from which both
+   * {@code act_chain_depth} and the rendered {@code act_chain} are derived (empty for a headless
+   * exchange, whose incoming chain is not propagated).
    *
    * <p>{@code actor} and {@code subjectToken} may be null for a rejection audited before the
    * request's identity could be established; their fields are then reported empty.</p>
@@ -120,7 +124,7 @@ final class TokenExchangeAuditing {
   private static String message(String eventType, String reasonField, ActorIdentity actor,
                                 JWT subjectToken, String requestedSubject,
                                 Set<String> requestedResources, boolean audiencesHonored,
-                                int actChainDepth) {
+                                List<Map<String, Object>> actChain) {
     final StringBuilder message = new StringBuilder();
     message.append("event_type=").append(eventType);
     if (reasonField != null) {
@@ -133,7 +137,11 @@ final class TokenExchangeAuditing {
     message.append(" requested_subject=").append(label(requestedSubject));
     message.append(" requested_resources=").append(requestedResources);
     message.append(" audiences_honored=").append(audiencesHonored);
-    message.append(" act_chain_depth=").append(actChainDepth);
+    message.append(" act_chain_depth=").append(actChain == null ? 0 : actChain.size());
+    final String renderedChain = SubjectUtils.renderActorChain(actChain);
+    if (!renderedChain.isEmpty()) {
+      message.append(" act_chain=").append(renderedChain);
+    }
     return message.toString();
   }
 
diff --git a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java
index f4f1b1aaf..7308a506f 100644
--- a/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java
+++ b/gateway-provider-security-jwt/src/main/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandler.java
@@ -182,7 +182,7 @@ class TokenExchangeHandler {
         // carries (it is exchanged as-is; TokenResource preserves any existing chain).
         final ActorIdentity subjectAsActor = ActorIdentity.fromJwt(subjectToken);
         final Set<String> uniqueRequestedAudiences = distinctNonBlankValues(requestedAudiences);
-        final int actChainDepth = TokenUtils.extractActorChain(subjectToken).size();
+        final List<Map<String, Object>> actChain = TokenUtils.extractActorChain(subjectToken);
         if (!requestedAudiences.isEmpty() && filter.isTokenExchangeSameSubjectRequestedAudienceEnabled()) {
           // Honoring enabled: authorize each requested audience against the subject token's own aud
           // claim; a value the subject token does not already carry is rejected. This is what allows a
@@ -191,7 +191,7 @@ class TokenExchangeHandler {
           final Set<String> subjectAudiences = audienceClaimSet(subjectToken);
           if (!subjectAudiences.containsAll(uniqueRequestedAudiences)) {
             auditing.denied(subjectAsActor, subjectToken, null,
-                "requested_audience_not_authorized", uniqueRequestedAudiences, actChainDepth);
+                "requested_audience_not_authorized", uniqueRequestedAudiences, actChain);
             filter.handleValidationError(request, response, HttpServletResponse.SC_BAD_REQUEST,
                 "invalid_target",
                 "The requested audience is not authorized for this subject");
@@ -205,7 +205,7 @@ class TokenExchangeHandler {
         // SUCCESS record still lists the requested resources but reports audiences_honored=false, so a
         // dropped request is never mistaken for an honored one.
         auditing.allowed(subjectAsActor, subjectToken, null, uniqueRequestedAudiences,
-            conveyRequestedAudiences, actChainDepth);
+            conveyRequestedAudiences, actChain);
       }
 
       final Subject subject;
@@ -316,7 +316,7 @@ class TokenExchangeHandler {
         return DelegationTokenExchangeOutcome.rejected();
       }
     }
-    final int actChainDepth = hasActorToken ? TokenUtils.extractActorChain(subjectToken).size() : 0;
+    final List<Map<String, Object>> actChain = hasActorToken ? TokenUtils.extractActorChain(subjectToken) : Collections.emptyList();
     final JWT actorIdentitySource = hasActorToken ? actorToken : subjectToken;
     final ActorIdentity actorIdentity = ActorIdentity.fromJwt(actorIdentitySource);
 
@@ -341,7 +341,7 @@ class TokenExchangeHandler {
       // Audit it as an UNAVAILABLE outcome so every delegation exchange (allow/deny/unavailable)
       // leaves a TOKEN_EXCHANGE record.
       auditing.unavailable(actorIdentity, subjectToken, requestedSubjectValue,
-          "delegation_group_lookup_unavailable", uniqueRequestedAudiences, actChainDepth);
+          "delegation_group_lookup_unavailable", uniqueRequestedAudiences, actChain);
       filter.handleValidationError(request, response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
           "server_error", "This delegation policy restricts canActFor by group, which requires "
               + "the LDAP service to resolve group membership; ensure the LDAP service is enabled "
@@ -351,14 +351,14 @@ class TokenExchangeHandler {
 
     if (policyDecision.getDenyReason() != null) {
       auditing.denied(actorIdentity, subjectToken, requestedSubjectValue,
-          policyDecision.getDenyReason(), uniqueRequestedAudiences, actChainDepth);
+          policyDecision.getDenyReason(), uniqueRequestedAudiences, actChain);
       // A single, generic denial that does not identify which requested value failed.
       filter.handleValidationError(request, response, HttpServletResponse.SC_BAD_REQUEST,
           "invalid_request", "The token exchange request is rejected by policy");
       return DelegationTokenExchangeOutcome.rejected();
     }
     auditing.allowed(actorIdentity, subjectToken, requestedSubjectValue, uniqueRequestedAudiences,
-        !requestedAudiences.isEmpty(), actChainDepth);
+        !requestedAudiences.isEmpty(), actChain);
 
     request.setAttribute(CommonTokenConstants.REQUESTED_TTL_REQUEST_ATTR, policyDecision.getEffectiveTtlSec());
 
diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/AuthenticationActorChainAuditTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/AuthenticationActorChainAuditTest.java
new file mode 100644
index 000000000..932ad81bc
--- /dev/null
+++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/AuthenticationActorChainAuditTest.java
@@ -0,0 +1,173 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.knox.gateway.provider.federation;
+
+import static org.easymock.EasyMock.eq;
+import static org.easymock.EasyMock.expectLastCall;
+import static org.junit.Assert.assertEquals;
+
+import org.apache.knox.gateway.audit.api.Action;
+import org.apache.knox.gateway.audit.api.ActionOutcome;
+import org.apache.knox.gateway.audit.api.AuditContext;
+import org.apache.knox.gateway.audit.api.AuditService;
+import org.apache.knox.gateway.audit.api.Auditor;
+import org.apache.knox.gateway.audit.api.ResourceType;
+import org.apache.knox.gateway.filter.AbstractGatewayFilter;
+import org.apache.knox.gateway.provider.federation.jwt.filter.AbstractJWTFilter;
+import org.apache.knox.gateway.security.ActorChainPrincipalImpl;
+import org.apache.knox.gateway.security.PrimaryPrincipal;
+import org.easymock.Capture;
+import org.easymock.EasyMock;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import javax.security.auth.Subject;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+public class AuthenticationActorChainAuditTest {
+
+  private static final String SOURCE_URI = "https://localhost:8443/gateway/sandbox/service";
+
+  private Auditor originalAuditor;
+  private AuditService originalAuditService;
+
+  @Before
+  public void setUp() throws Exception {
+    originalAuditor = (Auditor) auditorField().get(null);
+    originalAuditService = (AuditService) auditServiceField().get(null);
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    auditorField().set(null, originalAuditor);
+    auditServiceField().set(null, originalAuditService);
+  }
+
+  @Test
+  public void testDelegatedTokenEmitsActChainInAuthenticationMessage() throws Exception {
+    final Capture<String> message = EasyMock.newCapture();
+    final Auditor auditor = EasyMock.createMock(Auditor.class);
+    auditor.audit(eq(Action.AUTHENTICATION), eq(SOURCE_URI), eq(ResourceType.URI),
+        eq(ActionOutcome.SUCCESS), EasyMock.capture(message));
+    expectLastCall().once();
+    EasyMock.replay(auditor);
+
+    invokeContinue(auditor, subjectWithChain(Arrays.asList(
+        actor("idp1", "svc-c"), actor("idp1", "svc-b"), actor("idp2", "svc-a"))));
+
+    EasyMock.verify(auditor);
+    assertEquals("act_chain=idp1/svc-c<-idp1/svc-b<-idp2/svc-a", message.getValue());
+  }
+
+  @Test
+  public void testNonDelegatedTokenEmitsNoMessage() throws Exception {
+    final Auditor auditor = EasyMock.createMock(Auditor.class);
+    auditor.audit(eq(Action.AUTHENTICATION), eq(SOURCE_URI), eq(ResourceType.URI),
+        eq(ActionOutcome.SUCCESS));
+    expectLastCall().once();
+    EasyMock.replay(auditor);
+
+    invokeContinue(auditor, subjectWithChain(null));
+
+    EasyMock.verify(auditor);
+  }
+
+  private void invokeContinue(Auditor auditor, Subject subject) throws Exception {
+    final AuditContext context = EasyMock.createNiceMock(AuditContext.class);
+    final AuditService auditService = EasyMock.createNiceMock(AuditService.class);
+    EasyMock.expect(auditService.getContext()).andReturn(context).anyTimes();
+    EasyMock.replay(context, auditService);
+
+    auditorField().set(null, auditor);
+    auditServiceField().set(null, auditService);
+
+    final HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class);
+    EasyMock.expect(request.getAttribute(AbstractGatewayFilter.SOURCE_REQUEST_CONTEXT_URL_ATTRIBUTE_NAME))
+        .andReturn(SOURCE_URI).anyTimes();
+    final HttpServletResponse response = EasyMock.createNiceMock(HttpServletResponse.class);
+    EasyMock.replay(request, response);
+
+    new TestHandler().invokeContinueWithEstablishedSecurityContext(subject, request, response,
+        new NoOpFilterChain());
+  }
+
+  private static Subject subjectWithChain(List<Map<String, Object>> chain) {
+    final Subject subject = new Subject();
+    subject.getPrincipals().add(new PrimaryPrincipal("alice"));
+    if (chain != null) {
+      subject.getPrincipals().add(new ActorChainPrincipalImpl(chain));
+    }
+    return subject;
+  }
+
+  private static Map<String, Object> actor(String iss, String sub) {
+    final Map<String, Object> actor = new LinkedHashMap<>();
+    actor.put("iss", iss);
+    actor.put("sub", sub);
+    return actor;
+  }
+
+  private static Field auditorField() throws Exception {
+    final Field field = AbstractJWTFilter.class.getDeclaredField("auditor");
+    field.setAccessible(true);
+    return field;
+  }
+
+  private static Field auditServiceField() throws Exception {
+    final Field field = AbstractJWTFilter.class.getDeclaredField("auditService");
+    field.setAccessible(true);
+    return field;
+  }
+
+  static final class TestHandler extends AbstractJWTFilter {
+    void invokeContinueWithEstablishedSecurityContext(Subject subject, HttpServletRequest request,
+        HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
+      continueWithEstablishedSecurityContext(subject, request, response, chain);
+    }
+
+    @Override
+    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
+    }
+
+    @Override
+    protected void handleValidationError(HttpServletRequest request, HttpServletResponse response,
+        int status, String error) {
+    }
+
+    @Override
+    public void destroy() {
+    }
+  }
+
+  static final class NoOpFilterChain implements FilterChain {
+    @Override
+    public void doFilter(ServletRequest request, ServletResponse response) {
+    }
+  }
+}
diff --git a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java
index 588f199aa..2f693b198 100644
--- a/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java
+++ b/gateway-provider-security-jwt/src/test/java/org/apache/knox/gateway/provider/federation/jwt/filter/TokenExchangeHandlerTest.java
@@ -501,6 +501,7 @@ public class TokenExchangeHandlerTest {
     assertTrue(auditMessage.getValue().contains("actor_id=svc-dataservice"));
     // subject_token carries no prior 'act' chain, so the audited incoming depth is 0
     assertTrue(auditMessage.getValue().contains("act_chain_depth=0"));
+    assertFalse(auditMessage.getValue().contains("act_chain="));
   }
 
   @Test
@@ -516,6 +517,7 @@ public class TokenExchangeHandlerTest {
 
     EasyMock.verify(auditor);
     assertTrue(auditMessage.getValue().contains("act_chain_depth=2"));
+    assertTrue(auditMessage.getValue().contains("act_chain=actor-1<-actor-2"));
   }
 
   @Test
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/security/SubjectUtils.java b/gateway-spi/src/main/java/org/apache/knox/gateway/security/SubjectUtils.java
index 9b6b50a62..b2f3632fc 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/security/SubjectUtils.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/security/SubjectUtils.java
@@ -24,6 +24,8 @@ import javax.security.auth.Subject;
 import java.security.AccessController;
 import java.security.Principal;
 import java.util.Collections;
+import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 
@@ -106,6 +108,37 @@ public class SubjectUtils {
     return currentSubject.getPrincipals(ActorChainPrincipal.class);
   }
 
+  public static ActorChainPrincipal getActorChainPrincipal(Subject subject) {
+    if (subject == null) {
+      return null;
+    }
+    final Set<ActorChainPrincipal> principals = subject.getPrincipals(ActorChainPrincipal.class);
+    return principals.isEmpty() ? null : principals.iterator().next();
+  }
+
+  public static String renderActorChain(List<Map<String, Object>> actorChain) {
+    if (actorChain == null || actorChain.isEmpty()) {
+      return "";
+    }
+    final StringBuilder sb = new StringBuilder();
+    for (Map<String, Object> actor : actorChain) {
+      if (sb.length() > 0) {
+        sb.append("<-");
+      }
+      final Object iss = actor.get("iss");
+      if (iss != null) {
+        sb.append(iss).append('/');
+      }
+      final Object sub = actor.get("sub");
+      sb.append(sub == null ? "" : sub.toString());
+    }
+    return sb.toString();
+  }
+
+  public static String renderActorChain(ActorChainPrincipal principal) {
+    return principal == null ? "" : renderActorChain(principal.getActorChain());
+  }
+
   /**
    * Get the TokenExchangePrincipal from the subject if present.
    *
diff --git a/gateway-spi/src/test/java/org/apache/knox/gateway/security/SubjectUtilsTest.java b/gateway-spi/src/test/java/org/apache/knox/gateway/security/SubjectUtilsTest.java
new file mode 100644
index 000000000..3b9f1cdb5
--- /dev/null
+++ b/gateway-spi/src/test/java/org/apache/knox/gateway/security/SubjectUtilsTest.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.security;
+
+import static org.junit.Assert.assertEquals;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.Test;
+
+public class SubjectUtilsTest {
+
+  private static Map<String, Object> actor(String iss, String sub) {
+    final Map<String, Object> actor = new LinkedHashMap<>();
+    if (iss != null) {
+      actor.put("iss", iss);
+    }
+    if (sub != null) {
+      actor.put("sub", sub);
+    }
+    return actor;
+  }
+
+  @Test
+  public void testRenderActorChainWithIssuerSubjectPairs() {
+    final List<Map<String, Object>> chain = Arrays.asList(
+        actor("idp1", "svc-c"), actor("idp1", "svc-b"), actor("idp2", "svc-a"));
+    assertEquals("idp1/svc-c<-idp1/svc-b<-idp2/svc-a", SubjectUtils.renderActorChain(chain));
+  }
+
+  @Test
+  public void testRenderActorChainFallsBackToSubjectWhenNoIssuer() {
+    final List<Map<String, Object>> chain = Arrays.asList(actor(null, "actor-1"), actor(null, "actor-2"));
+    assertEquals("actor-1<-actor-2", SubjectUtils.renderActorChain(chain));
+  }
+
+  @Test
+  public void testRenderActorChainMixesPresentAndAbsentIssuers() {
+    final List<Map<String, Object>> chain = Arrays.asList(actor("idp1", "svc-c"), actor(null, "svc-a"));
+    assertEquals("idp1/svc-c<-svc-a", SubjectUtils.renderActorChain(chain));
+  }
+
+  @Test
+  public void testRenderActorChainEmptyAndNull() {
+    assertEquals("", SubjectUtils.renderActorChain((List<Map<String, Object>>) null));
+    assertEquals("", SubjectUtils.renderActorChain(Collections.emptyList()));
+    assertEquals("", SubjectUtils.renderActorChain((ActorChainPrincipal) null));
+  }
+
+  @Test
+  public void testRenderActorChainFromPrincipal() {
+    final List<Map<String, Object>> chain = Arrays.asList(actor("idp1", "svc-c"), actor("idp2", "svc-a"));
+    assertEquals("idp1/svc-c<-idp2/svc-a",
+        SubjectUtils.renderActorChain(new ActorChainPrincipalImpl(chain)));
+  }
+}