diff --git a/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/pom.xml b/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/pom.xml index be73f95343..d4e8fa5f3e 100644 --- a/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/pom.xml +++ b/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/pom.xml @@ -21,11 +21,10 @@ nifi-github-extensions jar + + 0.12.5 + - - org.apache.nifi - nifi-api - org.apache.nifi nifi-utils @@ -39,5 +38,20 @@ github-api ${github-api.version} + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + diff --git a/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/GitHubAuthenticationType.java b/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/GitHubAuthenticationType.java index 964c376e8d..8cb7c4be7c 100644 --- a/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/GitHubAuthenticationType.java +++ b/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/GitHubAuthenticationType.java @@ -24,6 +24,6 @@ public enum GitHubAuthenticationType { NONE, PERSONAL_ACCESS_TOKEN, - APP_INSTALLATION_TOKEN; - + APP_INSTALLATION_TOKEN, + APP_INSTALLATION } diff --git a/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/GitHubFlowRegistryClient.java b/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/GitHubFlowRegistryClient.java index 34fce1d4cb..e17fc33050 100644 --- a/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/GitHubFlowRegistryClient.java +++ b/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/GitHubFlowRegistryClient.java @@ -129,6 +129,24 @@ public class GitHubFlowRegistryClient extends AbstractFlowRegistryClient { .dependsOn(AUTHENTICATION_TYPE, GitHubAuthenticationType.APP_INSTALLATION_TOKEN.name()) .build(); + static final PropertyDescriptor APP_ID = new PropertyDescriptor.Builder() + .name("App ID") + .description("Identifier of GitHub App to use for authentication") + .addValidator(StandardValidators.NON_BLANK_VALIDATOR) + .required(true) + .sensitive(false) + .dependsOn(AUTHENTICATION_TYPE, GitHubAuthenticationType.APP_INSTALLATION.name()) + .build(); + + static final PropertyDescriptor APP_PRIVATE_KEY = new PropertyDescriptor.Builder() + .name("App Private Key") + .description("RSA private key associated with GitHub App to use for authentication.") + .addValidator(StandardValidators.NON_BLANK_VALIDATOR) + .required(true) + .sensitive(true) + .dependsOn(AUTHENTICATION_TYPE, GitHubAuthenticationType.APP_INSTALLATION.name()) + .build(); + static final List PROPERTY_DESCRIPTORS = List.of( GITHUB_API_URL, REPOSITORY_OWNER, @@ -137,7 +155,9 @@ public class GitHubFlowRegistryClient extends AbstractFlowRegistryClient { REPOSITORY_PATH, AUTHENTICATION_TYPE, PERSONAL_ACCESS_TOKEN, - APP_INSTALLATION_TOKEN + APP_INSTALLATION_TOKEN, + APP_ID, + APP_PRIVATE_KEY ); static final String DEFAULT_BUCKET_NAME = "default"; @@ -641,6 +661,8 @@ public class GitHubFlowRegistryClient extends AbstractFlowRegistryClient { .authenticationType(GitHubAuthenticationType.valueOf(context.getProperty(AUTHENTICATION_TYPE).getValue())) .personalAccessToken(context.getProperty(PERSONAL_ACCESS_TOKEN).getValue()) .appInstallationToken(context.getProperty(APP_INSTALLATION_TOKEN).getValue()) + .appId(context.getProperty(APP_ID).getValue()) + .appPrivateKey(context.getProperty(APP_PRIVATE_KEY).getValue()) .repoOwner(context.getProperty(REPOSITORY_OWNER).getValue()) .repoName(context.getProperty(REPOSITORY_NAME).getValue()) .repoPath(context.getProperty(REPOSITORY_PATH).getValue()) diff --git a/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/GitHubRepositoryClient.java b/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/GitHubRepositoryClient.java index fc85db0fc8..32ec2cff8a 100644 --- a/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/GitHubRepositoryClient.java +++ b/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/GitHubRepositoryClient.java @@ -29,13 +29,19 @@ import org.kohsuke.github.GHRef; import org.kohsuke.github.GHRepository; import org.kohsuke.github.GitHub; import org.kohsuke.github.GitHubBuilder; +import org.kohsuke.github.authorization.AppInstallationAuthorizationProvider; +import org.kohsuke.github.authorization.AuthorizationProvider; +import org.kohsuke.github.extras.authorization.JWTTokenProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; +import java.security.PrivateKey; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -49,6 +55,10 @@ public class GitHubRepositoryClient { private static final Logger LOGGER = LoggerFactory.getLogger(GitHubRepositoryClient.class); + private static final String REPOSITORY_CONTENTS_PERMISSION = "contents"; + + private static final String WRITE_ACCESS = "write"; + private static final String BRANCH_REF_PATTERN = "refs/heads/%s"; private static final int COMMIT_PAGE_SIZE = 50; @@ -71,9 +81,13 @@ public class GitHubRepositoryClient { repoName = Objects.requireNonNull(builder.repoName, "Repository Name is required"); authenticationType = Objects.requireNonNull(builder.authenticationType, "Authentication Type is required"); + // Map of permission to access for tracking App Installation permissions from internal authorization + final Map appPermissions = new LinkedHashMap<>(); + switch (authenticationType) { case PERSONAL_ACCESS_TOKEN -> gitHubBuilder.withOAuthToken(builder.personalAccessToken); case APP_INSTALLATION_TOKEN -> gitHubBuilder.withAppInstallationToken(builder.appInstallationToken); + case APP_INSTALLATION -> gitHubBuilder.withAuthorizationProvider(getAppInstallationAuthorizationProvider(builder, appPermissions)); } gitHub = gitHubBuilder.build(); @@ -90,6 +104,11 @@ public class GitHubRepositoryClient { if (gitHub.isAnonymous()) { canRead = true; canWrite = false; + } else if (GitHubAuthenticationType.APP_INSTALLATION == authenticationType) { + // The contents permission can be read or write when defined for an App Installation + canRead = appPermissions.containsKey(REPOSITORY_CONTENTS_PERMISSION); + final String repositoryContentsPermissions = appPermissions.get(REPOSITORY_CONTENTS_PERMISSION); + canWrite = WRITE_ACCESS.equals(repositoryContentsPermissions); } else { final GHMyself currentUser = gitHub.getMyself(); canRead = repository.hasPermission(currentUser, GHPermissionType.READ); @@ -397,6 +416,26 @@ public class GitHubRepositoryClient { } } + private AuthorizationProvider getAppInstallationAuthorizationProvider(final Builder builder, final Map appPermissions) throws FlowRegistryException { + final AuthorizationProvider appAuthorizationProvider = getAppAuthorizationProvider(builder.appId, builder.appPrivateKey); + return new AppInstallationAuthorizationProvider(gitHubApp -> { + // Get Permissions for initial authentication as GitHub App before returning App Installation + appPermissions.putAll(gitHubApp.getPermissions()); + // Get App Installation for named Repository + return gitHubApp.getInstallationByRepository(builder.repoOwner, builder.repoName); + }, appAuthorizationProvider); + } + + private AuthorizationProvider getAppAuthorizationProvider(final String appId, final String appPrivateKey) throws FlowRegistryException { + try { + final PrivateKeyReader privateKeyReader = new StandardPrivateKeyReader(); + final PrivateKey privateKey = privateKeyReader.readPrivateKey(appPrivateKey); + return new JWTTokenProvider(appId, privateKey); + } catch (final Exception e) { + throw new FlowRegistryException("Failed to build Authorization Provider from App ID and App Private Key", e); + } + } + /** * Functional interface for making a request to GitHub which may throw IOException. * @@ -427,6 +466,8 @@ public class GitHubRepositoryClient { private String repoOwner; private String repoName; private String repoPath; + private String appPrivateKey; + private String appId; public Builder apiUrl(final String apiUrl) { this.apiUrl = apiUrl; @@ -462,6 +503,15 @@ public class GitHubRepositoryClient { this.repoPath = repoPath; return this; } + public Builder appId(final String appId) { + this.appId = appId; + return this; + } + + public Builder appPrivateKey(final String appPrivateKey) { + this.appPrivateKey = appPrivateKey; + return this; + } public GitHubRepositoryClient build() throws IOException, FlowRegistryException { return new GitHubRepositoryClient(this); diff --git a/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/PrivateKeyReader.java b/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/PrivateKeyReader.java new file mode 100644 index 0000000000..4b604dafb6 --- /dev/null +++ b/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/PrivateKeyReader.java @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.nifi.github; + +import java.security.GeneralSecurityException; +import java.security.PrivateKey; + +/** + * Abstraction for reading Application Private Keys from encoded string + */ +interface PrivateKeyReader { + /** + * Read Private Key from PEM-encoded string + * + * @param inputPrivateKey PEM-encoded string + * @return Private Key + * @throws GeneralSecurityException Thrown on failure to read Private Key + */ + PrivateKey readPrivateKey(String inputPrivateKey) throws GeneralSecurityException; +} diff --git a/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/StandardPrivateKeyReader.java b/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/StandardPrivateKeyReader.java new file mode 100644 index 0000000000..8fab765a54 --- /dev/null +++ b/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/StandardPrivateKeyReader.java @@ -0,0 +1,110 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.nifi.github; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.StringReader; +import java.security.GeneralSecurityException; +import java.security.InvalidKeyException; +import java.security.Key; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.interfaces.RSAPrivateKey; +import java.util.Base64; +import java.util.Objects; + +/** + * Standard implementation of Private Key Reader supporting RSA PKCS #1 encoding + */ +class StandardPrivateKeyReader implements PrivateKeyReader { + + private static final Base64.Decoder DECODER = Base64.getDecoder(); + + private static final String RSA_ALGORITHM = "RSA"; + + private static final String PKCS1_FORMAT = "PKCS#1"; + + private static final String PEM_BOUNDARY_PREFIX = "-----"; + + /** + * Read RSA Private Key from PEM-encoded PKCS #1 string + * + * @param inputPrivateKey PEM-encoded string + * @return RSA Private Key + * @throws GeneralSecurityException Thrown on failures to parse private key + */ + @Override + public PrivateKey readPrivateKey(final String inputPrivateKey) throws GeneralSecurityException { + Objects.requireNonNull(inputPrivateKey, "Private Key required"); + + final byte[] decoded = getDecoded(inputPrivateKey); + + final PrivateKey encodedPrivateKey = new PKCS1EncodedPrivateKey(decoded); + final KeyFactory keyFactory = KeyFactory.getInstance(RSA_ALGORITHM); + final Key translatedKey = keyFactory.translateKey(encodedPrivateKey); + if (translatedKey instanceof RSAPrivateKey) { + return (RSAPrivateKey) translatedKey; + } else { + throw new InvalidKeyException("Failed to parse encoded RSA Private Key: unsupported class [%s]".formatted(translatedKey.getClass())); + } + } + + private byte[] getDecoded(final String inputPrivateKey) throws GeneralSecurityException { + try (BufferedReader bufferedReader = new BufferedReader(new StringReader(inputPrivateKey))) { + final StringBuilder encodedBuilder = new StringBuilder(); + + String line = bufferedReader.readLine(); + while (line != null) { + if (!line.startsWith(PEM_BOUNDARY_PREFIX)) { + encodedBuilder.append(line); + } + + line = bufferedReader.readLine(); + } + + final String encoded = encodedBuilder.toString(); + return DECODER.decode(encoded); + } catch (final IOException e) { + throw new InvalidKeyException("Failed to read Private Key", e); + } + } + + private static class PKCS1EncodedPrivateKey implements PrivateKey { + + private final byte[] encoded; + + private PKCS1EncodedPrivateKey(final byte[] encoded) { + this.encoded = encoded; + } + + @Override + public String getAlgorithm() { + return RSA_ALGORITHM; + } + + @Override + public String getFormat() { + return PKCS1_FORMAT; + } + + @Override + public byte[] getEncoded() { + return encoded.clone(); + } + } +}