diff --git a/binder/src/main/java/io/grpc/binder/AsyncSecurityPolicies.java b/binder/src/main/java/io/grpc/binder/AsyncSecurityPolicies.java new file mode 100644 index 00000000000..a1631a76364 --- /dev/null +++ b/binder/src/main/java/io/grpc/binder/AsyncSecurityPolicies.java @@ -0,0 +1,92 @@ +/* + * Copyright 2026 The gRPC 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 + * + * 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 io.grpc.binder; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.base.Preconditions; +import com.google.common.util.concurrent.AsyncCallable; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.errorprone.annotations.CheckReturnValue; +import io.grpc.ExperimentalApi; +import io.grpc.Status; +import java.util.concurrent.Executor; + +/** Static factory methods for creating asynchronous security policies. */ +@CheckReturnValue +public final class AsyncSecurityPolicies { + + private AsyncSecurityPolicies() {} + + /** + * Returns an {@link AsyncSecurityPolicy} that delegates to some other policy that's provided + * lazily and asynchronously. + * + *

Use this when your security policy is slow or expensive to load and not immediately + * available at Channel or Server initialization. It's particularly useful in {@code + * android.app.Service#onCreate()} where blocking the main thread to load a server's security + * policy risks an "Application Not Responding" (ANR) error. Implementations of 'policyProvider' + * must not block the calling thread either. + * + *

The provided {@link AsyncCallable} is invoked each time the returned policy is evaluated. + * This happens once per connection for a grpc-binder Channel and once per (service, incoming + * connection) called on a Server. So 'policyProvider' must be prepared to be invoked more than + * once but not normally for every RPC. Depending on the cost of loading the policy, a provider + * may want to memoize and reuse its products. + * + *

Binder Channels and Servers try to coalesce multiple identical authorization checks that + * overlap in time but this isn't guaranteed. So 'policyProvider' must be thread-safe but may or + * may not go to the trouble of coalescing simultaneous calls for itself. Those that do should use + * {@link Futures#nonCancellationPropagating} or similar to protect a future returned to multiple + * callers from individual cancellation. + * + *

'policyProvider' can express the failure to load a security policy by returning a failed + * future. This failure will propagate to the Server or Channel operation that needed authorizing, + * but will not be cached, leaving open the possibility of success upon retry. A memoizing policy + * provider may want to retain only successes for the same reason. + * + * @param policyProvider used to get the delegate SecurityPolicy when needed + * @param executor used to call into the delegate once provided. If the delegate is a + * SecurityPolicy, note that many implementations of checkAuthorization() block. + */ + @ExperimentalApi("https://github.com/grpc/grpc-java/issues/8022") + public static AsyncSecurityPolicy deferredAsync( + AsyncCallable policyProvider, Executor executor) { + checkNotNull(policyProvider, "policyProvider"); + checkNotNull(executor, "executor"); + return new AsyncSecurityPolicy() { + @Override + public ListenableFuture checkAuthorizationAsync(int uid) { + try { + return Futures.transformAsync( + policyProvider.call(), + policy -> { + checkNotNull(policy, "policyProvider returned a null SecurityPolicy"); + if (policy instanceof AsyncSecurityPolicy) { + return ((AsyncSecurityPolicy) policy).checkAuthorizationAsync(uid); + } + return Futures.immediateFuture(policy.checkAuthorization(uid)); + }, + executor); + } catch (Exception e) { + return Futures.immediateFailedFuture(e); + } + } + }; + } +} diff --git a/binder/src/test/java/io/grpc/binder/AsyncSecurityPoliciesTest.java b/binder/src/test/java/io/grpc/binder/AsyncSecurityPoliciesTest.java new file mode 100644 index 00000000000..db379de9d18 --- /dev/null +++ b/binder/src/test/java/io/grpc/binder/AsyncSecurityPoliciesTest.java @@ -0,0 +1,325 @@ +/* + * Copyright 2026 The gRPC 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 + * + * 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 io.grpc.binder; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import android.os.Process; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; +import io.grpc.Status; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public final class AsyncSecurityPoliciesTest { + + private static final int MY_UID = Process.myUid(); + + private ExecutorService executor; + + @Before + public void setUp() { + executor = Executors.newSingleThreadExecutor(); + } + + @After + public void tearDown() { + executor.shutdown(); + } + + @Test + public void testDeferredAsync_asyncPolicy_succeeds() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferredAsync(() -> futurePolicy, executor); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(MY_UID); + assertThat(authFuture.isDone()).isFalse(); + + AsyncSecurityPolicy delegatePolicy = + new AsyncSecurityPolicy() { + @Override + public ListenableFuture checkAuthorizationAsync(int uid) { + return Futures.immediateFuture(Status.OK.withDescription("yay")); + } + }; + + futurePolicy.set(delegatePolicy); + + assertThat(authFuture.get().getDescription()).isEqualTo("yay"); + } + + @Test + public void testDeferredAsync_asyncPolicy_fails() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferredAsync(() -> futurePolicy, executor); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(MY_UID); + assertThat(authFuture.isDone()).isFalse(); + + AsyncSecurityPolicy delegatePolicy = + new AsyncSecurityPolicy() { + @Override + public ListenableFuture checkAuthorizationAsync(int uid) { + return Futures.immediateFuture(Status.PERMISSION_DENIED.withDescription("ouch")); + } + }; + + futurePolicy.set(delegatePolicy); + + assertThat(authFuture.get().getDescription()).isEqualTo("ouch"); + } + + @Test + public void testDeferredAsync_policyProviderReturnsFailedFuture() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferredAsync(() -> futurePolicy, executor); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(MY_UID); + assertThat(authFuture.isDone()).isFalse(); + + Exception exception = new RuntimeException("failed to load policy"); + futurePolicy.setException(exception); + + try { + authFuture.get(); + fail("Expected ExecutionException"); + } catch (ExecutionException e) { + assertThat(e.getCause()).isEqualTo(exception); + } + } + + @Test + public void testDeferredAsync_policyProviderThrowsException() throws Exception { + Exception exception = new RuntimeException("failed to call callable"); + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferredAsync( + () -> { + throw exception; + }, + executor); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(MY_UID); + + try { + authFuture.get(); + fail("Expected ExecutionException"); + } catch (ExecutionException e) { + assertThat(e.getCause()).isEqualTo(exception); + } + } + + @Test + public void testDeferredAsync_syncPolicy_succeeds() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferredAsync(() -> futurePolicy, executor); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(MY_UID); + assertThat(authFuture.isDone()).isFalse(); + + SecurityPolicy delegatePolicy = + new SecurityPolicy() { + @Override + public Status checkAuthorization(int uid) { + return Status.OK.withDescription("yay"); + } + }; + + futurePolicy.set(delegatePolicy); + + assertThat(authFuture.get().getDescription()).isEqualTo("yay"); + } + + @Test + public void testDeferredAsync_syncPolicy_fails() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferredAsync(() -> futurePolicy, executor); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(MY_UID); + assertThat(authFuture.isDone()).isFalse(); + + SecurityPolicy delegatePolicy = + new SecurityPolicy() { + @Override + public Status checkAuthorization(int uid) { + return Status.PERMISSION_DENIED.withDescription("ouch"); + } + }; + + futurePolicy.set(delegatePolicy); + + assertThat(authFuture.get().getDescription()).isEqualTo("ouch"); + } + + @Test + public void testDeferredAsync_syncPolicy_usesExecutor() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AtomicInteger executeCount = new AtomicInteger(0); + Executor customExecutor = + r -> { + executeCount.incrementAndGet(); + executor.execute(r); + }; + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferredAsync(() -> futurePolicy, customExecutor); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(MY_UID); + SecurityPolicy delegatePolicy = + new SecurityPolicy() { + @Override + public Status checkAuthorization(int uid) { + return Status.OK.withDescription("yay"); + } + }; + futurePolicy.set(delegatePolicy); + + assertThat(authFuture.get().getDescription()).isEqualTo("yay"); + assertThat(executeCount.get()).isEqualTo(1); + } + + @Test + public void testDeferredAsync_invokesCallableOnEachCheck() throws Exception { + AtomicInteger callCount = new AtomicInteger(0); + SecurityPolicy delegatePolicy = + new SecurityPolicy() { + @Override + public Status checkAuthorization(int uid) { + return Status.OK.withDescription("yay"); + } + }; + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferredAsync( + () -> { + callCount.incrementAndGet(); + return Futures.immediateFuture(delegatePolicy); + }, + executor); + + ListenableFuture authFuture1 = asyncPolicy.checkAuthorizationAsync(MY_UID); + assertThat(authFuture1.get().getDescription()).isEqualTo("yay"); + + ListenableFuture authFuture2 = asyncPolicy.checkAuthorizationAsync(MY_UID); + assertThat(authFuture2.get().getDescription()).isEqualTo("yay"); + + assertThat(callCount.get()).isEqualTo(2); + } + + @Test + public void testDeferredAsync_retriesAfterFailure() throws Exception { + AtomicInteger callCount = new AtomicInteger(0); + SecurityPolicy delegatePolicy = + new SecurityPolicy() { + @Override + public Status checkAuthorization(int uid) { + return Status.OK.withDescription("yay"); + } + }; + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferredAsync( + () -> { + if (callCount.getAndIncrement() == 0) { + return Futures.immediateFailedFuture( + new RuntimeException("first attempt failed")); + } + return Futures.immediateFuture(delegatePolicy); + }, + executor); + + ListenableFuture authFuture1 = asyncPolicy.checkAuthorizationAsync(MY_UID); + try { + authFuture1.get(); + fail("Expected ExecutionException"); + } catch (ExecutionException e) { + assertThat(e.getCause().getMessage()).isEqualTo("first attempt failed"); + } + + ListenableFuture authFuture2 = asyncPolicy.checkAuthorizationAsync(MY_UID); + assertThat(authFuture2.get().getDescription()).isEqualTo("yay"); + assertThat(callCount.get()).isEqualTo(2); + } + + @Test + public void testDeferredAsync_cancellationDoesNotPropagateBackwards() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferredAsync( + () -> Futures.nonCancellationPropagating(futurePolicy), executor); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(MY_UID); + authFuture.cancel(true); + + assertThat(futurePolicy.isCancelled()).isFalse(); + } + + @Test + public void testDeferredAsync_cancellationDoesNotPoisonSubsequentCalls() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferredAsync( + () -> Futures.nonCancellationPropagating(futurePolicy), executor); + + ListenableFuture authFuture1 = asyncPolicy.checkAuthorizationAsync(MY_UID); + authFuture1.cancel(true); + + SecurityPolicy delegatePolicy = + new SecurityPolicy() { + @Override + public Status checkAuthorization(int uid) { + return Status.OK.withDescription("yay"); + } + }; + futurePolicy.set(delegatePolicy); + + ListenableFuture authFuture2 = asyncPolicy.checkAuthorizationAsync(MY_UID); + assertThat(authFuture2.get().getDescription()).isEqualTo("yay"); + } + + @Test + public void testDeferredAsync_nullProvider_throwsException() { + try { + AsyncSecurityPolicies.deferredAsync(null, executor); + fail("Expected NullPointerException"); + } catch (NullPointerException e) { + assertThat(e).hasMessageThat().contains("policyProvider"); + } + } + + @Test + public void testDeferredAsync_nullExecutor_throwsException() { + try { + AsyncSecurityPolicies.deferredAsync(() -> Futures.immediateFuture(null), null); + fail("Expected NullPointerException"); + } catch (NullPointerException e) { + assertThat(e).hasMessageThat().contains("executor"); + } + } +}