NIFI-13231 Added App Private Key Auth to GitHub FlowRegistryClient

This closes #8890

Signed-off-by: David Handermann <exceptionfactory@apache.org>
Co-authored-by: David Handermann <exceptionfactory@apache.org>
This commit is contained in:
maybevanshh 2024-05-29 15:57:28 +05:30 committed by exceptionfactory
parent 1c0297bba2
commit cfcae70d37
No known key found for this signature in database
6 changed files with 237 additions and 7 deletions

View File

@ -21,11 +21,10 @@
</parent>
<artifactId>nifi-github-extensions</artifactId>
<packaging>jar</packaging>
<properties>
<jjwt.version>0.12.5</jjwt.version>
</properties>
<dependencies>
<dependency>
<groupId>org.apache.nifi</groupId>
<artifactId>nifi-api</artifactId>
</dependency>
<dependency>
<groupId>org.apache.nifi</groupId>
<artifactId>nifi-utils</artifactId>
@ -39,5 +38,20 @@
<artifactId>github-api</artifactId>
<version>${github-api.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
</dependency>
</dependencies>
</project>

View File

@ -24,6 +24,6 @@ public enum GitHubAuthenticationType {
NONE,
PERSONAL_ACCESS_TOKEN,
APP_INSTALLATION_TOKEN;
APP_INSTALLATION_TOKEN,
APP_INSTALLATION
}

View File

@ -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<PropertyDescriptor> 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())

View File

@ -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<String, String> 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<String, String> 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);

View File

@ -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;
}

View File

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