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:
Anthony Stirling
2026-08-12 15:02:10 +00:00
committed by GitHub
parent dc75d399bc
commit 7a748d4ad2
30 changed files with 1996 additions and 22 deletions
@@ -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() {
@@ -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);
}
}
@@ -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());
}
}
@@ -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) {}
@@ -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);
}
}
}
@@ -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");
}
}
}
@@ -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;
}
@@ -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;
}
}
@@ -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);
}
@@ -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;
}
};
}
}
@@ -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);
}
@@ -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();
}
@@ -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 {
@@ -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(
@@ -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.
@@ -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) {
@@ -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();
@@ -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 =
@@ -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
@@ -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.
@@ -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",
@@ -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();
}
}
@@ -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 {}
}
@@ -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));
}
}
@@ -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());
}
}
@@ -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);
}
}
@@ -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();
}
@@ -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.
@@ -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));
}
@@ -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 {}
}