diff --git a/docs/github-app.adoc b/docs/github-app.adoc index 439fe8bd0..1010beb26 100644 --- a/docs/github-app.adoc +++ b/docs/github-app.adoc @@ -87,8 +87,9 @@ Fill out the form: - App ID: the github app ID, it can be found in the 'About' section of your GitHub app in the general tab. - API endpoint (optional, only required for GitHub enterprise this will only show up if a GitHub enterprise server is configured). - Key: click add, paste the contents of the converted private key -- Advanced: (optional) If you've installed your same GitHub app on multiple organizations you need the next step - * Owner: the name of the organisation or user, i.e. jenkinsci for https://github.com/jenkinsci +- Advanced: (optional): + * Repository access strategy: Controls what GitHub repositories will be accessible to these credentials in untrusted contexts (see below for details) + * Default permissions strategy: Controls what GitHub permissions will be accessible to these credentials in untrusted contexts (see below for details) - Click OK === link:https://github.com/jenkinsci/configuration-as-code-plugin[Configuration as Code Plugin] @@ -135,6 +136,42 @@ Verify at the bottom of the scan log it says: Finished: SUCCESS ---- +=== Enhancing security using repository access strategies and default permissions strategies + +GitHub App Credentials offer advanced configuration options that can provide additional security in some scenarios. +In particular, when GitHub App Credentials are used by an Organization Folder or Multibranch Pipeline, these strategies may dynamically restrict the accessible repositories and permissions available to the credentials when they are accessed in untrusted contexts, such as when they are accessed by a `withCredentials` step in one of the individual Pipeline jobs. +See TODO for additional information on why it may be beneficial to limit credentials in this way. +These strategies do not apply when using the credentials in trusted contexts, such as during organization folder scans and branch indexing. +Note also that Jenkins users who have Job/Configure permission in a context where the credentials are available are considered trusted and can bypass these strategies by configuring jobs as desired. +In trusted contexts, the generated access tokens will have the same access as configured for the app installation in GitHub. + +The following repository access strategies are available: +* **Infer owner and allow access to all owned repositories** (Default) + * The credentials may only be used in contexts where a GitHub organization can be inferred, such as Organization Folders and Multibranch Pipelines + * The access tokens generated in untrusted contexts will be able to access all repositories in the inferred GitHub organization that are accessible to the GitHub App installation. +* **Infer accessible repository** + * The credentials may only be used in contexts where a GitHub organization and repository can be inferred, such as Organization Folders and Multibranch Pipelines + * The access tokens generated in untrusted contexts will only be able to access the inferred repository +* **Specify accessible repositories** + * The access tokens generated in untrusted contexts will be able to access the repositories specified statically in the credential configuration + * If the GitHub app is installed in a single organization, the owner field may be left blank empty, in which case that organization will be accessed automatically + * Leaving the repositories field empty will result in all repositories accessible to the configured owner being accessible + +The following default permissions strategies are available: +* Read-only access to repository contents + * The access tokens generated in untrusted contexts will only be able to read the repository contents +* Read and write access to repository contents + * The access tokens generated in untrusted contexts will only be able to read and write the repository contents +* All permissions available to the app installation (default) + * The access tokens generated in untrusted contexts will have the same permissions as the app installation in GitHub + +==== Repository access strategies and Pipeline libraries + +Repository inference for GitHub App Credentials does not work when checking out Pipeline libraries. +If you have a GitHub App Credential for an Organization Folder or Multibranch Pipeline whose individual Pipeline jobs access a Pipeline library, the contextually inferred repository for the library checkout will be the repository for the Pipeline job rather than the library. +This means that the library will be inaccessible if you use an inference-based repository access strategy which only provides access to a single contextually-inferred repository, or if the Pipeline library is in a different GitHub organization than the repository being built. +For now, in this case, you either need to use a less restrictive strategy for the GitHub App credential, such as "Infer owner and allow access to all owned repositories", or you can define a second credential specifically for the Pipeline library which uses "Specify accessible repositories" and only allows access to the repository for the Pipeline library. + === Help? Raise an issue on link:https://issues.jenkins-ci.org/[Jenkins jira] diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/Connector.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/Connector.java index 8bb1b338a..0973951c5 100644 --- a/src/main/java/org/jenkinsci/plugins/github_branch_source/Connector.java +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/Connector.java @@ -227,7 +227,7 @@ public static FormValidation checkScanCredentials( } finally { Connector.release(connector); } - } catch (IllegalArgumentException | InvalidPrivateKeyException e) { + } catch (IllegalArgumentException | IllegalStateException | InvalidPrivateKeyException e) { String msg = "Exception validating credentials " + CredentialsNameProvider.name(credentials); LOGGER.log(Level.WARNING, msg, e); return FormValidation.error(e, msg); @@ -261,8 +261,7 @@ public static StandardCredentials lookupScanCredentials( } /** - * Resolves the specified scan credentials in the specified context for use against the specified - * API endpoint. + * Retained for binary compatibility only. * * @param context the context. * @param apiUri the API endpoint. @@ -281,6 +280,9 @@ public static StandardCredentials lookupScanCredentials( * Resolves the specified scan credentials in the specified context for use against the specified * API endpoint. * + *

Callers of this method must not expose the credentials to unprivileged users for + * uncontrolled usage. + * * @param context the context. * @param apiUri the API endpoint. * @param scanCredentialsId the credentials to resolve. @@ -306,9 +308,20 @@ public static StandardCredentials lookupScanCredentials( githubDomainRequirements(apiUri)), CredentialsMatchers.allOf( CredentialsMatchers.withId(scanCredentialsId), githubScanCredentialsMatcher())); - if (c instanceof GitHubAppCredentials && repoOwner != null) { - c = ((GitHubAppCredentials) c).withOwner(repoOwner); + // Note: We considered adding an overload so that all existing callers in this plugin could + // specify an exact repository and granular permission, but decided against it. This method + // should only be called in contexts where the credential could not be exposed to users + // other than those who were able to create/configure whatever is using the credential in + // the first place. Those users would be able to steal the GitHub App refresh JWT, which + // they can then use to generate their own credentials, so dynamic limitations in this + // context have no benefits, and would unnecessarily increase the size of the connection + // cache because the cache keys are distinct for every context. + final var usageContext = GitHubAppUsageContext.builder() + .inferredOwner(repoOwner) + .trust() + .build(); + return ((GitHubAppCredentials) c).contextualize(usageContext); } return c; } @@ -368,12 +381,15 @@ public static ListBoxModel listCheckoutCredentials(@CheckForNull Item context, S password = null; gitHubAppCredentials = (GitHubAppCredentials) credentials; hash = Util.getDigestOf(gitHubAppCredentials.getAppID() - + gitHubAppCredentials.getOwner() + + gitHubAppCredentials.getAccessibleRepositories() + + gitHubAppCredentials.getPermissions() + gitHubAppCredentials.getPrivateKey().getPlainText() + SALT); // want to ensure pooling by credential authHash = Util.getDigestOf(gitHubAppCredentials.getAppID() + "::" - + gitHubAppCredentials.getOwner() + + gitHubAppCredentials.getAccessibleRepositories() + + "::" + + gitHubAppCredentials.getPermissions() + "::" + gitHubAppCredentials.getPrivateKey().getPlainText() + "::" diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials.java index 0c2927aa2..b4695ebca 100644 --- a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials.java +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials.java @@ -25,11 +25,12 @@ import java.security.GeneralSecurityException; import java.time.Duration; import java.time.Instant; -import java.util.HashMap; +import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; @@ -38,12 +39,22 @@ import jenkins.security.SlaveToMasterCallable; import jenkins.util.JenkinsJVM; import net.sf.json.JSONObject; +import org.apache.commons.lang3.StringUtils; +import org.jenkinsci.plugins.github_branch_source.app_credentials.AccessInferredOwner; +import org.jenkinsci.plugins.github_branch_source.app_credentials.AccessSpecifiedRepositories; +import org.jenkinsci.plugins.github_branch_source.app_credentials.AccessibleRepositories; +import org.jenkinsci.plugins.github_branch_source.app_credentials.DefaultPermissionsStrategy; +import org.jenkinsci.plugins.github_branch_source.app_credentials.MigrationAdminMonitor; +import org.jenkinsci.plugins.github_branch_source.app_credentials.RepositoryAccessStrategy; +import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner; import org.jenkinsci.plugins.workflow.support.concurrent.Timeout; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.github.GHApp; +import org.kohsuke.github.GHAppCreateTokenBuilder; import org.kohsuke.github.GHAppInstallation; import org.kohsuke.github.GHAppInstallationToken; +import org.kohsuke.github.GHPermissionType; import org.kohsuke.github.GitHub; import org.kohsuke.github.authorization.AuthorizationProvider; import org.kohsuke.github.extras.authorization.JWTTokenProvider; @@ -63,7 +74,7 @@ public class GitHubAppCredentials extends BaseStandardCredentials implements Sta private static final String ERROR_NOT_INSTALLED = ERROR_AUTHENTICATING_GITHUB_APP + NOT_INSTALLED; private static final String ERROR_NO_OWNER_MATCHING = "Found multiple installations for GitHub app ID %s but none match credential owner \"%s\". " - + "Set the right owner in the credential advanced options to one of: %s"; + + "Configure the repository access strategy for the credential to use one of these owners: %s"; /** * When a new {@link AppInstallationToken} is generated, wait this many seconds before continuing. @@ -74,6 +85,17 @@ public class GitHubAppCredentials extends BaseStandardCredentials implements Sta private static long AFTER_TOKEN_GENERATION_DELAY_SECONDS = Long.getLong(GitHubAppCredentials.class.getName() + ".AFTER_TOKEN_GENERATION_DELAY_SECONDS", 0); + /** + * Controls whether {@link GithubProjectProperty} is considered by {@link #forRun} for Pipeline builds. + *

{@link RepositoryAccessStrategy} is intended to prevent users with the ability to edit a Jenkinsfile in a + * single repository from being able to use GitHub app credentials available to that Pipeline to access other + * repositories. The existence of the {@code properties} step means that job properties may not be trusted for + * Pipeline repository inference. + */ + @SuppressFBWarnings(value = "MS_SHOULD_BE_FINAL", justification = "Non-final for modification from script console") + public static boolean ALLOW_UNSAFE_REPOSITORY_INFERENCE = + Boolean.getBoolean(GitHubAppCredentials.class.getName() + ".ALLOW_UNSAFE_REPOSITORY_INFERENCE"); + @NonNull private final String appID; @@ -83,14 +105,24 @@ public class GitHubAppCredentials extends BaseStandardCredentials implements Sta private String apiUri; @SuppressFBWarnings(value = "IS2_INCONSISTENT_SYNC", justification = "#withOwner locking only for #byOwner") + @Deprecated private String owner; + private RepositoryAccessStrategy repositoryAccessStrategy; + private DefaultPermissionsStrategy defaultPermissionsStrategy; + + @NonNull + private transient GitHubAppUsageContext context = new GitHubAppUsageContext(); + private transient AppInstallationToken cachedToken; /** - * Cache of credentials specialized by {@link #owner}, so that {@link #cachedToken} is preserved. + * Caches temporary instances of these credentials created for use in distinct contexts. + * + * @see #contextualize + * @see GitHubAppUsageContext */ - private transient Map byOwner; + private transient Map cachedCredentials = new ConcurrentHashMap<>(); @DataBoundConstructor @SuppressWarnings("unused") // by stapler @@ -103,6 +135,8 @@ public GitHubAppCredentials( super(scope, id, description); this.appID = appID; this.privateKey = privateKey; + this.repositoryAccessStrategy = new AccessInferredOwner(); + this.defaultPermissionsStrategy = DefaultPermissionsStrategy.INHERIT_ALL; } public String getApiUri() { @@ -124,23 +158,56 @@ public Secret getPrivateKey() { return privateKey; } - /** - * Owner of this installation, i.e. a user or organisation, used to differentiate app installations - * when the app is installed to multiple organisations / users. - * - *

If this is null then call listInstallations and if there's only one in the list then use - * that installation. - * - * @return the owner of the organisation or null. - */ + /** @deprecated Use {@link #getRepositoryAccessStrategy}. */ + @Deprecated @CheckForNull public String getOwner() { - return owner; + return null; } - @DataBoundSetter + // This method is not deprecated or restricted only to preserve compatibility with existing CasC YAML files. + /** Do not call this method, use {@link #setRepositoryAccessStrategy} instead. */ public void setOwner(String owner) { - this.owner = Util.fixEmpty(owner); + owner = Util.fixEmptyAndTrim(owner); + if (owner != null) { + this.repositoryAccessStrategy = new AccessSpecifiedRepositories(owner, List.of()); + } else { + this.repositoryAccessStrategy = new AccessInferredOwner(); + } + // We only expect this to be called by CasC and by a few plugins which implement variants of this class based on + // external credential providers, so we still count it as a migration. + MigrationAdminMonitor.addMigratedCredentialId(getId()); + } + + @NonNull + public RepositoryAccessStrategy getRepositoryAccessStrategy() { + return repositoryAccessStrategy; + } + + @DataBoundSetter + public void setRepositoryAccessStrategy(@NonNull RepositoryAccessStrategy strategy) { + this.repositoryAccessStrategy = strategy; + } + + @NonNull + public DefaultPermissionsStrategy getDefaultPermissionsStrategy() { + return defaultPermissionsStrategy; + } + + @DataBoundSetter + public void setDefaultPermissionsStrategy(@NonNull DefaultPermissionsStrategy strategy) { + this.defaultPermissionsStrategy = strategy; + } + + AccessibleRepositories getAccessibleRepositories() { + return repositoryAccessStrategy.forContext(context); + } + + Map getPermissions() { + if (context.getPermissions() != null) { + return context.getPermissions(); + } + return defaultPermissionsStrategy.getPermissions(); } @SuppressWarnings("deprecation") @@ -204,7 +271,13 @@ public String getEncodedAuthorization() throws IOException { @SuppressWarnings("deprecation") // preview features are required for GitHub app integration, GitHub api adds // deprecated to all preview methods static AppInstallationToken generateAppInstallationToken( - GitHub gitHubApp, String appId, String appPrivateKey, String apiUrl, String owner) { + GitHub gitHubApp, + String appId, + String appPrivateKey, + String apiUrl, + String owner, + List repositories, + Map permissions) { JenkinsJVM.checkJenkinsJVM(); // We expect this to be fast but if anything hangs in here we do not want to block indefinitely @@ -225,7 +298,8 @@ static AppInstallationToken generateAppInstallationToken( throw new IllegalArgumentException(String.format(ERROR_NOT_INSTALLED, appId)); } GHAppInstallation appInstallation; - if (appInstallations.size() == 1) { + if (StringUtils.isEmpty(owner) && appInstallations.size() == 1) { + // This case is only used when AccessSpecifiedRepositories.getOwner is empty. appInstallation = appInstallations.get(0); } else { final String ownerOrEmpty = owner != null ? owner : ""; @@ -246,17 +320,24 @@ static AppInstallationToken generateAppInstallationToken( appInstallation = appInstallationOptional.get(); } - GHAppInstallationToken appInstallationToken = appInstallation - .createToken(appInstallation.getPermissions()) - .create(); + GHAppCreateTokenBuilder builder = appInstallation.createToken(); + if (!repositories.isEmpty()) { + builder.repositories(repositories); + } + if (!permissions.isEmpty()) { + builder.permissions(permissions); + } else { + builder.permissions(appInstallation.getPermissions()); + } + GHAppInstallationToken appInstallationToken = builder.create(); long expiration = getExpirationSeconds(appInstallationToken); AppInstallationToken token = new AppInstallationToken(Secret.fromString(appInstallationToken.getToken()), expiration); - LOGGER.log(Level.FINER, "Generated App Installation Token for app ID {0}", appId); LOGGER.log( Level.FINEST, - () -> "Generated App Installation Token at " + Instant.now().toEpochMilli()); + "Generated App Installation Token for app ID {0} limited to {1}/{2} with permissions {3} at {4}", + new Object[] {appId, owner, repositories, permissions, Instant.now()}); if (AFTER_TOKEN_GENERATION_DELAY_SECONDS > 0) { // Delay can be up to 10 seconds. @@ -287,13 +368,33 @@ String actualApiUri() { return Util.fixEmpty(getApiUri()) == null ? "https://api.github.com" : getApiUri(); } + private static class InferredAccessibleRepositoriesException extends IllegalStateException { + + public InferredAccessibleRepositoriesException(final GitHubAppCredentials credentials) { + super("Cannot generate App Installation Token for app ID " + + credentials.getAppID() + + " because the accessible repositories could not be inferred. This is due to the repository access configuration for the credentials with ID: " + + credentials.getId()); + } + } + private AppInstallationToken getToken(GitHub gitHub) { synchronized (this) { try { if (cachedToken == null || cachedToken.isStale()) { LOGGER.log(Level.FINE, "Generating App Installation Token for app ID {0}", getAppID()); + final var accessibleRepositories = getAccessibleRepositories(); + if (accessibleRepositories == null) { + throw new InferredAccessibleRepositoriesException(this); + } cachedToken = generateAppInstallationToken( - gitHub, getAppID(), getPrivateKey().getPlainText(), actualApiUri(), getOwner()); + gitHub, + getAppID(), + getPrivateKey().getPlainText(), + actualApiUri(), + accessibleRepositories.getOwner(), + accessibleRepositories.getRepositories(), + getPermissions()); LOGGER.log(Level.FINER, "Retrieved GitHub App Installation Token for app ID {0}", getAppID()); } } catch (Exception e) { @@ -337,39 +438,45 @@ public boolean isUsernameSecret() { } @NonNull - public synchronized GitHubAppCredentials withOwner(@NonNull String owner) { - if (this.getOwner() != null) { - if (!owner.equals(this.getOwner())) { - throw new IllegalArgumentException("Owner mismatch: " + this.getOwner() + " vs. " + owner); - } - return this; - } - if (byOwner == null) { - byOwner = new HashMap<>(); - } - return byOwner.computeIfAbsent(owner, k -> { - GitHubAppCredentials clone = - new GitHubAppCredentials(getScope(), getId(), getDescription(), getAppID(), getPrivateKey()); - clone.apiUri = getApiUri(); - clone.owner = owner; - return clone; - }); + public GitHubAppCredentials contextualize(final GitHubAppUsageContext context) { + return cachedCredentials.computeIfAbsent(context, this::clone); + } + + @NonNull + private GitHubAppCredentials clone(final GitHubAppUsageContext context) { + final var clone = new GitHubAppCredentials(getScope(), getId(), getDescription(), getAppID(), getPrivateKey()); + clone.apiUri = getApiUri(); + clone.repositoryAccessStrategy = getRepositoryAccessStrategy(); + clone.defaultPermissionsStrategy = getDefaultPermissionsStrategy(); + clone.context = context; + return clone; } @NonNull @Override public Credentials forRun(Run context) { - if (getOwner() != null) { - return this; - } Job job = context.getParent(); SCMSource src = SCMSource.SourceByItem.findSource(job); if (src instanceof GitHubSCMSource) { - return withOwner(((GitHubSCMSource) src).getRepoOwner()); + GitHubSCMSource source = (GitHubSCMSource) src; + final var usageContext = GitHubAppUsageContext.builder() + .inferredOwner(source.getRepoOwner()) + .inferredRepository(source.getRepository()) + .permissions(defaultPermissionsStrategy.getPermissions()) + .build(); + return contextualize(usageContext); } + GitHubRepositoryName ghrn = GitHubRepositoryName.create(job.getProperty(GithubProjectProperty.class)); if (ghrn != null) { - return withOwner(ghrn.userName); + if (ALLOW_UNSAFE_REPOSITORY_INFERENCE || !(context instanceof FlowExecutionOwner.Executable)) { + final var usageContext = GitHubAppUsageContext.builder() + .inferredOwner(ghrn.userName) + .inferredRepository(ghrn.repositoryName) + .permissions(defaultPermissionsStrategy.getPermissions()) + .build(); + return contextualize(usageContext); + } } return this; } @@ -486,6 +593,30 @@ long getTokenStaleEpochSeconds() { } } + private Object readResolve() { + cachedCredentials = new ConcurrentHashMap<>(); + if (repositoryAccessStrategy == null || defaultPermissionsStrategy == null) { + if (owner != null) { + // In this case, the migration should result in identical behavior. + repositoryAccessStrategy = new AccessSpecifiedRepositories(owner, Collections.emptyList()); + } else { + // There is a choice here: We can either preserve compatibility for users who have + // the app installed in multiple orgs and only use the credentials in contexts + // where owner inference is supported by using AccessInferredOwner, _or_ we can + // preserve compatibility for users who have the app installed in a single org and + // use it in contexts where inference is not supported by using + // AccessSpecifiedRepositories with a null owner. + // None of the new strategies support these two use cases simultaneously. + repositoryAccessStrategy = new AccessInferredOwner(); + } + defaultPermissionsStrategy = DefaultPermissionsStrategy.INHERIT_ALL; + MigrationAdminMonitor.addMigratedCredentialId(getId()); + } + owner = null; + context = new GitHubAppUsageContext(); + return this; + } + /** * Ensures that the credentials state as serialized via Remoting to an agent calls back to the * controller. Benefits: @@ -528,7 +659,13 @@ private static final class DelegatingGitHubAppCredentials extends BaseStandardCr j.put("appID", appID); j.put("privateKey", onMaster.getPrivateKey().getPlainText()); j.put("apiUri", onMaster.actualApiUri()); - j.put("owner", onMaster.getOwner()); + final var accessibleRepositories = onMaster.getAccessibleRepositories(); + if (accessibleRepositories == null) { + throw new InferredAccessibleRepositoriesException(onMaster); + } + j.put("owner", accessibleRepositories.getOwner()); + j.put("repositories", accessibleRepositories.getRepositories()); + j.put("permissions", onMaster.getPermissions()); tokenRefreshData = Secret.fromString(j.toString()).getEncryptedValue(); // Check token is valid before sending it to the agent. @@ -637,7 +774,9 @@ public AppInstallationToken call() throws RuntimeException { (String) fields.get("appID"), (String) fields.get("privateKey"), (String) fields.get("apiUri"), - (String) fields.get("owner")); + (String) fields.get("owner"), + (List) fields.get("repositories"), + (Map) fields.get("permissions")); LOGGER.log( Level.FINER, "Retrieved GitHub App Installation Token for app ID {0} for agent", @@ -697,14 +836,13 @@ public FormValidation doTestConnection( @QueryParameter("appID") final String appID, @QueryParameter("privateKey") final String privateKey, @QueryParameter("apiUri") final String apiUri, - @QueryParameter("owner") final String owner) { + @QueryParameter("testConnectionOwner") final String owner) { GitHubAppCredentials gitHubAppCredential = new GitHubAppCredentials( CredentialsScope.GLOBAL, "test-id-not-being-saved", null, appID, Secret.fromString(privateKey)); gitHubAppCredential.setApiUri(apiUri); - gitHubAppCredential.setOwner(owner); - try { + final String inferredOwner; // If no owner is specified, check if the app has multiple installations. if (owner == null || owner.isEmpty()) { GitHub gitHubApp = TokenProvider.createTokenRefreshGitHub( @@ -714,13 +852,20 @@ public FormValidation doTestConnection( if (appInstallations.size() > 1) { // Just pick the owner of the first installation, so we have a valid // owner to create an access token for testing the connection. - String anyInstallationOwner = - appInstallations.get(0).getAccount().getLogin(); - gitHubAppCredential.setOwner(anyInstallationOwner); + inferredOwner = appInstallations.get(0).getAccount().getLogin(); + } else { + inferredOwner = StringUtils.EMPTY; } + } else { + inferredOwner = owner; } - GitHub connect = Connector.connect(apiUri, gitHubAppCredential); + final var usageContext = GitHubAppUsageContext.builder() + .inferredOwner(inferredOwner) + .trust() + .build(); + final var contextualized = gitHubAppCredential.contextualize(usageContext); + GitHub connect = Connector.connect(apiUri, contextualized); try { return FormValidation.ok("Success, Remaining rate limit: " + connect.getRateLimit().getRemaining()); diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppUsageContext.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppUsageContext.java new file mode 100644 index 000000000..6112538a8 --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppUsageContext.java @@ -0,0 +1,113 @@ +package org.jenkinsci.plugins.github_branch_source; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import org.jenkinsci.plugins.github_branch_source.app_credentials.RepositoryAccessStrategy; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.github.GHPermissionType; + +/** + * Holds the inferred owner, repository, and required permissions for whatever operation is going + * to be performed by the code that looked up these credentials. + * + *

Context is inferred either in {@link Connector#lookupScanCredentials} or {@link GitHubAppCredentials#forRun}. + * Each call to {@link GitHubAppCredentials#contextualize} for a distinct context returns a different instance of + * these {@link GitHubAppCredentials}. + * + * @see GitHubAppCredentials#contextualize + * @see GitHubAppCredentials#cachedCredentials + * @see GitHubAppCredentials#getAccessibleRepositories + * @see GitHubAppCredentials#getPermissions + * @see RepositoryAccessStrategy#forContext + */ +@Restricted(NoExternalUse.class) +public class GitHubAppUsageContext { + + private String inferredOwner; + private String inferredRepository; + private Map permissions = Collections.emptyMap(); + private boolean trusted; + + public static final class Builder { + + private final GitHubAppUsageContext result = new GitHubAppUsageContext(); + + private Builder() {} + + public Builder inferredOwner(final String inferredOwner) { + result.inferredOwner = inferredOwner; + return this; + } + + public Builder inferredRepository(final String inferredRepository) { + result.inferredRepository = inferredRepository; + return this; + } + + public Builder permissions(final Map permissions) { + result.permissions = permissions; + return this; + } + + public Builder trust() { + result.trusted = true; + return this; + } + + public GitHubAppUsageContext build() { + return result; + } + } + + public static Builder builder() { + return new Builder(); + } + + @CheckForNull + public String getInferredOwner() { + return inferredOwner; + } + + @CheckForNull + public String getInferredRepository() { + return inferredRepository; + } + + @CheckForNull + public Map getPermissions() { + return permissions; + } + + /** + * @return {@code true} if the generated installation access token will only be used in a + * controlled scenario such as an organization folder scan or multibranch project branch + * indexing. {@code false} if the token will be available for arbitrary use, for example if it + * is bound using {@code withCredentials} in a Pipeline. + */ + @CheckForNull + public boolean isTrusted() { + return trusted; + } + + @Override + public int hashCode() { + return Objects.hash(inferredOwner, inferredRepository, permissions, trusted); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } else if (obj == null || getClass() != obj.getClass()) { + return false; + } + final GitHubAppUsageContext other = (GitHubAppUsageContext) obj; + return Objects.equals(inferredOwner, other.inferredOwner) + && Objects.equals(inferredRepository, other.inferredRepository) + && Objects.equals(permissions, other.permissions) + && trusted == other.trusted; + } +} diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubBuildStatusNotification.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubBuildStatusNotification.java index b6aaf3e78..750559de5 100644 --- a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubBuildStatusNotification.java +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubBuildStatusNotification.java @@ -74,7 +74,11 @@ private static void createBuildCommitStatus(Run build, TaskListener listen if (revision != null) { // only notify if we have a revision to notify try { GitHub gitHub = lookUpGitHub(build.getParent()); + if (gitHub == null) { + return; + } try { + Connector.configureLocalRateLimitChecker(listener, gitHub); GHRepository repo = lookUpRepo(gitHub, build.getParent()); if (repo != null) { Result result = build.getResult(); @@ -127,7 +131,7 @@ private static void createBuildCommitStatus(Run build, TaskListener listen } finally { Connector.release(gitHub); } - } catch (IOException ioe) { + } catch (IOException | InterruptedException ioe) { listener.getLogger() .format("%n" + "Could not update commit status. Message: %s%n" + "%n", ioe.getMessage()); if (LOGGER.isLoggable(Level.FINE)) { @@ -147,9 +151,6 @@ private static void createBuildCommitStatus(Run build, TaskListener listen */ @CheckForNull private static GHRepository lookUpRepo(GitHub github, @NonNull Job job) throws IOException { - if (github == null) { - return null; - } SCMSource src = SCMSource.SourceByItem.findSource(job); if (src instanceof GitHubSCMSource) { GitHubSCMSource source = (GitHubSCMSource) src; @@ -161,7 +162,8 @@ private static GHRepository lookUpRepo(GitHub github, @NonNull Job job) th } /** - * Returns the GitHub Repository associated to a Job. + * Returns a GitHub client that can be used to modify the commit status for the repository + * associated with a job. * * @param job A {@link Job} * @return A {@link GHRepository} or {@code null}, if any of: a credentials was not provided; @@ -182,7 +184,10 @@ private static GitHub lookUpGitHub(@NonNull Job job) throws IOException { return Connector.connect( source.getApiUri(), Connector.lookupScanCredentials( - job, source.getApiUri(), source.getScanCredentialsId(), source.getRepoOwner())); + source.getOwner(), + source.getApiUri(), + source.getScanCredentialsId(), + source.getRepoOwner())); } } return null; diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator.java index 6dc522c57..4e35ad913 100644 --- a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator.java +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator.java @@ -100,7 +100,14 @@ import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.DoNotUse; import org.kohsuke.accmod.restrictions.NoExternalUse; -import org.kohsuke.github.*; +import org.kohsuke.github.GHMyself; +import org.kohsuke.github.GHOrganization; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GHRepositorySearchBuilder; +import org.kohsuke.github.GHUser; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.HttpException; +import org.kohsuke.github.PagedIterable; import org.kohsuke.stapler.AncestorInPath; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.DataBoundSetter; @@ -1178,7 +1185,19 @@ public void visitSources(SCMSourceObserver observer) throws IOException, Interru } if (user != null && repoOwner.equalsIgnoreCase(user.getLogin())) { listener.getLogger().format("Looking up repositories of user %s%n%n", repoOwner); - for (GHRepository repo : user.listRepositories(100)) { + PagedIterable repositories; + if (githubAppAuthentication) { + // If we get here, then the app is installed in a user's "organization", because + // GET /org/:org returned null. GET /users/:user/repos will only include public + // repositories, so we use an alternate API to list repositories available to the + // installation instead. + // https://docs.github.com/en/rest/apps/installations#list-repositories-accessible-to-the-app-installation + repositories = + github.getInstallation().listRepositories().withPageSize(100); + } else { + repositories = user.listRepositories(100); + } + for (GHRepository repo : repositories) { if (repo.isArchived() && gitHubSCMNavigatorContext.isExcludeArchivedRepositories()) { witness.record(repo.getName(), false); listener.getLogger() diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMSource.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMSource.java index 857ed773a..6b1583df9 100644 --- a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMSource.java +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMSource.java @@ -2116,8 +2116,7 @@ public FormValidation doCheckCredentialsId( public FormValidation doValidateRepositoryUrlAndCredentials( @CheckForNull @AncestorInPath Item context, @QueryParameter String repositoryUrl, - @QueryParameter String credentialsId, - @QueryParameter String repoOwner) { + @QueryParameter String credentialsId) { if (context == null && !Jenkins.get().hasPermission(Jenkins.MANAGE) || context != null && !context.hasPermission(Item.EXTENDED_READ)) { return FormValidation.error( @@ -2137,7 +2136,7 @@ public FormValidation doValidateRepositoryUrlAndCredentials( } StandardCredentials credentials = - Connector.lookupScanCredentials(context, info.getApiUri(), credentialsId, repoOwner); + Connector.lookupScanCredentials(context, info.getApiUri(), credentialsId, info.getRepoOwner()); StringBuilder sb = new StringBuilder(); try { GitHub github = Connector.connect(info.getApiUri(), credentials); diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessInferredOwner.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessInferredOwner.java new file mode 100644 index 000000000..26ff1475e --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessInferredOwner.java @@ -0,0 +1,29 @@ +package org.jenkinsci.plugins.github_branch_source.app_credentials; + +import hudson.Extension; +import org.jenkinsci.Symbol; +import org.jenkinsci.plugins.github_branch_source.GitHubAppUsageContext; +import org.kohsuke.stapler.DataBoundConstructor; + +public class AccessInferredOwner extends RepositoryAccessStrategy { + + @DataBoundConstructor + public AccessInferredOwner() {} + + @Override + public AccessibleRepositories forContext(final GitHubAppUsageContext context) { + if (context.getInferredOwner() == null) { + return null; + } + return new AccessibleRepositories(context.getInferredOwner()); + } + + @Symbol("inferOwner") + @Extension + public static class DescriptorImpl extends RepositoryAccessStrategyDescriptor { + @Override + public String getDisplayName() { + return Messages.AccessInferredOwner_displayName(); + } + } +} diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessInferredRepository.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessInferredRepository.java new file mode 100644 index 000000000..50977e643 --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessInferredRepository.java @@ -0,0 +1,51 @@ +package org.jenkinsci.plugins.github_branch_source.app_credentials; + +import hudson.Extension; +import org.jenkinsci.Symbol; +import org.jenkinsci.plugins.github_branch_source.GitHubAppCredentials; +import org.jenkinsci.plugins.github_branch_source.GitHubAppUsageContext; +import org.kohsuke.stapler.DataBoundConstructor; + +/** + * This mode allows {@link GitHubAppCredentials} to generate an installation token whose owner and + * accessible repositories depend on the context in which the credential is used. + * + *

For example, when used in a multibranch project, the generated installation tokens will only + * allow access to the repository for the multibranch project itself. When used with an organization + * folder, each multibranch project will get an access token that is only valid for its own + * repository. + * + *

Note that some organization folder functionality (e.g. org scans) uses tokens that are not + * limited to a specific repository, but these tokens should never be accessible in the context of a + * {@code Run}. + */ +public class AccessInferredRepository extends RepositoryAccessStrategy { + + @DataBoundConstructor + public AccessInferredRepository() {} + + @Override + public AccessibleRepositories forContext(final GitHubAppUsageContext context) { + final var inferredOwner = context.getInferredOwner(); + if (inferredOwner == null) { + return null; + } + if (context.isTrusted()) { + return new AccessibleRepositories(inferredOwner); + } + final var inferredRepository = context.getInferredRepository(); + if (inferredRepository == null) { + return null; + } + return new AccessibleRepositories(inferredOwner, inferredRepository); + } + + @Symbol("inferRepository") + @Extension + public static class DescriptorImpl extends RepositoryAccessStrategyDescriptor { + @Override + public String getDisplayName() { + return Messages.AccessInferredRepository_displayName(); + } + } +} diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessSpecifiedRepositories.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessSpecifiedRepositories.java new file mode 100644 index 000000000..304e7b070 --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessSpecifiedRepositories.java @@ -0,0 +1,97 @@ +package org.jenkinsci.plugins.github_branch_source.app_credentials; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.Util; +import hudson.util.FormValidation; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import net.sf.json.JSONObject; +import org.jenkinsci.Symbol; +import org.jenkinsci.plugins.github_branch_source.GitHubAppCredentials; +import org.jenkinsci.plugins.github_branch_source.GitHubAppUsageContext; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.StaplerRequest; + +/** + * This mode only allows the {@link GitHubAppCredentials} to generate an installation token for the + * specified owner and list of repositories. + * + *

The context of where the credentials are used in Jenkins is irrelevant. + * + *

Specifying an empty list of repositories allows the credential to generate an installation + * token that can access any repository available to the owner. + */ +public class AccessSpecifiedRepositories extends RepositoryAccessStrategy { + + private final @CheckForNull String owner; + private final @NonNull List repositories; + + @DataBoundConstructor + public AccessSpecifiedRepositories(@CheckForNull String owner, @NonNull List repositories) { + this.owner = Util.fixEmptyAndTrim(owner); + this.repositories = new ArrayList<>(repositories == null ? List.of() : repositories); + } + + public String getOwner() { + return owner; + } + + public List getRepositories() { + return repositories; + } + + @Restricted(NoExternalUse.class) + public String getRepositoriesForJelly() { + return String.join("\n", repositories); + } + + @Override + public AccessibleRepositories forContext(final GitHubAppUsageContext context) { + return new AccessibleRepositories(owner, repositories); + } + + @Symbol("specificRepositories") + @Extension + public static class DescriptorImpl extends RepositoryAccessStrategyDescriptor { + @Override + public String getDisplayName() { + return Messages.AccessSpecifiedRepositories_displayName(); + } + + // TODO: JENKINS-27901 + @Override + public AccessSpecifiedRepositories newInstance(StaplerRequest req, JSONObject formData) throws FormException { + String owner = formData.getString("owner"); + String repositoryField = formData.getString("repositories"); + List repositories = parseRepositories(repositoryField); + return new AccessSpecifiedRepositories(owner, repositories); + } + + public FormValidation doCheckRepositories(@QueryParameter String repositories) { + if (parseRepositories(repositories).isEmpty()) { + return FormValidation.warning(Messages.AccessSpecifiedRepositories_noRespositories()); + } + return FormValidation.ok(); + } + + private static List parseRepositories(String repositoryField) { + repositoryField = Util.fixEmptyAndTrim(repositoryField); + if (repositoryField == null) { + return Collections.emptyList(); + } + return Stream.of(repositoryField.split("\r?\n")) + .map(Util::fixEmptyAndTrim) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + } +} diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessibleRepositories.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessibleRepositories.java new file mode 100644 index 000000000..b9ffc854a --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessibleRepositories.java @@ -0,0 +1,80 @@ +package org.jenkinsci.plugins.github_branch_source.app_credentials; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Specify a set of repositories that should be accessible. + */ +public class AccessibleRepositories { + + private final String owner; + private final List repositories; + + /** + * Constructor. + * + * @param owner if null, then the credential may only be used if the app is installed in a single + * organization, otherwise only the specified owner can be accessed + * @param repositories the names of the repositories that should be accessible, or empty list to + * access all repositories + */ + public AccessibleRepositories(@CheckForNull String owner, @NonNull List repositories) { + this.owner = owner; + this.repositories = new ArrayList<>(repositories); + } + + /** + * Constructor. + * + * @param owner if null, then the credential may only be used if the app is installed in a single + * organization, otherwise only the specified owner can be accessed + */ + public AccessibleRepositories(@CheckForNull String owner) { + this(owner, Collections.emptyList()); + } + + /** + * Constructor. + * + * @param owner if null, then the credential may only be used if the app is installed in a single + * organization, otherwise only the specified owner can be accessed + * @param repository the name of the repository that should be accessible + */ + public AccessibleRepositories(@CheckForNull String owner, @NonNull String repository) { + this(owner, Collections.singletonList(repository)); + } + + public @CheckForNull String getOwner() { + return owner; + } + + public @NonNull List getRepositories() { + return Collections.unmodifiableList(repositories); + } + + @Override + public int hashCode() { + return Objects.hash(owner, repositories); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } else if (obj == null || getClass() != obj.getClass()) { + return false; + } + final AccessibleRepositories other = (AccessibleRepositories) obj; + return Objects.equals(owner, other.owner) && Objects.equals(repositories, other.repositories); + } + + @Override + public String toString() { + return owner + "/" + repositories; + } +} diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/app_credentials/DefaultPermissionsStrategy.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/app_credentials/DefaultPermissionsStrategy.java new file mode 100644 index 000000000..45223434b --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/app_credentials/DefaultPermissionsStrategy.java @@ -0,0 +1,36 @@ +package org.jenkinsci.plugins.github_branch_source.app_credentials; + +import java.util.Collections; +import java.util.Map; +import org.kohsuke.github.GHPermissionType; + +public enum DefaultPermissionsStrategy { + CONTENTS_READ(Collections.singletonMap("contents", GHPermissionType.READ)), + CONTENTS_WRITE(Collections.singletonMap("contents", GHPermissionType.WRITE)), + INHERIT_ALL(Collections.emptyMap()); + // TODO: Would it make sense to add a NO_PERMISSIONS mode, which would effectively prevent these + // credentials from being used in generic contexts? + + private final Map permissions; + + private DefaultPermissionsStrategy(Map permissions) { + this.permissions = permissions; + } + + public Map getPermissions() { + return permissions; + } + + public String getDisplayName() { + switch (this) { + case CONTENTS_READ: + return Messages.DefaultPermissionsStrategy_contentsRead(); + case CONTENTS_WRITE: + return Messages.DefaultPermissionsStrategy_contentsWrite(); + case INHERIT_ALL: + return Messages.DefaultPermissionsStrategy_inheritAll(); + default: + throw new AssertionError("Unsupported enum variant " + this); + } + } +} diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/app_credentials/MigrationAdminMonitor.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/app_credentials/MigrationAdminMonitor.java new file mode 100644 index 000000000..f15528947 --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/app_credentials/MigrationAdminMonitor.java @@ -0,0 +1,36 @@ +package org.jenkinsci.plugins.github_branch_source.app_credentials; + +import hudson.Extension; +import hudson.ExtensionList; +import hudson.model.AdministrativeMonitor; +import java.util.HashSet; +import java.util.Set; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +@Extension +@Restricted(NoExternalUse.class) +public class MigrationAdminMonitor extends AdministrativeMonitor { + + private final Set migratedCredentialIds = new HashSet<>(); + + @Override + public boolean isActivated() { + return !migratedCredentialIds.isEmpty(); + } + + @Override + public String getDisplayName() { + return Messages.MigrationAdminMonitor_displayName(); + } + + public Set getMigratedCredentialIds() { + return migratedCredentialIds; + } + + public static void addMigratedCredentialId(String id) { + ExtensionList.lookupSingleton(MigrationAdminMonitor.class) + .migratedCredentialIds + .add(id); + } +} diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/app_credentials/RepositoryAccessStrategy.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/app_credentials/RepositoryAccessStrategy.java new file mode 100644 index 000000000..5c7126a3b --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/app_credentials/RepositoryAccessStrategy.java @@ -0,0 +1,30 @@ +package org.jenkinsci.plugins.github_branch_source.app_credentials; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import hudson.model.AbstractDescribableImpl; +import hudson.model.Descriptor; +import java.io.Serializable; +import org.jenkinsci.plugins.github_branch_source.GitHubAppCredentials; +import org.jenkinsci.plugins.github_branch_source.GitHubAppUsageContext; + +/** + * Controls the repositories available to installation access tokens generated by {@link + * GitHubAppCredentials}. + */ +public abstract class RepositoryAccessStrategy extends AbstractDescribableImpl + implements Serializable { + + /** + * Get the {@link AccessibleRepositories} to use when generating installation access tokens for + * the inferred contextual owner and repository. + * + *

Called when the credential is used in a context where an owner and repository can be + * inferred. + * + * @return {@code null} if the accessible repositories are unknown, or if no repositories are + * accessible in the specified context + */ + public abstract @CheckForNull AccessibleRepositories forContext(GitHubAppUsageContext context); + + public static class RepositoryAccessStrategyDescriptor extends Descriptor {} +} diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/config.jelly b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/config.jelly index da3ba7f86..715c2f218 100644 --- a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/config.jelly +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/config.jelly @@ -22,12 +22,21 @@ - - + + + + ${it.displayName} + - + + + + + + + diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-defaultPermissionsStrategy.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-defaultPermissionsStrategy.html new file mode 100644 index 000000000..b99313afe --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-defaultPermissionsStrategy.html @@ -0,0 +1,7 @@ +

+ This option controls what permissions are available to the access tokens generated when using these credentials in untrusted contexts. + For example, this setting is used when the credentials are bound in a Pipeline job using the withCredentials step. +

+

+ In other contexts, such as organization folder scans, multibranch project branch indexing, and GitHub commit status updates, this setting is ignored. +

diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-repositoryAccessStrategy.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-repositoryAccessStrategy.html new file mode 100644 index 000000000..e72b42236 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-repositoryAccessStrategy.html @@ -0,0 +1,4 @@ +
+ This option controls what GitHub repositories are accessible to the access tokens generated when using these credentials. + Jenkins can generate tokens which only have access to a subset of the repositories available to the app installation. +
diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessInferredOwner/help.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessInferredOwner/help.html new file mode 100644 index 000000000..37a298c30 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessInferredOwner/help.html @@ -0,0 +1,6 @@ +
+ When using this mode, the credentials may only be used in contexts where a GitHub owner can be inferred, such + as in a Multibranch Pipeline or Organization Folder, or in jobs where the "GitHub project" property is configured. + Attempts to use the credentials in other contexts will fail. + The generated access tokens will have access to any repository available to that owner for the GitHub App. +
diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessInferredRepository/help.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessInferredRepository/help.html new file mode 100644 index 000000000..6a921117c --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessInferredRepository/help.html @@ -0,0 +1,6 @@ +
+ When using this mode, the credentials may only be used in contexts where a GitHub repository can be inferred, such + as in a Multibranch Pipeline or Organization Folder, or in jobs where the "GitHub project" property is configured. + Attempts to use the credentials in other contexts will fail. + The generated access tokens will only be able to access the contextually appropriate repository. +
diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessSpecifiedRepositories/config.jelly b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessSpecifiedRepositories/config.jelly new file mode 100644 index 000000000..726bb68ac --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessSpecifiedRepositories/config.jelly @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessSpecifiedRepositories/help-owner.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessSpecifiedRepositories/help-owner.html new file mode 100644 index 000000000..bea235275 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessSpecifiedRepositories/help-owner.html @@ -0,0 +1,5 @@ +

+ The organization or user that this credential is to be used for. + If left blank, and the GitHub App is only installed in a single organization, then these + credentials will be able to access that organization in any context in Jenkins. +

diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessSpecifiedRepositories/help-repositories.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessSpecifiedRepositories/help-repositories.html new file mode 100644 index 000000000..7d39e0ecc --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessSpecifiedRepositories/help-repositories.html @@ -0,0 +1,5 @@ +

+ The names of the repositories that this credential is to be used for, one per line. + If no repositories are specified, then the generated access tokens will have access to every + repository available to the owner. +

diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessSpecifiedRepositories/help.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessSpecifiedRepositories/help.html new file mode 100644 index 000000000..14f56edf6 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessSpecifiedRepositories/help.html @@ -0,0 +1,7 @@ +
+ When using this mode, the credentials may be used in any context in Jenkins. + The generated access tokens will be to access the specified repositories. + + If the app is installed in multiple organizations, this mode will only allow one of those organizations to be accessed. + Additional credentials may be defined to access other organizations. +
diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/Messages.properties b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/Messages.properties new file mode 100644 index 000000000..d73ed3954 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/Messages.properties @@ -0,0 +1,8 @@ +AccessInferredOwner.displayName=Infer owner and allow access to all owned repositories +AccessInferredRepository.displayName=Infer accessible repository +AccessSpecifiedRepositories.displayName=Specify accessible repositories +AccessSpecifiedRepositories.noRespositories=Because no repositories are specified, these credentials can be used to access any repository available to the owner +MigrationAdminMonitor.displayName=Reconfigure GitHub App credentials to use new features +DefaultPermissionsStrategy.contentsRead=Read-only access to repository contents +DefaultPermissionsStrategy.contentsWrite=Read and write access to repository contents +DefaultPermissionsStrategy.inheritAll=All permissions available to the app installation \ No newline at end of file diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/MigrationAdminMonitor/description.jelly b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/MigrationAdminMonitor/description.jelly new file mode 100644 index 000000000..e14e156f0 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/MigrationAdminMonitor/description.jelly @@ -0,0 +1,4 @@ + + + ${%blurb} + diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/MigrationAdminMonitor/description.properties b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/MigrationAdminMonitor/description.properties new file mode 100644 index 000000000..2b951d093 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/MigrationAdminMonitor/description.properties @@ -0,0 +1 @@ +blurb=Informs administrators of new features available for existing GitHub App credentials that allow dynamic restriction of the repositories and permissions available to jobs that use the credentials diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/MigrationAdminMonitor/message.jelly b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/MigrationAdminMonitor/message.jelly new file mode 100644 index 000000000..aadf50d8d --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/MigrationAdminMonitor/message.jelly @@ -0,0 +1,17 @@ + + +
+
+ + + ${%blurb} +

See here for more information.

+

${%affectedCredentials} +

    + +
  • ${c}
  • +
    +
+

+
+
diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/MigrationAdminMonitor/message.properties b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/MigrationAdminMonitor/message.properties new file mode 100644 index 000000000..a6a485825 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/MigrationAdminMonitor/message.properties @@ -0,0 +1,4 @@ +blurb=Consider reconfiguring your existing GitHub App credentials to take advantage of new options \ + which allow dynamic restriction of the repositories and permissions available to the generated \ + access tokens used by jobs. +affectedCredentials=Affected credentials (entries are not removed until Jenkins restarts): diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/GitHubApp.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/GitHubApp.java index 37b385448..192e9fdee 100644 --- a/src/test/java/org/jenkinsci/plugins/github_branch_source/GitHubApp.java +++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/GitHubApp.java @@ -42,10 +42,4 @@ public class GitHubApp { public static GitHubAppCredentials createCredentials(final String id) { return new GitHubAppCredentials(CredentialsScope.GLOBAL, id, "sample", "54321", Secret.fromString(PRIVATE_KEY)); } - - public static GitHubAppCredentials createCredentials(final String id, final String owner) { - final var credentials = createCredentials(id); - credentials.setOwner(owner); - return credentials; - } } diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsJCasCCompatibilityTest.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsJCasCCompatibilityTest.java index 473b79da5..33696fc39 100644 --- a/src/test/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsJCasCCompatibilityTest.java +++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsJCasCCompatibilityTest.java @@ -2,9 +2,9 @@ import static io.jenkins.plugins.casc.misc.Util.toStringFromYamlFile; import static io.jenkins.plugins.casc.misc.Util.toYamlString; +import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; -import static org.junit.Assert.assertThat; import static org.jvnet.hudson.test.JenkinsMatchers.hasPlainText; import com.cloudbees.plugins.credentials.Credentials; @@ -18,10 +18,14 @@ import io.jenkins.plugins.casc.misc.JenkinsConfiguredWithCodeRule; import io.jenkins.plugins.casc.model.CNode; import io.jenkins.plugins.casc.model.Mapping; -import io.jenkins.plugins.casc.model.Sequence; import java.util.List; import java.util.Objects; import jenkins.model.Jenkins; +import org.jenkinsci.plugins.github_branch_source.app_credentials.AccessInferredOwner; +import org.jenkinsci.plugins.github_branch_source.app_credentials.AccessInferredRepository; +import org.jenkinsci.plugins.github_branch_source.app_credentials.AccessSpecifiedRepositories; +import org.jenkinsci.plugins.github_branch_source.app_credentials.DefaultPermissionsStrategy; +import org.jenkinsci.plugins.github_branch_source.app_credentials.RepositoryAccessStrategy; import org.junit.ClassRule; import org.junit.Test; import org.junit.rules.RuleChain; @@ -38,39 +42,59 @@ public class GitHubAppCredentialsJCasCCompatibilityTest { .around(j); @Test - public void should_support_configuration_as_code() { + @ConfiguredWithCode("github-app-jcasc-minimal.yaml") + public void should_support_configuration_as_code() throws Exception { List domainCredentials = SystemCredentialsProvider.getInstance().getDomainCredentials(); assertThat(domainCredentials.size(), is(1)); List credentials = domainCredentials.get(0).getCredentials(); - assertThat(credentials.size(), is(1)); - - Credentials credential = credentials.get(0); - assertThat(credential, instanceOf(GitHubAppCredentials.class)); - GitHubAppCredentials gitHubAppCredentials = (GitHubAppCredentials) credential; - - assertThat(gitHubAppCredentials.getAppID(), is("1111")); - assertThat(gitHubAppCredentials.getDescription(), is("GitHub app 1111")); - assertThat(gitHubAppCredentials.getId(), is("github-app")); - assertThat(gitHubAppCredentials.getPrivateKey(), hasPlainText(GITHUB_APP_KEY)); + assertThat(credentials.size(), is(7)); + + assertGitHubAppCredential(credentials.get(0), "github-app", "GitHub app 1111"); + assertGitHubAppCredential( + credentials.get(1), "old-owner", "", new AccessSpecifiedRepositories("test", List.of())); + assertGitHubAppCredential( + credentials.get(2), "new-specific-empty", "", new AccessSpecifiedRepositories(null, List.of())); + assertGitHubAppCredential( + credentials.get(3), "new-specific-owner", "", new AccessSpecifiedRepositories("test", List.of())); + assertGitHubAppCredential( + credentials.get(4), + "new-specific-repos", + "", + new AccessSpecifiedRepositories("test", List.of("repo1", "repo2"))); + assertGitHubAppCredential( + credentials.get(5), + "new-infer-owner", + "", + new AccessInferredOwner(), + DefaultPermissionsStrategy.CONTENTS_READ); + assertGitHubAppCredential( + credentials.get(6), + "new-infer-repo", + "", + new AccessInferredRepository(), + DefaultPermissionsStrategy.CONTENTS_WRITE); } @Test + @ConfiguredWithCode("github-app-jcasc-minimal.yaml") public void should_support_configuration_export() throws Exception { - Sequence credentials = getCredentials(); - CNode githubApp = credentials.get(0).asMapping().get("gitHubApp"); + CNode credentials = getCredentials(); - String exported = toYamlString(githubApp) + String exported = toYamlString(credentials) // replace secret with a constant value .replaceAll("privateKey: .*", "privateKey: \"some-secret-value\""); String expected = toStringFromYamlFile(this, "github-app-jcasc-minimal-expected-export.yaml"); + // TODO: CasC plugin incorrectly oversimplifies the YAML export for new-specific-empty by + // fully removing the repositoryAccessStrategy configuration because its inner + // configuration is all empty, but that means it no longer round-trips. assertThat(exported, is(expected)); } - private Sequence getCredentials() throws Exception { + private CNode getCredentials() throws Exception { CredentialsRootConfigurator root = Jenkins.get() .getExtensionList(CredentialsRootConfigurator.class) .get(0); @@ -79,13 +103,38 @@ private Sequence getCredentials() throws Exception { ConfigurationContext context = new ConfigurationContext(registry); Mapping configNode = Objects.requireNonNull(root.describe(root.getTargetComponent(context), context)) .asMapping(); - Mapping domainCredentials = configNode - .get("system") - .asMapping() - .get("domainCredentials") - .asSequence() - .get(0) - .asMapping(); - return domainCredentials.get("credentials").asSequence(); + return configNode; + } + + private static void assertGitHubAppCredential(Credentials credentials, String id, String description) { + assertGitHubAppCredential(credentials, id, description, new AccessInferredOwner()); + } + + private static void assertGitHubAppCredential( + Credentials credentials, String id, String description, RepositoryAccessStrategy repoStrategy) { + assertGitHubAppCredential(credentials, id, description, repoStrategy, DefaultPermissionsStrategy.INHERIT_ALL); + } + + private static void assertGitHubAppCredential( + Credentials credentials, + String id, + String description, + RepositoryAccessStrategy repoStrategy, + DefaultPermissionsStrategy permissionsStrategy) { + assertThat(credentials, instanceOf(GitHubAppCredentials.class)); + GitHubAppCredentials appCredentials = (GitHubAppCredentials) credentials; + assertThat(appCredentials.getAppID(), is("1111")); + assertThat(appCredentials.getDescription(), is(description)); + assertThat(appCredentials.getId(), is(id)); + assertThat(appCredentials.getPrivateKey(), hasPlainText(GITHUB_APP_KEY)); + assertThat(appCredentials.getDefaultPermissionsStrategy(), is(permissionsStrategy)); + var actualRepoStrategy = appCredentials.getRepositoryAccessStrategy(); + assertThat(actualRepoStrategy.getClass(), is(repoStrategy.getClass())); + if (actualRepoStrategy instanceof AccessSpecifiedRepositories) { + var actualRepos = (AccessSpecifiedRepositories) actualRepoStrategy; + var expectedRepos = (AccessSpecifiedRepositories) repoStrategy; + assertThat(actualRepos.getOwner(), is(expectedRepos.getOwner())); + assertThat(actualRepos.getRepositories(), is(expectedRepos.getRepositories())); + } } } diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/GithubAppCredentialsTest.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/GithubAppCredentialsTest.java index f71b3d401..6949b5aaf 100644 --- a/src/test/java/org/jenkinsci/plugins/github_branch_source/GithubAppCredentialsTest.java +++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/GithubAppCredentialsTest.java @@ -24,6 +24,7 @@ import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.List; @@ -33,6 +34,7 @@ import java.util.logging.SimpleFormatter; import java.util.stream.Collectors; import jenkins.plugins.git.GitSampleRepoRule; +import org.jenkinsci.plugins.github_branch_source.app_credentials.AccessSpecifiedRepositories; import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; import org.jenkinsci.plugins.workflow.job.WorkflowJob; import org.jenkinsci.plugins.workflow.job.WorkflowRun; @@ -71,9 +73,14 @@ public static void setUpJenkins() throws Exception { // Add credential (Must have valid private key for Jwt to work, but App doesn't have to actually // exist) store = CredentialsProvider.lookupStores(r.jenkins).iterator().next(); - appCredentials = GitHubApp.createCredentials(myAppCredentialsId, "cloudBeers"); + appCredentials = GitHubApp.createCredentials(myAppCredentialsId); + appCredentials.setRepositoryAccessStrategy( + new AccessSpecifiedRepositories("cloudBeers", Collections.emptyList())); + store.addCredentials(Domain.global(), appCredentials); appCredentialsNoOwner = GitHubApp.createCredentials(myAppCredentialsNoOwnerId); + appCredentialsNoOwner.setRepositoryAccessStrategy( + new AccessSpecifiedRepositories(null, Collections.emptyList())); store.addCredentials(Domain.global(), appCredentialsNoOwner); // Add agent @@ -455,8 +462,8 @@ public void testPassword() throws Exception { assertThrows(IllegalArgumentException.class, () -> appCredentialsNoOwner.getPassword()); assertThat( expected.getMessage(), - is("Found multiple installations for GitHub app ID 54321 but none match credential owner \"\". " - + "Set the right owner in the credential advanced options to one of: cloudbeers, bogus")); + is( + "Found multiple installations for GitHub app ID 54321 but none match credential owner \"\". Configure the repository access strategy for the credential to use one of these owners: cloudbeers, bogus")); } finally { GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS = notStaleSeconds; logRecorder.doClear(); diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessInferredOwnerTest.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessInferredOwnerTest.java new file mode 100644 index 000000000..54d822d3b --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessInferredOwnerTest.java @@ -0,0 +1,33 @@ +package org.jenkinsci.plugins.github_branch_source.app_credentials; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; + +import org.jenkinsci.plugins.github_branch_source.GitHubAppUsageContext; +import org.junit.Test; + +public class AccessInferredOwnerTest { + + private final RepositoryAccessStrategy strategy = new AccessInferredOwner(); + + @Test + public void smokes() { + assertThat( + strategy.forContext(GitHubAppUsageContext.builder() + .inferredOwner("inferred-owner") + .inferredRepository("inferred-repo") + .build()), + equalTo(new AccessibleRepositories("inferred-owner"))); + } + + @Test + public void requiresInferredOwner() { + assertThat(strategy.forContext(GitHubAppUsageContext.builder().build()), nullValue()); + assertThat( + strategy.forContext(GitHubAppUsageContext.builder() + .inferredRepository("inferred-repository") + .build()), + nullValue()); + } +} diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessInferredRepositoryTest.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessInferredRepositoryTest.java new file mode 100644 index 000000000..5d2f43e09 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessInferredRepositoryTest.java @@ -0,0 +1,69 @@ +package org.jenkinsci.plugins.github_branch_source.app_credentials; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; + +import java.util.List; +import org.jenkinsci.plugins.github_branch_source.GitHubAppUsageContext; +import org.junit.Test; + +public class AccessInferredRepositoryTest { + + private final RepositoryAccessStrategy strategy = new AccessInferredRepository(); + + @Test + public void smokes() { + final var strategy = new AccessInferredRepository(); + assertThat( + strategy.forContext(GitHubAppUsageContext.builder() + .inferredOwner("inferred-owner") + .inferredRepository("inferred-repo") + .build()), + equalTo(new AccessibleRepositories("inferred-owner", List.of("inferred-repo")))); + } + + @Test + public void constrainedUsageAllowsMultiRepositoryAccess() { + final var strategy = new AccessInferredRepository(); + + assertThat( + strategy.forContext(GitHubAppUsageContext.builder() + .inferredOwner("inferred-owner") + .trust() + .build()), + equalTo(new AccessibleRepositories("inferred-owner"))); + assertThat( + strategy.forContext(GitHubAppUsageContext.builder() + .inferredOwner("inferred-owner") + .inferredRepository("inferred-repo-fake") + .trust() + .build()), + equalTo(new AccessibleRepositories("inferred-owner"))); + } + + @Test + public void requiresInferredOwner() { + assertThat(strategy.forContext(GitHubAppUsageContext.builder().build()), nullValue()); + assertThat( + strategy.forContext(GitHubAppUsageContext.builder() + .inferredRepository("inferred-repo") + .build()), + nullValue()); + assertThat( + strategy.forContext(GitHubAppUsageContext.builder() + .inferredRepository("inferred-repo") + .trust() + .build()), + nullValue()); + } + + @Test + public void requiresInferredRepository() { + assertThat( + strategy.forContext(GitHubAppUsageContext.builder() + .inferredOwner("inferred-owner") + .build()), + nullValue()); + } +} diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessSpecifiedRepositoriesTest.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessSpecifiedRepositoriesTest.java new file mode 100644 index 000000000..0f3a7eaca --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/app_credentials/AccessSpecifiedRepositoriesTest.java @@ -0,0 +1,35 @@ +package org.jenkinsci.plugins.github_branch_source.app_credentials; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +import java.util.List; +import org.jenkinsci.plugins.github_branch_source.GitHubAppUsageContext; +import org.junit.Test; + +public class AccessSpecifiedRepositoriesTest { + + private final RepositoryAccessStrategy strategy = + new AccessSpecifiedRepositories("owner", List.of("repo-one", "repo-two")); + + @Test + public void smokes() { + assertThat( + strategy.forContext(GitHubAppUsageContext.builder() + .inferredOwner("inferred-owner") + .inferredRepository("inferred-repo") + .build()), + equalTo(new AccessibleRepositories("owner", List.of("repo-one", "repo-two")))); + } + + @Test + public void trustedUsageAllowsArbitraryRepositoryAccess() { + assertThat( + strategy.forContext(GitHubAppUsageContext.builder() + .inferredOwner("inferred-owner") + .inferredRepository("inferred-repo") + .trust() + .build()), + equalTo(new AccessibleRepositories("owner", List.of("repo-one", "repo-two")))); + } +} diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/app_credentials/MigrationAdminMonitorTest.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/app_credentials/MigrationAdminMonitorTest.java new file mode 100644 index 000000000..293dc5ad7 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/app_credentials/MigrationAdminMonitorTest.java @@ -0,0 +1,62 @@ +package org.jenkinsci.plugins.github_branch_source.app_credentials; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertTrue; + +import com.cloudbees.plugins.credentials.CredentialsMatchers; +import com.cloudbees.plugins.credentials.CredentialsProvider; +import hudson.ExtensionList; +import hudson.security.ACL; +import org.jenkinsci.plugins.github_branch_source.GitHubAppCredentials; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.recipes.LocalData; + +public class MigrationAdminMonitorTest { + + @Rule + public JenkinsRule r = new JenkinsRule(); + + // Checks the migration behavior for credentials created prior to the introduction of the repository access + // strategy and the default permissions strategy. + @Test + @LocalData + public void smokes() throws Throwable { + // LocalData based on the following code at commit 50351eb + /* + var store = CredentialsProvider.lookupStores(r.jenkins).iterator().next(); + var credentials = GitHubApp.createCredentials(myAppCredentialsId); + credentials.setOwner("cloudBeers"); + store.addCredentials(Domain.global(), credentials); + credentials = GitHubApp.createCredentials(credentials); + store.addCredentials(Domain.global(), credentials); + */ + var credentialsWithOwner = CredentialsMatchers.firstOrNull( + CredentialsProvider.lookupCredentialsInItemGroup(GitHubAppCredentials.class, r.jenkins, ACL.SYSTEM2), + CredentialsMatchers.withId("old-credentials-with-owner")); + assertThat(credentialsWithOwner.getOwner(), nullValue()); + var strategy = (AccessSpecifiedRepositories) credentialsWithOwner.getRepositoryAccessStrategy(); + assertThat(strategy.getOwner(), is("cloudBeers")); + assertThat(strategy.getRepositories(), empty()); + assertThat(credentialsWithOwner.getDefaultPermissionsStrategy(), is(DefaultPermissionsStrategy.INHERIT_ALL)); + + var credentialsNoOwner = CredentialsMatchers.firstOrNull( + CredentialsProvider.lookupCredentialsInItemGroup(GitHubAppCredentials.class, r.jenkins, ACL.SYSTEM2), + CredentialsMatchers.withId("old-credentials-no-owner")); + assertThat(credentialsNoOwner.getOwner(), nullValue()); + assertThat(credentialsNoOwner.getRepositoryAccessStrategy(), instanceOf(AccessInferredOwner.class)); + assertThat(credentialsNoOwner.getDefaultPermissionsStrategy(), is(DefaultPermissionsStrategy.INHERIT_ALL)); + + var monitor = ExtensionList.lookupSingleton(MigrationAdminMonitor.class); + assertTrue(monitor.isActivated()); + assertThat( + monitor.getMigratedCredentialIds(), + containsInAnyOrder("old-credentials-with-owner", "old-credentials-no-owner")); + } +} diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/app_credentials/RunWithCredentialsTest.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/app_credentials/RunWithCredentialsTest.java new file mode 100644 index 000000000..2ddc9be01 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/app_credentials/RunWithCredentialsTest.java @@ -0,0 +1,372 @@ +package org.jenkinsci.plugins.github_branch_source.app_credentials; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; + +import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.cloudbees.plugins.credentials.CredentialsStore; +import com.cloudbees.plugins.credentials.domains.Domain; +import com.coravy.hudson.plugins.github.GithubProjectProperty; +import com.github.tomakehurst.wiremock.http.HttpHeader; +import com.github.tomakehurst.wiremock.http.HttpHeaders; +import hudson.ProxyConfiguration; +import hudson.model.Descriptor.FormException; +import hudson.model.InvisibleAction; +import hudson.model.Result; +import hudson.model.Run; +import hudson.model.TaskListener; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpResponse.BodyHandlers; +import java.time.Duration; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import net.sf.json.JSONObject; +import org.jenkinsci.plugins.github_branch_source.AbstractGitHubWireMockTest; +import org.jenkinsci.plugins.github_branch_source.ApiRateLimitChecker; +import org.jenkinsci.plugins.github_branch_source.GitHubApp; +import org.jenkinsci.plugins.github_branch_source.GitHubAppCredentials; +import org.jenkinsci.plugins.github_branch_source.GitHubConfiguration; +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; +import org.jenkinsci.plugins.workflow.job.WorkflowJob; +import org.jenkinsci.plugins.workflow.steps.Step; +import org.jenkinsci.plugins.workflow.steps.StepContext; +import org.jenkinsci.plugins.workflow.steps.StepDescriptor; +import org.jenkinsci.plugins.workflow.steps.StepExecution; +import org.jenkinsci.plugins.workflow.steps.StepExecutions; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.BuildWatcher; +import org.jvnet.hudson.test.FlagRule; +import org.jvnet.hudson.test.TestExtension; +import org.kohsuke.stapler.DataBoundConstructor; + +/** + * This creates a pipeline job that logs the repositories accessible from the contextualized credentials. + * It asserts that the correct repositories are accessible based on the repository access strategy set on the credentials. + */ +public class RunWithCredentialsTest extends AbstractGitHubWireMockTest { + + @ClassRule + public static BuildWatcher watcher = new BuildWatcher(); + + @Rule + public FlagRule resetAllowUnsafeRepoInference = new FlagRule<>( + () -> GitHubAppCredentials.ALLOW_UNSAFE_REPOSITORY_INFERENCE, + x -> GitHubAppCredentials.ALLOW_UNSAFE_REPOSITORY_INFERENCE = x); + + private enum InstallationAccessToken { + TOKEN, + TOKEN_A, + TOKEN_B, + TOKEN_AB; + + public String json() { + return "{\"token\":\"" + name() + "\",\"expires_at\": \"" + createTokenExpiration() + "\"}"; + } + + public String bearer() { + return String.format("Bearer %s", name()); + } + + private String createTokenExpiration() { + // This token will go stale at the soonest allowed time but will not + // expire for the duration of the test + // Format: 2019-08-10T05:54:58Z + return DateTimeFormatter.ISO_INSTANT.format( + Instant.now().plus(Duration.ofMinutes(10)).truncatedTo(ChronoUnit.SECONDS)); + } + } + + private static final HttpHeaders HEADERS = + new HttpHeaders(new HttpHeader("Content-Type", "application/json; charset=utf-8")); + + private GitHubAppCredentials credentials; + private CredentialsStore credentialsStore; + private WorkflowJob project; + + @Before + public void setup() throws IOException, FormException, InterruptedException, ExecutionException { + GitHubConfiguration.get().setApiRateLimitChecker(ApiRateLimitChecker.ThrottleOnOver); + // Tests here use WorkflowJob+GithubProjectProperty for simplicity. We could switch to Multibranch Projects + // instead to avoid this flag. + GitHubAppCredentials.ALLOW_UNSAFE_REPOSITORY_INFERENCE = true; + + credentials = GitHubApp.createCredentials("theCredentials"); + credentials.setApiUri(githubApi.baseUrl()); + + credentialsStore = + CredentialsProvider.lookupStores(r.jenkins).iterator().next(); + credentialsStore.addCredentials(Domain.global(), credentials); + + project = r.createProject(WorkflowJob.class); + project.addProperty(new GithubProjectProperty("https://github.com/cloudbeers/repository-a/")); + project.setDefinition(new CpsFlowDefinition( + "getAccessibleRepositories(credentialsId: '" + credentials.getId() + + "', githubApiUri: 'http://localhost:" + githubApi.port() + "/installation/repositories')", + true)); + + // Sub app + githubApi.stubFor(get(urlEqualTo("/app")) + .willReturn(aResponse() + .withHeaders(HEADERS) + .withBodyFile("../AppCredentials/files/body-mapping-githubapp-app.json"))); + + // Stub app installation + githubApi.stubFor(get(urlEqualTo("/app/installations")) + .willReturn(aResponse() + .withHeaders(HEADERS) + .withBodyFile("../AppCredentials/files/body-mapping-githubapp-installations.json"))); + + // Stub app installation access token + githubApi.stubFor(post(urlEqualTo("/app/installations/654321/access_tokens")) + .withRequestBody(equalToJson( + "{\"permissions\":{\"pull_requests\":\"write\",\"metadata\":\"read\",\"checks\":\"write\",\"contents\":\"read\"}}", + true, + false)) + .willReturn(aResponse().withHeaders(HEADERS).withBody(InstallationAccessToken.TOKEN.json()))); + githubApi.stubFor(post(urlEqualTo("/app/installations/654321/access_tokens")) + .withRequestBody(equalToJson( + "{\"repositories\":[\"repository-a\"],\"permissions\":{\"pull_requests\":\"write\",\"metadata\":\"read\",\"checks\":\"write\",\"contents\":\"read\"}}", + true, + false)) + .willReturn(aResponse().withHeaders(HEADERS).withBody(InstallationAccessToken.TOKEN_A.json()))); + githubApi.stubFor(post(urlEqualTo("/app/installations/654321/access_tokens")) + .withRequestBody(equalToJson( + "{\"repositories\":[\"repository-b\"],\"permissions\":{\"pull_requests\":\"write\",\"metadata\":\"read\",\"checks\":\"write\",\"contents\":\"read\"}}", + true, + false)) + .willReturn(aResponse().withHeaders(HEADERS).withBody(InstallationAccessToken.TOKEN_B.json()))); + githubApi.stubFor(post(urlEqualTo("/app/installations/654321/access_tokens")) + .withRequestBody(equalToJson( + "{\"repositories\":[\"repository-a\",\"repository-b\"],\"permissions\":{\"pull_requests\":\"write\",\"metadata\":\"read\",\"checks\":\"write\",\"contents\":\"read\"}}", + true, + false)) + .willReturn(aResponse().withHeaders(HEADERS).withBody(InstallationAccessToken.TOKEN_AB.json()))); + githubApi.stubFor( + post(urlEqualTo("/app/installations/654321/access_tokens")) + .withRequestBody(equalToJson( + "{\"repositories\":[\"repository-a\",\"repository-b\",\"repository-c\"],\"permissions\":{\"pull_requests\":\"write\",\"metadata\":\"read\",\"checks\":\"write\",\"contents\":\"read\"}}", + true, + false)) + .willReturn( + aResponse() + .withHeaders(HEADERS) + .withStatus(422) + .withBody( + "{\"message\":\"There is at least one repository that does not exist or is not accessible to the parent installation.\",\"documentation_url\":\"https://docs.github.com/rest/reference/apps#create-an-installation-access-token-for-an-app\",\"status\":\"422\"}"))); + + // Stub installation repositories + githubApi.stubFor( + get(urlEqualTo("/installation/repositories")) + .withHeader("Authorization", equalTo(InstallationAccessToken.TOKEN.bearer())) + .willReturn( + aResponse() + .withHeaders(HEADERS) + .withBody( + "{\"repositories\":[{\"id\":1,\"name\":\"repository-a\",\"full_name\":\"cloudbeers/repository-a\"},{\"id\":2,\"name\":\"repository-b\",\"full_name\":\"cloudbeers/repository-b\"}]}"))); + githubApi.stubFor( + get(urlEqualTo("/installation/repositories")) + .withHeader("Authorization", equalTo(InstallationAccessToken.TOKEN_A.bearer())) + .willReturn( + aResponse() + .withHeaders(HEADERS) + .withBody( + "{\"repositories\":[{\"id\":1,\"name\":\"repository-a\",\"full_name\":\"cloudbeers/repository-a\"}]}"))); + githubApi.stubFor( + get(urlEqualTo("/installation/repositories")) + .withHeader("Authorization", equalTo(InstallationAccessToken.TOKEN_B.bearer())) + .willReturn( + aResponse() + .withHeaders(HEADERS) + .withBody( + "{\"repositories\":[{\"id\":2,\"name\":\"repository-b\",\"full_name\":\"cloudbeers/repository-b\"}]}"))); + githubApi.stubFor( + get(urlEqualTo("/installation/repositories")) + .withHeader("Authorization", equalTo(InstallationAccessToken.TOKEN_AB.bearer())) + .willReturn( + aResponse() + .withHeaders(HEADERS) + .withBody( + "{\"repositories\":[{\"id\":1,\"name\":\"repository-a\",\"full_name\":\"cloudbeers/repository-a\"},{\"id\":2,\"name\":\"repository-b\",\"full_name\":\"cloudbeers/repository-b\"}]}"))); + } + + @After + public void cleanup() throws IOException, InterruptedException { + if (project != null) { + project.delete(); + } + if (credentials != null && credentialsStore != null) { + credentialsStore.removeCredentials(Domain.global(), credentials); + } + } + + @Test + public void inferredRepository() throws Exception { + credentials.setRepositoryAccessStrategy(new AccessInferredRepository()); + + final var build = r.buildAndAssertSuccess(project); + + // Only the inferred repository should be accessible from the contextualized credentials + assertAccessibleRepositories(build, "cloudbeers/repository-a"); + } + + @Test + public void inferredOwner() throws Exception { + credentials.setRepositoryAccessStrategy(new AccessInferredOwner()); + + final var build = r.buildAndAssertSuccess(project); + + // All repositories owned by inferred owner should be accessible from the contextualized credentials (TOKEN) + assertAccessibleRepositories(build, "cloudbeers/repository-a", "cloudbeers/repository-b"); + } + + @Test + public void specifiedRepositoriesA() throws Exception { + credentials.setRepositoryAccessStrategy( + new AccessSpecifiedRepositories("cloudbeers", Arrays.asList("repository-a"))); + + final var build = r.buildAndAssertSuccess(project); + + // Only specified repositories should be accessible from the contextualized credentials (TOKEN_A) + assertAccessibleRepositories(build, "cloudbeers/repository-a"); + } + + @Test + public void specifiedRepositoriesB() throws Exception { + credentials.setRepositoryAccessStrategy( + new AccessSpecifiedRepositories("cloudbeers", Arrays.asList("repository-b"))); + + final var build = r.buildAndAssertSuccess(project); + + // Only specified repositories should be accessible from the contextualized credentials (TOKEN_B) + assertAccessibleRepositories(build, "cloudbeers/repository-b"); + } + + @Test + public void specifiedRepositoriesAB() throws Exception { + credentials.setRepositoryAccessStrategy( + new AccessSpecifiedRepositories("cloudbeers", Arrays.asList("repository-a", "repository-b"))); + + final var build = r.buildAndAssertSuccess(project); + + // Only specified repositories should be accessible from the contextualized credentials (TOKEN_AB) + assertAccessibleRepositories(build, "cloudbeers/repository-a", "cloudbeers/repository-b"); + } + + @Test + public void specifiedRepositoriesABC() throws Exception { + credentials.setRepositoryAccessStrategy(new AccessSpecifiedRepositories( + "cloudbeers", Arrays.asList("repository-a", "repository-b", "repository-c"))); + + final var build = r.buildAndAssertStatus(Result.FAILURE, project); + + // Should fail as one specified repository does not exist + r.waitForMessage( + "There is at least one repository that does not exist or is not accessible to the parent installation.", + build); + } + + /** + * Demonstrates how a user who only has access to edit a Jenkinsfile can abuse GithubProjectProperty in combination + * with the properties step to access any repository accessible to the app installation, as long as the Pipeline is + * not part of a MultiBranchProject. + */ + @Test + public void propertiesStepAllowsAccessBypass() throws Exception { + credentials.setRepositoryAccessStrategy(new AccessInferredRepository()); + + project.setDefinition(new CpsFlowDefinition( + "properties([githubProjectProperty('https://github.com/cloudbeers/repository-b/')])\n" + + "getAccessibleRepositories(credentialsId: '" + credentials.getId() + + "', githubApiUri: 'http://localhost:" + githubApi.port() + "/installation/repositories')", + true)); + + // First build uses the property configured in RunWithCredentialsTest#before + var build = r.buildAndAssertSuccess(project); + assertAccessibleRepositories(build, "cloudbeers/repository-a"); + + // But the second build uses the new property configured by the properties step. + build = r.buildAndAssertSuccess(project); + assertAccessibleRepositories(build, "cloudbeers/repository-b"); + } + + private static void assertAccessibleRepositories(Run build, String... repositoryNames) { + var action = build.getAction(GetAccessibleRepositories.AccessibleRepositoriesAction.class); + assertThat(action.repositories, contains(repositoryNames)); + } + + public static class GetAccessibleRepositories extends Step { + private final String credentialsId; + private final String githubApiUri; + + @DataBoundConstructor + public GetAccessibleRepositories(String credentialsId, String githubApiUri) { + this.credentialsId = credentialsId; + this.githubApiUri = githubApiUri; + } + + @Override + public StepExecution start(StepContext context) throws Exception { + return StepExecutions.synchronous(context, c -> { + var run = c.get(Run.class); + var credentials = + CredentialsProvider.findCredentialById(credentialsId, GitHubAppCredentials.class, run); + var req = ProxyConfiguration.newHttpRequestBuilder(URI.create(githubApiUri)) + .header( + "Authorization", + "Bearer " + credentials.getPassword().getPlainText()) + .header("Accept", "application/vnd.github+json") + .GET() + .build(); + var rsp = ProxyConfiguration.newHttpClient().send(req, BodyHandlers.ofString()); + var body = rsp.body(); + c.get(TaskListener.class).getLogger().println(body); + List repositoryNames = new ArrayList<>(); + var json = JSONObject.fromObject(body); + for (var repository : json.getJSONArray("repositories")) { + var repoName = ((JSONObject) repository).getString("full_name"); + repositoryNames.add(repoName); + } + run.addAction(new AccessibleRepositoriesAction(repositoryNames)); + return null; + }); + } + + public static class AccessibleRepositoriesAction extends InvisibleAction { + private final List repositories; + + public AccessibleRepositoriesAction(List repositories) { + this.repositories = new ArrayList<>(repositories); + } + } + + @TestExtension + public static class DescriptorImpl extends StepDescriptor { + @Override + public Set> getRequiredContext() { + return Set.of(TaskListener.class); + } + + @Override + public String getFunctionName() { + return "getAccessibleRepositories"; + } + } + } +} diff --git a/src/test/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/MigrationAdminMonitorTest/smokes/credentials.xml b/src/test/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/MigrationAdminMonitorTest/smokes/credentials.xml new file mode 100644 index 000000000..09785af2d --- /dev/null +++ b/src/test/resources/org/jenkinsci/plugins/github_branch_source/app_credentials/MigrationAdminMonitorTest/smokes/credentials.xml @@ -0,0 +1,27 @@ + + + + + + + + + + GLOBAL + old-credentials-with-owner + description + 54321 + does-not-matter + cloudBeers + + + GLOBAL + old-credentials-no-owner + description + 54321 + does-not-matter + + + + + \ No newline at end of file diff --git a/src/test/resources/org/jenkinsci/plugins/github_branch_source/github-app-jcasc-minimal-expected-export.yaml b/src/test/resources/org/jenkinsci/plugins/github_branch_source/github-app-jcasc-minimal-expected-export.yaml index ce7789c89..d9dd54532 100644 --- a/src/test/resources/org/jenkinsci/plugins/github_branch_source/github-app-jcasc-minimal-expected-export.yaml +++ b/src/test/resources/org/jenkinsci/plugins/github_branch_source/github-app-jcasc-minimal-expected-export.yaml @@ -1,4 +1,49 @@ -appID: "1111" -description: "GitHub app 1111" -id: "github-app" -privateKey: "some-secret-value" +system: + domainCredentials: + - credentials: + - gitHubApp: + appID: "1111" + description: "GitHub app 1111" + id: "github-app" + privateKey: "some-secret-value" + repositoryAccessStrategy: "inferOwner" + - gitHubApp: + appID: "1111" + id: "old-owner" + privateKey: "some-secret-value" + repositoryAccessStrategy: + specificRepositories: + owner: "test" + - gitHubApp: + appID: "1111" + id: "new-specific-empty" + privateKey: "some-secret-value" + - gitHubApp: + appID: "1111" + id: "new-specific-owner" + privateKey: "some-secret-value" + repositoryAccessStrategy: + specificRepositories: + owner: "test" + - gitHubApp: + appID: "1111" + id: "new-specific-repos" + privateKey: "some-secret-value" + repositoryAccessStrategy: + specificRepositories: + owner: "test" + repositories: + - "repo1" + - "repo2" + - gitHubApp: + appID: "1111" + defaultPermissionsStrategy: CONTENTS_READ + id: "new-infer-owner" + privateKey: "some-secret-value" + repositoryAccessStrategy: "inferOwner" + - gitHubApp: + appID: "1111" + defaultPermissionsStrategy: CONTENTS_WRITE + id: "new-infer-repo" + privateKey: "some-secret-value" + repositoryAccessStrategy: "inferRepository" diff --git a/src/test/resources/org/jenkinsci/plugins/github_branch_source/github-app-jcasc-minimal.yaml b/src/test/resources/org/jenkinsci/plugins/github_branch_source/github-app-jcasc-minimal.yaml index 6d3bc3ed9..51c280cdc 100644 --- a/src/test/resources/org/jenkinsci/plugins/github_branch_source/github-app-jcasc-minimal.yaml +++ b/src/test/resources/org/jenkinsci/plugins/github_branch_source/github-app-jcasc-minimal.yaml @@ -1,9 +1,55 @@ credentials: system: domainCredentials: - - credentials: - - gitHubApp: - appID: "1111" - description: "GitHub app 1111" - id: "github-app" - privateKey: "${GITHUB_APP_KEY}" \ No newline at end of file + - credentials: + - gitHubApp: + appID: "1111" + description: "GitHub app 1111" + id: "github-app" + privateKey: "${GITHUB_APP_KEY}" + - gitHubApp: + appID: "1111" + id: "old-owner" + privateKey: "${GITHUB_APP_KEY}" + owner: test + - gitHubApp: + appID: "1111" + id: "new-specific-empty" + privateKey: "${GITHUB_APP_KEY}" + defaultPermissionsStrategy: INHERIT_ALL + # ConfigurationAsCode.toYaml exports this incorrectly: It fully removes specificRepositories because it has no non-default values. + repositoryAccessStrategy: + specificRepositories: + repositories: [] + - gitHubApp: + appID: "1111" + id: "new-specific-owner" + privateKey: "${GITHUB_APP_KEY}" + defaultPermissionsStrategy: INHERIT_ALL + repositoryAccessStrategy: + specificRepositories: + owner: "test" + repositories: [] + - gitHubApp: + appID: "1111" + id: "new-specific-repos" + privateKey: "${GITHUB_APP_KEY}" + defaultPermissionsStrategy: INHERIT_ALL + repositoryAccessStrategy: + specificRepositories: + owner: "test" + repositories: + - "repo1" + - "repo2" + - gitHubApp: + appID: "1111" + id: "new-infer-owner" + privateKey: "${GITHUB_APP_KEY}" + defaultPermissionsStrategy: CONTENTS_READ + repositoryAccessStrategy: "inferOwner" + - gitHubApp: + appID: "1111" + id: "new-infer-repo" + privateKey: "${GITHUB_APP_KEY}" + defaultPermissionsStrategy: CONTENTS_WRITE + repositoryAccessStrategy: "inferRepository"