Skip to content

Commit

Permalink
Add Application Default Credentials & Service Account impersonation (#…
Browse files Browse the repository at this point in the history
…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 <[email protected]>
  • Loading branch information
a-mackay and pauldthomson authored Nov 12, 2024
1 parent a8e7257 commit c33d4d5
Show file tree
Hide file tree
Showing 11 changed files with 307 additions and 7 deletions.
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "[email protected]" // 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions play/plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ dependencies {
testImplementation(testLibs.junit.engine)
testImplementation(testLibs.junit.params)
testImplementation(testLibs.truth)
testImplementation(testLibs.mockito)
}

tasks.withType<PluginUnderTestMetadata>().configureEach {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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<Boolean>

/**
* Specify the Service Account to impersonate
*/
@get:Optional
@get:Input
abstract val impersonateServiceAccount: Property<String>

/**
* Choose the default packaging method. Either App Bundles or APKs. Affects tasks like
* `publish`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,8 +25,32 @@ internal abstract class PlayApiService @Inject constructor(
private val fileOps: FileSystemOperations,
) : BuildService<PlayApiService.Params>, 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 {
Expand Down Expand Up @@ -147,6 +170,8 @@ internal abstract class PlayApiService @Inject constructor(
interface Params : BuildServiceParameters {
val appId: Property<String>
val credentials: RegularFileProperty
val useApplicationDefaultCredentials: Property<Boolean>
val impersonateServiceAccount: Property<String>
val editIdFile: RegularFileProperty

@Suppress("PropertyName") // Don't use this
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ class PlayPublisherExtensionTest {
override val versionCode: Property<Long> get() = project.objects.property()
override val enabled: Property<Boolean> = project.objects.property()
override val serviceAccountCredentials: RegularFileProperty = project.objects.fileProperty()
override val useApplicationDefaultCredentials: Property<Boolean> = project.objects.property()
override val impersonateServiceAccount: Property<String> = project.objects.property()
override val defaultToAppBundles: Property<Boolean> = project.objects.property()
override val commit: Property<Boolean> = project.objects.property()
override val fromTrack: Property<String> = project.objects.property()
Expand Down
Loading

0 comments on commit c33d4d5

Please sign in to comment.