Skip to content

Commit

Permalink
BFD-3723: Determine SAMHSA authorization based on certificate identity (
Browse files Browse the repository at this point in the history
  • Loading branch information
aschey-forpeople authored Dec 2, 2024
1 parent a142293 commit 93e2728
Show file tree
Hide file tree
Showing 29 changed files with 928 additions and 91 deletions.
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
"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;

/**
* 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,69 @@
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;

/** Utilities for parsing metadata from client certificates. */
public class ClientCertificateUtils {
/** 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,12 @@ public final class CommonTransformerUtils {
private static final String COVERAGE_SIMPLE_CLASSNAME =
org.hl7.fhir.dstu3.model.Coverage.class.getSimpleName();

/**
* 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 +792,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

0 comments on commit 93e2728

Please sign in to comment.