From 7a748d4ad2c56b5944f660d22d303dc0274a7b37 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:02:10 +0000 Subject: [PATCH] Add stored supporting files for pipeline steps (#7146) # Description of Changes Backend only change for pipelines to support files (ie pipeline to sign all files with the same cert file etc) --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../crypto/CredentialEncryption.java | 46 +++- .../asset/InProcessPolicyAssetStore.java | 82 ++++++ .../policy/asset/JpaPolicyAssetStore.java | 95 +++++++ .../proprietary/policy/asset/PolicyAsset.java | 17 ++ .../policy/asset/PolicyAssetCleaner.java | 124 +++++++++ .../policy/asset/PolicyAssetController.java | 187 ++++++++++++++ .../policy/asset/PolicyAssetEntity.java | 58 +++++ .../policy/asset/PolicyAssetRefs.java | 63 +++++ .../policy/asset/PolicyAssetRepository.java | 42 +++ .../policy/asset/PolicyAssetResolver.java | 112 ++++++++ .../policy/asset/PolicyAssetStore.java | 32 +++ .../policy/config/PolicyAccessGuard.java | 18 ++ .../policy/controller/PolicyController.java | 20 +- .../policy/engine/PolicyEngine.java | 13 +- .../policy/engine/PolicyValidator.java | 41 +++ .../policy/model/PipelineStep.java | 4 +- .../policy/store/InProcessPolicyStore.java | 10 + .../policy/store/JpaPolicyStore.java | 5 + .../policy/store/PolicyRepository.java | 8 + .../proprietary/policy/store/PolicyStore.java | 7 + .../configuration/DatabaseConfig.java | 2 + .../crypto/CredentialEncryptionTest.java | 43 ++++ .../asset/JpaPolicyAssetStoreDbTest.java | 127 +++++++++ .../policy/asset/PolicyAssetCleanerTest.java | 195 ++++++++++++++ .../asset/PolicyAssetControllerTest.java | 243 ++++++++++++++++++ .../policy/asset/PolicyAssetResolverTest.java | 243 ++++++++++++++++++ .../controller/PolicyControllerTest.java | 20 ++ .../policy/engine/PolicyEngineTest.java | 5 +- .../policy/engine/PolicyValidatorTest.java | 91 +++++++ .../policy/store/JpaPolicyStoreDbTest.java | 65 +++++ 30 files changed, 1996 insertions(+), 22 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/InProcessPolicyAssetStore.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/JpaPolicyAssetStore.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAsset.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetCleaner.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetEntity.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetRefs.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetResolver.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetStore.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/JpaPolicyAssetStoreDbTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/PolicyAssetCleanerTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/PolicyAssetControllerTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/PolicyAssetResolverTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreDbTest.java diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java index 8ac0ddda96..5e507ff285 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java @@ -92,7 +92,8 @@ public class CredentialEncryption { writeOwnerOnly(path, Base64.getEncoder().encodeToString(generated.getEncoded())); log.warn( "Generated a new credential encryption key at {}. Back this file up: losing it" - + " makes stored integration secrets unrecoverable.", + + " makes stored integration secrets and pipeline supporting files" + + " unrecoverable.", path); return generated; } catch (Exception e) { @@ -119,7 +120,8 @@ public class CredentialEncryption { } } - public static String encrypt(String plaintext) { + /** Raw {@code IV || ciphertext}, for binary columns that can't afford Base64's 33% overhead. */ + public static byte[] encryptBytes(byte[] plaintext) { if (plaintext == null) { return null; } @@ -128,30 +130,46 @@ public class CredentialEncryption { RANDOM.nextBytes(iv); Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, requireKey(), new GCMParameterSpec(GCM_TAG_BITS, iv)); - byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + byte[] ciphertext = cipher.doFinal(plaintext); byte[] combined = new byte[iv.length + ciphertext.length]; System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length); - return Base64.getEncoder().encodeToString(combined); + return combined; } catch (GeneralSecurityException e) { throw new IllegalStateException("Failed to encrypt credential", e); } } + /** Inverse of {@link #encryptBytes}; throws if the blob was tampered with (GCM tag). */ + public static byte[] decryptBytes(byte[] stored) { + if (stored == null) { + return null; + } + try { + byte[] iv = Arrays.copyOfRange(stored, 0, IV_BYTES); + byte[] ciphertext = Arrays.copyOfRange(stored, IV_BYTES, stored.length); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, requireKey(), new GCMParameterSpec(GCM_TAG_BITS, iv)); + return cipher.doFinal(ciphertext); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Failed to decrypt credential", e); + } + } + + public static String encrypt(String plaintext) { + if (plaintext == null) { + return null; + } + return Base64.getEncoder() + .encodeToString(encryptBytes(plaintext.getBytes(StandardCharsets.UTF_8))); + } + public static String decrypt(String stored) { if (stored == null) { return null; } - try { - byte[] combined = Base64.getDecoder().decode(stored); - byte[] iv = Arrays.copyOfRange(combined, 0, IV_BYTES); - byte[] ciphertext = Arrays.copyOfRange(combined, IV_BYTES, combined.length); - Cipher cipher = Cipher.getInstance(TRANSFORMATION); - cipher.init(Cipher.DECRYPT_MODE, requireKey(), new GCMParameterSpec(GCM_TAG_BITS, iv)); - return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); - } catch (GeneralSecurityException e) { - throw new IllegalStateException("Failed to decrypt credential", e); - } + // Base64's IllegalArgumentException stays uncaught: LegacyDecryptStringConverter needs it. + return new String(decryptBytes(Base64.getDecoder().decode(stored)), StandardCharsets.UTF_8); } private static SecretKey requireKey() { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/InProcessPolicyAssetStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/InProcessPolicyAssetStore.java new file mode 100644 index 0000000000..35f20aa15a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/InProcessPolicyAssetStore.java @@ -0,0 +1,82 @@ +package stirling.software.proprietary.policy.asset; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-memory {@link PolicyAssetStore} for tests and any future no-database mode. {@link + * JpaPolicyAssetStore} is the runtime bean. + */ +public class InProcessPolicyAssetStore implements PolicyAssetStore { + + private final Map assets = new ConcurrentHashMap<>(); + private final Map contents = new ConcurrentHashMap<>(); + + @Override + public PolicyAsset save(PolicyAsset asset, byte[] content) { + String id = + asset.id() == null || asset.id().isBlank() + ? UUID.randomUUID().toString() + : asset.id(); + PolicyAsset stored = + new PolicyAsset( + id, + asset.fileName(), + asset.contentType(), + content.length, + asset.owner(), + asset.teamId(), + asset.createdAt()); + assets.put(id, stored); + contents.put(id, content); + return stored; + } + + @Override + public Optional get(String id) { + return Optional.ofNullable(assets.get(id)); + } + + @Override + public Optional content(String id) { + return Optional.ofNullable(contents.get(id)); + } + + @Override + public List findByTeam(Long teamId) { + return assets.values().stream() + .filter(asset -> Objects.equals(asset.teamId(), teamId)) + .sorted(newestFirst()) + .toList(); + } + + @Override + public List all() { + return assets.values().stream().sorted(newestFirst()).toList(); + } + + @Override + public List idsCreatedBefore(long cutoff) { + return assets.values().stream() + .filter(asset -> asset.createdAt() < cutoff) + .map(PolicyAsset::id) + .toList(); + } + + @Override + public boolean delete(String id) { + contents.remove(id); + return assets.remove(id) != null; + } + + private static Comparator newestFirst() { + return Comparator.comparingLong(PolicyAsset::createdAt) + .reversed() + .thenComparing(PolicyAsset::id); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/JpaPolicyAssetStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/JpaPolicyAssetStore.java new file mode 100644 index 0000000000..65c6b5ed25 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/JpaPolicyAssetStore.java @@ -0,0 +1,95 @@ +package stirling.software.proprietary.policy.asset; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.integration.crypto.CredentialEncryption; + +/** + * Durable {@link PolicyAssetStore} backed by JPA; the runtime store. Bytes are encrypted at rest + * here rather than by an attribute converter, so the metadata reads (list, validate, clean up) + * never decrypt - only {@link #content} does. + */ +@Service +@RequiredArgsConstructor +public class JpaPolicyAssetStore implements PolicyAssetStore { + + private final PolicyAssetRepository repository; + + @Override + @Transactional + public PolicyAsset save(PolicyAsset asset, byte[] content) { + String id = + asset.id() == null || asset.id().isBlank() + ? UUID.randomUUID().toString() + : asset.id(); + PolicyAssetEntity entity = new PolicyAssetEntity(); + entity.setId(id); + entity.setFileName(asset.fileName()); + entity.setContentType(asset.contentType()); + // Plaintext length: it is the size the UI shows, not the stored ciphertext's. + entity.setFileSize(content.length); + entity.setOwner(asset.owner()); + entity.setTeamId(asset.teamId()); + entity.setCreatedAt(asset.createdAt()); + entity.setData(CredentialEncryption.encryptBytes(content)); + repository.save(entity); + return toAsset(entity); + } + + @Override + public Optional get(String id) { + return repository.findMetaById(id); + } + + @Override + @Transactional(readOnly = true) + public Optional content(String id) { + // The only read of the data column, and so the only decrypt. + return repository + .findById(id) + .map(entity -> CredentialEncryption.decryptBytes(entity.getData())); + } + + @Override + public List findByTeam(Long teamId) { + return repository.findMetaByTeam(teamId); + } + + @Override + public List all() { + return repository.findAllMeta(); + } + + @Override + public List idsCreatedBefore(long cutoff) { + return repository.findIdsCreatedBefore(cutoff); + } + + @Override + @Transactional + public boolean delete(String id) { + if (!repository.existsById(id)) { + return false; + } + repository.deleteById(id); + return true; + } + + private static PolicyAsset toAsset(PolicyAssetEntity entity) { + return new PolicyAsset( + entity.getId(), + entity.getFileName(), + entity.getContentType(), + entity.getFileSize(), + entity.getOwner(), + entity.getTeamId(), + entity.getCreatedAt()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAsset.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAsset.java new file mode 100644 index 0000000000..697319a182 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAsset.java @@ -0,0 +1,17 @@ +package stirling.software.proprietary.policy.asset; + +/** + * Metadata for a stored supporting file (e.g. a watermark image, signing certificate, or overlay + * PDF) that pipeline steps reference from their {@code fileParameters}. The bytes live in the + * {@link PolicyAssetStore}; this record is what lists and API responses carry. Team-scoped like + * policies: {@code owner}/{@code teamId} are stamped server-side at upload ({@code null} when login + * is disabled). + */ +public record PolicyAsset( + String id, + String fileName, + String contentType, + long size, + String owner, + Long teamId, + long createdAt) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetCleaner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetCleaner.java new file mode 100644 index 0000000000..582465e874 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetCleaner.java @@ -0,0 +1,124 @@ +package stirling.software.proprietary.policy.asset; + +import java.time.Duration; +import java.time.Instant; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.store.PolicyStore; + +/** + * Removes stored assets that no policy references any more, so replaced certificates/images don't + * accumulate. Runs after a policy save (assets its old version referenced but its new one dropped) + * and after a policy delete (everything the deleted policy referenced). An asset still referenced + * by any other policy in the team survives; assets belonging to a different team are never touched, + * whatever a policy claims. + * + *

Those two hooks only ever see ids a policy once referenced, so an upload abandoned before any + * policy bound it is reclaimed by {@link #sweepAbandonedUploads()} instead. Cleanup is best-effort + * throughout: the policy write has already committed, so a failure is logged, not propagated. + */ +@Slf4j +@Service +public class PolicyAssetCleaner { + + // An upload sits unreferenced until the save that binds it, so the window has to outlast a + // builder session comfortably. + private static final Duration ABANDONED_UPLOAD_AGE = Duration.ofDays(1); + + private final PolicyAssetStore assetStore; + private final PolicyStore policyStore; + private final Supplier clock; + + @Autowired + public PolicyAssetCleaner(PolicyAssetStore assetStore, PolicyStore policyStore) { + this(assetStore, policyStore, Instant::now); + } + + // Clock seam so tests can pin "now"; the runtime bean uses the wall clock above. + PolicyAssetCleaner( + PolicyAssetStore assetStore, PolicyStore policyStore, Supplier clock) { + this.assetStore = assetStore; + this.policyStore = policyStore; + this.clock = clock; + } + + /** + * After an update: drop assets the previous version referenced and the new one no longer does. + */ + public void cleanupAfterSave(Policy previous, Policy saved) { + if (previous == null) { + return; + } + Set dropped = new HashSet<>(PolicyAssetRefs.referencedAssetIds(previous.steps())); + dropped.removeAll(PolicyAssetRefs.referencedAssetIds(saved.steps())); + deleteUnreferenced(saved.teamId(), dropped); + } + + /** After a delete: drop everything the deleted policy referenced, if now unreferenced. */ + public void cleanupAfterDelete(Policy deleted) { + deleteUnreferenced(deleted.teamId(), PolicyAssetRefs.referencedAssetIds(deleted.steps())); + } + + /** + * Uploads no policy ever bound: the save/delete hooks never see these ids, so without this they + * would keep their (up to 50 MB) bytes forever. Deliberately not team-scoped - an abandoned + * upload is unreferenced everywhere or nowhere. Runs at startup too, so an instance restarted + * more often than daily still reclaims; the age cutoff is what keeps fresh uploads safe. + */ + @Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS) + public void sweepAbandonedUploads() { + long cutoff = clock.get().minus(ABANDONED_UPLOAD_AGE).toEpochMilli(); + try { + for (String id : assetStore.idsCreatedBefore(cutoff)) { + if (!policyStore.anyPolicyReferences(id) && assetStore.delete(id)) { + log.debug("Deleted abandoned policy asset {}", id); + } + } + } catch (RuntimeException e) { + log.warn("Abandoned policy asset sweep failed: {}", e.getMessage(), e); + } + } + + private void deleteUnreferenced(Long teamId, Set candidates) { + if (candidates.isEmpty()) { + return; + } + try { + Set stillReferenced = new HashSet<>(); + for (Policy policy : policyStore.findByTeam(teamId)) { + stillReferenced.addAll(PolicyAssetRefs.referencedAssetIds(policy.steps())); + } + for (String id : candidates) { + if (stillReferenced.contains(id)) { + continue; + } + assetStore + .get(id) + .filter(asset -> Objects.equals(asset.teamId(), teamId)) + .ifPresent( + asset -> { + assetStore.delete(id); + log.debug( + "Deleted unreferenced policy asset {} ({})", + id, + asset.fileName()); + }); + } + } catch (RuntimeException e) { + // The save/delete already committed: failing here must not fail the request or skip + // the caller's trigger re-sync. + log.warn("Policy asset cleanup failed for team {}: {}", teamId, e.getMessage(), e); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetController.java new file mode 100644 index 0000000000..14349fd64c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetController.java @@ -0,0 +1,187 @@ +package stirling.software.proprietary.policy.asset; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +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.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.RequestMapping; +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 io.github.pixee.security.Filenames; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.policy.config.PolicyAccessGuard; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.store.PolicyStore; + +/** + * Stored supporting files for pipelines: the certificate/image/overlay a step needs beyond its + * document stream. Uploaded when a pipeline is built, persisted server-side, and referenced from a + * step's {@code fileParameters} as {@code asset:} - so triggered and scheduled runs have the + * file without anyone re-supplying it. Team-scoped exactly like the policies that reference them. + */ +@RestController +@RequestMapping("/api/v1/policies/assets") +@Hidden +@RequiredArgsConstructor +@Tag(name = "Policies", description = "Run tool pipelines on the backend") +public class PolicyAssetController { + + /** Defensive cap; supporting files (certs, images, overlay PDFs) are far smaller. */ + private static final long MAX_ASSET_BYTES = 50L * 1024 * 1024; + + private final PolicyAssetStore assetStore; + private final PolicyStore policyStore; + private final PolicyAccessGuard policyAccessGuard; + private final PolicyManagementAuthority policyManagementAuthority; + private final ApplicationProperties applicationProperties; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Upload a pipeline supporting file", + description = + "Stores a supporting file (multipart field 'file') for pipeline steps to" + + " reference from their fileParameters as 'asset:', and returns" + + " its metadata including the assigned id.") + public ResponseEntity upload(@RequestPart("file") MultipartFile file) + throws IOException { + requirePolicyEditingAllowed(); + if (file == null || file.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Uploaded file is empty"); + } + if (file.getSize() > MAX_ASSET_BYTES) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Supporting files may be at most " + (MAX_ASSET_BYTES / (1024 * 1024)) + " MB"); + } + String fileName = Filenames.toSimpleFileName(file.getOriginalFilename()); + if (fileName == null || fileName.isBlank()) { + fileName = "asset"; + } + PolicyAsset meta = + new PolicyAsset( + null, + fileName, + file.getContentType(), + file.getSize(), + policyAccessGuard.ownerForNewPolicy(), + policyAccessGuard.teamForNewPolicy(), + System.currentTimeMillis()); + return ResponseEntity.ok(assetStore.save(meta, file.getBytes())); + } + + @GetMapping + @Operation( + summary = "List stored supporting files", + description = + "Lists the supporting files visible to the caller's team (metadata only), so" + + " the builder can show which file a step's binding points at.") + public List list() { + return policyAccessGuard.visibleFrom(assetStore); + } + + @GetMapping("/{assetId}/content") + @Operation( + summary = "Download a stored supporting file", + description = + "Returns the asset's bytes with its stored content type and filename. Gated" + + " like upload and delete: supporting files include signing" + + " certificates, so reading the bytes back needs the same authority" + + " that put them there, not merely team membership.") + public ResponseEntity content(@PathVariable String assetId) { + requirePolicyEditingAllowed(); + PolicyAsset asset = accessibleAsset(assetId); + byte[] bytes = + assetStore + .content(assetId) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, "No asset: " + assetId)); + MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM; + try { + if (asset.contentType() != null) { + mediaType = MediaType.parseMediaType(asset.contentType()); + } + } catch (RuntimeException ignored) { + // Stored content type unparsable: serve as a generic binary. + } + return ResponseEntity.ok() + .contentType(mediaType) + .header( + HttpHeaders.CONTENT_DISPOSITION, + // UTF-8 so a non-ASCII filename encodes per RFC 5987 instead of mangling. + ContentDisposition.attachment() + .filename(asset.fileName(), StandardCharsets.UTF_8) + .build() + .toString()) + .body(new ByteArrayResource(bytes)); + } + + @DeleteMapping("/{assetId}") + @Operation( + summary = "Delete a stored supporting file", + description = + "Removes an asset no pipeline references. An asset still referenced by a" + + " pipeline's step returns 409 - remove or replace the binding first." + + " (Assets are also cleaned up automatically when the pipelines" + + " referencing them are saved without them or deleted.)") + public ResponseEntity delete(@PathVariable String assetId) { + requirePolicyEditingAllowed(); + accessibleAsset(assetId); + boolean referenced = + policyAccessGuard.visibleFrom(policyStore).stream() + .anyMatch( + policy -> + PolicyAssetRefs.referencedAssetIds(policy.steps()) + .contains(assetId)); + if (referenced) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, "Asset is still referenced by a pipeline step"); + } + assetStore.delete(assetId); + return ResponseEntity.noContent().build(); + } + + /** The asset, scoped to the caller's team — another team's asset reads as not-found. */ + private PolicyAsset accessibleAsset(String assetId) { + return assetStore + .get(assetId) + .filter(policyAccessGuard::canAccess) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, "No asset: " + assetId)); + } + + /** Same gate as policy edits (see {@code PolicyController#requirePolicyEditingAllowed}). */ + private void requirePolicyEditingAllowed() { + if (!applicationProperties.getSecurity().isEnableLogin()) { + return; + } + if (!policyManagementAuthority.canEditPolicies()) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, + "Policies may only be created or modified by a team leader"); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetEntity.java new file mode 100644 index 0000000000..b535cc1971 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetEntity.java @@ -0,0 +1,58 @@ +package stirling.software.proprietary.policy.asset; + +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; + +/** + * JPA row for a {@link PolicyAsset} plus its bytes. Stored in the database (not on disk) so + * multi-node deployments see the same assets regardless of which node stored or runs a policy. + * {@code owner} and {@code teamId} are plain values, not foreign keys, matching {@code + * PolicyEntity}. + */ +@Entity +@Table(name = "policy_assets") +@NoArgsConstructor +@Getter +@Setter +public class PolicyAssetEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "id") + private String id; + + @Column(name = "file_name") + private String fileName; + + @Column(name = "content_type") + private String contentType; + + // Not "size": a reserved word in Oracle, and an HQL keyword the metadata projections would + // read as the collection-size function. + @Column(name = "file_size") + private long fileSize; + + @Column(name = "owner") + private String owner; + + @Column(name = "team_id") + private Long teamId; + + @Column(name = "created_at") + private long createdAt; + + // Ciphertext; JpaPolicyAssetStore is the only place these bytes are encrypted or decrypted. + @Lob + @Column(name = "data", nullable = false, columnDefinition = "bytea") + private byte[] data; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetRefs.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetRefs.java new file mode 100644 index 0000000000..1acf3d7c76 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetRefs.java @@ -0,0 +1,63 @@ +package stirling.software.proprietary.policy.asset; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import stirling.software.proprietary.policy.model.PipelineStep; + +/** + * Parses stored-asset references out of pipeline steps. A step's {@code fileParameters} value takes + * one of two forms: + * + *

    + *
  • {@code asset:[,...]} - stored assets, loaded from the asset store at run time. + * Several ids appear when one tool field carries multiple files (e.g. attachments). + *
  • anything else - the name of a file supplied with the run itself (the multipart {@code + * assets[i].key} form), which is all a binding could mean before assets could be stored. + *
+ * + *

The prefix is what keeps the two apart: without it a run-supplied key reads as a missing asset + * id, and a stored binding can be satisfied by whatever file the run supplies. The executor looks + * supporting files up by the whole value, so resolution keeps it as the map key and only splits it + * to load each asset. + */ +public final class PolicyAssetRefs { + + /** Marks a {@code fileParameters} value as stored asset ids rather than a run-supplied key. */ + public static final String PREFIX = "asset:"; + + private PolicyAssetRefs() {} + + /** Whether a {@code fileParameters} value names stored assets. */ + public static boolean isAssetRef(String fileParameterValue) { + return fileParameterValue != null && fileParameterValue.startsWith(PREFIX); + } + + /** The individual asset ids inside one {@code fileParameters} value; none if it isn't a ref. */ + public static List assetIds(String fileParameterValue) { + List ids = new ArrayList<>(); + if (!isAssetRef(fileParameterValue)) { + return ids; + } + for (String token : fileParameterValue.substring(PREFIX.length()).split(",")) { + String id = token.trim(); + if (!id.isEmpty()) { + ids.add(id); + } + } + return ids; + } + + /** Every asset id referenced by any step's {@code fileParameters}, in encounter order. */ + public static Set referencedAssetIds(List steps) { + Set ids = new LinkedHashSet<>(); + for (PipelineStep step : steps) { + for (String value : step.fileParameters().values()) { + ids.addAll(assetIds(value)); + } + } + return ids; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetRepository.java new file mode 100644 index 0000000000..e423ad01ef --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetRepository.java @@ -0,0 +1,42 @@ +package stirling.software.proprietary.policy.asset; + +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; + +@Repository +public interface PolicyAssetRepository extends JpaRepository { + + /** + * Metadata projection. Lists, validation and cleanup need only the fields, and selecting whole + * entities would drag every asset's LOB (up to 50 MB each) along with them. + */ + String META = + "select new stirling.software.proprietary.policy.asset.PolicyAsset(a.id, a.fileName," + + " a.contentType, a.fileSize, a.owner, a.teamId, a.createdAt) from" + + " PolicyAssetEntity a"; + + @Query(META + " where a.id = :id") + Optional findMetaById(@Param("id") String id); + + /** + * Assets belonging to a team, newest first. A {@code null} teamId matches the rows with no team + * (login-disabled data), mirroring {@code PolicyRepository#findByTeam}. + */ + @Query( + META + + " where ((:teamId is null and a.teamId is null) or a.teamId = :teamId) order" + + " by a.createdAt desc, a.id asc") + List findMetaByTeam(@Param("teamId") Long teamId); + + @Query(META + " order by a.createdAt desc, a.id asc") + List findAllMeta(); + + /** Ids of assets uploaded before {@code cutoff}, for the abandoned-upload sweep. */ + @Query("select a.id from PolicyAssetEntity a where a.createdAt < :cutoff") + List findIdsCreatedBefore(@Param("cutoff") long cutoff); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetResolver.java new file mode 100644 index 0000000000..862d3511a7 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetResolver.java @@ -0,0 +1,112 @@ +package stirling.software.proprietary.policy.asset; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyInputs; + +/** + * Loads a stored policy's referenced supporting files into a run's inputs, so a triggered or + * scheduled run has the certificate/image/overlay its steps need without anyone uploading it at run + * time. Assets are matched to the policy's own team (both stamped server-side), so a forged asset + * id in a policy JSON can't pull another team's file - run-time has no principal to check instead. + * + *

Stored assets win: a binding the policy stores is overwritten with the policy's own asset, so + * a member who can run the policy (runs are not leader-gated) can't swap the pinned certificate by + * posting {@code assets[i].key=}. A binding that names no stored asset keeps whatever the run + * supplied, which is what a binding meant before assets could be stored. + * + *

Resolution is all or nothing per binding: a partly resolvable one is dropped so it surfaces as + * the executor's existing missing-supporting-file error, rather than running the step short a file. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PolicyAssetResolver { + + private final PolicyAssetStore assetStore; + + /** Inputs with the policy's stored assets merged in under each step's asset key. */ + public PolicyInputs resolve(Policy policy, PolicyInputs inputs) { + Map> supporting = null; + // Track loaded keys separately: `supporting` starts as a copy of the run's own files, so + // it can't tell "already loaded" from "the run supplied this one" - which is what we + // deliberately overwrite. + Set loaded = new HashSet<>(); + for (PipelineStep step : policy.steps()) { + for (String assetKey : step.fileParameters().values()) { + if (assetKey == null || assetKey.isBlank() || !loaded.add(assetKey)) { + continue; + } + List resources = load(policy, assetKey); + if (resources.isEmpty()) { + // A stored binding that has gone dead must not fall back to whatever the run + // posted under its key: that is the override this class exists to prevent. + if (PolicyAssetRefs.isAssetRef(assetKey) + && inputs.supportingFiles().containsKey(assetKey)) { + if (supporting == null) { + supporting = new LinkedHashMap<>(inputs.supportingFiles()); + } + supporting.remove(assetKey); + } + continue; + } + if (supporting == null) { + supporting = new LinkedHashMap<>(inputs.supportingFiles()); + } + supporting.put(assetKey, resources); + } + } + return supporting == null ? inputs : new PolicyInputs(inputs.primary(), supporting); + } + + /** Every id or none: a part-loaded binding would run the step with fewer files than it asks. */ + private List load(Policy policy, String assetKey) { + List resources = new ArrayList<>(); + for (String id : PolicyAssetRefs.assetIds(assetKey)) { + PolicyAsset asset = assetStore.get(id).orElse(null); + if (asset == null || !Objects.equals(asset.teamId(), policy.teamId())) { + log.warn( + "Policy {} references stored asset {} which is missing or inaccessible;" + + " dropping its binding", + policy.id(), + id); + return List.of(); + } + byte[] content = assetStore.content(id).orElse(null); + if (content == null) { + log.warn( + "Policy {} stored asset {} has no content; dropping its binding", + policy.id(), + id); + return List.of(); + } + resources.add(named(content, asset.fileName())); + } + return resources; + } + + /** The asset bytes as a Resource carrying its original filename (tools read the extension). */ + private static Resource named(byte[] content, String fileName) { + return new ByteArrayResource(content) { + @Override + public String getFilename() { + return fileName; + } + }; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetStore.java new file mode 100644 index 0000000000..f5406e8980 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/asset/PolicyAssetStore.java @@ -0,0 +1,32 @@ +package stirling.software.proprietary.policy.asset; + +import java.util.List; +import java.util.Optional; + +/** + * Persistence for pipeline supporting files. Metadata and bytes are stored together but read + * separately: lists and validation only need {@link PolicyAsset}, while a run loads {@link + * #content} for just the assets its steps reference. + */ +public interface PolicyAssetStore { + + /** Persist an asset (a blank id is assigned) and return the stored metadata. */ + PolicyAsset save(PolicyAsset asset, byte[] content); + + Optional get(String id); + + /** The asset's bytes, or empty if the id is unknown. */ + Optional content(String id); + + /** Assets belonging to a team, newest first. {@code null} matches no-team (login-disabled). */ + List findByTeam(Long teamId); + + /** All assets, for team-scoping-off (login-disabled) reads. */ + List all(); + + /** Ids of assets uploaded before {@code cutoff} (epoch millis), for orphan reclamation. */ + List idsCreatedBefore(long cutoff); + + /** Remove an asset. Returns false if the id was unknown. */ + boolean delete(String id); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java index 4fa90cc04b..2d7061c966 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java @@ -9,6 +9,8 @@ import lombok.RequiredArgsConstructor; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.UserServiceInterface; +import stirling.software.proprietary.policy.asset.PolicyAsset; +import stirling.software.proprietary.policy.asset.PolicyAssetStore; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.store.PolicyStore; @@ -58,6 +60,22 @@ public class PolicyAccessGuard { return store.findByTeam(policyManagementAuthority.currentUserTeamId()); } + /** Whether the stored asset belongs to the current user's team (same rule as policies). */ + public boolean canAccess(PolicyAsset asset) { + if (!enforced()) { + return true; + } + return Objects.equals(asset.teamId(), policyManagementAuthority.currentUserTeamId()); + } + + /** The stored assets visible to the caller, scoped exactly like {@link #visibleFrom}. */ + public List visibleFrom(PolicyAssetStore store) { + if (!enforced()) { + return store.all(); + } + return store.findByTeam(policyManagementAuthority.currentUserTeamId()); + } + private boolean enforced() { return applicationProperties.getSecurity().isEnableLogin(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index a382f96bbc..506bb75578 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -50,6 +50,7 @@ import stirling.software.common.service.ToolChainValidator; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.proprietary.audit.AuditContext; +import stirling.software.proprietary.policy.asset.PolicyAssetCleaner; import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; import stirling.software.proprietary.policy.engine.PolicyRunHandle; @@ -104,6 +105,7 @@ public class PolicyController { private final PolicyManagementAuthority policyManagementAuthority; private final PolicyTriggerManager policyTriggerManager; private final PolicyOverviewService policyOverviewService; + private final PolicyAssetCleaner assetCleaner; private final ProcessedLedger processedLedger; private final List policyTriggers; private final ApplicationProperties applicationProperties; @@ -275,7 +277,14 @@ public class PolicyController { } catch (IllegalArgumentException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); } + // Snapshot the previous version before saving so supporting files this edit dropped can + // be cleaned up once nothing references them. + Policy previous = + owned.id() == null || owned.id().isBlank() + ? null + : policyStore.get(owned.id()).orElse(null); Policy saved = policyStore.save(owned); + assetCleaner.cleanupAfterSave(previous, saved); // Re-sync trigger registrations now so a new/changed folder-watch policy starts being // watched immediately instead of after the next reconcile sweep. policyTriggerManager.notifyPoliciesChanged(); @@ -497,10 +506,10 @@ public class PolicyController { public ResponseEntity deletePolicy(@PathVariable String policyId) { requirePolicyEditingAllowed(); // Scope to the caller's team: a policy in another team reads as not-found. - boolean accessible = - policyStore.get(policyId).filter(policyAccessGuard::canAccess).isPresent(); - if (accessible && policyStore.delete(policyId)) { + Policy policy = policyStore.get(policyId).filter(policyAccessGuard::canAccess).orElse(null); + if (policy != null && policyStore.delete(policyId)) { processedLedger.clearPolicy(policyId); + assetCleaner.cleanupAfterDelete(policy); // Cancel any now-orphaned folder watch promptly rather than leaving the WatchKey open // until the next reconcile sweep. policyTriggerManager.notifyPoliciesChanged(); @@ -534,8 +543,9 @@ public class PolicyController { description = "Runs the stored policy's pipeline on the supplied files (primary documents" + " under 'fileInput', supporting files under 'assets[i].key' /" - + " 'assets[i].file'). Runs regardless of the policy's enabled flag," - + " which only gates automatic triggering. Returns a run id.") + + " 'assets[i].file' - only for bindings the policy does not already" + + " store). Runs regardless of the policy's enabled flag, which only" + + " gates automatic triggering. Returns a run id.") public ResponseEntity> runStoredPolicy( @PathVariable String policyId, @Valid @ModelAttribute PolicyRunFiles files) throws IOException { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index 33ac70d510..a58670ac25 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -34,6 +34,7 @@ import stirling.software.common.util.ExecutorFactory; import stirling.software.common.util.JobContext; import stirling.software.proprietary.failure.FailureKind; import stirling.software.proprietary.failure.PolicyFailureRecorder; +import stirling.software.proprietary.policy.asset.PolicyAssetResolver; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineDefinition; import stirling.software.proprietary.policy.model.Policy; @@ -82,6 +83,7 @@ public class PolicyEngine { private final PolicyOutputResolver outputResolver; private final ResourceMonitor resourceMonitor; private final JobQueue jobQueue; + private final PolicyAssetResolver assetResolver; private final ExecutorService asyncExecutor = ExecutorFactory.newVirtualThreadExecutor(); @@ -156,6 +158,9 @@ public class PolicyEngine { // the owner owns those outputs. String triggeringUser = currentActingPrincipal(); String fileOwner = triggeringUser != null ? triggeringUser : policy.owner(); + // Stored supporting files (certificates, watermark images, ...) load here, before the + // async hop: worker threads have no principal, so assets bind by the policy's own team. + PolicyInputs resolved = assetResolver.resolve(policy, inputs); // Resolve the referenced output destinations live (like sourceIds), so a stored policy // delivers to each of its saved Source destinations. Unreferenced policies fall back to // their inline output. @@ -163,7 +168,13 @@ public class PolicyEngine { new PipelineDefinition( policy.name(), policy.steps(), outputResolver.resolve(policy)); return submitForPrincipal( - policy.owner(), fileOwner, policy.id(), definition, inputs, fileIdentity, listener); + policy.owner(), + fileOwner, + policy.id(), + definition, + resolved, + fileIdentity, + listener); } private PolicyRunHandle submitForPrincipal( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java index 7d9a8cf88b..1faa016c42 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java @@ -1,6 +1,8 @@ package stirling.software.proprietary.policy.engine; import java.util.List; +import java.util.Map; +import java.util.Objects; import org.springframework.stereotype.Service; @@ -9,6 +11,8 @@ import lombok.RequiredArgsConstructor; import stirling.software.common.model.tool.ToolDiagnostic; import stirling.software.common.model.tool.ToolFormat; import stirling.software.common.service.ToolChainValidator; +import stirling.software.proprietary.policy.asset.PolicyAssetRefs; +import stirling.software.proprietary.policy.asset.PolicyAssetStore; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; @@ -37,6 +41,7 @@ public class PolicyValidator { private final List outputSinks; private final List stepValidators; private final SourceStore sourceStore; + private final PolicyAssetStore assetStore; private final ToolChainValidator toolChainValidator; /** @@ -69,10 +74,46 @@ public class PolicyValidator { inputSourceFor(spec).validate(spec); } validateSteps(policy.steps()); + validateAssetReferences(policy); validateChain(policy.steps()); validateOutput(policy.output()); } + /** + * A step binding that names stored assets ({@code asset:}) must resolve in the policy's own + * team, so a saved pipeline can't fail its later (principal-less) runs on a missing file, and a + * client can't bind another team's asset by id. A binding without that prefix names a file + * supplied with the run instead, and is only checked when the run arrives. + */ + private void validateAssetReferences(Policy policy) { + for (PipelineStep step : policy.steps()) { + for (Map.Entry binding : step.fileParameters().entrySet()) { + if (!PolicyAssetRefs.isAssetRef(binding.getValue())) { + continue; + } + List ids = PolicyAssetRefs.assetIds(binding.getValue()); + if (ids.isEmpty()) { + throw new IllegalArgumentException( + "step " + + step.operation() + + " has an empty file binding for field '" + + binding.getKey() + + "'"); + } + for (String id : ids) { + // One message for absent and other-team: existence must not leak across teams. + assetStore + .get(id) + .filter(asset -> Objects.equals(asset.teamId(), policy.teamId())) + .orElseThrow( + () -> + new IllegalArgumentException( + "unknown stored file: " + id)); + } + } + } + } + /** * Reject a chain whose steps cannot run on each other. Such a policy saves fine today and only * fails part-way through its first run, which for a scheduled one may be much later. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineStep.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineStep.java index 99d8884996..00a072e895 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineStep.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineStep.java @@ -9,7 +9,9 @@ import java.util.Map; * *

{@code fileParameters} maps a tool's named file field (e.g. {@code stampImage}, beyond the * primary {@code fileInput} stream) to an asset key in the run's supporting-file store, keeping - * supporting inputs out of the document stream that flows step to step. + * supporting inputs out of the document stream that flows step to step. The key is either {@code + * asset:} for a stored supporting file or a plain name supplied with the run itself; see {@code + * PolicyAssetRefs}. */ public record PipelineStep( String operation, Map parameters, Map fileParameters) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java index de6be8ef5d..70d67bba0f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java @@ -8,6 +8,7 @@ import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import stirling.software.proprietary.policy.asset.PolicyAssetRefs; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyBinding; @@ -76,6 +77,15 @@ public class InProcessPolicyStore implements PolicyStore { .toList(); } + @Override + public boolean anyPolicyReferences(String assetId) { + return policies.values().stream() + .anyMatch( + policy -> + PolicyAssetRefs.referencedAssetIds(policy.steps()) + .contains(assetId)); + } + @Override public List findBindingsByTriggerType(String triggerType) { List enabled = policies.values().stream().filter(Policy::enabled).toList(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java index 4f91f9e930..6edaa76c78 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java @@ -120,6 +120,11 @@ public class JpaPolicyStore implements PolicyStore { .toList(); } + @Override + public boolean anyPolicyReferences(String assetId) { + return assetId != null && !assetId.isBlank() && repository.anyMentioning(assetId); + } + @Override public List findBindingsByTriggerType(String triggerType) { List enabled = diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java index 1c8465c82a..07f31b4a9c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java @@ -35,6 +35,14 @@ public interface PolicyRepository extends JpaRepository { @Query("select p from PolicyEntity p order by coalesce(p.sortOrder, 0) asc, p.id asc") List findAllOrdered(); + /** + * Whether any policy's stored JSON mentions this id. Matched against the raw column rather than + * parsed steps so a row we can't deserialize still protects the assets it references; asset ids + * are UUIDs, so a substring false positive only ever means "keep". + */ + @Query("select count(p) > 0 from PolicyEntity p where p.policyJson like concat('%', :id, '%')") + boolean anyMentioning(@Param("id") String id); + /** * The team's policy rows, locked for the transaction (SELECT … FOR UPDATE). Appending a new * policy reads the max {@code sortOrder} from these under the lock, so two concurrent creates diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java index fa2a7e9b87..d86663d596 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java @@ -19,6 +19,13 @@ public interface PolicyStore { /** Policies owned by the given team, loaded scoped rather than fetched globally. */ List findByTeam(Long teamId); + /** + * Whether any policy mentions this stored-asset id, across every team. Asked before reclaiming + * an asset, so it must answer from the raw stored form: a row {@link #all()} skips as + * unreadable still holds its certificate hostage, and deleting that would be unrecoverable. + */ + boolean anyPolicyReferences(String assetId); + /** * Enabled inputs with the given trigger type, as {@code (policy, input)} bindings, so a * background trigger fires each input independently and pulls only its own source. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java index 3bc3807e1e..389ffa535e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java @@ -32,6 +32,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.repository", "stirling.software.proprietary.storage.repository", "stirling.software.proprietary.workflow.repository", + "stirling.software.proprietary.policy.asset", "stirling.software.proprietary.policy.store", "stirling.software.proprietary.policy.source", "stirling.software.proprietary.policy.migration", @@ -46,6 +47,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.model", "stirling.software.proprietary.storage.model", "stirling.software.proprietary.workflow.model", + "stirling.software.proprietary.policy.asset", "stirling.software.proprietary.policy.store", "stirling.software.proprietary.policy.source", "stirling.software.proprietary.policy.migration", diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/CredentialEncryptionTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/CredentialEncryptionTest.java index e6e5d356bf..9f424e6367 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/CredentialEncryptionTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/CredentialEncryptionTest.java @@ -61,4 +61,47 @@ class CredentialEncryptionTest { assertThatThrownBy(() -> CredentialEncryption.decrypt(tampered)) .isInstanceOf(IllegalStateException.class); } + + @Test + void encryptStaysBase64SoStringColumnsKeepTheirWireFormat() { + assertThat(Base64.getDecoder().decode(CredentialEncryption.encrypt("secret"))).isNotEmpty(); + } + + @Test + void byteRoundTripRecoversPlaintext() { + // Not valid UTF-8: the byte path must not go via a charset. + byte[] plaintext = new byte[] {0, -1, 0x7F, -128}; + + byte[] encrypted = CredentialEncryption.encryptBytes(plaintext); + + assertThat(encrypted).isNotEqualTo(plaintext); + assertThat(CredentialEncryption.decryptBytes(encrypted)).isEqualTo(plaintext); + } + + @Test + void sameBytesProduceDifferentCiphertext() { + byte[] plaintext = "repeated-secret".getBytes(); + + byte[] first = CredentialEncryption.encryptBytes(plaintext); + byte[] second = CredentialEncryption.encryptBytes(plaintext); + + assertThat(first).isNotEqualTo(second); + assertThat(CredentialEncryption.decryptBytes(first)).isEqualTo(plaintext); + assertThat(CredentialEncryption.decryptBytes(second)).isEqualTo(plaintext); + } + + @Test + void tamperedBlobIsRejected() { + byte[] encrypted = CredentialEncryption.encryptBytes("top-secret".getBytes()); + encrypted[encrypted.length - 1] ^= 0x01; + + assertThatThrownBy(() -> CredentialEncryption.decryptBytes(encrypted)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void nullBytesPassThrough() { + assertThat(CredentialEncryption.encryptBytes(null)).isNull(); + assertThat(CredentialEncryption.decryptBytes(null)).isNull(); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/JpaPolicyAssetStoreDbTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/JpaPolicyAssetStoreDbTest.java new file mode 100644 index 0000000000..4677166aeb --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/JpaPolicyAssetStoreDbTest.java @@ -0,0 +1,127 @@ +package stirling.software.proprietary.policy.asset; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurationPackage; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; + +import stirling.software.proprietary.integration.crypto.CredentialEncryption; + +/** + * {@link JpaPolicyAssetStore} on a real (H2) database: proves the entity (including its LOB column) + * creates via ddl-auto, that bytes round-trip while sitting encrypted in the column, and that the + * metadata projections select every field in the right order. + */ +@DataJpaTest +@Import(CredentialEncryption.class) +@TestPropertySource( + properties = + "stirling.security.credentialEncryptionKey=" + + "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=") +class JpaPolicyAssetStoreDbTest { + + @Autowired private PolicyAssetRepository repository; + + @Test + void savesAndReadsBackMetadataAndContent() { + JpaPolicyAssetStore store = new JpaPolicyAssetStore(repository); + byte[] content = new byte[] {1, 2, 3, 4}; + + PolicyAsset saved = + store.save( + new PolicyAsset(null, "logo.png", "image/png", 0, "owner", 7L, 42L), + content); + + assertFalse(saved.id().isBlank()); + assertEquals(content.length, saved.size()); + PolicyAsset read = store.get(saved.id()).orElseThrow(); + // Every field: a transposed projection argument is invisible to the compiler. + assertEquals(saved, read); + assertEquals("logo.png", read.fileName()); + assertEquals("image/png", read.contentType()); + assertEquals("owner", read.owner()); + assertEquals(content.length, read.size()); + assertEquals(7L, read.teamId()); + assertEquals(42L, read.createdAt()); + assertArrayEquals(content, store.content(saved.id()).orElseThrow()); + } + + @Test + void storesTheBytesEncrypted() { + JpaPolicyAssetStore store = new JpaPolicyAssetStore(repository); + byte[] content = "-----BEGIN PRIVATE KEY-----".getBytes(); + + PolicyAsset saved = + store.save(new PolicyAsset(null, "cert.p12", null, 0, null, null, 1L), content); + + byte[] atRest = repository.findById(saved.id()).orElseThrow().getData(); + assertFalse(Arrays.equals(content, atRest)); + assertArrayEquals(content, CredentialEncryption.decryptBytes(atRest)); + // Plaintext length, not the ciphertext's: it is the size the UI shows. + assertEquals(content.length, saved.size()); + } + + @Test + void findByTeamScopesRowsAndMatchesNullTeam() { + JpaPolicyAssetStore store = new JpaPolicyAssetStore(repository); + PolicyAsset teamAsset = + store.save(new PolicyAsset(null, "a.pdf", null, 0, null, 7L, 1L), new byte[] {1}); + PolicyAsset noTeamAsset = + store.save(new PolicyAsset(null, "b.pdf", null, 0, null, null, 2L), new byte[] {2}); + + List team = store.findByTeam(7L); + List noTeam = store.findByTeam(null); + + assertEquals(List.of(teamAsset), team); + assertEquals(List.of(noTeamAsset.id()), noTeam.stream().map(PolicyAsset::id).toList()); + } + + @Test + void allReturnsEveryAssetNewestFirst() { + JpaPolicyAssetStore store = new JpaPolicyAssetStore(repository); + PolicyAsset oldest = + store.save(new PolicyAsset(null, "a.pdf", null, 0, null, 7L, 1L), new byte[] {1}); + PolicyAsset newest = + store.save(new PolicyAsset(null, "b.pdf", null, 0, null, 99L, 3L), new byte[] {2}); + + assertEquals( + List.of(newest.id(), oldest.id()), + store.all().stream().map(PolicyAsset::id).toList()); + } + + @Test + void idsCreatedBeforeSelectsOnlyOlderRows() { + JpaPolicyAssetStore store = new JpaPolicyAssetStore(repository); + PolicyAsset old = + store.save(new PolicyAsset(null, "old.p12", null, 0, null, 7L, 1L), new byte[] {1}); + store.save(new PolicyAsset(null, "new.p12", null, 0, null, 7L, 1000L), new byte[] {2}); + + assertEquals(List.of(old.id()), store.idsCreatedBefore(500L)); + } + + @Test + void deleteRemovesTheRow() { + JpaPolicyAssetStore store = new JpaPolicyAssetStore(repository); + PolicyAsset saved = + store.save(new PolicyAsset(null, "x.p12", null, 0, null, null, 1L), new byte[] {1}); + + assertTrue(store.delete(saved.id())); + assertFalse(store.get(saved.id()).isPresent()); + assertFalse(store.delete(saved.id())); + } + + @SpringBootConfiguration + @AutoConfigurationPackage + static class TestApp {} +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/PolicyAssetCleanerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/PolicyAssetCleanerTest.java new file mode 100644 index 0000000000..7d7fbb5d97 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/PolicyAssetCleanerTest.java @@ -0,0 +1,195 @@ +package stirling.software.proprietary.policy.asset; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.store.InProcessPolicyStore; +import stirling.software.proprietary.policy.store.PolicyStore; + +/** + * Tests for {@link PolicyAssetCleaner}: assets a policy stops referencing are deleted once no other + * policy in the team references them, never across teams, and uploads no policy ever bound are + * reclaimed by the sweep once they are old enough. + */ +class PolicyAssetCleanerTest { + + private static final Instant NOW = Instant.ofEpochMilli(10_000_000_000L); + + private final InProcessPolicyAssetStore assetStore = new InProcessPolicyAssetStore(); + private final PolicyStore policyStore = new InProcessPolicyStore(); + private final PolicyAssetCleaner cleaner = + new PolicyAssetCleaner(assetStore, policyStore, () -> NOW); + + @Test + void deletesAssetsDroppedByASave() { + PolicyAsset dropped = asset("old.png", 7L); + PolicyAsset kept = asset("kept.png", 7L); + Policy previous = + savedPolicy( + "p1", + 7L, + step("watermarkImage", dropped.id()), + step("stampImage", kept.id())); + Policy saved = savedPolicy("p1", 7L, step("stampImage", kept.id())); + + cleaner.cleanupAfterSave(previous, saved); + + assertFalse(assetStore.get(dropped.id()).isPresent()); + assertTrue(assetStore.get(kept.id()).isPresent()); + } + + @Test + void keepsAnAssetAnotherPolicyStillReferences() { + PolicyAsset shared = asset("shared.p12", 7L); + savedPolicy("other", 7L, step("p12File", shared.id())); + Policy deleted = policy("gone", 7L, true, step("p12File", shared.id())); + + cleaner.cleanupAfterDelete(deleted); + + assertTrue(assetStore.get(shared.id()).isPresent()); + } + + @Test + void keepsAnAssetOnlyAPausedPolicyReferences() { + // Pausing is a re-save with enabled=false, so reference counting must span every policy in + // the team, not just the enabled ones. + PolicyAsset shared = asset("paused.p12", 7L); + policyStore.save(policy("paused", 7L, false, step("p12File", shared.id()))); + Policy deleted = policy("gone", 7L, true, step("p12File", shared.id())); + + cleaner.cleanupAfterDelete(deleted); + + assertTrue(assetStore.get(shared.id()).isPresent()); + } + + @Test + void deletesAssetsAfterTheirLastReferencingPolicyIsDeleted() { + PolicyAsset orphaned = asset("orphan.png", 7L); + Policy deleted = policy("gone", 7L, true, step("watermarkImage", orphaned.id())); + + cleaner.cleanupAfterDelete(deleted); + + assertFalse(assetStore.get(orphaned.id()).isPresent()); + } + + @Test + void neverDeletesAnotherTeamsAsset() { + PolicyAsset foreign = asset("foreign.png", 99L); + Policy deleted = policy("gone", 7L, true, step("watermarkImage", foreign.id())); + + cleaner.cleanupAfterDelete(deleted); + + assertTrue(assetStore.get(foreign.id()).isPresent()); + } + + @Test + void ignoresARunSuppliedFileKey() { + // No asset: prefix, so the binding names a run-supplied file and nothing is a candidate. + PolicyAsset unrelated = asset("unrelated.png", 7L); + Policy deleted = + policy( + "gone", + 7L, + true, + new PipelineStep("/api/v1/x", Map.of(), Map.of("watermarkImage", "logo"))); + + cleaner.cleanupAfterDelete(deleted); + + assertTrue(assetStore.get(unrelated.id()).isPresent()); + } + + @Test + void swallowsAStoreFailureSoTheCallersOwnWorkIsNotLost() { + // The policy write has already committed by the time cleanup runs. + PolicyAsset orphaned = asset("orphan.png", 7L); + PolicyStore failing = mock(PolicyStore.class); + when(failing.findByTeam(any())).thenThrow(new RuntimeException("db down")); + PolicyAssetCleaner guarded = new PolicyAssetCleaner(assetStore, failing, () -> NOW); + Policy deleted = policy("gone", 7L, true, step("watermarkImage", orphaned.id())); + + assertDoesNotThrow(() -> guarded.cleanupAfterDelete(deleted)); + assertTrue(assetStore.get(orphaned.id()).isPresent()); + } + + @Test + void sweepDeletesAnUploadNoPolicyEverReferenced() { + PolicyAsset abandoned = agedAsset("abandoned.p12", NOW.toEpochMilli() - dayMillis(2), 7L); + + cleaner.sweepAbandonedUploads(); + + assertFalse(assetStore.get(abandoned.id()).isPresent()); + } + + @Test + void sweepKeepsARecentUpload() { + // The grace window is what stops the sweep racing a builder session that is mid-save. + PolicyAsset justUploaded = agedAsset("fresh.p12", NOW.toEpochMilli() - 60_000L, 7L); + + cleaner.sweepAbandonedUploads(); + + assertTrue(assetStore.get(justUploaded.id()).isPresent()); + } + + @Test + void sweepKeepsAnOldAssetAPolicyStillReferences() { + PolicyAsset bound = agedAsset("bound.p12", NOW.toEpochMilli() - dayMillis(30), 7L); + savedPolicy("p1", 7L, step("p12File", bound.id())); + + cleaner.sweepAbandonedUploads(); + + assertTrue(assetStore.get(bound.id()).isPresent()); + } + + @Test + void sweepReclaimsAcrossTeams() { + // An abandoned upload is unreferenced everywhere or nowhere, so the sweep is not scoped. + PolicyAsset teamSeven = agedAsset("seven.png", NOW.toEpochMilli() - dayMillis(2), 7L); + PolicyAsset teamNinetyNine = agedAsset("99.png", NOW.toEpochMilli() - dayMillis(2), 99L); + + cleaner.sweepAbandonedUploads(); + + assertFalse(assetStore.get(teamSeven.id()).isPresent()); + assertFalse(assetStore.get(teamNinetyNine.id()).isPresent()); + } + + private static long dayMillis(int days) { + return days * 24L * 60L * 60L * 1000L; + } + + private PolicyAsset asset(String name, Long teamId) { + return assetStore.save( + new PolicyAsset(null, name, null, 0, "owner", teamId, 1L), new byte[] {1}); + } + + private PolicyAsset agedAsset(String name, long createdAt, Long teamId) { + return assetStore.save( + new PolicyAsset(null, name, null, 0, "owner", teamId, createdAt), new byte[] {1}); + } + + private static PipelineStep step(String field, String assetId) { + return new PipelineStep( + "/api/v1/x", Map.of(), Map.of(field, PolicyAssetRefs.PREFIX + assetId)); + } + + private static Policy policy(String id, Long teamId, boolean enabled, PipelineStep... steps) { + return new Policy( + id, id, "owner", enabled, List.of(), List.of(steps), OutputSpec.inline(), teamId); + } + + private Policy savedPolicy(String id, Long teamId, PipelineStep... steps) { + return policyStore.save(policy(id, teamId, true, steps)); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/PolicyAssetControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/PolicyAssetControllerTest.java new file mode 100644 index 0000000000..f2e3ea147b --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/PolicyAssetControllerTest.java @@ -0,0 +1,243 @@ +package stirling.software.proprietary.policy.asset; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.springframework.http.HttpHeaders; +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.proprietary.policy.config.PolicyAccessGuard; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.store.InProcessPolicyStore; +import stirling.software.proprietary.policy.store.PolicyStore; + +@ExtendWith(MockitoExtension.class) +@DisplayName("PolicyAssetController") +class PolicyAssetControllerTest { + + @Mock private PolicyAccessGuard policyAccessGuard; + @Mock private PolicyManagementAuthority policyManagementAuthority; + + private final PolicyAssetStore assetStore = new InProcessPolicyAssetStore(); + private final PolicyStore policyStore = new InProcessPolicyStore(); + private ApplicationProperties applicationProperties; + private PolicyAssetController controller; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + controller = + new PolicyAssetController( + assetStore, + policyStore, + policyAccessGuard, + policyManagementAuthority, + applicationProperties); + } + + @Nested + @DisplayName("upload") + class Upload { + + @Test + void stampsTheCallersOwnerAndTeamAndTheActualByteLength() throws Exception { + // Owner/team come from the guard, never the request: a client cannot forge either. + applicationProperties.getSecurity().setEnableLogin(false); + when(policyAccessGuard.ownerForNewPolicy()).thenReturn("lead@example.com"); + when(policyAccessGuard.teamForNewPolicy()).thenReturn(7L); + + PolicyAsset saved = controller.upload(file("logo.png", "image/png", "abcd")).getBody(); + + assertThat(saved).isNotNull(); + assertThat(saved.id()).isNotBlank(); + assertThat(saved.owner()).isEqualTo("lead@example.com"); + assertThat(saved.teamId()).isEqualTo(7L); + assertThat(saved.size()).isEqualTo(4); + } + + @Test + void stripsAnyPathFromTheSuppliedFilename() throws Exception { + applicationProperties.getSecurity().setEnableLogin(false); + + PolicyAsset saved = + controller.upload(file("../../etc/passwd", "text/plain", "x")).getBody(); + + assertThat(saved).isNotNull(); + assertThat(saved.fileName()).isEqualTo("passwd"); + } + + @Test + void rejectsAnEmptyUpload() { + applicationProperties.getSecurity().setEnableLogin(false); + + assertThatThrownBy(() -> controller.upload(file("empty.png", "image/png", ""))) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void rejectsANonLeaderWhenLoginIsEnabled() { + applicationProperties.getSecurity().setEnableLogin(true); + when(policyManagementAuthority.canEditPolicies()).thenReturn(false); + + assertThatThrownBy(() -> controller.upload(file("c.p12", null, "x"))) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + } + + @Nested + @DisplayName("content") + class Content { + + @Test + void needsTheSameAuthorityAsUploading() { + // Supporting files include signing certificates: reading the bytes back is gated on + // who may manage policies, not merely on team membership. + applicationProperties.getSecurity().setEnableLogin(true); + when(policyManagementAuthority.canEditPolicies()).thenReturn(false); + PolicyAsset asset = store("cert.p12", 7L, "secret"); + + assertThatThrownBy(() -> controller.content(asset.id())) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void readsAnotherTeamsAssetAsNotFound() { + applicationProperties.getSecurity().setEnableLogin(true); + when(policyManagementAuthority.canEditPolicies()).thenReturn(true); + PolicyAsset asset = store("cert.p12", 9L, "secret"); + when(policyAccessGuard.canAccess(asset)).thenReturn(false); + + assertThatThrownBy(() -> controller.content(asset.id())) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void encodesANonAsciiFilenameInTheContentDispositionHeader() { + applicationProperties.getSecurity().setEnableLogin(false); + PolicyAsset asset = store("café.png", null, "bytes"); + when(policyAccessGuard.canAccess(asset)).thenReturn(true); + + String disposition = + controller + .content(asset.id()) + .getHeaders() + .getFirst(HttpHeaders.CONTENT_DISPOSITION); + + // RFC 5987 form, not the raw non-ASCII byte that would mangle in the header. + assertThat(disposition).contains("filename*=UTF-8''caf%C3%A9.png"); + } + + @Test + void fallsBackToOctetStreamForAnUnparsableStoredContentType() { + applicationProperties.getSecurity().setEnableLogin(false); + PolicyAsset asset = + assetStore.save( + new PolicyAsset(null, "x.bin", "not/a/media/type", 0, null, null, 1L), + "bytes".getBytes()); + when(policyAccessGuard.canAccess(asset)).thenReturn(true); + + assertThat(controller.content(asset.id()).getHeaders().getContentType()) + .hasToString("application/octet-stream"); + } + } + + @Nested + @DisplayName("delete") + class Delete { + + @Test + void refusesWhileAPipelineStepStillReferencesTheAsset() { + applicationProperties.getSecurity().setEnableLogin(false); + PolicyAsset asset = store("stamp.png", null, "bytes"); + when(policyAccessGuard.canAccess(asset)).thenReturn(true); + policyStore.save(policyBinding(asset.id())); + when(policyAccessGuard.visibleFrom(policyStore)) + .thenReturn(policyStore.findByTeam(null)); + + assertThatThrownBy(() -> controller.delete(asset.id())) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.CONFLICT); + assertThat(assetStore.get(asset.id())).isPresent(); + } + + @Test + void removesAnAssetNoStepReferences() { + applicationProperties.getSecurity().setEnableLogin(false); + PolicyAsset asset = store("old.png", null, "bytes"); + when(policyAccessGuard.canAccess(asset)).thenReturn(true); + when(policyAccessGuard.visibleFrom(policyStore)).thenReturn(List.of()); + + assertThat(controller.delete(asset.id()).getStatusCode()) + .isEqualTo(HttpStatus.NO_CONTENT); + assertThat(assetStore.get(asset.id())).isEmpty(); + } + + @Test + void seesOneIdInsideAMultiFileBindingAsStillReferenced() { + // A field carrying several files stores its ids comma-joined; each one still counts. + applicationProperties.getSecurity().setEnableLogin(false); + PolicyAsset first = store("a.pdf", null, "a"); + PolicyAsset second = store("b.pdf", null, "b"); + when(policyAccessGuard.canAccess(second)).thenReturn(true); + policyStore.save(policyBinding(first.id() + "," + second.id())); + when(policyAccessGuard.visibleFrom(policyStore)) + .thenReturn(policyStore.findByTeam(null)); + + assertThatThrownBy(() -> controller.delete(second.id())) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.CONFLICT); + } + } + + private PolicyAsset store(String fileName, Long teamId, String content) { + return assetStore.save( + new PolicyAsset(null, fileName, null, 0, null, teamId, 1L), content.getBytes()); + } + + private static Policy policyBinding(String assetIds) { + return new Policy( + "p1", + "p", + "owner", + true, + List.of(PipelineInput.manual("s1")), + List.of( + new PipelineStep( + "/api/v1/security/cert-sign", + Map.of(), + Map.of("certFile", PolicyAssetRefs.PREFIX + assetIds))), + OutputSpec.inline()); + } + + private static MockMultipartFile file(String name, String contentType, String content) { + return new MockMultipartFile("file", name, contentType, content.getBytes()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/PolicyAssetResolverTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/PolicyAssetResolverTest.java new file mode 100644 index 0000000000..b71536ffa6 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/PolicyAssetResolverTest.java @@ -0,0 +1,243 @@ +package stirling.software.proprietary.policy.asset; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; + +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyInputs; + +/** + * Tests for {@link PolicyAssetResolver}: stored assets referenced by a policy's steps load into the + * run's supporting files, keyed by the step's binding value, team-checked, and winning over + * anything the run supplied under the same key. + */ +class PolicyAssetResolverTest { + + private final InProcessPolicyAssetStore store = new InProcessPolicyAssetStore(); + private final PolicyAssetResolver resolver = new PolicyAssetResolver(store); + + @Test + void loadsReferencedAssetsUnderTheStepBindingKey() throws IOException { + PolicyAsset image = save("logo.png", 7L, new byte[] {1, 2}); + String key = ref(image.id()); + Policy policy = + policy( + 7L, + new PipelineStep( + "/api/v1/security/add-watermark", + Map.of(), + Map.of("watermarkImage", key))); + + PolicyInputs resolved = resolver.resolve(policy, PolicyInputs.of(List.of())); + + List bound = resolved.supportingFiles().get(key); + assertEquals(1, bound.size()); + assertEquals("logo.png", bound.get(0).getFilename()); + assertArrayEquals(new byte[] {1, 2}, bound.get(0).getContentAsByteArray()); + } + + @Test + void aCommaSeparatedBindingLoadsEveryAssetUnderTheFullKey() { + PolicyAsset first = save("a.pdf", null, new byte[] {1}); + PolicyAsset second = save("b.pdf", null, new byte[] {2}); + String key = ref(first.id() + "," + second.id()); + Policy policy = + policy( + null, + new PipelineStep( + "/api/v1/general/overlay-pdfs", + Map.of(), + Map.of("overlayFiles", key))); + + PolicyInputs resolved = resolver.resolve(policy, PolicyInputs.of(List.of())); + + assertEquals(2, resolved.supportingFiles().get(key).size()); + } + + @Test + void storedAssetsWinOverRunSuppliedOnes() throws IOException { + // Runs are open to the whole team, so a member must not be able to swap a leader-pinned + // certificate by posting their own file under the binding's key. + PolicyAsset stored = save("stored.png", null, new byte[] {9}); + String key = ref(stored.id()); + Policy policy = + policy( + null, + new PipelineStep( + "/api/v1/security/add-watermark", + Map.of(), + Map.of("watermarkImage", key))); + Resource supplied = new ByteArrayResource(new byte[] {5}); + PolicyInputs inputs = new PolicyInputs(List.of(), Map.of(key, List.of(supplied))); + + PolicyInputs resolved = resolver.resolve(policy, inputs); + + assertArrayEquals( + new byte[] {9}, resolved.supportingFiles().get(key).get(0).getContentAsByteArray()); + } + + @Test + void aLaterStepsStoredBindingStillWinsOverARunSuppliedFile() { + // The first resolved key seeds the merged map from the run's own files; the second must + // still overwrite its run-supplied entry rather than read as already resolved. + PolicyAsset first = save("first.png", null, new byte[] {1}); + PolicyAsset second = save("second.p12", null, new byte[] {2}); + String firstKey = ref(first.id()); + String secondKey = ref(second.id()); + Policy policy = + new Policy( + "p1", + "p", + "owner", + true, + List.of(), + List.of( + new PipelineStep( + "/api/v1/security/add-watermark", + Map.of(), + Map.of("watermarkImage", firstKey)), + new PipelineStep( + "/api/v1/security/cert-sign", + Map.of(), + Map.of("p12File", secondKey))), + OutputSpec.inline(), + null); + Resource supplied = new ByteArrayResource(new byte[] {5}); + PolicyInputs inputs = new PolicyInputs(List.of(), Map.of(secondKey, List.of(supplied))); + + PolicyInputs resolved = resolver.resolve(policy, inputs); + + assertEquals("second.p12", resolved.supportingFiles().get(secondKey).get(0).getFilename()); + } + + @Test + void aRunSuppliedKeyIsLeftAlone() { + // Bindings without the asset: prefix name a file uploaded with the run, as they did before + // supporting files could be stored. + Policy policy = + policy( + null, + new PipelineStep( + "/api/v1/security/add-watermark", + Map.of(), + Map.of("watermarkImage", "company-logo"))); + Resource supplied = new ByteArrayResource(new byte[] {5}); + PolicyInputs inputs = + new PolicyInputs(List.of(), Map.of("company-logo", List.of(supplied))); + + PolicyInputs resolved = resolver.resolve(policy, inputs); + + assertSame(inputs, resolved); + assertSame(supplied, resolved.supportingFiles().get("company-logo").get(0)); + } + + @Test + void anotherTeamsAssetDoesNotResolve() { + PolicyAsset foreign = save("secret.p12", 99L, new byte[] {1}); + String key = ref(foreign.id()); + Policy policy = + policy( + 7L, + new PipelineStep( + "/api/v1/security/cert-sign", Map.of(), Map.of("p12File", key))); + + PolicyInputs resolved = resolver.resolve(policy, PolicyInputs.of(List.of())); + + assertFalse(resolved.supportingFiles().containsKey(key)); + } + + @Test + void aBindingWithOneUnresolvableIdResolvesToNothing() { + // Half a binding would run the step short a file; drop it so the executor fails the run. + PolicyAsset present = save("a.pdf", null, new byte[] {1}); + String key = ref(present.id() + ",missing-id"); + Policy policy = + policy( + null, + new PipelineStep( + "/api/v1/general/overlay-pdfs", + Map.of(), + Map.of("overlayFiles", key))); + PolicyInputs inputs = PolicyInputs.of(List.of()); + + PolicyInputs resolved = resolver.resolve(policy, inputs); + + assertSame(inputs, resolved); + assertFalse(resolved.supportingFiles().containsKey(key)); + } + + @Test + void aDeadStoredBindingDoesNotFallBackToTheRunSuppliedFile() { + // Otherwise losing the pinned asset would hand the binding straight back to the member + // running the policy - the very override stored assets are meant to prevent. + String key = ref("missing-id"); + Policy policy = + policy( + 7L, + new PipelineStep( + "/api/v1/security/cert-sign", Map.of(), Map.of("p12File", key))); + PolicyInputs inputs = + new PolicyInputs( + List.of(), Map.of(key, List.of(new ByteArrayResource(new byte[] {5})))); + + PolicyInputs resolved = resolver.resolve(policy, inputs); + + // Key absent, so the executor raises its missing-supporting-file error and the run fails. + assertFalse(resolved.supportingFiles().containsKey(key)); + } + + @Test + void aBindingWhoseSecondIdIsAnotherTeamsResolvesToNothing() { + PolicyAsset own = save("own.pdf", 7L, new byte[] {1}); + PolicyAsset foreign = save("foreign.pdf", 99L, new byte[] {2}); + String key = ref(own.id() + "," + foreign.id()); + Policy policy = + policy( + 7L, + new PipelineStep( + "/api/v1/general/overlay-pdfs", + Map.of(), + Map.of("overlayFiles", key))); + + PolicyInputs resolved = resolver.resolve(policy, PolicyInputs.of(List.of())); + + assertFalse(resolved.supportingFiles().containsKey(key)); + } + + @Test + void stepsWithoutBindingsLeaveInputsUntouched() { + Policy policy = policy(null, new PipelineStep("/api/v1/misc/compress-pdf", Map.of())); + PolicyInputs inputs = PolicyInputs.of(List.of()); + + assertSame(inputs, resolver.resolve(policy, inputs)); + assertTrue(inputs.supportingFiles().isEmpty()); + } + + private static String ref(String ids) { + return PolicyAssetRefs.PREFIX + ids; + } + + private PolicyAsset save(String name, Long teamId, byte[] content) { + return store.save( + new PolicyAsset(null, name, "application/octet-stream", 0, "owner", teamId, 1L), + content); + } + + private static Policy policy(Long teamId, PipelineStep step) { + return new Policy( + "p1", "p", "owner", true, List.of(), List.of(step), OutputSpec.inline(), teamId); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java index 6cafde14c8..43477aa9ed 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -80,6 +80,8 @@ class PolicyControllerTest { private stirling.software.proprietary.policy.overview.PolicyOverviewService policyOverviewService; + @Mock private stirling.software.proprietary.policy.asset.PolicyAssetCleaner assetCleaner; + @Mock private ProcessedLedger processedLedger; @Mock private TempFileManager tempFileManager; @@ -111,6 +113,7 @@ class PolicyControllerTest { policyManagementAuthority, policyTriggerManager, policyOverviewService, + assetCleaner, processedLedger, policyTriggers, applicationProperties, @@ -473,6 +476,22 @@ class PolicyControllerTest { assertThat(response.getBody().owner()).isEqualTo("origOwner"); assertThat(response.getBody().teamId()).isEqualTo(3L); } + + @Test + @DisplayName("hands the pre-save version to the asset cleaner") + void cleansUpAssetsTheEditDropped() { + applicationProperties.getSecurity().setEnableLogin(false); + Policy existing = + new Policy("p2", "name", "owner", true, List.of(), List.of(), null, 3L); + when(policyStore.get("p2")).thenReturn(Optional.of(existing)); + when(policyAccessGuard.canAccess(existing)).thenReturn(true); + when(policyStore.save(any())).thenAnswer(i -> i.getArgument(0)); + + controller.savePolicy( + new Policy("p2", "name", "owner", true, List.of(), List.of(), null, 3L)); + + verify(assetCleaner).cleanupAfterSave(eq(existing), any()); + } } @Nested @@ -557,6 +576,7 @@ class PolicyControllerTest { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); verify(processedLedger).clearPolicy("a"); + verify(assetCleaner).cleanupAfterDelete(p); verify(policyTriggerManager).notifyPoliciesChanged(); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java index 479dccd3ca..b3aaf291f9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java @@ -55,6 +55,8 @@ import stirling.software.common.service.ToolMetadataService; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.TempFileRegistry; import stirling.software.proprietary.failure.PolicyFailureRecorder; +import stirling.software.proprietary.policy.asset.InProcessPolicyAssetStore; +import stirling.software.proprietary.policy.asset.PolicyAssetResolver; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineDefinition; import stirling.software.proprietary.policy.model.PipelineStep; @@ -124,7 +126,8 @@ class PolicyEngineTest { List.of(sink, recordingSink), outputResolver, resourceMonitor, - jobQueue); + jobQueue, + new PolicyAssetResolver(new InProcessPolicyAssetStore())); // Identity scoping: the run id is the generated UUID unchanged. Lenient because the // resume/cancel tests do not submit a run. diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java index 074adc2b36..2928dd3b65 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java @@ -23,6 +23,10 @@ import stirling.software.common.model.tool.ToolFormat; import stirling.software.common.model.tool.ToolIOSource; import stirling.software.common.model.tool.ToolIOSpec; import stirling.software.common.service.ToolChainValidator; +import stirling.software.proprietary.policy.asset.InProcessPolicyAssetStore; +import stirling.software.proprietary.policy.asset.PolicyAsset; +import stirling.software.proprietary.policy.asset.PolicyAssetRefs; +import stirling.software.proprietary.policy.asset.PolicyAssetStore; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; @@ -46,6 +50,7 @@ class PolicyValidatorTest { @Mock private PipelineStepValidator stepValidator; private final SourceStore sourceStore = new InProcessSourceStore(); + private final PolicyAssetStore assetStore = new InProcessPolicyAssetStore(); private PolicyValidator validator; @BeforeEach @@ -57,6 +62,7 @@ class PolicyValidatorTest { List.of(outputSink), List.of(stepValidator), sourceStore, + assetStore, new ToolChainValidator(path -> java.util.Optional.empty())); } @@ -120,6 +126,90 @@ class PolicyValidatorTest { () -> validator.validateOutput(new OutputSpec("s3", Map.of("connectionId", 1)))); } + @Test + void acceptsAStepBindingThatReferencesATeamAsset() { + when(inputSource.supports(any())).thenReturn(true); + when(outputSink.supports(any())).thenReturn(true); + PolicyAsset asset = + assetStore.save( + new PolicyAsset(null, "logo.png", null, 0, "owner", null, 1L), + new byte[] {1}); + + validator.validate(withFileBinding(PolicyAssetRefs.PREFIX + asset.id(), null)); + } + + @Test + void acceptsARunSuppliedFileKey() { + // No asset: prefix, so the binding names a file uploaded with the run - it existed before + // stored assets did, and pausing such a policy must not start failing. + when(inputSource.supports(any())).thenReturn(true); + when(outputSink.supports(any())).thenReturn(true); + + validator.validate(withFileBinding("company-logo", null)); + } + + @Test + void rejectsAStepBindingToAnUnknownAsset() { + when(inputSource.supports(any())).thenReturn(true); + + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> + validator.validate( + withFileBinding( + PolicyAssetRefs.PREFIX + "missing-asset", null))); + assertTrue(ex.getMessage().contains("unknown stored file")); + } + + @Test + void rejectsAStepBindingToAnotherTeamsAsset() { + when(inputSource.supports(any())).thenReturn(true); + PolicyAsset foreign = + assetStore.save( + new PolicyAsset(null, "secret.p12", null, 0, "owner", 99L, 1L), + new byte[] {1}); + + // Policy has no team; the asset belongs to team 99 - must read as unknown, not leak. + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> + validator.validate( + withFileBinding( + PolicyAssetRefs.PREFIX + foreign.id(), null))); + assertTrue(ex.getMessage().contains("unknown stored file")); + } + + @Test + void rejectsAnAssetBindingWithNoIds() { + when(inputSource.supports(any())).thenReturn(true); + + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> validator.validate(withFileBinding(PolicyAssetRefs.PREFIX, null))); + assertTrue(ex.getMessage().contains("empty file binding")); + } + + /** A manual-only policy whose single step binds a file field to the given asset key. */ + private Policy withFileBinding(String assetKey, Long teamId) { + PipelineStep step = + new PipelineStep( + "/api/v1/security/add-watermark", + Map.of(), + Map.of("watermarkImage", assetKey)); + return new Policy( + "p1", + "p", + "owner", + true, + List.of(PipelineInput.manual(folderSourceId())), + List.of(step), + OutputSpec.inline(), + teamId); + } + @Test void rejectsAnUnknownTriggerType() { when(trigger.type()).thenReturn("schedule"); @@ -184,6 +274,7 @@ class PolicyValidatorTest { List.of(outputSink), List.of(stepValidator), sourceStore, + assetStore, new ToolChainValidator(toolIO)); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreDbTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreDbTest.java new file mode 100644 index 0000000000..e690c72827 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreDbTest.java @@ -0,0 +1,65 @@ +package stirling.software.proprietary.policy.store; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurationPackage; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; + +import tools.jackson.databind.json.JsonMapper; + +/** + * {@link JpaPolicyStore#anyPolicyReferences} on a real (H2) database. It guards a scheduled DELETE + * over stored certificates, so it has to be proven against the raw column rather than the parsed + * policies {@code all()} would hand back. + */ +@DataJpaTest +class JpaPolicyStoreDbTest { + + @Autowired private PolicyRepository repository; + + private JpaPolicyStore store() { + return new JpaPolicyStore(repository, JsonMapper.builder().build()); + } + + @Test + void seesAnAssetIdMentionedByAStoredPolicy() { + save("p1", "{\"steps\":[{\"fileParameters\":{\"p12File\":\"asset:abc-123\"}}]}"); + + assertTrue(store().anyPolicyReferences("abc-123")); + assertFalse(store().anyPolicyReferences("def-456")); + } + + @Test + void seesAnAssetIdInsideAPolicyRowItCannotParse() { + // The whole point of matching raw JSON: a row all() skips as unreadable still holds its + // certificate, and reclaiming that would be unrecoverable. + save("broken", "{ this is not valid json, asset:abc-123"); + + assertTrue(store().all().isEmpty()); + assertTrue(store().anyPolicyReferences("abc-123")); + } + + @Test + void ignoresBlankIds() { + save("p1", "{\"steps\":[]}"); + + assertFalse(store().anyPolicyReferences("")); + assertFalse(store().anyPolicyReferences(null)); + } + + private void save(String id, String policyJson) { + PolicyEntity entity = new PolicyEntity(); + entity.setId(id); + entity.setName(id); + entity.setPolicyJson(policyJson); + repository.save(entity); + } + + @SpringBootConfiguration + @AutoConfigurationPackage + static class TestApp {} +}