From 83f689cf14daa3dd7856ba9c200aa7b7db05086f Mon Sep 17 00:00:00 2001 From: John Cormie Date: Thu, 18 Jun 2026 20:12:40 -0700 Subject: [PATCH] binder: Let servers load their SecurityPolicy asynchronously Android IPC servers can't defer "listening" while some slow or async initialization process completes. Instead, Android *tells* a server to initialize itself just-in-time for the first client transaction. This instruction arrives as a callback to Service#onCreate() then Service#onBind() on the app's main thread, where blocking to load a security policy would risk an "Application Not Responding" (ANR) error. Introduce AsyncSecurityPolicies#deferred which defers policy creation until it's actually needed by a Channel or Server. --- .../io/grpc/binder/AsyncSecurityPolicies.java | 93 +++++ .../binder/AsyncSecurityPoliciesTest.java | 348 ++++++++++++++++++ 2 files changed, 441 insertions(+) create mode 100644 binder/src/main/java/io/grpc/binder/AsyncSecurityPolicies.java create mode 100644 binder/src/test/java/io/grpc/binder/AsyncSecurityPoliciesTest.java 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..331676b4e63 --- /dev/null +++ b/binder/src/main/java/io/grpc/binder/AsyncSecurityPolicies.java @@ -0,0 +1,93 @@ +/* + * 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 deferred( + 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); + } + // This may block but 'executor' must be prepared for that. + 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..b0d84f1be74 --- /dev/null +++ b/binder/src/test/java/io/grpc/binder/AsyncSecurityPoliciesTest.java @@ -0,0 +1,348 @@ +/* + * 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 com.google.common.util.concurrent.Futures.immediateFuture; +import static org.junit.Assert.assertThrows; + +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.ArrayDeque; +import java.util.Arrays; +import java.util.Queue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +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 SOME_UID = 10001; + + private ExecutorService executor; + + @Before + public void setUp() { + executor = Executors.newSingleThreadExecutor(); + } + + @After + public void tearDown() { + executor.shutdown(); + } + + @Test + public void testDeferred_asyncPolicy_succeeds() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferred(() -> futurePolicy, executor); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(SOME_UID); + assertThat(authFuture.isDone()).isFalse(); + + AsyncSecurityPolicy delegatePolicy = + asyncPolicyReturning(Status.OK.withDescription("yay")); + + futurePolicy.set(delegatePolicy); + + Status status = awaitResult(authFuture); + assertThat(status.getCode()).isEqualTo(Status.Code.OK); + assertThat(status.getDescription()).isEqualTo("yay"); + } + + @Test + public void testDeferred_asyncPolicy_permissionDenied() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferred(() -> futurePolicy, executor); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(SOME_UID); + assertThat(authFuture.isDone()).isFalse(); + + AsyncSecurityPolicy delegatePolicy = + asyncPolicyReturning(Status.PERMISSION_DENIED.withDescription("no!")); + + futurePolicy.set(delegatePolicy); + + Status status = awaitResult(authFuture); + assertThat(status.getCode()).isEqualTo(Status.Code.PERMISSION_DENIED); + assertThat(status.getDescription()).isEqualTo("no!"); + } + + @Test + public void testDeferred_asyncPolicy_throwsException() throws Exception { + AsyncSecurityPolicy delegatePolicy = + new AsyncSecurityPolicy() { + @Override + public ListenableFuture checkAuthorizationAsync(int uid) { + throw new RuntimeException("async checkAuthorization failed"); + } + }; + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferred(() -> immediateFuture(delegatePolicy), executor); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(SOME_UID); + + ExecutionException e = + assertThrows(ExecutionException.class, () -> awaitResult(authFuture)); + assertThat(e).hasCauseThat().isInstanceOf(RuntimeException.class); + assertThat(e).hasCauseThat().hasMessageThat().isEqualTo("async checkAuthorization failed"); + } + + @Test + public void testDeferred_asyncPolicy_forwardsUidToDelegate() throws Exception { + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferred( + () -> immediateFuture(asyncUniqueAuthorizedUidPolicy(11111)), executor); + + assertThat(awaitResult(asyncPolicy.checkAuthorizationAsync(11111)).getCode()) + .isEqualTo(Status.Code.OK); + assertThat(awaitResult(asyncPolicy.checkAuthorizationAsync(22222)).getCode()) + .isEqualTo(Status.Code.PERMISSION_DENIED); + } + + @Test + public void testDeferred_policyProviderReturnsFailedFuture() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferred(() -> futurePolicy, executor); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(SOME_UID); + assertThat(authFuture.isDone()).isFalse(); + + Exception exception = new RuntimeException("failed to load policy"); + futurePolicy.setException(exception); + + ExecutionException e = + assertThrows(ExecutionException.class, () -> awaitResult(authFuture)); + assertThat(e).hasCauseThat().isEqualTo(exception); + } + + @Test + public void testDeferred_policyProviderThrowsException() throws Exception { + Exception exception = new RuntimeException("ouch"); + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferred( + () -> { + throw exception; + }, + executor); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(SOME_UID); + assertThat(authFuture.isDone()).isTrue(); + + ExecutionException e = + assertThrows(ExecutionException.class, () -> awaitResult(authFuture)); + assertThat(e).hasCauseThat().isEqualTo(exception); + } + + @Test + public void testDeferred_policyProviderReturnsNullFuture_returnsFailedFuture() + throws Exception { + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferred(() -> null, executor); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(SOME_UID); + + ExecutionException e = + assertThrows(ExecutionException.class, () -> awaitResult(authFuture)); + assertThat(e).hasCauseThat().isInstanceOf(NullPointerException.class); + } + + @Test + public void testDeferred_policyProviderResolvesToNullPolicy_returnsFailedFuture() + throws Exception { + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferred(() -> immediateFuture(null), executor); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(SOME_UID); + + ExecutionException e = + assertThrows(ExecutionException.class, () -> awaitResult(authFuture)); + assertThat(e).hasCauseThat().isInstanceOf(NullPointerException.class); + assertThat(e).hasCauseThat().hasMessageThat().contains("policyProvider"); + } + + @Test + public void testDeferred_syncPolicy_succeeds() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferred(() -> futurePolicy, executor); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(SOME_UID); + assertThat(authFuture.isDone()).isFalse(); + + SecurityPolicy delegatePolicy = + syncPolicyReturning(Status.OK.withDescription("yay")); + + futurePolicy.set(delegatePolicy); + + Status status = awaitResult(authFuture); + assertThat(status.getCode()).isEqualTo(Status.Code.OK); + assertThat(status.getDescription()).isEqualTo("yay"); + } + + @Test + public void testDeferred_syncPolicy_fails() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferred(() -> futurePolicy, executor); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(SOME_UID); + assertThat(authFuture.isDone()).isFalse(); + + SecurityPolicy delegatePolicy = + syncPolicyReturning(Status.PERMISSION_DENIED.withDescription("no!")); + + futurePolicy.set(delegatePolicy); + + Status status = awaitResult(authFuture); + assertThat(status.getCode()).isEqualTo(Status.Code.PERMISSION_DENIED); + assertThat(status.getDescription()).isEqualTo("no!"); + } + + @Test + public void testDeferred_syncPolicy_throwsException() throws Exception { + SecurityPolicy delegatePolicy = + new SecurityPolicy() { + @Override + public Status checkAuthorization(int uid) { + throw new RuntimeException("checkAuthorization failed"); + } + }; + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferred(() -> immediateFuture(delegatePolicy), executor); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(SOME_UID); + + ExecutionException e = + assertThrows(ExecutionException.class, () -> awaitResult(authFuture)); + assertThat(e).hasCauseThat().isInstanceOf(RuntimeException.class); + assertThat(e).hasCauseThat().hasMessageThat().isEqualTo("checkAuthorization failed"); + } + + @Test + public void testDeferred_syncPolicy_forwardsUidToDelegate() throws Exception { + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferred( + () -> immediateFuture(uniqueAuthorizedUidPolicy(11111)), executor); + + assertThat(awaitResult(asyncPolicy.checkAuthorizationAsync(11111)).getCode()) + .isEqualTo(Status.Code.OK); + assertThat(awaitResult(asyncPolicy.checkAuthorizationAsync(22222)).getCode()) + .isEqualTo(Status.Code.PERMISSION_DENIED); + } + + @Test + public void testDeferred_cancellationPropagatesToProvider() { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferred(() -> futurePolicy, executor); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(SOME_UID); + authFuture.cancel(true); + + assertThat(futurePolicy.isCancelled()).isTrue(); + } + + @Test + public void testDeferred_cancellationPropagatesToAsyncDelegate() throws Exception { + SettableFuture delegateAuthFuture = SettableFuture.create(); + SettableFuture settableUid = SettableFuture.create(); + AsyncSecurityPolicy delegatePolicy = + new AsyncSecurityPolicy() { + @Override + public ListenableFuture checkAuthorizationAsync(int uid) { + settableUid.set(uid); + return delegateAuthFuture; + } + }; + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.deferred(() -> immediateFuture(delegatePolicy), executor); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(SOME_UID); + assertThat(awaitResult(settableUid)).isEqualTo(SOME_UID); + authFuture.cancel(false); + + assertThat(delegateAuthFuture.isCancelled()).isTrue(); + } + + @Test + public void testDeferred_nullProvider_throwsException() { + NullPointerException e = + assertThrows( + NullPointerException.class, () -> AsyncSecurityPolicies.deferred(null, executor)); + assertThat(e).hasMessageThat().contains("policyProvider"); + } + + private static T awaitResult(Future future) throws Exception { + return future.get(10, TimeUnit.SECONDS); + } + + private static SecurityPolicy syncPolicyReturning(Status status) { + return new SecurityPolicy() { + @Override + public Status checkAuthorization(int uid) { + return status; + } + }; + } + + private static AsyncSecurityPolicy asyncPolicyReturning(Status status) { + return new AsyncSecurityPolicy() { + @Override + public ListenableFuture checkAuthorizationAsync(int uid) { + return immediateFuture(status); + } + }; + } + + private static SecurityPolicy uniqueAuthorizedUidPolicy(int authorizedUid) { + return new SecurityPolicy() { + @Override + public Status checkAuthorization(int uid) { + return uid == authorizedUid + ? Status.OK + : Status.PERMISSION_DENIED.withDescription("unauthorized uid: " + uid); + } + }; + } + + private static AsyncSecurityPolicy asyncUniqueAuthorizedUidPolicy(int authorizedUid) { + return new AsyncSecurityPolicy() { + @Override + public ListenableFuture checkAuthorizationAsync(int uid) { + return immediateFuture( + uid == authorizedUid + ? Status.OK + : Status.PERMISSION_DENIED.withDescription("unauthorized uid: " + uid)); + } + }; + } +}