Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NIFI-13231 Added Private key authentication for GitHubFlowRegistryClient #8890

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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>
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@ public enum GitHubAuthenticationType {

NONE,
PERSONAL_ACCESS_TOKEN,
APP_INSTALLATION_TOKEN;

APP_INSTALLATION_TOKEN,
APP_INSTALLATION
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
exceptionfactory marked this conversation as resolved.
Show resolved Hide resolved
.dependsOn(AUTHENTICATION_TYPE, GitHubAuthenticationType.APP_INSTALLATION.name())
.build();

static final List<PropertyDescriptor> PROPERTY_DESCRIPTORS = List.of(
GITHUB_API_URL,
REPOSITORY_OWNER,
Expand All @@ -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";
Expand Down Expand Up @@ -641,6 +661,8 @@ protected GitHubRepositoryClient createRepositoryClient(final FlowRegistryClient
.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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,19 @@
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;
Expand All @@ -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;

Expand All @@ -71,9 +81,13 @@ private GitHubRepositoryClient(final Builder builder) throws IOException, FlowRe
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();
Expand All @@ -90,6 +104,11 @@ private GitHubRepositoryClient(final Builder builder) throws IOException, FlowRe
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);
Expand Down Expand Up @@ -397,6 +416,26 @@ private <T> T execute(final GHRequest<T> action) throws FlowRegistryException, I
}
}

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.
*
Expand Down Expand Up @@ -427,6 +466,8 @@ public static class Builder {
private String repoOwner;
private String repoName;
private String repoPath;
private String appPrivateKey;
private String appId;

public Builder apiUrl(final String apiUrl) {
this.apiUrl = apiUrl;
Expand Down Expand Up @@ -462,6 +503,15 @@ public Builder repoPath(final String repoPath) {
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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
}