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();
+ }
+ }
+}