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

lukaszlenart pushed a commit to branch WW-5626-approach-c
in repository https://gitbox.apache.org/repos/asf/struts.git

commit dc3c590821f86eae230744e5e5c80e45a5227796
Author: Lukasz Lenart <[email protected]>
AuthorDate: Mon May 4 13:53:40 2026 +0200

    WW-5626 add ParameterAuthorizationContext for deserializer-level 
authorization
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
---
 .../parameter/ParameterAuthorizationContext.java   | 123 +++++++++++++++++++++
 .../ParameterAuthorizationContextTest.java         | 106 ++++++++++++++++++
 2 files changed, 229 insertions(+)

diff --git 
a/core/src/main/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContext.java
 
b/core/src/main/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContext.java
new file mode 100644
index 000000000..67bae09c8
--- /dev/null
+++ 
b/core/src/main/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContext.java
@@ -0,0 +1,123 @@
+/*
+ * 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.struts2.interceptor.parameter;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+
+/**
+ * ThreadLocal holder for per-request parameter authorization state, used by 
deserializer-level
+ * authorization (e.g. the REST plugin's Jackson module). All state — the 
{@link ParameterAuthorizer},
+ * the target, the action, and the current property-path stack — is bound by
+ * {@link org.apache.struts2.rest.ContentTypeInterceptor} (or other 
input-channel interceptors)
+ * before invoking the deserializer, and unbound in a {@code finally} block 
afterwards.
+ *
+ * <p>Implementations that consult this context (e.g. {@code 
AuthorizingSettableBeanProperty}) call
+ * {@link #isActive()} to decide whether to enforce authorization at all — 
when no context is bound
+ * (default config, {@code requireAnnotations=false}), they short-circuit to 
the delegate behavior.</p>
+ *
+ * @since 7.2.0
+ */
+public final class ParameterAuthorizationContext {
+
+    private static final ThreadLocal<State> STATE = new ThreadLocal<>();
+    private static final ThreadLocal<Deque<String>> PATH_STACK = new 
ThreadLocal<>();
+
+    private ParameterAuthorizationContext() {
+        // utility
+    }
+
+    public static void bind(ParameterAuthorizer authorizer, Object target, 
Object action) {
+        STATE.set(new State(authorizer, target, action));
+    }
+
+    public static void unbind() {
+        STATE.remove();
+        PATH_STACK.remove();
+    }
+
+    public static boolean isActive() {
+        return STATE.get() != null;
+    }
+
+    /**
+     * Authorizes a parameter at the given path against the bound authorizer. 
Returns {@code true}
+     * when no context is bound — callers that don't want enforcement at all 
should not bind context
+     * in the first place; this default keeps wrapping deserializers safe for 
non-authorized requests.
+     */
+    public static boolean isAuthorized(String parameterPath) {
+        State state = STATE.get();
+        if (state == null) {
+            return true;
+        }
+        return state.authorizer.isAuthorized(parameterPath, state.target, 
state.action);
+    }
+
+    public static void pushPath(String fullPath) {
+        pathStack().push(fullPath);
+    }
+
+    public static void popPath() {
+        Deque<String> stack = PATH_STACK.get();
+        if (stack != null && !stack.isEmpty()) {
+            stack.pop();
+        }
+    }
+
+    /**
+     * @return the current top-of-stack path prefix, or empty string if none
+     */
+    public static String currentPathPrefix() {
+        Deque<String> stack = PATH_STACK.get();
+        if (stack == null || stack.isEmpty()) {
+            return "";
+        }
+        return stack.peek();
+    }
+
+    /**
+     * Builds the full path for a property at the current nesting level: 
{@code prefix.propertyName}
+     * (or just {@code propertyName} when at the root).
+     */
+    public static String pathFor(String propertyName) {
+        String prefix = currentPathPrefix();
+        return prefix.isEmpty() ? propertyName : prefix + "." + propertyName;
+    }
+
+    private static Deque<String> pathStack() {
+        Deque<String> stack = PATH_STACK.get();
+        if (stack == null) {
+            stack = new ArrayDeque<>();
+            PATH_STACK.set(stack);
+        }
+        return stack;
+    }
+
+    private static final class State {
+        final ParameterAuthorizer authorizer;
+        final Object target;
+        final Object action;
+
+        State(ParameterAuthorizer authorizer, Object target, Object action) {
+            this.authorizer = authorizer;
+            this.target = target;
+            this.action = action;
+        }
+    }
+}
diff --git 
a/core/src/test/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContextTest.java
 
b/core/src/test/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContextTest.java
new file mode 100644
index 000000000..40971287e
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContextTest.java
@@ -0,0 +1,106 @@
+/*
+ * 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.struts2.interceptor.parameter;
+
+import org.junit.After;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class ParameterAuthorizationContextTest {
+
+    @After
+    public void tearDown() {
+        ParameterAuthorizationContext.unbind();
+    }
+
+    @Test
+    public void notActive_byDefault() {
+        assertThat(ParameterAuthorizationContext.isActive()).isFalse();
+    }
+
+    @Test
+    public void bind_thenActive() {
+        ParameterAuthorizer authorizer = (n, t, a) -> true;
+        Object action = new Object();
+        ParameterAuthorizationContext.bind(authorizer, action, action);
+        assertThat(ParameterAuthorizationContext.isActive()).isTrue();
+    }
+
+    @Test
+    public void unbind_clearsState() {
+        ParameterAuthorizer authorizer = (n, t, a) -> true;
+        Object action = new Object();
+        ParameterAuthorizationContext.bind(authorizer, action, action);
+        ParameterAuthorizationContext.unbind();
+        assertThat(ParameterAuthorizationContext.isActive()).isFalse();
+    }
+
+    @Test
+    public void isAuthorized_delegatesToBoundAuthorizer() {
+        Object action = new Object();
+        ParameterAuthorizationContext.bind((n, t, a) -> "name".equals(n), 
action, action);
+        
assertThat(ParameterAuthorizationContext.isAuthorized("name")).isTrue();
+        
assertThat(ParameterAuthorizationContext.isAuthorized("role")).isFalse();
+    }
+
+    @Test
+    public void isAuthorized_returnsTrue_whenNotActive() {
+        // Defensive default: no context bound = no enforcement
+        
assertThat(ParameterAuthorizationContext.isAuthorized("anything")).isTrue();
+    }
+
+    @Test
+    public void pathStack_emptyByDefault() {
+        
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEmpty();
+    }
+
+    @Test
+    public void pushPath_buildsPrefix() {
+        ParameterAuthorizationContext.pushPath("address");
+        
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEqualTo("address");
+        ParameterAuthorizationContext.pushPath("address.city");
+        
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEqualTo("address.city");
+    }
+
+    @Test
+    public void popPath_unwinds() {
+        ParameterAuthorizationContext.pushPath("address");
+        ParameterAuthorizationContext.pushPath("address.city");
+        ParameterAuthorizationContext.popPath();
+        
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEqualTo("address");
+        ParameterAuthorizationContext.popPath();
+        
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEmpty();
+    }
+
+    @Test
+    public void pathFor_concatenatesPropertyName() {
+        
assertThat(ParameterAuthorizationContext.pathFor("name")).isEqualTo("name");
+        ParameterAuthorizationContext.pushPath("address");
+        
assertThat(ParameterAuthorizationContext.pathFor("city")).isEqualTo("address.city");
+    }
+
+    @Test
+    public void unbind_clearsPathStack() {
+        ParameterAuthorizationContext.bind((n, t, a) -> true, new Object(), 
new Object());
+        ParameterAuthorizationContext.pushPath("address");
+        ParameterAuthorizationContext.unbind();
+        
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEmpty();
+    }
+}

Reply via email to