From ee7efcc2f314af139161cbeae2657c5c173bb2f5 Mon Sep 17 00:00:00 2001 From: Chris Malloy Date: Mon, 8 Apr 2024 10:45:53 -0300 Subject: [PATCH] Merge users for access types --- .../java/jasper/component/ConfigCache.java | 19 ++++++- .../jasper/component/ProfileManagerScim.java | 13 +---- .../java/jasper/component/channel/Mail.java | 4 +- src/main/java/jasper/domain/proj/HasTags.java | 19 ++++++- .../jasper/repository/spec/QualifiedTag.java | 5 ++ src/main/java/jasper/security/Auth.java | 49 +++++++++---------- .../jasper/security/AuthoritiesConstants.java | 1 - .../security/jwt/AbstractTokenProvider.java | 19 ++++--- .../security/jwt/JwtAuthentication.java | 8 +-- .../security/jwt/TokenProviderImpl.java | 32 ++++++------ .../java/jasper/service/ProfileService.java | 26 ++++------ .../security/AuthMultiTenantUnitTest.java | 4 +- .../java/jasper/security/AuthUnitTest.java | 4 +- 13 files changed, 113 insertions(+), 90 deletions(-) diff --git a/src/main/java/jasper/component/ConfigCache.java b/src/main/java/jasper/component/ConfigCache.java index 31e79a37..4bcfb3bd 100644 --- a/src/main/java/jasper/component/ConfigCache.java +++ b/src/main/java/jasper/component/ConfigCache.java @@ -26,9 +26,13 @@ import javax.annotation.PostConstruct; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Stream; + +import static jasper.domain.proj.HasTags.pub; @Component public class ConfigCache { @@ -103,12 +107,23 @@ public void clearTemplateCache() { @Cacheable("user-cache") @Transactional(readOnly = true) - public UserDto getUser(String tag) { - return userRepository.findOneByQualifiedTag(tag) + public UserDto getUser(String userTag) { + return userRepository.findOneByQualifiedTag(userTag) .map(dtoMapper::domainToDto) .orElse(null); } + @Cacheable("user-cache") + @Transactional(readOnly = true) + public List getUsers(String userTag) { + return Stream.of( + userRepository.findOneByQualifiedTag("+" + pub(userTag)).orElse(null), + userRepository.findOneByQualifiedTag("_" + pub(userTag)).orElse(null)) + .filter(Objects::nonNull) + .map(dtoMapper::domainToDto) + .toList(); + } + @Cacheable(value = "config-cache", key = "#tag + #origin + '@' + #url") @Transactional(readOnly = true) public T getConfig(String url, String origin, String tag, Class toValueType) { diff --git a/src/main/java/jasper/component/ProfileManagerScim.java b/src/main/java/jasper/component/ProfileManagerScim.java index 12a424df..d5efc97b 100644 --- a/src/main/java/jasper/component/ProfileManagerScim.java +++ b/src/main/java/jasper/component/ProfileManagerScim.java @@ -25,8 +25,6 @@ import java.util.Arrays; import java.util.List; -import static jasper.security.AuthoritiesConstants.PRIVATE; - @Profile("scim") @Component public class ProfileManagerScim implements ProfileManager { @@ -91,16 +89,9 @@ private ProfileDto mapUser(ScimUserResource user) { result.setActive(user.isActive()); var roles = Arrays.asList(getRoles(user)); for (var role : roles) { - if (!role.equals(PRIVATE)) { - result.setRole(role); - break; - } - } - if (roles.contains(PRIVATE)) { - result.setTag("_user/" + user.getUserName()); - } else { - result.setTag("+user/" + user.getUserName()); + result.setRole(role); } + result.setTag("user/" + user.getUserName()); return result; } diff --git a/src/main/java/jasper/component/channel/Mail.java b/src/main/java/jasper/component/channel/Mail.java index 90728643..6b208739 100644 --- a/src/main/java/jasper/component/channel/Mail.java +++ b/src/main/java/jasper/component/channel/Mail.java @@ -4,6 +4,7 @@ import jasper.component.ConfigCache; import jasper.component.delta.Async; import jasper.domain.Ref; +import jasper.domain.proj.HasTags; import jasper.domain.proj.RefUrl; import jasper.repository.ExtRepository; import jasper.repository.RefRepository; @@ -25,6 +26,7 @@ import java.util.stream.Stream; import static jasper.domain.Ref.removePrefixTags; +import static jasper.domain.proj.HasTags.author; import static jasper.domain.proj.Tag.localTag; import static jasper.domain.proj.Tag.tagOrigin; import static java.util.Arrays.stream; @@ -115,7 +117,7 @@ public void run(Ref ref) throws Exception { } }).map(URI::getHost).orElse(Stream.of(ref.getOrigin(), configs.root().getEmailHost()).filter(StringUtils::isNotBlank).collect(Collectors.joining("."))); message.setFrom(ts.stream() - .filter(t -> t.startsWith("+user/") || t.startsWith("+user") || t.startsWith("_user/") || t.startsWith("_user")) + .filter(HasTags::author) .findFirst() .map(t -> t + "@" + host) .orElse("no-reply@" + host) diff --git a/src/main/java/jasper/domain/proj/HasTags.java b/src/main/java/jasper/domain/proj/HasTags.java index c4b214ea..dfb6d27e 100644 --- a/src/main/java/jasper/domain/proj/HasTags.java +++ b/src/main/java/jasper/domain/proj/HasTags.java @@ -60,6 +60,7 @@ static String prefix(String prefix, String ...rest) { } static String pub(String tag) { + if (isBlank(tag)) return ""; if (tag.startsWith("_") || tag.startsWith("+")) { return tag.substring(1); } @@ -67,6 +68,22 @@ static String pub(String tag) { } static boolean isPub(String tag) { - return !tag.startsWith("_") && tag.startsWith("+"); + return !tag.startsWith("_") && !tag.startsWith("+"); + } + + static String priv(String tag) { + return "_" + pub(tag); + } + + static boolean isPriv(String tag) { + return tag.startsWith("_"); + } + + static String pro(String tag) { + return "+" + pub(tag); + } + + static boolean isPro(String tag) { + return tag.startsWith("+"); } } diff --git a/src/main/java/jasper/repository/spec/QualifiedTag.java b/src/main/java/jasper/repository/spec/QualifiedTag.java index fdfe80dd..b937becb 100644 --- a/src/main/java/jasper/repository/spec/QualifiedTag.java +++ b/src/main/java/jasper/repository/spec/QualifiedTag.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.stream.Collectors; +import static jasper.domain.proj.HasTags.pub; import static jasper.repository.spec.OriginSpec.isOrigin; import static jasper.repository.spec.RefSpec.hasTag; import static jasper.repository.spec.TagSpec.isTag; @@ -64,6 +65,10 @@ public boolean matches(QualifiedTag qt) { return tag.equals(qt.tag) && origin.equals(qt.origin) && not == qt.not; } + public boolean matchesIgnoringAccess(QualifiedTag qt) { + return pub(tag).equals(pub(qt.tag)) && origin.equals(qt.origin) && not == qt.not; + } + public boolean captures(String capture) { return captures(selector(capture)); } diff --git a/src/main/java/jasper/security/Auth.java b/src/main/java/jasper/security/Auth.java index 7b8aff14..f5a18afb 100644 --- a/src/main/java/jasper/security/Auth.java +++ b/src/main/java/jasper/security/Auth.java @@ -47,6 +47,7 @@ import static jasper.config.JacksonConfiguration.dump; import static jasper.domain.proj.HasOrigin.isSubOrigin; +import static jasper.domain.proj.HasTags.pub; import static jasper.repository.spec.OriginSpec.isOrigin; import static jasper.repository.spec.QualifiedTag.qt; import static jasper.repository.spec.QualifiedTag.qtList; @@ -153,7 +154,7 @@ public class Auth { protected String principal; protected QualifiedTag userTag; protected String origin; - protected Optional user; + protected List user; protected List readAccess; protected List writeAccess; protected List tagReadAccess; @@ -187,7 +188,7 @@ public void clear(Authentication authentication) { @PostConstruct public void log() { logger.debug("AUTH{} User: {} {} (hasUser: {})", - getOrigin(), getPrincipal(), getAuthoritySet(), getUser().isPresent()); + getOrigin(), getPrincipal(), getAuthoritySet(), getUsers().size()); if (logger.isTraceEnabled()) { logger.trace("Auth Config: {} {}", dump(configs.root()), dump(configs.security(getOrigin()))); } @@ -512,7 +513,7 @@ public boolean canWriteTag(String qualifiedTag) { * Does the user's tag match this tag? */ public boolean isUser(QualifiedTag qt) { - return isLoggedIn() && getUserTag().matches(qt); + return isLoggedIn() && getUserTag().matchesIgnoringAccess(qt); } public boolean isUser(String qualifiedTag) { @@ -736,14 +737,14 @@ public String getPrincipal() { var authn = getAuthentication(); if (authn == null) return null; if (authn instanceof JwtAuthentication j) { - principal = j.getPrincipal(); + principal = pub(j.getPrincipal()); } else { if (authn instanceof AnonymousAuthenticationToken) return null; if (authn.getPrincipal() == null) return null; if (authn.getPrincipal() instanceof String username) { - principal = username; + principal = pub(username); } else if (authn.getPrincipal() instanceof UserDetails d) { - principal = d.getUsername(); + principal = pub(d.getUsername()); } else { return null; } @@ -760,14 +761,12 @@ public QualifiedTag getUserTag() { return userTag; } - protected Optional getUser() { + protected List getUsers() { if (user == null) { - var auth = ofNullable(getAuthentication()); - user = auth.map(a -> a.getDetails() instanceof UserDto - ? (UserDto) a.getDetails() - : null); - if (isLoggedIn() && user.isEmpty()) { - user = ofNullable(configs.getUser(getUserTag().toString())); + if (isLoggedIn()) { + user = configs.getUsers(getUserTag().toString()); + } else { + user = List.of(); } } return user; @@ -813,9 +812,9 @@ public List getReadAccess() { } readAccess.addAll(getClaimQualifiedTags(security().getReadAccessClaim())); if (isLoggedIn()) { - readAccess.addAll(selectors(getSubOrigins(), getUser() - .map(UserDto::getReadAccess) - .orElse(List.of()))); + readAccess.addAll(selectors(getSubOrigins(), getUsers().stream() + .flatMap(u -> ofNullable(u.getReadAccess()).orElse(List.of()).stream()) + .toList())); } } return readAccess; @@ -835,9 +834,9 @@ public List getWriteAccess() { } writeAccess.addAll(getClaimQualifiedTags(security().getWriteAccessClaim())); if (isLoggedIn()) { - writeAccess.addAll(selectors(getSubOrigins(), getUser() - .map(UserDto::getWriteAccess) - .orElse(List.of()))); + writeAccess.addAll(selectors(getSubOrigins(), getUsers().stream() + .flatMap(u -> ofNullable(u.getWriteAccess()).orElse(List.of()).stream()) + .toList())); } } return writeAccess; @@ -857,9 +856,9 @@ public List getTagReadAccess() { } tagReadAccess.addAll(getClaimQualifiedTags(security().getTagReadAccessClaim())); if (isLoggedIn()) { - tagReadAccess.addAll(selectors(getSubOrigins(), getUser() - .map(UserDto::getTagReadAccess) - .orElse(List.of()))); + tagReadAccess.addAll(selectors(getSubOrigins(), getUsers().stream() + .flatMap(u -> ofNullable(u.getTagReadAccess()).orElse(List.of()).stream()) + .toList())); } } return tagReadAccess; @@ -879,9 +878,9 @@ public List getTagWriteAccess() { } tagWriteAccess.addAll(getClaimQualifiedTags(security().getTagWriteAccessClaim())); if (isLoggedIn()) { - tagWriteAccess.addAll(selectors(getSubOrigins(), getUser() - .map(UserDto::getTagWriteAccess) - .orElse(List.of()))); + tagWriteAccess.addAll(selectors(getSubOrigins(), getUsers().stream() + .flatMap(u -> ofNullable(u.getTagWriteAccess()).orElse(List.of()).stream()) + .toList())); } } return tagWriteAccess; diff --git a/src/main/java/jasper/security/AuthoritiesConstants.java b/src/main/java/jasper/security/AuthoritiesConstants.java index 89d531c6..31b4ebce 100644 --- a/src/main/java/jasper/security/AuthoritiesConstants.java +++ b/src/main/java/jasper/security/AuthoritiesConstants.java @@ -13,7 +13,6 @@ public final class AuthoritiesConstants { public static final String VIEWER = "ROLE_VIEWER"; public static final String ANONYMOUS = "ROLE_ANONYMOUS"; public static final String BANNED = "ROLE_BANNED"; - public static final String PRIVATE = "ROLE_PRIVATE"; private AuthoritiesConstants() {} } diff --git a/src/main/java/jasper/security/jwt/AbstractTokenProvider.java b/src/main/java/jasper/security/jwt/AbstractTokenProvider.java index 1a1fbf91..8cffe773 100644 --- a/src/main/java/jasper/security/jwt/AbstractTokenProvider.java +++ b/src/main/java/jasper/security/jwt/AbstractTokenProvider.java @@ -30,19 +30,22 @@ public abstract class AbstractTokenProvider implements TokenProvider { this.configs = configs; } - UserDto getUser(String userTag) { + List getUsers(String userTag) { if (configs == null) return null; - return configs.getUser(userTag); + return configs.getUsers(userTag); } - Collection getAuthorities(UserDto user, String origin) { + Collection getAuthorities(List user, String origin) { var auth = getPartialAuthorities(origin); - if (user != null && user.getRole() != null) { - logger.debug("User Roles: {}", user.getRole()); - if (User.ROLES.contains(user.getRole().trim())) { - auth.add(new SimpleGrantedAuthority(user.getRole().trim())); + for (var u : user) { + if (isNotBlank(u.getRole())) { + logger.debug("User Roles: {}", u.getRole()); + if (User.ROLES.contains(u.getRole().trim())) { + auth.add(new SimpleGrantedAuthority(u.getRole().trim())); + } } - } else { + } + if (user.isEmpty()) { logger.debug("No User"); } return auth; diff --git a/src/main/java/jasper/security/jwt/JwtAuthentication.java b/src/main/java/jasper/security/jwt/JwtAuthentication.java index 56d3e43c..d460b490 100644 --- a/src/main/java/jasper/security/jwt/JwtAuthentication.java +++ b/src/main/java/jasper/security/jwt/JwtAuthentication.java @@ -2,15 +2,17 @@ import io.jsonwebtoken.Claims; import jasper.service.dto.UserDto; +import org.apache.commons.lang3.NotImplementedException; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import org.springframework.util.Assert; import java.util.Collection; +import java.util.List; public class JwtAuthentication extends AbstractAuthenticationToken { - private final UserDto user; + private final List user; private final Claims claims; private final String principal; @@ -22,7 +24,7 @@ public JwtAuthentication(String principal) { setAuthenticated(false); } - public JwtAuthentication(String principal, UserDto user, Claims claims, Collection authorities) { + public JwtAuthentication(String principal, List user, Claims claims, Collection authorities) { super(authorities); this.principal = principal; this.user = user; @@ -48,7 +50,7 @@ public Object getCredentials() { } @Override - public UserDto getDetails() { + public List getDetails() { return user; } diff --git a/src/main/java/jasper/security/jwt/TokenProviderImpl.java b/src/main/java/jasper/security/jwt/TokenProviderImpl.java index a90f8586..72072154 100644 --- a/src/main/java/jasper/security/jwt/TokenProviderImpl.java +++ b/src/main/java/jasper/security/jwt/TokenProviderImpl.java @@ -39,7 +39,6 @@ import static jasper.security.Auth.getOriginHeader; import static jasper.security.AuthoritiesConstants.ADMIN; import static jasper.security.AuthoritiesConstants.MOD; -import static jasper.security.AuthoritiesConstants.PRIVATE; import static org.apache.commons.lang3.StringUtils.isBlank; import static org.apache.commons.lang3.StringUtils.isNotBlank; @@ -79,7 +78,7 @@ public String createToken(Authentication authentication, int validityInSeconds) public Authentication getAuthentication(String token, String origin) { var claims = getParser(origin).parseClaimsJws(token).getBody(); var principal = getUsername(claims, origin); - var user = getUser(principal); + var user = getUsers(principal); logger.debug("Token Auth {} {}", principal, origin); return new JwtAuthentication(principal, user, claims, getAuthorities(claims, user, origin)); } @@ -107,14 +106,17 @@ JwtParser getParser(String origin) { return jwtParsers.get(origin); } - Collection getAuthorities(Claims claims, UserDto user, String origin) { + Collection getAuthorities(Claims claims, List user, String origin) { var auth = getPartialAuthorities(claims, origin); - if (user != null && user.getRole() != null) { - logger.debug("User Roles: {}", user.getRole()); - if (User.ROLES.contains(user.getRole().trim())) { - auth.add(new SimpleGrantedAuthority(user.getRole().trim())); + for (var u : user) { + if (isNotBlank(u.getRole())) { + logger.debug("User Roles: {}", u.getRole()); + if (User.ROLES.contains(u.getRole().trim())) { + auth.add(new SimpleGrantedAuthority(u.getRole().trim())); + } } - } else { + } + if (user.isEmpty()) { logger.debug("No User"); } return auth; @@ -147,6 +149,7 @@ String getUsername(Claims claims, String origin) { principal = claims.get(security.getUsernameClaim(), String.class); logger.debug("User tag set by JWT claim {}: {} ({})", security.getUsernameClaim(), principal, origin); } + if (principal == null) principal = ""; logger.debug("Principal: {}", principal); if (principal.contains("@")) { var emailDomain = principal.substring(principal.indexOf("@") + 1); @@ -159,8 +162,7 @@ String getUsername(Claims claims, String origin) { var authorities = getPartialAuthorities(claims, origin); if (isBlank(principal) || !principal.matches(Tag.QTAG_REGEX) || - principal.equals("+user") || - principal.equals("_user")) { + principal.equals("user")) { logger.debug("Invalid principal {}.", principal); if (authorities.stream().noneMatch(a -> Arrays.stream(ROOT_ROLES_ALLOWED).anyMatch(r -> a.getAuthority().equals(r)))) { @@ -170,13 +172,9 @@ String getUsername(Claims claims, String origin) { } // The root user has access to every other user. // Only assign to mods or higher when username is missing. - if (!"+user".equals(principal)) { - // Default to private user if +user is not exactly specified - principal = "_user"; - } - } else if (!principal.startsWith("+user/") && !principal.startsWith("_user/")) { - var isPrivate = authorities.stream().map(GrantedAuthority::getAuthority).anyMatch(a -> a.equals(PRIVATE)); - principal = (isPrivate ? "_user/" : "+user/") + principal; + principal = "user"; + } else if (!principal.startsWith("user/")) { + principal = "user/" + principal; } logger.debug("Username: {}", principal + origin); return principal + origin; diff --git a/src/main/java/jasper/service/ProfileService.java b/src/main/java/jasper/service/ProfileService.java index 4fba780d..b2312646 100644 --- a/src/main/java/jasper/service/ProfileService.java +++ b/src/main/java/jasper/service/ProfileService.java @@ -19,7 +19,6 @@ import static jasper.security.AuthoritiesConstants.ANONYMOUS; import static jasper.security.AuthoritiesConstants.EDITOR; import static jasper.security.AuthoritiesConstants.MOD; -import static jasper.security.AuthoritiesConstants.PRIVATE; import static jasper.security.AuthoritiesConstants.USER; import static jasper.security.AuthoritiesConstants.VIEWER; @@ -41,13 +40,10 @@ public class ProfileService { @Timed(value = "jasper.service", extraTags = {"service", "profile"}, histogram = true) public void create(String qualifiedTag, String password, String role) { validateRole(role); - if (qualifiedTag.startsWith("user/")) { - throw new InvalidUserProfileException("User tag must be protected or private."); - } - if (!qualifiedTag.startsWith("_user/") && !qualifiedTag.startsWith("+user/")) { + if (!qualifiedTag.startsWith("user/")) { throw new InvalidUserProfileException("User tag must be start with user/"); } - profileManager.createUser(qualifiedTag.substring("+user/".length()), password, getRoles(qualifiedTag, role)); + profileManager.createUser(qualifiedTag.substring("user/".length()), password, getRoles(role)); } private void validateRole(String role) { @@ -56,21 +52,17 @@ private void validateRole(String role) { } } - private String[] getRoles(String qualifiedTag, String role) { + private String[] getRoles(String role) { if (!ROLES.contains(role)) { throw new InvalidUserProfileException("Invalid role: " + role); } - if (qualifiedTag.startsWith("_")) { - return new String[]{ role, PRIVATE }; - } else { - return new String[]{ role }; - } + return new String[]{ role }; } @PreAuthorize( "@auth.hasRole('VIEWER') and @auth.canReadTag(#qualifiedTag)") @Timed(value = "jasper.service", extraTags = {"service", "profile"}, histogram = true) public ProfileDto get(String qualifiedTag) { - return profileManager.getUser(qualifiedTag.substring("+user/".length())); + return profileManager.getUser(qualifiedTag.substring("user/".length())); } @PreAuthorize("@auth.rootMod()") @@ -82,14 +74,14 @@ public Page page(int pageNumber, int pageSize) { @PreAuthorize("(@auth.isUser(#qualifiedTag) or @auth.rootMod()) and @auth.freshLogin()") @Timed(value = "jasper.service", extraTags = {"service", "profile"}, histogram = true) public void changePassword(String qualifiedTag, String password) { - profileManager.changePassword(qualifiedTag.substring("+user/".length()), password); + profileManager.changePassword(qualifiedTag.substring("user/".length()), password); } @PreAuthorize( "@auth.hasRole('MOD') and @auth.canWriteUserTag(#qualifiedTag)") @Timed(value = "jasper.service", extraTags = {"service", "profile"}, histogram = true) public void changeRole(String qualifiedTag, String role) { validateRole(role); - profileManager.changeRoles(qualifiedTag.substring("+user/".length()), getRoles(qualifiedTag, role)); + profileManager.changeRoles(qualifiedTag.substring("user/".length()), getRoles(role)); } @PreAuthorize("@auth.rootMod()") @@ -98,12 +90,12 @@ public void setActive(String qualifiedTag, boolean active) { if (!active && auth.getUserTag().tag.equals(qualifiedTag)) { throw new DeactivateSelfException(); } - profileManager.setActive(qualifiedTag.substring("+user/".length()), active); + profileManager.setActive(qualifiedTag.substring("user/".length()), active); } @PreAuthorize("@auth.rootMod()") @Timed(value = "jasper.service", extraTags = {"service", "profile"}, histogram = true) public void delete(String qualifiedTag) { - profileManager.deleteUser(qualifiedTag.substring("+user/".length())); + profileManager.deleteUser(qualifiedTag.substring("user/".length())); } } diff --git a/src/test/java/jasper/security/AuthMultiTenantUnitTest.java b/src/test/java/jasper/security/AuthMultiTenantUnitTest.java index d59731ab..be85be9d 100644 --- a/src/test/java/jasper/security/AuthMultiTenantUnitTest.java +++ b/src/test/java/jasper/security/AuthMultiTenantUnitTest.java @@ -99,10 +99,10 @@ RefRepository getRefRepo(Ref ...refs) { ConfigCache getConfigs(UserDto ...users) { var configCache = mock(ConfigCache.class); - when(configCache.getUser(anyString())) + when(configCache.getUsers(anyString())) .thenReturn(null); for (var user : users) { - when(configCache.getUser(user.getQualifiedTag())) + when(configCache.getUsers(user.getQualifiedTag())) .thenReturn(user); } var root = new ServerConfig(); diff --git a/src/test/java/jasper/security/AuthUnitTest.java b/src/test/java/jasper/security/AuthUnitTest.java index e979787b..143f5eab 100644 --- a/src/test/java/jasper/security/AuthUnitTest.java +++ b/src/test/java/jasper/security/AuthUnitTest.java @@ -111,10 +111,10 @@ RefRepository getRefRepo(Ref ...refs) { } ConfigCache getConfigCache(UserDto ...users) { - when(configCache.getUser(anyString())) + when(configCache.getUsers(anyString())) .thenReturn(null); for (var user : users) { - when(configCache.getUser(user.getQualifiedTag())) + when(configCache.getUsers(user.getQualifiedTag())) .thenReturn(user); } return configCache;