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(); + } +}
