Added users, authentication API, tokens, and more content access rule models.

This commit is contained in:
Andrew Lalis 2023-10-22 19:55:22 -04:00
parent be880e312f
commit 19c7f35abc
30 changed files with 673 additions and 62 deletions

3
onyx-api/.gitignore vendored
View File

@ -34,3 +34,6 @@ build/
*.mv.db *.mv.db
*.db *.db
*.der
*.pem

View File

@ -5,7 +5,7 @@
<parent> <parent>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId> <artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.4</version> <version>3.1.5</version>
<relativePath/> <!-- lookup parent from repository --> <relativePath/> <!-- lookup parent from repository -->
</parent> </parent>
<groupId>com.andrewlalis</groupId> <groupId>com.andrewlalis</groupId>
@ -56,6 +56,25 @@
<artifactId>spring-security-test</artifactId> <artifactId>spring-security-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- JWT dependencies -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@ -0,0 +1,31 @@
package com.andrewlalis.onyx.auth.api;
import com.andrewlalis.onyx.auth.model.User;
import com.andrewlalis.onyx.auth.service.TokenService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequiredArgsConstructor
public class AuthController {
private final TokenService tokenService;
@PostMapping("/auth/login")
public TokenPair login(@RequestBody LoginRequest loginRequest) {
return tokenService.generateTokenPair(loginRequest);
}
@GetMapping("/auth/access")
public Object getAccessToken(HttpServletRequest request) {
return Map.of("accessToken", tokenService.generateAccessToken(request));
}
@DeleteMapping("/auth/refresh-tokens")
public void removeAllRefreshTokens(@AuthenticationPrincipal User user) {
tokenService.removeAllRefreshTokens(user);
}
}

View File

@ -0,0 +1,3 @@
package com.andrewlalis.onyx.auth.api;
public record LoginRequest(String username, String password) {}

View File

@ -0,0 +1,3 @@
package com.andrewlalis.onyx.auth.api;
public record TokenPair(String refreshToken, String accessToken) {}

View File

@ -0,0 +1,45 @@
package com.andrewlalis.onyx.auth.components;
import com.andrewlalis.onyx.auth.service.TokenService;
import com.andrewlalis.onyx.auth.service.UserService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {
private final UserService userService;
private final TokenService tokenService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
try {
Jws<Claims> jws = tokenService.getToken(request);
if (jws != null) {
long userId = Long.parseLong(jws.getBody().getSubject());
userService.findById(userId).ifPresent(user -> SecurityContextHolder.getContext()
.setAuthentication(new TokenAuthentication(user, jws))
);
}
} catch (Exception e) {
log.warn("Exception occurred in JwtFilter.", e);
}
filterChain.doFilter(request, response);
}
}

View File

@ -0,0 +1,53 @@
package com.andrewlalis.onyx.auth.components;
import com.andrewlalis.onyx.auth.model.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;
import java.util.Collections;
/**
* The authentication implementation that's used when a user logs in with an
* access token.
* @param user The user that the token belongs to.
* @param jws The raw token.
*/
public record TokenAuthentication(User user, Jws<Claims> jws) implements Authentication {
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.emptyList();
}
@Override
public Object getCredentials() {
return this.jws;
}
@Override
public Object getDetails() {
return null;
}
@Override
public User getPrincipal() {
return this.user;
}
@Override
public boolean isAuthenticated() {
return true;
}
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
throw new RuntimeException("Cannot set the authenticated status of TokenAuthentication.");
}
@Override
public String getName() {
return user.getUsername();
}
}

View File

@ -0,0 +1,39 @@
package com.andrewlalis.onyx.auth.model;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
@Entity
@Table(name = "onyx_auth_refresh_token")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class RefreshToken {
public static final int SUFFIX_LENGTH = 7;
@Id
private String hash;
@Column(nullable = false, updatable = false, length = SUFFIX_LENGTH)
private String tokenSuffix;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
private User user;
@CreationTimestamp
private LocalDateTime createdAt;
@Column(nullable = false, updatable = false)
private LocalDateTime expiresAt;
public RefreshToken(String hash, String tokenSuffix, User user, LocalDateTime expiresAt) {
this.hash = hash;
this.tokenSuffix = tokenSuffix;
this.user = user;
this.expiresAt = expiresAt;
}
}

View File

@ -0,0 +1,10 @@
package com.andrewlalis.onyx.auth.model;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, String> {
Iterable<RefreshToken> findAllByTokenSuffix(String suffix);
void deleteAllByUserId(long userId);
}

View File

@ -0,0 +1,43 @@
package com.andrewlalis.onyx.auth.model;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
/**
* The main authentication principal entity, representing a single user that
* has an account on this onyx node.
*/
@Entity
@Table(name = "onyx_auth_user")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class User {
public static final int MAX_USERNAME_LENGTH = 32;
public static final int MAX_DISPLAY_NAME_LENGTH = 64;
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Column(nullable = false, unique = true, length = MAX_USERNAME_LENGTH)
private String username;
@Column(nullable = false, length = MAX_DISPLAY_NAME_LENGTH)
private String displayName;
@Column(nullable = false, length = 60)
private String passwordHash;
@CreationTimestamp
private LocalDateTime createdAt;
public User(String username, String displayName, String passwordHash) {
this.username = username;
this.displayName = displayName;
this.passwordHash = passwordHash;
}
}

View File

@ -0,0 +1,11 @@
package com.andrewlalis.onyx.auth.model;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}

View File

@ -0,0 +1,161 @@
package com.andrewlalis.onyx.auth.service;
import com.andrewlalis.onyx.auth.api.LoginRequest;
import com.andrewlalis.onyx.auth.api.TokenPair;
import com.andrewlalis.onyx.auth.model.RefreshToken;
import com.andrewlalis.onyx.auth.model.RefreshTokenRepository;
import com.andrewlalis.onyx.auth.model.User;
import com.andrewlalis.onyx.auth.model.UserRepository;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwtParserBuilder;
import io.jsonwebtoken.Jwts;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Date;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Slf4j
public class TokenService {
private static final String BEARER_PREFIX = "Bearer ";
private static final String ISSUER = "Onyx API";
private PrivateKey signingKey;
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(10);
private final RefreshTokenRepository refreshTokenRepository;
private final UserRepository userRepository;
@Transactional
public TokenPair generateTokenPair(LoginRequest request) {
Optional<User> optionalUser = userRepository.findByUsername(request.username());
if (
optionalUser.isEmpty() ||
!passwordEncoder.matches(request.password(), optionalUser.get().getPasswordHash())
) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials.");
}
User user = optionalUser.get();
String refreshToken = generateRefreshToken(user);
String accessToken = generateAccessToken(refreshToken);
return new TokenPair(refreshToken, accessToken);
}
@Transactional
public String generateRefreshToken(User user) {
Instant expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plusDays(7).toInstant();
try {
String token = Jwts.builder()
.setSubject(Long.toString(user.getId()))
.setIssuer(ISSUER)
.setAudience("Onyx App")
.setExpiration(Date.from(expiresAt))
.claim("username", user.getUsername())
.claim("token-type", "refresh")
.signWith(getSigningKey())
.compact();
refreshTokenRepository.saveAndFlush(new RefreshToken(
passwordEncoder.encode(token),
token.substring(token.length() - RefreshToken.SUFFIX_LENGTH),
user,
LocalDateTime.ofInstant(expiresAt, ZoneOffset.UTC)
));
return token;
} catch (Exception e) {
log.error("Failed to generate refresh token.", e);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to generate token.");
}
}
@Transactional(readOnly = true)
public String generateAccessToken(String refreshTokenString) {
String suffix = refreshTokenString.substring(refreshTokenString.length() - RefreshToken.SUFFIX_LENGTH);
RefreshToken refreshToken = null;
for (RefreshToken possibleToken : refreshTokenRepository.findAllByTokenSuffix(suffix)) {
if (passwordEncoder.matches(refreshTokenString, possibleToken.getHash())) {
refreshToken = possibleToken;
break;
}
}
if (refreshToken == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unknown refresh token.");
}
if (refreshToken.getExpiresAt().isBefore(LocalDateTime.now(ZoneOffset.UTC))) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Refresh token is expired.");
}
Instant expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plusHours(1).toInstant();
try {
return Jwts.builder()
.setSubject(Long.toString(refreshToken.getUser().getId()))
.setIssuer(ISSUER)
.setAudience("Onyx App")
.setExpiration(Date.from(expiresAt))
.claim("username", refreshToken.getUser().getUsername())
.claim("token-type", "access")
.signWith(getSigningKey())
.compact();
} catch (Exception e) {
log.error("Failed to generate access token.", e);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to generate token.");
}
}
@Transactional(readOnly = true)
public String generateAccessToken(HttpServletRequest request) {
String refreshTokenString = extractBearerToken(request);
if (refreshTokenString == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing refresh token.");
}
return generateAccessToken(refreshTokenString);
}
@Transactional
public void removeAllRefreshTokens(User user) {
refreshTokenRepository.deleteAllByUserId(user.getId());
}
public Jws<Claims> getToken(HttpServletRequest request) throws Exception {
String rawToken = extractBearerToken(request);
if (rawToken == null) return null;
JwtParserBuilder parserBuilder = Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.requireIssuer(ISSUER);
return parserBuilder.build().parseClaimsJws(rawToken);
}
private String extractBearerToken(HttpServletRequest request) {
String authorizationHeader = request.getHeader("Authorization");
if (authorizationHeader == null || !authorizationHeader.startsWith(BEARER_PREFIX)) return null;
String rawToken = authorizationHeader.substring(BEARER_PREFIX.length());
if (rawToken.isBlank()) return null;
return rawToken;
}
private PrivateKey getSigningKey() throws Exception {
if (signingKey == null) {
byte[] keyBytes = Files.readAllBytes(Path.of("private_key.der"));
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory kf = KeyFactory.getInstance("RSA");
signingKey = kf.generatePrivate(spec);
}
return signingKey;
}
}

View File

@ -0,0 +1,18 @@
package com.andrewlalis.onyx.auth.service;
import com.andrewlalis.onyx.auth.model.User;
import com.andrewlalis.onyx.auth.model.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public Optional<User> findById(long id) {
return userRepository.findById(id);
}
}

View File

@ -0,0 +1,34 @@
package com.andrewlalis.onyx.config;
import com.andrewlalis.onyx.auth.components.JwtFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtFilter jwtFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(registry -> {
registry.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/auth/login")).permitAll();
registry.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/auth/access")).permitAll();
registry.anyRequest().authenticated();
});
http.csrf(AbstractHttpConfigurer::disable);
http.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.NEVER));
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
http.cors(configurer -> configurer.configure(http));
return http.build();
}
}

View File

@ -1,22 +0,0 @@
package com.andrewlalis.onyx.content;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
/**
* A type of content node that contains a single document.
*/
@Entity
@Table(name = "content_node_document")
public class ContentDocumentNode extends ContentNode {
@Column(nullable = false, updatable = false, length = 127)
private String type;
/**
* The raw file content for this document.
*/
@Lob @Column(nullable = false)
private byte[] content;
}

View File

@ -0,0 +1,30 @@
package com.andrewlalis.onyx.content;
import com.andrewlalis.onyx.content.dao.ContentNodeRepository;
import com.andrewlalis.onyx.content.model.access.ContentAccessLevel;
import com.andrewlalis.onyx.content.model.ContentContainerNode;
import com.andrewlalis.onyx.content.model.ContentNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
@Slf4j
public class ContentTreeInitializer implements CommandLineRunner {
private final ContentNodeRepository contentNodeRepository;
@Override
public void run(String... args) throws Exception {
log.info("Check if content root container node exists.");
if (!contentNodeRepository.existsByName(ContentNode.ROOT_NODE_NAME)) {
ContentNode rootNode = new ContentContainerNode(ContentNode.ROOT_NODE_NAME, null);
rootNode.getAccessInfo().setPublicAccessLevel(ContentAccessLevel.NONE);
rootNode.getAccessInfo().setNetworkAccessLevel(ContentAccessLevel.NONE);
rootNode.getAccessInfo().setNodeAccessLevel(ContentAccessLevel.NONE);
contentNodeRepository.saveAndFlush(rootNode);
log.info("Created content root container node.");
}
}
}

View File

@ -0,0 +1,10 @@
package com.andrewlalis.onyx.content.dao;
import com.andrewlalis.onyx.content.model.ContentNode;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ContentNodeRepository extends JpaRepository<ContentNode, Long> {
boolean existsByName(String name);
}

View File

@ -1,6 +1,8 @@
package com.andrewlalis.onyx.content; package com.andrewlalis.onyx.content.model;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import java.util.Set; import java.util.Set;
@ -9,11 +11,16 @@ import java.util.Set;
* themselves be any type of content node. * themselves be any type of content node.
*/ */
@Entity @Entity
@Table(name = "content_node_container") @Table(name = "onyx_content_container_node")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public final class ContentContainerNode extends ContentNode { public final class ContentContainerNode extends ContentNode {
/** /**
* The set of children that belong to this container. * The set of children that belong to this container.
*/ */
@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "parentContainer") @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "parentContainer")
private Set<ContentNode> children; private Set<ContentNode> children;
public ContentContainerNode(String name, ContentContainerNode parentContainer) {
super(name, Type.CONTAINER, parentContainer);
}
} }

View File

@ -0,0 +1,31 @@
package com.andrewlalis.onyx.content.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* A type of content node that contains a single document.
*/
@Entity
@Table(name = "onyx_content_document_node")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ContentDocumentNode extends ContentNode {
@Column(nullable = false, updatable = false, length = 127)
private String contentType;
/**
* The raw file content for this document.
*/
@Lob @Column(nullable = false) @Setter
private byte[] content;
public ContentDocumentNode(String contentType, byte[] content) {
this.contentType = contentType;
this.content = content;
}
}

View File

@ -1,8 +1,10 @@
package com.andrewlalis.onyx.content; package com.andrewlalis.onyx.content.model;
import com.andrewlalis.onyx.content.history.ContentNodeHistory; import com.andrewlalis.onyx.content.model.access.ContentAccessRules;
import com.andrewlalis.onyx.content.model.history.ContentNodeHistory;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
@ -10,11 +12,13 @@ import lombok.NoArgsConstructor;
* content tree, including both <em>containers</em> and <em>documents</em>. * content tree, including both <em>containers</em> and <em>documents</em>.
*/ */
@Entity @Entity
@Table(name = "content_node") @Table(name = "onyx_content_node")
@Inheritance(strategy = InheritanceType.JOINED) @Inheritance(strategy = InheritanceType.JOINED)
@NoArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public abstract class ContentNode { public abstract class ContentNode {
public static final int MAX_NAME_LENGTH = 127; public static final int MAX_NAME_LENGTH = 127;
public static final String ROOT_NODE_NAME = "___ROOT___";
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private long id; private long id;
@ -23,7 +27,7 @@ public abstract class ContentNode {
private String name; private String name;
@Enumerated(EnumType.ORDINAL) @Column(columnDefinition = "TINYINT NOT NULL") @Enumerated(EnumType.ORDINAL) @Column(columnDefinition = "TINYINT NOT NULL")
private Type type; private Type nodeType;
@OneToOne(fetch = FetchType.LAZY, optional = false, orphanRemoval = true, cascade = CascadeType.ALL) @OneToOne(fetch = FetchType.LAZY, optional = false, orphanRemoval = true, cascade = CascadeType.ALL)
private ContentAccessRules accessInfo; private ContentAccessRules accessInfo;
@ -49,7 +53,8 @@ public abstract class ContentNode {
public ContentNode(String name, Type type, ContentContainerNode parentContainer) { public ContentNode(String name, Type type, ContentContainerNode parentContainer) {
this.name = name; this.name = name;
this.type = type; this.nodeType = type;
this.accessInfo = new ContentAccessRules();
this.parentContainer = parentContainer; this.parentContainer = parentContainer;
this.history = new ContentNodeHistory(this); this.history = new ContentNodeHistory(this);
} }

View File

@ -1,4 +1,4 @@
package com.andrewlalis.onyx.content; package com.andrewlalis.onyx.content.model.access;
/** /**
* The different levels of access that can be set for content in an onyx node. * The different levels of access that can be set for content in an onyx node.

View File

@ -1,14 +1,18 @@
package com.andrewlalis.onyx.content; package com.andrewlalis.onyx.content.model.access;
import com.andrewlalis.onyx.content.model.ContentNode;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Getter; import lombok.Getter;
import lombok.Setter;
import java.util.Set;
/** /**
* An entity meant to be attached to a {@link ContentNode}, which contains the * An entity meant to be attached to a {@link ContentNode}, which contains the
* access levels for the different ways in which a content node can be accessed. * access levels for the different ways in which a content node can be accessed.
*/ */
@Entity @Entity
@Table(name = "content_access_rules") @Table(name = "onyx_content_access_rules")
@Getter @Getter
public class ContentAccessRules { public class ContentAccessRules {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@ -21,17 +25,20 @@ public class ContentAccessRules {
private ContentNode contentNode; private ContentNode contentNode;
/** The access level that applies to users from outside this onyx network. */ /** The access level that applies to users from outside this onyx network. */
@Enumerated(EnumType.ORDINAL) @Column(nullable = false, columnDefinition = "TINYINT NOT NULL") @Enumerated(EnumType.ORDINAL) @Column(nullable = false, columnDefinition = "TINYINT NOT NULL") @Setter
private ContentAccessLevel publicAccessLevel = ContentAccessLevel.INHERIT; private ContentAccessLevel publicAccessLevel = ContentAccessLevel.INHERIT;
/** The access level that applies to users from within this onyx network. */ /** The access level that applies to users from within this onyx network. */
@Enumerated(EnumType.ORDINAL) @Column(nullable = false, columnDefinition = "TINYINT NOT NULL") @Enumerated(EnumType.ORDINAL) @Column(nullable = false, columnDefinition = "TINYINT NOT NULL") @Setter
private ContentAccessLevel networkAccessLevel = ContentAccessLevel.INHERIT; private ContentAccessLevel networkAccessLevel = ContentAccessLevel.INHERIT;
/** The access level that applies to users within only this onyx node. */ /** The access level that applies to users within only this onyx node. */
@Enumerated(EnumType.ORDINAL) @Column(nullable = false, columnDefinition = "TINYINT NOT NULL") @Enumerated(EnumType.ORDINAL) @Column(nullable = false, columnDefinition = "TINYINT NOT NULL") @Setter
private ContentAccessLevel nodeAccessLevel = ContentAccessLevel.INHERIT; private ContentAccessLevel nodeAccessLevel = ContentAccessLevel.INHERIT;
// TODO: Add a user allowlist. /**
* User-specific access rules that override other more generic rules, if present.
*/
@OneToMany(fetch = FetchType.LAZY, orphanRemoval = true, cascade = CascadeType.ALL, mappedBy = "contentAccessRules")
private Set<UserContentAccessRule> userAccessRules;
} }

View File

@ -0,0 +1,32 @@
package com.andrewlalis.onyx.content.model.access;
import com.andrewlalis.onyx.auth.model.User;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "onyx_content_access_rules_user")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class UserContentAccessRule {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private User user;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private ContentAccessRules contentAccessRules;
@Enumerated(EnumType.ORDINAL) @Column(nullable = false, columnDefinition = "TINYINT NOT NULL") @Setter
private ContentAccessLevel accessLevel;
public UserContentAccessRule(User user, ContentAccessRules contentAccessRules, ContentAccessLevel accessLevel) {
this.user = user;
this.contentAccessRules = contentAccessRules;
this.accessLevel = accessLevel;
}
}

View File

@ -1,8 +1,9 @@
package com.andrewlalis.onyx.content.history; package com.andrewlalis.onyx.content.model.history;
import com.andrewlalis.onyx.content.ContentNode; import com.andrewlalis.onyx.content.model.ContentNode;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
@ -15,8 +16,9 @@ import java.util.Set;
* storing an ordered list of entries detailing how that node has changed. * storing an ordered list of entries detailing how that node has changed.
*/ */
@Entity @Entity
@Table(name = "content_node_history") @Table(name = "onyx_content_node_history")
@NoArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ContentNodeHistory { public class ContentNodeHistory {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private long id; private long id;

View File

@ -1,4 +1,4 @@
package com.andrewlalis.onyx.content.history; package com.andrewlalis.onyx.content.model.history;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AccessLevel; import lombok.AccessLevel;
@ -13,7 +13,7 @@ import java.time.LocalDateTime;
* some basic properties that all entries should have. * some basic properties that all entries should have.
*/ */
@Entity @Entity
@Table(name = "content_node_history_entry") @Table(name = "onyx_content_node_history_entry")
@Inheritance(strategy = InheritanceType.JOINED) @Inheritance(strategy = InheritanceType.JOINED)
@Getter @Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED)

View File

@ -1,8 +1,8 @@
package com.andrewlalis.onyx.content.history.entry; package com.andrewlalis.onyx.content.model.history.entry;
import com.andrewlalis.onyx.content.ContentAccessLevel; import com.andrewlalis.onyx.content.model.access.ContentAccessLevel;
import com.andrewlalis.onyx.content.history.ContentNodeHistory; import com.andrewlalis.onyx.content.model.history.ContentNodeHistory;
import com.andrewlalis.onyx.content.history.ContentNodeHistoryEntry; import com.andrewlalis.onyx.content.model.history.ContentNodeHistoryEntry;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.Getter; import lombok.Getter;
@ -13,7 +13,7 @@ import lombok.NoArgsConstructor;
* by a user or the system. * by a user or the system.
*/ */
@Entity @Entity
@Table(name = "content_node_history_entry_access_rules") @Table(name = "onyx_content_node_history_entry__access_rules")
@Getter @Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AccessRulesEntry extends ContentNodeHistoryEntry { public class AccessRulesEntry extends ContentNodeHistoryEntry {

View File

@ -1,7 +1,7 @@
package com.andrewlalis.onyx.content.history.entry; package com.andrewlalis.onyx.content.model.history.entry;
import com.andrewlalis.onyx.content.history.ContentNodeHistory; import com.andrewlalis.onyx.content.model.history.ContentNodeHistory;
import com.andrewlalis.onyx.content.history.ContentNodeHistoryEntry; import com.andrewlalis.onyx.content.model.history.ContentNodeHistoryEntry;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.Table; import jakarta.persistence.Table;
@ -13,7 +13,7 @@ import lombok.NoArgsConstructor;
* History entry for tracking the state of a content node's `archived` status. * History entry for tracking the state of a content node's `archived` status.
*/ */
@Entity @Entity
@Table(name = "content_node_history_entry_archived") @Table(name = "onyx_content_node_history_entry__archived")
@Getter @Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ArchivedEntry extends ContentNodeHistoryEntry { public class ArchivedEntry extends ContentNodeHistoryEntry {

View File

@ -1,8 +1,9 @@
package com.andrewlalis.onyx.content.history.entry; package com.andrewlalis.onyx.content.model.history.entry;
import com.andrewlalis.onyx.content.ContentNode; import com.andrewlalis.onyx.content.model.ContentContainerNode;
import com.andrewlalis.onyx.content.history.ContentNodeHistory; import com.andrewlalis.onyx.content.model.ContentNode;
import com.andrewlalis.onyx.content.history.ContentNodeHistoryEntry; import com.andrewlalis.onyx.content.model.history.ContentNodeHistory;
import com.andrewlalis.onyx.content.model.history.ContentNodeHistoryEntry;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
@ -11,7 +12,7 @@ import lombok.NoArgsConstructor;
* History entry for tracking updates to the contents of a container node. * History entry for tracking updates to the contents of a container node.
*/ */
@Entity @Entity
@Table(name = "content_node_history_entry_container_edit") @Table(name = "onyx_content_node_history_entry__container_edit")
@NoArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ContainerEditEntry extends ContentNodeHistoryEntry { public class ContainerEditEntry extends ContentNodeHistoryEntry {
public enum EditType { public enum EditType {
@ -29,6 +30,9 @@ public class ContainerEditEntry extends ContentNodeHistoryEntry {
public ContainerEditEntry(ContentNodeHistory history, EditType type, String affectedNodeName) { public ContainerEditEntry(ContentNodeHistory history, EditType type, String affectedNodeName) {
super(history); super(history);
if (!(history.getContentNode() instanceof ContentContainerNode)) {
throw new IllegalArgumentException("Only the history of a ContentContainerNode may be used to create a ContainerEditEntry.");
}
this.type = type; this.type = type;
this.affectedNodeName = affectedNodeName; this.affectedNodeName = affectedNodeName;
} }

View File

@ -0,0 +1,32 @@
package com.andrewlalis.onyx.content.model.history.entry;
import com.andrewlalis.onyx.content.model.ContentDocumentNode;
import com.andrewlalis.onyx.content.model.history.ContentNodeHistory;
import com.andrewlalis.onyx.content.model.history.ContentNodeHistoryEntry;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* A history entry that records an edit that was made to a document.
*/
@Entity
@Table(name = "onyx_content_node_history_entry__document_edit")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class DocumentEditEntry extends ContentNodeHistoryEntry {
@Lob @Column(nullable = false, updatable = false)
private byte[] diff;
public DocumentEditEntry(ContentNodeHistory history, byte[] diff) {
super(history);
if (!(history.getContentNode() instanceof ContentDocumentNode)) {
throw new IllegalArgumentException("Only the history of a ContentDocumentNode may be used to create a DocumentEditEntry.");
}
this.diff = diff;
}
}

View File

@ -1,8 +1,8 @@
package com.andrewlalis.onyx.content.history.entry; package com.andrewlalis.onyx.content.model.history.entry;
import com.andrewlalis.onyx.content.ContentNode; import com.andrewlalis.onyx.content.model.ContentNode;
import com.andrewlalis.onyx.content.history.ContentNodeHistory; import com.andrewlalis.onyx.content.model.history.ContentNodeHistory;
import com.andrewlalis.onyx.content.history.ContentNodeHistoryEntry; import com.andrewlalis.onyx.content.model.history.ContentNodeHistoryEntry;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.Table; import jakarta.persistence.Table;
@ -11,7 +11,7 @@ import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
@Entity @Entity
@Table(name = "content_node_history_entry_rename") @Table(name = "onyx_content_node_history_entry__rename")
@Getter @Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RenameEntry extends ContentNodeHistoryEntry { public class RenameEntry extends ContentNodeHistoryEntry {