Co-authored-by: ConnorYoh <[email protected]>
Co-authored-by: Connor Yoh <[email protected]>
Co-authored-by: EthanHealy01 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Anthony Stirling
2026-03-25 11:00:40 +00:00
committed by GitHub
co-authored by ConnorYoh Connor Yoh EthanHealy01 Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
parent 47cad0a131
commit 28613caf8a
181 changed files with 25715 additions and 124 deletions
@@ -58,6 +58,7 @@ public class ApplicationProperties {
private Legal legal = new Legal();
private Security security = new Security();
private System system = new System();
private Storage storage = new Storage();
private Ui ui = new Ui();
private Endpoints endpoints = new Endpoints();
private Metrics metrics = new Metrics();
@@ -634,6 +635,41 @@ public class ApplicationProperties {
}
}
@Data
public static class Storage {
private boolean enabled = false;
private String provider = "local";
private Local local = new Local();
private Quotas quotas = new Quotas();
private Sharing sharing = new Sharing();
private Signing signing = new Signing();
@Data
public static class Local {
private String basePath = InstallationPathConfig.getPath() + "storage";
}
@Data
public static class Sharing {
private boolean enabled = false;
private boolean linkEnabled = false;
private boolean emailEnabled = false;
private int linkExpirationDays = 3;
}
@Data
public static class Quotas {
private long maxStorageMbPerUser = -1;
private long maxStorageMbTotal = -1;
private long maxFileMb = -1;
}
@Data
public static class Signing {
private boolean enabled = false;
}
}
@Data
public static class DatabaseBackup {
private String cron = "0 0 0 * * ?"; // daily at midnight
@@ -0,0 +1,16 @@
package stirling.software.common.model.api.security;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserSummaryDTO {
private Long userId;
private String username;
private String displayName;
private String teamName;
private boolean enabled;
}
@@ -0,0 +1,37 @@
package stirling.software.common.service;
import java.security.KeyStore;
/**
* Abstraction for PDF digital signature operations. Defined in common so that proprietary services
* can use it without creating a circular dependency on core.
*/
public interface PdfSigningService {
/**
* Signs a PDF document using the provided KeyStore.
*
* @param pdfBytes raw PDF bytes to sign
* @param keystore the KeyStore containing the signing key and certificate chain
* @param password keystore password
* @param showSignature whether to render a visible signature block
* @param pageNumber 0-indexed page on which to render the visible signature (may be null)
* @param name signer name embedded in the signature
* @param location location string embedded in the signature
* @param reason reason string embedded in the signature
* @param showLogo whether to include the Stirling-PDF logo in the visible signature
* @return signed PDF bytes
* @throws Exception on any signing failure
*/
byte[] signWithKeystore(
byte[] pdfBytes,
KeyStore keystore,
char[] password,
boolean showSignature,
Integer pageNumber,
String name,
String location,
String reason,
boolean showLogo)
throws Exception;
}
@@ -204,6 +204,27 @@ public class ConfigController {
boolean invitesEnabled = applicationProperties.getMail().isEnableInvites();
configData.put("enableEmailInvites", smtpEnabled && invitesEnabled);
// Storage settings
boolean storageEnabled = enableLogin && applicationProperties.getStorage().isEnabled();
boolean sharingEnabled =
storageEnabled && applicationProperties.getStorage().getSharing().isEnabled();
boolean frontendUrlConfigured = frontendUrl != null && !frontendUrl.trim().isEmpty();
boolean shareLinksEnabled =
sharingEnabled
&& applicationProperties.getStorage().getSharing().isLinkEnabled()
&& frontendUrlConfigured;
boolean shareEmailEnabled =
sharingEnabled
&& applicationProperties.getStorage().getSharing().isEmailEnabled()
&& applicationProperties.getMail().isEnabled();
boolean groupSigningEnabled =
storageEnabled && applicationProperties.getStorage().getSigning().isEnabled();
configData.put("storageEnabled", storageEnabled);
configData.put("storageSharingEnabled", sharingEnabled);
configData.put("storageShareLinksEnabled", shareLinksEnabled);
configData.put("storageShareEmailEnabled", shareEmailEnabled);
configData.put("storageGroupSigningEnabled", groupSigningEnabled);
// Check if user is admin using UserServiceInterface
boolean isAdmin = false;
if (userService != null) {
@@ -113,7 +113,7 @@ public class CertSignController {
this.serverCertificateService = serverCertificateService;
}
private static void sign(
public static void sign(
CustomPDFDocumentFactory pdfDocumentFactory,
MultipartFile input,
OutputStream output,
@@ -304,7 +304,7 @@ public class CertSignController {
}
}
class CreateSignature extends CreateSignatureBase {
public static class CreateSignature extends CreateSignatureBase {
File logoFile;
public CreateSignature(KeyStore keystore, char[] pin)
@@ -0,0 +1,111 @@
package stirling.software.SPDF.service;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.security.KeyStore;
import org.springframework.stereotype.Service;
import stirling.software.SPDF.controller.api.security.CertSignController;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfSigningService;
/** Core implementation of {@link PdfSigningService} backed by {@link CertSignController}. */
@Service
public class PdfSigningServiceImpl implements PdfSigningService {
private final CustomPDFDocumentFactory pdfDocumentFactory;
public PdfSigningServiceImpl(CustomPDFDocumentFactory pdfDocumentFactory) {
this.pdfDocumentFactory = pdfDocumentFactory;
}
@Override
public byte[] signWithKeystore(
byte[] pdfBytes,
KeyStore keystore,
char[] password,
boolean showSignature,
Integer pageNumber,
String name,
String location,
String reason,
boolean showLogo)
throws Exception {
CertSignController.CreateSignature createSignature =
new CertSignController.CreateSignature(keystore, password);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ByteArrayMultipartFile inputFile =
new ByteArrayMultipartFile(pdfBytes, "document.pdf", "application/pdf");
CertSignController.sign(
pdfDocumentFactory,
inputFile,
outputStream,
createSignature,
showSignature,
pageNumber,
name,
location,
reason,
showLogo);
return outputStream.toByteArray();
}
/** Minimal MultipartFile wrapper for passing raw PDF bytes to CertSignController.sign(). */
private static class ByteArrayMultipartFile
implements org.springframework.web.multipart.MultipartFile {
private final byte[] content;
private final String filename;
private final String contentType;
ByteArrayMultipartFile(byte[] content, String filename, String contentType) {
this.content = content;
this.filename = filename;
this.contentType = contentType;
}
@Override
public String getName() {
return "file";
}
@Override
public String getOriginalFilename() {
return filename;
}
@Override
public String getContentType() {
return contentType;
}
@Override
public boolean isEmpty() {
return content == null || content.length == 0;
}
@Override
public long getSize() {
return content == null ? 0 : content.length;
}
@Override
public byte[] getBytes() {
return content;
}
@Override
public java.io.InputStream getInputStream() {
return new ByteArrayInputStream(content);
}
@Override
public void transferTo(java.io.File dest) throws java.io.IOException {
java.nio.file.Files.write(dest.toPath(), content);
}
}
}
@@ -50,6 +50,8 @@ spring.mvc.problemdetails.enabled=false
# Or via SYSTEMFILEUPLOADLIMIT/SYSTEM_MAXFILESIZE which will also set fileUploadLimit in settings.yml
spring.servlet.multipart.max-file-size=${SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE:2000MB}
spring.servlet.multipart.max-request-size=${SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE:2000MB}
# Jetty max form content size (default 200KB is too small for signature images)
server.jetty.max-http-form-post-size=10MB
server.servlet.session.tracking-modes=cookie
server.servlet.context-path=${SYSTEM_ROOTURIPATH:/}
spring.devtools.restart.enabled=true
@@ -240,6 +240,22 @@ system:
databaseBackup:
cron: "0 0 0 * * ?" # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight
storage:
enabled: false # set to 'true' to allow users to store files on the server (requires security.enableLogin) [ALPHA]
provider: local # storage provider: 'local' for filesystem storage, 'database' for DB-backed storage
local:
basePath: './storage' # base directory for stored files
quotas:
maxStorageMbPerUser: -1 # Max storage per user in MB; -1 disables per-user cap
maxStorageMbTotal: -1 # Max storage across all users in MB; -1 disables total cap
maxFileMb: -1 # Max size per stored file (including history/audit) in MB; -1 disables limit
sharing:
enabled: false # set to 'true' to enable file sharing features [ALPHA]
linkEnabled: true # set to 'false' to disable share links (requires system.frontendUrl)
emailEnabled: false # set to 'true' to allow sharing by email (requires mail.enabled)
linkExpirationDays: 3 # Number of days before share links expire
signing:
enabled: false # set to 'true' to enable group signing workflow (requires storage.enabled) [ALPHA]
autoPipeline:
outputFolder: "" # Output folder for processed pipeline files (leave empty for default)
fileReadiness:
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -28,9 +28,16 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
basePackages = {
"stirling.software.proprietary.security.database.repository",
"stirling.software.proprietary.security.repository",
"stirling.software.proprietary.repository"
"stirling.software.proprietary.repository",
"stirling.software.proprietary.storage.repository",
"stirling.software.proprietary.workflow.repository"
})
@EntityScan({"stirling.software.proprietary.security.model", "stirling.software.proprietary.model"})
@EntityScan({
"stirling.software.proprietary.security.model",
"stirling.software.proprietary.model",
"stirling.software.proprietary.storage.model",
"stirling.software.proprietary.workflow.model"
})
public class DatabaseConfig {
public final String DATASOURCE_DEFAULT_URL;
@@ -0,0 +1,22 @@
package stirling.software.proprietary.security.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.filter.ParticipantRateLimitInterceptor;
@Configuration
@RequiredArgsConstructor
public class ProprietaryWebMvcConfig implements WebMvcConfigurer {
private final ParticipantRateLimitInterceptor participantRateLimitInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(participantRateLimitInterceptor)
.addPathPatterns("/api/v1/workflow/participant/**");
}
}
@@ -612,6 +612,7 @@ public class AdminSettingsController {
case "endpoints" -> applicationProperties.getEndpoints();
case "metrics" -> applicationProperties.getMetrics();
case "mail" -> applicationProperties.getMail();
case "storage" -> applicationProperties.getStorage();
case "premium" -> applicationProperties.getPremium();
case "processexecutor", "processExecutor" -> applicationProperties.getProcessExecutor();
case "autopipeline", "autoPipeline" -> applicationProperties.getAutoPipeline();
@@ -633,6 +634,7 @@ public class AdminSettingsController {
"endpoints",
"metrics",
"mail",
"storage",
"premium",
"processExecutor",
"processexecutor",
@@ -18,6 +18,7 @@ import org.springframework.security.core.session.SessionInformation;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -33,6 +34,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.UserApi;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.api.security.UserSummaryDTO;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.audit.AuditEventType;
@@ -775,6 +777,7 @@ public class UserController {
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/deleteUser/{username}")
@Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC)
public ResponseEntity<?> deleteUser(
@PathVariable("username") String username, Authentication authentication) {
if (!userService.usernameExistsIgnoreCase(username)) {
@@ -964,4 +967,34 @@ public class UserController {
.body("Failed to complete initial setup");
}
}
/**
* List all enabled users for selection in signing workflows.
*
* @param principal The authenticated user
* @return List of user summaries
*/
@GetMapping("/users")
public ResponseEntity<List<UserSummaryDTO>> listUsers(Principal principal) {
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
List<UserSummaryDTO> users =
userRepository.findAll().stream()
.filter(User::isEnabled)
.map(this::toUserSummaryDTO)
.collect(java.util.stream.Collectors.toList());
return ResponseEntity.ok(users);
}
private UserSummaryDTO toUserSummaryDTO(User user) {
return new UserSummaryDTO(
user.getId(),
user.getUsername(),
user.getUsername(), // Use username as displayName
user.getTeam() != null ? user.getTeam().getName() : null,
user.isEnabled());
}
}
@@ -0,0 +1,73 @@
package stirling.software.proprietary.security.filter;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.http.HttpStatus;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
/** Per-IP rate limiter for the unauthenticated participant token endpoints. */
@Slf4j
@Component
public class ParticipantRateLimitInterceptor implements HandlerInterceptor {
private static final int MAX_REQUESTS_PER_MINUTE = 20;
private static final long WINDOW_MS = 60_000L;
// value: [requestCount, windowStartMs]
private final ConcurrentHashMap<String, long[]> requestCounts = new ConcurrentHashMap<>();
@Override
public boolean preHandle(
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String ip = getClientIp(request);
long now = System.currentTimeMillis();
long[] entry =
requestCounts.compute(
ip,
(key, existing) -> {
if (existing == null || now - existing[1] >= WINDOW_MS) {
return new long[] {1, now};
}
existing[0]++;
return existing;
});
if (entry[0] > MAX_REQUESTS_PER_MINUTE) {
log.warn(
"Rate limit exceeded for IP {} on participant endpoint {}",
ip,
request.getRequestURI());
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setHeader("Retry-After", "60");
response.setContentType("application/json");
response.getWriter()
.write("{\"error\":\"Rate limit exceeded. Try again in 60 seconds.\"}");
return false;
}
return true;
}
private String getClientIp(HttpServletRequest request) {
// Do not trust X-Forwarded-For: it is user-controlled and trivially spoofed,
// which would allow an attacker to bypass this rate limiter by rotating fake IPs.
// Operators who deploy behind a trusted reverse proxy should configure Spring's
// RemoteIpFilter / ForwardedHeaderFilter at the framework level instead.
return request.getRemoteAddr();
}
@Scheduled(fixedDelay = 300_000)
public void cleanupExpiredWindows() {
long cutoff = System.currentTimeMillis() - WINDOW_MS;
requestCounts.entrySet().removeIf(e -> e.getValue()[1] < cutoff);
}
}
@@ -40,6 +40,7 @@ public class User implements UserDetails, Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
@EqualsAndHashCode.Include
private Long id;
@Column(name = "username", unique = true)
@@ -41,6 +41,7 @@ import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.Authority;
@@ -48,6 +49,16 @@ import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.StorageCleanupEntry;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.repository.FileShareAccessRepository;
import stirling.software.proprietary.storage.repository.FileShareRepository;
import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository;
import stirling.software.proprietary.storage.repository.StoredFileRepository;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository;
import stirling.software.proprietary.workflow.service.UserServerCertificateService;
@Service
@Slf4j
@@ -68,6 +79,15 @@ public class UserService implements UserServiceInterface {
private final ApplicationProperties.Security.OAUTH2 oAuth2;
private final PersistentLoginRepository persistentLoginRepository;
private final UserServerCertificateService userServerCertificateService;
private final WorkflowParticipantRepository workflowParticipantRepository;
private final WorkflowSessionRepository workflowSessionRepository;
private final StoredFileRepository storedFileRepository;
private final StorageCleanupEntryRepository storageCleanupEntryRepository;
private final FileShareRepository fileShareRepository;
private final FileShareAccessRepository fileShareAccessRepository;
@Transactional
public void processSSOPostLogin(
String username,
@@ -200,19 +220,78 @@ public class UserService implements UserServiceInterface {
return userOpt.isPresent() && apiKey.equals(userOpt.get().getApiKey());
}
@Transactional
public void deleteUser(String username) {
Optional<User> userOpt = findByUsernameIgnoreCase(username);
if (userOpt.isPresent()) {
for (Authority authority : userOpt.get().getAuthorities()) {
User user = userOpt.get();
for (Authority authority : user.getAuthorities()) {
if (authority.getAuthority().equals(Role.INTERNAL_API_USER.getRoleId())) {
return;
}
}
userRepository.delete(userOpt.get());
deleteUserRelatedData(user);
userRepository.delete(user);
persistentLoginRepository.deleteByUsername(username);
}
invalidateUserSessions(username);
}
private void deleteUserRelatedData(User user) {
log.info("Deleting all associated data for user: {}", user.getUsername());
// Delete server certificate (non-nullable OneToOne → User)
userServerCertificateService.deleteUserCertificate(user.getId());
// Delete FileShareAccess records where this user is the accessor
fileShareAccessRepository.deleteByUser(user);
// Delete FileShare records where this user is the recipient (shared with them by others).
// FileShareAccess for those shares must be cleared first (no cascade from FileShare side).
List<FileShare> sharesTargetingUser = fileShareRepository.findBySharedWithUser(user);
sharesTargetingUser.forEach(fileShareAccessRepository::deleteByFileShare);
fileShareRepository.deleteAll(sharesTargetingUser);
// Null out WorkflowParticipant.user for sessions this user participates in but does not
// own.
// The participant record is retained to preserve the workflow audit trail.
workflowParticipantRepository.clearUserReferences(user);
// Break circular FK: null out stored_files.workflow_session_id before deleting sessions
storedFileRepository.clearWorkflowSessionReferencesByOwner(user);
// Delete WorkflowSessions (CascadeType.ALL cascades to WorkflowParticipant)
workflowSessionRepository.deleteAll(
workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(user));
// Collect storage keys for physical cleanup before deleting DB records
List<StoredFile> files = storedFileRepository.findAllByOwner(user);
List<String> storageKeys =
files.stream()
.flatMap(
f ->
java.util.stream.Stream.of(
f.getStorageKey(),
f.getHistoryStorageKey(),
f.getAuditLogStorageKey()))
.filter(k -> k != null && !k.isBlank())
.toList();
// Clear FileShareAccess per share (no cascade from FileShare), then delete StoredFiles
// (CascadeType.ALL on StoredFile.shares cascades to FileShare)
for (StoredFile file : files) {
file.getShares().forEach(fileShareAccessRepository::deleteByFileShare);
}
storedFileRepository.deleteAll(files);
// Schedule physical deletion of all storage blobs; StorageCleanupService handles retry
for (String key : storageKeys) {
StorageCleanupEntry entry = new StorageCleanupEntry();
entry.setStorageKey(key);
storageCleanupEntryRepository.save(entry);
}
}
public boolean usernameExists(String username) {
return findByUsername(username).isPresent();
}
@@ -0,0 +1,74 @@
package stirling.software.proprietary.storage.config;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Locale;
import java.util.Optional;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.storage.provider.DatabaseStorageProvider;
import stirling.software.proprietary.storage.provider.LocalStorageProvider;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.repository.StoredFileBlobRepository;
@Configuration
@RequiredArgsConstructor
@Slf4j
public class StorageProviderConfig {
private final ApplicationProperties applicationProperties;
private final StoredFileBlobRepository storedFileBlobRepository;
@Bean
public StorageProvider storageProvider() {
boolean storageEnabled = applicationProperties.getStorage().isEnabled();
String providerName =
Optional.ofNullable(applicationProperties.getStorage().getProvider())
.orElse("local")
.trim()
.toLowerCase(Locale.ROOT);
if ("database".equals(providerName)) {
return new DatabaseStorageProvider(storedFileBlobRepository);
}
if (!"local".equals(providerName)) {
throw new IllegalStateException("Storage provider not supported: " + providerName);
}
String basePathValue = applicationProperties.getStorage().getLocal().getBasePath();
if (basePathValue == null || basePathValue.isBlank()) {
if (storageEnabled) {
throw new IllegalStateException("Storage base path is not configured");
}
basePathValue = InstallationPathConfig.getPath() + "storage";
}
Path basePath = Paths.get(basePathValue).toAbsolutePath().normalize();
Path installRoot = Paths.get(InstallationPathConfig.getPath()).toAbsolutePath().normalize();
if (!basePath.startsWith(installRoot)) {
// Warn rather than hard-fail: admins may legitimately point storage at an external
// volume, but an unexpected path could indicate a misconfiguration or traversal
// attempt.
log.warn(
"Storage basePath '{}' is outside the installation directory '{}'. "
+ "Verify this is intentional.",
basePath,
installRoot);
}
if (storageEnabled) {
try {
Files.createDirectories(basePath);
} catch (IOException e) {
throw new IllegalStateException(
"Unable to create storage base directory: " + basePath, e);
}
}
return new LocalStorageProvider(basePath);
}
}
@@ -0,0 +1,270 @@
package stirling.software.proprietary.storage.controller;
import java.util.List;
import java.util.Locale;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.model.api.CreateShareLinkRequest;
import stirling.software.proprietary.storage.model.api.ShareLinkAccessResponse;
import stirling.software.proprietary.storage.model.api.ShareLinkMetadataResponse;
import stirling.software.proprietary.storage.model.api.ShareLinkResponse;
import stirling.software.proprietary.storage.model.api.ShareWithUserRequest;
import stirling.software.proprietary.storage.model.api.StoredFileResponse;
import stirling.software.proprietary.storage.service.FileStorageService;
@RestController
@RequestMapping("/api/v1/storage")
@RequiredArgsConstructor
public class FileStorageController {
private final FileStorageService fileStorageService;
@PostMapping(
value = "/files",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public StoredFileResponse uploadFile(
@RequestPart("file") MultipartFile file,
@RequestPart(name = "historyBundle", required = false) MultipartFile historyBundle,
@RequestPart(name = "auditLog", required = false) MultipartFile auditLog) {
User user = fileStorageService.requireAuthenticatedUser();
return fileStorageService.storeFileResponse(user, file, historyBundle, auditLog);
}
@PutMapping(
value = "/files/{fileId}",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public StoredFileResponse updateFile(
@PathVariable Long fileId,
@RequestPart("file") MultipartFile file,
@RequestPart(name = "historyBundle", required = false) MultipartFile historyBundle,
@RequestPart(name = "auditLog", required = false) MultipartFile auditLog) {
User user = fileStorageService.requireAuthenticatedUser();
return fileStorageService.updateFileResponse(user, fileId, file, historyBundle, auditLog);
}
@GetMapping(value = "/files", produces = MediaType.APPLICATION_JSON_VALUE)
public List<StoredFileResponse> listFiles() {
User user = fileStorageService.requireAuthenticatedUser();
return fileStorageService.listAccessibleFileResponses(user);
}
@GetMapping(value = "/files/{fileId}", produces = MediaType.APPLICATION_JSON_VALUE)
public StoredFileResponse getFileMetadata(@PathVariable Long fileId) {
User user = fileStorageService.requireAuthenticatedUser();
return fileStorageService.getAccessibleFileResponse(user, fileId);
}
@GetMapping("/files/{fileId}/download")
public ResponseEntity<org.springframework.core.io.Resource> downloadFile(
@PathVariable Long fileId,
@RequestParam(name = "inline", defaultValue = "false") boolean inline) {
User user = fileStorageService.requireAuthenticatedUser();
StoredFile file = fileStorageService.getAccessibleFile(user, fileId);
fileStorageService.requireReadAccess(user, file);
return buildFileResponse(file, inline);
}
@DeleteMapping("/files/{fileId}")
public ResponseEntity<Void> deleteFile(@PathVariable Long fileId) {
User user = fileStorageService.requireAuthenticatedUser();
StoredFile file = fileStorageService.getOwnedFile(user, fileId);
fileStorageService.deleteFile(user, file);
return ResponseEntity.noContent().build();
}
@PostMapping(
value = "/files/{fileId}/shares/users",
produces = MediaType.APPLICATION_JSON_VALUE)
public StoredFileResponse shareWithUser(
@PathVariable Long fileId, @RequestBody ShareWithUserRequest request) {
User owner = fileStorageService.requireAuthenticatedUser();
if (request == null || request.getUsername() == null || request.getUsername().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Username is required");
}
return fileStorageService.shareWithUserResponse(
owner,
fileId,
request.getUsername(),
fileStorageService.normalizeShareRole(request.getAccessRole()));
}
@DeleteMapping("/files/{fileId}/shares/users/{username}")
public ResponseEntity<Void> revokeUserShare(
@PathVariable Long fileId, @PathVariable String username) {
User owner = fileStorageService.requireAuthenticatedUser();
StoredFile file = fileStorageService.getOwnedFile(owner, fileId);
fileStorageService.revokeUserShare(owner, file, username);
return ResponseEntity.noContent().build();
}
@DeleteMapping("/files/{fileId}/shares/self")
public ResponseEntity<Void> leaveUserShare(@PathVariable Long fileId) {
User user = fileStorageService.requireAuthenticatedUser();
StoredFile file = fileStorageService.getAccessibleFile(user, fileId);
fileStorageService.leaveUserShare(user, file);
return ResponseEntity.noContent().build();
}
@PostMapping(
value = "/files/{fileId}/shares/links",
produces = MediaType.APPLICATION_JSON_VALUE)
public ShareLinkResponse createShareLink(
@PathVariable Long fileId, @RequestBody CreateShareLinkRequest request) {
User owner = fileStorageService.requireAuthenticatedUser();
StoredFile file = fileStorageService.getOwnedFile(owner, fileId);
FileShare share =
fileStorageService.createShareLink(
owner,
file,
fileStorageService.normalizeShareRole(
request != null ? request.getAccessRole() : null));
return ShareLinkResponse.builder()
.token(share.getShareToken())
.accessRole(
share.getAccessRole() != null
? share.getAccessRole().name().toLowerCase(Locale.ROOT)
: null)
.createdAt(share.getCreatedAt())
.expiresAt(share.getExpiresAt())
.build();
}
@DeleteMapping("/files/{fileId}/shares/links/{token}")
public ResponseEntity<Void> revokeShareLink(
@PathVariable Long fileId, @PathVariable String token) {
User owner = fileStorageService.requireAuthenticatedUser();
StoredFile file = fileStorageService.getOwnedFile(owner, fileId);
fileStorageService.revokeShareLink(owner, file, token);
return ResponseEntity.noContent().build();
}
@GetMapping("/share-links/{token}")
public ResponseEntity<org.springframework.core.io.Resource> downloadShareLink(
@PathVariable String token,
Authentication authentication,
@RequestParam(name = "inline", defaultValue = "false") boolean inline) {
fileStorageService.ensureShareLinksEnabled();
FileShare share = fileStorageService.getShareByToken(token);
if (!fileStorageService.canAccessShareLink(share, authentication)) {
HttpStatus status =
isAuthenticated(authentication)
? HttpStatus.FORBIDDEN
: HttpStatus.UNAUTHORIZED;
String message =
status == HttpStatus.FORBIDDEN
? "Access denied for this share link"
: "Authentication required for this share link";
throw new ResponseStatusException(status, message);
}
fileStorageService.requireReadAccess(share);
fileStorageService.recordShareAccess(share, authentication, inline);
StoredFile file = share.getFile();
return buildFileResponse(file, inline);
}
@GetMapping("/share-links/{token}/metadata")
public ShareLinkMetadataResponse getShareLinkMetadata(
@PathVariable String token, Authentication authentication) {
fileStorageService.ensureShareLinksEnabled();
FileShare share = fileStorageService.getShareByToken(token);
if (!fileStorageService.canAccessShareLink(share, authentication)) {
HttpStatus status =
isAuthenticated(authentication)
? HttpStatus.FORBIDDEN
: HttpStatus.UNAUTHORIZED;
String message =
status == HttpStatus.FORBIDDEN
? "Access denied for this share link"
: "Authentication required for this share link";
throw new ResponseStatusException(status, message);
}
StoredFile file = share.getFile();
User currentUser = fileStorageService.requireAuthenticatedUser();
boolean ownedByCurrentUser =
currentUser != null
&& file.getOwner() != null
&& currentUser.getId().equals(file.getOwner().getId());
return ShareLinkMetadataResponse.builder()
.shareToken(share.getShareToken())
.fileId(file.getId())
.fileName(file.getOriginalFilename())
.owner(file.getOwner() != null ? file.getOwner().getUsername() : null)
.ownedByCurrentUser(ownedByCurrentUser)
.accessRole(
share.getAccessRole() != null
? share.getAccessRole().name().toLowerCase(Locale.ROOT)
: null)
.createdAt(share.getCreatedAt())
.expiresAt(share.getExpiresAt())
.build();
}
@GetMapping("/share-links/accessed")
public List<ShareLinkMetadataResponse> listAccessedShareLinks() {
fileStorageService.ensureShareLinksEnabled();
User user = fileStorageService.requireAuthenticatedUser();
return fileStorageService.listAccessedShareLinkResponses(user);
}
@GetMapping("/files/{fileId}/shares/links/{token}/accesses")
public List<ShareLinkAccessResponse> listShareAccesses(
@PathVariable Long fileId, @PathVariable String token) {
fileStorageService.ensureShareLinksEnabled();
User owner = fileStorageService.requireAuthenticatedUser();
StoredFile file = fileStorageService.getOwnedFile(owner, fileId);
return fileStorageService.listShareAccessResponses(owner, file, token);
}
private ResponseEntity<org.springframework.core.io.Resource> buildFileResponse(
StoredFile file, boolean inline) {
org.springframework.core.io.Resource resource = fileStorageService.loadFile(file);
String contentType =
file.getContentType() == null
? MediaType.APPLICATION_OCTET_STREAM_VALUE
: file.getContentType();
ContentDisposition disposition =
ContentDisposition.builder(inline ? "inline" : "attachment")
.filename(file.getOriginalFilename())
.build();
HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(disposition);
try {
headers.setContentType(MediaType.parseMediaType(contentType));
} catch (IllegalArgumentException ex) {
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
}
headers.setContentLength(file.getSizeBytes());
return ResponseEntity.ok().headers(headers).body(resource);
}
private boolean isAuthenticated(Authentication authentication) {
return authentication != null
&& authentication.isAuthenticated()
&& !"anonymousUser".equals(authentication.getPrincipal());
}
}
@@ -0,0 +1,81 @@
package stirling.software.proprietary.storage.converter;
import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import lombok.extern.slf4j.Slf4j;
/**
* JPA AttributeConverter for storing Map<String, Object> as JSON in database columns.
*
* <p>Converts between Java Map objects and JSON strings for PostgreSQL JSONB or TEXT columns.
* Includes backward compatibility handling for legacy double-encoded JSON data.
*/
@Converter
@Slf4j
public class JsonMapConverter implements AttributeConverter<Map<String, Object>, String> {
private static final ObjectMapper objectMapper = new ObjectMapper();
@Override
public String convertToDatabaseColumn(Map<String, Object> attribute) {
if (attribute == null || attribute.isEmpty()) {
return null;
}
try {
return objectMapper.writeValueAsString(attribute);
} catch (JsonProcessingException e) {
log.error("Failed to convert map to JSON", e);
throw new RuntimeException("Failed to convert map to JSON", e);
}
}
@Override
public Map<String, Object> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) {
return new HashMap<>();
}
try {
// Try normal parsing first
return objectMapper.readValue(dbData, new TypeReference<Map<String, Object>>() {});
} catch (JsonProcessingException e) {
// Fallback: try double-parsing for legacy double-encoded data
// This handles data that was stored as JSON strings instead of JSON objects
log.debug("Attempting double-decode fallback for legacy metadata format");
try {
JsonNode node = objectMapper.readTree(dbData);
if (node.isTextual()) {
log.warn(
"╔════════════════════════════════════════════════════════════════════╗");
log.warn(
"║ WARNING: DOUBLE-ENCODED JSON DETECTED - LEGACY DATA FOUND ║");
log.warn(
"║ This should not occur in newly created records. ║");
log.warn(
"║ Data preview: {}",
dbData.length() > 100 ? dbData.substring(0, 100) + "..." : dbData);
log.warn(
"╚════════════════════════════════════════════════════════════════════╝");
return objectMapper.readValue(
node.asText(), new TypeReference<Map<String, Object>>() {});
}
} catch (JsonProcessingException e2) {
log.error("Failed to parse metadata even with double-decode fallback", e2);
}
// If all parsing fails, return empty map to prevent application errors
log.error("Unable to parse JSON metadata, returning empty map", e);
return new HashMap<>();
}
}
}
@@ -0,0 +1,19 @@
package stirling.software.proprietary.storage.model;
/**
* Defines the purpose classification for stored files. Used to categorize files based on their role
* in the system.
*/
public enum FilePurpose {
/** Regular file sharing - generic uploaded files */
GENERIC,
/** Original PDF in a signing session - the document to be signed */
SIGNING_ORIGINAL,
/** Final signed PDF - the completed document with all signatures applied */
SIGNING_SIGNED,
/** Audit trail for signing session - history and metadata */
SIGNING_HISTORY
}
@@ -0,0 +1,77 @@
package stirling.software.proprietary.storage.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.security.model.User;
/** Represents a file sharing relationship between a file and a user or token. */
@Entity
@Table(
name = "file_shares",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_file_share_user",
columnNames = {"stored_file_id", "shared_with_user_id"}),
@UniqueConstraint(
name = "uk_file_share_token",
columnNames = {"share_token"})
},
indexes = {
@Index(name = "idx_file_shares_file_id", columnList = "stored_file_id"),
@Index(name = "idx_file_shares_share_token", columnList = "share_token")
})
@NoArgsConstructor
@Getter
@Setter
public class FileShare implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "file_share_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "stored_file_id", nullable = false)
private StoredFile file;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "shared_with_user_id")
private User sharedWithUser;
@Column(name = "share_token", unique = true)
private String shareToken;
@Enumerated(EnumType.STRING)
@Column(name = "access_role")
private ShareAccessRole accessRole;
@Column(name = "expires_at")
private LocalDateTime expiresAt;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
}
@@ -0,0 +1,64 @@
package stirling.software.proprietary.storage.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.security.model.User;
@Entity
@Table(
name = "file_share_accesses",
indexes = {
@Index(name = "idx_share_access_file_share", columnList = "file_share_id"),
@Index(name = "idx_share_access_user", columnList = "user_id"),
@Index(
name = "idx_share_access_file_share_accessed",
columnList = "file_share_id, accessed_at")
})
@NoArgsConstructor
@Getter
@Setter
public class FileShareAccess implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "file_share_access_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "file_share_id", nullable = false)
private FileShare fileShare;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Enumerated(EnumType.STRING)
@Column(name = "access_type", nullable = false)
private FileShareAccessType accessType;
@CreationTimestamp
@Column(name = "accessed_at", updatable = false)
private LocalDateTime accessedAt;
}
@@ -0,0 +1,6 @@
package stirling.software.proprietary.storage.model;
public enum FileShareAccessType {
VIEW,
DOWNLOAD
}
@@ -0,0 +1,7 @@
package stirling.software.proprietary.storage.model;
public enum ShareAccessRole {
EDITOR,
COMMENTER,
VIEWER
}
@@ -0,0 +1,47 @@
package stirling.software.proprietary.storage.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "storage_cleanup_entries")
@NoArgsConstructor
@Getter
@Setter
public class StorageCleanupEntry implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "cleanup_entry_id")
private Long id;
@Column(name = "storage_key", nullable = false, length = 128)
private String storageKey;
@Column(name = "attempt_count")
private int attemptCount;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
@@ -0,0 +1,116 @@
package stirling.software.proprietary.storage.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.workflow.model.WorkflowSession;
@Entity
@Table(
name = "stored_files",
indexes = {
@Index(name = "idx_stored_files_owner", columnList = "owner_id"),
@Index(name = "idx_stored_files_workflow", columnList = "workflow_session_id")
})
@NoArgsConstructor
@Getter
@Setter
public class StoredFile implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "stored_file_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "owner_id", nullable = false)
private User owner;
@Column(name = "original_filename", nullable = false)
private String originalFilename;
@Column(name = "content_type")
private String contentType;
@Column(name = "size_bytes")
private long sizeBytes;
@Column(name = "storage_key", nullable = false, unique = true)
private String storageKey;
@Column(name = "history_filename")
private String historyFilename;
@Column(name = "history_content_type")
private String historyContentType;
@Column(name = "history_size_bytes")
private Long historySizeBytes;
@Column(name = "history_storage_key", unique = true)
private String historyStorageKey;
@Column(name = "audit_log_filename")
private String auditLogFilename;
@Column(name = "audit_log_content_type")
private String auditLogContentType;
@Column(name = "audit_log_size_bytes")
private Long auditLogSizeBytes;
@Column(name = "audit_log_storage_key", unique = true)
private String auditLogStorageKey;
// Link to workflow if this file is part of a workflow
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "workflow_session_id")
private WorkflowSession workflowSession;
// Purpose classification
@Column(name = "file_purpose")
@Enumerated(EnumType.STRING)
private FilePurpose purpose;
@OneToMany(
mappedBy = "file",
fetch = FetchType.LAZY,
cascade = CascadeType.ALL,
orphanRemoval = true)
private Set<FileShare> shares = new HashSet<>();
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
@@ -0,0 +1,31 @@
package stirling.software.proprietary.storage.model;
import java.io.Serializable;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "stored_file_blobs")
@NoArgsConstructor
@Getter
@Setter
public class StoredFileBlob implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@Column(name = "storage_key", nullable = false, length = 128)
private String storageKey;
@Lob
@Column(name = "data", nullable = false, columnDefinition = "BYTEA")
private byte[] data;
}
@@ -0,0 +1,12 @@
package stirling.software.proprietary.storage.model.api;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
public class CreateShareLinkRequest {
private String accessRole;
}
@@ -0,0 +1,14 @@
package stirling.software.proprietary.storage.model.api;
import java.time.LocalDateTime;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class ShareLinkAccessResponse {
private final String username;
private final String accessType;
private final LocalDateTime accessedAt;
}
@@ -0,0 +1,20 @@
package stirling.software.proprietary.storage.model.api;
import java.time.LocalDateTime;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class ShareLinkMetadataResponse {
private final String shareToken;
private final Long fileId;
private final String fileName;
private final String owner;
private final boolean ownedByCurrentUser;
private final String accessRole;
private final LocalDateTime createdAt;
private final LocalDateTime expiresAt;
private final LocalDateTime lastAccessedAt;
}
@@ -0,0 +1,15 @@
package stirling.software.proprietary.storage.model.api;
import java.time.LocalDateTime;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class ShareLinkResponse {
private final String token;
private final String accessRole;
private final LocalDateTime createdAt;
private final LocalDateTime expiresAt;
}
@@ -0,0 +1,13 @@
package stirling.software.proprietary.storage.model.api;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
public class ShareWithUserRequest {
private String username;
private String accessRole;
}
@@ -0,0 +1,11 @@
package stirling.software.proprietary.storage.model.api;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class SharedUserResponse {
private final String username;
private final String accessRole;
}
@@ -0,0 +1,25 @@
package stirling.software.proprietary.storage.model.api;
import java.time.LocalDateTime;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class StoredFileResponse {
private final Long id;
private final String fileName;
private final String contentType;
private final long sizeBytes;
private final String owner;
private final boolean ownedByCurrentUser;
private final String accessRole;
private final LocalDateTime createdAt;
private final LocalDateTime updatedAt;
private final List<String> sharedWithUsers;
private final List<SharedUserResponse> sharedUsers;
private final List<ShareLinkResponse> shareLinks;
private final String filePurpose;
}
@@ -0,0 +1,53 @@
package stirling.software.proprietary.storage.provider;
import java.io.IOException;
import java.util.UUID;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.StoredFileBlob;
import stirling.software.proprietary.storage.repository.StoredFileBlobRepository;
@RequiredArgsConstructor
public class DatabaseStorageProvider implements StorageProvider {
private final StoredFileBlobRepository storedFileBlobRepository;
@Override
public StoredObject store(User owner, MultipartFile file) throws IOException {
String storageKey = UUID.randomUUID().toString();
StoredFileBlob blob = new StoredFileBlob();
blob.setStorageKey(storageKey);
blob.setData(file.getBytes());
storedFileBlobRepository.save(blob);
return StoredObject.builder()
.storageKey(storageKey)
.originalFilename(file.getOriginalFilename())
.contentType(file.getContentType())
.sizeBytes(file.getSize())
.build();
}
@Override
public Resource load(String storageKey) throws IOException {
StoredFileBlob blob =
storedFileBlobRepository
.findById(storageKey)
.orElseThrow(() -> new IOException("File not found"));
return new ByteArrayResource(blob.getData());
}
@Override
public void delete(String storageKey) throws IOException {
if (!storedFileBlobRepository.existsById(storageKey)) {
return;
}
storedFileBlobRepository.deleteById(storageKey);
}
}
@@ -0,0 +1,82 @@
package stirling.software.proprietary.storage.provider;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Optional;
import java.util.UUID;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.model.User;
@RequiredArgsConstructor
public class LocalStorageProvider implements StorageProvider {
private final Path basePath;
@Override
public StoredObject store(User owner, MultipartFile file) throws IOException {
String originalFilename = sanitizeFilename(file.getOriginalFilename());
String storageKey =
owner.getId()
+ "/"
+ UUID.randomUUID()
+ "_"
+ Optional.ofNullable(originalFilename).orElse("file");
Path targetPath = basePath.resolve(storageKey).normalize();
if (!targetPath.startsWith(basePath)) {
throw new IOException("Resolved storage path is outside the storage directory");
}
Files.createDirectories(targetPath.getParent());
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING);
}
return StoredObject.builder()
.storageKey(storageKey)
.originalFilename(originalFilename)
.contentType(file.getContentType())
.sizeBytes(file.getSize())
.build();
}
@Override
public Resource load(String storageKey) throws IOException {
Path targetPath = basePath.resolve(storageKey).normalize();
if (!targetPath.startsWith(basePath)) {
throw new IOException("Resolved storage path is outside the storage directory");
}
if (!Files.exists(targetPath)) {
throw new IOException("File not found");
}
return new FileSystemResource(targetPath.toFile());
}
@Override
public void delete(String storageKey) throws IOException {
Path targetPath = basePath.resolve(storageKey).normalize();
if (!targetPath.startsWith(basePath)) {
throw new IOException("Resolved storage path is outside the storage directory");
}
Files.deleteIfExists(targetPath);
}
private String sanitizeFilename(String filename) {
if (filename == null || filename.isBlank()) {
return "file";
}
return Paths.get(filename).getFileName().toString();
}
}
@@ -0,0 +1,16 @@
package stirling.software.proprietary.storage.provider;
import java.io.IOException;
import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.proprietary.security.model.User;
public interface StorageProvider {
StoredObject store(User owner, MultipartFile file) throws IOException;
Resource load(String storageKey) throws IOException;
void delete(String storageKey) throws IOException;
}
@@ -0,0 +1,13 @@
package stirling.software.proprietary.storage.provider;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class StoredObject {
private final String storageKey;
private final String originalFilename;
private final String contentType;
private final long sizeBytes;
}
@@ -0,0 +1,34 @@
package stirling.software.proprietary.storage.repository;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.FileShareAccess;
public interface FileShareAccessRepository extends JpaRepository<FileShareAccess, Long> {
@Query(
"SELECT a FROM FileShareAccess a "
+ "LEFT JOIN FETCH a.user "
+ "WHERE a.fileShare = :fileShare "
+ "ORDER BY a.accessedAt DESC")
List<FileShareAccess> findByFileShareWithUserOrderByAccessedAtDesc(
@Param("fileShare") FileShare fileShare);
void deleteByFileShare(FileShare fileShare);
void deleteByUser(User user);
@Query(
"SELECT a FROM FileShareAccess a "
+ "JOIN FETCH a.fileShare s "
+ "JOIN FETCH s.file f "
+ "LEFT JOIN FETCH f.owner "
+ "WHERE a.user = :user "
+ "ORDER BY a.accessedAt DESC")
List<FileShareAccess> findByUserWithShareAndFile(@Param("user") User user);
}
@@ -0,0 +1,39 @@
package stirling.software.proprietary.storage.repository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.StoredFile;
public interface FileShareRepository extends JpaRepository<FileShare, Long> {
Optional<FileShare> findByFileAndSharedWithUser(StoredFile file, User sharedWithUser);
Optional<FileShare> findByShareToken(String shareToken);
@Query(
"SELECT s FROM FileShare s "
+ "JOIN FETCH s.file f "
+ "LEFT JOIN FETCH f.owner "
+ "WHERE s.shareToken = :shareToken")
Optional<FileShare> findByShareTokenWithFile(@Param("shareToken") String shareToken);
@Query("SELECT s FROM FileShare s WHERE s.file = :file AND s.shareToken IS NOT NULL")
List<FileShare> findShareLinks(@Param("file") StoredFile file);
List<FileShare> findBySharedWithUser(User sharedWithUser);
List<FileShare> findByExpiresAtBeforeAndShareTokenNotNull(java.time.LocalDateTime now);
@Query(
"SELECT s FROM FileShare s "
+ "JOIN FETCH s.file f "
+ "WHERE s.sharedWithUser = :user AND f IN :files")
List<FileShare> findBySharedWithUserAndFileIn(
@Param("user") User user, @Param("files") List<StoredFile> files);
}
@@ -0,0 +1,11 @@
package stirling.software.proprietary.storage.repository;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import stirling.software.proprietary.storage.model.StorageCleanupEntry;
public interface StorageCleanupEntryRepository extends JpaRepository<StorageCleanupEntry, Long> {
List<StorageCleanupEntry> findTop50ByOrderByUpdatedAtAsc();
}
@@ -0,0 +1,7 @@
package stirling.software.proprietary.storage.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import stirling.software.proprietary.storage.model.StoredFileBlob;
public interface StoredFileBlobRepository extends JpaRepository<StoredFileBlob, String> {}
@@ -0,0 +1,69 @@
package stirling.software.proprietary.storage.repository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.workflow.model.WorkflowSession;
public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
Optional<StoredFile> findByIdAndOwner(Long id, User owner);
@Query(
"SELECT DISTINCT f FROM StoredFile f "
+ "LEFT JOIN FETCH f.owner "
+ "LEFT JOIN FETCH f.shares s "
+ "LEFT JOIN FETCH s.sharedWithUser "
+ "WHERE f.id = :id AND f.owner = :owner")
Optional<StoredFile> findByIdAndOwnerWithShares(
@Param("id") Long id, @Param("owner") User owner);
@Query(
"SELECT DISTINCT f FROM StoredFile f "
+ "LEFT JOIN FETCH f.owner "
+ "LEFT JOIN FETCH f.shares s "
+ "LEFT JOIN FETCH s.sharedWithUser "
+ "WHERE f.id = :id")
Optional<StoredFile> findByIdWithShares(@Param("id") Long id);
@Query(
"SELECT DISTINCT f FROM StoredFile f "
+ "LEFT JOIN FETCH f.owner "
+ "LEFT JOIN FETCH f.shares s "
+ "LEFT JOIN FETCH s.sharedWithUser "
+ "WHERE f.owner = :user "
+ "OR s.sharedWithUser = :user")
List<StoredFile> findAccessibleFiles(@Param("user") User user);
@Query(
"SELECT COALESCE(SUM(f.sizeBytes + COALESCE(f.historySizeBytes, 0) "
+ "+ COALESCE(f.auditLogSizeBytes, 0)), 0) "
+ "FROM StoredFile f WHERE f.owner = :owner")
long sumStorageBytesByOwner(@Param("owner") User owner);
@Query(
"SELECT COALESCE(SUM(f.sizeBytes + COALESCE(f.historySizeBytes, 0) "
+ "+ COALESCE(f.auditLogSizeBytes, 0)), 0) "
+ "FROM StoredFile f")
long sumStorageBytesTotal();
/** Finds all files associated with a workflow session. */
List<StoredFile> findByWorkflowSession(WorkflowSession workflowSession);
List<StoredFile> findAllByOwner(User owner);
@Modifying
@Transactional
@Query(
"UPDATE StoredFile sf SET sf.workflowSession = null "
+ "WHERE sf.workflowSession IN "
+ "(SELECT ws FROM WorkflowSession ws WHERE ws.owner = :user)")
void clearWorkflowSessionReferencesByOwner(@Param("user") User user);
}
@@ -0,0 +1,73 @@
package stirling.software.proprietary.storage.service;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.storage.model.StorageCleanupEntry;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.repository.FileShareRepository;
import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository;
@Service
@RequiredArgsConstructor
@Slf4j
public class StorageCleanupService {
private static final int MAX_CLEANUP_ATTEMPTS = 10;
private final StorageProvider storageProvider;
private final StorageCleanupEntryRepository cleanupEntryRepository;
private final FileShareRepository fileShareRepository;
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS)
public void cleanupOrphanedStorage() {
List<StorageCleanupEntry> entries = cleanupEntryRepository.findTop50ByOrderByUpdatedAtAsc();
if (entries.isEmpty()) {
return;
}
for (StorageCleanupEntry entry : entries) {
try {
storageProvider.delete(entry.getStorageKey());
cleanupEntryRepository.delete(entry);
} catch (IOException ex) {
int attempts = entry.getAttemptCount() + 1;
if (attempts >= MAX_CLEANUP_ATTEMPTS) {
log.error(
"Abandoning cleanup for storage key {} after {} failed attempts."
+ " The blob may be orphaned and require manual removal.",
entry.getStorageKey(),
attempts,
ex);
cleanupEntryRepository.delete(entry);
} else {
entry.setAttemptCount(attempts);
cleanupEntryRepository.save(entry);
log.warn(
"Failed to cleanup storage key {} (attempt {}/{})",
entry.getStorageKey(),
attempts,
MAX_CLEANUP_ATTEMPTS,
ex);
}
}
}
}
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS)
public void cleanupExpiredShareLinks() {
List<stirling.software.proprietary.storage.model.FileShare> expired =
fileShareRepository.findByExpiresAtBeforeAndShareTokenNotNull(LocalDateTime.now());
if (expired.isEmpty()) {
return;
}
fileShareRepository.deleteAll(expired);
}
}
@@ -0,0 +1,408 @@
package stirling.software.proprietary.workflow.controller;
import java.security.Principal;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.workflow.dto.ParticipantRequest;
import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.service.SigningFinalizationService;
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
@Slf4j
@RestController
@RequestMapping("/api/v1/security")
@Tag(name = "Security", description = "Security APIs - Signing Workflows")
@RequiredArgsConstructor
public class SigningSessionController {
private final WorkflowSessionService workflowSessionService;
private final UserService userService;
private final SigningFinalizationService signingFinalizationService;
private final ObjectMapper objectMapper = new ObjectMapper();
@Operation(summary = "List all signing sessions for current user")
@Transactional(readOnly = true)
@GetMapping(value = "/cert-sign/sessions")
public ResponseEntity<?> listSessions(Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
User user = getCurrentUser(principal);
List<stirling.software.proprietary.workflow.model.WorkflowSession> sessions =
workflowSessionService.listUserSessions(user);
List<stirling.software.proprietary.workflow.dto.WorkflowSessionResponse> responses =
sessions.stream()
.map(
stirling.software.proprietary.workflow.util.WorkflowMapper
::toResponse)
.collect(java.util.stream.Collectors.toList());
return ResponseEntity.ok(responses);
} catch (Exception e) {
log.error("Error listing sessions for user {}", principal.getName(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Error listing sessions");
}
}
@PostMapping(
consumes = {MediaType.MULTIPART_FORM_DATA_VALUE},
value = "/cert-sign/sessions",
produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(
summary = "Create a shared signing session",
description =
"Starts a collaboration session, distributes share links, and optionally notifies"
+ " participants. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<?> createSession(
@org.springframework.web.bind.annotation.RequestParam("file")
org.springframework.web.multipart.MultipartFile file,
@ModelAttribute WorkflowCreationRequest request,
Principal principal)
throws Exception {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
User owner = getCurrentUser(principal);
WorkflowSession session = workflowSessionService.createSession(owner, file, request);
return ResponseEntity.ok(
stirling.software.proprietary.workflow.util.WorkflowMapper.toResponse(session));
} catch (Exception e) {
log.error("Error creating signing session", e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
}
}
@Operation(summary = "Fetch signing session details")
@Transactional(readOnly = true)
@GetMapping(value = "/cert-sign/sessions/{sessionId}")
public ResponseEntity<?> getSession(
@PathVariable("sessionId") @NotBlank String sessionId, Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
User owner = getCurrentUser(principal);
WorkflowSession session = workflowSessionService.getSessionForOwner(sessionId, owner);
// Include wet signatures in response for owner preview
return ResponseEntity.ok(
stirling.software.proprietary.workflow.util.WorkflowMapper.toResponse(
session, objectMapper));
} catch (Exception e) {
log.error("Error fetching session {}", sessionId, e);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Access denied or session not found");
}
}
@Operation(summary = "Delete a signing session")
@DeleteMapping(value = "/cert-sign/sessions/{sessionId}")
public ResponseEntity<?> deleteSession(
@PathVariable("sessionId") @NotBlank String sessionId, Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
User owner = getCurrentUser(principal);
workflowSessionService.deleteSession(sessionId, owner);
return ResponseEntity.noContent().build();
} catch (Exception e) {
log.error("Error deleting session {}", sessionId, e);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Cannot delete session: " + e.getMessage());
}
}
@Operation(summary = "Add participants to an existing session")
@PostMapping(value = "/cert-sign/sessions/{sessionId}/participants")
public ResponseEntity<?> addParticipants(
@PathVariable("sessionId") @NotBlank String sessionId,
@RequestBody List<ParticipantRequest> participants,
Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
User owner = getCurrentUser(principal);
workflowSessionService.addParticipants(sessionId, participants, owner);
WorkflowSession session =
workflowSessionService.getSessionWithParticipantsForOwner(sessionId, owner);
return ResponseEntity.ok(
stirling.software.proprietary.workflow.util.WorkflowMapper.toResponse(session));
} catch (Exception e) {
log.error("Error adding participants to session {}", sessionId, e);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Cannot add participants: " + e.getMessage());
}
}
@Operation(summary = "Remove a participant from a session")
@DeleteMapping(value = "/cert-sign/sessions/{sessionId}/participants/{participantId}")
public ResponseEntity<?> removeParticipant(
@PathVariable("sessionId") @NotBlank String sessionId,
@PathVariable("participantId") Long participantId,
Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
User owner = getCurrentUser(principal);
workflowSessionService.removeParticipant(sessionId, participantId, owner);
return ResponseEntity.noContent().build();
} catch (Exception e) {
log.error("Error removing participant {} from session {}", participantId, sessionId, e);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Cannot remove participant: " + e.getMessage());
}
}
@Operation(summary = "Get session PDF for participant view")
@GetMapping(value = "/cert-sign/sessions/{sessionId}/pdf")
public ResponseEntity<byte[]> getSessionPdf(
@PathVariable("sessionId") @NotBlank String sessionId, Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
try {
User owner = getCurrentUser(principal);
workflowSessionService.getSessionForOwner(sessionId, owner);
byte[] pdfBytes = workflowSessionService.getOriginalFile(sessionId);
return WebResponseUtils.bytesToWebResponse(pdfBytes, "document.pdf");
} catch (Exception e) {
log.error("Error fetching PDF for session {}", sessionId, e);
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}
@PostMapping(value = "/cert-sign/sessions/{sessionId}/finalize")
@Operation(
summary = "Finalize signing session",
description =
"Applies collected wet signatures and digital certificates, then returns the"
+ " signed document.")
@StandardPdfResponse
public ResponseEntity<byte[]> finalizeSession(
@PathVariable("sessionId") @NotBlank String sessionId, Principal principal)
throws Exception {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
try {
User owner = getCurrentUser(principal);
WorkflowSession session =
workflowSessionService.getSessionWithParticipantsForOwner(sessionId, owner);
byte[] originalPdf = workflowSessionService.getOriginalFile(sessionId);
byte[] pdf = signingFinalizationService.finalizeDocument(session, originalPdf);
String filename = session.getDocumentName().replace(".pdf", "") + "_shared_signed.pdf";
workflowSessionService.storeProcessedFile(session, pdf, filename);
workflowSessionService.finalizeSession(sessionId, owner);
workflowSessionService.deleteOriginalFile(session);
try {
signingFinalizationService.clearSensitiveMetadata(session);
} catch (Exception e) {
log.error(
"SECURITY: Failed to clear sensitive metadata for session {} "
+ "(participants: {}). Keystore credentials may remain in the "
+ "database until manual cleanup.",
sessionId,
session.getParticipants() != null
? session.getParticipants().stream().map(p -> p.getEmail()).toList()
: "unknown",
e);
throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR,
"Document signed successfully but post-signing cleanup failed. "
+ "Contact your administrator to complete the cleanup.");
}
return WebResponseUtils.bytesToWebResponse(pdf, filename);
} catch (Exception e) {
log.error("Error finalizing session {}", sessionId, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@Operation(summary = "Get signed PDF from finalized session")
@GetMapping(value = "/cert-sign/sessions/{sessionId}/signed-pdf")
@StandardPdfResponse
public ResponseEntity<byte[]> getSignedPdf(
@PathVariable("sessionId") @NotBlank String sessionId, Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
try {
User owner = getCurrentUser(principal);
byte[] signedPdf = workflowSessionService.getProcessedFile(sessionId, owner);
if (signedPdf == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("Session not finalized".getBytes());
}
WorkflowSession session = workflowSessionService.getSessionForOwner(sessionId, owner);
return WebResponseUtils.bytesToWebResponse(
signedPdf,
GeneralUtils.generateFilename(session.getDocumentName(), "_shared_signed.pdf"));
} catch (Exception e) {
log.error("Error fetching signed PDF for session {}", sessionId, e);
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}
// ===== SIGN REQUESTS (Participant View) =====
@Operation(summary = "List sign requests for authenticated user")
@Transactional(readOnly = true)
@GetMapping(value = "/cert-sign/sign-requests")
public ResponseEntity<?> listSignRequests(Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
User user = getCurrentUser(principal);
return ResponseEntity.ok(workflowSessionService.listSignRequests(user));
} catch (Exception e) {
log.error("Error listing sign requests for user {}", principal.getName(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Cannot list sign requests: " + e.getMessage());
}
}
@Transactional(readOnly = true)
@Operation(summary = "Get sign request detail for participant")
@GetMapping(value = "/cert-sign/sign-requests/{sessionId}")
public ResponseEntity<?> getSignRequestDetail(
@PathVariable("sessionId") @NotBlank String sessionId, Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
User user = getCurrentUser(principal);
return ResponseEntity.ok(workflowSessionService.getSignRequestDetail(sessionId, user));
} catch (Exception e) {
log.error("Error fetching sign request detail for session {}", sessionId, e);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Access denied or sign request not found: " + e.getMessage());
}
}
@Operation(summary = "Get document for sign request")
@GetMapping(value = "/cert-sign/sign-requests/{sessionId}/document")
public ResponseEntity<byte[]> getSignRequestDocument(
@PathVariable("sessionId") @NotBlank String sessionId, Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
try {
User user = getCurrentUser(principal);
byte[] document = workflowSessionService.getSignRequestDocument(sessionId, user);
return WebResponseUtils.bytesToWebResponse(document, "document.pdf");
} catch (Exception e) {
log.error("Error fetching document for sign request {}", sessionId, e);
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}
@Operation(summary = "Sign a document with certificate and optional wet signature")
@PostMapping(
value = "/cert-sign/sign-requests/{sessionId}/sign",
consumes = {
MediaType.MULTIPART_FORM_DATA_VALUE,
MediaType.APPLICATION_FORM_URLENCODED_VALUE
})
public ResponseEntity<?> signDocument(
@PathVariable("sessionId") @NotBlank String sessionId,
@ModelAttribute stirling.software.proprietary.workflow.dto.SignDocumentRequest request,
Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
User user = getCurrentUser(principal);
workflowSessionService.signDocument(sessionId, user, request);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
log.error("Invalid sign request for session {}", sessionId, e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
} catch (Exception e) {
log.error("Error signing document for session {}", sessionId, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Cannot sign document: " + e.getMessage());
}
}
@Operation(summary = "Decline a sign request")
@PostMapping(value = "/cert-sign/sign-requests/{sessionId}/decline")
public ResponseEntity<?> declineSignRequest(
@PathVariable("sessionId") @NotBlank String sessionId, Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
User user = getCurrentUser(principal);
workflowSessionService.declineSignRequest(sessionId, user);
return ResponseEntity.noContent().build();
} catch (Exception e) {
log.error("Error declining sign request for session {}", sessionId, e);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Cannot decline sign request: " + e.getMessage());
}
}
// ===== HELPER METHODS =====
private User getCurrentUser(Principal principal) {
return userService
.findByUsernameIgnoreCase(principal.getName())
.orElseThrow(
() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthorized"));
}
}
@@ -0,0 +1,328 @@
package stirling.software.proprietary.workflow.controller;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.workflow.dto.ParticipantResponse;
import stirling.software.proprietary.workflow.dto.SignatureSubmissionRequest;
import stirling.software.proprietary.workflow.dto.WetSignatureMetadata;
import stirling.software.proprietary.workflow.dto.WorkflowSessionResponse;
import stirling.software.proprietary.workflow.model.ParticipantStatus;
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import stirling.software.proprietary.workflow.service.MetadataEncryptionService;
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
import stirling.software.proprietary.workflow.util.WorkflowMapper;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
/**
* REST controller for workflow participant actions. Handles participant-facing operations like
* viewing sessions, submitting signatures, and updating participant status.
*
* <p>Access is controlled via share tokens, not requiring authentication.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/workflow/participant")
@Tag(name = "Workflow Participant", description = "Participant Action APIs")
@RequiredArgsConstructor
public class WorkflowParticipantController {
private final WorkflowSessionService workflowSessionService;
private final WorkflowParticipantRepository participantRepository;
private final ObjectMapper objectMapper;
private final MetadataEncryptionService metadataEncryptionService;
@Operation(
summary = "Get workflow session details by participant token",
description = "Allows participants to view session details using their share token")
@GetMapping(value = "/session", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<WorkflowSessionResponse> getSessionByToken(
@RequestParam("token") @NotBlank String token) {
workflowSessionService.ensureSigningEnabled();
WorkflowParticipant participant =
participantRepository
.findByShareToken(token)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
// Check if participant is expired
if (participant.isExpired()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired");
}
// Mark as viewed if not already
if (participant.getStatus() == ParticipantStatus.PENDING
|| participant.getStatus() == ParticipantStatus.NOTIFIED) {
workflowSessionService.updateParticipantStatus(
participant.getId(), ParticipantStatus.VIEWED);
}
WorkflowSession session = participant.getWorkflowSession();
return ResponseEntity.ok(WorkflowMapper.toResponse(session));
}
@Operation(
summary = "Get participant details by token",
description = "Returns participant-specific information")
@GetMapping(value = "/details", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ParticipantResponse> getParticipantDetails(
@RequestParam("token") @NotBlank String token) {
workflowSessionService.ensureSigningEnabled();
WorkflowParticipant participant =
participantRepository
.findByShareToken(token)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant));
}
@Operation(
summary = "Submit signature (wet signature and/or certificate)",
description =
"Participants submit their signature data and certificate information for signing")
@PostMapping(
value = "/submit-signature",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ParticipantResponse> submitSignature(
@ModelAttribute SignatureSubmissionRequest request) {
workflowSessionService.ensureSigningEnabled();
if (request.getParticipantToken() == null || request.getParticipantToken().isBlank()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Participant token is required");
}
WorkflowParticipant participant =
participantRepository
.findByShareToken(request.getParticipantToken())
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
// Check if participant can still submit
if (participant.isExpired()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired");
}
if (participant.hasCompleted()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Participant has already completed their action");
}
if (!participant.getWorkflowSession().isActive()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Workflow session is no longer active");
}
try {
// Build metadata map with certificate and wet signature data
Map<String, Object> metadata = buildSubmissionMetadata(request);
participant.setParticipantMetadata(metadata);
// Update status to SIGNED
participant.setStatus(ParticipantStatus.SIGNED);
participant = participantRepository.save(participant);
log.info(
"Participant {} submitted signature for session {}",
participant.getEmail(),
participant.getWorkflowSession().getSessionId());
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant));
} catch (Exception e) {
log.error("Error submitting signature for participant {}", participant.getEmail(), e);
throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR, "Failed to submit signature", e);
}
}
@Operation(
summary = "Decline participation",
description = "Participant declines to sign or participate in the workflow")
@PostMapping(value = "/decline", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ParticipantResponse> declineParticipation(
@RequestParam("token") @NotBlank String token,
@RequestParam(value = "reason", required = false) @Size(max = 500) String reason) {
workflowSessionService.ensureSigningEnabled();
WorkflowParticipant participant =
participantRepository
.findByShareToken(token)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
if (participant.hasCompleted()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Participant has already completed their action");
}
// Update status to DECLINED
participant.setStatus(ParticipantStatus.DECLINED);
// Add decline reason to notifications
if (reason != null && !reason.isBlank()) {
workflowSessionService.addParticipantNotification(
participant.getId(), "Declined: " + reason);
} else {
workflowSessionService.addParticipantNotification(
participant.getId(), "Declined participation");
}
participant = participantRepository.save(participant);
log.info(
"Participant {} declined workflow session {}",
participant.getEmail(),
participant.getWorkflowSession().getSessionId());
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant));
}
@Operation(
summary = "Get original PDF for review",
description = "Participant downloads the original document")
@GetMapping(value = "/document", produces = MediaType.APPLICATION_PDF_VALUE)
public ResponseEntity<byte[]> getDocument(@RequestParam("token") @NotBlank String token) {
workflowSessionService.ensureSigningEnabled();
WorkflowParticipant participant =
participantRepository
.findByShareToken(token)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
if (participant.isExpired()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired");
}
try {
WorkflowSession session = participant.getWorkflowSession();
byte[] pdf = workflowSessionService.getOriginalFile(session.getSessionId());
return ResponseEntity.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment()
.filename(session.getDocumentName(), StandardCharsets.UTF_8)
.build()
.toString())
.contentType(org.springframework.http.MediaType.APPLICATION_PDF)
.body(pdf);
} catch (IOException e) {
log.error("Error retrieving document for participant", e);
throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR, "Failed to retrieve document", e);
}
}
/**
* Builds metadata map from signature submission request. Includes certificate submission and
* wet signature data.
*/
private Map<String, Object> buildSubmissionMetadata(SignatureSubmissionRequest request)
throws IOException {
Map<String, Object> metadata = new HashMap<>();
// Add certificate submission if provided
if (request.getCertType() != null) {
Map<String, Object> certSubmission = new HashMap<>();
certSubmission.put("certType", request.getCertType());
certSubmission.put(
"password", metadataEncryptionService.encrypt(request.getPassword()));
certSubmission.put("showSignature", request.getShowSignature());
certSubmission.put("pageNumber", request.getPageNumber());
certSubmission.put("location", request.getLocation());
certSubmission.put("reason", request.getReason());
certSubmission.put("showLogo", request.getShowLogo());
// Store certificate files as base64
if (request.getP12File() != null && !request.getP12File().isEmpty()) {
certSubmission.put(
"p12Keystore",
java.util.Base64.getEncoder()
.encodeToString(request.getP12File().getBytes()));
}
if (request.getJksFile() != null && !request.getJksFile().isEmpty()) {
certSubmission.put(
"jksKeystore",
java.util.Base64.getEncoder()
.encodeToString(request.getJksFile().getBytes()));
}
metadata.put("certificateSubmission", certSubmission);
}
// Add wet signatures data if provided - parse once and store as List directly
if (request.getWetSignaturesData() != null && !request.getWetSignaturesData().isBlank()) {
if (request.getWetSignaturesData().length() > 5 * 1024 * 1024) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Wet signatures data exceeds maximum allowed size");
}
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> wetSigs =
objectMapper.readValue(
request.getWetSignaturesData(),
new TypeReference<java.util.List<Map<String, Object>>>() {});
if (wetSigs.size() > WetSignatureMetadata.MAX_SIGNATURES_PER_PARTICIPANT) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Too many wet signatures submitted");
}
metadata.put("wetSignatures", wetSigs);
}
return metadata;
}
}
@@ -0,0 +1,46 @@
package stirling.software.proprietary.workflow.dto;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Certificate submission details extracted from a participant's stored metadata. Contains the
* certificate type, optional keystore bytes (decoded from base64), password, and per-participant
* signature appearance overrides.
*/
@Getter
@Setter
@NoArgsConstructor
public class CertificateSubmission {
/** Certificate type: P12, JKS, SERVER, or USER_CERT */
private String certType;
/**
* Keystore password. Stored encrypted at rest; decrypted by MetadataEncryptionService before
* use. Cleared from the database after finalization.
*/
private String password;
/** PKCS12 keystore bytes, decoded from the base64 stored in participant metadata. */
private byte[] p12Keystore;
/** JKS keystore bytes, decoded from the base64 stored in participant metadata. */
private byte[] jksKeystore;
/** Whether to show a visible digital signature block on the page. */
private Boolean showSignature;
/** 1-indexed page number for the digital signature block (session-level default). */
private Integer pageNumber;
/** Participant's location when signing (included in digital signature metadata). */
private String location;
/** Participant's reason for signing (included in digital signature metadata). */
private String reason;
/** Whether to show the Stirling logo in the digital signature block. */
private Boolean showLogo;
}
@@ -0,0 +1,43 @@
package stirling.software.proprietary.workflow.dto;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import stirling.software.proprietary.storage.model.ShareAccessRole;
/**
* Request DTO for adding or configuring a workflow participant. Supports both registered users and
* external email participants.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ParticipantRequest {
/** User ID if participant is a registered user */
private Long userId;
/** Email address (required for external users, optional for registered users) */
private String email;
/** Display name for the participant */
private String name;
/** Access role for the participant (EDITOR, COMMENTER, VIEWER) */
private ShareAccessRole accessRole;
/** Optional expiration timestamp for participant access */
private LocalDateTime expiresAt;
/** Participant-specific metadata (JSON string) */
private String participantMetadata;
/** Whether to send notification immediately */
private boolean sendNotification = true;
/** Owner-set default reason for this participant's signature */
private String defaultReason;
}
@@ -0,0 +1,34 @@
package stirling.software.proprietary.workflow.dto;
import java.time.LocalDateTime;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import stirling.software.proprietary.storage.model.ShareAccessRole;
import stirling.software.proprietary.workflow.model.ParticipantStatus;
/**
* Response DTO for workflow participant details. Used in API responses to provide participant
* information.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ParticipantResponse {
private Long id;
private Long userId;
private String email;
private String name;
private ParticipantStatus status;
private String shareToken;
private ShareAccessRole accessRole;
private LocalDateTime expiresAt;
private LocalDateTime lastUpdated;
private boolean hasCompleted;
private boolean isExpired;
private List<WetSignatureMetadata> wetSignatures;
}
@@ -0,0 +1,72 @@
package stirling.software.proprietary.workflow.dto;
import java.util.ArrayList;
import java.util.List;
import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Request object for signing a document. Combines certificate submission data with optional wet
* signature (visual signature) metadata. Supports multiple wet signatures.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SignDocumentRequest {
// Certificate-related fields
@NotNull(message = "Certificate type is required")
@Pattern(
regexp = "SERVER|USER_CERT|UPLOAD|PEM|PKCS12|PFX|JKS",
message = "Invalid certificate type")
private String certType;
private MultipartFile p12File;
private String password;
private MultipartFile privateKeyFile;
private MultipartFile certFile;
// Signature metadata (participant can override owner defaults)
private String reason; // Participant's reason for signing
private String location; // Participant's location when signing
// Wet signatures as JSON string (from frontend FormData)
private String wetSignaturesData;
// Parsed wet signatures (populated by controller/service)
private List<WetSignatureMetadata> wetSignatures;
/**
* Checks if this request includes wet signature metadata.
*
* @return true if wet signatures list is not empty
*/
public boolean hasWetSignatures() {
return wetSignatures != null && !wetSignatures.isEmpty();
}
/**
* Extracts and validates wet signature metadata.
*
* @return List of validated WetSignatureMetadata objects
*/
public List<WetSignatureMetadata> extractWetSignatureMetadata() {
List<WetSignatureMetadata> signatures = new ArrayList<>();
if (hasWetSignatures()) {
for (WetSignatureMetadata signature : wetSignatures) {
signature.validate();
signatures.add(signature);
}
}
return signatures;
}
}
@@ -0,0 +1,27 @@
package stirling.software.proprietary.workflow.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import stirling.software.proprietary.workflow.model.ParticipantStatus;
/** DTO for sign request detail (participant view) */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SignRequestDetailDTO {
private String sessionId;
private String documentName;
private String ownerUsername;
private String message;
private String dueDate;
private String createdAt;
private ParticipantStatus myStatus;
// Signature appearance settings (read-only, configured by owner)
private Boolean showSignature;
private Integer pageNumber;
private String reason;
private String location;
private Boolean showLogo;
}
@@ -0,0 +1,20 @@
package stirling.software.proprietary.workflow.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import stirling.software.proprietary.workflow.model.ParticipantStatus;
/** DTO for sign request summary (participant view) */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SignRequestSummaryDTO {
private String sessionId;
private String documentName;
private String ownerUsername;
private String createdAt;
private String dueDate;
private ParticipantStatus myStatus;
}
@@ -0,0 +1,34 @@
package stirling.software.proprietary.workflow.dto;
import org.springframework.web.multipart.MultipartFile;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Request DTO for submitting a signature (wet signature or certificate). Used when a participant
* completes their signing action. Supports multiple wet signatures.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SignatureSubmissionRequest {
// Certificate submission fields
private String certType; // P12, JKS, SERVER, USER_CERT
private String password;
private MultipartFile p12File;
private MultipartFile jksFile;
private Boolean showSignature;
private Integer pageNumber;
private String location;
private String reason;
private Boolean showLogo;
// Wet signatures (JSON array string with coordinates and image data)
private String wetSignaturesData;
// Participant identification
private String participantToken;
}
@@ -0,0 +1,112 @@
package stirling.software.proprietary.workflow.dto;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Data Transfer Object for wet signature (visual signature) metadata. Contains information about a
* signature annotation placed by a participant on the PDF. This data is used to overlay the
* signature on the PDF during finalization.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class WetSignatureMetadata {
/** Maximum number of wet signatures allowed per participant submission. */
public static final int MAX_SIGNATURES_PER_PARTICIPANT = 50;
/** Type of wet signature: "canvas" (drawn), "image" (uploaded), or "text" (typed) */
@NotNull(message = "Wet signature type is required")
@Pattern(
regexp = "canvas|image|text",
message = "Wet signature type must be canvas, image, or text")
private String type;
/**
* Base64-encoded image data or text content for the signature. For canvas/image types:
* data:image/png;base64,... format For text type: plain text string
*/
@NotNull(message = "Wet signature data is required")
@Size(max = 5_000_000, message = "Wet signature data exceeds maximum size of 5MB")
private String data;
/** Zero-indexed page number where the signature is placed */
@NotNull(message = "Page number is required")
@PositiveOrZero(message = "Page number must be zero or positive")
private Integer page;
/** X position as a fraction (01) of page width, measured from left edge */
@NotNull(message = "X coordinate is required")
@PositiveOrZero(message = "X coordinate must be zero or positive")
@DecimalMax(value = "1.0", message = "X coordinate must not exceed 1.0 (page width)")
private Double x;
/**
* Y position as a fraction (01) of page height, measured from top edge. Note: This is UI
* coordinate system (top-left origin). Will be converted to PDF coordinate system (bottom-left
* origin) during overlay.
*/
@NotNull(message = "Y coordinate is required")
@PositiveOrZero(message = "Y coordinate must be zero or positive")
@DecimalMax(value = "1.0", message = "Y coordinate must not exceed 1.0 (page height)")
private Double y;
/** Width of the signature rectangle as a fraction (01) of page width */
@NotNull(message = "Width is required")
@Positive(message = "Width must be positive")
@DecimalMax(value = "1.0", message = "Width must not exceed 1.0 (page width)")
private Double width;
/** Height of the signature rectangle as a fraction (01) of page height */
@NotNull(message = "Height is required")
@Positive(message = "Height must be positive")
@DecimalMax(value = "1.0", message = "Height must not exceed 1.0 (page height)")
private Double height;
/**
* Validates that the wet signature data is properly formatted based on type. For image types,
* ensures data starts with data:image prefix.
*
* @return true if validation passes
* @throws IllegalArgumentException if validation fails
*/
public boolean validate() {
if (type.equals("canvas") || type.equals("image")) {
if (!data.startsWith("data:image/")) {
throw new IllegalArgumentException(
"Image wet signature data must start with data:image/ prefix");
}
}
if (x != null && width != null && x + width > 1.0) {
throw new IllegalArgumentException(
"Signature extends beyond the right edge of the page (x + width > 1.0)");
}
if (y != null && height != null && y + height > 1.0) {
throw new IllegalArgumentException(
"Signature extends beyond the bottom edge of the page (y + height > 1.0)");
}
return true;
}
/**
* Extracts just the base64 data portion from a data URL. Removes the "data:image/png;base64,"
* prefix.
*
* @return pure base64 string without data URL prefix
*/
public String extractBase64Data() {
if (data != null && data.contains(",")) {
return data.substring(data.indexOf(",") + 1);
}
return data;
}
}
@@ -0,0 +1,43 @@
package stirling.software.proprietary.workflow.dto;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import stirling.software.proprietary.workflow.model.WorkflowType;
/**
* Request DTO for creating a new workflow session. Used to initialize workflow sessions with
* participants and settings.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class WorkflowCreationRequest {
/** Type of workflow to create (SIGNING, REVIEW, APPROVAL) */
private WorkflowType workflowType;
/** Display name for the document in the workflow */
private String documentName;
/** Owner's email address (optional, used for notifications) */
private String ownerEmail;
/** Message/instructions for participants */
private String message;
/** Due date for workflow completion (flexible string format) */
private String dueDate;
/** List of participant user IDs (for registered users) */
private List<Long> participantUserIds;
/** List of participant email addresses (for external/unregistered users) */
private List<String> participantEmails;
/** Workflow-specific metadata (JSON string) */
private String workflowMetadata;
}
@@ -0,0 +1,40 @@
package stirling.software.proprietary.workflow.dto;
import java.time.LocalDateTime;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import stirling.software.proprietary.workflow.model.WorkflowStatus;
import stirling.software.proprietary.workflow.model.WorkflowType;
/**
* Response DTO for workflow session details. Used in API responses to provide session information
* to clients.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class WorkflowSessionResponse {
private String sessionId;
private Long ownerId;
private String ownerUsername;
private WorkflowType workflowType;
private String documentName;
private String ownerEmail;
private String message;
private String dueDate;
private WorkflowStatus status;
private boolean finalized;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private List<ParticipantResponse> participants;
private int participantCount;
private int signedCount;
private boolean hasProcessedFile;
private Long originalFileId;
private Long processedFileId;
}
@@ -0,0 +1,6 @@
package stirling.software.proprietary.workflow.model;
public enum CertificateType {
AUTO_GENERATED,
USER_UPLOADED
}
@@ -0,0 +1,22 @@
package stirling.software.proprietary.workflow.model;
/**
* Defines the status of a participant in a workflow session. Tracks participant progress through
* the workflow lifecycle.
*/
public enum ParticipantStatus {
/** Participant has been added but not yet notified */
PENDING,
/** Participant has been notified via email or other means */
NOTIFIED,
/** Participant has viewed the document */
VIEWED,
/** Participant has completed their action (e.g., signed the document) */
SIGNED,
/** Participant has declined to participate or rejected the action */
DECLINED
}
@@ -0,0 +1,73 @@
package stirling.software.proprietary.workflow.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.*;
import stirling.software.proprietary.security.model.User;
@Entity
@Table(name = "user_server_certificates")
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class UserServerCertificateEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
@EqualsAndHashCode.Include
@ToString.Include
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", unique = true, nullable = false)
@JsonIgnore
private User user;
@Lob
@Basic(fetch = FetchType.EAGER)
@Column(name = "keystore_data", nullable = false, columnDefinition = "bytea")
@JsonIgnore
private byte[] keystoreData;
@Column(name = "keystore_password", nullable = false)
@JsonIgnore
private String keystorePassword;
@Enumerated(EnumType.STRING)
@Column(name = "certificate_type", nullable = false, length = 50)
private CertificateType certificateType;
@Column(name = "subject_dn", length = 500)
private String subjectDn;
@Column(name = "issuer_dn", length = 500)
private String issuerDn;
@Column(name = "valid_from")
private LocalDateTime validFrom;
@Column(name = "valid_to")
private LocalDateTime validTo;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
@@ -0,0 +1,141 @@
package stirling.software.proprietary.workflow.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.ShareAccessRole;
/**
* Represents a participant in a workflow session. Replaces SigningParticipantEntity with broader
* workflow support.
*
* <p>Integrates with FileShare for access control - each participant gets a FileShare entry linked
* to this participant record for unified access control.
*/
@Entity
@Table(
name = "workflow_participants",
indexes = {
@Index(name = "idx_workflow_participants_session", columnList = "workflow_session_id"),
@Index(name = "idx_workflow_participants_token", columnList = "share_token"),
@Index(name = "idx_workflow_participants_user", columnList = "user_id")
})
@NoArgsConstructor
@Getter
@Setter
public class WorkflowParticipant implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "workflow_session_id", nullable = false)
private WorkflowSession workflowSession;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@Column(name = "email")
private String email;
@Column(name = "name")
private String name;
// Workflow progress tracking
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private ParticipantStatus status = ParticipantStatus.PENDING;
// Access control (unified with FileShare)
@Column(name = "share_token", unique = true, length = 36)
private String shareToken;
@Enumerated(EnumType.STRING)
@Column(name = "access_role", nullable = false, length = 20)
private ShareAccessRole accessRole;
@Column(name = "expires_at")
private LocalDateTime expiresAt;
// Workflow-specific data stored as JSON for flexibility
// For signing: wet signature coordinates, signature appearance settings
// For review: assigned review sections, comment preferences
// For approval: decision criteria, approval authority level
@org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.JSON)
@Column(name = "participant_metadata", columnDefinition = "jsonb")
private Map<String, Object> participantMetadata = new HashMap<>();
// Notification history
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(
name = "participant_notifications",
joinColumns = @JoinColumn(name = "participant_id"))
@Column(name = "notification_message", columnDefinition = "text")
private List<String> notifications = new ArrayList<>();
@UpdateTimestamp
@Column(name = "last_updated")
private LocalDateTime lastUpdated;
// Helper methods
public void addNotification(String message) {
notifications.add(message);
}
public boolean isExpired() {
return expiresAt != null && LocalDateTime.now().isAfter(expiresAt);
}
public boolean hasCompleted() {
return status == ParticipantStatus.SIGNED || status == ParticipantStatus.DECLINED;
}
/**
* Determines the effective access role based on participant status. After completion
* (signed/declined), downgrade to VIEWER.
*/
public ShareAccessRole getEffectiveRole() {
if (hasCompleted()) {
return ShareAccessRole.VIEWER;
}
return accessRole;
}
public boolean canEdit() {
return !hasCompleted()
&& !isExpired()
&& (accessRole == ShareAccessRole.EDITOR
|| accessRole == ShareAccessRole.COMMENTER);
}
}
@@ -0,0 +1,143 @@
package stirling.software.proprietary.workflow.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.StoredFile;
/**
* Represents a workflow session for multi-participant document processing. Replaces
* SigningSessionEntity with a more generic workflow abstraction that supports signing, review,
* approval, and other collaborative workflows.
*
* <p>This entity coordinates the workflow lifecycle and links to StoredFile for actual document
* storage (no more direct BLOBs).
*/
@Entity
@Table(
name = "workflow_sessions",
indexes = {
@Index(name = "idx_workflow_sessions_owner", columnList = "owner_id"),
@Index(name = "idx_workflow_sessions_session_id", columnList = "session_id")
})
@NoArgsConstructor
@Getter
@Setter
public class WorkflowSession implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "session_id", unique = true, nullable = false, length = 36)
private String sessionId = UUID.randomUUID().toString();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "owner_id", nullable = false)
private User owner;
@Column(name = "workflow_type", nullable = false, length = 20)
@Enumerated(EnumType.STRING)
private WorkflowType workflowType;
@Column(name = "document_name", nullable = false)
private String documentName;
// Replaces BLOB storage with StoredFile reference
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "original_file_id", nullable = false)
private StoredFile originalFile;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "processed_file_id")
private StoredFile processedFile;
@Column(name = "owner_email")
private String ownerEmail;
@Column(name = "message", columnDefinition = "text")
private String message;
@Column(name = "due_date", length = 50)
private String dueDate;
@Column(name = "status", nullable = false, length = 20)
@Enumerated(EnumType.STRING)
private WorkflowStatus status = WorkflowStatus.IN_PROGRESS;
@Column(name = "finalized", nullable = false)
private boolean finalized = false;
@OneToMany(
mappedBy = "workflowSession",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY)
private List<WorkflowParticipant> participants = new ArrayList<>();
// Workflow-specific settings stored as JSON for flexibility
// For signing: signature appearance settings, wet signature metadata
// For review: review guidelines, comment templates
// For approval: approval criteria, decision options
@org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.JSON)
@Column(name = "workflow_metadata", columnDefinition = "jsonb")
private Map<String, Object> workflowMetadata = new HashMap<>();
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
// Helper methods
public void addParticipant(WorkflowParticipant participant) {
participants.add(participant);
participant.setWorkflowSession(this);
}
public void removeParticipant(WorkflowParticipant participant) {
participants.remove(participant);
participant.setWorkflowSession(null);
}
public boolean isActive() {
return status == WorkflowStatus.IN_PROGRESS && !finalized;
}
public boolean hasProcessedFile() {
return processedFile != null;
}
}
@@ -0,0 +1,16 @@
package stirling.software.proprietary.workflow.model;
/**
* Defines the overall status of a workflow session. Tracks the lifecycle from creation through
* completion or cancellation.
*/
public enum WorkflowStatus {
/** Workflow is active and awaiting participant actions */
IN_PROGRESS,
/** Workflow has been successfully completed by all participants */
COMPLETED,
/** Workflow has been cancelled by the owner or system */
CANCELLED
}
@@ -0,0 +1,16 @@
package stirling.software.proprietary.workflow.model;
/**
* Defines the type of workflow being executed. Determines the business logic and lifecycle for the
* workflow session.
*/
public enum WorkflowType {
/** Document signing workflow - participants sign a PDF with digital certificates */
SIGNING,
/** Document review workflow - participants review and comment on a document */
REVIEW,
/** Document approval workflow - participants approve or reject a document */
APPROVAL
}
@@ -0,0 +1,23 @@
package stirling.software.proprietary.workflow.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import stirling.software.proprietary.workflow.model.UserServerCertificateEntity;
@Repository
public interface UserServerCertificateRepository
extends JpaRepository<UserServerCertificateEntity, Long> {
@Query("SELECT c FROM UserServerCertificateEntity c WHERE c.user.id = :userId")
Optional<UserServerCertificateEntity> findByUserId(@Param("userId") Long userId);
@Query("SELECT c FROM UserServerCertificateEntity c WHERE c.user.username = :username")
Optional<UserServerCertificateEntity> findByUsername(@Param("username") String username);
boolean existsByUserId(Long userId);
}
@@ -0,0 +1,75 @@
package stirling.software.proprietary.workflow.repository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.workflow.model.ParticipantStatus;
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
import stirling.software.proprietary.workflow.model.WorkflowSession;
@Repository
public interface WorkflowParticipantRepository extends JpaRepository<WorkflowParticipant, Long> {
/** Find participant by share token */
Optional<WorkflowParticipant> findByShareToken(String shareToken);
/** Find all participants in a workflow session */
List<WorkflowParticipant> findByWorkflowSession(WorkflowSession session);
/** Find participant by session and user */
Optional<WorkflowParticipant> findByWorkflowSessionAndUser(WorkflowSession session, User user);
/** Find participant by session and email */
Optional<WorkflowParticipant> findByWorkflowSessionAndEmail(
WorkflowSession session, String email);
/** Find all participants with a specific status in a session */
List<WorkflowParticipant> findByWorkflowSessionAndStatus(
WorkflowSession session, ParticipantStatus status);
/** Find all sessions where a user is a participant */
List<WorkflowParticipant> findByUserOrderByLastUpdatedDesc(User user);
/** Find all sessions where an email is a participant */
List<WorkflowParticipant> findByEmailOrderByLastUpdatedDesc(String email);
/** Check if a participant exists by share token */
boolean existsByShareToken(String shareToken);
/** Count participants in a session by status */
long countByWorkflowSessionAndStatus(WorkflowSession session, ParticipantStatus status);
/** Find expired participants that haven't completed */
@Query(
"SELECT p FROM WorkflowParticipant p WHERE p.expiresAt < CURRENT_TIMESTAMP AND p.status NOT IN ('SIGNED', 'DECLINED')")
List<WorkflowParticipant> findExpiredIncompleteParticipants();
/** Find all participants pending notification */
@Query(
"SELECT p FROM WorkflowParticipant p WHERE p.status = 'PENDING' AND p.workflowSession.status = 'IN_PROGRESS'")
List<WorkflowParticipant> findPendingNotifications();
/** Delete participant by ID and session owner (for authorization) */
@Query(
"DELETE FROM WorkflowParticipant p WHERE p.id = :participantId AND p.workflowSession.owner = :owner")
void deleteByIdAndSessionOwner(
@Param("participantId") Long participantId, @Param("owner") User owner);
/**
* Null out the user reference for all participants linked to the given user. Used during user
* deletion to preserve workflow audit history while removing the personal data link.
* Participants in sessions owned by others are retained but de-linked from the deleted account.
*/
@Modifying
@Transactional
@Query("UPDATE WorkflowParticipant wp SET wp.user = null WHERE wp.user = :user")
void clearUserReferences(@Param("user") User user);
}
@@ -0,0 +1,61 @@
package stirling.software.proprietary.workflow.repository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.model.WorkflowStatus;
import stirling.software.proprietary.workflow.model.WorkflowType;
@Repository
public interface WorkflowSessionRepository extends JpaRepository<WorkflowSession, Long> {
/** Find workflow session by unique session ID */
Optional<WorkflowSession> findBySessionId(String sessionId);
/** Find workflow session by unique session ID with participants eagerly loaded */
@Query(
"SELECT ws FROM WorkflowSession ws LEFT JOIN FETCH ws.participants WHERE ws.sessionId = :sessionId")
Optional<WorkflowSession> findBySessionIdWithParticipants(@Param("sessionId") String sessionId);
/** Find all workflow sessions owned by a specific user */
List<WorkflowSession> findByOwnerOrderByCreatedAtDesc(User owner);
/** Find all workflow sessions of a specific type for a user */
List<WorkflowSession> findByOwnerAndWorkflowTypeOrderByCreatedAtDesc(
User owner, WorkflowType workflowType);
/** Find all workflow sessions with a specific status */
List<WorkflowSession> findByStatusOrderByCreatedAtDesc(WorkflowStatus status);
/** Find all active (non-finalized, in-progress) sessions for a user */
@Query(
"SELECT ws FROM WorkflowSession ws WHERE ws.owner = :owner AND ws.status = 'IN_PROGRESS' AND ws.finalized = false ORDER BY ws.createdAt DESC")
List<WorkflowSession> findActiveSessionsByOwner(@Param("owner") User owner);
/** Find all finalized sessions for a user */
List<WorkflowSession> findByOwnerAndFinalizedTrueOrderByCreatedAtDesc(User owner);
/** Check if a session exists by session ID */
boolean existsBySessionId(String sessionId);
/** Find sessions that need cleanup (e.g., old cancelled sessions) */
@Query(
"SELECT ws FROM WorkflowSession ws WHERE ws.status = 'CANCELLED' AND ws.updatedAt < :cutoffDate")
List<WorkflowSession> findCancelledSessionsOlderThan(
@Param("cutoffDate") java.time.LocalDateTime cutoffDate);
/** Count active sessions for a user */
@Query(
"SELECT COUNT(ws) FROM WorkflowSession ws WHERE ws.owner = :owner AND ws.status = 'IN_PROGRESS' AND ws.finalized = false")
long countActiveSessionsByOwner(@Param("owner") User owner);
/** Delete session by session ID and owner (for authorization) */
void deleteBySessionIdAndOwner(String sessionId, User owner);
}
@@ -0,0 +1,119 @@
package stirling.software.proprietary.workflow.service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
/**
* Provides AES-256-GCM encryption for sensitive fields stored in JSONB metadata columns (e.g.
* keystore passwords). The encryption key is derived from the application's
* AutomaticallyGenerated.key, which is persisted in settings on first run.
*
* <p>Encrypted values are prefixed with {@value #ENC_PREFIX} so that legacy plaintext values
* written before this service was introduced can still be decrypted transparently.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class MetadataEncryptionService {
static final String ENC_PREFIX = "enc:";
private static final String ALGORITHM = "AES/GCM/NoPadding";
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128; // bits
private final ApplicationProperties applicationProperties;
// Public API
/**
* Encrypts {@code plaintext} with AES-256-GCM and returns a Base64-encoded ciphertext prefixed
* with {@value #ENC_PREFIX}.
*/
public String encrypt(String plaintext) {
if (plaintext == null) {
return null;
}
try {
SecretKeySpec keySpec = deriveKey();
byte[] iv = generateIv();
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
byte[] cipherBytes = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
// Prepend IV to ciphertext for storage: [12-byte IV][ciphertext+tag]
byte[] combined = new byte[iv.length + cipherBytes.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(cipherBytes, 0, combined, iv.length, cipherBytes.length);
return ENC_PREFIX + Base64.getEncoder().encodeToString(combined);
} catch (Exception e) {
throw new IllegalStateException("Failed to encrypt metadata field", e);
}
}
/**
* Decrypts a value produced by {@link #encrypt}. If the value does not start with {@value
* #ENC_PREFIX} it is returned as-is to preserve backwards compatibility with plaintext values
* written before this service existed.
*/
public String decrypt(String value) {
if (value == null) {
return null;
}
if (!value.startsWith(ENC_PREFIX)) {
// Legacy plaintext return unchanged
return value;
}
try {
SecretKeySpec keySpec = deriveKey();
byte[] combined = Base64.getDecoder().decode(value.substring(ENC_PREFIX.length()));
byte[] iv = Arrays.copyOfRange(combined, 0, GCM_IV_LENGTH);
byte[] cipherBytes = Arrays.copyOfRange(combined, GCM_IV_LENGTH, combined.length);
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, keySpec, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
return new String(cipher.doFinal(cipherBytes), StandardCharsets.UTF_8);
} catch (Exception e) {
throw new IllegalStateException("Failed to decrypt metadata field", e);
}
}
// Internals
private SecretKeySpec deriveKey() throws Exception {
String rawKey = applicationProperties.getAutomaticallyGenerated().getKey();
if (rawKey == null || rawKey.isBlank()) {
throw new IllegalStateException(
"AutomaticallyGenerated.key is not initialised — cannot derive encryption key");
}
// SHA-256 of the raw key gives a stable 32-byte AES-256 key
byte[] hash =
MessageDigest.getInstance("SHA-256")
.digest(rawKey.getBytes(StandardCharsets.UTF_8));
return new SecretKeySpec(hash, "AES");
}
private static byte[] generateIv() {
byte[] iv = new byte[GCM_IV_LENGTH];
new SecureRandom().nextBytes(iv);
return iv;
}
}
@@ -0,0 +1,233 @@
package stirling.software.proprietary.workflow.service;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.ShareAccessRole;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.repository.FileShareRepository;
import stirling.software.proprietary.workflow.model.ParticipantStatus;
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
/**
* Unified access control service that consolidates validation logic for both generic file shares
* and workflow participants.
*
* <p>This service bridges the gap between the file sharing infrastructure and workflow-specific
* access control.
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional(readOnly = true)
public class UnifiedAccessControlService {
private final FileShareRepository fileShareRepository;
private final WorkflowParticipantRepository workflowParticipantRepository;
/**
* Validates a share token and returns access validation result. Works for both generic file
* shares and workflow participant shares.
*/
public AccessValidationResult validateToken(String token, User user) {
log.debug("Validating access token: {}", token);
// First try as file share token
Optional<FileShare> fileShareOpt = fileShareRepository.findByShareTokenWithFile(token);
if (fileShareOpt.isPresent()) {
return validateGenericShare(fileShareOpt.get(), user);
}
// Try as workflow participant token
Optional<WorkflowParticipant> participantOpt =
workflowParticipantRepository.findByShareToken(token);
if (participantOpt.isPresent()) {
return validateParticipant(participantOpt.get(), user);
}
log.warn("Invalid or expired token: {}", token);
return AccessValidationResult.denied("Invalid or expired access token");
}
/** Validates a generic file share (non-workflow) */
private AccessValidationResult validateGenericShare(FileShare share, User user) {
// Check expiration
if (share.getExpiresAt() != null && LocalDateTime.now().isAfter(share.getExpiresAt())) {
log.warn("Share token expired: {}", share.getShareToken());
return AccessValidationResult.denied("Access link has expired");
}
// Check if user matches (if share is user-specific)
if (share.getSharedWithUser() != null && !share.getSharedWithUser().equals(user)) {
log.warn(
"User mismatch for share: expected {}, got {}",
share.getSharedWithUser().getId(),
user != null ? user.getId() : "null");
return AccessValidationResult.denied("Access denied for this user");
}
return AccessValidationResult.allowed(share.getFile(), share.getAccessRole(), null, false);
}
/** Validates a workflow participant by token */
private AccessValidationResult validateParticipant(WorkflowParticipant participant, User user) {
// Check expiration
if (participant.isExpired()) {
log.warn("Workflow participant access expired: {}", participant.getShareToken());
return AccessValidationResult.denied("Workflow access has expired");
}
// Check if workflow is still active
if (!participant.getWorkflowSession().isActive()) {
log.info(
"Workflow session no longer active: {}",
participant.getWorkflowSession().getSessionId());
return AccessValidationResult.denied("Workflow session is no longer active");
}
// Check user authorization
if (participant.getUser() != null && !participant.getUser().equals(user)) {
log.warn(
"User mismatch for participant: expected {}, got {}",
participant.getUser().getId(),
user != null ? user.getId() : "null");
return AccessValidationResult.denied("Access denied for this user");
}
// Get effective role based on participant status
ShareAccessRole effectiveRole = getEffectiveRole(participant);
// Get the file from the workflow session
StoredFile file = participant.getWorkflowSession().getOriginalFile();
return AccessValidationResult.allowed(file, effectiveRole, participant, true);
}
/**
* Maps participant status to effective access role. After completion (signed/declined),
* downgrade to VIEWER.
*/
public ShareAccessRole getEffectiveRole(WorkflowParticipant participant) {
ParticipantStatus status = participant.getStatus();
switch (status) {
case SIGNED:
case DECLINED:
// After action completed, downgrade to read-only
return ShareAccessRole.VIEWER;
case PENDING:
case NOTIFIED:
case VIEWED:
// Active participants retain their assigned role
return participant.getAccessRole();
default:
log.warn("Unknown participant status: {}", status);
return ShareAccessRole.VIEWER;
}
}
/** Checks if a user can access a specific file */
public boolean canAccessFile(User user, StoredFile file) {
// Owner always has access
if (file.getOwner().equals(user)) {
return true;
}
// Check for file share
Optional<FileShare> share = fileShareRepository.findByFileAndSharedWithUser(file, user);
if (share.isPresent() && !isExpired(share.get())) {
return true;
}
// Check for workflow participant access
if (file.getWorkflowSession() != null) {
Optional<WorkflowParticipant> participant =
workflowParticipantRepository.findByWorkflowSessionAndUser(
file.getWorkflowSession(), user);
return participant.isPresent()
&& !participant.get().isExpired()
&& participant.get().getWorkflowSession().isActive();
}
return false;
}
private boolean isExpired(FileShare share) {
return share.getExpiresAt() != null && LocalDateTime.now().isAfter(share.getExpiresAt());
}
/** Result of access validation */
public static class AccessValidationResult {
private final boolean allowed;
private final String denialReason;
private final StoredFile file;
private final ShareAccessRole role;
private final WorkflowParticipant participant;
private final boolean isWorkflowAccess;
private AccessValidationResult(
boolean allowed,
String denialReason,
StoredFile file,
ShareAccessRole role,
WorkflowParticipant participant,
boolean isWorkflowAccess) {
this.allowed = allowed;
this.denialReason = denialReason;
this.file = file;
this.role = role;
this.participant = participant;
this.isWorkflowAccess = isWorkflowAccess;
}
public static AccessValidationResult allowed(
StoredFile file,
ShareAccessRole role,
WorkflowParticipant participant,
boolean isWorkflowAccess) {
return new AccessValidationResult(
true, null, file, role, participant, isWorkflowAccess);
}
public static AccessValidationResult denied(String reason) {
return new AccessValidationResult(false, reason, null, null, null, false);
}
public boolean isAllowed() {
return allowed;
}
public String getDenialReason() {
return denialReason;
}
public StoredFile getFile() {
return file;
}
public ShareAccessRole getRole() {
return role;
}
public WorkflowParticipant getParticipant() {
return participant;
}
public boolean isWorkflowAccess() {
return isWorkflowAccess;
}
public boolean canEdit() {
return allowed && (role == ShareAccessRole.EDITOR || role == ShareAccessRole.COMMENTER);
}
}
}
@@ -0,0 +1,258 @@
package stirling.software.proprietary.workflow.service;
import java.io.*;
import java.math.BigInteger;
import java.security.*;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.Optional;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.BasicConstraints;
import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.KeyPurposeId;
import org.bouncycastle.asn1.x509.KeyUsage;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.workflow.model.CertificateType;
import stirling.software.proprietary.workflow.model.UserServerCertificateEntity;
import stirling.software.proprietary.workflow.repository.UserServerCertificateRepository;
@Service
@Slf4j
@RequiredArgsConstructor
public class UserServerCertificateService {
private static final String KEYSTORE_ALIAS = "stirling-pdf-user-cert";
private static final String DEFAULT_PASSWORD_PREFIX = "stirling-user-cert-";
private static final int VALIDITY_DAYS = 365;
private final UserServerCertificateRepository certificateRepository;
private final UserRepository userRepository;
private final MetadataEncryptionService metadataEncryptionService;
static {
Security.addProvider(new BouncyCastleProvider());
}
/** Get or create user certificate (auto-generate if not exists) */
@Transactional
public UserServerCertificateEntity getOrCreateUserCertificate(Long userId) throws Exception {
Optional<UserServerCertificateEntity> existing = certificateRepository.findByUserId(userId);
if (existing.isPresent()) {
return existing.get();
}
User user =
userRepository
.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
return generateUserCertificate(user);
}
/** Generate new certificate for user */
@Transactional
public UserServerCertificateEntity generateUserCertificate(User user) throws Exception {
log.info("Generating server certificate for user: {}", user.getUsername());
// Generate key pair
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
keyPairGenerator.initialize(2048, new SecureRandom());
KeyPair keyPair = keyPairGenerator.generateKeyPair();
// Certificate details with username
String username = user.getUsername();
X500Name subject = new X500Name("CN=" + username + ", O=Stirling-PDF User, C=US");
BigInteger serialNumber = BigInteger.valueOf(System.currentTimeMillis());
Date notBefore = new Date();
Date notAfter =
new Date(notBefore.getTime() + ((long) VALIDITY_DAYS * 24 * 60 * 60 * 1000));
// Build certificate
JcaX509v3CertificateBuilder certBuilder =
new JcaX509v3CertificateBuilder(
subject, serialNumber, notBefore, notAfter, subject, keyPair.getPublic());
// Add PDF-specific certificate extensions
JcaX509ExtensionUtils extUtils = new JcaX509ExtensionUtils();
// End-entity certificate, not a CA
certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false));
// Key usage for PDF digital signatures
certBuilder.addExtension(
Extension.keyUsage,
true,
new KeyUsage(KeyUsage.digitalSignature | KeyUsage.nonRepudiation));
// Extended key usage for document signing
certBuilder.addExtension(
Extension.extendedKeyUsage,
false,
new ExtendedKeyUsage(KeyPurposeId.id_kp_codeSigning));
// Subject Key Identifier
certBuilder.addExtension(
Extension.subjectKeyIdentifier,
false,
extUtils.createSubjectKeyIdentifier(keyPair.getPublic()));
// Authority Key Identifier for self-signed cert
certBuilder.addExtension(
Extension.authorityKeyIdentifier,
false,
extUtils.createAuthorityKeyIdentifier(keyPair.getPublic()));
// Sign certificate
ContentSigner signer =
new JcaContentSignerBuilder("SHA256WithRSA")
.setProvider("BC")
.build(keyPair.getPrivate());
X509CertificateHolder certHolder = certBuilder.build(signer);
X509Certificate cert =
new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder);
// Create keystore
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(null, null);
String password = generateUserPassword(user.getId());
keyStore.setKeyEntry(
KEYSTORE_ALIAS,
keyPair.getPrivate(),
password.toCharArray(),
new Certificate[] {cert});
// Store keystore bytes
ByteArrayOutputStream baos = new ByteArrayOutputStream();
keyStore.store(baos, password.toCharArray());
byte[] keystoreBytes = baos.toByteArray();
// Create entity
UserServerCertificateEntity entity = new UserServerCertificateEntity();
entity.setUser(user);
entity.setKeystoreData(keystoreBytes);
entity.setKeystorePassword(metadataEncryptionService.encrypt(password));
entity.setCertificateType(CertificateType.AUTO_GENERATED);
entity.setSubjectDn(cert.getSubjectX500Principal().getName());
entity.setIssuerDn(cert.getIssuerX500Principal().getName());
entity.setValidFrom(
LocalDateTime.ofInstant(cert.getNotBefore().toInstant(), ZoneId.systemDefault()));
entity.setValidTo(
LocalDateTime.ofInstant(cert.getNotAfter().toInstant(), ZoneId.systemDefault()));
return certificateRepository.save(entity);
}
/** Upload user-provided certificate */
@Transactional
public UserServerCertificateEntity uploadUserCertificate(
User user, InputStream p12Stream, String password) throws Exception {
log.info("Uploading user certificate for user: {}", user.getUsername());
// Validate keystore
byte[] keystoreBytes = p12Stream.readNBytes(10 * 1024 * 1024 + 1); // read at most 10 MB + 1
if (keystoreBytes.length > 10 * 1024 * 1024) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Keystore file exceeds maximum allowed size of 10 MB");
}
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(new ByteArrayInputStream(keystoreBytes), password.toCharArray());
// Extract certificate info
String alias = keyStore.aliases().nextElement();
X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias);
if (cert == null) {
throw new IllegalArgumentException("No certificate found in keystore");
}
// Create or update entity
UserServerCertificateEntity entity =
certificateRepository
.findByUserId(user.getId())
.orElse(new UserServerCertificateEntity());
entity.setUser(user);
entity.setKeystoreData(keystoreBytes);
entity.setKeystorePassword(metadataEncryptionService.encrypt(password));
entity.setCertificateType(CertificateType.USER_UPLOADED);
entity.setSubjectDn(cert.getSubjectX500Principal().getName());
entity.setIssuerDn(cert.getIssuerX500Principal().getName());
entity.setValidFrom(
LocalDateTime.ofInstant(cert.getNotBefore().toInstant(), ZoneId.systemDefault()));
entity.setValidTo(
LocalDateTime.ofInstant(cert.getNotAfter().toInstant(), ZoneId.systemDefault()));
return certificateRepository.save(entity);
}
/** Get user's KeyStore for signing operations */
@Transactional(readOnly = true)
public KeyStore getUserKeyStore(Long userId) throws Exception {
UserServerCertificateEntity cert =
certificateRepository
.findByUserId(userId)
.orElseThrow(
() -> new IllegalArgumentException("User certificate not found"));
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(
new ByteArrayInputStream(cert.getKeystoreData()),
metadataEncryptionService.decrypt(cert.getKeystorePassword()).toCharArray());
return keyStore;
}
/** Get user's keystore password */
@Transactional(readOnly = true)
public String getUserKeystorePassword(Long userId) {
UserServerCertificateEntity cert =
certificateRepository
.findByUserId(userId)
.orElseThrow(
() -> new IllegalArgumentException("User certificate not found"));
return metadataEncryptionService.decrypt(cert.getKeystorePassword());
}
/** Delete user certificate */
@Transactional
public void deleteUserCertificate(Long userId) {
certificateRepository.findByUserId(userId).ifPresent(certificateRepository::delete);
}
/** Check if user has certificate */
@Transactional(readOnly = true)
public boolean hasUserCertificate(Long userId) {
return certificateRepository.findByUserId(userId).isPresent();
}
/** Get certificate info (without keystore data) */
@Transactional(readOnly = true)
public Optional<UserServerCertificateEntity> getCertificateInfo(Long userId) {
return certificateRepository.findByUserId(userId);
}
/** Generate consistent password for user (based on user ID) */
private String generateUserPassword(Long userId) {
return DEFAULT_PASSWORD_PREFIX + userId;
}
}
@@ -0,0 +1,869 @@
package stirling.software.proprietary.workflow.service;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.FilePurpose;
import stirling.software.proprietary.storage.model.ShareAccessRole;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.provider.StoredObject;
import stirling.software.proprietary.storage.repository.StoredFileRepository;
import stirling.software.proprietary.workflow.dto.ParticipantRequest;
import stirling.software.proprietary.workflow.dto.WetSignatureMetadata;
import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest;
import stirling.software.proprietary.workflow.model.ParticipantStatus;
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.model.WorkflowStatus;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository;
import tools.jackson.core.JacksonException;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
/**
* Core service for workflow session management. Handles creation, participant management, and
* lifecycle coordination.
*
* <p>Delegates file storage to FileStorageService/StorageProvider and integrates with the file
* sharing infrastructure.
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class WorkflowSessionService {
private final WorkflowSessionRepository workflowSessionRepository;
private final WorkflowParticipantRepository workflowParticipantRepository;
private final StoredFileRepository storedFileRepository;
private final UserRepository userRepository;
private final StorageProvider storageProvider;
private final ObjectMapper objectMapper;
private final ApplicationProperties applicationProperties;
private final MetadataEncryptionService metadataEncryptionService;
public void ensureSigningEnabled() {
if (!applicationProperties.getStorage().isEnabled()
|| !applicationProperties.getStorage().getSigning().isEnabled()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Group signing is disabled");
}
}
/**
* Creates a new workflow session with participants. Stores the original file using
* StorageProvider.
*/
public WorkflowSession createSession(
User owner, MultipartFile file, WorkflowCreationRequest request) throws IOException {
log.info(
"Creating workflow session for user {} with type {}",
owner.getUsername(),
request.getWorkflowType());
// Validate request
if (file == null || file.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "File is required");
}
if (request.getWorkflowType() == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Workflow type is required");
}
// Store original file using StorageProvider
StoredFile originalFile = storeWorkflowFile(owner, file, FilePurpose.SIGNING_ORIGINAL);
// Create workflow session
WorkflowSession session = new WorkflowSession();
session.setSessionId(UUID.randomUUID().toString());
session.setOwner(owner);
session.setWorkflowType(request.getWorkflowType());
session.setDocumentName(
request.getDocumentName() != null
? request.getDocumentName()
: file.getOriginalFilename());
session.setOriginalFile(originalFile);
session.setOwnerEmail(request.getOwnerEmail());
session.setMessage(request.getMessage());
session.setDueDate(request.getDueDate());
session.setStatus(WorkflowStatus.IN_PROGRESS);
// Parse workflow metadata from JSON string to Map
if (request.getWorkflowMetadata() != null && !request.getWorkflowMetadata().isBlank()) {
try {
@SuppressWarnings("unchecked")
Map<String, Object> metadataMap =
objectMapper.readValue(request.getWorkflowMetadata(), Map.class);
session.setWorkflowMetadata(metadataMap);
} catch (JacksonException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Invalid workflowMetadata: must be a valid JSON object");
}
}
// Link file back to session
originalFile.setWorkflowSession(session);
originalFile.setPurpose(FilePurpose.SIGNING_ORIGINAL);
session = workflowSessionRepository.save(session);
storedFileRepository.save(originalFile);
// Add participants
List<ParticipantRequest> participants = new ArrayList<>();
if (request.getParticipantUserIds() != null) {
for (Long userId : request.getParticipantUserIds()) {
ParticipantRequest pr = new ParticipantRequest();
pr.setUserId(userId);
pr.setAccessRole(ShareAccessRole.EDITOR);
participants.add(pr);
}
}
if (request.getParticipantEmails() != null) {
for (String email : request.getParticipantEmails()) {
ParticipantRequest pr = new ParticipantRequest();
pr.setEmail(email);
pr.setAccessRole(ShareAccessRole.EDITOR);
participants.add(pr);
}
}
if (!participants.isEmpty()) {
addParticipantsToSession(session, participants);
}
log.info(
"Created workflow session {} with {} participants",
session.getSessionId(),
session.getParticipants().size());
return session;
}
/** Adds participants to a workflow session. */
private void addParticipantsToSession(
WorkflowSession session, List<ParticipantRequest> participantRequests) {
for (ParticipantRequest request : participantRequests) {
WorkflowParticipant participant = new WorkflowParticipant();
participant.setShareToken(UUID.randomUUID().toString());
participant.setAccessRole(
request.getAccessRole() != null
? request.getAccessRole()
: ShareAccessRole.EDITOR);
participant.setExpiresAt(request.getExpiresAt());
// Parse participant metadata from JSON string to Map
if (request.getParticipantMetadata() != null
&& !request.getParticipantMetadata().isBlank()) {
try {
@SuppressWarnings("unchecked")
Map<String, Object> metadataMap =
objectMapper.readValue(request.getParticipantMetadata(), Map.class);
participant.setParticipantMetadata(metadataMap);
} catch (JacksonException e) {
log.warn(
"Failed to parse participant metadata for {}, using empty map",
request.getEmail(),
e);
participant.setParticipantMetadata(new HashMap<>());
}
}
// Store defaultReason in participant metadata if provided
if (request.getDefaultReason() != null && !request.getDefaultReason().isBlank()) {
Map<String, Object> metadata = participant.getParticipantMetadata();
if (metadata == null) {
metadata = new HashMap<>();
}
metadata.put("defaultReason", request.getDefaultReason());
participant.setParticipantMetadata(metadata);
log.debug(
"Set default reason for participant {}: {}",
request.getEmail(),
request.getDefaultReason());
}
participant.setStatus(ParticipantStatus.PENDING);
// Set user or email
if (request.getUserId() != null) {
User user =
userRepository
.findById(request.getUserId())
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND,
"User not found: " + request.getUserId()));
participant.setUser(user);
participant.setEmail(user.getUsername()); // User entity uses username, not email
participant.setName(user.getUsername());
} else if (request.getEmail() != null) {
participant.setEmail(request.getEmail());
participant.setName(
request.getName() != null ? request.getName() : request.getEmail());
} else {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Participant must have either userId or email");
}
session.addParticipant(participant);
participant = workflowParticipantRepository.save(participant);
}
}
/** Stores a file as part of a workflow using the StorageProvider. */
private StoredFile storeWorkflowFile(User owner, MultipartFile file, FilePurpose purpose)
throws IOException {
// Store file content (storage provider generates the key)
StoredObject storedObject = storageProvider.store(owner, file);
// Create StoredFile entity
StoredFile storedFile = new StoredFile();
storedFile.setOwner(owner);
storedFile.setOriginalFilename(storedObject.getOriginalFilename());
storedFile.setContentType(storedObject.getContentType());
storedFile.setSizeBytes(storedObject.getSizeBytes());
storedFile.setStorageKey(storedObject.getStorageKey());
storedFile.setPurpose(purpose);
return storedFileRepository.save(storedFile);
}
/** Retrieves a workflow session by session ID. */
@Transactional(readOnly = true)
public WorkflowSession getSession(String sessionId) {
return workflowSessionRepository
.findBySessionId(sessionId)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND,
"Workflow session not found: " + sessionId));
}
/** Retrieves a workflow session with authorization check. */
@Transactional(readOnly = true)
public WorkflowSession getSessionForOwner(String sessionId, User owner) {
WorkflowSession session = getSession(sessionId);
if (!session.getOwner().equals(owner)) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN, "Not authorized to access this workflow session");
}
return session;
}
/** Retrieves a workflow session with participants eagerly loaded for finalization. */
@Transactional(readOnly = true)
public WorkflowSession getSessionWithParticipants(String sessionId) {
return workflowSessionRepository
.findBySessionIdWithParticipants(sessionId)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND,
"Workflow session not found: " + sessionId));
}
/** Retrieves a workflow session with participants, with authorization check. */
@Transactional(readOnly = true)
public WorkflowSession getSessionWithParticipantsForOwner(String sessionId, User owner) {
WorkflowSession session = getSessionWithParticipants(sessionId);
if (!session.getOwner().equals(owner)) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN, "Not authorized to access this workflow session");
}
return session;
}
/** Lists all workflow sessions owned by a user. */
@Transactional(readOnly = true)
public List<WorkflowSession> listUserSessions(User owner) {
return workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(owner);
}
/** Lists active workflow sessions for a user. */
@Transactional(readOnly = true)
public List<WorkflowSession> listActiveSessions(User owner) {
return workflowSessionRepository.findActiveSessionsByOwner(owner);
}
/** Adds additional participants to an existing session. */
@Transactional
public void addParticipants(
String sessionId, List<ParticipantRequest> participants, User owner) {
WorkflowSession session = getSessionForOwner(sessionId, owner);
if (!session.isActive()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Cannot add participants to inactive workflow");
}
addParticipantsToSession(session, participants);
log.info("Added {} participants to session {}", participants.size(), sessionId);
}
/** Removes a participant from a workflow session. */
@Transactional
public void removeParticipant(String sessionId, Long participantId, User owner) {
WorkflowSession session = getSessionForOwner(sessionId, owner);
WorkflowParticipant participant =
workflowParticipantRepository
.findById(participantId)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND,
"Participant not found: " + participantId));
if (!participant.getWorkflowSession().equals(session)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Participant not in this workflow session");
}
session.removeParticipant(participant);
workflowParticipantRepository.delete(participant);
log.info("Removed participant {} from session {}", participantId, sessionId);
}
/** Updates participant status (e.g., NOTIFIED, VIEWED, SIGNED). */
public void updateParticipantStatus(Long participantId, ParticipantStatus newStatus) {
WorkflowParticipant participant =
workflowParticipantRepository
.findById(participantId)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND,
"Participant not found: " + participantId));
participant.setStatus(newStatus);
workflowParticipantRepository.save(participant);
log.debug("Updated participant {} status to {}", participantId, newStatus);
}
/** Adds a notification message to a participant's history. */
public void addParticipantNotification(Long participantId, String message) {
WorkflowParticipant participant =
workflowParticipantRepository
.findById(participantId)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND,
"Participant not found: " + participantId));
String timestampedMessage = LocalDateTime.now().toString() + ": " + message;
participant.addNotification(timestampedMessage);
workflowParticipantRepository.save(participant);
}
/** Stores the processed/finalized file for a workflow session. */
public void storeProcessedFile(WorkflowSession session, byte[] fileData, String filename)
throws IOException {
log.info("Storing processed file for session {}", session.getSessionId());
// Create a temporary multipart file wrapper
MultipartFile processedFile = new ByteArrayMultipartFile(fileData, filename);
// Store using StorageProvider
StoredFile storedFile =
storeWorkflowFile(session.getOwner(), processedFile, FilePurpose.SIGNING_SIGNED);
// Link to session
storedFile.setWorkflowSession(session);
session.setProcessedFile(storedFile);
storedFileRepository.save(storedFile);
workflowSessionRepository.save(session);
}
/** Marks a workflow session as finalized. */
public void finalizeSession(String sessionId, User owner) {
WorkflowSession session = getSessionForOwner(sessionId, owner);
if (session.isFinalized()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Workflow session already finalized");
}
session.setFinalized(true);
session.setStatus(WorkflowStatus.COMPLETED);
workflowSessionRepository.save(session);
log.info("Finalized workflow session {}", sessionId);
}
/** Retrieves the processed file data for a workflow session. */
@Transactional(readOnly = true)
public byte[] getProcessedFile(String sessionId, User owner) throws IOException {
WorkflowSession session = getSessionForOwner(sessionId, owner);
if (session.getProcessedFile() == null) {
throw new ResponseStatusException(
HttpStatus.NOT_FOUND, "No processed file available for this session");
}
String storageKey = session.getProcessedFile().getStorageKey();
org.springframework.core.io.Resource resource = storageProvider.load(storageKey);
return resource.getContentAsByteArray();
}
/** Retrieves the original file data for a workflow session. */
@Transactional(readOnly = true)
public byte[] getOriginalFile(String sessionId) throws IOException {
WorkflowSession session = getSession(sessionId);
if (session.getOriginalFile() == null) {
throw new ResponseStatusException(
HttpStatus.NOT_FOUND,
"Original file no longer available (session may be finalized)");
}
String storageKey = session.getOriginalFile().getStorageKey();
org.springframework.core.io.Resource resource = storageProvider.load(storageKey);
return resource.getContentAsByteArray();
}
/** Deletes a workflow session and associated files. */
@Transactional
public void deleteSession(String sessionId, User owner) {
WorkflowSession session = getSessionForOwner(sessionId, owner);
if (session.isFinalized()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Cannot delete a finalized session. The signed PDF remains accessible from your session history.");
}
// Delete physical storage files (non-fatal; may already be absent)
try {
if (session.getOriginalFile() != null) {
storageProvider.delete(session.getOriginalFile().getStorageKey());
}
if (session.getProcessedFile() != null) {
storageProvider.delete(session.getProcessedFile().getStorageKey());
}
} catch (Exception e) {
log.error("Error deleting files for session {}", sessionId, e);
}
// Clear only the StoredFile WorkflowSession back-reference before deleting.
//
// We do NOT null session.originalFile here because that would emit an UPDATE with
// original_file_id=NULL, violating the NOT NULL constraint. There is no need to a
// DELETE statement removes the row entirely, so the NOT NULL constraint never triggers.
//
// We DO null StoredFile.workflowSession (workflow_session_id IS nullable) so that
// Hibernate does not see a persistent StoredFile referencing a "removed" WorkflowSession
// during flush, which would throw TransientPropertyValueException.
StoredFile originalFile = session.getOriginalFile();
StoredFile processedFile = session.getProcessedFile();
if (originalFile != null) {
originalFile.setWorkflowSession(null);
storedFileRepository.save(originalFile);
}
if (processedFile != null) {
processedFile.setWorkflowSession(null);
storedFileRepository.save(processedFile);
}
// Delete the session row. Cascades to WorkflowParticipant via orphanRemoval=true.
workflowSessionRepository.delete(session);
// StoredFile rows can now be deleted the workflow_sessions FK is gone.
if (originalFile != null) storedFileRepository.delete(originalFile);
if (processedFile != null) storedFileRepository.delete(processedFile);
log.info("Deleted workflow session {}", sessionId);
}
/**
* Deletes the original (presigned) file from storage after finalization. The original file is
* no longer needed once the signed document has been stored. Non-fatal: logs errors but does
* not fail finalization.
*/
public void deleteOriginalFile(WorkflowSession session) {
if (session.getOriginalFile() == null) {
return;
}
try {
storageProvider.delete(session.getOriginalFile().getStorageKey());
StoredFile originalFile = session.getOriginalFile();
session.setOriginalFile(null);
workflowSessionRepository.save(session);
storedFileRepository.delete(originalFile);
log.info("Deleted original presigned file for session {}", session.getSessionId());
} catch (Exception e) {
log.error(
"Failed to delete original file for session {}: {}",
session.getSessionId(),
e.getMessage());
}
}
// ===== SIGN REQUEST METHODS (Participant View) =====
/**
* List all sign requests where the user is a participant.
*
* @param user The participant user
* @return List of sign request summaries
*/
@Transactional(readOnly = true)
public List<stirling.software.proprietary.workflow.dto.SignRequestSummaryDTO> listSignRequests(
User user) {
List<WorkflowParticipant> participations =
workflowParticipantRepository.findByUserOrderByLastUpdatedDesc(user);
return participations.stream()
.map(
p -> {
WorkflowSession session = p.getWorkflowSession();
stirling.software.proprietary.workflow.dto.SignRequestSummaryDTO dto =
new stirling.software.proprietary.workflow.dto
.SignRequestSummaryDTO();
dto.setSessionId(session.getSessionId());
dto.setDocumentName(session.getDocumentName());
dto.setOwnerUsername(session.getOwner().getUsername());
dto.setCreatedAt(session.getCreatedAt().toString());
dto.setDueDate(
session.getDueDate() != null
? session.getDueDate().toString()
: null);
dto.setMyStatus(p.getStatus());
return dto;
})
.collect(java.util.stream.Collectors.toList());
}
/**
* Get detailed information about a sign request.
*
* @param sessionId The session ID
* @param user The participant user
* @return Sign request detail
*/
@Transactional(readOnly = true)
public stirling.software.proprietary.workflow.dto.SignRequestDetailDTO getSignRequestDetail(
String sessionId, User user) {
WorkflowSession session = getSession(sessionId);
WorkflowParticipant participant = getParticipantForUser(session, user);
stirling.software.proprietary.workflow.dto.SignRequestDetailDTO dto =
new stirling.software.proprietary.workflow.dto.SignRequestDetailDTO();
dto.setSessionId(session.getSessionId());
dto.setDocumentName(session.getDocumentName());
dto.setOwnerUsername(session.getOwner().getUsername());
dto.setMessage(session.getMessage());
dto.setDueDate(session.getDueDate());
dto.setCreatedAt(session.getCreatedAt().toString());
dto.setMyStatus(participant.getStatus());
// Load signature appearance settings from workflow metadata
Map<String, Object> metadata = session.getWorkflowMetadata();
if (metadata != null && !metadata.isEmpty()) {
dto.setShowSignature(
metadata.containsKey("showSignature")
? (Boolean) metadata.get("showSignature")
: false);
dto.setPageNumber(
metadata.containsKey("pageNumber")
? ((Number) metadata.get("pageNumber")).intValue()
: null);
dto.setReason(metadata.containsKey("reason") ? (String) metadata.get("reason") : null);
dto.setLocation(
metadata.containsKey("location") ? (String) metadata.get("location") : null);
dto.setShowLogo(
metadata.containsKey("showLogo") ? (Boolean) metadata.get("showLogo") : false);
} else {
// Default values if no metadata
dto.setShowSignature(false);
dto.setPageNumber(null);
dto.setReason(null);
dto.setLocation(null);
dto.setShowLogo(false);
}
// Update status to VIEWED if it was NOTIFIED
if (participant.getStatus() == ParticipantStatus.NOTIFIED) {
participant.setStatus(ParticipantStatus.VIEWED);
workflowParticipantRepository.save(participant);
}
return dto;
}
/**
* Get the document for a sign request.
*
* <p>After finalization, returns the signed document. Before finalization, returns the
* original.
*
* @param sessionId The session ID
* @param user The participant user
* @return PDF document bytes
*/
@Transactional(readOnly = true)
public byte[] getSignRequestDocument(String sessionId, User user) {
WorkflowSession session = getSession(sessionId);
getParticipantForUser(session, user); // Verify participant access
// After finalization, serve the signed document instead of the original
StoredFile fileToServe =
(session.isFinalized() && session.getProcessedFile() != null)
? session.getProcessedFile()
: session.getOriginalFile();
if (fileToServe == null) {
throw new ResponseStatusException(
HttpStatus.NOT_FOUND, "Document not available for this session");
}
try {
org.springframework.core.io.Resource resource =
storageProvider.load(fileToServe.getStorageKey());
return resource.getContentAsByteArray();
} catch (IOException e) {
log.error("Failed to retrieve document for session {}", sessionId, e);
throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR, "Failed to retrieve document");
}
}
/**
* Sign a document in a workflow session.
*
* @param sessionId The session ID
* @param user The participant user
* @param request Sign document request with certificate and optional wet signature
*/
public void signDocument(
String sessionId,
User user,
stirling.software.proprietary.workflow.dto.SignDocumentRequest request) {
WorkflowSession session = getSession(sessionId);
WorkflowParticipant participant = getParticipantForUser(session, user);
if (participant.getStatus() == ParticipantStatus.SIGNED) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Document already signed by this user");
}
if (participant.getStatus() == ParticipantStatus.DECLINED) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Cannot sign after declining");
}
// Build metadata JSON containing certificate submission and wet signature data
// Merge with existing metadata if present (preserves owner-configured appearance
// settings)
Map<String, Object> metadata = new HashMap<>();
// Get existing metadata if present
Map<String, Object> existingMetadata = participant.getParticipantMetadata();
if (existingMetadata != null && !existingMetadata.isEmpty()) {
metadata = new HashMap<>(existingMetadata);
}
// 1. Store certificate submission data
Map<String, Object> certSubmission = new HashMap<>();
certSubmission.put("certType", request.getCertType());
certSubmission.put("password", metadataEncryptionService.encrypt(request.getPassword()));
// Store keystore files as base64 if provided
if (request.getP12File() != null && !request.getP12File().isEmpty()) {
try {
byte[] keystoreBytes = request.getP12File().getBytes();
String base64Keystore = java.util.Base64.getEncoder().encodeToString(keystoreBytes);
certSubmission.put("p12Keystore", base64Keystore);
} catch (IOException e) {
log.error("Failed to read P12 keystore file", e);
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Failed to process certificate file");
}
}
// Note: Signature appearance settings (showSignature, pageNumber, location, reason,
// showLogo)
// may already be in metadata if owner configured them when adding participant.
// If not present, the finalization process will use defaults.
metadata.put("certificateSubmission", certSubmission);
// 2. Parse wet signatures from JSON string if provided
if (request.getWetSignaturesData() != null && !request.getWetSignaturesData().isBlank()) {
try {
List<WetSignatureMetadata> wetSigs =
objectMapper.readValue(
request.getWetSignaturesData(),
new TypeReference<List<WetSignatureMetadata>>() {});
if (wetSigs.size() > WetSignatureMetadata.MAX_SIGNATURES_PER_PARTICIPANT) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Too many wet signatures submitted");
}
request.setWetSignatures(wetSigs);
log.info("Parsed {} wet signatures from wetSignaturesData", wetSigs.size());
} catch (JacksonException e) {
log.error("Failed to parse wetSignaturesData: {}", e.getMessage());
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Invalid wet signatures data");
}
}
// 3. Store wet signatures metadata if provided (supports multiple signatures)
if (request.hasWetSignatures()) {
List<WetSignatureMetadata> wetSigs = request.extractWetSignatureMetadata();
List<Map<String, Object>> wetSignatures = new ArrayList<>();
for (WetSignatureMetadata wetSig : wetSigs) {
Map<String, Object> wetSignature = new HashMap<>();
wetSignature.put("type", wetSig.getType());
wetSignature.put("data", wetSig.getData());
wetSignature.put("page", wetSig.getPage());
wetSignature.put("x", wetSig.getX());
wetSignature.put("y", wetSig.getY());
wetSignature.put("width", wetSig.getWidth());
wetSignature.put("height", wetSig.getHeight());
wetSignatures.add(wetSignature);
}
// Always store as array
metadata.put("wetSignatures", wetSignatures);
log.info(
"Stored {} wet signature(s) metadata for participant {}",
wetSignatures.size(),
user.getUsername());
}
// 4. Store metadata in participant (JPA converter handles JSON serialization)
participant.setParticipantMetadata(metadata);
log.info(
"Stored signature metadata for participant ID {}, email {}: {} wet signatures, cert type: {}",
participant.getId(),
user.getUsername(),
metadata.containsKey("wetSignatures")
? ((List<?>) metadata.get("wetSignatures")).size()
: 0,
((Map<?, ?>) metadata.get("certificateSubmission")).get("certType"));
// 5. Update participant status
participant.setStatus(ParticipantStatus.SIGNED);
workflowParticipantRepository.save(participant);
log.info(
"User {} signed document in session {} - certificate and signature data stored",
user.getUsername(),
sessionId);
}
/**
* Decline a sign request.
*
* @param sessionId The session ID
* @param user The participant user
*/
public void declineSignRequest(String sessionId, User user) {
WorkflowSession session = getSession(sessionId);
WorkflowParticipant participant = getParticipantForUser(session, user);
if (participant.getStatus() == ParticipantStatus.SIGNED) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Cannot decline after signing");
}
participant.setStatus(ParticipantStatus.DECLINED);
workflowParticipantRepository.save(participant); // updatedAt is auto-updated
log.info("User {} declined sign request for session {}", user.getUsername(), sessionId);
}
/**
* Get participant record for a user in a session.
*
* @param session The workflow session
* @param user The user
* @return Participant record
* @throws ResponseStatusException if user is not a participant
*/
private WorkflowParticipant getParticipantForUser(WorkflowSession session, User user) {
return session.getParticipants().stream()
.filter(p -> p.getUser() != null && p.getUser().equals(user))
.findFirst()
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"User is not a participant in this session"));
}
/** Helper class to wrap byte array as MultipartFile. */
private static class ByteArrayMultipartFile implements MultipartFile {
private final byte[] content;
private final String filename;
public ByteArrayMultipartFile(byte[] content, String filename) {
this.content = content;
this.filename = filename;
}
@Override
public String getName() {
return "file";
}
@Override
public String getOriginalFilename() {
return filename;
}
@Override
public String getContentType() {
return "application/pdf";
}
@Override
public boolean isEmpty() {
return content == null || content.length == 0;
}
@Override
public long getSize() {
return content.length;
}
@Override
public byte[] getBytes() {
return content;
}
@Override
public java.io.InputStream getInputStream() {
return new java.io.ByteArrayInputStream(content);
}
@Override
public void transferTo(java.io.File dest) throws IOException {
java.nio.file.Files.write(dest.toPath(), content);
}
}
}
@@ -0,0 +1,169 @@
package stirling.software.proprietary.workflow.util;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.proprietary.workflow.dto.ParticipantResponse;
import stirling.software.proprietary.workflow.dto.WetSignatureMetadata;
import stirling.software.proprietary.workflow.dto.WorkflowSessionResponse;
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
import stirling.software.proprietary.workflow.model.WorkflowSession;
/**
* Utility class for mapping workflow entities to DTOs. Centralizes conversion logic for consistent
* API responses.
*/
public class WorkflowMapper {
/** Converts a WorkflowSession entity to a response DTO. */
public static WorkflowSessionResponse toResponse(WorkflowSession session) {
return toResponse(session, null);
}
/**
* Converts a WorkflowSession entity to a response DTO with optional wet signature extraction.
*
* @param session The workflow session entity
* @param objectMapper ObjectMapper for JSON processing (null to skip wet signature extraction)
* @return WorkflowSessionResponse with participants (and wet signatures if objectMapper
* provided)
*/
public static WorkflowSessionResponse toResponse(
WorkflowSession session, ObjectMapper objectMapper) {
if (session == null) {
return null;
}
WorkflowSessionResponse response = new WorkflowSessionResponse();
response.setSessionId(session.getSessionId());
response.setOwnerId(session.getOwner().getId());
response.setOwnerUsername(session.getOwner().getUsername());
response.setWorkflowType(session.getWorkflowType());
response.setDocumentName(session.getDocumentName());
response.setOwnerEmail(session.getOwnerEmail());
response.setMessage(session.getMessage());
response.setDueDate(session.getDueDate());
response.setStatus(session.getStatus());
response.setFinalized(session.isFinalized());
response.setCreatedAt(session.getCreatedAt());
response.setUpdatedAt(session.getUpdatedAt());
response.setHasProcessedFile(session.hasProcessedFile());
if (session.getOriginalFile() != null) {
response.setOriginalFileId(session.getOriginalFile().getId());
}
if (session.getProcessedFile() != null) {
response.setProcessedFileId(session.getProcessedFile().getId());
}
// Convert participants (with wet signatures if objectMapper provided)
if (objectMapper != null) {
response.setParticipants(
session.getParticipants().stream()
.map(p -> toParticipantResponse(p, objectMapper))
.collect(Collectors.toList()));
} else {
response.setParticipants(
session.getParticipants().stream()
.map(WorkflowMapper::toParticipantResponse)
.collect(Collectors.toList()));
}
// Calculate participant counts
response.setParticipantCount(session.getParticipants().size());
response.setSignedCount(
(int)
session.getParticipants().stream()
.filter(
p ->
p.getStatus()
== stirling.software.proprietary.workflow
.model.ParticipantStatus.SIGNED)
.count());
return response;
}
/** Converts a WorkflowParticipant entity to a response DTO. */
public static ParticipantResponse toParticipantResponse(WorkflowParticipant participant) {
if (participant == null) {
return null;
}
ParticipantResponse response = new ParticipantResponse();
response.setId(participant.getId());
if (participant.getUser() != null) {
response.setUserId(participant.getUser().getId());
}
response.setEmail(participant.getEmail());
response.setName(participant.getName());
response.setStatus(participant.getStatus());
response.setShareToken(participant.getShareToken());
response.setAccessRole(participant.getAccessRole());
response.setExpiresAt(participant.getExpiresAt());
response.setLastUpdated(participant.getLastUpdated());
response.setHasCompleted(participant.hasCompleted());
response.setExpired(
participant.isExpired()); // Lombok generates setExpired() for isExpired field
return response;
}
/**
* Converts a WorkflowParticipant entity to a response DTO with wet signatures extracted.
*
* @param participant The participant entity
* @param objectMapper ObjectMapper for JSON processing
* @return ParticipantResponse with wet signatures included
*/
public static ParticipantResponse toParticipantResponse(
WorkflowParticipant participant, ObjectMapper objectMapper) {
ParticipantResponse response = toParticipantResponse(participant);
if (response != null) {
response.setWetSignatures(extractWetSignatures(participant, objectMapper));
}
return response;
}
/**
* Extracts wet signature metadata from a participant's metadata JSON field.
*
* @param participant The participant entity
* @param objectMapper ObjectMapper for JSON processing
* @return List of wet signatures, empty if none found
*/
private static List<WetSignatureMetadata> extractWetSignatures(
WorkflowParticipant participant, ObjectMapper objectMapper) {
List<WetSignatureMetadata> signatures = new ArrayList<>();
Map<String, Object> metadata = participant.getParticipantMetadata();
if (metadata == null || metadata.isEmpty() || !metadata.containsKey("wetSignatures")) {
return signatures;
}
try {
// Convert metadata to JsonNode for processing
var node = objectMapper.valueToTree(metadata);
if (node.has("wetSignatures")) {
var wetSigsNode = node.get("wetSignatures");
if (wetSigsNode.isArray()) {
for (var wetSigNode : wetSigsNode) {
WetSignatureMetadata wetSig =
objectMapper.treeToValue(wetSigNode, WetSignatureMetadata.class);
signatures.add(wetSig);
}
}
}
} catch (Exception e) {
// Log error but don't fail the entire response
// In production, you might want to use a logger here
return signatures;
}
return signatures;
}
}
@@ -6,7 +6,10 @@ import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.*;
import java.sql.SQLException;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -23,11 +26,23 @@ import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.repository.FileShareAccessRepository;
import stirling.software.proprietary.storage.repository.FileShareRepository;
import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository;
import stirling.software.proprietary.storage.repository.StoredFileRepository;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository;
import stirling.software.proprietary.workflow.service.UserServerCertificateService;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@@ -40,6 +55,14 @@ class UserServiceTest {
@Mock private SessionPersistentRegistry sessionRegistry;
@Mock private DatabaseServiceInterface databaseService;
@Mock private ApplicationProperties.Security.OAUTH2 oAuth2;
@Mock private PersistentLoginRepository persistentLoginRepository;
@Mock private UserServerCertificateService userServerCertificateService;
@Mock private WorkflowParticipantRepository workflowParticipantRepository;
@Mock private WorkflowSessionRepository workflowSessionRepository;
@Mock private StoredFileRepository storedFileRepository;
@Mock private StorageCleanupEntryRepository storageCleanupEntryRepository;
@Mock private FileShareRepository fileShareRepository;
@Mock private FileShareAccessRepository fileShareAccessRepository;
@Spy @InjectMocks private UserService userService;
@@ -185,4 +208,86 @@ class UserServiceTest {
assertFalse(userService.isUsernameValid("ALL_USERS"));
assertTrue(userService.isUsernameValid("[email protected]"));
}
@Test
void deleteUser_withRelatedData_cleansUpInCorrectOrder() {
User user = new User();
user.setId(1L);
user.setUsername("target");
FileShare share = new FileShare();
StoredFile ownedFile = new StoredFile();
ownedFile.setOwner(user);
ownedFile.setStorageKey("key-main");
ownedFile.setHistoryStorageKey("key-history");
Set<FileShare> shares = new HashSet<>();
shares.add(share);
ownedFile.setShares(shares);
WorkflowSession session = new WorkflowSession();
session.setOwner(user);
FileShare inboundShare = new FileShare();
when(userRepository.findByUsernameIgnoreCase("target")).thenReturn(Optional.of(user));
when(workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(user))
.thenReturn(List.of(session));
when(storedFileRepository.findAllByOwner(user)).thenReturn(List.of(ownedFile));
when(fileShareRepository.findBySharedWithUser(user)).thenReturn(List.of(inboundShare));
userService.deleteUser("target");
verify(userServerCertificateService).deleteUserCertificate(1L);
verify(fileShareAccessRepository).deleteByUser(user);
// Inbound share (file shared with this user by others) cleaned up
verify(fileShareAccessRepository).deleteByFileShare(inboundShare);
verify(fileShareRepository).deleteAll(List.of(inboundShare));
// Participant records in others' sessions de-linked (not deleted) to preserve audit trail
verify(workflowParticipantRepository).clearUserReferences(user);
verify(storedFileRepository).clearWorkflowSessionReferencesByOwner(user);
verify(workflowSessionRepository).deleteAll(List.of(session));
verify(fileShareAccessRepository).deleteByFileShare(share);
verify(storedFileRepository).deleteAll(List.of(ownedFile));
verify(userRepository).delete(user);
// Persistent login (remember-me) tokens revoked
verify(persistentLoginRepository).deleteByUsername("target");
// Storage blobs scheduled for physical deletion
verify(storageCleanupEntryRepository, times(2)).save(any());
verify(userService).invalidateUserSessions("target");
}
@Test
void deleteUser_withNoRelatedData_deletesUserSuccessfully() {
User user = new User();
user.setId(2L);
user.setUsername("clean");
when(userRepository.findByUsernameIgnoreCase("clean")).thenReturn(Optional.of(user));
when(workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(user)).thenReturn(List.of());
when(storedFileRepository.findAllByOwner(user)).thenReturn(List.of());
when(fileShareRepository.findBySharedWithUser(user)).thenReturn(List.of());
userService.deleteUser("clean");
verify(userRepository).delete(user);
verify(fileShareAccessRepository, never()).deleteByFileShare(any());
verify(workflowSessionRepository).deleteAll(List.of());
verify(storedFileRepository).deleteAll(List.of());
}
@Test
void deleteUser_internalApiUser_isNotDeleted() {
Authority internalAuth = new Authority();
internalAuth.setAuthority(Role.INTERNAL_API_USER.getRoleId());
User user = new User();
user.setId(3L);
user.setUsername("internal");
user.getAuthorities().add(internalAuth);
when(userRepository.findByUsernameIgnoreCase("internal")).thenReturn(Optional.of(user));
userService.deleteUser("internal");
verify(userRepository, never()).delete(any());
verify(workflowSessionRepository, never()).findByOwnerOrderByCreatedAtDesc(any());
}
}
@@ -0,0 +1,97 @@
package stirling.software.proprietary.storage.converter;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import java.util.Map;
import org.junit.jupiter.api.Test;
class JsonMapConverterTest {
private final JsonMapConverter converter = new JsonMapConverter();
// -------------------------------------------------------------------------
// convertToDatabaseColumn
// -------------------------------------------------------------------------
@Test
void convertToDatabaseColumn_nullMap_returnsNull() {
assertThat(converter.convertToDatabaseColumn(null)).isNull();
}
@Test
void convertToDatabaseColumn_emptyMap_returnsNull() {
assertThat(converter.convertToDatabaseColumn(Map.of())).isNull();
}
@Test
void convertToDatabaseColumn_singleEntry_producesValidJson() {
String json = converter.convertToDatabaseColumn(Map.of("key", "value"));
assertThat(json).contains("\"key\"").contains("\"value\"");
}
@Test
void convertToDatabaseColumn_mapWithMixedTypes_roundTrips() {
Map<String, Object> input = Map.of("str", "hello", "num", 42);
String json = converter.convertToDatabaseColumn(input);
Map<String, Object> result = converter.convertToEntityAttribute(json);
assertThat(result.get("str")).isEqualTo("hello");
assertThat(result.get("num")).isEqualTo(42);
}
// -------------------------------------------------------------------------
// convertToEntityAttribute normal paths
// -------------------------------------------------------------------------
@Test
void convertToEntityAttribute_nullInput_returnsEmptyMap() {
assertThat(converter.convertToEntityAttribute(null)).isEmpty();
}
@Test
void convertToEntityAttribute_blankInput_returnsEmptyMap() {
assertThat(converter.convertToEntityAttribute(" ")).isEmpty();
}
@Test
void convertToEntityAttribute_validJson_restoresMap() {
Map<String, Object> result = converter.convertToEntityAttribute("{\"foo\":\"bar\"}");
assertThat(result).containsEntry("foo", "bar");
}
@Test
void convertToEntityAttribute_nestedObject_preservesStructure() {
String json = "{\"outer\":{\"inner\":\"value\"}}";
Map<String, Object> result = converter.convertToEntityAttribute(json);
assertThat(result).containsKey("outer");
}
// -------------------------------------------------------------------------
// convertToEntityAttribute legacy double-encoded fallback
// -------------------------------------------------------------------------
@Test
void convertToEntityAttribute_doubleEncodedJson_fallbackRecovery() {
// A JSON string node whose text content is itself valid JSON
String doubleEncoded = "\"{\\\"foo\\\":\\\"bar\\\"}\"";
Map<String, Object> result = converter.convertToEntityAttribute(doubleEncoded);
assertThat(result).containsEntry("foo", "bar");
}
// -------------------------------------------------------------------------
// convertToEntityAttribute malformed input
// -------------------------------------------------------------------------
@Test
void convertToEntityAttribute_completelyMalformed_returnsEmptyMap() {
Map<String, Object> result = converter.convertToEntityAttribute("not-json-at-all");
assertThat(result).isEmpty();
}
@Test
void convertToEntityAttribute_malformedJson_doesNotThrow() {
assertThatCode(() -> converter.convertToEntityAttribute("{broken"))
.doesNotThrowAnyException();
}
}
@@ -0,0 +1,573 @@
package stirling.software.proprietary.storage.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.ShareAccessRole;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.provider.StoredObject;
import stirling.software.proprietary.storage.repository.FileShareAccessRepository;
import stirling.software.proprietary.storage.repository.FileShareRepository;
import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository;
import stirling.software.proprietary.storage.repository.StoredFileRepository;
import stirling.software.proprietary.workflow.model.WorkflowSession;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class FileStorageServiceTest {
@Mock private StoredFileRepository storedFileRepository;
@Mock private FileShareRepository fileShareRepository;
@Mock private FileShareAccessRepository fileShareAccessRepository;
@Mock private UserRepository userRepository;
@Mock private ApplicationProperties applicationProperties;
@Mock private StorageProvider storageProvider;
@Mock private StorageCleanupEntryRepository storageCleanupEntryRepository;
@Mock private ApplicationProperties.Security securityProperties;
@Mock private ApplicationProperties.System systemProperties;
@Mock private ApplicationProperties.Storage storageProperties;
@Mock private ApplicationProperties.Storage.Sharing sharingProperties;
@Mock private ApplicationProperties.Storage.Quotas quotasProperties;
private FileStorageService service;
@BeforeEach
void setUp() {
service =
new FileStorageService(
storedFileRepository,
fileShareRepository,
fileShareAccessRepository,
userRepository,
applicationProperties,
storageProvider,
Optional.empty(),
storageCleanupEntryRepository);
// Default: storage and sharing fully enabled, share links enabled, no expiry
when(applicationProperties.getSecurity()).thenReturn(securityProperties);
when(securityProperties.isEnableLogin()).thenReturn(true);
when(applicationProperties.getStorage()).thenReturn(storageProperties);
when(storageProperties.isEnabled()).thenReturn(true);
when(storageProperties.getSharing()).thenReturn(sharingProperties);
when(sharingProperties.isEnabled()).thenReturn(true);
when(sharingProperties.isLinkEnabled()).thenReturn(true);
when(sharingProperties.getLinkExpirationDays()).thenReturn(0);
when(applicationProperties.getSystem()).thenReturn(systemProperties);
when(systemProperties.getFrontendUrl()).thenReturn("http://localhost:8080");
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private User user(long id) {
User u = new User();
u.setId(id);
u.setUsername("user" + id);
return u;
}
private StoredFile ownedFile(User owner) {
StoredFile f = new StoredFile();
f.setId(100L);
f.setOwner(owner);
f.setOriginalFilename("test.pdf");
return f;
}
private FileShare shareFor(StoredFile file, User user, ShareAccessRole role) {
FileShare s = new FileShare();
s.setFile(file);
s.setSharedWithUser(user);
s.setAccessRole(role);
return s;
}
// -------------------------------------------------------------------------
// getAccessibleFile
// -------------------------------------------------------------------------
@Test
void getAccessibleFile_owner_returnsFile() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
when(storedFileRepository.findByIdWithShares(100L)).thenReturn(Optional.of(f));
assertThat(service.getAccessibleFile(owner, 100L)).isSameAs(f);
}
@Test
void getAccessibleFile_sharedUser_returnsFile() {
User owner = user(1L);
User requester = user(2L);
StoredFile f = ownedFile(owner);
f.getShares().add(shareFor(f, requester, ShareAccessRole.VIEWER));
when(storedFileRepository.findByIdWithShares(100L)).thenReturn(Optional.of(f));
assertThat(service.getAccessibleFile(requester, 100L)).isSameAs(f);
}
@Test
void getAccessibleFile_noAccess_throwsForbidden() {
User owner = user(1L);
User requester = user(2L);
StoredFile f = ownedFile(owner);
when(storedFileRepository.findByIdWithShares(100L)).thenReturn(Optional.of(f));
assertThatThrownBy(() -> service.getAccessibleFile(requester, 100L))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(403);
}
@Test
void getAccessibleFile_fileNotFound_throwsNotFound() {
User owner = user(1L);
when(storedFileRepository.findByIdWithShares(999L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.getAccessibleFile(owner, 999L))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(404);
}
// -------------------------------------------------------------------------
// requireEditorAccess
// -------------------------------------------------------------------------
@Test
void requireEditorAccess_owner_passes() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
service.requireEditorAccess(owner, f);
}
@Test
void requireEditorAccess_editorShare_passes() {
User owner = user(1L);
User requester = user(2L);
StoredFile f = ownedFile(owner);
FileShare share = shareFor(f, requester, ShareAccessRole.EDITOR);
when(fileShareRepository.findByFileAndSharedWithUser(f, requester))
.thenReturn(Optional.of(share));
service.requireEditorAccess(requester, f);
}
@Test
void requireEditorAccess_viewerShare_throwsForbidden() {
User owner = user(1L);
User requester = user(2L);
StoredFile f = ownedFile(owner);
FileShare share = shareFor(f, requester, ShareAccessRole.VIEWER);
when(fileShareRepository.findByFileAndSharedWithUser(f, requester))
.thenReturn(Optional.of(share));
assertThatThrownBy(() -> service.requireEditorAccess(requester, f))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(403);
}
@Test
void requireEditorAccess_noShare_throwsForbidden() {
User owner = user(1L);
User requester = user(2L);
StoredFile f = ownedFile(owner);
when(fileShareRepository.findByFileAndSharedWithUser(f, requester))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> service.requireEditorAccess(requester, f))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(403);
}
// -------------------------------------------------------------------------
// requireReadAccess
// -------------------------------------------------------------------------
@Test
void requireReadAccess_owner_passes() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
service.requireReadAccess(owner, f);
}
@Test
void requireReadAccess_viewerShare_passes() {
User owner = user(1L);
User requester = user(2L);
StoredFile f = ownedFile(owner);
FileShare share = shareFor(f, requester, ShareAccessRole.VIEWER);
when(fileShareRepository.findByFileAndSharedWithUser(f, requester))
.thenReturn(Optional.of(share));
service.requireReadAccess(requester, f);
}
// -------------------------------------------------------------------------
// shareWithUser
// -------------------------------------------------------------------------
@Test
void shareWithUser_newShare_created() {
User owner = user(1L);
User target = user(2L);
StoredFile f = ownedFile(owner);
when(userRepository.findByUsernameIgnoreCase("user2")).thenReturn(Optional.of(target));
when(fileShareRepository.findByFileAndSharedWithUser(f, target))
.thenReturn(Optional.empty());
when(fileShareRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
FileShare result = service.shareWithUser(owner, f, "user2", ShareAccessRole.VIEWER);
assertThat(result.getSharedWithUser()).isEqualTo(target);
assertThat(result.getAccessRole()).isEqualTo(ShareAccessRole.VIEWER);
verify(fileShareRepository).save(any(FileShare.class));
}
@Test
void shareWithUser_existingShare_updatesRole() {
User owner = user(1L);
User target = user(2L);
StoredFile f = ownedFile(owner);
FileShare existing = shareFor(f, target, ShareAccessRole.VIEWER);
when(userRepository.findByUsernameIgnoreCase("user2")).thenReturn(Optional.of(target));
when(fileShareRepository.findByFileAndSharedWithUser(f, target))
.thenReturn(Optional.of(existing));
when(fileShareRepository.save(existing)).thenReturn(existing);
service.shareWithUser(owner, f, "user2", ShareAccessRole.EDITOR);
assertThat(existing.getAccessRole()).isEqualTo(ShareAccessRole.EDITOR);
verify(fileShareRepository).save(existing);
}
@Test
void shareWithUser_selfShare_throwsBadRequest() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
when(userRepository.findByUsernameIgnoreCase("user1")).thenReturn(Optional.of(owner));
assertThatThrownBy(() -> service.shareWithUser(owner, f, "user1", ShareAccessRole.VIEWER))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
void shareWithUser_nonOwner_throwsForbidden() {
User owner = user(1L);
User nonOwner = user(2L);
StoredFile f = ownedFile(owner);
assertThatThrownBy(
() -> service.shareWithUser(nonOwner, f, "user1", ShareAccessRole.VIEWER))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(403);
}
// -------------------------------------------------------------------------
// revokeUserShare
// -------------------------------------------------------------------------
@Test
void revokeUserShare_owner_removesShare() {
User owner = user(1L);
User target = user(2L);
StoredFile f = ownedFile(owner);
FileShare share = shareFor(f, target, ShareAccessRole.VIEWER);
when(userRepository.findByUsernameIgnoreCase("user2")).thenReturn(Optional.of(target));
when(fileShareRepository.findByFileAndSharedWithUser(f, target))
.thenReturn(Optional.of(share));
service.revokeUserShare(owner, f, "user2");
verify(fileShareRepository).delete(share);
}
@Test
void revokeUserShare_shareNotFound_silentSuccess() {
User owner = user(1L);
User target = user(2L);
StoredFile f = ownedFile(owner);
when(userRepository.findByUsernameIgnoreCase("user2")).thenReturn(Optional.of(target));
when(fileShareRepository.findByFileAndSharedWithUser(f, target))
.thenReturn(Optional.empty());
service.revokeUserShare(owner, f, "user2");
verify(fileShareRepository, never()).delete(any());
}
@Test
void revokeUserShare_nonOwner_throwsForbidden() {
User owner = user(1L);
User nonOwner = user(2L);
StoredFile f = ownedFile(owner);
assertThatThrownBy(() -> service.revokeUserShare(nonOwner, f, "user2"))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(403);
}
// -------------------------------------------------------------------------
// createShareLink
// -------------------------------------------------------------------------
@Test
void createShareLink_owner_tokenGenerated() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
when(fileShareRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
FileShare result = service.createShareLink(owner, f, ShareAccessRole.VIEWER);
assertThat(result.getShareToken()).isNotNull();
assertThat(result.getAccessRole()).isEqualTo(ShareAccessRole.VIEWER);
verify(fileShareRepository).save(any(FileShare.class));
}
@Test
void createShareLink_nonOwner_throwsForbidden() {
User owner = user(1L);
User nonOwner = user(2L);
StoredFile f = ownedFile(owner);
assertThatThrownBy(() -> service.createShareLink(nonOwner, f, ShareAccessRole.VIEWER))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(403);
}
// -------------------------------------------------------------------------
// revokeShareLink
// -------------------------------------------------------------------------
@Test
void revokeShareLink_owner_validToken_deletesShareAndAccessRecords() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
FileShare share = shareFor(f, null, ShareAccessRole.VIEWER);
share.setShareToken("test-token");
when(fileShareRepository.findByShareToken("test-token")).thenReturn(Optional.of(share));
service.revokeShareLink(owner, f, "test-token");
verify(fileShareAccessRepository).deleteByFileShare(share);
verify(fileShareRepository).delete(share);
}
@Test
void revokeShareLink_tokenBelongsToOtherFile_throwsForbidden() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
f.setId(1L);
StoredFile otherFile = ownedFile(owner);
otherFile.setId(2L);
FileShare share = shareFor(otherFile, null, ShareAccessRole.VIEWER);
share.setShareToken("token");
when(fileShareRepository.findByShareToken("token")).thenReturn(Optional.of(share));
assertThatThrownBy(() -> service.revokeShareLink(owner, f, "token"))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(403);
}
@Test
void revokeShareLink_tokenNotFound_throwsNotFound() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
when(fileShareRepository.findByShareToken("unknown")).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.revokeShareLink(owner, f, "unknown"))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(404);
}
// -------------------------------------------------------------------------
// Storage quota enforcement (via storeFile / replaceFile public API)
// -------------------------------------------------------------------------
@Test
void storeFile_nullQuotas_passes() throws IOException {
when(storageProperties.getQuotas()).thenReturn(null);
User owner = user(1L);
MockMultipartFile file =
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[] {1});
when(storageProvider.store(any(), any()))
.thenReturn(
StoredObject.builder()
.storageKey("k")
.originalFilename("test.pdf")
.contentType("application/pdf")
.sizeBytes(1L)
.build());
when(storedFileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.storeFile(owner, file);
verify(storageProvider).store(owner, file);
}
@Test
void storeFile_fileTooLarge_throwsPayloadTooLarge() {
when(storageProperties.getQuotas()).thenReturn(quotasProperties);
when(quotasProperties.getMaxFileMb()).thenReturn(1L); // 1 MB limit
when(quotasProperties.getMaxStorageMbPerUser()).thenReturn(-1L);
when(quotasProperties.getMaxStorageMbTotal()).thenReturn(-1L);
User owner = user(1L);
// 2 MB file exceeds the 1 MB limit
MockMultipartFile file =
new MockMultipartFile(
"file", "big.pdf", "application/pdf", new byte[2 * 1024 * 1024]);
assertThatThrownBy(() -> service.storeFile(owner, file))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(413);
}
@Test
void storeFile_perUserQuotaExceeded_throwsPayloadTooLarge() {
when(storageProperties.getQuotas()).thenReturn(quotasProperties);
when(quotasProperties.getMaxFileMb()).thenReturn(-1L);
when(quotasProperties.getMaxStorageMbPerUser()).thenReturn(10L); // 10 MB per-user cap
when(quotasProperties.getMaxStorageMbTotal()).thenReturn(-1L);
User owner = user(1L);
// user already has 9 MB stored; a 2 MB upload pushes to 11 MB > 10 MB cap
when(storedFileRepository.sumStorageBytesByOwner(owner)).thenReturn(9L * 1024 * 1024);
MockMultipartFile file =
new MockMultipartFile(
"file", "f.pdf", "application/pdf", new byte[2 * 1024 * 1024]);
assertThatThrownBy(() -> service.storeFile(owner, file))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(413);
}
@Test
void storeFile_globalQuotaExceeded_throwsPayloadTooLarge() {
when(storageProperties.getQuotas()).thenReturn(quotasProperties);
when(quotasProperties.getMaxFileMb()).thenReturn(-1L);
when(quotasProperties.getMaxStorageMbPerUser()).thenReturn(-1L);
when(quotasProperties.getMaxStorageMbTotal()).thenReturn(100L); // 100 MB global cap
User owner = user(1L);
// system already has 99 MB; a 2 MB upload pushes to 101 MB > 100 MB cap
when(storedFileRepository.sumStorageBytesTotal()).thenReturn(99L * 1024 * 1024);
MockMultipartFile file =
new MockMultipartFile(
"file", "f.pdf", "application/pdf", new byte[2 * 1024 * 1024]);
assertThatThrownBy(() -> service.storeFile(owner, file))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(413);
}
@Test
void replaceFile_replacementShrinks_skipsPerUserAndGlobalCheck() throws IOException {
when(storageProperties.getQuotas()).thenReturn(quotasProperties);
when(quotasProperties.getMaxFileMb()).thenReturn(-1L);
User owner = user(1L);
// existing file is 5 MB; replacement is 1 MB delta 0 quota repos never queried
StoredFile existing = ownedFile(owner);
existing.setSizeBytes(5L * 1024 * 1024);
existing.setStorageKey("old-key");
MockMultipartFile newFile =
new MockMultipartFile(
"file", "small.pdf", "application/pdf", new byte[1 * 1024 * 1024]);
when(storageProvider.store(any(), any()))
.thenReturn(
StoredObject.builder()
.storageKey("new-key")
.originalFilename("small.pdf")
.contentType("application/pdf")
.sizeBytes(1L * 1024 * 1024)
.build());
when(storedFileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.replaceFile(owner, existing, newFile);
verify(storedFileRepository, never()).sumStorageBytesByOwner(any());
verify(storedFileRepository, never()).sumStorageBytesTotal();
}
// -------------------------------------------------------------------------
// deleteFile workflow guard
// -------------------------------------------------------------------------
@Test
void deleteFile_fileInActiveWorkflow_throwsBadRequest() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
WorkflowSession session = mock(WorkflowSession.class);
when(session.isActive()).thenReturn(true);
f.setWorkflowSession(session);
assertThatThrownBy(() -> service.deleteFile(owner, f))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
verify(storedFileRepository, never()).delete(any());
}
@Test
void deleteFile_fileNotInAnyWorkflow_deletesSuccessfully() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
// no workflow session set
when(fileShareRepository.findShareLinks(f)).thenReturn(List.of());
service.deleteFile(owner, f);
verify(storedFileRepository).delete(f);
}
@Test
void deleteFile_fileInCompletedWorkflow_deletesSuccessfully() {
User owner = user(1L);
StoredFile f = ownedFile(owner);
WorkflowSession session = mock(WorkflowSession.class);
when(session.isActive()).thenReturn(false);
f.setWorkflowSession(session);
when(fileShareRepository.findShareLinks(f)).thenReturn(List.of());
service.deleteFile(owner, f);
verify(storedFileRepository).delete(f);
}
}
@@ -0,0 +1,115 @@
package stirling.software.proprietary.workflow.dto;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.Test;
class WetSignatureMetadataTest {
private static WetSignatureMetadata canvas(double x, double y, double w, double h) {
return new WetSignatureMetadata("canvas", "data:image/png;base64,abc==", 0, x, y, w, h);
}
// -------------------------------------------------------------------------
// validate() image data prefix
// -------------------------------------------------------------------------
@Test
void validate_canvas_withDataImagePrefix_passes() {
assertThatCode(() -> canvas(0.0, 0.0, 0.5, 0.5).validate()).doesNotThrowAnyException();
}
@Test
void validate_image_withDataImagePrefix_passes() {
WetSignatureMetadata sig =
new WetSignatureMetadata(
"image", "data:image/jpeg;base64,xyz==", 0, 0.1, 0.1, 0.3, 0.3);
assertThatCode(sig::validate).doesNotThrowAnyException();
}
@Test
void validate_canvas_withoutDataImagePrefix_throws() {
WetSignatureMetadata sig =
new WetSignatureMetadata(
"canvas", "raw-base64-without-prefix", 0, 0.0, 0.0, 0.5, 0.5);
assertThatThrownBy(sig::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("data:image/");
}
@Test
void validate_text_doesNotRequireDataImagePrefix() {
WetSignatureMetadata sig =
new WetSignatureMetadata("text", "John Doe", 0, 0.1, 0.1, 0.3, 0.2);
assertThatCode(sig::validate).doesNotThrowAnyException();
}
// -------------------------------------------------------------------------
// validate() cross-field boundary checks (x + width, y + height)
// -------------------------------------------------------------------------
@Test
void validate_xPlusWidthExactlyOne_passes() {
assertThatCode(() -> canvas(0.5, 0.0, 0.5, 0.5).validate()).doesNotThrowAnyException();
}
@Test
void validate_xPlusWidthExceedsOne_throws() {
assertThatThrownBy(() -> canvas(0.6, 0.0, 0.5, 0.5).validate())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("right edge");
}
@Test
void validate_yPlusHeightExactlyOne_passes() {
assertThatCode(() -> canvas(0.0, 0.5, 0.5, 0.5).validate()).doesNotThrowAnyException();
}
@Test
void validate_yPlusHeightExceedsOne_throws() {
assertThatThrownBy(() -> canvas(0.0, 0.6, 0.5, 0.5).validate())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("bottom edge");
}
@Test
void validate_originWithFullPageSize_passes() {
assertThatCode(() -> canvas(0.0, 0.0, 1.0, 1.0).validate()).doesNotThrowAnyException();
}
// -------------------------------------------------------------------------
// MAX_SIGNATURES_PER_PARTICIPANT constant
// -------------------------------------------------------------------------
@Test
void maxSignaturesConstant_isPositive() {
assertThat(WetSignatureMetadata.MAX_SIGNATURES_PER_PARTICIPANT).isGreaterThan(0);
}
// -------------------------------------------------------------------------
// extractBase64Data()
// -------------------------------------------------------------------------
@Test
void extractBase64Data_stripsDataUrlPrefix() {
WetSignatureMetadata sig = canvas(0.0, 0.0, 0.5, 0.5);
sig.setData("data:image/png;base64,iVBORw0KGgo=");
assertThat(sig.extractBase64Data()).isEqualTo("iVBORw0KGgo=");
}
@Test
void extractBase64Data_noComma_returnsDataUnchanged() {
WetSignatureMetadata sig = canvas(0.0, 0.0, 0.5, 0.5);
sig.setData("plainbase64withoutcomma");
assertThat(sig.extractBase64Data()).isEqualTo("plainbase64withoutcomma");
}
@Test
void extractBase64Data_null_returnsNull() {
WetSignatureMetadata sig = canvas(0.0, 0.0, 0.5, 0.5);
sig.setData(null);
assertThat(sig.extractBase64Data()).isNull();
}
}
@@ -0,0 +1,123 @@
package stirling.software.proprietary.workflow.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.AutomaticallyGenerated;
class MetadataEncryptionServiceTest {
private MetadataEncryptionService service;
@BeforeEach
void setUp() {
service = serviceWithKey("test-encryption-key-for-unit-tests-only");
}
private static MetadataEncryptionService serviceWithKey(String key) {
AutomaticallyGenerated generated = new AutomaticallyGenerated();
generated.setKey(key);
ApplicationProperties props = new ApplicationProperties();
props.setAutomaticallyGenerated(generated);
return new MetadataEncryptionService(props);
}
// -------------------------------------------------------------------------
// Null / empty passthrough
// -------------------------------------------------------------------------
@Test
void encrypt_null_returnsNull() {
assertThat(service.encrypt(null)).isNull();
}
@Test
void decrypt_null_returnsNull() {
assertThat(service.decrypt(null)).isNull();
}
// -------------------------------------------------------------------------
// Legacy plaintext backwards-compatibility
// -------------------------------------------------------------------------
@Test
void decrypt_plaintextWithoutPrefix_returnsUnchanged() {
assertThat(service.decrypt("plaintext-password")).isEqualTo("plaintext-password");
}
@Test
void decrypt_emptyStringWithoutPrefix_returnsUnchanged() {
assertThat(service.decrypt("")).isEqualTo("");
}
// -------------------------------------------------------------------------
// Encrypt / decrypt round-trip
// -------------------------------------------------------------------------
@Test
void encrypt_producesEncPrefix() {
String encrypted = service.encrypt("secret");
assertThat(encrypted).startsWith(MetadataEncryptionService.ENC_PREFIX);
}
@Test
void roundTrip_restoresOriginalValue() {
String original = "my-keystore-password";
String encrypted = service.encrypt(original);
assertThat(service.decrypt(encrypted)).isEqualTo(original);
}
@Test
void roundTrip_emptyString() {
String encrypted = service.encrypt("");
assertThat(service.decrypt(encrypted)).isEqualTo("");
}
@Test
void roundTrip_specialCharactersAndUnicode() {
String original = "p@$$w0rd!£€#\u00e9";
assertThat(service.decrypt(service.encrypt(original))).isEqualTo(original);
}
// -------------------------------------------------------------------------
// IV randomisation each call must produce a distinct ciphertext
// -------------------------------------------------------------------------
@Test
void encrypt_sameInput_producesDifferentCiphertexts() {
String a = service.encrypt("same-value");
String b = service.encrypt("same-value");
assertThat(a).isNotEqualTo(b);
}
// -------------------------------------------------------------------------
// Key dependency different keys must produce different ciphertexts
// -------------------------------------------------------------------------
@Test
void encrypt_differentKey_producesIncompatibleCiphertext() {
MetadataEncryptionService otherService = serviceWithKey("completely-different-key");
String encryptedByOther = otherService.encrypt("secret");
// The original service cannot decrypt what the other service encrypted
assertThatThrownBy(() -> service.decrypt(encryptedByOther))
.isInstanceOf(IllegalStateException.class);
}
// -------------------------------------------------------------------------
// Missing key guard
// -------------------------------------------------------------------------
@Test
void encrypt_missingKey_throwsIllegalState() {
MetadataEncryptionService noKeyService = serviceWithKey(null);
assertThatThrownBy(() -> noKeyService.encrypt("anything"))
.isInstanceOf(IllegalStateException.class);
}
}
@@ -0,0 +1,183 @@
package stirling.software.proprietary.workflow.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfSigningService;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.proprietary.workflow.model.ParticipantStatus;
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import tools.jackson.databind.ObjectMapper;
@ExtendWith(MockitoExtension.class)
class SigningFinalizationServiceTest {
@Mock private WorkflowParticipantRepository participantRepository;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private ObjectMapper objectMapper;
@Mock private PdfSigningService pdfSigningService;
@Mock private MetadataEncryptionService metadataEncryptionService;
@Mock private ServerCertificateServiceInterface serverCertificateService;
@Mock private UserServerCertificateService userServerCertificateService;
@InjectMocks private SigningFinalizationService service;
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private WorkflowSession sessionWithParticipants(WorkflowParticipant... participants) {
WorkflowSession session = new WorkflowSession();
session.setSessionId("test-session");
List<WorkflowParticipant> list = new ArrayList<>();
for (WorkflowParticipant p : participants) {
list.add(p);
}
session.setParticipants(list);
return session;
}
private WorkflowParticipant participantWithMetadata(Map<String, Object> metadata) {
WorkflowParticipant p = new WorkflowParticipant();
p.setStatus(ParticipantStatus.SIGNED);
p.setEmail("[email protected]");
p.setParticipantMetadata(new HashMap<>(metadata));
return p;
}
// -------------------------------------------------------------------------
// clearSensitiveMetadata individual key removal
// -------------------------------------------------------------------------
@Test
void clearSensitiveMetadata_removesWetSignaturesKey() {
WorkflowParticipant p =
participantWithMetadata(Map.of("wetSignatures", List.of("sig1"), "other", "keep"));
WorkflowSession session = sessionWithParticipants(p);
service.clearSensitiveMetadata(session);
assertThat(p.getParticipantMetadata()).doesNotContainKey("wetSignatures");
assertThat(p.getParticipantMetadata()).containsKey("other");
verify(participantRepository, times(1)).save(p);
}
@Test
void clearSensitiveMetadata_removesCertificateSubmissionKey() {
WorkflowParticipant p =
participantWithMetadata(
Map.of("certificateSubmission", Map.of("certType", "SERVER")));
WorkflowSession session = sessionWithParticipants(p);
service.clearSensitiveMetadata(session);
assertThat(p.getParticipantMetadata()).doesNotContainKey("certificateSubmission");
verify(participantRepository, times(1)).save(p);
}
@Test
void clearSensitiveMetadata_removesBothKeys_savesOnce() {
Map<String, Object> metadata = new HashMap<>();
metadata.put("wetSignatures", List.of("sig1"));
metadata.put("certificateSubmission", Map.of("certType", "SERVER"));
metadata.put("showLogo", true);
WorkflowParticipant p = participantWithMetadata(metadata);
WorkflowSession session = sessionWithParticipants(p);
service.clearSensitiveMetadata(session);
assertThat(p.getParticipantMetadata()).doesNotContainKey("wetSignatures");
assertThat(p.getParticipantMetadata()).doesNotContainKey("certificateSubmission");
assertThat(p.getParticipantMetadata()).containsKey("showLogo");
verify(participantRepository, times(1)).save(p);
}
// -------------------------------------------------------------------------
// clearSensitiveMetadata no-op cases (save must NOT be called)
// -------------------------------------------------------------------------
@Test
void clearSensitiveMetadata_noSensitiveKeys_doesNotSave() {
WorkflowParticipant p = participantWithMetadata(Map.of("showLogo", true, "pageNumber", 1));
WorkflowSession session = sessionWithParticipants(p);
service.clearSensitiveMetadata(session);
verify(participantRepository, never()).save(any());
}
@Test
void clearSensitiveMetadata_nullMetadata_doesNotSave() {
WorkflowParticipant p = new WorkflowParticipant();
p.setStatus(ParticipantStatus.SIGNED);
p.setParticipantMetadata(null);
WorkflowSession session = sessionWithParticipants(p);
service.clearSensitiveMetadata(session);
verify(participantRepository, never()).save(any());
}
@Test
void clearSensitiveMetadata_emptyMetadata_doesNotSave() {
WorkflowParticipant p = participantWithMetadata(Map.of());
WorkflowSession session = sessionWithParticipants(p);
service.clearSensitiveMetadata(session);
verify(participantRepository, never()).save(any());
}
// -------------------------------------------------------------------------
// clearSensitiveMetadata multiple participants
// -------------------------------------------------------------------------
@Test
void clearSensitiveMetadata_multipleParticipants_allWithSensitiveData_allCleared() {
WorkflowParticipant p1 = participantWithMetadata(Map.of("wetSignatures", List.of("s1")));
WorkflowParticipant p2 =
participantWithMetadata(Map.of("certificateSubmission", Map.of("k", "v")));
WorkflowParticipant p3 =
participantWithMetadata(Map.of("wetSignatures", List.of("s3"), "extra", "keep"));
WorkflowSession session = sessionWithParticipants(p1, p2, p3);
service.clearSensitiveMetadata(session);
assertThat(p1.getParticipantMetadata()).doesNotContainKey("wetSignatures");
assertThat(p2.getParticipantMetadata()).doesNotContainKey("certificateSubmission");
assertThat(p3.getParticipantMetadata()).doesNotContainKey("wetSignatures");
assertThat(p3.getParticipantMetadata()).containsKey("extra");
verify(participantRepository, times(3)).save(any());
}
@Test
void clearSensitiveMetadata_mixedParticipants_onlySavesModified() {
WorkflowParticipant withSensitive =
participantWithMetadata(Map.of("wetSignatures", List.of("s1")));
WorkflowParticipant withoutSensitive = participantWithMetadata(Map.of("showLogo", false));
WorkflowSession session = sessionWithParticipants(withSensitive, withoutSensitive);
service.clearSensitiveMetadata(session);
verify(participantRepository, times(1)).save(withSensitive);
verify(participantRepository, never()).save(withoutSensitive);
}
}
@@ -0,0 +1,337 @@
package stirling.software.proprietary.workflow.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.ShareAccessRole;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.repository.FileShareRepository;
import stirling.software.proprietary.workflow.model.ParticipantStatus;
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.model.WorkflowStatus;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import stirling.software.proprietary.workflow.service.UnifiedAccessControlService.AccessValidationResult;
@ExtendWith(MockitoExtension.class)
class UnifiedAccessControlServiceTest {
@Mock private FileShareRepository fileShareRepository;
@Mock private WorkflowParticipantRepository workflowParticipantRepository;
@InjectMocks private UnifiedAccessControlService service;
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private User user(long id) {
User u = new User();
u.setId(id);
u.setUsername("user" + id);
return u;
}
private FileShare share(StoredFile file, User sharedWith, LocalDateTime expiresAt) {
FileShare s = new FileShare();
s.setFile(file);
s.setShareToken("tok-" + System.nanoTime());
s.setAccessRole(ShareAccessRole.EDITOR);
s.setSharedWithUser(sharedWith);
s.setExpiresAt(expiresAt);
return s;
}
private WorkflowParticipant participant(
User user, ParticipantStatus status, boolean sessionActive, LocalDateTime expiresAt) {
WorkflowSession session = new WorkflowSession();
session.setStatus(sessionActive ? WorkflowStatus.IN_PROGRESS : WorkflowStatus.COMPLETED);
session.setFinalized(false);
StoredFile file = new StoredFile();
session.setOriginalFile(file);
WorkflowParticipant p = new WorkflowParticipant();
p.setUser(user);
p.setStatus(status);
p.setAccessRole(ShareAccessRole.EDITOR);
p.setExpiresAt(expiresAt);
p.setWorkflowSession(session);
return p;
}
// -------------------------------------------------------------------------
// validateToken file share branch
// -------------------------------------------------------------------------
@Test
void validateToken_genericShare_noExpiry_noUserRestriction_allowed() {
StoredFile file = new StoredFile();
FileShare s = share(file, null, null);
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.of(s));
AccessValidationResult result = service.validateToken("tok", null);
assertThat(result.isAllowed()).isTrue();
assertThat(result.isWorkflowAccess()).isFalse();
}
@Test
void validateToken_genericShare_expired_denied() {
StoredFile file = new StoredFile();
FileShare s = share(file, null, LocalDateTime.now().minusSeconds(1));
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.of(s));
AccessValidationResult result = service.validateToken("tok", null);
assertThat(result.isAllowed()).isFalse();
assertThat(result.getDenialReason()).containsIgnoringCase("expired");
}
@Test
void validateToken_userSpecificShare_wrongUser_denied() {
User owner = user(1L);
User requester = user(2L);
StoredFile file = new StoredFile();
FileShare s = share(file, owner, null);
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.of(s));
AccessValidationResult result = service.validateToken("tok", requester);
assertThat(result.isAllowed()).isFalse();
assertThat(result.getDenialReason()).containsIgnoringCase("denied");
}
@Test
void validateToken_userSpecificShare_correctUser_allowed() {
User u = user(1L);
StoredFile file = new StoredFile();
FileShare s = share(file, u, null);
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.of(s));
AccessValidationResult result = service.validateToken("tok", u);
assertThat(result.isAllowed()).isTrue();
}
// -------------------------------------------------------------------------
// validateToken workflow participant branch
// -------------------------------------------------------------------------
@Test
void validateToken_participantToken_notFileShare_activeParticipant_allowed() {
User u = user(1L);
WorkflowParticipant p = participant(u, ParticipantStatus.PENDING, true, null);
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.empty());
when(workflowParticipantRepository.findByShareToken("tok")).thenReturn(Optional.of(p));
AccessValidationResult result = service.validateToken("tok", u);
assertThat(result.isAllowed()).isTrue();
assertThat(result.isWorkflowAccess()).isTrue();
}
@Test
void validateToken_participantToken_expired_denied() {
User u = user(1L);
WorkflowParticipant p =
participant(
u, ParticipantStatus.PENDING, true, LocalDateTime.now().minusSeconds(1));
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.empty());
when(workflowParticipantRepository.findByShareToken("tok")).thenReturn(Optional.of(p));
AccessValidationResult result = service.validateToken("tok", u);
assertThat(result.isAllowed()).isFalse();
assertThat(result.getDenialReason()).containsIgnoringCase("expired");
}
@Test
void validateToken_participantToken_sessionInactive_denied() {
User u = user(1L);
WorkflowParticipant p = participant(u, ParticipantStatus.PENDING, false, null);
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.empty());
when(workflowParticipantRepository.findByShareToken("tok")).thenReturn(Optional.of(p));
AccessValidationResult result = service.validateToken("tok", u);
assertThat(result.isAllowed()).isFalse();
assertThat(result.getDenialReason()).containsIgnoringCase("no longer active");
}
@Test
void validateToken_participantToken_wrongUser_denied() {
User owner = user(1L);
User requester = user(2L);
WorkflowParticipant p = participant(owner, ParticipantStatus.PENDING, true, null);
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.empty());
when(workflowParticipantRepository.findByShareToken("tok")).thenReturn(Optional.of(p));
AccessValidationResult result = service.validateToken("tok", requester);
assertThat(result.isAllowed()).isFalse();
}
@Test
void validateToken_unknownToken_denied() {
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.empty());
when(workflowParticipantRepository.findByShareToken("tok")).thenReturn(Optional.empty());
AccessValidationResult result = service.validateToken("tok", null);
assertThat(result.isAllowed()).isFalse();
}
// -------------------------------------------------------------------------
// getEffectiveRole
// -------------------------------------------------------------------------
@Test
void getEffectiveRole_signed_returnsViewer() {
WorkflowParticipant p = participant(null, ParticipantStatus.SIGNED, true, null);
assertThat(service.getEffectiveRole(p)).isEqualTo(ShareAccessRole.VIEWER);
}
@Test
void getEffectiveRole_declined_returnsViewer() {
WorkflowParticipant p = participant(null, ParticipantStatus.DECLINED, true, null);
assertThat(service.getEffectiveRole(p)).isEqualTo(ShareAccessRole.VIEWER);
}
@Test
void getEffectiveRole_pending_returnsAssignedRole() {
WorkflowParticipant p = participant(null, ParticipantStatus.PENDING, true, null);
p.setAccessRole(ShareAccessRole.EDITOR);
assertThat(service.getEffectiveRole(p)).isEqualTo(ShareAccessRole.EDITOR);
}
@Test
void getEffectiveRole_notified_returnsAssignedRole() {
WorkflowParticipant p = participant(null, ParticipantStatus.NOTIFIED, true, null);
p.setAccessRole(ShareAccessRole.VIEWER);
assertThat(service.getEffectiveRole(p)).isEqualTo(ShareAccessRole.VIEWER);
}
@Test
void getEffectiveRole_viewed_returnsAssignedRole() {
WorkflowParticipant p = participant(null, ParticipantStatus.VIEWED, true, null);
p.setAccessRole(ShareAccessRole.COMMENTER);
assertThat(service.getEffectiveRole(p)).isEqualTo(ShareAccessRole.COMMENTER);
}
// -------------------------------------------------------------------------
// canAccessFile
// -------------------------------------------------------------------------
@Test
void canAccessFile_owner_returnsTrue() {
User u = user(1L);
StoredFile file = new StoredFile();
file.setOwner(u);
assertThat(service.canAccessFile(u, file)).isTrue();
}
@Test
void canAccessFile_nonOwner_withValidShare_returnsTrue() {
User owner = user(1L);
User requester = user(2L);
StoredFile file = new StoredFile();
file.setOwner(owner);
FileShare s = share(file, requester, null);
when(fileShareRepository.findByFileAndSharedWithUser(file, requester))
.thenReturn(Optional.of(s));
assertThat(service.canAccessFile(requester, file)).isTrue();
}
@Test
void canAccessFile_nonOwner_withExpiredShare_returnsFalse() {
User owner = user(1L);
User requester = user(2L);
StoredFile file = new StoredFile();
file.setOwner(owner);
FileShare s = share(file, requester, LocalDateTime.now().minusSeconds(1));
when(fileShareRepository.findByFileAndSharedWithUser(file, requester))
.thenReturn(Optional.of(s));
assertThat(service.canAccessFile(requester, file)).isFalse();
}
@Test
void canAccessFile_nonOwner_noShare_returnsFalse() {
User owner = user(1L);
User requester = user(2L);
StoredFile file = new StoredFile();
file.setOwner(owner);
when(fileShareRepository.findByFileAndSharedWithUser(file, requester))
.thenReturn(Optional.empty());
assertThat(service.canAccessFile(requester, file)).isFalse();
}
@Test
void canAccessFile_workflowParticipant_activeSession_returnsTrue() {
User owner = user(1L);
User requester = user(2L);
WorkflowSession session = new WorkflowSession();
session.setStatus(WorkflowStatus.IN_PROGRESS);
session.setFinalized(false);
StoredFile file = new StoredFile();
file.setOwner(owner);
file.setWorkflowSession(session);
WorkflowParticipant p = participant(requester, ParticipantStatus.PENDING, true, null);
when(fileShareRepository.findByFileAndSharedWithUser(file, requester))
.thenReturn(Optional.empty());
when(workflowParticipantRepository.findByWorkflowSessionAndUser(session, requester))
.thenReturn(Optional.of(p));
assertThat(service.canAccessFile(requester, file)).isTrue();
}
@Test
void canAccessFile_workflowParticipant_expiredParticipant_returnsFalse() {
User owner = user(1L);
User requester = user(2L);
WorkflowSession session = new WorkflowSession();
session.setStatus(WorkflowStatus.IN_PROGRESS);
session.setFinalized(false);
StoredFile file = new StoredFile();
file.setOwner(owner);
file.setWorkflowSession(session);
WorkflowParticipant p =
participant(
requester,
ParticipantStatus.PENDING,
true,
LocalDateTime.now().minusSeconds(1));
when(fileShareRepository.findByFileAndSharedWithUser(file, requester))
.thenReturn(Optional.empty());
when(workflowParticipantRepository.findByWorkflowSessionAndUser(session, requester))
.thenReturn(Optional.of(p));
assertThat(service.canAccessFile(requester, file)).isFalse();
}
}
@@ -0,0 +1,201 @@
package stirling.software.proprietary.workflow.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.SecureRandom;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.Date;
import java.util.Optional;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.AutomaticallyGenerated;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.workflow.model.UserServerCertificateEntity;
import stirling.software.proprietary.workflow.repository.UserServerCertificateRepository;
@ExtendWith(MockitoExtension.class)
class UserServerCertificateServiceTest {
@Mock private UserServerCertificateRepository certificateRepository;
@Mock private UserRepository userRepository;
private MetadataEncryptionService encryptionService;
private UserServerCertificateService service;
@BeforeEach
void setUp() {
AutomaticallyGenerated generated = new AutomaticallyGenerated();
generated.setKey("test-key-for-unit-tests-only");
ApplicationProperties props = new ApplicationProperties();
props.setAutomaticallyGenerated(generated);
encryptionService = new MetadataEncryptionService(props);
service =
new UserServerCertificateService(
certificateRepository, userRepository, encryptionService);
}
private User user(long id) {
User u = new User();
u.setId(id);
u.setUsername("user" + id);
return u;
}
// -------------------------------------------------------------------------
// generateUserCertificate password must be stored encrypted
// -------------------------------------------------------------------------
@Test
void generateUserCertificate_keystorePasswordStoredEncrypted() throws Exception {
User user = user(1L);
when(certificateRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.generateUserCertificate(user);
ArgumentCaptor<UserServerCertificateEntity> captor =
ArgumentCaptor.forClass(UserServerCertificateEntity.class);
verify(certificateRepository).save(captor.capture());
String stored = captor.getValue().getKeystorePassword();
assertThat(stored).startsWith(MetadataEncryptionService.ENC_PREFIX);
// The raw predictable prefix must not appear in the stored value
assertThat(stored).doesNotContain("stirling-user-cert-");
}
// -------------------------------------------------------------------------
// uploadUserCertificate password must be stored encrypted
// -------------------------------------------------------------------------
@Test
void uploadUserCertificate_keystorePasswordStoredEncrypted() throws Exception {
User user = user(2L);
String uploadPassword = "my-upload-password";
byte[] p12Bytes = buildP12(uploadPassword);
when(certificateRepository.findByUserId(2L)).thenReturn(Optional.empty());
when(certificateRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.uploadUserCertificate(user, new ByteArrayInputStream(p12Bytes), uploadPassword);
ArgumentCaptor<UserServerCertificateEntity> captor =
ArgumentCaptor.forClass(UserServerCertificateEntity.class);
verify(certificateRepository).save(captor.capture());
String stored = captor.getValue().getKeystorePassword();
assertThat(stored).startsWith(MetadataEncryptionService.ENC_PREFIX);
assertThat(stored).doesNotContain(uploadPassword);
}
// -------------------------------------------------------------------------
// getUserKeystorePassword must decrypt before returning
// -------------------------------------------------------------------------
@Test
void getUserKeystorePassword_returnsDecryptedPlaintext() {
String original = "plain-password";
String encrypted = encryptionService.encrypt(original);
UserServerCertificateEntity entity = new UserServerCertificateEntity();
entity.setKeystorePassword(encrypted);
when(certificateRepository.findByUserId(1L)).thenReturn(Optional.of(entity));
assertThat(service.getUserKeystorePassword(1L)).isEqualTo(original);
}
@Test
void getUserKeystorePassword_legacyPlaintext_returnedUnchanged() {
// Backwards-compatibility: values without enc: prefix pass through unchanged
UserServerCertificateEntity entity = new UserServerCertificateEntity();
entity.setKeystorePassword("legacy-plain");
when(certificateRepository.findByUserId(1L)).thenReturn(Optional.of(entity));
assertThat(service.getUserKeystorePassword(1L)).isEqualTo("legacy-plain");
}
// -------------------------------------------------------------------------
// getUserKeyStore decrypted password must successfully open the keystore
// -------------------------------------------------------------------------
@Test
void getUserKeyStore_decryptsPasswordToLoadKeystore() throws Exception {
String keystorePassword = "keystore-pass";
byte[] p12Bytes = buildP12(keystorePassword);
String encryptedPassword = encryptionService.encrypt(keystorePassword);
UserServerCertificateEntity entity = new UserServerCertificateEntity();
entity.setKeystoreData(p12Bytes);
entity.setKeystorePassword(encryptedPassword);
when(certificateRepository.findByUserId(1L)).thenReturn(Optional.of(entity));
KeyStore ks = service.getUserKeyStore(1L);
assertThat(ks).isNotNull();
assertThat(ks.aliases().hasMoreElements()).isTrue();
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/**
* Builds a minimal PKCS12 keystore containing a self-signed RSA certificate, using the same
* BouncyCastle provider that is already on the classpath.
*/
private static byte[] buildP12(String password) throws Exception {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", "BC");
kpg.initialize(2048, new SecureRandom());
KeyPair kp = kpg.generateKeyPair();
org.bouncycastle.asn1.x500.X500Name subject =
new org.bouncycastle.asn1.x500.X500Name("CN=test");
BigInteger serial = BigInteger.valueOf(System.currentTimeMillis());
Date notBefore = new Date();
Date notAfter = new Date(notBefore.getTime() + 365L * 24 * 60 * 60 * 1000);
JcaX509v3CertificateBuilder builder =
new JcaX509v3CertificateBuilder(
subject, serial, notBefore, notAfter, subject, kp.getPublic());
ContentSigner signer =
new JcaContentSignerBuilder("SHA256WithRSA")
.setProvider("BC")
.build(kp.getPrivate());
X509Certificate cert =
new JcaX509CertificateConverter()
.setProvider(new BouncyCastleProvider())
.getCertificate(builder.build(signer));
KeyStore ks = KeyStore.getInstance("PKCS12");
ks.load(null, null);
ks.setKeyEntry("alias", kp.getPrivate(), password.toCharArray(), new Certificate[] {cert});
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ks.store(baos, password.toCharArray());
return baos.toByteArray();
}
}
@@ -0,0 +1,572 @@
package stirling.software.proprietary.workflow.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Storage;
import stirling.software.common.model.ApplicationProperties.Storage.Signing;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.provider.StoredObject;
import stirling.software.proprietary.storage.repository.StoredFileRepository;
import stirling.software.proprietary.workflow.dto.SignDocumentRequest;
import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest;
import stirling.software.proprietary.workflow.model.ParticipantStatus;
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.model.WorkflowStatus;
import stirling.software.proprietary.workflow.model.WorkflowType;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository;
import tools.jackson.databind.ObjectMapper;
@ExtendWith(MockitoExtension.class)
class WorkflowSessionServiceTest {
@Mock private WorkflowSessionRepository workflowSessionRepository;
@Mock private WorkflowParticipantRepository workflowParticipantRepository;
@Mock private StoredFileRepository storedFileRepository;
@Mock private UserRepository userRepository;
@Mock private StorageProvider storageProvider;
@Mock private ObjectMapper objectMapper;
@Mock private ApplicationProperties applicationProperties;
@Mock private MetadataEncryptionService metadataEncryptionService;
@InjectMocks private WorkflowSessionService service;
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private WorkflowSession sessionWithParticipant(
String sessionId, WorkflowParticipant participant) {
WorkflowSession session = new WorkflowSession();
session.setSessionId(sessionId);
List<WorkflowParticipant> participants = new ArrayList<>();
participants.add(participant);
session.setParticipants(participants);
when(workflowSessionRepository.findBySessionId(sessionId)).thenReturn(Optional.of(session));
return session;
}
private WorkflowParticipant pendingParticipant(User user) {
WorkflowParticipant p = new WorkflowParticipant();
p.setUser(user);
p.setStatus(ParticipantStatus.PENDING);
return p;
}
private User user(String username) {
User u = new User();
u.setUsername(username);
return u;
}
// -------------------------------------------------------------------------
// signDocument status transition
// -------------------------------------------------------------------------
@Test
void signDocument_transitionsParticipantToSigned() {
User user = user("alice");
WorkflowParticipant participant = pendingParticipant(user);
sessionWithParticipant("s1", participant);
when(metadataEncryptionService.encrypt(any())).thenReturn("enc:pw");
when(workflowParticipantRepository.save(any())).thenAnswer(i -> i.getArgument(0));
SignDocumentRequest req = new SignDocumentRequest();
req.setCertType("SERVER");
service.signDocument("s1", user, req);
ArgumentCaptor<WorkflowParticipant> captor =
ArgumentCaptor.forClass(WorkflowParticipant.class);
verify(workflowParticipantRepository).save(captor.capture());
assertThat(captor.getValue().getStatus()).isEqualTo(ParticipantStatus.SIGNED);
}
// -------------------------------------------------------------------------
// signDocument certificate metadata
// -------------------------------------------------------------------------
@Test
void signDocument_storesCertTypeAndEncryptedPasswordInMetadata() {
User user = user("bob");
WorkflowParticipant participant = pendingParticipant(user);
sessionWithParticipant("s2", participant);
when(metadataEncryptionService.encrypt("secret")).thenReturn("enc:secret");
when(workflowParticipantRepository.save(any())).thenAnswer(i -> i.getArgument(0));
SignDocumentRequest req = new SignDocumentRequest();
req.setCertType("USER_CERT");
req.setPassword("secret");
service.signDocument("s2", user, req);
ArgumentCaptor<WorkflowParticipant> captor =
ArgumentCaptor.forClass(WorkflowParticipant.class);
verify(workflowParticipantRepository).save(captor.capture());
Map<String, Object> meta = captor.getValue().getParticipantMetadata();
assertThat(meta).containsKey("certificateSubmission");
@SuppressWarnings("unchecked")
Map<String, Object> cert = (Map<String, Object>) meta.get("certificateSubmission");
assertThat(cert.get("certType")).isEqualTo("USER_CERT");
// Raw password must not be stored only the encrypted form
assertThat(cert.get("password")).isEqualTo("enc:secret");
assertThat(cert.get("password")).isNotEqualTo("secret");
}
@Test
void signDocument_preservesExistingParticipantMetadata() {
User user = user("carol");
WorkflowParticipant participant = pendingParticipant(user);
Map<String, Object> existing = new HashMap<>();
existing.put("showLogo", true);
existing.put("pageNumber", 1);
participant.setParticipantMetadata(existing);
sessionWithParticipant("s3", participant);
when(metadataEncryptionService.encrypt(any())).thenReturn("enc:pw");
when(workflowParticipantRepository.save(any())).thenAnswer(i -> i.getArgument(0));
SignDocumentRequest req = new SignDocumentRequest();
req.setCertType("SERVER");
service.signDocument("s3", user, req);
ArgumentCaptor<WorkflowParticipant> captor =
ArgumentCaptor.forClass(WorkflowParticipant.class);
verify(workflowParticipantRepository).save(captor.capture());
Map<String, Object> meta = captor.getValue().getParticipantMetadata();
// Owner-configured appearance settings must survive the sign operation
assertThat(meta.get("showLogo")).isEqualTo(true);
assertThat(meta.get("pageNumber")).isEqualTo(1);
assertThat(meta).containsKey("certificateSubmission");
}
// -------------------------------------------------------------------------
// signDocument guard conditions
// -------------------------------------------------------------------------
@Test
void signDocument_throwsBadRequest_whenAlreadySigned() {
User user = user("dave");
WorkflowParticipant participant = pendingParticipant(user);
participant.setStatus(ParticipantStatus.SIGNED);
sessionWithParticipant("s4", participant);
SignDocumentRequest req = new SignDocumentRequest();
req.setCertType("SERVER");
assertThatThrownBy(() -> service.signDocument("s4", user, req))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
verify(workflowParticipantRepository, never()).save(any());
}
@Test
void signDocument_throwsBadRequest_whenAlreadyDeclined() {
User user = user("eve");
WorkflowParticipant participant = pendingParticipant(user);
participant.setStatus(ParticipantStatus.DECLINED);
sessionWithParticipant("s5", participant);
SignDocumentRequest req = new SignDocumentRequest();
req.setCertType("SERVER");
assertThatThrownBy(() -> service.signDocument("s5", user, req))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
verify(workflowParticipantRepository, never()).save(any());
}
@Test
void signDocument_throwsForbidden_whenUserIsNotParticipant() {
User owner = user("frank");
owner.setId(1L);
User intruder = user("intruder");
intruder.setId(2L);
WorkflowParticipant participant = pendingParticipant(owner);
sessionWithParticipant("s6", participant);
SignDocumentRequest req = new SignDocumentRequest();
req.setCertType("SERVER");
assertThatThrownBy(() -> service.signDocument("s6", intruder, req))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN);
verify(workflowParticipantRepository, never()).save(any());
}
// -------------------------------------------------------------------------
// createSession validation guards
// -------------------------------------------------------------------------
private void stubSigningEnabled() {
Storage storage = mock(Storage.class);
Signing signing = mock(Signing.class);
when(applicationProperties.getStorage()).thenReturn(storage);
when(storage.isEnabled()).thenReturn(true);
when(storage.getSigning()).thenReturn(signing);
when(signing.isEnabled()).thenReturn(true);
}
@Test
void createSession_nullFile_throwsBadRequest() {
WorkflowCreationRequest request = new WorkflowCreationRequest();
request.setWorkflowType(WorkflowType.SIGNING);
assertThatThrownBy(() -> service.createSession(user("owner"), null, request))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void createSession_emptyFile_throwsBadRequest() {
MockMultipartFile empty =
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[0]);
WorkflowCreationRequest request = new WorkflowCreationRequest();
request.setWorkflowType(WorkflowType.SIGNING);
assertThatThrownBy(() -> service.createSession(user("owner"), empty, request))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void createSession_nullWorkflowType_throwsBadRequest() {
MockMultipartFile file =
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[] {1});
WorkflowCreationRequest request = new WorkflowCreationRequest();
request.setWorkflowType(null);
assertThatThrownBy(() -> service.createSession(user("owner"), file, request))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void createSession_validRequest_sessionSavedWithOwnerAndInProgressStatus() throws IOException {
User owner = user("alice");
MockMultipartFile file =
new MockMultipartFile("file", "doc.pdf", "application/pdf", new byte[] {1, 2});
WorkflowCreationRequest request = new WorkflowCreationRequest();
request.setWorkflowType(WorkflowType.SIGNING);
request.setDocumentName("My Doc");
StoredObject storedObject =
StoredObject.builder()
.storageKey("key-1")
.originalFilename("doc.pdf")
.contentType("application/pdf")
.sizeBytes(2L)
.build();
when(storageProvider.store(any(), any())).thenReturn(storedObject);
StoredFile savedFile = new StoredFile();
when(storedFileRepository.save(any())).thenReturn(savedFile);
WorkflowSession savedSession = new WorkflowSession();
savedSession.setSessionId("s-abc");
savedSession.setParticipants(new ArrayList<>());
when(workflowSessionRepository.save(any())).thenReturn(savedSession);
WorkflowSession result = service.createSession(owner, file, request);
ArgumentCaptor<WorkflowSession> captor = ArgumentCaptor.forClass(WorkflowSession.class);
verify(workflowSessionRepository).save(captor.capture());
assertThat(captor.getValue().getOwner()).isEqualTo(owner);
assertThat(captor.getValue().getStatus()).isEqualTo(WorkflowStatus.IN_PROGRESS);
assertThat(result).isNotNull();
}
@Test
void createSession_documentNameFromRequest() throws IOException {
User owner = user("alice");
MockMultipartFile file =
new MockMultipartFile("file", "original.pdf", "application/pdf", new byte[] {1});
WorkflowCreationRequest request = new WorkflowCreationRequest();
request.setWorkflowType(WorkflowType.SIGNING);
request.setDocumentName("Custom Name");
when(storageProvider.store(any(), any()))
.thenReturn(
StoredObject.builder()
.storageKey("k")
.originalFilename("original.pdf")
.contentType("application/pdf")
.sizeBytes(1L)
.build());
when(storedFileRepository.save(any())).thenReturn(new StoredFile());
WorkflowSession savedSession = new WorkflowSession();
savedSession.setSessionId("s-1");
savedSession.setParticipants(new ArrayList<>());
when(workflowSessionRepository.save(any())).thenReturn(savedSession);
service.createSession(owner, file, request);
ArgumentCaptor<WorkflowSession> captor = ArgumentCaptor.forClass(WorkflowSession.class);
verify(workflowSessionRepository).save(captor.capture());
assertThat(captor.getValue().getDocumentName()).isEqualTo("Custom Name");
}
@Test
void createSession_documentNameFallsBackToOriginalFilename() throws IOException {
User owner = user("alice");
MockMultipartFile file =
new MockMultipartFile("file", "uploaded.pdf", "application/pdf", new byte[] {1});
WorkflowCreationRequest request = new WorkflowCreationRequest();
request.setWorkflowType(WorkflowType.SIGNING);
request.setDocumentName(null);
when(storageProvider.store(any(), any()))
.thenReturn(
StoredObject.builder()
.storageKey("k")
.originalFilename("uploaded.pdf")
.contentType("application/pdf")
.sizeBytes(1L)
.build());
when(storedFileRepository.save(any())).thenReturn(new StoredFile());
WorkflowSession savedSession = new WorkflowSession();
savedSession.setSessionId("s-2");
savedSession.setParticipants(new ArrayList<>());
when(workflowSessionRepository.save(any())).thenReturn(savedSession);
service.createSession(owner, file, request);
ArgumentCaptor<WorkflowSession> captor = ArgumentCaptor.forClass(WorkflowSession.class);
verify(workflowSessionRepository).save(captor.capture());
assertThat(captor.getValue().getDocumentName()).isEqualTo("uploaded.pdf");
}
// -------------------------------------------------------------------------
// getSessionForOwner
// -------------------------------------------------------------------------
@Test
void getSessionForOwner_ownerMatch_returnsSession() {
User owner = user("alice");
owner.setId(1L);
WorkflowSession session = new WorkflowSession();
session.setSessionId("s1");
session.setOwner(owner);
when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(session));
WorkflowSession result = service.getSessionForOwner("s1", owner);
assertThat(result).isSameAs(session);
}
@Test
void getSessionForOwner_sessionNotFound_throwsNotFound() {
when(workflowSessionRepository.findBySessionId("missing")).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.getSessionForOwner("missing", user("alice")))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void getSessionForOwner_wrongOwner_throwsForbidden() {
User owner = user("alice");
owner.setId(1L);
User intruder = user("bob");
intruder.setId(2L);
WorkflowSession session = new WorkflowSession();
session.setSessionId("s2");
session.setOwner(owner);
when(workflowSessionRepository.findBySessionId("s2")).thenReturn(Optional.of(session));
assertThatThrownBy(() -> service.getSessionForOwner("s2", intruder))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN);
}
// -------------------------------------------------------------------------
// deleteSession
// -------------------------------------------------------------------------
@Test
void deleteSession_ownerAuthorized_deletesSession() {
User owner = user("alice");
owner.setId(1L);
WorkflowSession session = new WorkflowSession();
session.setSessionId("s3");
session.setOwner(owner);
when(workflowSessionRepository.findBySessionId("s3")).thenReturn(Optional.of(session));
service.deleteSession("s3", owner);
verify(workflowSessionRepository).delete(session);
// session.save() must NOT be called that would UPDATE original_file_id to NULL,
// violating the NOT NULL constraint; the row is simply deleted instead
verify(workflowSessionRepository, never()).save(any());
}
@Test
void deleteSession_withBothFiles_nullsBackRefsAndDeletesInOrder() {
User owner = user("alice");
owner.setId(1L);
StoredFile originalFile = new StoredFile();
originalFile.setStorageKey("key-orig");
StoredFile processedFile = new StoredFile();
processedFile.setStorageKey("key-proc");
WorkflowSession session = new WorkflowSession();
session.setSessionId("s3b");
session.setOwner(owner);
session.setOriginalFile(originalFile);
session.setProcessedFile(processedFile);
when(workflowSessionRepository.findBySessionId("s3b")).thenReturn(Optional.of(session));
service.deleteSession("s3b", owner);
// Only the back-reference (StoredFile session) must be nulled; session.originalFile
// is NOT nulled because that would emit UPDATE original_file_id=NULL (NOT NULL violation)
assertThat(originalFile.getWorkflowSession()).isNull();
assertThat(processedFile.getWorkflowSession()).isNull();
// Order: save StoredFiles (clear back-refs) delete session delete StoredFile rows
InOrder inOrder = inOrder(workflowSessionRepository, storedFileRepository);
inOrder.verify(storedFileRepository).save(originalFile);
inOrder.verify(storedFileRepository).save(processedFile);
inOrder.verify(workflowSessionRepository).delete(session);
inOrder.verify(storedFileRepository).delete(originalFile);
inOrder.verify(storedFileRepository).delete(processedFile);
}
@Test
void deleteSession_finalizedSession_throwsBadRequest() {
User owner = user("alice");
owner.setId(1L);
WorkflowSession session = new WorkflowSession();
session.setSessionId("s3c");
session.setOwner(owner);
session.setFinalized(true);
when(workflowSessionRepository.findBySessionId("s3c")).thenReturn(Optional.of(session));
assertThatThrownBy(() -> service.deleteSession("s3c", owner))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
verify(workflowSessionRepository, never()).delete(any());
}
@Test
void deleteSession_storageErrorOnOriginalFile_stillDeletesSession() throws Exception {
User owner = user("alice");
owner.setId(1L);
StoredFile originalFile = new StoredFile();
originalFile.setStorageKey("key-orig");
WorkflowSession session = new WorkflowSession();
session.setSessionId("s4");
session.setOwner(owner);
session.setOriginalFile(originalFile);
when(workflowSessionRepository.findBySessionId("s4")).thenReturn(Optional.of(session));
doThrow(new RuntimeException("storage unavailable"))
.when(storageProvider)
.delete("key-orig");
service.deleteSession("s4", owner);
// Storage failure is non-fatal back-ref is still cleared and DB records still deleted
verify(storedFileRepository).save(originalFile); // back-ref cleared
verify(workflowSessionRepository).delete(session);
verify(storedFileRepository).delete(originalFile);
}
@Test
void deleteSession_notOwner_throwsForbidden() {
User owner = user("alice");
owner.setId(1L);
User other = user("bob");
other.setId(2L);
WorkflowSession session = new WorkflowSession();
session.setSessionId("s5");
session.setOwner(owner);
when(workflowSessionRepository.findBySessionId("s5")).thenReturn(Optional.of(session));
assertThatThrownBy(() -> service.deleteSession("s5", other))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN);
verify(workflowSessionRepository, never()).delete(any());
}
// -------------------------------------------------------------------------
// listUserSessions
// -------------------------------------------------------------------------
@Test
void listUserSessions_returnsAllSessionsForOwner() {
User owner = user("alice");
WorkflowSession s1 = new WorkflowSession();
WorkflowSession s2 = new WorkflowSession();
when(workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(owner))
.thenReturn(List.of(s1, s2));
List<WorkflowSession> result = service.listUserSessions(owner);
assertThat(result).containsExactly(s1, s2);
}
@Test
void listUserSessions_noSessions_returnsEmptyList() {
User owner = user("alice");
when(workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(owner))
.thenReturn(List.of());
assertThat(service.listUserSessions(owner)).isEmpty();
}
}