mirror of
https://github.com/apache/nifi.git
synced 2025-03-01 15:09:11 +00:00
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:
parent
1c0297bba2
commit
cfcae70d37
@ -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>
|
||||
|
@ -24,6 +24,6 @@ public enum GitHubAuthenticationType {
|
||||
|
||||
NONE,
|
||||
PERSONAL_ACCESS_TOKEN,
|
||||
APP_INSTALLATION_TOKEN;
|
||||
|
||||
APP_INSTALLATION_TOKEN,
|
||||
APP_INSTALLATION
|
||||
}
|
||||
|
@ -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())
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user