Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 9a64b63c authored by Desmond Yao's avatar Desmond Yao
Browse files

Allow non admin user capture bugreport without consent

Test: atest FrameworksServicesTests:com.android.server.incident.PendingReportsTest
Bug: 392118999
Flag: android.os.allow_consentless_bugreport_delegated_consent

Change-Id: I85c43f7f2ebcbce133e369d523541b610e434a3d
parent 1b1a3622
Loading
Loading
Loading
Loading
+81 −40
Original line number Diff line number Diff line
@@ -40,6 +40,8 @@ import android.os.UserManager;
import android.permission.PermissionManager;
import android.util.Log;

import com.android.internal.annotations.VisibleForTesting;

import java.io.FileDescriptor;
import java.io.PrintWriter;
import java.text.SimpleDateFormat;
@@ -56,12 +58,13 @@ import java.util.List;
class PendingReports {
    static final String TAG = IncidentCompanionService.TAG;

    private final Handler mHandler = new Handler();
    private final RequestQueue mRequestQueue = new RequestQueue(mHandler);
    private final RequestQueue mRequestQueue;
    private final Context mContext;
    private final PackageManager mPackageManager;
    private final AppOpsManager mAppOpsManager;
    private final PermissionManager mPermissionManager;
    private final UserManager mUserManager;
    private final Injector mInjector;

    //
    // All fields below must be protected by mLock
@@ -126,14 +129,56 @@ class PendingReports {
        }
    }

    static class Injector {
        private final Context mContext;
        private final Handler mHandler;

        Injector(Context context, Handler handler) {
            mContext = context;
            mHandler = handler;
        }

        public Context getContext() {
            return mContext;
        }

        public Handler getHandler() {
            return mHandler;
        }

        UserManager getUserManager() {
            return UserManager.get(mContext);
        }

        AppOpsManager getAppOpsManager() {
            return mContext.getSystemService(AppOpsManager.class);
        }

        /**
         * Check whether the current user is an admin user, and return the user id if they are.
         * Returns UserHandle.USER_NULL if not valid.
         */
        int getCurrentUserIfAdmin() {
            return IncidentCompanionService.getCurrentUserIfAdmin();
        }
    }

    /**
     * Construct new PendingReports with the context.
     */
    PendingReports(Context context) {
        mContext = context;
        mPackageManager = context.getPackageManager();
        mAppOpsManager = context.getSystemService(AppOpsManager.class);
        mPermissionManager = context.getSystemService(PermissionManager.class);
        this(new Injector(context, new Handler()));
    }

    @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE)
    PendingReports(Injector injector) {
        mContext = injector.getContext();
        mInjector = injector;
        mRequestQueue = new RequestQueue(injector.getHandler());
        mPackageManager = mContext.getPackageManager();
        mPermissionManager = mContext.getSystemService(PermissionManager.class);
        mAppOpsManager = injector.getAppOpsManager();
        mUserManager = injector.getUserManager();
    }

    /**
@@ -282,28 +327,6 @@ class PendingReports {
            return;
        }

        // Find the current user of the device and check if they are an admin.
        final int currentAdminUser = getCurrentUserIfAdmin();
        final int callingUser = UserHandle.getUserId(callingUid);

        // Deny the report if the current admin user is null
        // or the calling user is not from the same profile group of current user.
        if (currentAdminUser == UserHandle.USER_NULL
                || !isSameProfileGroupUser(callingUser, currentAdminUser)) {
            Log.w(TAG, "Calling user " + callingUser + " doesn't belong to the same profile "
                    + "group of the current admin user " + currentAdminUser);
            denyReportBeforeAddingRec(listener, callingPackage);
            return;
        }

        // Find the approver app (hint: it's PermissionController).
        final ComponentName receiver = getApproverComponent(currentAdminUser);
        if (receiver == null) {
            // We couldn't find an approver... so deny the request here and now, before we
            // do anything else.
            denyReportBeforeAddingRec(listener, callingPackage);
            return;
        }
        AttributionSource attributionSource =
                    new AttributionSource.Builder(callingUid)
                            .setPackageName(callingPackage)
@@ -350,6 +373,30 @@ class PendingReports {
            }
        }

        // Find the current user of the device and check if they are an admin.
        final int currentAdminUser = mInjector.getCurrentUserIfAdmin();
        final int callingUser = UserHandle.getUserId(callingUid);

        // Deny the report if the current admin user is null
        // or the calling user is not from the same profile group of current user.
        if (currentAdminUser == UserHandle.USER_NULL
                || !isSameProfileGroupUser(callingUser, currentAdminUser)) {
            Log.w(TAG, "Calling user " + callingUser + " doesn't belong to the same profile "
                    + "group of the current admin user " + currentAdminUser);
            denyReportBeforeAddingRec(listener, callingPackage);
            return;
        }

        // Find the approver app (hint: it's PermissionController).
        final ComponentName receiver = getApproverComponent(currentAdminUser);
        if (receiver == null) {
            // We couldn't find an approver... so deny the request here and now, before we
            // do anything else.
            Log.w(TAG, "We couldn't find an approver for currentAdminUser " + currentAdminUser);
            denyReportBeforeAddingRec(listener, callingPackage);
            return;
        }

        // Save the record for when the PermissionController comes back to authorize it.
        PendingReportRec rec = null;
        synchronized (mLock) {
@@ -376,10 +423,13 @@ class PendingReports {
     * Cancel a pending report request (because of an explicit call to cancel)
     */
    private void cancelReportImpl(IIncidentAuthListener listener) {
        final int currentAdminUser = getCurrentUserIfAdmin();
        final int currentAdminUser = mInjector.getCurrentUserIfAdmin();
        final ComponentName receiver = getApproverComponent(currentAdminUser);
        if (currentAdminUser != UserHandle.USER_NULL && receiver != null) {
            cancelReportImpl(listener, receiver, currentAdminUser);
        } else {
            Log.w(TAG, "Didn't find exactly approver component for currentAdminUser "
                    + currentAdminUser);
        }
    }

@@ -404,7 +454,7 @@ class PendingReports {
     * cleanup cases to keep the apps' list in sync with ours.
     */
    private void sendBroadcast() {
        final int currentAdminUser = getCurrentUserIfAdmin();
        final int currentAdminUser = mInjector.getCurrentUserIfAdmin();
        if (currentAdminUser == UserHandle.USER_NULL) {
            return;
        }
@@ -481,14 +531,6 @@ class PendingReports {
        }
    }

    /**
     * Check whether the current user is an admin user, and return the user id if they are.
     * Returns UserHandle.USER_NULL if not valid.
     */
    private int getCurrentUserIfAdmin() {
        return IncidentCompanionService.getCurrentUserIfAdmin();
    }

    /**
     * Return the ComponentName of the BroadcastReceiver that will approve reports.
     * The system must have zero or one of these installed.  We only look on the
@@ -530,8 +572,7 @@ class PendingReports {
     */
    private boolean isSameProfileGroupUser(@UserIdInt int currentAdminUser,
            @UserIdInt int callingUser) {
        return UserManager.get(mContext)
                .isSameProfileGroup(currentAdminUser, callingUser);
        return mUserManager.isSameProfileGroup(currentAdminUser, callingUser);
    }
}
+4 −1
Original line number Diff line number Diff line
@@ -5,6 +5,9 @@
    },
    {
      "name": "BugreportManagerTestCases"
    },
    {
      "name": "FrameworksServicesTests_incident"
    }
  ]
}
+10 −0
Original line number Diff line number Diff line
@@ -1081,3 +1081,13 @@ test_module_config {
    ],
    include_filters: ["com.android.server.supervision"],
}

test_module_config {
    name: "FrameworksServicesTests_incident",
    base: "FrameworksServicesTests",
    test_suites: [
        "automotive-tests",
        "device-tests",
    ],
    include_filters: ["com.android.server.incident"],
}
+2 −0
Original line number Diff line number Diff line
# Bug component: 153446
include /cmds/incidentd/OWNERS
+312 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * 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 com.android.server.incident;

import static android.Manifest.permission.CAPTURE_CONSENTLESS_BUGREPORT_DELEGATED_CONSENT;
import static android.content.Intent.ACTION_PENDING_INCIDENT_REPORTS_CHANGED;
import static android.os.IncidentManager.FLAG_CONFIRMATION_DIALOG;

import static com.google.common.truth.Truth.assertThat;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.app.AppOpsManager;
import android.content.AttributionSourceState;
import android.content.Context;
import android.content.Intent;
import android.content.PermissionChecker;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.pm.UserInfo;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.IIncidentAuthListener;
import android.os.Message;
import android.os.TestLooperManager;
import android.os.UserHandle;
import android.os.UserManager;
import android.permission.PermissionCheckerManager;
import android.permission.PermissionManager;
import android.testing.TestableContext;

import androidx.test.InstrumentationRegistry;
import androidx.test.ext.junit.runners.AndroidJUnit4;

import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.ArgumentMatcher;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;
import org.mockito.quality.Strictness;

import java.util.ArrayList;
import java.util.List;

/**
 * Build/Install/Run: atest FrameworksServicesTests:com.android.server.incident.PendingReportsTest
 */
@RunWith(AndroidJUnit4.class)
public class PendingReportsTest {
    private static final UserInfo ADMIN_USER_INFO =
            new UserInfo(/* id= */ 5678, "adminUser", UserInfo.FLAG_ADMIN);
    private static final UserInfo GUEST_USER_INFO = new UserInfo(/* id= */ 1234, "guestUser", 0);

    public @Rule MockitoRule mMockitoRule = MockitoJUnit.rule().strictness(Strictness.LENIENT);

    @Rule
    public TestableContext mContext =
            spy(new TestableContext(InstrumentationRegistry.getContext(), null));

    private final IIncidentAuthListener mIncidentAuthListener = mock(IIncidentAuthListener.class);

    private PendingReports mPendingReports;
    private TestInjector mTestInjector;
    private HandlerThread mUiThread;
    private TestLooperManager mTestLooperManager;
    @Mock private PackageManager mMockPackageManager;
    @Mock private UserManager mMockUserManager;
    @Mock private AppOpsManager mMockAppOpsManager;
    @Mock private PermissionCheckerManager mPermissionCheckerManager;
    @Mock private IBinder mListenerBinder;

    public class TestInjector extends PendingReports.Injector {

        private int mUserId = ADMIN_USER_INFO.id;

        TestInjector(Context context, Handler handler) {
            super(context, handler);
        }

        @Override
        UserManager getUserManager() {
            return mMockUserManager;
        }

        @Override
        AppOpsManager getAppOpsManager() {
            return mMockAppOpsManager;
        }

        @Override
        int getCurrentUserIfAdmin() {
            return mUserId;
        }

        void clearAdminUserId() {
            mUserId = UserHandle.USER_NULL;
        }
    }

    @Before
    public void setup() throws Exception {
        // generate new IBinder instance every time for test
        when(mIncidentAuthListener.asBinder()).thenReturn(mListenerBinder);

        ResolveInfo resolveInfo = new ResolveInfo();
        ActivityInfo mincidentActivityInfo = new ActivityInfo();
        mincidentActivityInfo.name = "PendingReportsTest";
        mincidentActivityInfo.packageName = mContext.getPackageName();
        resolveInfo.activityInfo = mincidentActivityInfo;

        List<ResolveInfo> intentReceivers = new ArrayList<>();
        intentReceivers.add(resolveInfo);
        ArgumentMatcher<Intent> filterIntent =
                intent -> intent.getAction().equals(Intent.ACTION_PENDING_INCIDENT_REPORTS_CHANGED);
        when(mMockPackageManager.queryBroadcastReceiversAsUser(
                        argThat(filterIntent),
                        /* flags= */ anyInt(),
                        /* userId= */ eq(ADMIN_USER_INFO.id)))
                .thenReturn(intentReceivers);
        mContext.setMockPackageManager(mMockPackageManager);

        when(mMockUserManager.isSameProfileGroup(anyInt(), eq(ADMIN_USER_INFO.id)))
                .thenReturn(true);

        mUiThread = new HandlerThread("MockUiThread");
        mUiThread.start();
        mTestLooperManager =
                InstrumentationRegistry.getInstrumentation()
                        .acquireLooperManager(mUiThread.getLooper());

        doReturn(Context.PERMISSION_CHECKER_SERVICE)
                .when(mContext)
                .getSystemServiceName(PermissionCheckerManager.class);
        mContext.addMockSystemService(PermissionCheckerManager.class, mPermissionCheckerManager);
        mContext.addMockSystemService(
                Context.PERMISSION_CHECKER_SERVICE, mPermissionCheckerManager);
        mContext.addMockSystemService(PermissionManager.class, new PermissionManager(mContext));

        Handler testHandler = new Handler(mUiThread.getLooper());
        mTestInjector = new TestInjector(mContext, testHandler);
        mPendingReports = new PendingReports(mTestInjector);
        mPendingReports.onBootCompleted();
    }

    @After
    public void tearDown() {
        mTestLooperManager.release();
        mUiThread.quit();
    }

    @Test
    public void testAuthorizeReport_sendsIncidentBroadcast() throws Exception {
        mockDelegatePermissionStatus(false);
        mPendingReports.authorizeReport(
                ADMIN_USER_INFO.id,
                mContext.getPackageName(),
                "receiverClass",
                "report_id",
                FLAG_CONFIRMATION_DIALOG,
                mIncidentAuthListener);
        drainRequestQueue();

        assertThat(mPendingReports.getPendingReports()).hasSize(1);
        assertThat(mPendingReports.getPendingReports().get(0))
                .matches(
                        "content://android\\.os\\.IncidentManager/pending\\?id=1"
                                + "&pkg=com\\.android\\.frameworks\\.servicestests"
                                + "&flags=1"
                                + "&t=(\\d+)"
                                + "&receiver=receiverClass&r=report_id");
        ArgumentCaptor<Intent> intentArgumentCaptor = ArgumentCaptor.forClass(Intent.class);
        verify(mContext)
                .sendBroadcastAsUser(
                        intentArgumentCaptor.capture(),
                        /* user= */ any(),
                        /* receiverPermission= */ eq(
                                android.Manifest.permission.APPROVE_INCIDENT_REPORTS),
                        /* options= */ any());
        assertBroadcastHasExpectedValue(intentArgumentCaptor.getValue());
    }

    private void assertBroadcastHasExpectedValue(Intent intent) {
        assertThat(intent.getAction()).isEqualTo(ACTION_PENDING_INCIDENT_REPORTS_CHANGED);
        assertThat(intent.getComponent().getClassName()).isEqualTo("PendingReportsTest");
        assertThat(intent.getComponent().getPackageName()).isEqualTo(mContext.getPackageName());
    }

    @Test
    public void testAuthorizeReport_nonAdmin_getsApprovedIfHaveConsentlessPermission()
            throws Exception {
        mockDelegatePermissionStatus(true);
        mTestInjector.clearAdminUserId();

        mPendingReports.authorizeReport(
                GUEST_USER_INFO.id,
                mContext.getPackageName(),
                "receiverClass",
                "report_id",
                FLAG_CONFIRMATION_DIALOG,
                mIncidentAuthListener);
        drainRequestQueue();

        verify(mIncidentAuthListener).onReportApproved();
    }

    @Test
    public void testAuthorizeReport_nonAdmin_denysByDefault() throws Exception {
        mockDelegatePermissionStatus(false);
        mTestInjector.clearAdminUserId();

        mPendingReports.authorizeReport(
                GUEST_USER_INFO.id,
                mContext.getPackageName(),
                "receiverClass",
                "report_id",
                FLAG_CONFIRMATION_DIALOG,
                mIncidentAuthListener);
        drainRequestQueue();

        verify(mIncidentAuthListener).onReportDenied();
    }

    @Test
    public void testCancelAuthorization_sendsIncidentBroadcast() throws Exception {
        mockDelegatePermissionStatus(false);
        mPendingReports.authorizeReport(
                ADMIN_USER_INFO.id,
                mContext.getPackageName(),
                "receiverClass",
                "report_id",
                FLAG_CONFIRMATION_DIALOG,
                mIncidentAuthListener);
        drainRequestQueue();
        mPendingReports.cancelAuthorization(mIncidentAuthListener);
        drainRequestQueue();

        assertThat(mPendingReports.getPendingReports()).isEmpty();
        ArgumentCaptor<Intent> intentArgumentCaptor = ArgumentCaptor.forClass(Intent.class);
        verify(mContext, times(2))
                .sendBroadcastAsUser(
                        /* intent= */ intentArgumentCaptor.capture(),
                        /* user= */ any(),
                        /* receiverPermission= */ eq(
                                android.Manifest.permission.APPROVE_INCIDENT_REPORTS),
                        /* options= */ any());
        List<Intent> intents = intentArgumentCaptor.getAllValues();
        assertThat(intents).hasSize(2);
        // authorize and cancel sends same intent
        assertBroadcastHasExpectedValue(intents.get(0));
        assertBroadcastHasExpectedValue(intents.get(1));
    }

    private void mockDelegatePermissionStatus(boolean granted) {
        int permissionCode =
                granted
                        ? PermissionChecker.PERMISSION_GRANTED
                        : PermissionChecker.PERMISSION_HARD_DENIED;
        doReturn(permissionCode)
                .when(mPermissionCheckerManager)
                .checkPermission(
                        eq(CAPTURE_CONSENTLESS_BUGREPORT_DELEGATED_CONSENT),
                        any(AttributionSourceState.class),
                        isNull(),
                        anyBoolean(),
                        anyBoolean(),
                        anyBoolean(),
                        anyInt());
    }

    private void drainRequestQueue() {
        while (true) {
            Message m = mTestLooperManager.poll();
            if (m == null) {
                break;
            }
            mTestLooperManager.execute(m);
            mTestLooperManager.recycle(m);
        }
    }
}
Loading