Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -118,18 +118,17 @@ public Object invoke(final MethodInvocation invocation) {
// @formatter:on
PostInvocationAttribute attr = findPostInvocationAttribute(attributes);
if (Mono.class.isAssignableFrom(returnType)) {
return toInvoke.flatMap((auth) -> PrePostAdviceReactiveMethodInterceptor.<Mono<?>>proceed(invocation)
return toInvoke.flatMap((auth) -> proceedAsMono(invocation)
.map((r) -> (attr != null) ? this.postAdvice.after(auth, invocation, attr, r) : r));
}
if (Flux.class.isAssignableFrom(returnType)) {
return toInvoke.flatMapMany((auth) -> PrePostAdviceReactiveMethodInterceptor.<Flux<?>>proceed(invocation)
return toInvoke.flatMapMany((auth) -> proceedAsFlux(invocation)
.map((r) -> (attr != null) ? this.postAdvice.after(auth, invocation, attr, r) : r));
}
if (hasFlowReturnType) {
if (isSuspendingFunction) {
return toInvoke
.flatMapMany((auth) -> Flux.from(PrePostAdviceReactiveMethodInterceptor.proceed(invocation))
.map((r) -> (attr != null) ? this.postAdvice.after(auth, invocation, attr, r) : r));
return toInvoke.flatMapMany((auth) -> proceedAsFlux(invocation)
.map((r) -> (attr != null) ? this.postAdvice.after(auth, invocation, attr, r) : r));
}
else {
ReactiveAdapter adapter = ReactiveAdapterRegistry.getSharedInstance().getAdapter(returnType);
Expand All @@ -141,18 +140,32 @@ public Object invoke(final MethodInvocation invocation) {
return KotlinDelegate.asFlow(response);
}
}
return toInvoke.flatMap((auth) -> Mono.from(PrePostAdviceReactiveMethodInterceptor.proceed(invocation))
return toInvoke.flatMap((auth) -> proceedAsMono(invocation)
.map((r) -> (attr != null) ? this.postAdvice.after(auth, invocation, attr, r) : r));
}

@SuppressWarnings("unchecked")
private static <T extends Publisher<?>> @Nullable T proceed(final MethodInvocation invocation) {
try {
return (T) invocation.proceed();
private static Mono<Object> proceedAsMono(MethodInvocation invocation) {
Object result = flowProceed(invocation);
if (result instanceof Mono<?> mono) {
return (Mono<Object>) mono;
}
catch (Throwable throwable) {
throw Exceptions.propagate(throwable);
if (result instanceof Publisher<?> publisher) {
return Mono.from(publisher);
}
return Mono.justOrEmpty(result);
}

@SuppressWarnings("unchecked")
private static Flux<Object> proceedAsFlux(MethodInvocation invocation) {
Object result = flowProceed(invocation);
if (result instanceof Flux<?> flux) {
return (Flux<Object>) flux;
}
if (result instanceof Publisher<?> publisher) {
return Flux.from(publisher);
}
return (result != null) ? Flux.just(result) : Flux.empty();
}

private static @Nullable Object flowProceed(final MethodInvocation invocation) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed 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
*
* https://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.springframework.security.config.annotation.method.configuration

import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.cache.CacheManager
import org.springframework.cache.annotation.Cacheable
import org.springframework.cache.annotation.EnableCaching
import org.springframework.cache.concurrent.ConcurrentMapCacheManager
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.test.context.support.WithMockUser
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.junit.jupiter.SpringExtension
import java.util.concurrent.atomic.AtomicInteger

// gh-19400
@ExtendWith(SpringExtension::class)
@ContextConfiguration
class KotlinReactiveMethodSecurityCacheableTests {

@Autowired
lateinit var cachedService: CachedSuspendService

@Autowired
lateinit var cacheManager: CacheManager

@BeforeEach
fun setUp() {
cacheManager.getCache("demo")?.clear()
cachedService.resetCounter()
}

@Test
@WithMockUser(authorities = ["demo.read"])
fun `suspending PreAuthorize and Cacheable when cache hit then returns cached value`() {
runBlocking {
val first = cachedService.demo()
val second = cachedService.demo()
assertThat(first).isEqualTo(second)
assertThat(first.invocation).isEqualTo(1)
assertThat(cachedService.invocationCount()).isEqualTo(1)
}
}

@Test
@WithMockUser(authorities = ["demo.read"])
fun `suspending PreAuthorize and Cacheable when cache miss then invokes target`() {
runBlocking {
val result = cachedService.demo()
assertThat(result.invocation).isEqualTo(1)
assertThat(result.message).isEqualTo("cached response")
assertThat(cachedService.invocationCount()).isEqualTo(1)
}
}

@Configuration
@EnableReactiveMethodSecurity
@EnableCaching
open class Config {
@Bean
open fun cacheManager(): CacheManager = ConcurrentMapCacheManager("demo")

@Bean
open fun cachedService(): CachedSuspendService = CachedSuspendService()
}
}

open class CachedSuspendService {
private val invocationCounter = AtomicInteger()

@Cacheable("demo", key = "'fixed'")
@PreAuthorize("hasAuthority('demo.read')")
open suspend fun demo(): DemoResponse =
DemoResponse(
message = "cached response",
invocation = invocationCounter.incrementAndGet(),
)

open fun invocationCount(): Int = invocationCounter.get()

open fun resetCounter() {
invocationCounter.set(0)
}
}

data class DemoResponse(
val message: String,
val invocation: Int,
)
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,7 @@ public Object invoke(MethodInvocation mi) throws Throwable {
ReactiveAdapter adapter = ReactiveAdapterRegistry.getSharedInstance().getAdapter(type);
if (hasFlowReturnType) {
if (isSuspendingFunction) {
Publisher<?> publisher = ReactiveMethodInvocationUtils.proceed(mi);
if (publisher == null) {
return Flux.empty();
}
return Flux.from(publisher).materialize().flatMap(postAuthorize);
return ReactiveMethodInvocationUtils.proceedAsFlux(mi).materialize().flatMap(postAuthorize);
}
else {
Assert.state(adapter != null, () -> "The returnType " + type + " on " + method
Expand All @@ -152,15 +148,11 @@ public Object invoke(MethodInvocation mi) throws Throwable {
return KotlinDelegate.asFlow(response);
}
}
Publisher<?> publisher = ReactiveMethodInvocationUtils.proceed(mi);
if (publisher == null) {
return Flux.empty();
}
if (isMultiValue(type, adapter)) {
Flux<?> flux = Flux.from(publisher).materialize().flatMap(postAuthorize);
Flux<?> flux = ReactiveMethodInvocationUtils.proceedAsFlux(mi).materialize().flatMap(postAuthorize);
return (adapter != null) ? adapter.fromPublisher(flux) : flux;
}
Mono<?> mono = Mono.from(publisher).materialize().flatMap(postAuthorize);
Mono<?> mono = ReactiveMethodInvocationUtils.proceedAsMono(mi).materialize().flatMap(postAuthorize);
return (adapter != null) ? adapter.fromPublisher(mono) : mono;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ public Object invoke(MethodInvocation mi) throws Throwable {
ReactiveAdapter adapter = ReactiveAdapterRegistry.getSharedInstance().getAdapter(type);
if (hasFlowReturnType) {
if (isSuspendingFunction) {
return preAuthorized(mi, Flux.defer(() -> ReactiveMethodInvocationUtils.proceed(mi)));
return preAuthorized(mi, Flux.defer(() -> ReactiveMethodInvocationUtils.proceedAsFlux(mi)));
}
else {
Assert.state(adapter != null, () -> "The returnType " + type + " on " + method
Expand All @@ -132,10 +132,10 @@ public Object invoke(MethodInvocation mi) throws Throwable {
}
}
if (isMultiValue(type, adapter)) {
Flux<?> result = preAuthorized(mi, Flux.defer(() -> ReactiveMethodInvocationUtils.proceed(mi)));
Flux<?> result = preAuthorized(mi, Flux.defer(() -> ReactiveMethodInvocationUtils.proceedAsFlux(mi)));
return (adapter != null) ? adapter.fromPublisher(result) : result;
}
Mono<?> result = preAuthorized(mi, Mono.defer(() -> ReactiveMethodInvocationUtils.proceed(mi)));
Mono<?> result = preAuthorized(mi, Mono.defer(() -> ReactiveMethodInvocationUtils.proceedAsMono(mi)));
return (adapter != null) ? adapter.fromPublisher(result) : result;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@

import org.aopalliance.intercept.MethodInvocation;
import org.jspecify.annotations.Nullable;
import org.reactivestreams.Publisher;
import reactor.core.Exceptions;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

/**
* For internal use only, as this contract is likely to change.
Expand All @@ -37,6 +40,52 @@ final class ReactiveMethodInvocationUtils {
}
}

/**
* Proceeds with the {@link MethodInvocation} and adapts the result to a {@link Mono}.
* <p>
* Kotlin suspending functions are invoked by Spring AOP as {@link Mono}, but other
* advisors in the chain (for example, {@code @Cacheable} on a cache hit) may return a
* plain value. This method normalizes both cases so reactive method security can
* compose over the invocation result.
* @param mi the method invocation
* @return a {@link Mono} that emits the invocation result
*/
static Mono<Object> proceedAsMono(MethodInvocation mi) {
return toMono(proceed(mi));
}

/**
* Proceeds with the {@link MethodInvocation} and adapts the result to a {@link Flux}.
* @param mi the method invocation
* @return a {@link Flux} that emits the invocation result(s)
* @see #proceedAsMono(MethodInvocation)
*/
static Flux<Object> proceedAsFlux(MethodInvocation mi) {
return toFlux(proceed(mi));
}

@SuppressWarnings("unchecked")
private static Mono<Object> toMono(@Nullable Object result) {
if (result instanceof Mono<?> mono) {
return (Mono<Object>) mono;
}
if (result instanceof Publisher<?> publisher) {
return Mono.from(publisher);
}
return Mono.justOrEmpty(result);
}

@SuppressWarnings("unchecked")
private static Flux<Object> toFlux(@Nullable Object result) {
if (result instanceof Flux<?> flux) {
return (Flux<Object>) flux;
}
if (result instanceof Publisher<?> publisher) {
return Flux.from(publisher);
}
return (result != null) ? Flux.just(result) : Flux.empty();
}

private ReactiveMethodInvocationUtils() {
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,26 @@ public void invokeWhenCustomAuthorizationDeniedExceptionThenThrows() throws Thro
.isThrownBy(() -> ((Mono<?>) advice.invoke(mockMethodInvocation)).block());
}

// gh-19400
@Test
public void invokeSuspendingWhenProceedReturnsPlainValueThenWrapsAsMono() throws Throwable {
MethodInvocation mockMethodInvocation = spy(new MockMethodInvocation(new Sample(),
Sample.class.getDeclaredMethod("suspending", kotlin.coroutines.Continuation.class)));
// Simulates @Cacheable cache hit returning a plain value instead of a Mono
given(mockMethodInvocation.proceed()).willReturn("cached");
ReactiveAuthorizationManager<MethodInvocationResult> mockReactiveAuthorizationManager = mock(
ReactiveAuthorizationManager.class);
given(mockReactiveAuthorizationManager.authorize(any(), any()))
.willReturn(Mono.just(new AuthorizationDecision(true)));
AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor(
Pointcut.TRUE, mockReactiveAuthorizationManager);
Object result = interceptor.invoke(mockMethodInvocation);
assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class))
.extracting(Mono::block)
.isEqualTo("cached");
verify(mockReactiveAuthorizationManager).authorize(any(), any());
}

private Object masking(InvocationOnMock invocation) {
MethodInvocationResult result = invocation.getArgument(0);
return result.getResult() + "-masked";
Expand All @@ -279,6 +299,14 @@ Flux<String> flux() {
return Flux.just("john", "bob");
}

/**
* JVM shape of a Kotlin {@code suspend} function (last parameter is
* {@link kotlin.coroutines.Continuation}).
*/
Object suspending(kotlin.coroutines.Continuation<? super String> continuation) {
return null;
}

}

static class MyAuthzDeniedException extends AuthorizationDeniedException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,44 @@ public void invokeWhenCustomAuthorizationDeniedExceptionThenThrows() throws Thro
.isThrownBy(() -> ((Mono<?>) advice.invoke(mockMethodInvocation)).block());
}

// gh-19400
@Test
public void invokeSuspendingWhenProceedReturnsPlainValueThenWrapsAsMono() throws Throwable {
MethodInvocation mockMethodInvocation = spy(new MockMethodInvocation(new Sample(),
Sample.class.getDeclaredMethod("suspending", kotlin.coroutines.Continuation.class)));
// Simulates @Cacheable cache hit returning a plain value instead of a Mono
given(mockMethodInvocation.proceed()).willReturn("cached");
ReactiveAuthorizationManager<MethodInvocation> mockReactiveAuthorizationManager = mock(
ReactiveAuthorizationManager.class);
given(mockReactiveAuthorizationManager.authorize(any(), eq(mockMethodInvocation)))
.willReturn(Mono.just(new AuthorizationDecision(true)));
AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor(
Pointcut.TRUE, mockReactiveAuthorizationManager);
Object result = interceptor.invoke(mockMethodInvocation);
assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class))
.extracting(Mono::block)
.isEqualTo("cached");
verify(mockReactiveAuthorizationManager).authorize(any(), eq(mockMethodInvocation));
}

// gh-19400
@Test
public void invokeSuspendingWhenProceedReturnsMonoThenUsesMono() throws Throwable {
MethodInvocation mockMethodInvocation = spy(new MockMethodInvocation(new Sample(),
Sample.class.getDeclaredMethod("suspending", kotlin.coroutines.Continuation.class)));
given(mockMethodInvocation.proceed()).willReturn(Mono.just("from-mono"));
ReactiveAuthorizationManager<MethodInvocation> mockReactiveAuthorizationManager = mock(
ReactiveAuthorizationManager.class);
given(mockReactiveAuthorizationManager.authorize(any(), eq(mockMethodInvocation)))
.willReturn(Mono.just(new AuthorizationDecision(true)));
AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor(
Pointcut.TRUE, mockReactiveAuthorizationManager);
Object result = interceptor.invoke(mockMethodInvocation);
assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class))
.extracting(Mono::block)
.isEqualTo("from-mono");
}

interface HandlingReactiveAuthorizationManager
extends ReactiveAuthorizationManager<MethodInvocation>, MethodAuthorizationDeniedHandler {

Expand All @@ -240,6 +278,14 @@ Flux<String> flux() {
return Flux.just("john", "bob");
}

/**
* JVM shape of a Kotlin {@code suspend} function (last parameter is
* {@link kotlin.coroutines.Continuation}).
*/
Object suspending(kotlin.coroutines.Continuation<? super String> continuation) {
return null;
}

}

static class MyAuthzDeniedException extends AuthorizationDeniedException {
Expand Down