From c33d4d51dc83bd3de737b458ac391511d40c3fc4 Mon Sep 17 00:00:00 2001 From: a-mackay Date: Tue, 12 Nov 2024 13:11:27 +1100 Subject: [PATCH] Add Application Default Credentials & Service Account impersonation (#1148) * Add Application Default Credentials & Service Account impersonation * Fail if service account impersonation used with default creds * Fix some formatting --------- Co-authored-by: Paul Thomson --- README.md | 18 ++ .../gradle/androidpublisher/PlayPublisher.kt | 21 ++ .../internal/AndroidPublisher.kt | 24 +++ .../internal/DefaultPlayPublisher.kt | 5 + .../androidpublisher/FakePlayPublisher.kt | 1 + play/plugin/build.gradle.kts | 1 + .../gradle/play/PlayPublisherExtension.kt | 15 ++ .../gradle/play/PlayPublisherPlugin.kt | 6 +- .../play/tasks/internal/PlayApiService.kt | 31 ++- .../gradle/play/PlayPublisherExtensionTest.kt | 2 + .../PlayPublisherPluginIntegrationTest.kt | 190 ++++++++++++++++++ 11 files changed, 307 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4abdb2277..c858c2023 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,24 @@ play { > the `ANDROID_PUBLISHER_CREDENTIALS` environment variable and don't specify the > `serviceAccountCredentials` property. +#### Application Default Credentials + +Alternatively, you can use [Application Default Credentials](https://cloud.google.com/docs/authentication/provide-credentials-adc) +(and optionally [Service Account impersonation](https://cloud.google.com/docs/authentication/use-service-account-impersonation)) +instead of specifying a JSON private key file or environment variable: + +```kt +android { ... } + +play { + useApplicationDefaultCredentials = true + impersonateServiceAccount = "account@your-project.iam.gserviceaccount.com" // Optional +} +``` + +> Note: Currently [Service Account impersonation](https://cloud.google.com/docs/authentication/use-service-account-impersonation) +is only supported when using [Application Default Credentials](https://cloud.google.com/docs/authentication/provide-credentials-adc) + ## Task organization GPP follows the Android Gradle Plugin's (AGP) naming convention: `[action][Variant][Thing]`. For diff --git a/play/android-publisher/src/main/kotlin/com/github/triplet/gradle/androidpublisher/PlayPublisher.kt b/play/android-publisher/src/main/kotlin/com/github/triplet/gradle/androidpublisher/PlayPublisher.kt index d0ff74b8f..79d4fb1a8 100644 --- a/play/android-publisher/src/main/kotlin/com/github/triplet/gradle/androidpublisher/PlayPublisher.kt +++ b/play/android-publisher/src/main/kotlin/com/github/triplet/gradle/androidpublisher/PlayPublisher.kt @@ -119,6 +119,16 @@ interface PlayPublisher { credentials: InputStream, appId: String, ): PlayPublisher + + /** + * Creates a new [PlayPublisher] using ApplicationDefaultCredentials. + * + * @param appId the app's package name + */ + fun create( + appId: String, + impersonateServiceAccount: String? + ): PlayPublisher } companion object { @@ -131,5 +141,16 @@ interface PlayPublisher { appId: String, ): PlayPublisher = ServiceLoader.load(Factory::class.java).last() .create(credentials, appId) + + /** + * Creates a new [PlayPublisher] using Application Default Credentials on GCP + * and using Service Account Impersonation if a Service Account to impersonate is + * configured. + */ + operator fun invoke( + appId: String, + impersonateServiceAccount: String? + ): PlayPublisher = ServiceLoader.load(Factory::class.java).last() + .create(appId, impersonateServiceAccount) } } diff --git a/play/android-publisher/src/main/kotlin/com/github/triplet/gradle/androidpublisher/internal/AndroidPublisher.kt b/play/android-publisher/src/main/kotlin/com/github/triplet/gradle/androidpublisher/internal/AndroidPublisher.kt index 93a1679e0..65030000a 100644 --- a/play/android-publisher/src/main/kotlin/com/github/triplet/gradle/androidpublisher/internal/AndroidPublisher.kt +++ b/play/android-publisher/src/main/kotlin/com/github/triplet/gradle/androidpublisher/internal/AndroidPublisher.kt @@ -14,6 +14,7 @@ import com.google.api.services.androidpublisher.AndroidPublisher import com.google.api.services.androidpublisher.AndroidPublisherScopes import com.google.auth.http.HttpCredentialsAdapter import com.google.auth.oauth2.GoogleCredentials +import com.google.auth.oauth2.ImpersonatedCredentials import org.apache.http.auth.AuthScope import org.apache.http.auth.UsernamePasswordCredentials import org.apache.http.impl.client.BasicCredentialsProvider @@ -42,6 +43,29 @@ internal fun createPublisher(credentials: InputStream): AndroidPublisher { ).setApplicationName(PLUGIN_NAME).build() } +internal fun createPublisher(impersonateServiceAccount: String?): AndroidPublisher { + val transport = buildTransport() + + val appDefaultCreds = GoogleCredentials.getApplicationDefault() + + val credential = if (impersonateServiceAccount != null) { + ImpersonatedCredentials.newBuilder() + .setSourceCredentials(appDefaultCreds) + .setTargetPrincipal(impersonateServiceAccount) + .setScopes(listOf(AndroidPublisherScopes.ANDROIDPUBLISHER)) + .setDelegates(null) + .build() + } else { + appDefaultCreds + } + + return AndroidPublisher.Builder( + transport, + GsonFactory.getDefaultInstance(), + AndroidPublisherAdapter(credential as GoogleCredentials) + ).setApplicationName(PLUGIN_NAME).build() +} + internal infix fun GoogleJsonResponseException.has(error: String) = details?.errors.orEmpty().any { it.reason == error } diff --git a/play/android-publisher/src/main/kotlin/com/github/triplet/gradle/androidpublisher/internal/DefaultPlayPublisher.kt b/play/android-publisher/src/main/kotlin/com/github/triplet/gradle/androidpublisher/internal/DefaultPlayPublisher.kt index 1cf657a61..93cf0207b 100644 --- a/play/android-publisher/src/main/kotlin/com/github/triplet/gradle/androidpublisher/internal/DefaultPlayPublisher.kt +++ b/play/android-publisher/src/main/kotlin/com/github/triplet/gradle/androidpublisher/internal/DefaultPlayPublisher.kt @@ -304,6 +304,11 @@ internal class DefaultPlayPublisher( val publisher = createPublisher(credentials) return DefaultPlayPublisher(publisher, appId) } + + override fun create(appId: String, impersonateServiceAccount: String?): PlayPublisher { + val publisher = createPublisher(impersonateServiceAccount) + return DefaultPlayPublisher(publisher, appId) + } } private companion object { diff --git a/play/android-publisher/src/testFixtures/kotlin/com/github/triplet/gradle/androidpublisher/FakePlayPublisher.kt b/play/android-publisher/src/testFixtures/kotlin/com/github/triplet/gradle/androidpublisher/FakePlayPublisher.kt index 49e301a4a..322890a75 100644 --- a/play/android-publisher/src/testFixtures/kotlin/com/github/triplet/gradle/androidpublisher/FakePlayPublisher.kt +++ b/play/android-publisher/src/testFixtures/kotlin/com/github/triplet/gradle/androidpublisher/FakePlayPublisher.kt @@ -46,6 +46,7 @@ abstract class FakePlayPublisher : PlayPublisher { class Factory : PlayPublisher.Factory { override fun create(credentials: InputStream, appId: String) = publisher + override fun create(appId: String, impersonateServiceAccount: String?) = publisher } companion object { diff --git a/play/plugin/build.gradle.kts b/play/plugin/build.gradle.kts index e7aa47c61..3f52d2bc9 100644 --- a/play/plugin/build.gradle.kts +++ b/play/plugin/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { testImplementation(testLibs.junit.engine) testImplementation(testLibs.junit.params) testImplementation(testLibs.truth) + testImplementation(testLibs.mockito) } tasks.withType().configureEach { diff --git a/play/plugin/src/main/kotlin/com/github/triplet/gradle/play/PlayPublisherExtension.kt b/play/plugin/src/main/kotlin/com/github/triplet/gradle/play/PlayPublisherExtension.kt index fb9e978c7..09c9da144 100644 --- a/play/plugin/src/main/kotlin/com/github/triplet/gradle/play/PlayPublisherExtension.kt +++ b/play/plugin/src/main/kotlin/com/github/triplet/gradle/play/PlayPublisherExtension.kt @@ -15,6 +15,7 @@ import org.gradle.api.tasks.Nested import org.gradle.api.tasks.Optional import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity +import org.gradle.configurationcache.problems.PropertyTrace import javax.inject.Inject /** The entry point for all GPP related configuration. */ @@ -41,6 +42,20 @@ abstract class PlayPublisherExtension @Inject constructor( @get:InputFile abstract val serviceAccountCredentials: RegularFileProperty + /** + * Use GCP Application Default Credentials. + */ + @get:Optional + @get:Input + abstract val useApplicationDefaultCredentials: Property + + /** + * Specify the Service Account to impersonate + */ + @get:Optional + @get:Input + abstract val impersonateServiceAccount: Property + /** * Choose the default packaging method. Either App Bundles or APKs. Affects tasks like * `publish`. diff --git a/play/plugin/src/main/kotlin/com/github/triplet/gradle/play/PlayPublisherPlugin.kt b/play/plugin/src/main/kotlin/com/github/triplet/gradle/play/PlayPublisherPlugin.kt index 2131d31af..6d1c6f72c 100644 --- a/play/plugin/src/main/kotlin/com/github/triplet/gradle/play/PlayPublisherPlugin.kt +++ b/play/plugin/src/main/kotlin/com/github/triplet/gradle/play/PlayPublisherPlugin.kt @@ -47,7 +47,6 @@ import com.github.triplet.gradle.play.tasks.internal.PublishTaskBase import com.github.triplet.gradle.play.tasks.internal.PublishableTrackLifecycleTask import com.github.triplet.gradle.play.tasks.internal.UpdatableTrackLifecycleTask import com.github.triplet.gradle.play.tasks.internal.WriteTrackLifecycleTask -import org.gradle.api.DomainObjectSet import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.Task @@ -57,15 +56,12 @@ import org.gradle.api.services.BuildServiceRegistration import org.gradle.build.event.BuildEventsListenerRegistry import org.gradle.kotlin.dsl.container import org.gradle.kotlin.dsl.create -import org.gradle.kotlin.dsl.domainObjectSet import org.gradle.kotlin.dsl.findPlugin import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.invoke -import org.gradle.kotlin.dsl.mapProperty import org.gradle.kotlin.dsl.named import org.gradle.kotlin.dsl.newInstance import org.gradle.kotlin.dsl.registerIfAbsent -import org.gradle.kotlin.dsl.setProperty import org.gradle.kotlin.dsl.withType import javax.inject.Inject @@ -265,6 +261,8 @@ internal abstract class PlayPublisherPlugin @Inject constructor( if (!priorityProp.isPresent || newPriority < priorityProp.get()) { parameters.credentials.set(extension.serviceAccountCredentials) + parameters.useApplicationDefaultCredentials.set(extension.useApplicationDefaultCredentials) + parameters.impersonateServiceAccount.set(extension.impersonateServiceAccount) priorityProp.set(newPriority) } } diff --git a/play/plugin/src/main/kotlin/com/github/triplet/gradle/play/tasks/internal/PlayApiService.kt b/play/plugin/src/main/kotlin/com/github/triplet/gradle/play/tasks/internal/PlayApiService.kt index ab0c9c73c..2450ab0f7 100644 --- a/play/plugin/src/main/kotlin/com/github/triplet/gradle/play/tasks/internal/PlayApiService.kt +++ b/play/plugin/src/main/kotlin/com/github/triplet/gradle/play/tasks/internal/PlayApiService.kt @@ -17,7 +17,6 @@ import org.gradle.api.services.BuildServiceParameters import org.gradle.tooling.events.FinishEvent import org.gradle.tooling.events.OperationCompletionListener import org.gradle.tooling.events.task.TaskFailureResult -import org.gradle.tooling.events.task.TaskFinishEvent import java.io.ByteArrayInputStream import java.io.InputStream import javax.inject.Inject @@ -26,8 +25,32 @@ internal abstract class PlayApiService @Inject constructor( private val fileOps: FileSystemOperations, ) : BuildService, OperationCompletionListener, AutoCloseable { val publisher by lazy { - credentialStream().use { - PlayPublisher(it, parameters.appId.get()) + val useAppDefaultCreds = parameters.useApplicationDefaultCredentials.getOrElse(false) + val useExplicitCreds = (parameters.credentials.isPresent || + System.getenv(PlayPublisher.CREDENTIAL_ENV_VAR) != null) + + if (useAppDefaultCreds && useExplicitCreds) { + error(""" + |Cannot use both application default credentials and explicit credentials. + |Please read our docs for more details: + |https://github.com/Triple-T/gradle-play-publisher#authenticating-gradle-play-publisher + """.trimMargin()) + } + + if (useExplicitCreds && parameters.impersonateServiceAccount.isPresent) { + error(""" + |Service Account impersonation with explicit credentials is currently not supported. + |Please read our docs for more details: + |https://github.com/Triple-T/gradle-play-publisher#authenticating-gradle-play-publisher + """.trimMargin()) + } + + if (useAppDefaultCreds) { + PlayPublisher(parameters.appId.get(), parameters.impersonateServiceAccount.getOrNull()) + } else { + credentialStream().use { + PlayPublisher(it, parameters.appId.get()) + } } } val edits by lazy { @@ -147,6 +170,8 @@ internal abstract class PlayApiService @Inject constructor( interface Params : BuildServiceParameters { val appId: Property val credentials: RegularFileProperty + val useApplicationDefaultCredentials: Property + val impersonateServiceAccount: Property val editIdFile: RegularFileProperty @Suppress("PropertyName") // Don't use this diff --git a/play/plugin/src/test/kotlin/com/github/triplet/gradle/play/PlayPublisherExtensionTest.kt b/play/plugin/src/test/kotlin/com/github/triplet/gradle/play/PlayPublisherExtensionTest.kt index 7d8eec4d3..fbbe383be 100644 --- a/play/plugin/src/test/kotlin/com/github/triplet/gradle/play/PlayPublisherExtensionTest.kt +++ b/play/plugin/src/test/kotlin/com/github/triplet/gradle/play/PlayPublisherExtensionTest.kt @@ -158,6 +158,8 @@ class PlayPublisherExtensionTest { override val versionCode: Property get() = project.objects.property() override val enabled: Property = project.objects.property() override val serviceAccountCredentials: RegularFileProperty = project.objects.fileProperty() + override val useApplicationDefaultCredentials: Property = project.objects.property() + override val impersonateServiceAccount: Property = project.objects.property() override val defaultToAppBundles: Property = project.objects.property() override val commit: Property = project.objects.property() override val fromTrack: Property = project.objects.property() diff --git a/play/plugin/src/test/kotlin/com/github/triplet/gradle/play/PlayPublisherPluginIntegrationTest.kt b/play/plugin/src/test/kotlin/com/github/triplet/gradle/play/PlayPublisherPluginIntegrationTest.kt index a0bd1aa9a..74503f5f2 100644 --- a/play/plugin/src/test/kotlin/com/github/triplet/gradle/play/PlayPublisherPluginIntegrationTest.kt +++ b/play/plugin/src/test/kotlin/com/github/triplet/gradle/play/PlayPublisherPluginIntegrationTest.kt @@ -230,6 +230,196 @@ class PlayPublisherPluginIntegrationTest : IntegrationTestBase() { } } + @Test + fun `Application Default Credentials and Service Account Impersonation works`() { + // language=gradle + File(appDir, "build.gradle").writeText(""" + import com.github.triplet.gradle.play.tasks.internal.PlayApiService + + plugins { + id 'com.android.application' + id 'com.github.triplet.play' + } + + android { + compileSdk 34 + namespace = "com.example.publisher" + + defaultConfig { + applicationId "com.example.publisher" + minSdk 31 + targetSdk 33 + versionCode 1 + versionName "1.0" + } + } + + task usePublisher { + doLast { + def service = gradle.sharedServices.registrations + .named("playApi-com.example.publisher") + .get().service.get() as PlayApiService + + service.publisher + } + } + + play { + useApplicationDefaultCredentials = true + impersonateServiceAccount = "someaccount@project.com" + } + + $factoryInstallerStatement + """) + + executeGradle(false) { + withArguments("usePublisher") + withEnvironment(mapOf("GOOGLE_APPLICATION_CREDENTIALS" to "fake-creds")) + } + } + + @Test + fun `Application Default Credentials without Service Account Impersonation works`() { + // language=gradle + File(appDir, "build.gradle").writeText(""" + import com.github.triplet.gradle.play.tasks.internal.PlayApiService + + plugins { + id 'com.android.application' + id 'com.github.triplet.play' + } + + android { + compileSdk 34 + namespace = "com.example.publisher" + + defaultConfig { + applicationId "com.example.publisher" + minSdk 31 + targetSdk 33 + versionCode 1 + versionName "1.0" + } + } + + task usePublisher { + doLast { + def service = gradle.sharedServices.registrations + .named("playApi-com.example.publisher") + .get().service.get() as PlayApiService + + service.publisher + } + } + + play { + useApplicationDefaultCredentials = true + } + + $factoryInstallerStatement + """) + + executeGradle(false) { + withArguments("usePublisher") + withEnvironment(mapOf("GOOGLE_APPLICATION_CREDENTIALS" to "fake-creds")) + } + } + + @Test + fun `Fails if Application Default Credentials and ServiceAccountCredentials both set`() { + // language=gradle + File(appDir, "build.gradle").writeText(""" + import com.github.triplet.gradle.play.tasks.internal.PlayApiService + + plugins { + id 'com.android.application' + id 'com.github.triplet.play' + } + + android { + compileSdk 34 + namespace = "com.example.publisher" + + defaultConfig { + applicationId "com.example.publisher" + minSdk 31 + targetSdk 33 + versionCode 1 + versionName "1.0" + } + } + + task usePublisher { + doLast { + def service = gradle.sharedServices.registrations + .named("playApi-com.example.publisher") + .get().service.get() as PlayApiService + + service.publisher + } + } + + play { + useApplicationDefaultCredentials = true + serviceAccountCredentials.set(file('creds.json')) + } + + $factoryInstallerStatement + """) + + executeGradle(true) { + withArguments("usePublisher") + withEnvironment(mapOf("GOOGLE_APPLICATION_CREDENTIALS" to "fake-creds")) + } + } + + @Test + fun `Fails if Application Default Credentials not set and Impersonate Service Account set`() { + // language=gradle + File(appDir, "build.gradle").writeText(""" + import com.github.triplet.gradle.play.tasks.internal.PlayApiService + + plugins { + id 'com.android.application' + id 'com.github.triplet.play' + } + + android { + compileSdk 34 + namespace = "com.example.publisher" + + defaultConfig { + applicationId "com.example.publisher" + minSdk 31 + targetSdk 33 + versionCode 1 + versionName "1.0" + } + } + + task usePublisher { + doLast { + def service = gradle.sharedServices.registrations + .named("playApi-com.example.publisher") + .get().service.get() as PlayApiService + + service.publisher + } + } + + play { + serviceAccountCredentials.set(file('creds.json')) + impersonateServiceAccount = "someaccount@project.com" + } + + $factoryInstallerStatement + """) + + executeGradle(true) { + withArguments("usePublisher") + } + } + @Test fun `Variant specific lifecycle task publishes APKs by default`() { val result = execute("", "publishReleaseApps", "--dry-run")