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

BFD-3723: Determine SAMHSA authorization based on certificate identity #2486

Open
wants to merge 29 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7dbff73
add request filter to check client cert
aschey-forpeople Nov 4, 2024
f99e668
Merge remote-tracking branch 'origin/master' into BFD-3723
aschey-forpeople Nov 7, 2024
f9367e5
allow samhsa based on cert alias
aschey-forpeople Nov 8, 2024
0d659f7
rename SSM parameter, fix javadocs
aschey-forpeople Nov 8, 2024
86bdf19
fix claim tests
aschey-forpeople Nov 9, 2024
e4b1679
fix javadocs
aschey-forpeople Nov 12, 2024
0274892
Merge branch 'master' into BFD-3723
aschey-forpeople Nov 12, 2024
8a29949
always forget the periods
aschey-forpeople Nov 12, 2024
088f89e
fix missing parameter
aschey-forpeople Nov 14, 2024
12acb0d
add test certs with samhsa access
aschey-forpeople Nov 14, 2024
e949844
make test cert script configurable
aschey-forpeople Nov 14, 2024
0c5960c
add samhsa test certs for ephemeral envs
aschey-forpeople Nov 14, 2024
555707b
Merge branch 'master' into BFD-3723
aschey-forpeople Nov 15, 2024
6ec95ca
switch to jackson
aschey-forpeople Nov 15, 2024
133a488
Merge branch 'master' into BFD-3723
aschey-forpeople Nov 18, 2024
3775cd5
Merge branch 'master' into BFD-3723
aschey-forpeople Nov 26, 2024
4f9ab8f
remove unused cert from allowlist
aschey-forpeople Nov 26, 2024
6166f4b
add new cert for regression tests
aschey-forpeople Nov 26, 2024
1aa9dd4
change cert for load tests
aschey-forpeople Nov 26, 2024
17b1f77
fix copy/paste fail
aschey-forpeople Nov 26, 2024
ed0c83e
Update apps/bfd-server/bfd-server-war/src/test/java/gov/cms/bfd/serve…
aschey-forpeople Nov 26, 2024
b7bda3d
Update apps/bfd-server/bfd-server-war/src/test/java/gov/cms/bfd/serve…
aschey-forpeople Nov 26, 2024
0fdf201
Update apps/bfd-server/bfd-server-war/src/main/java/gov/cms/bfd/serve…
aschey-forpeople Nov 26, 2024
43d45f2
Update apps/bfd-server/bfd-server-war/src/main/java/gov/cms/bfd/serve…
aschey-forpeople Nov 26, 2024
47e6999
Update apps/bfd-server/bfd-server-war/src/main/java/gov/cms/bfd/serve…
aschey-forpeople Nov 26, 2024
ace9eab
Update apps/bfd-server/bfd-server-war/src/main/java/gov/cms/bfd/serve…
aschey-forpeople Nov 26, 2024
b21543a
Update apps/bfd-server/bfd-server-war/src/main/java/gov/cms/bfd/serve…
aschey-forpeople Nov 26, 2024
b70ed60
Merge branch 'master' into BFD-3723
aschey-forpeople Nov 26, 2024
053122d
fmt
aschey-forpeople Nov 26, 2024
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
11 changes: 8 additions & 3 deletions .github/scripts/pre-commit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,14 @@ runShellCheckForCommitFiles() {
for file in $commits; do
filename=$(basename -- "$file")
extension="${filename##*.}"
if [ "$extension" == "zip" ]; then
continue
fi

# Skip binary formats
case "$extension" in
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shellcheck was blowing up when trying to parse the certificate formats

"zip" | "p12" | "pfx" | "cer" | "pem")
continue ;;
*) ;;
esac

firstTwo=$( sed 's/^\(..\).*/\1/;q' "$file" )
# check for a hashbang or a .sh extension to determine if this is a shell script.
if [ "$firstTwo" == "#!" ] && [ "$filename" != "Jenkinsfile" ] || [ "$extension" == "sh" ]; then
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package gov.cms.bfd.server.war;

import static gov.cms.bfd.server.war.SpringConfiguration.SSM_PATH_SAMHSA_ALLOWED_CERTIFICATE_ALIASES_JSON;
import static gov.cms.bfd.server.war.commons.CommonTransformerUtils.SHOULD_FILTER_SAMHSA;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import gov.cms.bfd.server.war.commons.ClientCertificateUtils;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.math.BigInteger;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

/***
aschey-forpeople marked this conversation as resolved.
Show resolved Hide resolved
* Filter class to add metadata to the request that says whether clients can see SAMHSA data or not.
*/
@Component("AllowSamhsaFilterBean")
public class AllowSamhsaFilter extends OncePerRequestFilter {

/** The logger. */
private static final Logger LOGGER = LoggerFactory.getLogger(AllowSamhsaFilter.class);

/** List of allowed certificate serial numbers. */
private final List<BigInteger> samhsaAllowedSerialNumbers;

/**
* Creates a new {@link AllowSamhsaFilter}.
*
* @param samhsaAllowedCertificateAliasesJson list of certificate aliases to identify clients that
* are allowed to see SAMHSA data
* @param keyStore server key store
*/
public AllowSamhsaFilter(
@Value("${" + SSM_PATH_SAMHSA_ALLOWED_CERTIFICATE_ALIASES_JSON + "}")
String samhsaAllowedCertificateAliasesJson,
@Qualifier("serverTrustStore") KeyStore keyStore)
throws JsonProcessingException {
super();
ObjectMapper mapper = new ObjectMapper();
String[] samhsaAllowedCertAliases =
mapper.readValue(samhsaAllowedCertificateAliasesJson, String[].class);
this.samhsaAllowedSerialNumbers =
Arrays.stream(samhsaAllowedCertAliases)
.map(allowedCert -> getCertSerialNumber(keyStore, allowedCert))
.filter(Objects::nonNull)
.collect(Collectors.toList());
}

/**
* Gets the serial number from the certificate alias.
*
* @param keyStore server key store
* @param allowedCertAlias certificate alias
* @return serial number
*/
private static BigInteger getCertSerialNumber(KeyStore keyStore, String allowedCertAlias) {
try {
X509Certificate cert = ((X509Certificate) keyStore.getCertificate(allowedCertAlias));
if (cert == null) {
LOGGER.error(
"Certificate {} was configured to allow SAMHSA, but was not found", allowedCertAlias);
return null;
}
return cert.getSerialNumber();
} catch (KeyStoreException e) {
LOGGER.error("Error loading keystore", e);
return null;
}
}

/** {@inheritDoc} */
@Override
protected void doFilterInternal(
@NotNull HttpServletRequest request,
@NotNull HttpServletResponse response,
@NotNull FilterChain chain)
throws ServletException, IOException {
BigInteger serialNumber = ClientCertificateUtils.getClientSslSerialNumber(request);
// Set the attribute on the request so the transformers can check for this property
request.setAttribute(
SHOULD_FILTER_SAMHSA,
serialNumber == null || !samhsaAllowedSerialNumbers.contains(serialNumber));
chain.doFilter(request, response);
}
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
package gov.cms.bfd.server.war;

import gov.cms.bfd.server.sharedutils.BfdMDC;
import gov.cms.bfd.server.war.commons.ClientCertificateUtils;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.EOFException;
import java.io.IOException;
import java.security.cert.X509Certificate;
import java.util.Collection;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.security.auth.x500.X500Principal;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
Expand Down Expand Up @@ -66,7 +66,7 @@ public class RequestResponsePopulateMdcFilter extends OncePerRequestFilter {
/** {@inheritDoc} */
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain chain)
@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, FilterChain chain)
throws ServletException {

/*
Expand Down Expand Up @@ -132,7 +132,7 @@ private void handleRequest(HttpServletRequest request) {
BfdMDC.put(BfdMDC.computeMDCKey(MDC_PREFIX, REQUEST_PREFIX, "query_string"), queryString);
BfdMDC.put(
BfdMDC.computeMDCKey(MDC_PREFIX, REQUEST_PREFIX, "clientSSL", "DN"),
getClientSslPrincipalDistinguishedName(request));
ClientCertificateUtils.getClientSslPrincipalDistinguishedName(request));

// Record the request headers.
Enumeration<String> headerNames = request.getHeaderNames();
Expand Down Expand Up @@ -234,43 +234,4 @@ else if (headerValues.size() == 1)
BfdMDC.clear();
}
}

/**
* Gets the {@link X500Principal#getName()} for the client certificate if available.
*
* @param request the {@link HttpServletRequest} to get the client principal DN (if any) for
* @return the {@link X500Principal#getName()} for the client certificate, or <code>null</code> if
* that's not available
*/
private static String getClientSslPrincipalDistinguishedName(HttpServletRequest request) {
/*
* Note: Now that Wildfly/JBoss is properly configured with a security realm,
* this method is equivalent to calling `request.getRemoteUser()`.
*/
X509Certificate clientCert = getClientCertificate(request);
if (clientCert == null || clientCert.getSubjectX500Principal() == null) {
LOGGER.debug("No client SSL principal available: {}", clientCert);
return null;
}

return clientCert.getSubjectX500Principal().getName();
}

/**
* Gets the {@link X509Certificate} for the {@link HttpServletRequest}'s client SSL certificate if
* available.
*
* @param request the {@link HttpServletRequest} to get the client SSL certificate for
* @return the {@link X509Certificate} for the {@link HttpServletRequest}'s client SSL
* certificate, or <code>null</code> if that's not available
*/
private static X509Certificate getClientCertificate(HttpServletRequest request) {
X509Certificate[] certs =
(X509Certificate[]) request.getAttribute("jakarta.servlet.request.X509Certificate");
if (certs == null || certs.length == 0) {
LOGGER.debug("No client certificate found for request.");
return null;
}
return certs[certs.length - 1];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,13 @@
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.PersistenceUnit;
import jakarta.servlet.ServletContext;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -81,6 +86,13 @@ public class SpringConfiguration extends BaseConfiguration {
*/
public static final String PROP_ORG_FILE_NAME = "bfdServer.org.file.name";

/**
* The {@link String} property that lists the client certificates that are allowed to see SAMHSA
* data.
*/
public static final String SSM_PATH_SAMHSA_ALLOWED_CERTIFICATE_ALIASES_JSON =
"samhsa_allowed_certificate_aliases_json";

/**
* The {@link String } Boolean property that is used to enable the partially adjudicated claims
* data resources.
Expand All @@ -100,6 +112,9 @@ public class SpringConfiguration extends BaseConfiguration {
*/
public static final String SSM_PATH_PAC_CLAIM_SOURCE_TYPES = "pac/claim_source_types";

/** SSM Path for the server trust store. */
private static final String SSM_PATH_TRUSTSTORE = "paths/files/truststore";

/** The {@link String } Boolean property that is used to enable the C4DIC profile. */
public static final String SSM_PATH_C4DIC_ENABLED = "c4dic/enabled";

Expand Down Expand Up @@ -167,6 +182,25 @@ public AwsClientConfig awsClientConfig(ConfigLoader configLoader) {
return loadAwsClientConfig(configLoader);
}

/**
* Creates a {@link KeyStore} from the trust store path.
*
* @param configLoader config loader
* @return the {@link KeyStore} object
* @throws KeyStoreException if the key store can't be created
* @throws IOException if there's a problem reading the file
* @throws CertificateException if the certificates can't be loaded
* @throws NoSuchAlgorithmException if the key store algorithm can't be found
*/
@Bean(name = "serverTrustStore")
public KeyStore serverTrustStore(ConfigLoader configLoader)
throws KeyStoreException, IOException, CertificateException, NoSuchAlgorithmException {
String truststore = configLoader.readableFile(SSM_PATH_TRUSTSTORE).toString();
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(new FileInputStream(truststore), "changeit".toCharArray());
return keyStore;
}

/**
* Creates a factory to create {@link DataSource}s.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package gov.cms.bfd.server.war.commons;

import jakarta.servlet.http.HttpServletRequest;
import java.math.BigInteger;
import java.security.cert.X509Certificate;
import javax.security.auth.x500.X500Principal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/***
aschey-forpeople marked this conversation as resolved.
Show resolved Hide resolved
* Utilities for parsing metadata from client certificates.
*/
public class ClientCertificateUtils {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of this was moved here from RequestResponseMdcFilter so it can be reused.

/** The logger for this filter. */
private static final Logger LOGGER = LoggerFactory.getLogger(ClientCertificateUtils.class);

/**
* Gets the {@link X500Principal#getName()} for the client certificate if available.
*
* @param request the {@link HttpServletRequest} to get the client principal DN (if any) for
* @return the {@link X500Principal#getName()} for the client certificate, or <code>null</code> if
* that's not available
*/
public static String getClientSslPrincipalDistinguishedName(HttpServletRequest request) {
/*
* Note: Now that Wildfly/JBoss is properly configured with a security realm,
* this method is equivalent to calling `request.getRemoteUser()`.
*/
X509Certificate clientCert = getClientCertificate(request);
if (clientCert == null || clientCert.getSubjectX500Principal() == null) {
LOGGER.debug("No client SSL principal available: {}", clientCert);
return null;
}

return clientCert.getSubjectX500Principal().getName();
}

/**
* Gets the serial number for the client certificate if available.
*
* @param request the {@link HttpServletRequest} containing the certificate
* @return the serial number
*/
public static BigInteger getClientSslSerialNumber(HttpServletRequest request) {
X509Certificate clientCert = getClientCertificate(request);
if (clientCert == null) {
LOGGER.debug("No client cert available");
return null;
}

return clientCert.getSerialNumber();
}

/**
* Gets the {@link X509Certificate} for the {@link HttpServletRequest}'s client SSL certificate if
* available.
*
* @param request the {@link HttpServletRequest} to get the client SSL certificate for
* @return the {@link X509Certificate} for the {@link HttpServletRequest}'s client SSL
* certificate, or <code>null</code> if that's not available
*/
private static X509Certificate getClientCertificate(HttpServletRequest request) {
X509Certificate[] certs =
(X509Certificate[]) request.getAttribute("jakarta.servlet.request.X509Certificate");
if (certs == null || certs.length == 0) {
LOGGER.debug("No client certificate found for request.");
return null;
}
return certs[certs.length - 1];
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package gov.cms.bfd.server.war.commons;

import ca.uhn.fhir.model.primitive.IdDt;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.param.TokenAndListParam;
import ca.uhn.fhir.rest.param.TokenOrListParam;
import ca.uhn.fhir.rest.param.TokenParam;
Expand Down Expand Up @@ -80,6 +81,11 @@ public final class CommonTransformerUtils {
private static final String COVERAGE_SIMPLE_CLASSNAME =
org.hl7.fhir.dstu3.model.Coverage.class.getSimpleName();

/***
aschey-forpeople marked this conversation as resolved.
Show resolved Hide resolved
* Constant for setting and retrieving the attribute from the request that determines if the client can see SAMHSA data.
* */
public static final String SHOULD_FILTER_SAMHSA = "SHOULD_FILTER_SAMHSA";

/**
* Tracks the {@link CcwCodebookInterface} that have already had code lookup failures due to
* missing {@link Value} matches. Why track this? To ensure that we don't spam log events for
Expand Down Expand Up @@ -785,4 +791,24 @@ public static Set<ClaimType> parseTypeParam(TokenAndListParam type) {

return claimTypes;
}

/**
* Determines if SAMHSA data should be filtered based on the client's identity and the
* "excludeSAMHSA" request parameter.
*
* @param excludeSamhsa the value of the "excludeSAMHSA" parameter
* @param requestDetails the {@link RequestDetails} containing the authentication info
* @return whether to filter SAMHSA
*/
public static boolean shouldFilterSamhsa(String excludeSamhsa, RequestDetails requestDetails) {

if (Boolean.parseBoolean(excludeSamhsa)) {
return true;
}
Object shouldFilterSamhsa = requestDetails.getAttribute(SHOULD_FILTER_SAMHSA);
if (shouldFilterSamhsa == null) {
throw new BadCodeMonkeyException(SHOULD_FILTER_SAMHSA + " attribute missing from request");
}
return (boolean) shouldFilterSamhsa;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ public Bundle findByPatient(
Long beneficiaryId = Long.parseLong(patient.getIdPart());
Set<ClaimType> claimTypesRequested = CommonTransformerUtils.parseTypeParam(type);
boolean includeTaxNumbers = returnIncludeTaxNumbers(requestDetails);
boolean filterSamhsa = Boolean.parseBoolean(excludeSamhsa);
boolean filterSamhsa = CommonTransformerUtils.shouldFilterSamhsa(excludeSamhsa, requestDetails);
Map<String, String> operationOptions = new HashMap<>();
operationOptions.put("by", "patient");
operationOptions.put("IncludeTaxNumbers", String.valueOf(includeTaxNumbers));
Expand Down
Loading