Auth.java
package jasper.security;
import io.jsonwebtoken.Claims;
import jakarta.annotation.PostConstruct;
import jasper.component.ConfigCache;
import jasper.config.Config.SecurityConfig;
import jasper.config.Props;
import jasper.config.WebSocketConfig;
import jasper.domain.Ref;
import jasper.domain.User;
import jasper.domain.proj.HasOrigin;
import jasper.domain.proj.HasTags;
import jasper.domain.proj.Tag;
import jasper.errors.FreshLoginException;
import jasper.repository.RefRepository;
import jasper.repository.filter.Query;
import jasper.repository.spec.QualifiedTag;
import jasper.security.jwt.JwtAuthentication;
import jasper.service.dto.UserDto;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.web.context.annotation.RequestScope;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static io.jsonwebtoken.Jwts.claims;
import static jasper.config.JacksonConfiguration.dump;
import static jasper.domain.proj.HasOrigin.isSubOrigin;
import static jasper.domain.proj.Tag.matchesTag;
import static jasper.domain.proj.Tag.matchesTemplate;
import static jasper.domain.proj.Tag.tagOrigin;
import static jasper.domain.proj.Tag.tagUrl;
import static jasper.domain.proj.Tag.urlToTag;
import static jasper.domain.proj.Tag.userUrl;
import static jasper.repository.spec.OriginSpec.isOrigin;
import static jasper.repository.spec.QualifiedTag.qt;
import static jasper.repository.spec.QualifiedTag.qtList;
import static jasper.repository.spec.QualifiedTag.selector;
import static jasper.repository.spec.QualifiedTag.selectors;
import static jasper.repository.spec.RefSpec.hasAnyQualifiedTag;
import static jasper.repository.spec.TagSpec.isAnyQualifiedTag;
import static jasper.repository.spec.TagSpec.notPrivateTag;
import static jasper.security.AuthoritiesConstants.ADMIN;
import static jasper.security.AuthoritiesConstants.BANNED;
import static jasper.security.AuthoritiesConstants.EDITOR;
import static jasper.security.AuthoritiesConstants.MOD;
import static jasper.security.AuthoritiesConstants.ROLE_PREFIX;
import static jasper.security.AuthoritiesConstants.USER;
import static jasper.security.AuthoritiesConstants.VIEWER;
import static java.net.URLDecoder.decode;
import static java.util.Optional.ofNullable;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
import static org.springframework.data.jpa.domain.Specification.where;
import static org.springframework.security.core.authority.AuthorityUtils.authorityListToSet;
/**
* This single class is where all authorization decisions are made.
* Authorization decisions are made based on six criteria:
* 1. The user tag
* 2. The local origin (always the same as the user tag origin)
* 3. The user role (ADMIN, MOD, EDITOR, USER, VIEWER, ANONYMOUS)
* 4. The user access tags
* 5. Is the JWT token fresh? (less than 15 minutes old)
*
* These criteria may be sourced in three cascading steps:
* 1. Application properties (set by command line, environment variables, or default value)
* 2. {@link SecurityConfig}
* 3. JWT token claims
* 4. Request headers
*
* The local origin is set by headers with the highest precedence, then JWT, then
* installed configs and finally application properties.
* Roles and access tags merge to be the more elevated role.
*
* The application properties that can be configured are:
* 1. localOrigin (""): set the local origin
* 2. minRole ("ROLE_ANONYMOUS"): set the minimum role
* 3. defaultRole ("ROLE_ANONYMOUS"): set the default role
* 4. defaultReadAccess ([]): set the default read access tags
* 5. defaultWriteAccess ([]): set the default write access tags
* 6. defaultTagReadAccess ([]): set the default tag read access tags
* 7. defaultTagWriteAccess ([]): set the default tag write access tags
* 8. allowLocalOriginHeader (false): enable setting the local origin in the header
* 9. allowUserTagHeader (false): enable setting the user tag in the header
* 10. allowUserRoleHeader (false): enable setting the user role in the header
* 11. allowAuthHeaders (false): enable setting the user access tags in the header
*
* The {@link SecurityConfig} fields that can be configured are:
* 1. minRole ("ROLE_ANONYMOUS"): set the minimum role
* 2. defaultRole ("ROLE_ANONYMOUS"): set the default role
* 3. defaultUser (""): set the default user if logged out
* 4. defaultReadAccess ([]): set the default read access tags
* 5. defaultWriteAccess ([]): set the default write access tags
* 6. defaultTagReadAccess ([]): set the default tag read access tags
* 7. defaultTagWriteAccess ([]): set the default tag write access tags
*
* As well {@link SecurityConfig} has other fields for setting the token
* claim names.
* When merging values between application properties and
* {@link SecurityConfig}, the effect is additive. So for minRole, the
* effective min role is the lower of the two. For defaultRole, both
* roles are added, so effectively the larger of the two. For all the default
* access fields, the tags are all combined.
*
* The following headers are checked if enabled:
* 1. Local-Origin
* 2. User-Tag
* 3. User-Role
* 4. Write-Access
* 5. Read-Access
* 6. Tag-Write-Access
* 7. Tag-Read-Access
*
* If no username is not set and the role is at least MOD it will default to +user.
* Otherwise the username will remain unset.
*/
@Component
@RequestScope
public class Auth {
private static final Logger logger = LoggerFactory.getLogger(Auth.class);
public static final String USER_TAG_HEADER = "User-Tag";
public static final String USER_ROLE_HEADER = "User-Role";
public static final String LOCAL_ORIGIN_HEADER = "Local-Origin";
public static final String WRITE_ACCESS_HEADER = "Write-Access";
public static final String READ_ACCESS_HEADER = "Read-Access";
public static final String TAG_WRITE_ACCESS_HEADER = "Tag-Write-Access";
public static final String TAG_READ_ACCESS_HEADER = "Tag-Read-Access";
Props props;
RoleHierarchy roleHierarchy;
ConfigCache configs;
RefRepository refRepository;
// Cache
protected Authentication authentication;
protected Set<String> roles;
protected Claims claims;
protected String principal;
protected QualifiedTag userTag;
protected String origin;
protected Optional<User> user;
protected List<QualifiedTag> readAccess;
protected List<QualifiedTag> writeAccess;
protected List<QualifiedTag> tagReadAccess;
protected List<QualifiedTag> tagWriteAccess;
public Auth(Props props, RoleHierarchy roleHierarchy, ConfigCache configs, RefRepository refRepository) {
this.props = props;
this.roleHierarchy = roleHierarchy;
this.configs = configs;
this.refRepository = refRepository;
}
public void clear(Authentication authentication) {
logger.debug("CLEAR AUTHENTICATION {}", authentication.getPrincipal());
this.authentication = authentication;
roles = null;
claims = null;
principal = null;
userTag = null;
user = null;
readAccess = null;
writeAccess = null;
tagReadAccess = null;
tagWriteAccess = null;
if (getPrincipal().startsWith("@")) {
origin = getPrincipal();
} else {
origin = qt(getPrincipal()).origin;
}
}
@PostConstruct
public void log() {
logger.debug("AUTH{} User: {} {} (hasUser: {})",
getOrigin(), getPrincipal(), getAuthoritySet(), getUser().isPresent());
if (logger.isTraceEnabled()) {
logger.trace("Auth Config: {} {}", dump(configs.root()), dump(security()));
}
}
public SecurityConfig security() {
return configs.security(getOrigin());
}
/**
* Is this origin "".
*/
public boolean root() {
return isBlank(getOrigin());
}
/**
* Is this origin local. Nulls and empty strings are both considered to
* be the default origin.
*/
public boolean local(String origin) {
if (isBlank(origin)) return isBlank(getOrigin());
if (!origin.startsWith("@")) origin = qt(origin).origin;
return getOrigin().equals(origin);
}
/**
* Is this origin a sub-origin.
*/
public boolean subOrigin(String origin) {
return isSubOrigin(getOrigin(), tagOrigin(origin));
}
/**
* Has the user logged in within 15 minutes?
*/
public boolean freshLogin() {
var iat = getClaims().getIssuedAt();
if (iat != null && iat.toInstant().isAfter(Instant.now().minus(Duration.of(15, ChronoUnit.MINUTES)))) {
return true;
}
throw new FreshLoginException();
}
/**
* Is the current user logged in? Only if they have a user tag which is not blank.
* Otherwise, an anonymous request is being made and no user tag should be expected.
* Mods and Admins cannot make anonymous requests, as they will default to user tag +user.
*/
public boolean isLoggedIn() {
return isNotBlank(getPrincipal()) && !getPrincipal().startsWith("@");
}
/**
* Can the current user access this origin?
*/
public boolean canReadOrigin(String origin) {
if (!subOrigin(origin)) return false;
return minRole();
}
/**
* Can the user read this Ref?
* Only considers the Ref given, does not check if it is modified from
* the database version.
*/
public boolean canReadRef(HasTags ref) {
// Only origin and sub origins can be read
if (!subOrigin(ref.getOrigin())) return false;
// Mods can read anything
if (hasRole(MOD)) return true;
// Min Role
if (!minRole()) return false;
// User URL
if (userUrl(ref.getUrl())) return isLoggedIn() && userUrl(ref.getUrl(), getUserTag().tag);
// Tag URLs
if (tagUrl(ref.getUrl())) return canReadTag(urlToTag(ref.getUrl()) + ref.getOrigin());
// No tags, only mods can read
if (ref.getTags() == null) return false;
// Add the ref's origin to its tag list
var qualifiedTags = qtList(ref.getOrigin(), ref.getTags());
// Check if owner
if (owns(qualifiedTags)) return true;
// Check if user read access tags capture anything in the ref tags
return captures(getReadAccess(), qualifiedTags);
}
/**
* Can the user read a ref by tags.
*/
public boolean canReadRef(String url, String origin) {
// Only origin and sub origins can be read
if (!subOrigin(origin)) return false;
// Mods can read anything
if (hasRole(MOD)) return true;
// User URL
if (userUrl(url) && isLoggedIn() && userUrl(url, getUserTag().tag)) return true;
// Tag URLs
if (tagUrl(url)) return canReadTag(urlToTag(url) + origin);
var maybeExisting = refRepository.findOneByUrlAndOrigin(url, origin);
return maybeExisting.filter(this::canReadRef).isPresent();
}
/**
* Can the user update an existing Ref with given updated version?
* Checks the existing database version for write access and verifies any
* tag additions.
* @param ref the updated ref
*/
public boolean canWriteRef(Ref ref) {
// First check if we can write to the existing Ref
if (!canWriteRef(ref.getUrl(), ref.getOrigin())) return false;
// If we can write to the existing we are granted permission
// We do not need to check if we have write access to the updated Ref,
// as self revocation is allowed
var maybeExisting = refRepository.findOneByUrlAndOrigin(ref.getUrl(), ref.getOrigin());
// We do need to check if we are allowed to add any of the new tags
// by calling canAddTag on each one
return newTags(ref.getTags(), maybeExisting.map(Ref::getTags)).allMatch(this::canAddTag);
}
/**
* Can the user write to an existing Ref.
*/
public boolean canWriteRef(String url, String origin) {
// Only writing to the local origin ever permitted
if (!local(origin)) return false;
// Min Role
if (!minRole()) return false;
// Minimum role for writing
if (!minWriteRole()) return false;
// User URL
if (userUrl(url)) return hasRole(MOD) || isLoggedIn() && userUrl(url, getUserTag().tag);
// Tag URLs
if (tagUrl(url)) return hasRole(MOD) || canWriteTag(urlToTag(url) + origin);
var maybeExisting = refRepository.findOneByUrlAndOrigin(url, origin);
if (maybeExisting.isEmpty()) {
// If we're creating, simply having the role USER is enough
return hasRole(USER);
}
var existing = maybeExisting.get();
// First write check of an existing Ref must be for the locked tag
if (existing.hasTag("locked")) return false;
// Mods can write anything in their origin
if (hasRole(MOD)) return true;
if (existing.getTags() == null) return false;
var qualifiedTags = qtList(origin, existing.getTags());
// Check if owner
if (owns(qualifiedTags)) return true;
// Check access tags
return captures(getWriteAccess(), qualifiedTags);
}
/**
* Can subscribe to STOMP topic.
*/
public boolean canSubscribeTo(String destination) {
// Min Role
if (!minRole()) return false;
if (destination == null) return false;
if (destination.startsWith("/topic/cursor/")) {
var origin = destination.substring("/topic/cursor/".length());
if (origin.equals("default")) origin = "";
return canReadOrigin(origin);
} else if (destination.startsWith("/topic/tag/")) {
var topic = destination.substring("/topic/tag/".length());
var origin = topic.substring(0, topic.indexOf('/'));
if (origin.equals("default")) origin = "";
var tag = topic.substring(topic.indexOf('/') + 1);
var decodedTag = decode(tag, StandardCharsets.UTF_8);
return canReadTag(decodedTag + origin);
} else if (destination.startsWith("/topic/ref/")) {
var topic = destination.substring("/topic/ref/".length());
var origin = topic.substring(0, topic.indexOf('/'));
if (origin.equals("default")) origin = "";
var url = topic.substring(topic.indexOf('/') + 1);
var decodedUrl = decode(url, StandardCharsets.UTF_8);
return canReadRef(decodedUrl, origin);
} else if (destination.startsWith("/topic/response/")) {
var topic = destination.substring("/topic/response/".length());
var origin = topic.substring(0, topic.indexOf('/'));
if (origin.equals("default")) origin = "";
return subOrigin(origin);
} else if (destination.startsWith("/topic/ext/")) {
var topic = destination.substring("/topic/ext/".length());
var origin = topic.substring(0, topic.indexOf('/'));
if (origin.equals("default")) origin = "";
var tag = topic.substring(topic.indexOf('/') + 1);
var decodedTag = decode(tag, StandardCharsets.UTF_8);
return canReadTag(decodedTag + origin);
}
return false;
}
/**
* Does the user have permission to use a tag when tagging Refs?
*/
public boolean canAddTag(String tag) {
// Min Role
if (!minRole()) return false;
// Minimum role for writing
if (!minWriteRole()) return false;
if (hasRole(MOD)) return true;
if (isPublicTag(tag)) return true;
var qt = qt(tag + getOrigin());
if (isUser(qt)) return true;
return captures(getTagReadAccess(), qt);
}
/**
* Does the user have permission to remove a tag when tagging Refs?
*/
public boolean canDeleteTag(String tag) {
// Min Role
if (!minRole()) return false;
// Minimum role for writing
if (!minWriteRole()) return false;
if (hasRole(MOD)) return true;
if (!isPrivateTag(tag)) return true;
var qt = qt(tag + getOrigin());
if (isUser(qt)) return true;
return captures(getTagReadAccess(), qt);
}
/**
* Does the user have permission to use all tags when tagging Refs?
*/
public boolean canPatchTags(List<String> tags) {
// Min Role
if (!minRole()) return false;
// Minimum role for writing
if (!minWriteRole()) return false;
if (hasRole(MOD)) return true;
return tags.stream().allMatch(t -> {
if (t.startsWith("-")) {
return this.canDeleteTag(t.substring(1));
} else {
return this.canAddTag(t);
}
});
}
/**
* Can the user add these tags to an existing ref?
*/
public boolean canPatchTags(List<String> tags, String url, String origin) {
// Only writing to the local origin ever permitted
if (!local(origin)) return false;
// Min Role
if (!minRole()) return false;
// Minimum role for writing
if (!minWriteRole()) return false;
if (hasRole(MOD)) return true;
for (var tag : tags) {
if (tag.startsWith("-")) {
if (!canUntag(tag.substring(1), url, origin)) return false;
} else {
if (!canTag(tag, url, origin)) return false;
}
}
return true;
}
/**
* Can the user add this tag to an existing ref?
*/
public boolean canTag(String tag, String url, String origin) {
// Only writing to the local origin ever permitted
if (!local(origin)) return false;
// Min Role
if (!minRole()) return false;
if (hasRole(MOD)) return true;
// Editor has special access to add public tags to Refs they can read
if (hasRole(EDITOR) &&
isPublicTag(tag) &&
// Except for user, an Editor cannot add ownership to a Ref or vice-versa
!matchesTemplate("user", tag) &&
// Except for public, an Editor cannot make a private Ref public or vice-versa
!matchesTag("public", tag) &&
// Except for locked, an Editor cannot make a locked Ref editable or vice-versa
!matchesTag("locked", tag) &&
canReadRef(url, origin)) return true;
// You can add the tag, and you can edit the ref
return canAddTag(tag) && canWriteRef(url, origin);
}
/**
* Can the user remove this tag to an existing ref?
*/
public boolean canUntag(String tag, String url, String origin) {
// Only writing to the local origin ever permitted
if (!local(origin)) return false;
// Min Role
if (!minRole()) return false;
// Editor has special access to remove public tags to Refs they can read
if (hasRole(EDITOR) &&
isPublicTag(tag) &&
// Except for user, an Editor cannot add ownership to a Ref or vice-versa
!matchesTemplate("user", tag) &&
// Except for public, an Editor cannot make a private Ref public or vice-versa
!matchesTag("public", tag) &&
// Except for locked, an Editor cannot make a locked Ref editable or vice-versa
!matchesTag("locked", tag) &&
canReadRef(url, origin)) return true;
// You can delete the tag, and you can edit the ref
return canDeleteTag(tag) && canWriteRef(url, origin);
}
/**
* Is this a public tag?
* Public tags start with a letter or number.
*/
public static boolean isPublicTag(String tag) {
if (isPrivateTag(tag)) return false;
if (isProtectedTag(tag)) return false;
return true;
}
/**
* Is this a private tag?
* Private tags start with a _.
*/
public static boolean isPrivateTag(String tag) {
return tag.startsWith("_");
}
/**
* Is this a protected tag?
* Protected tags start with a +.
*/
public static boolean isProtectedTag(String tag) {
return tag.startsWith("+");
}
/**
* Can the user read the given qualified tag and any associated entities? (Ext, User, Plugin, Template)
* A selector is a tag that may contain an origin, or an origin with no tag.
*/
public boolean canReadTag(String qualifiedTag) {
// Min Role
if (!minRole()) return false;
// Only origin and sub origins can be read
if (!subOrigin(selector(qualifiedTag).origin)) return false;
// The root template is public
if (isBlank(qualifiedTag) || qualifiedTag.startsWith("@")) return true;
// All non-private tags can be read
if (!isPrivateTag(qualifiedTag)) return true;
// Mod can read anything
if (hasRole(MOD)) return true;
// Can read own user tag
if (isUser(qualifiedTag)) return true;
// Finally check access tags
return captures(getTagReadAccess(), qt(qualifiedTag));
}
/**
* Can the user create the associated Ext entities of a tag?
*/
public boolean canCreateTag(String qualifiedTag) {
if (!canWriteTag(qualifiedTag)) return false;
// User role is required to create Exts (except your user Ext)
return hasRole(USER) || hasRole(VIEWER) && isUser(qt(qualifiedTag));
}
/**
* Can the user modify the associated Ext entities of a tag?
*/
public boolean canWriteTag(String qualifiedTag) {
var qt = qt(qualifiedTag);
// Only writing to the local origin ever permitted
if (!local(qt.origin)) return false;
// Min Role
if (!minRole()) return false;
// Minimum role for writing
if (!minWriteRole()) return false;
// Viewers may only edit their user ext
if (hasRole(VIEWER) && isUser(qt)) return true;
// Mods can write anything in their origin
if (hasRole(MOD)) return true;
// Editors have special access to edit public tag Exts
if (hasRole(EDITOR) && isPublicTag(qualifiedTag)) return true;
// Check access tags
return captures(getTagWriteAccess(), qt);
}
/**
* Does the user's tag match this tag?
*/
public boolean isUser(QualifiedTag qt) {
return !isPublicTag(qt.tag) && isLoggedIn() && getUserTag().matchesDownwards(qt);
}
public boolean isUser(String qualifiedTag) {
return isUser(qt(qualifiedTag));
}
public boolean owns(List<QualifiedTag> qt) {
return qt.stream().anyMatch(this::isUser);
}
/**
* Check all the individual selectors of the query and verify they can all
* be read.
*/
public boolean canReadQuery(Query filter) {
// Min Role
if (!minRole()) return false;
// Anyone can read the empty query (retrieve all Refs)
if (filter.getQuery() == null) return true;
// Mod
if (hasRole(MOD)) return true;
var tagList = Arrays.stream(filter.getQuery().split("[!:|()\\s]+"))
.filter(StringUtils::isNotBlank)
.filter(Auth::isPrivateTag)
.filter(qt -> !isUser(qt))
.map(QualifiedTag::selector)
.toList();
if (tagList.isEmpty()) return true;
return captures(getTagReadAccess(), tagList);
}
/**
* Silently remove sorts that reference private plugins the user cannot read.
*/
public Pageable pageable(Pageable pageable) {
if (pageable == null || pageable.getSort().isUnsorted()) return pageable;
if (hasRole(MOD)) return pageable;
var orders = pageable.getSort().toList();
var filtered = orders.stream()
.filter(order -> {
var property = order.getProperty();
String afterPrefix;
if (property.startsWith("plugins->")) {
afterPrefix = property.substring("plugins->".length());
} else if (property.startsWith("metadata->plugins->")) {
afterPrefix = property.substring("metadata->plugins->".length());
} else {
return true;
}
int end = afterPrefix.indexOf("->");
if (end == -1) end = afterPrefix.indexOf(":");
var tag = end == -1 ? afterPrefix : afterPrefix.substring(0, end);
if (!isPrivateTag(tag)) return true;
if (isUser(tag)) return true;
return captures(getTagReadAccess(), QualifiedTag.selector(tag));
})
.toList();
if (filtered.size() == orders.size()) return pageable;
return PageRequest.of(
pageable.getPageNumber(),
pageable.getPageSize(),
Sort.by(filtered));
}
/**
* Has the maximum role or lower.
*/
private boolean maxRole(String role) {
if (props.getMaxRole().equals(role)) return true;
return roleHierarchy.getReachableGrantedAuthorities(List.of(new SimpleGrantedAuthority(role)))
.stream()
.map(GrantedAuthority::getAuthority)
.noneMatch(r -> props.getMaxRole().equals(r));
}
/**
* Has the minimum role or higher.
*/
public boolean minRole() {
// Don't call hasRole() from here or you get an infinite loop
if (hasAnyRole(BANNED)) return false;
return (isBlank(props.getMinRole()) || hasAnyRole(props.getMinRole()))
&& (isBlank(security().getMinRole()) || hasAnyRole(security().getMinRole()));
}
/**
* Has the minimum role to write.
*/
public boolean minWriteRole() {
if (hasAnyRole(BANNED)) return false;
return (isBlank(props.getMinWriteRole()) || hasAnyRole(props.getMinWriteRole()))
&& (isBlank(security().getMinWriteRole()) || hasAnyRole(security().getMinWriteRole()));
}
/**
* Has the minimum role to configure admin settings.
*/
public boolean minConfigRole() {
if (hasAnyRole(BANNED)) return false;
if (hasAnyRole(ADMIN)) return true;
// Lowest valid role for configuring admin settings is EDITOR
if (!hasAnyRole(EDITOR)) return false;
return hasAnyRole(props.getMinConfigRole()) && hasAnyRole(security().getMinConfigRole());
}
/**
* Has the minimum role to fetch external resources.
*/
public boolean minFetchRole() {
if (hasAnyRole(BANNED)) return false;
if (hasAnyRole(ADMIN)) return true;
return hasAnyRole(props.getMinFetchRole()) && hasAnyRole(security().getMinFetchRole());
}
/**
* Has the minimum role download backups.
*/
public boolean minReadBackupRole() {
if (hasAnyRole(BANNED)) return false;
if (hasAnyRole(ADMIN)) return true;
// Lowest valid role for reading backups is MOD
if (!hasAnyRole(MOD)) return false;
return hasAnyRole(props.getMinReadBackupsRole()) && hasAnyRole(security().getMinReadBackupsRole());
}
/**
* Can this admin config be edited?
*/
public boolean canEditConfig(Tag config) {
return canEditConfig(config.getQualifiedTag());
}
/**
* Can this admin config be edited?
*/
public boolean canEditConfig(String qualifiedTag) {
if (!local(qualifiedTag)) return false;
if (hasAnyRole(ADMIN)) return true;
if (!minConfigRole()) return false;
// Non-admins may only edit public configs, or assigned private configs
return captures(getTagWriteAccess(), qt(qualifiedTag));
}
/**
* Admin in the root origin.
*/
public boolean rootMod() {
return root() && hasRole(MOD);
}
/**
* Can this user be updated?
* Check if the user can write this tag, and that their role is not smaller.
*/
public boolean canWriteUserTag(String qualifiedTag) {
// Only writing to the local origin ever permitted
if (!local(qt(qualifiedTag).origin)) return false;
if (!canWriteTag(qualifiedTag)) return false;
var role = ofNullable(configs.getUser(qualifiedTag)).map(User::getRole).orElse(null);
// Only Mods and above can unban
if (BANNED.equals(role)) return hasRole(MOD);
// Cannot edit user with higher role
return isBlank(role) || hasRole(role);
}
/**
* Can this user be updated with the given user?
* Check that the user is writeable, and that the user has write access to all new tags.
* Do not allow public tags to be given write access.
*/
public boolean canWriteUser(User user) {
if (!canWriteUserTag(user.getQualifiedTag())) return false;
// Cannot add role higher than your own
if (isNotBlank(user.getRole()) && !BANNED.equals(user.getRole()) && !hasRole(user.getRole())) return false;
// Mods can add any tag permissions
if (hasRole(MOD)) return true;
var maybeExisting = ofNullable(configs.getUser(user.getQualifiedTag()));
// User role is required to create Users
if (maybeExisting.isEmpty() && !hasRole(USER)) return false;
// No public tags in write access
if (user.getWriteAccess() != null && user.getWriteAccess().stream().anyMatch(Auth::isPublicTag)) return false;
// The writing user must already have write access to give read or write access to another user
if (!newTags(user.getTagReadAccess(), maybeExisting.map(User::getTagReadAccess)).allMatch(this::tagWriteAccessCaptures)) return false;
if (!newTags(user.getTagWriteAccess(), maybeExisting.map(User::getTagWriteAccess)).allMatch(this::tagWriteAccessCaptures)) return false;
if (!newTags(user.getReadAccess(), maybeExisting.map(User::getReadAccess)).allMatch(this::writeAccessCaptures)) return false;
if (!newTags(user.getWriteAccess(), maybeExisting.map(User::getWriteAccess)).allMatch(this::writeAccessCaptures)) return false;
return true;
}
public UserDto filterUser(UserDto user) {
if (hasRole(MOD)) return user;
if (canWriteUserTag(user.getQualifiedTag())) return user;
user.setExternal(null);
return user;
}
public List<String> filterTags(List<String> tags) {
if (tags == null) return null;
if (hasRole(MOD)) return tags;
return tags.stream()
.filter(tag -> canReadTag(tag + getOrigin()))
.toList();
}
public List<String> hiddenTags(List<String> tags) {
if (hasRole(MOD)) return null;
if (tags == null) return null;
return tags.stream()
.filter(tag -> !canReadTag(tag + getOrigin()))
.toList();
}
public List<String> unwritableTags(List<String> tags) {
if (hasRole(MOD)) return null;
if (tags == null) return null;
return tags.stream()
.filter(tag -> !canAddTag(tag + getOrigin()))
.toList();
}
public Specification<Ref> refReadSpec() {
var spec = where(hasRole(MOD)
? isOrigin(getSubOrigins())
: selector("public" + getSubOrigins()).refSpec());
if (isLoggedIn()) {
spec = spec.or(getUserTag().downwardRefSpec());
}
return spec.or(hasAnyQualifiedTag(getReadAccess()));
}
public <T extends Tag> Specification<T> tagReadSpec() {
var spec = Specification.<T>where(isOrigin(getSubOrigins()));
if (!hasRole(MOD)) spec = spec.and(notPrivateTag());
if (isLoggedIn()) {
spec = spec.or(getUserTag().downwardSpec());
}
return spec.or(isAnyQualifiedTag(getTagReadAccess()));
}
protected boolean tagWriteAccessCaptures(String tag) {
if (hasRole(MOD)) return true;
return captures(getTagWriteAccess(), qt(tag + getOrigin()));
}
protected boolean writeAccessCaptures(String tag) {
if (hasRole(MOD)) return true;
return captures(getWriteAccess(), qt(tag + getOrigin()));
}
protected static boolean captures(List<QualifiedTag> selectors, List<QualifiedTag> target) {
if (selectors == null) return false;
if (selectors.isEmpty()) return false;
if (target == null) return false;
if (target.isEmpty()) return false;
for (var selector : selectors) {
if (captures(selector, target)) return true;
}
return false;
}
protected static boolean captures(List<QualifiedTag> selectors, QualifiedTag target) {
if (selectors == null) return false;
if (selectors.isEmpty()) return false;
if (target == null) return false;
for (var selector : selectors) {
if (selector.capturesDownwards(target)) return true;
}
return false;
}
protected static boolean captures(QualifiedTag selector, List<QualifiedTag> target) {
if (selector == null) return false;
if (target == null) return false;
if (target.isEmpty()) return false;
for (var t : target) {
if (selector.capturesDownwards(t)) return true;
}
return false;
}
protected static Stream<String> newTags(List<String> changes, Optional<List<String>> existing) {
if (changes == null) return Stream.empty();
if (existing.isEmpty()) return changes.stream();
return changes.stream().filter(tag -> !existing.get().contains(tag));
}
public String getPrincipal() {
if (principal == null) {
var authn = getAuthentication();
if (authn == null) return null;
if (authn instanceof JwtAuthentication j) {
principal = j.getPrincipal();
} else {
if (authn instanceof AnonymousAuthenticationToken) return null;
if (authn.getPrincipal() == null) return null;
if (authn.getPrincipal() instanceof String username) {
principal = username;
} else if (authn.getPrincipal() instanceof UserDetails d) {
principal = d.getUsername();
} else {
return null;
}
}
}
return principal;
}
public QualifiedTag getUserTag() {
if (userTag == null) {
if (!isLoggedIn()) return null;
userTag = qt(getPrincipal());
}
return userTag;
}
protected Optional<User> getUser() {
if (user == null) {
var auth = ofNullable(getAuthentication());
user = auth.map(a -> a.getDetails() instanceof UserDto
? (User) a.getDetails()
: null);
if (isLoggedIn() && user.isEmpty()) {
user = ofNullable(configs.getUser(getUserTag().toString()));
}
}
return user;
}
public String getOrigin() {
if (origin == null) {
origin = props.getOrigin();
var originHeader = getOriginHeader();
if (originHeader != null && isSubOrigin(props.getLocalOrigin(), originHeader)) {
origin = originHeader;
}
}
return origin;
}
private static String getOriginHeader() {
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attribs) {
var originHeader = attribs.getRequest().getHeader(LOCAL_ORIGIN_HEADER);
logger.trace("{}: {}", LOCAL_ORIGIN_HEADER, originHeader);
if (isBlank(originHeader)) return null;
originHeader = originHeader.toLowerCase();
if ("default".equals(originHeader)) return "";
if (originHeader.matches(HasOrigin.REGEX)) return originHeader;
}
return null;
}
protected String getSubOrigins() {
return getOrigin().isEmpty() ? "@*" : getOrigin() + ".*";
}
public List<QualifiedTag> getReadAccess() {
if (readAccess == null) {
readAccess = new ArrayList<>(List.of(selector("public" + getSubOrigins())));
if (props.getDefaultReadAccess() != null) {
readAccess.addAll(getQualifiedTags(props.getDefaultReadAccess()));
}
if (security().getDefaultReadAccess() != null) {
readAccess.addAll(getQualifiedTags(security().getDefaultReadAccess()));
}
if (props.isAllowAuthHeaders()) {
readAccess.addAll(getHeaderQualifiedTags(READ_ACCESS_HEADER));
}
readAccess.addAll(getClaimQualifiedTags(security().getReadAccessClaim()));
if (isLoggedIn()) {
readAccess.add(getUserTag());
readAccess.addAll(selectors(getSubOrigins(), getUser()
.map(User::getReadAccess)
.orElse(List.of())));
}
}
return readAccess;
}
public List<QualifiedTag> getWriteAccess() {
if (writeAccess == null) {
writeAccess = new ArrayList<>();
if (props.getDefaultWriteAccess() != null) {
writeAccess.addAll(getQualifiedTags(props.getDefaultWriteAccess()));
}
if (security().getDefaultWriteAccess() != null) {
writeAccess.addAll(getQualifiedTags(security().getDefaultWriteAccess()));
}
if (props.isAllowAuthHeaders()) {
writeAccess.addAll(getHeaderQualifiedTags(WRITE_ACCESS_HEADER));
}
writeAccess.addAll(getClaimQualifiedTags(security().getWriteAccessClaim()));
if (isLoggedIn()) {
writeAccess.addAll(selectors(getSubOrigins(), getUser()
.map(User::getWriteAccess)
.orElse(List.of())));
}
}
return writeAccess;
}
public List<QualifiedTag> getTagReadAccess() {
if (tagReadAccess == null) {
tagReadAccess = new ArrayList<>(getReadAccess());
if (props.getDefaultTagReadAccess() != null) {
tagReadAccess.addAll(getQualifiedTags(props.getDefaultTagReadAccess()));
}
if (security().getDefaultTagReadAccess() != null) {
tagReadAccess.addAll(getQualifiedTags(security().getDefaultTagReadAccess()));
}
if (props.isAllowAuthHeaders()) {
tagReadAccess.addAll(getHeaderQualifiedTags(TAG_READ_ACCESS_HEADER));
}
tagReadAccess.addAll(getClaimQualifiedTags(security().getTagReadAccessClaim()));
if (isLoggedIn()) {
tagReadAccess.addAll(selectors(getSubOrigins(), getUser()
.map(User::getTagReadAccess)
.orElse(List.of())));
}
}
return tagReadAccess;
}
public List<QualifiedTag> getTagWriteAccess() {
if (tagWriteAccess == null) {
tagWriteAccess = new ArrayList<>(getWriteAccess());
if (props.getDefaultTagWriteAccess() != null) {
tagWriteAccess.addAll(getQualifiedTags(props.getDefaultTagWriteAccess()));
}
if (security().getDefaultTagWriteAccess() != null) {
tagWriteAccess.addAll(getQualifiedTags(security().getDefaultTagWriteAccess()));
}
if (props.isAllowAuthHeaders()) {
tagWriteAccess.addAll(getHeaderQualifiedTags(TAG_WRITE_ACCESS_HEADER));
}
tagWriteAccess.addAll(getClaimQualifiedTags(security().getTagWriteAccessClaim()));
if (isLoggedIn()) {
tagWriteAccess.addAll(selectors(getSubOrigins(), getUser()
.map(User::getTagWriteAccess)
.orElse(List.of())));
}
}
return tagWriteAccess;
}
public boolean hasAuthority(String authority) {
return hasAnyAuthority(authority);
}
public boolean hasAnyAuthority(String... authorities) {
return hasAnyAuthorityName(null, authorities);
}
public boolean hasRole(String role) {
if (BANNED.equals(role) && hasAnyRole(BANNED)) return true;
return minRole() && hasAnyRole(role);
}
public boolean hasAnyRole(String... roles) {
return hasAnyAuthorityName(ROLE_PREFIX, roles);
}
private boolean hasAnyAuthorityName(String prefix, String... roles) {
var roleSet = getAuthoritySet();
for (var role : roles) {
var defaultedRole = getRoleWithPrefix(prefix, role);
if (roleSet.contains(defaultedRole)) {
return true;
}
}
return false;
}
public Authentication getAuthentication() {
if (authentication == null) {
authentication = SecurityContextHolder.getContext().getAuthentication();
}
return authentication;
}
public Claims getClaims() {
if (claims == null) {
var auth = getAuthentication();
if (auth instanceof JwtAuthentication j) {
claims = j.getClaims();
} else {
claims = claims().build();
}
}
return claims;
}
public Set<String> getAuthoritySet() {
if (roles == null) {
if (getAuthentication() == null) {
roles = new HashSet<>();
} else {
var userAuthorities = getAuthentication().getAuthorities();
roles = authorityListToSet(roleHierarchy.getReachableGrantedAuthorities(userAuthorities))
.stream()
.filter(this::maxRole)
.collect(Collectors.toSet());
}
}
return roles;
}
private static String getRoleWithPrefix(String prefix, String role) {
if (isBlank(role)) return null;
if (isBlank(prefix)) return role;
if (role.startsWith(prefix)) return role;
return prefix + role;
}
private static List<String> getHeaderList(String headerName) {
var header = getHeader(headerName);
if (header != null) {
return List.of(header.split(","));
}
return List.of();
}
public static String getHeader(String headerName) {
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes a) {
return a.getRequest().getHeader(headerName);
}
if (RequestContextHolder.getRequestAttributes() instanceof WebSocketConfig.WebSocketRequestAttributes a) {
return a.getHeader(headerName);
}
return null;
}
private static List<QualifiedTag> getHeaderQualifiedTags(String headerName) {
return getHeaderList(headerName).stream().map(QualifiedTag::selector).toList();
}
public List<String> getClaimTags(String claim) {
if (!getClaims().containsKey(claim)) return List.of();
return List.of(getClaims().get(claim, String.class).split(","));
}
public List<QualifiedTag> getClaimQualifiedTags(String claim) {
return getClaimTags(claim).stream().map(QualifiedTag::selector).toList();
}
public static List<QualifiedTag> getQualifiedTags(String[] tags) {
return Stream.of(tags).map(QualifiedTag::selector).toList();
}
public static List<QualifiedTag> getQualifiedTags(List<String> tags) {
return tags.stream().map(QualifiedTag::selector).toList();
}
}