Condense comments to single lines
This commit is contained in:
@@ -255,11 +255,7 @@ public class ApplicationProperties {
|
||||
*/
|
||||
private boolean allowPrivateS3Endpoints = false;
|
||||
|
||||
/**
|
||||
* Largest inbound document (bytes) the webhook source receiver will accept in one delivery.
|
||||
* A larger body is rejected with 413 before anything is written to the spool, so an
|
||||
* unauthenticated caller cannot fill the disk. Default 100 MB.
|
||||
*/
|
||||
/** Max inbound webhook body (bytes); over it is rejected 413. Default 100 MB. */
|
||||
private long webhookMaxBytes = 104857600L;
|
||||
}
|
||||
|
||||
|
||||
@@ -198,8 +198,7 @@ public class RequestUriUtils {
|
||||
|| trimmedUri.startsWith("/readiness")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|
||||
// Policy webhook source receiver - authenticated per-request by its HMAC signature,
|
||||
// not a login session, so it must bypass the session auth wall.
|
||||
// Webhook receiver: authenticated per-request by HMAC signature, not a session.
|
||||
|| trimmedUri.startsWith("/api/v1/webhooks/")
|
||||
|| trimmedUri.startsWith("/v1/api-docs")
|
||||
// Workflow participant endpoints - access controlled by share tokens, not login
|
||||
|
||||
+1
-7
@@ -23,13 +23,7 @@ public interface InputSource {
|
||||
/** Throws {@link IllegalArgumentException} on bad config. Called on save to fail fast. */
|
||||
default void validate(InputSpec spec) {}
|
||||
|
||||
/**
|
||||
* Normalise a source's options just before it is persisted, so a source type can populate
|
||||
* server-owned config the client neither supplies nor controls. {@code isCreate} is true only
|
||||
* for a brand-new source (blank id). Runs before {@link #validate}. The default returns the
|
||||
* options unchanged; the webhook source overrides it to mint its routing id and signing secret
|
||||
* on create. Must not mutate the argument.
|
||||
*/
|
||||
/** Normalise a source's options before persistence (default no-op); must not mutate the arg. */
|
||||
default Map<String, Object> prepareOptionsForSave(
|
||||
Map<String, Object> options, boolean isCreate) {
|
||||
return options;
|
||||
|
||||
+8
-46
@@ -26,25 +26,7 @@ import stirling.software.proprietary.policy.webhook.WebhookConfig;
|
||||
import stirling.software.proprietary.policy.webhook.WebhookIds;
|
||||
import stirling.software.proprietary.policy.webhook.WebhookSpool;
|
||||
|
||||
/**
|
||||
* A push source: documents arrive by signed HTTP POST to {@code /api/v1/webhooks/{webhookId}} and
|
||||
* are staged for the referencing policies. Where they are staged depends on the source's config
|
||||
* (see {@link WebhookConfig}):
|
||||
*
|
||||
* <ul>
|
||||
* <li>no {@code connectionId}: a node-local {@link WebhookSpool} directory, read exactly as
|
||||
* {@link FolderInputSource} reads a watched directory (self-hosted);
|
||||
* <li>a {@code connectionId}: the referenced S3 {@code IntegrationConfig} connection, under a
|
||||
* reserved per-webhook prefix - read, claimed, and consumed by delegating to {@link
|
||||
* S3InputSource}, so the durable, multi-node model hosted deployments need is reused, not
|
||||
* reinvented.
|
||||
* </ul>
|
||||
*
|
||||
* <p>Either way the {@link ResolveContext} ledger does the claiming, "mode" is "consume" (default:
|
||||
* a staged document is removed once every policy that claimed it has settled successfully) or
|
||||
* "snapshot" (stateless), and "webhookId"/"signingSecret" are minted server-side on create. Empty
|
||||
* staging reads as "verifiably no files".
|
||||
*/
|
||||
/** Push source: deliveries staged to an S3 connection or the local spool, read via the ledger. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -55,9 +37,7 @@ public class WebhookInputSource implements InputSource {
|
||||
|
||||
private final WebhookSpool spool;
|
||||
private final FileReadinessChecker readinessChecker;
|
||||
// Deliveries staged to an S3 connection are read/claimed/consumed exactly like an S3 source, so
|
||||
// that whole path is reused rather than reimplemented; a connection-less webhook uses the
|
||||
// spool.
|
||||
// Connection-backed staging delegates to the S3 source; else the local spool.
|
||||
private final S3InputSource s3InputSource;
|
||||
|
||||
@Override
|
||||
@@ -74,16 +54,12 @@ public class WebhookInputSource implements InputSource {
|
||||
public void validate(InputSpec spec) {
|
||||
WebhookConfig config = WebhookConfig.from(spec.options());
|
||||
if (config.usesConnection()) {
|
||||
// Resolves the connection with the caller present, so save fails if their team can't
|
||||
// use it (the ownership check the delivery path then trusts).
|
||||
// Resolve with the caller present so save fails if they can't use the connection.
|
||||
s3InputSource.validate(stagingSpec(config));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The S3 view of a webhook's durable staging: its connection plus the reserved per-webhook
|
||||
* prefix, as an {@code s3} input spec {@link S3InputSource} can resolve directly.
|
||||
*/
|
||||
/** The webhook's durable staging as an {@code s3} input spec for {@link S3InputSource}. */
|
||||
private static InputSpec stagingSpec(WebhookConfig config) {
|
||||
return new InputSpec(
|
||||
"s3",
|
||||
@@ -96,11 +72,7 @@ public class WebhookInputSource implements InputSource {
|
||||
config.mode()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint the routing id and signing secret on create, so the delivery URL and its HMAC key are
|
||||
* always server-generated. An edit (or any options that already carry a webhookId) is left
|
||||
* untouched, so the URL a sender is already configured against never changes underneath them.
|
||||
*/
|
||||
/** Mint the routing id + signing secret on create; an existing webhook is left untouched. */
|
||||
@Override
|
||||
public Map<String, Object> prepareOptionsForSave(
|
||||
Map<String, Object> options, boolean isCreate) {
|
||||
@@ -125,16 +97,12 @@ public class WebhookInputSource implements InputSource {
|
||||
public List<ResolvedInput> resolve(InputSpec spec, ResolveContext ctx) throws IOException {
|
||||
WebhookConfig config = WebhookConfig.from(spec.options());
|
||||
if (config.usesConnection()) {
|
||||
// Durable staging: the delivery objects live in the connection's bucket and are read,
|
||||
// claimed, and consumed by the S3 source (no principal here - the save-time check the
|
||||
// resolver trusts already ran).
|
||||
// Durable staging read by the S3 source (no principal; the save-time check is trusted).
|
||||
return s3InputSource.resolve(stagingSpec(config), ctx);
|
||||
}
|
||||
Path dir = spool.dirFor(config.webhookId());
|
||||
if (!Files.isDirectory(dir)) {
|
||||
// No deliveries yet (or none since the last consume): a verifiably empty source, so the
|
||||
// sweep may prune ledger rows for files that are gone. Unlike the folder source a
|
||||
// missing directory is normal here, not an unmounted-drive error.
|
||||
// No deliveries yet: verifiably empty (a missing dir is normal here, not an error).
|
||||
ctx.reportPresent(List.of());
|
||||
return List.of();
|
||||
}
|
||||
@@ -182,13 +150,7 @@ public class WebhookInputSource implements InputSource {
|
||||
return work;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle at the claimed version, then remove the spooled file only when it is still that
|
||||
* version and every policy that claimed it has settled DONE - the same consensus delete the
|
||||
* folder and S3 sources use, so a shared webhook feeding several policies keeps a delivery
|
||||
* until all are done and one failure parks it for everyone. A failed run settles ERROR and
|
||||
* never deletes.
|
||||
*/
|
||||
/** Settle, then delete when unchanged and every claimant settled DONE (consensus). */
|
||||
private static void completeConsumed(
|
||||
ResolveContext ctx, String identity, Path file, String claimGate, boolean success) {
|
||||
ctx.settle(identity, claimGate, null, success);
|
||||
|
||||
+3
-12
@@ -231,11 +231,7 @@ public class SourceController {
|
||||
.validate(spec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Let the matching source type populate server-owned config just before persistence (a webhook
|
||||
* mints its routing id and signing secret on create). An unknown type is left untouched; {@link
|
||||
* #validateConfig} then rejects it with a clear message.
|
||||
*/
|
||||
/** Let the source type populate server-owned config before persistence. */
|
||||
private Source withPreparedOptions(Source source, boolean isCreate) {
|
||||
InputSpec spec = source.toInputSpec();
|
||||
InputSource input = inputSourceFor(spec).orElse(null);
|
||||
@@ -243,16 +239,11 @@ public class SourceController {
|
||||
return source;
|
||||
}
|
||||
Map<String, Object> prepared = input.prepareOptionsForSave(source.options(), isCreate);
|
||||
// A source type must return the (possibly augmented) options; if it returns null, keep the
|
||||
// originals rather than let the Source record normalise null to an empty map and wipe
|
||||
// config.
|
||||
// Treat a null return as "unchanged" (else the record wipes config to empty).
|
||||
return prepared == null ? source : withOptions(source, prepared);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal server-minted secrets (a webhook's signing secret) once, on the create response only,
|
||||
* so the operator can copy them; every other read - including an edit - is masked.
|
||||
*/
|
||||
/** Reveal a webhook's minted secret once on the create response; other reads mask. */
|
||||
private static Source revealOnCreate(Source saved, boolean isCreate) {
|
||||
if (isCreate && WEBHOOK_TYPE.equals(saved.type())) {
|
||||
return saved;
|
||||
|
||||
+1
-7
@@ -2,13 +2,7 @@ package stirling.software.proprietary.policy.source;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* One row in the Sources overview: a persisted input connection shown exactly once, with how many
|
||||
* policies reference it (and which) and how many documents it has fed into runs ({@code docsTotal}
|
||||
* lifetime plus the trailing 24-hour and 30-day windows). {@code webhookPath} is set only for a
|
||||
* webhook source - the server-relative delivery path senders POST to - and is null for every other
|
||||
* type; the client turns it into an absolute URL.
|
||||
*/
|
||||
/** One Sources-overview row; {@code webhookPath} is a webhook's delivery path, else null. */
|
||||
public record SourceView(
|
||||
String id,
|
||||
String name,
|
||||
|
||||
+3
-18
@@ -20,16 +20,7 @@ import stirling.software.proprietary.policy.source.SourceStore;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
import stirling.software.proprietary.policy.webhook.WebhookConfig;
|
||||
|
||||
/**
|
||||
* Fires policies when a document is delivered to one of their webhook sources. Delivery is push, so
|
||||
* the receiver calls {@link #fireForWebhook} the moment it spools a file and the referencing
|
||||
* policies run immediately. As with folder-watch, the instant fire is a latency optimisation, not
|
||||
* the source of truth: a periodic reconcile ({@code watchReconcileSeconds}) re-runs every webhook
|
||||
* policy to cover deliveries that landed while the process was down and any fire that was missed.
|
||||
* Redundant runs are harmless because the input source does the claiming.
|
||||
*
|
||||
* <p>The spool is on local disk, so - like folder-watch - this assumes a single node.
|
||||
*/
|
||||
/** Fires webhook policies on delivery plus a periodic reconcile safety net. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -37,8 +28,7 @@ import stirling.software.proprietary.policy.webhook.WebhookConfig;
|
||||
public class WebhookTrigger implements PolicyTrigger {
|
||||
|
||||
static final String TYPE = "webhook";
|
||||
// The webhook source type; equal to the trigger type but a distinct concept (source vs
|
||||
// trigger).
|
||||
// The webhook source type (equals the trigger type but a distinct concept).
|
||||
private static final String WEBHOOK_SOURCE_TYPE = "webhook";
|
||||
|
||||
private final PolicyStore policyStore;
|
||||
@@ -98,12 +88,7 @@ public class WebhookTrigger implements PolicyTrigger {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run every webhook policy that references the delivered-to source. Called by the receiver
|
||||
* right after it spools a document, so processing starts without waiting for the next
|
||||
* reconcile. A LIGHT sweep: the periodic reconcile does the full-listing pass. Best-effort - a
|
||||
* run failure is logged and the others still fire.
|
||||
*/
|
||||
/** Run every webhook policy referencing the delivered-to source (LIGHT, best-effort). */
|
||||
public void fireForWebhook(String webhookId) {
|
||||
for (Policy policy : policyStore.findByTriggerType(TYPE)) {
|
||||
if (!referencesWebhook(policy, webhookId)) {
|
||||
|
||||
+2
-19
@@ -2,20 +2,7 @@ package stirling.software.proprietary.policy.webhook;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Config for a webhook input source, parsed from a spec's options map. A webhook is a push source:
|
||||
* external systems POST documents to a signed URL keyed by {@code webhookId}, and each delivery is
|
||||
* verified against {@code signingSecret} before it is staged for the referencing policies. Both
|
||||
* {@code webhookId} (the public URL token) and {@code signingSecret} (the HMAC key) are generated
|
||||
* server-side on create - a client never supplies them.
|
||||
*
|
||||
* <p>Where deliveries are staged depends on {@code connectionId}: absent, they go to a node-local
|
||||
* spool directory (self-hosted); present, they go to the S3 {@code IntegrationConfig} connection it
|
||||
* references, under a reserved per-webhook prefix, so the staging is durable and reachable from any
|
||||
* node (the model hosted/SaaS needs). Either way {@code mode} is "consume" (default: a staged
|
||||
* document is removed once every policy that claimed it has settled successfully) or "snapshot"
|
||||
* (stateless, every run re-reads whatever is currently staged).
|
||||
*/
|
||||
/** Webhook source config: server-minted ids, staging via an S3 connection or the local spool. */
|
||||
public record WebhookConfig(
|
||||
String webhookId, String signingSecret, Long connectionId, boolean snapshot) {
|
||||
|
||||
@@ -55,11 +42,7 @@ public record WebhookConfig(
|
||||
return connectionId != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reserved key prefix this webhook's deliveries are staged under inside its connection's
|
||||
* bucket. Server-derived from the (unguessable) id, so two webhooks sharing a connection never
|
||||
* collide and an S3 source pointed at the same bucket won't see another webhook's inbox.
|
||||
*/
|
||||
/** Reserved per-webhook staging prefix inside the connection's bucket. */
|
||||
public String stagingPrefix() {
|
||||
return STAGING_ROOT + "/" + webhookId;
|
||||
}
|
||||
|
||||
+4
-15
@@ -4,17 +4,10 @@ import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Generates and validates the two server-side tokens a webhook source carries: the public {@code
|
||||
* webhookId} that routes a delivery URL to a source, and the {@code signingSecret} that
|
||||
* authenticates each delivery. Both are URL-safe base64 of {@link SecureRandom} bytes, so both are
|
||||
* unguessable; the id is the routing capability and the secret is the HMAC key. The id's character
|
||||
* set is constrained so it can be used verbatim as a single path segment and as a spool directory
|
||||
* name without traversal risk.
|
||||
*/
|
||||
/** Generates and validates a webhook's routing id and signing secret. */
|
||||
public final class WebhookIds {
|
||||
|
||||
/** URL-safe base64 without padding: exactly the characters a single path segment allows. */
|
||||
/** URL-safe base64 without padding: the characters a single path segment allows. */
|
||||
private static final Pattern VALID_ID = Pattern.compile("^[A-Za-z0-9_-]{16,128}$");
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
@@ -22,9 +15,7 @@ public final class WebhookIds {
|
||||
|
||||
private WebhookIds() {}
|
||||
|
||||
/**
|
||||
* A fresh routing token (~24 chars). Not secret, but unguessable so it cannot be enumerated.
|
||||
*/
|
||||
/** A fresh routing token (~24 chars), unguessable so it cannot be enumerated. */
|
||||
public static String newWebhookId() {
|
||||
return randomToken(18);
|
||||
}
|
||||
@@ -34,9 +25,7 @@ public final class WebhookIds {
|
||||
return randomToken(32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@code id} is a well-formed webhook id, safe as a path segment and directory name.
|
||||
*/
|
||||
/** Whether {@code id} is well-formed and safe as a path segment / directory name. */
|
||||
public static boolean isValidId(String id) {
|
||||
return id != null && VALID_ID.matcher(id).matches();
|
||||
}
|
||||
|
||||
+3
-26
@@ -38,22 +38,7 @@ import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
|
||||
/**
|
||||
* Public receiver for webhook input sources. External systems POST a document to {@code
|
||||
* /api/v1/webhooks/{webhookId}}; the request is authenticated not by a login session but by an
|
||||
* HMAC-SHA256 signature of the exact body under the source's signing secret, so this endpoint sits
|
||||
* outside the session auth wall (see {@code RequestUriUtils.isPublicAuthEndpoint}). A verified
|
||||
* delivery is spooled and the referencing policies are fired immediately.
|
||||
*
|
||||
* <p>The request body is the raw document bytes, sent with a binary content type - {@code
|
||||
* application/pdf} or {@code application/octet-stream} (e.g. {@code curl --data-binary @file.pdf -H
|
||||
* 'Content-Type: application/pdf'}). A form content type ({@code
|
||||
* application/x-www-form-urlencoded}) is rejected upstream because the container tries to parse the
|
||||
* body as form fields. The signature header is {@code sha256=<hex>}. Delivery is rejected before
|
||||
* anything is written if the id is unknown (404), the signature is missing or wrong (401), the
|
||||
* source is paused (403), or the body is empty (400) or larger than {@code
|
||||
* policies.webhookMaxBytes} (413).
|
||||
*/
|
||||
/** Public receiver: HMAC-verifies a signed delivery, stages it, and fires the policies. */
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/webhooks")
|
||||
@@ -151,11 +136,7 @@ public class WebhookReceiverController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage a delivery to the webhook's S3 connection under its reserved prefix. Resolved with no
|
||||
* principal - the referencing source's right to use the connection was checked when it was
|
||||
* saved - so a public delivery never needs the caller to hold connection rights.
|
||||
*/
|
||||
/** Stage a delivery to the S3 connection (save-time access check trusted). */
|
||||
private String stageToConnection(WebhookConfig config, String filename, byte[] body) {
|
||||
S3Config s3 =
|
||||
connectionResolver.resolve(
|
||||
@@ -191,11 +172,7 @@ public class WebhookReceiverController {
|
||||
return prefix + "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the body into memory, capped at {@code policies.webhookMaxBytes}. Reading one byte past
|
||||
* the limit is enough to reject an over-sized (or unbounded, chunked) delivery with 413 before
|
||||
* it can fill memory or the disk.
|
||||
*/
|
||||
/** Read the body into memory, capped at {@code policies.webhookMaxBytes}. */
|
||||
private byte[] readBoundedBody(HttpServletRequest request) {
|
||||
long maxBytes = applicationProperties.getPolicies().getWebhookMaxBytes();
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
|
||||
+3
-15
@@ -9,13 +9,7 @@ import java.util.HexFormat;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
* HMAC-SHA256 signing scheme for webhook deliveries. The sender signs the exact request body bytes
|
||||
* with the source's {@code signingSecret} and presents the result as {@code sha256=<hex>} in the
|
||||
* signature header; the receiver recomputes it and compares in constant time. Signing the raw body
|
||||
* (rather than form fields) keeps the contract unambiguous - what is signed is exactly what is
|
||||
* delivered - and matches how established webhook providers (Stripe, GitHub) sign payloads.
|
||||
*/
|
||||
/** HMAC-SHA256 signing of a webhook delivery's raw body (header sha256=<hex>). */
|
||||
public final class WebhookSignatures {
|
||||
|
||||
private static final String ALGORITHM = "HmacSHA256";
|
||||
@@ -23,18 +17,12 @@ public final class WebhookSignatures {
|
||||
|
||||
private WebhookSignatures() {}
|
||||
|
||||
/**
|
||||
* The header value a sender should present for {@code body}: {@code sha256=<lowercase hex>}.
|
||||
*/
|
||||
/** The header a sender presents for {@code body}: {@code sha256=<lowercase hex>}. */
|
||||
public static String sign(String signingSecret, byte[] body) {
|
||||
return PREFIX + HexFormat.of().formatHex(hmac(signingSecret, body));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@code presented} (a {@code sha256=<hex>} header value, or a bare hex string) is a
|
||||
* valid signature of {@code body} under {@code signingSecret}. Constant-time in the compared
|
||||
* bytes; a malformed or missing header is simply false, never an exception.
|
||||
*/
|
||||
/** Whether the presented signature is valid for {@code body}; false on bad input. */
|
||||
public static boolean verify(String signingSecret, byte[] body, String presented) {
|
||||
if (signingSecret == null || presented == null || body == null) {
|
||||
return false;
|
||||
|
||||
+4
-25
@@ -11,16 +11,7 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
|
||||
/**
|
||||
* Server-owned staging area for webhook deliveries. Each webhook source gets its own directory,
|
||||
* named by its (unguessable, path-segment-safe) {@code webhookId}, under a fixed root inside
|
||||
* Stirling's install path - never a user-supplied location, so this is not subject to the folder
|
||||
* source's allow-listing. The receiver writes each accepted document here atomically; {@link
|
||||
* stirling.software.proprietary.policy.input.WebhookInputSource} then reads it exactly as the
|
||||
* folder source reads a watched directory, reusing the whole claim/consume ledger. A delivered file
|
||||
* keeps its original name for downstream output naming, prefixed with a unique token so two
|
||||
* deliveries of the same name never collide.
|
||||
*/
|
||||
/** Server-owned per-webhook staging directory under the install path. */
|
||||
@Component
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class WebhookSpool {
|
||||
@@ -54,12 +45,7 @@ public class WebhookSpool {
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one delivered document into the webhook's spool, atomically so a partial write is never
|
||||
* picked up: staged under a hidden {@code .part} name and moved into place. Returns the final
|
||||
* path. The original filename (sanitised to a bare, safe basename) is preserved after a unique
|
||||
* prefix.
|
||||
*/
|
||||
/** Write a delivery into the spool atomically (staged .part, then moved into place). */
|
||||
public Path store(String webhookId, String filename, byte[] content) throws IOException {
|
||||
Path dir = dirFor(webhookId);
|
||||
Files.createDirectories(dir);
|
||||
@@ -90,19 +76,12 @@ public class WebhookSpool {
|
||||
return spoolFileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* The object-key suffix for an S3-staged delivery: a unique segment then the sanitised name
|
||||
* ({@code <unique>/<name>}). A subfolder rather than a name prefix so the object's basename -
|
||||
* and thus the resolved input's filename - stays the clean original name, matching the local
|
||||
* spool.
|
||||
*/
|
||||
/** S3 object-key suffix {@code <unique>/<name>}; the subfolder keeps the basename clean. */
|
||||
public static String objectKeySuffix(String filename) {
|
||||
return UUID.randomUUID().toString().replace("-", "") + "/" + sanitize(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sanitised display name of a staged delivery (the basename of {@link #objectKeySuffix}).
|
||||
*/
|
||||
/** The staged delivery's display name (basename of {@link #objectKeySuffix}). */
|
||||
public static String objectDisplayName(String filename) {
|
||||
return sanitize(filename);
|
||||
}
|
||||
|
||||
@@ -20,9 +20,7 @@ public final class SecretMasker {
|
||||
private static final Pattern SENSITIVE =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getPattern(
|
||||
// secret[_-]?access[_-]?key and signing[_-]?secret precede plain secret
|
||||
// so camelCase keys like secretAccessKey and signingSecret (whose inner
|
||||
// "secret" has no word boundary before/after it) still match.
|
||||
// specific keys precede plain "secret" so camelCase still matches.
|
||||
"(?i)\\b(password|token|secret[_-]?access[_-]?key|signing[_-]?secret|secret|api[_-]?key|authorization|auth|jwt|cred|cert)\\b");
|
||||
|
||||
private SecretMasker() {}
|
||||
|
||||
+2
-7
@@ -34,11 +34,7 @@ import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.webhook.WebhookConfig;
|
||||
import stirling.software.proprietary.policy.webhook.WebhookSpool;
|
||||
|
||||
/**
|
||||
* Tests for {@link WebhookInputSource}: spooled deliveries are read through the ledger like folder
|
||||
* files (consume removes them once processed, snapshot stays stateless), a not-yet-created spool is
|
||||
* an empty source rather than an error, and options gain a routing id + signing secret on create.
|
||||
*/
|
||||
/** Tests for {@link WebhookInputSource}: ledger-backed read/consume, and id/secret minting. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WebhookInputSourceTest {
|
||||
|
||||
@@ -193,8 +189,7 @@ class WebhookInputSourceTest {
|
||||
|
||||
source.validate(spec);
|
||||
|
||||
// Save-time validation defers to the S3 source, which resolves + ownership-checks the
|
||||
// connection (the check the delivery path later trusts).
|
||||
// Save-time validation defers to the S3 source, which ownership-checks the connection.
|
||||
verify(s3InputSource).validate(any());
|
||||
}
|
||||
|
||||
|
||||
+1
-3
@@ -70,9 +70,7 @@ class SourceControllerTest {
|
||||
policyGuard,
|
||||
new InProcessSourceDocCounter());
|
||||
triggerManager = mock(PolicyTriggerManager.class);
|
||||
// A permissive input source so config validation passes and save can be exercised. Model a
|
||||
// real source's prepareOptionsForSave as a pass-through (a bare mock would return an empty
|
||||
// map for the Map-typed method and wipe the config).
|
||||
// A permissive input source; stub prepareOptionsForSave as a pass-through.
|
||||
InputSource folderInput = mock(InputSource.class);
|
||||
when(folderInput.supports(any())).thenReturn(true);
|
||||
when(folderInput.prepareOptionsForSave(any(), anyBoolean()))
|
||||
|
||||
+1
-4
@@ -27,10 +27,7 @@ import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.source.SourceStore;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
|
||||
/**
|
||||
* Tests for {@link WebhookTrigger}'s dispatch: a delivery fires only the policies that reference
|
||||
* the delivered-to webhook, and validation requires at least one webhook source.
|
||||
*/
|
||||
/** Tests for {@link WebhookTrigger}: fires only policies referencing the delivered-to webhook. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WebhookTriggerTest {
|
||||
|
||||
|
||||
+1
-5
@@ -31,11 +31,7 @@ import stirling.software.proprietary.policy.source.SourceStore;
|
||||
import stirling.software.proprietary.policy.trigger.WebhookTrigger;
|
||||
import stirling.software.proprietary.policy.webhook.WebhookReceiverController.WebhookDeliveryResponse;
|
||||
|
||||
/**
|
||||
* Tests for the public webhook receiver: a signature-valid delivery is spooled and fires the
|
||||
* trigger; an unknown id, wrong signature, paused source, empty body, or over-sized body is
|
||||
* rejected before anything is stored.
|
||||
*/
|
||||
/** Tests for the public webhook receiver: valid delivery spools + fires; bad requests rejected. */
|
||||
class WebhookReceiverControllerTest {
|
||||
|
||||
private static final String WEBHOOK_ID = "receivertestid12";
|
||||
|
||||
+2
-9
@@ -54,13 +54,7 @@ import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
|
||||
import software.amazon.awssdk.services.s3.model.S3Object;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* End-to-end test of a connection-backed webhook against a real S3 API (MinIO): a signed delivery
|
||||
* is staged by the receiver into the S3 connection's bucket under the reserved per-webhook prefix,
|
||||
* then read and consumed by the source (which delegates to {@link S3InputSource}). Proves the
|
||||
* durable, multi-node staging model hosted deployments use, through the production S3 client
|
||||
* factory.
|
||||
*/
|
||||
/** End-to-end connection-backed webhook against MinIO: stage, read via S3 delegation, consume. */
|
||||
@Testcontainers(disabledWithoutDocker = true)
|
||||
class WebhookS3ConnectionMinioTest {
|
||||
|
||||
@@ -112,8 +106,7 @@ class WebhookS3ConnectionMinioTest {
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
properties.getPolicies().setAllowPrivateS3Endpoints(true);
|
||||
|
||||
// A resolver over a stored S3 connection pointing at MinIO. No SecurityContext in the test,
|
||||
// so the resolver's ownership check is skipped (the delivery path's trust-the-save model).
|
||||
// A resolver over a MinIO S3 connection; no principal here, so ownership is skipped.
|
||||
S3ConnectionResolver resolver = resolverFor(minioConnection());
|
||||
S3ConnectionPool pool = new S3ConnectionPool(properties);
|
||||
S3InputSource s3 = new S3InputSource(pool, resolver);
|
||||
|
||||
@@ -4,13 +4,7 @@ import {
|
||||
type CreatableSourceType,
|
||||
} from "@portal/components/sources/sourceTypes";
|
||||
|
||||
/**
|
||||
* Hosted deployments don't rely on the server's local filesystem. Folder sources
|
||||
* are dropped outright (denied by the backend's FolderAccessGuard). Webhook stays
|
||||
* - it works in hosted by staging deliveries to a durable S3 connection - but its
|
||||
* connection becomes required, since the node-local fallback can't be relied on
|
||||
* across a multi-node fleet. Cloud sources such as S3 are unchanged.
|
||||
*/
|
||||
/** Hosted drops folder (no server FS) and requires webhook's S3 connection (node-local isn't durable). */
|
||||
export function creatableSourceTypes(): CreatableSourceType[] {
|
||||
return CREATABLE_SOURCE_TYPES.filter((type) => type.type !== "folder").map(
|
||||
(type) =>
|
||||
|
||||
@@ -189,9 +189,7 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
// The delivery URL + signing secret are minted server-side on create (revealed once). An S3
|
||||
// connection makes staging durable + multi-node (required in hosted); left blank, deliveries
|
||||
// stage to the server's local disk (self-hosted only).
|
||||
// URL + secret minted server-side (revealed once); an S3 connection makes staging durable, else local disk.
|
||||
type: WEBHOOK_SOURCE_TYPE,
|
||||
labelKey: "portal.sources.types.webhook.label",
|
||||
descriptionKey: "portal.sources.types.webhook.description",
|
||||
|
||||
@@ -234,8 +234,7 @@ export const sourcesHandlers = [
|
||||
? store.find((s) => s.id === incoming.id)
|
||||
: undefined;
|
||||
const id = existing?.id ?? nextId();
|
||||
// A new webhook has its routing id + signing secret minted server-side, then revealed once on
|
||||
// this create response (the store keeps them; later reads mask the secret).
|
||||
// A new webhook's id + secret are minted server-side and revealed once on this create response.
|
||||
let options = incoming.options ?? {};
|
||||
if (incoming.type === "webhook" && !existing && !options.webhookId) {
|
||||
options = {
|
||||
|
||||
@@ -123,8 +123,7 @@ export function SourceBuilder() {
|
||||
);
|
||||
const canSave = name.trim() !== "" && requiredComplete && !submitting;
|
||||
|
||||
// An existing webhook's delivery URL, shown read-only in the edit view (the secret is not - it
|
||||
// is revealed only once, at creation).
|
||||
// An existing webhook's delivery URL, read-only in edit (the secret is revealed only at creation).
|
||||
const editingWebhookId =
|
||||
isEdit && sourceState.data?.type === WEBHOOK_SOURCE_TYPE
|
||||
? String(sourceState.data.options?.webhookId ?? "")
|
||||
@@ -149,8 +148,7 @@ export function SourceBuilder() {
|
||||
options,
|
||||
enabled,
|
||||
});
|
||||
// A new webhook returns its server-minted routing id + signing secret once; reveal them
|
||||
// (with the delivery URL) before leaving so the operator can copy the secret.
|
||||
// A new webhook returns its minted id + secret once; reveal them (with the URL) before leaving.
|
||||
if (!isEdit && type.type === WEBHOOK_SOURCE_TYPE) {
|
||||
const webhookId = String(saved.options?.webhookId ?? "");
|
||||
const secret = String(saved.options?.signingSecret ?? "");
|
||||
|
||||
Reference in New Issue
Block a user