mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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.
This commit is contained in:
+32
-14
@@ -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() {
|
||||
|
||||
+82
@@ -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<String, PolicyAsset> assets = new ConcurrentHashMap<>();
|
||||
private final Map<String, byte[]> 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<PolicyAsset> get(String id) {
|
||||
return Optional.ofNullable(assets.get(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<byte[]> content(String id) {
|
||||
return Optional.ofNullable(contents.get(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PolicyAsset> findByTeam(Long teamId) {
|
||||
return assets.values().stream()
|
||||
.filter(asset -> Objects.equals(asset.teamId(), teamId))
|
||||
.sorted(newestFirst())
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PolicyAsset> all() {
|
||||
return assets.values().stream().sorted(newestFirst()).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> 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<PolicyAsset> newestFirst() {
|
||||
return Comparator.comparingLong(PolicyAsset::createdAt)
|
||||
.reversed()
|
||||
.thenComparing(PolicyAsset::id);
|
||||
}
|
||||
}
|
||||
+95
@@ -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<PolicyAsset> get(String id) {
|
||||
return repository.findMetaById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<byte[]> 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<PolicyAsset> findByTeam(Long teamId) {
|
||||
return repository.findMetaByTeam(teamId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PolicyAsset> all() {
|
||||
return repository.findAllMeta();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> 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());
|
||||
}
|
||||
}
|
||||
+17
@@ -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) {}
|
||||
+124
@@ -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.
|
||||
*
|
||||
* <p>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<Instant> 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<Instant> 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<String> 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<String> candidates) {
|
||||
if (candidates.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Set<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+187
@@ -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:<id>} - 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:<id>', and returns"
|
||||
+ " its metadata including the assigned id.")
|
||||
public ResponseEntity<PolicyAsset> 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<PolicyAsset> 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<Resource> 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<Void> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -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;
|
||||
}
|
||||
+63
@@ -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:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code asset:<id>[,<id>...]} - stored assets, loaded from the asset store at run time.
|
||||
* Several ids appear when one tool field carries multiple files (e.g. attachments).
|
||||
* <li>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.
|
||||
* </ul>
|
||||
*
|
||||
* <p>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<String> assetIds(String fileParameterValue) {
|
||||
List<String> 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<String> referencedAssetIds(List<PipelineStep> steps) {
|
||||
Set<String> ids = new LinkedHashSet<>();
|
||||
for (PipelineStep step : steps) {
|
||||
for (String value : step.fileParameters().values()) {
|
||||
ids.addAll(assetIds(value));
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
+42
@@ -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<PolicyAssetEntity, String> {
|
||||
|
||||
/**
|
||||
* 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<PolicyAsset> 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<PolicyAsset> findMetaByTeam(@Param("teamId") Long teamId);
|
||||
|
||||
@Query(META + " order by a.createdAt desc, a.id asc")
|
||||
List<PolicyAsset> 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<String> findIdsCreatedBefore(@Param("cutoff") long cutoff);
|
||||
}
|
||||
+112
@@ -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.
|
||||
*
|
||||
* <p>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=<id>}. A binding that names no stored asset keeps whatever the run
|
||||
* supplied, which is what a binding meant before assets could be stored.
|
||||
*
|
||||
* <p>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<String, List<Resource>> 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<String> loaded = new HashSet<>();
|
||||
for (PipelineStep step : policy.steps()) {
|
||||
for (String assetKey : step.fileParameters().values()) {
|
||||
if (assetKey == null || assetKey.isBlank() || !loaded.add(assetKey)) {
|
||||
continue;
|
||||
}
|
||||
List<Resource> 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<Resource> load(Policy policy, String assetKey) {
|
||||
List<Resource> 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+32
@@ -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<PolicyAsset> get(String id);
|
||||
|
||||
/** The asset's bytes, or empty if the id is unknown. */
|
||||
Optional<byte[]> content(String id);
|
||||
|
||||
/** Assets belonging to a team, newest first. {@code null} matches no-team (login-disabled). */
|
||||
List<PolicyAsset> findByTeam(Long teamId);
|
||||
|
||||
/** All assets, for team-scoping-off (login-disabled) reads. */
|
||||
List<PolicyAsset> all();
|
||||
|
||||
/** Ids of assets uploaded before {@code cutoff} (epoch millis), for orphan reclamation. */
|
||||
List<String> idsCreatedBefore(long cutoff);
|
||||
|
||||
/** Remove an asset. Returns false if the id was unknown. */
|
||||
boolean delete(String id);
|
||||
}
|
||||
+18
@@ -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<PolicyAsset> visibleFrom(PolicyAssetStore store) {
|
||||
if (!enforced()) {
|
||||
return store.all();
|
||||
}
|
||||
return store.findByTeam(policyManagementAuthority.currentUserTeamId());
|
||||
}
|
||||
|
||||
private boolean enforced() {
|
||||
return applicationProperties.getSecurity().isEnableLogin();
|
||||
}
|
||||
|
||||
+15
-5
@@ -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<PolicyTrigger> 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<Void> 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<JobResponse<Void>> runStoredPolicy(
|
||||
@PathVariable String policyId, @Valid @ModelAttribute PolicyRunFiles files)
|
||||
throws IOException {
|
||||
|
||||
+12
-1
@@ -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(
|
||||
|
||||
+41
@@ -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<PolicyOutputSink> outputSinks;
|
||||
private final List<PipelineStepValidator> 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:<id>}) 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<String, String> binding : step.fileParameters().entrySet()) {
|
||||
if (!PolicyAssetRefs.isAssetRef(binding.getValue())) {
|
||||
continue;
|
||||
}
|
||||
List<String> 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.
|
||||
|
||||
+3
-1
@@ -9,7 +9,9 @@ import java.util.Map;
|
||||
*
|
||||
* <p>{@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:<id>} for a stored supporting file or a plain name supplied with the run itself; see {@code
|
||||
* PolicyAssetRefs}.
|
||||
*/
|
||||
public record PipelineStep(
|
||||
String operation, Map<String, Object> parameters, Map<String, String> fileParameters) {
|
||||
|
||||
+10
@@ -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<PolicyBinding> findBindingsByTriggerType(String triggerType) {
|
||||
List<Policy> enabled = policies.values().stream().filter(Policy::enabled).toList();
|
||||
|
||||
+5
@@ -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<PolicyBinding> findBindingsByTriggerType(String triggerType) {
|
||||
List<Policy> enabled =
|
||||
|
||||
+8
@@ -35,6 +35,14 @@ public interface PolicyRepository extends JpaRepository<PolicyEntity, String> {
|
||||
@Query("select p from PolicyEntity p order by coalesce(p.sortOrder, 0) asc, p.id asc")
|
||||
List<PolicyEntity> 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
|
||||
|
||||
+7
@@ -19,6 +19,13 @@ public interface PolicyStore {
|
||||
/** Policies owned by the given team, loaded scoped rather than fetched globally. */
|
||||
List<Policy> 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.
|
||||
|
||||
+2
@@ -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",
|
||||
|
||||
+43
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+127
@@ -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<PolicyAsset> team = store.findByTeam(7L);
|
||||
List<PolicyAsset> 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 {}
|
||||
}
|
||||
+195
@@ -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));
|
||||
}
|
||||
}
|
||||
+243
@@ -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());
|
||||
}
|
||||
}
|
||||
+243
@@ -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<Resource> 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);
|
||||
}
|
||||
}
|
||||
+20
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -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.
|
||||
|
||||
+91
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+65
@@ -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 {}
|
||||
}
|
||||
Reference in New Issue
Block a user