diff --git a/docs/README.md b/docs/README.md index f9e42f4c..c96a9ea6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -169,7 +169,7 @@ node { ### SSH User Private Key -An SSH *private key*, with a *username*. +An SSH *Private Key*, with a *Username*. - Value: *private key* - Tags: @@ -293,6 +293,80 @@ node { } ``` +#### Github App Credentials (Optional) + +Requires *git-source-branch* plugin to create this credential type + +A github *private key*, with a *github app id*. + +- Value: *content* +- Tags: + - `jenkins:credentials:type` = `githubApp` + - `jenkins:credentials:appid` = *Github App Id* + +The private key format used is PKCS#8 (starts with `-----BEGIN PRIVATE KEY-----`). + +##### Example + +AWS CLI: + +```bash +openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in pkcs1.key -out pkcs8.key +aws secretsmanager create-secret --name 'githubapp' --secret-string 'file://pkcs8.key' --tags 'Key=jenkins:credentials:type,Value=githubApp' 'Key=jenkins:credentials:appid,Value=11111' --description 'Github App Credentials' +``` + +Declarative Pipeline: + +```groovy +pipeline { + agent any + environment { + GITHUB_APP = credentials('githubapp') + } + stages { + stage('Example') { + steps { + echo 'Hello world' + } + } + } +} +``` + +Scripted Pipeline: + +```groovy +node { + withCredentials([usernamePassword(credentialsId: 'githubapp', usernameVariable: 'GITHUBAPP_USR', passwordVariable: 'GITHUBAPP_PSW')]) { + echo 'Hello world' + } +} +``` + +### SecretSource + +The plugin allows JCasC to interpolate string secrets from Secrets Manager. + +#### Example + +AWS CLI: + +```bash +aws secretsmanager create-secret --name 'my-password' --secret-string 'abc123' --description 'Jenkins user password' +``` + +JCasC: + +```yaml +jenkins: + securityRealm: + local: + allowsSignup: false + users: + - id: "foo" + password: "${my-password}" +``` + ## Advanced Usage You may need to deal with multi-field credentials or vendor-specific credential types that the plugin does not (yet) support. diff --git a/pom.xml b/pom.xml index 7d67027e..be8dd77b 100644 --- a/pom.xml +++ b/pom.xml @@ -130,6 +130,18 @@ 1.21 test + + org.jmockit + jmockit + 1.41 + test + + + org.jenkins-ci.plugins + github-branch-source + 2.9.3 + true + @@ -146,6 +158,11 @@ + + + -javaagent:"${settings.localRepository}"/org/jmockit/jmockit/1.41/jmockit-1.41.jar + + org.apache.maven.plugins diff --git a/src/main/java/io/jenkins/plugins/credentials/secretsmanager/factory/CredentialsFactory.java b/src/main/java/io/jenkins/plugins/credentials/secretsmanager/factory/CredentialsFactory.java index 7dd01198..df6488a5 100644 --- a/src/main/java/io/jenkins/plugins/credentials/secretsmanager/factory/CredentialsFactory.java +++ b/src/main/java/io/jenkins/plugins/credentials/secretsmanager/factory/CredentialsFactory.java @@ -12,6 +12,7 @@ import io.jenkins.plugins.credentials.secretsmanager.Messages; import io.jenkins.plugins.credentials.secretsmanager.factory.certificate.AwsCertificateCredentials; import io.jenkins.plugins.credentials.secretsmanager.factory.file.AwsFileCredentials; +import io.jenkins.plugins.credentials.secretsmanager.factory.git_app.GitCredentialFactory; import io.jenkins.plugins.credentials.secretsmanager.factory.ssh_user_private_key.AwsSshUserPrivateKey; import io.jenkins.plugins.credentials.secretsmanager.factory.string.AwsStringCredentials; import io.jenkins.plugins.credentials.secretsmanager.factory.username_password.AwsUsernamePasswordCredentials; @@ -40,6 +41,7 @@ public static Optional create(String arn, String name, Stri final String type = tags.getOrDefault(Tags.type, ""); final String username = tags.getOrDefault(Tags.username, ""); final String filename = tags.getOrDefault(Tags.filename, name); + final String appId = tags.getOrDefault(Tags.appid, ""); switch (type) { case Type.string: @@ -52,6 +54,8 @@ public static Optional create(String arn, String name, Stri return Optional.of(new AwsCertificateCredentials(name, description, new SecretBytesSupplier(client, arn))); case Type.file: return Optional.of(new AwsFileCredentials(name, description, filename, new SecretBytesSupplier(client, arn))); + case Type.githubApp: + return GitCredentialFactory.createCredential(name, description, appId, new StringSupplier(client, name)); default: return Optional.empty(); } diff --git a/src/main/java/io/jenkins/plugins/credentials/secretsmanager/factory/Tags.java b/src/main/java/io/jenkins/plugins/credentials/secretsmanager/factory/Tags.java index 202e739a..bae8fac2 100644 --- a/src/main/java/io/jenkins/plugins/credentials/secretsmanager/factory/Tags.java +++ b/src/main/java/io/jenkins/plugins/credentials/secretsmanager/factory/Tags.java @@ -9,6 +9,7 @@ public abstract class Tags { public static final String filename = namespace + "filename"; public static final String type = namespace + "type"; public static final String username = namespace + "username"; + public static final String appid = namespace + "appid"; private Tags() { diff --git a/src/main/java/io/jenkins/plugins/credentials/secretsmanager/factory/Type.java b/src/main/java/io/jenkins/plugins/credentials/secretsmanager/factory/Type.java index 75322c32..e461fc39 100644 --- a/src/main/java/io/jenkins/plugins/credentials/secretsmanager/factory/Type.java +++ b/src/main/java/io/jenkins/plugins/credentials/secretsmanager/factory/Type.java @@ -9,6 +9,7 @@ public abstract class Type { public static final String usernamePassword = "usernamePassword"; public static final String sshUserPrivateKey = "sshUserPrivateKey"; public static final String string = "string"; + public static final String githubApp = "githubApp"; private Type() { diff --git a/src/main/java/io/jenkins/plugins/credentials/secretsmanager/factory/git_app/GitCredentialFactory.java b/src/main/java/io/jenkins/plugins/credentials/secretsmanager/factory/git_app/GitCredentialFactory.java new file mode 100644 index 00000000..c24b6df2 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/credentials/secretsmanager/factory/git_app/GitCredentialFactory.java @@ -0,0 +1,29 @@ +package io.jenkins.plugins.credentials.secretsmanager.factory.git_app; + +import com.cloudbees.plugins.credentials.CredentialsScope; +import com.cloudbees.plugins.credentials.common.StandardCredentials; +import hudson.Extension; +import hudson.util.Secret; +import io.jenkins.plugins.credentials.secretsmanager.factory.Type; +import jenkins.model.Jenkins; +import org.jenkinsci.plugins.github_branch_source.GitHubAppCredentials; + +import java.util.function.Supplier; +import java.util.Optional; +import java.util.logging.Logger; + +@Extension(optional = true) +public class GitCredentialFactory { + private static final Logger LOG = Logger.getLogger(GitCredentialFactory.class.getName()); + + public static Optional createCredential(String name, String description, String appId, Supplier privateKey) { + if (Jenkins.get().getPlugin("github-branch-source") == null) { + LOG.warning("Plugin not installed: github-branch-source. Cannot create type: " + Type.githubApp); + return Optional.empty(); + } + + Secret secret = Secret.fromString(privateKey.get()); + + return Optional.of(new GitHubAppCredentials(CredentialsScope.GLOBAL, name, description, appId, secret)); + } +} diff --git a/src/test/java/io/jenkins/plugins/credentials/secretsmanager/GithubAppCredentialsIT.java b/src/test/java/io/jenkins/plugins/credentials/secretsmanager/GithubAppCredentialsIT.java new file mode 100644 index 00000000..1af6ca5b --- /dev/null +++ b/src/test/java/io/jenkins/plugins/credentials/secretsmanager/GithubAppCredentialsIT.java @@ -0,0 +1,227 @@ +package io.jenkins.plugins.credentials.secretsmanager; + +import com.amazonaws.services.secretsmanager.model.*; +import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey; +import com.cloudbees.plugins.credentials.CredentialsScope; +import com.cloudbees.plugins.credentials.common.StandardCredentials; +import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials; +import hudson.util.ListBoxModel; +import hudson.util.Secret; +import io.jenkins.plugins.casc.misc.ConfiguredWithCode; +import io.jenkins.plugins.credentials.secretsmanager.factory.Type; +import io.jenkins.plugins.credentials.secretsmanager.util.*; +import mockit.Mock; +import mockit.MockUp; +import mockit.integration.junit4.JMockit; +import org.jenkinsci.plugins.github_branch_source.GitHubAppCredentials; +import org.jenkinsci.plugins.workflow.job.WorkflowRun; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestRule; +import org.junit.runner.RunWith; + +import java.util.*; + +import static io.jenkins.plugins.credentials.secretsmanager.util.assertions.CustomAssertions.assertThat; +import static org.junit.Assume.assumeTrue; + +@RunWith(JMockit.class) +public class GithubAppCredentialsIT implements CredentialsTests { + + private static final String APP_ID = "11111"; + private static final String PRIVATE_KEY = Crypto.newPrivateKey() + .replace("-----BEGIN RSA PRIVATE KEY-----", "") + .replace("-----END RSA PRIVATE KEY-----", "");; + + public final MyJenkinsConfiguredWithCodeRule jenkins = new MyJenkinsConfiguredWithCodeRule(); + public final AWSSecretsManagerRule secretsManager = new AWSSecretsManagerRule(); + + @Rule + public final TestRule chain = Rules.jenkinsWithSecretsManager(jenkins, secretsManager); + + @BeforeClass + public static void checkClassExists() { + Optional clazz = getGithubAppCredentialClass(); + assumeTrue(clazz.isPresent()); + } + + @Test + @ConfiguredWithCode(value = "/integration.yml") + public void shouldSupportListView() { + // Given + final CreateSecretResult foo = createGitHubAppCredentialSecret(APP_ID, PRIVATE_KEY); + + // When + final ListBoxModel list = jenkins.getCredentials().list(StandardCredentials.class); + + // Then + assertThat(list) + .containsOption(foo.getName(), foo.getName()); + } + + @Test + @ConfiguredWithCode(value = "/integration.yml") + public void shouldHaveDescriptorIcon() { + final CreateSecretResult foo = createGitHubAppCredentialSecret(APP_ID, PRIVATE_KEY); + final GitHubAppCredentials ours = lookup(GitHubAppCredentials.class, foo.getName()); + + final GitHubAppCredentials theirs = new GitHubAppCredentials(CredentialsScope.GLOBAL, "name", "description", "11111", Secret.fromString("secret")); + + assertThat(ours) + .hasSameDescriptorIconAs(theirs); + } + + @Test + @ConfiguredWithCode(value = "/integration.yml") + public void shouldSupportWithCredentialsBinding() { + // Given + final CreateSecretResult foo = createGitHubAppCredentialSecret(APP_ID, PRIVATE_KEY); + + new MockUp() { + @Mock + public Secret getPassword() { + return Secret.fromString("test-token"); + } + }; + + // When + final WorkflowRun run = runPipeline("", + "withCredentials([usernamePassword(credentialsId: '" + foo.getName() + "', usernameVariable: 'USR', passwordVariable: 'PSW')]) {", + " echo \"Credential: {username: $USR, password: $PSW}\"", + "}"); + + // Then + assertThat(run) + .hasResult(hudson.model.Result.SUCCESS) + .hasLogContaining("Credential: {username: ****, password: ****}"); + } + + @Test + @ConfiguredWithCode(value = "/integration.yml") + public void shouldSupportEnvironmentBinding(){ + // Given + final CreateSecretResult foo = createGitHubAppCredentialSecret(APP_ID, PRIVATE_KEY); + + new MockUp() { + @Mock + public Secret getPassword() { + return Secret.fromString("test-token"); + } + }; + + // When + final WorkflowRun run = runPipeline("", + "pipeline {", + " agent none", + " stages {", + " stage('Example') {", + " environment {", + " FOO = credentials('" + foo.getName() + "')", + " }", + " steps {", + " echo \"{variable: $FOO, username: $FOO_USR, password: $FOO_PSW}\"", + " }", + " }", + " }", + "}"); + + // Then + assertThat(run) + .hasResult(hudson.model.Result.SUCCESS) + .hasLogContaining("{variable: ****, username: ****, password: ****}"); + } + + @Test + @ConfiguredWithCode(value = "/integration.yml") + public void shouldSupportSnapshots() { + + // Given + final CreateSecretResult foo = createGitHubAppCredentialSecret(APP_ID, PRIVATE_KEY); + final GitHubAppCredentials before = jenkins.getCredentials().lookup(GitHubAppCredentials.class, foo.getName()); + + // When + final GitHubAppCredentials after = CredentialSnapshots.snapshot(before); + + // Then + assertThat(after) + .hasUsername(before.getUsername()) + .hasPrivateKey(before.getPrivateKey()) + .hasId(before.getId()); + } + + @Test + @ConfiguredWithCode(value = "/integration.yml") + public void shouldHaveId() { + // Given + final CreateSecretResult foo = createGitHubAppCredentialSecret(APP_ID, PRIVATE_KEY); + + // When + final GitHubAppCredentials credential = + jenkins.getCredentials().lookup(GitHubAppCredentials.class, foo.getName()); + + // Then + assertThat(credential) + .hasId(foo.getName()); + } + + @Test + @ConfiguredWithCode(value = "/integration.yml") + public void shouldHaveUsername() { + // Given + final CreateSecretResult foo = createGitHubAppCredentialSecret(APP_ID, PRIVATE_KEY); + + // When + final StandardUsernamePasswordCredentials credential = + jenkins.getCredentials().lookup(GitHubAppCredentials.class, foo.getName()); + + // Then + assertThat(credential) + .hasUsername(APP_ID); + } + + @Test + @ConfiguredWithCode(value = "/integration.yml") + public void shouldHavePrivateKey() { + // Given + final CreateSecretResult foo = createGitHubAppCredentialSecret(APP_ID, PRIVATE_KEY); + + // When + final GitHubAppCredentials credential = lookup(GitHubAppCredentials.class, foo.getName()); + + // Then + assertThat(credential) + .hasPrivateKey(PRIVATE_KEY); + } + + private CreateSecretResult createGitHubAppCredentialSecret(String appId, String privateKey) { + final List tags = Lists.of( + AwsTags.type(Type.githubApp), + AwsTags.appid(appId)); + + final CreateSecretRequest request = new CreateSecretRequest() + .withName(CredentialNames.random()) + .withSecretString(privateKey) + .withTags(tags); + + return secretsManager.getClient().createSecret(request); + } + + private WorkflowRun runPipeline(String... pipeline) { + return jenkins.getPipelines().run(Strings.m(pipeline)); + } + + private C lookup(Class type, String id) { + return jenkins.getCredentials().lookup(type, id); + } + + private static Optional getGithubAppCredentialClass() { + Class githubCredentials; + try { + githubCredentials = Class.forName("org.jenkinsci.plugins.github_branch_source.GitHubAppCredentials"); + } catch (Throwable ex) { + return Optional.empty(); + } + return Optional.of(githubCredentials); + } +} diff --git a/src/test/java/io/jenkins/plugins/credentials/secretsmanager/util/AwsTags.java b/src/test/java/io/jenkins/plugins/credentials/secretsmanager/util/AwsTags.java index 7f5e1db7..7cf1b960 100644 --- a/src/test/java/io/jenkins/plugins/credentials/secretsmanager/util/AwsTags.java +++ b/src/test/java/io/jenkins/plugins/credentials/secretsmanager/util/AwsTags.java @@ -19,6 +19,10 @@ public static Tag username(String username) { return AwsTags.tag(Tags.username, username); } + public static Tag appid(String id) { + return AwsTags.tag(Tags.appid, id); + } + public static Tag type(String type) { return tag(Tags.type, type); } diff --git a/src/test/java/io/jenkins/plugins/credentials/secretsmanager/util/PagedIterableImpl.java b/src/test/java/io/jenkins/plugins/credentials/secretsmanager/util/PagedIterableImpl.java new file mode 100644 index 00000000..ad8dcbae --- /dev/null +++ b/src/test/java/io/jenkins/plugins/credentials/secretsmanager/util/PagedIterableImpl.java @@ -0,0 +1,27 @@ +package io.jenkins.plugins.credentials.secretsmanager.util; + +import org.kohsuke.github.*; + +import javax.annotation.Nonnull; +import java.io.IOException; +import java.util.Iterator; +import java.util.function.Consumer; + +public class PagedIterableImpl extends PagedIterable { + + private final Class receiverType; + private final Consumer itemInitializer; + private final Iterator iterator; + + public PagedIterableImpl(Class receiverType, Consumer itemInitializer, Iterator iterator) { + this.receiverType = receiverType; + this.itemInitializer = itemInitializer; + this.iterator = iterator; + } + + @Nonnull + public PagedIterator _iterator(int pageSize) { + return null; + } + +} diff --git a/src/test/java/io/jenkins/plugins/credentials/secretsmanager/util/assertions/CustomAssertions.java b/src/test/java/io/jenkins/plugins/credentials/secretsmanager/util/assertions/CustomAssertions.java index 63a92396..83ff4bbd 100644 --- a/src/test/java/io/jenkins/plugins/credentials/secretsmanager/util/assertions/CustomAssertions.java +++ b/src/test/java/io/jenkins/plugins/credentials/secretsmanager/util/assertions/CustomAssertions.java @@ -5,6 +5,7 @@ import com.cloudbees.plugins.credentials.common.StandardCredentials; import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials; import hudson.util.ListBoxModel; +import org.jenkinsci.plugins.github_branch_source.GitHubAppCredentials; import org.jenkinsci.plugins.plaincredentials.FileCredentials; import org.jenkinsci.plugins.plaincredentials.StringCredentials; import org.jenkinsci.plugins.workflow.job.WorkflowRun; @@ -42,6 +43,10 @@ public static SSHUserPrivateKeyAssert assertThat(SSHUserPrivateKey actual) { return new SSHUserPrivateKeyAssert(actual); } + public static GithubAppCredentialsAssert assertThat(GitHubAppCredentials actual) { + return new GithubAppCredentialsAssert(actual); + } + public static WorkflowRunAssert assertThat(WorkflowRun actual) { return new WorkflowRunAssert(actual); } diff --git a/src/test/java/io/jenkins/plugins/credentials/secretsmanager/util/assertions/GithubAppCredentialsAssert.java b/src/test/java/io/jenkins/plugins/credentials/secretsmanager/util/assertions/GithubAppCredentialsAssert.java new file mode 100644 index 00000000..3a71e04a --- /dev/null +++ b/src/test/java/io/jenkins/plugins/credentials/secretsmanager/util/assertions/GithubAppCredentialsAssert.java @@ -0,0 +1,62 @@ +package io.jenkins.plugins.credentials.secretsmanager.util.assertions; + +import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials; +import hudson.util.Secret; +import org.assertj.core.api.AbstractAssert; +import org.jenkinsci.plugins.github_branch_source.GitHubAppCredentials; + +import java.util.Objects; + +public class GithubAppCredentialsAssert extends AbstractAssert { + + public GithubAppCredentialsAssert(GitHubAppCredentials actual) { + super(actual, GithubAppCredentialsAssert.class); + } + + public GithubAppCredentialsAssert hasUsername(String username) { + isNotNull(); + + if (!Objects.equals(actual.getUsername(), username)) { + failWithMessage("Expected username to be <%s> but was <%s>", username, actual.getUsername()); + } + + return this; + } + + public GithubAppCredentialsAssert hasPrivateKey(String privateKey) { + return hasPrivateKey(Secret.fromString(privateKey)); + } + + public GithubAppCredentialsAssert hasPrivateKey(Secret privateKey) { + isNotNull(); + + if (!Objects.equals(actual.getPrivateKey(), privateKey)) { + failWithMessage("Expected private keys to be <%s> but was <%s>", privateKey, actual.getPrivateKey()); + } + + return this; + } + + public GithubAppCredentialsAssert hasAppId(String appId) { + isNotNull(); + + if (!Objects.equals(actual.getAppID(), appId)) { + failWithMessage("Expected App Id to be <%s> but was <%s>", appId, actual.getAppID()); + } + + return this; + } + + public GithubAppCredentialsAssert hasSameDescriptorIconAs(StandardUsernamePasswordCredentials theirs) { + new StandardCredentialsAssert(actual).hasSameDescriptorIconAs(theirs); + + return this; + } + + public GithubAppCredentialsAssert hasId(String id) { + new StandardCredentialsAssert(actual).hasId(id); + + return this; + } + +}