Context.java
package net.hostsharing.hsadminng.rbac.context;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.AllArgsConstructor;
import lombok.SneakyThrows;
import lombok.val;
import net.hostsharing.hsadminng.errors.ForbiddenException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.context.request.RequestContextHolder;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Collection;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static java.util.function.Predicate.not;
import static org.springframework.transaction.annotation.Propagation.MANDATORY;
@Service
@AllArgsConstructor
public class Context {
private static final Set<String> HEADERS_TO_IGNORE = Set.of(
"accept-encoding",
"connection",
"content-length",
"host",
"user-agent");
// headers containing credentials which must not leak into the audit-log (base.tx_context.currentRequest)
private static final Set<String> HEADERS_TO_MASK = Set.of(
"authorization",
"cookie",
"hostsharing-api-key",
"x-api-key");
// patterns of property names whose values are masked in the audit-logged request body,
// e.g. the write-only password/totpKey properties of hosting-asset configs:
// any name ending with "password", starting or ending with "secret", or ending with "totpKey"
private static final Pattern BODY_PROPERTIES_TO_MASK = Pattern.compile(
"password$|^secret|secret$|totpKey$",
Pattern.CASE_INSENSITIVE);
private static final String MASKED_PROPERTY_VALUE = "<masked>";
private static final ObjectMapper BODY_MASKING_JSON_MAPPER = new ObjectMapper();
@PersistenceContext
private EntityManager em;
@Autowired(required = false)
private HttpServletRequest request;
@Transactional(propagation = MANDATORY)
public void define(final String currentSubject) {
define(currentSubject, null);
}
@Transactional(propagation = MANDATORY)
public void define() {
val auth = SecurityContextHolder.getContext().getAuthentication();
// TODO.impl [for Story#458]: this code works for simplified JWT in tests as well as the real Keycloak, but there should be only one way
// if "preferred_username" is set, use it, otherwise use "sub"
val username = Optional.of(auth)
.filter(JwtAuthenticationToken.class::isInstance)
.map(JwtAuthenticationToken.class::cast)
.map(JwtAuthenticationToken::getToken)
.map(token -> token.getClaimAsString("preferred_username"))
.filter(claim -> !claim.isBlank()) // force to getName ("sub") if blank
.orElseGet(auth::getName);
define(toTask(request), toCurl(request), username, null, currentSubjectGroupNamesFromJWT());
}
@Transactional(propagation = MANDATORY)
public void define(final String currentSubject, final String assumedRoles) {
define(toTask(request), toCurl(request), currentSubject, assumedRoles, null);
}
@Transactional(propagation = MANDATORY)
public void assumeRoles(final String assumedRoles) {
final var currentSubject = SecurityContextHolder.getContext().getAuthentication().getName();
define(toTask(request), toCurl(request), currentSubject, assumedRoles, currentSubjectGroupNamesFromJWT());
}
@Transactional(propagation = MANDATORY)
public void define(
final String currentTask,
final String currentRequest,
final String currentSubject,
final String assumedRoles) {
define(currentTask, currentRequest, currentSubject, assumedRoles, null);
}
// TODO.refa: this method has to many String parameters, maybe reafactor to a builder-pattern?
@Transactional(propagation = MANDATORY)
public void define(
final String currentTask,
final String currentRequest,
final String currentSubject,
final String assumedRoles,
final String currentSubjectGroups) {
final var query = em.createNativeQuery("""
call base.defineContext(
cast(:currentTask as varchar(127)),
cast(:currentRequest as text),
cast(:currentSubject as varchar(63)),
cast(:assumedRoles as text),
cast(:currentSubjectGroups as text));
""");
query.setParameter("currentTask", shortenToMaxLength(currentTask, 127));
query.setParameter("currentRequest", currentRequest);
query.setParameter("currentSubject", subjectName(currentSubject));
query.setParameter("assumedRoles", assumedRoles != null ? assumedRoles : "");
query.setParameter("currentSubjectGroups", currentSubjectGroups != null ? currentSubjectGroups : "");
query.executeUpdate();
}
@Transactional(propagation = MANDATORY)
public void requireGlobalAdmin(final String message) {
define();
if (!isGlobalAdmin()) {
throw new ForbiddenException(message);
}
}
public String fetchCurrentTask() {
return (String) em.createNativeQuery("select current_setting('hsadminng.currentTask');").getSingleResult();
}
public String fetchCurrentSubject() {
return String.valueOf(em.createNativeQuery("select base.currentSubject()").getSingleResult());
}
public UUID fetchCurrentSubjectUuid() {
return (UUID) em.createNativeQuery("select rbac.currentSubjectUuid()", UUID.class).getSingleResult();
}
public String[] fetchAssumedRolesNames() {
return (String[]) em.createNativeQuery("select base.assumedRoles() as roles", String[].class).getSingleResult();
}
public UUID[] fetchCurrentSubjectOrAssumedRolesUuids() {
return (UUID[]) em.createNativeQuery("select rbac.currentSubjectOrAssumedRolesUuids() as uuids", UUID[].class).getSingleResult();
}
public boolean isGlobalAdmin() {
return (boolean) em.createNativeQuery("select rbac.isGlobalAdmin()", boolean.class).getSingleResult();
}
public boolean hasAssumedRole() {
val assumedRoles = fetchAssumedRolesNames();
return assumedRoles != null && Stream.of(assumedRoles).anyMatch(StringUtils::isNotBlank);
}
public boolean hasGlobalAdminRole() {
return (boolean) em.createNativeQuery("select rbac.hasGlobalAdminRole()", boolean.class).getSingleResult();
}
public static String getCallerMethodNameFromStackFrame(final int skipFrames) {
final Optional<StackWalker.StackFrame> caller =
StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
.walk(frames -> frames
.skip(skipFrames)
.filter(c -> c.getDeclaringClass() != Context.class)
.filter(c -> c.getDeclaringClass()
.getPackageName()
.startsWith("net.hostsharing.hsadminng"))
.filter(c -> !c.getDeclaringClass().getName().contains("$$SpringCGLIB$$"))
.findFirst());
return caller.map(
c -> c.getDeclaringClass().getSimpleName() + "." + c.getMethodName())
.orElse("unknown");
}
private String subjectName(final String nameOrUuid) {
if (nameOrUuid == null) {
return null;
}
// a subject UUID (e.g. JWT "sub" claim) is the default, a plain subject name the fallback
return toUuid(nameOrUuid)
.map(authenticatedUuid -> findSubjectNameByUuid(authenticatedUuid)
.orElseThrow(() -> new NoSuchElementException("cannot find Subject by uuid: " + authenticatedUuid)))
.orElse(nameOrUuid);
}
private static Optional<UUID> toUuid(final String maybeUuid) {
try {
return Optional.of(UUID.fromString(maybeUuid));
} catch (final IllegalArgumentException e) {
return Optional.empty();
}
}
private Optional<String> findSubjectNameByUuid(final UUID authenticatedUuid) {
final Stream<?> subjectNames = em.createNativeQuery("SELECT name FROM rbac.subject s WHERE s.uuid=:uuid")
.setParameter("uuid", authenticatedUuid)
.getResultStream();
return subjectNames.findFirst().map(Object::toString);
}
public List<String> fetchClaimedSubjectGroupNames() {
return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.filter(JwtAuthenticationToken.class::isInstance)
.map(JwtAuthenticationToken.class::cast)
.map(JwtAuthenticationToken::getToken)
.map(token -> token.getClaim("groups"))
.map(Context::groupNamesFromClaim)
.orElse(List.of());
}
private String currentSubjectGroupNamesFromJWT() {
return String.join(";", fetchClaimedSubjectGroupNames());
}
private static List<String> groupNamesFromClaim(final Object semicolonSeparatedGroupsClaim) {
final Stream<?> groupNames = semicolonSeparatedGroupsClaim instanceof Collection<?> groups
? groups.stream()
: Stream.of(semicolonSeparatedGroupsClaim);
return groupNames
.map(String::valueOf)
// split like the DB layer splits currentSubjectGroups, see rbac.determineCurrentSubjectGroupUuids
.flatMap(name -> Stream.of(name.split(";")))
.map(String::trim)
.filter(not(String::isBlank))
.distinct()
.toList();
}
private String toTask(final HttpServletRequest request) {
if (isRequestScopeAvailable()) {
return request.getMethod() + " " + request.getRequestURI();
} else {
return getCallerMethodNameFromStackFrame(2);
}
}
@SneakyThrows
private String toCurl(final HttpServletRequest request) {
if (!isRequestScopeAvailable()) {
return null;
}
var curlCommand = "curl -0 -v";
// append method
curlCommand += " -X " + request.getMethod();
// append request url
curlCommand += " " + request.getRequestURI();
// append headers
final var headers = Collections.list(request.getHeaderNames()).stream()
.filter(headerName -> !HEADERS_TO_IGNORE.contains(headerName.toLowerCase()))
.collect(Collectors.toSet());
for (String headerName : headers) {
final var headerValue = HEADERS_TO_MASK.contains(headerName.toLowerCase())
? MASKED_PROPERTY_VALUE
: request.getHeader(headerName);
curlCommand += " \\" + System.lineSeparator() + String.format("-H '%s:%s'", headerName, headerValue);
}
// body
final String body = request.getReader().lines().collect(Collectors.joining(System.lineSeparator()));
if (!StringUtils.isEmpty(body)) {
curlCommand += " \\" + System.lineSeparator() + "--data-binary @- ";
curlCommand +=
"<< EOF" + System.lineSeparator() + System.lineSeparator() + withMaskedProperties(body)
+ System.lineSeparator() + "EOF";
}
return curlCommand;
}
// masks the values of sensitive JSON body properties, e.g. hosting-asset passwords,
// so they don't leak into the audit-log (base.tx_context.currentRequest)
private static String withMaskedProperties(final String body) {
try {
final var root = BODY_MASKING_JSON_MAPPER.readTree(body);
return maskProperties(root) ? root.toString() : body;
} catch (final JsonProcessingException exc) {
return body; // not JSON, nothing to mask
}
}
private static boolean maskProperties(final JsonNode node) {
var masked = false;
if (node.isObject()) {
final var objectNode = (ObjectNode) node;
final var fieldNames = new ArrayList<String>();
objectNode.fieldNames().forEachRemaining(fieldNames::add);
for (final var fieldName : fieldNames) {
if (BODY_PROPERTIES_TO_MASK.matcher(fieldName).find()) {
objectNode.put(fieldName, MASKED_PROPERTY_VALUE);
masked = true;
} else {
masked |= maskProperties(objectNode.get(fieldName));
}
}
} else if (node.isArray()) {
for (final var element : node) {
masked |= maskProperties(element);
}
}
return masked;
}
private boolean isRequestScopeAvailable() {
return RequestContextHolder.getRequestAttributes() != null;
}
private static String shortenToMaxLength(final String raw, final int maxLength) {
if (raw == null) {
return "";
}
if (raw.length() <= maxLength) {
return raw;
}
return raw.substring(0, maxLength - 3) + "...";
}
}