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..bd75d5bdec3 --- /dev/null +++ b/binder/src/main/java/io/grpc/binder/AsyncSecurityPolicies.java @@ -0,0 +1,94 @@ +/* + * 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 com.google.common.base.Preconditions; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +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() {} + + /** + * Adapts a future {@link AsyncSecurityPolicy} into an instance of {@link AsyncSecurityPolicy} + * that can be used right away. + * + *

Use this when your actual security policy is loaded asynchronously (e.g., from disk or + * network) and is not immediately available at gRPC channel or server initialization. For + * example, 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 connection. And this instruction arrives as a callback to {@code + * Service#onCreate()} then {@code Service#onBind()} on the app's main thread, where blocking to + * load a security policy would risk an "Application Not Responding" (ANR) error. + * + *

The returned policy will defer all authorization decisions until {@code futurePolicy} + * completes. So any calls or connections needing authorization will wait. After the future + * completes, all new and pending authorization checks are delegated to the loaded policy. If + * {@code futurePolicy} completes with an exception, all authorization checks will fail. + * + * @param futurePolicy The future that will resolve to the delegate security policy. + */ + @ExperimentalApi("https://github.com/grpc/grpc-java/issues/8022") + public static AsyncSecurityPolicy fromFuture( + final ListenableFuture futurePolicy) { + Preconditions.checkNotNull(futurePolicy, "futurePolicy"); + return new AsyncSecurityPolicy() { + @Override + public ListenableFuture checkAuthorizationAsync(int uid) { + return Futures.transformAsync( + Futures.nonCancellationPropagating(futurePolicy), + policy -> policy.checkAuthorizationAsync(uid), + MoreExecutors.directExecutor()); + } + }; + } + + /** + * Adapts a future synchronous {@link SecurityPolicy} into an {@link AsyncSecurityPolicy}. + * + *

See {@link #fromFuture(ListenableFuture)} for details on the use case. + * + *

Because the delegate policy is synchronous and may perform blocking operations (like disk + * I/O), the actual authorization checks are offloaded to the provided {@code offloadExecutor}. + * + * @param futurePolicy The future that will resolve to the delegate security policy. + * @param offloadExecutor The executor on which to run the delegate's blocking {@link + * SecurityPolicy#checkAuthorization} call. + */ + @ExperimentalApi("https://github.com/grpc/grpc-java/issues/8022") + public static AsyncSecurityPolicy fromFuture( + final ListenableFuture futurePolicy, Executor offloadExecutor) { + Preconditions.checkNotNull(futurePolicy, "futurePolicy"); + return new AsyncSecurityPolicy() { + @Override + public ListenableFuture checkAuthorizationAsync(int uid) { + return Futures.transform( + Futures.nonCancellationPropagating(futurePolicy), + policy -> policy.checkAuthorization(uid), + offloadExecutor); + } + }; + } +} 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..f96e8458db7 --- /dev/null +++ b/binder/src/test/java/io/grpc/binder/AsyncSecurityPoliciesTest.java @@ -0,0 +1,268 @@ +/* + * 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.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; +import io.grpc.Status; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicInteger; +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(); + + @Test + public void testFromFuture_asyncPolicy_succeeds() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = AsyncSecurityPolicies.fromFuture(futurePolicy); + + 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.isDone()).isTrue(); + assertThat(authFuture.get().getDescription()).isEqualTo("yay"); + } + + @Test + public void testFromFuture_asyncPolicy_fails() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = AsyncSecurityPolicies.fromFuture(futurePolicy); + + 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.isDone()).isTrue(); + assertThat(authFuture.get().getDescription()).isEqualTo("ouch"); + } + + @Test + public void testFromFuture_asyncPolicy_futureFails() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = AsyncSecurityPolicies.fromFuture(futurePolicy); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(MY_UID); + assertThat(authFuture.isDone()).isFalse(); + + Exception exception = new RuntimeException("failed to load policy"); + futurePolicy.setException(exception); + + assertThat(authFuture.isDone()).isTrue(); + try { + authFuture.get(); + fail("Expected ExecutionException"); + } catch (java.util.concurrent.ExecutionException e) { + assertThat(e.getCause()).isEqualTo(exception); + } + } + + @Test + public void testFromFuture_syncPolicy_succeeds() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.fromFuture(futurePolicy, MoreExecutors.directExecutor()); + + 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.isDone()).isTrue(); + assertThat(authFuture.get().getDescription()).isEqualTo("yay"); + } + + @Test + public void testFromFuture_syncPolicy_fails() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.fromFuture(futurePolicy, MoreExecutors.directExecutor()); + + 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.isDone()).isTrue(); + assertThat(authFuture.get().getDescription()).isEqualTo("ouch"); + } + + @Test + public void testFromFuture_syncPolicy_futureFails() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.fromFuture(futurePolicy, MoreExecutors.directExecutor()); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(MY_UID); + assertThat(authFuture.isDone()).isFalse(); + + Exception exception = new RuntimeException("failed to load policy"); + futurePolicy.setException(exception); + + assertThat(authFuture.isDone()).isTrue(); + try { + authFuture.get(); + fail("Expected ExecutionException"); + } catch (java.util.concurrent.ExecutionException e) { + assertThat(e.getCause()).isEqualTo(exception); + } + } + + @Test + public void testFromFuture_syncPolicy_usesExecutor() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AtomicInteger executeCount = new AtomicInteger(0); + Executor executor = + r -> { + executeCount.incrementAndGet(); + r.run(); + }; + AsyncSecurityPolicy asyncPolicy = AsyncSecurityPolicies.fromFuture(futurePolicy, executor); + + 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 testFromFuture_asyncPolicy_cancellationDoesNotPropagateBackwards() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = AsyncSecurityPolicies.fromFuture(futurePolicy); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(MY_UID); + authFuture.cancel(true); + + assertThat(futurePolicy.isCancelled()).isFalse(); + } + + @Test + public void testFromFuture_syncPolicy_cancellationDoesNotPropagateBackwards() throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.fromFuture(futurePolicy, MoreExecutors.directExecutor()); + + ListenableFuture authFuture = asyncPolicy.checkAuthorizationAsync(MY_UID); + authFuture.cancel(true); + + assertThat(futurePolicy.isCancelled()).isFalse(); + } + + @Test + public void testFromFuture_asyncPolicy_cancellationDoesNotPoisonSubsequentCalls() + throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = AsyncSecurityPolicies.fromFuture(futurePolicy); + + ListenableFuture authFuture1 = asyncPolicy.checkAuthorizationAsync(MY_UID); + authFuture1.cancel(true); + + // Now try again + ListenableFuture authFuture2 = asyncPolicy.checkAuthorizationAsync(MY_UID); + + AsyncSecurityPolicy delegatePolicy = + new AsyncSecurityPolicy() { + @Override + public ListenableFuture checkAuthorizationAsync(int uid) { + return Futures.immediateFuture(Status.OK.withDescription("yay")); + } + }; + futurePolicy.set(delegatePolicy); + + assertThat(authFuture2.isDone()).isTrue(); + assertThat(authFuture2.get().getDescription()).isEqualTo("yay"); + } + + @Test + public void testFromFuture_syncPolicy_cancellationDoesNotPoisonSubsequentCalls() + throws Exception { + SettableFuture futurePolicy = SettableFuture.create(); + AsyncSecurityPolicy asyncPolicy = + AsyncSecurityPolicies.fromFuture(futurePolicy, MoreExecutors.directExecutor()); + + ListenableFuture authFuture1 = asyncPolicy.checkAuthorizationAsync(MY_UID); + authFuture1.cancel(true); + + // Now try again + ListenableFuture authFuture2 = asyncPolicy.checkAuthorizationAsync(MY_UID); + + SecurityPolicy delegatePolicy = + new SecurityPolicy() { + @Override + public Status checkAuthorization(int uid) { + return Status.OK.withDescription("yay"); + } + }; + futurePolicy.set(delegatePolicy); + + assertThat(authFuture2.isDone()).isTrue(); + assertThat(authFuture2.get().getDescription()).isEqualTo("yay"); + } +}