mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
+8
-27
@@ -1,9 +1,6 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
@@ -11,35 +8,20 @@ import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
/**
|
||||
* RFC 8707 audience binding: a JWT at the MCP endpoint must list this server's resource id (or one
|
||||
* of the explicitly accepted additional audiences) in its {@code aud} claim. The additional list
|
||||
* exists for IdPs that cannot mint resource-specific audiences - e.g. Supabase's OAuth server
|
||||
* always issues {@code aud=authenticated}. Fails closed when nothing is configured.
|
||||
* RFC 8707 audience binding: a JWT at the MCP endpoint must list this server's resource id in its
|
||||
* {@code aud} claim. Fails closed when the resource id is unset.
|
||||
*/
|
||||
public class McpAudienceValidator implements OAuth2TokenValidator<Jwt> {
|
||||
|
||||
private final Set<String> acceptedAudiences;
|
||||
private final String expectedResourceId;
|
||||
|
||||
public McpAudienceValidator(String expectedResourceId) {
|
||||
this(expectedResourceId, List.of());
|
||||
}
|
||||
|
||||
public McpAudienceValidator(String expectedResourceId, Collection<String> additionalAudiences) {
|
||||
Set<String> accepted = new LinkedHashSet<>();
|
||||
if (expectedResourceId != null && !expectedResourceId.isBlank()) {
|
||||
accepted.add(expectedResourceId);
|
||||
}
|
||||
if (additionalAudiences != null) {
|
||||
additionalAudiences.stream()
|
||||
.filter(a -> a != null && !a.isBlank())
|
||||
.forEach(accepted::add);
|
||||
}
|
||||
this.acceptedAudiences = accepted;
|
||||
this.expectedResourceId = expectedResourceId == null ? "" : expectedResourceId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt token) {
|
||||
if (acceptedAudiences.isEmpty()) {
|
||||
if (expectedResourceId.isBlank()) {
|
||||
return OAuth2TokenValidatorResult.failure(
|
||||
new OAuth2Error(
|
||||
"invalid_token",
|
||||
@@ -48,13 +30,12 @@ public class McpAudienceValidator implements OAuth2TokenValidator<Jwt> {
|
||||
null));
|
||||
}
|
||||
List<String> aud = token.getAudience();
|
||||
if (aud == null || aud.stream().noneMatch(acceptedAudiences::contains)) {
|
||||
if (aud == null || !aud.contains(expectedResourceId)) {
|
||||
return OAuth2TokenValidatorResult.failure(
|
||||
new OAuth2Error(
|
||||
"invalid_token",
|
||||
"Token audience does not include this server's resource id or an"
|
||||
+ " accepted audience ("
|
||||
+ String.join(", ", acceptedAudiences)
|
||||
"Token audience does not include this server's resource id ("
|
||||
+ expectedResourceId
|
||||
+ ").",
|
||||
null));
|
||||
}
|
||||
|
||||
+21
-38
@@ -24,7 +24,6 @@ import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadata;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
|
||||
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
|
||||
@@ -158,11 +157,7 @@ public class McpSecurityConfig {
|
||||
throws Exception {
|
||||
String metadataPath = "/.well-known/oauth-protected-resource";
|
||||
applyCors(http);
|
||||
// RFC 9728 section 3.1: clients derive the metadata URL by inserting the well-known
|
||||
// segment before the resource path, so /mcp is discovered at {metadataPath}/mcp. Claim
|
||||
// the subpaths too; otherwise they fall through to another filter chain whose default
|
||||
// Spring Security metadata filter serves a document without authorization_servers.
|
||||
http.securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath, metadataPath + "/**")
|
||||
http.securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath)
|
||||
// CSRF intentionally disabled: /mcp is a stateless JSON-RPC resource server
|
||||
// authenticated by OAuth2 Bearer JWTs (Authorization header). No cookies, no
|
||||
// session, no form submissions; CSRF requires browser-attached ambient credentials
|
||||
@@ -173,8 +168,7 @@ public class McpSecurityConfig {
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(
|
||||
a ->
|
||||
a.requestMatchers(
|
||||
HttpMethod.GET, metadataPath, metadataPath + "/**")
|
||||
a.requestMatchers(HttpMethod.GET, metadataPath)
|
||||
.permitAll()
|
||||
.anyRequest()
|
||||
.authenticated())
|
||||
@@ -193,18 +187,28 @@ public class McpSecurityConfig {
|
||||
.oauth2ResourceServer(
|
||||
oauth2 ->
|
||||
oauth2.authenticationEntryPoint(
|
||||
// Advertise the path-inserted form; RFC 9728 makes
|
||||
// it the canonical location for a resource with a
|
||||
// path component.
|
||||
new McpAuthenticationEntryPoint(
|
||||
metadataPath + BASE_PATH))
|
||||
new McpAuthenticationEntryPoint(metadataPath))
|
||||
// RFC 9728 protected-resource metadata for OAuth discovery.
|
||||
.protectedResourceMetadata(
|
||||
prm ->
|
||||
prm.protectedResourceMetadataCustomizer(
|
||||
builder ->
|
||||
buildResourceMetadata(
|
||||
builder, auth)))
|
||||
builder -> {
|
||||
if (!auth.getResourceId()
|
||||
.isBlank()) {
|
||||
builder.resource(
|
||||
auth
|
||||
.getResourceId());
|
||||
}
|
||||
if (!auth.getIssuerUri()
|
||||
.isBlank()) {
|
||||
builder.authorizationServer(
|
||||
auth
|
||||
.getIssuerUri());
|
||||
}
|
||||
builder.scope("mcp.tools.read");
|
||||
builder.scope(
|
||||
"mcp.tools.write");
|
||||
}))
|
||||
.jwt(
|
||||
jwt ->
|
||||
jwt.decoder(mcpJwtDecoder)
|
||||
@@ -213,25 +217,6 @@ public class McpSecurityConfig {
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/** Populate the RFC 9728 protected-resource metadata document from the configured auth. */
|
||||
private void buildResourceMetadata(
|
||||
OAuth2ProtectedResourceMetadata.Builder builder, ApplicationProperties.Mcp.Auth auth) {
|
||||
if (!auth.getResourceId().isBlank()) {
|
||||
builder.resource(auth.getResourceId());
|
||||
}
|
||||
if (!auth.getIssuerUri().isBlank()) {
|
||||
builder.authorizationServer(auth.getIssuerUri());
|
||||
}
|
||||
// Only advertise the granular tool scopes when we actually enforce them. When scopes are
|
||||
// disabled (e.g. the IdP only mints coarse tokens, like Supabase), advertising scopes the
|
||||
// authorization server can't issue makes spec-compliant clients request them and get
|
||||
// rejected with invalid_request.
|
||||
if (applicationProperties.getMcp().isScopesEnabled()) {
|
||||
builder.scope("mcp.tools.read");
|
||||
builder.scope("mcp.tools.write");
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
JwtDecoder mcpJwtDecoder() {
|
||||
ApplicationProperties.Mcp.Auth auth = applicationProperties.getMcp().getAuth();
|
||||
@@ -251,9 +236,7 @@ public class McpSecurityConfig {
|
||||
JwtValidators.createDefaultWithIssuer(auth.getIssuerUri());
|
||||
OAuth2TokenValidator<Jwt> combined =
|
||||
new DelegatingOAuth2TokenValidator<>(
|
||||
defaultValidators,
|
||||
new McpAudienceValidator(
|
||||
auth.getResourceId(), auth.getAcceptedAudiences()));
|
||||
defaultValidators, new McpAudienceValidator(auth.getResourceId()));
|
||||
decoder.setJwtValidator(combined);
|
||||
return decoder;
|
||||
}
|
||||
|
||||
-15
@@ -105,19 +105,4 @@ public class AiWorkflowResponse {
|
||||
+ " body or via the X-Stirling-Tool-Report header. May be null for tools"
|
||||
+ " that produce only a file.")
|
||||
private JsonNode report;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Structured error code when a downstream tool call was blocked (e.g."
|
||||
+ " PAYG_LIMIT_REACHED). Lets the client react — such as opening the"
|
||||
+ " usage-limit modal — instead of only seeing a generic failure. Null"
|
||||
+ " for ordinary outcomes.")
|
||||
private String errorCode;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Whether the team is subscribed, carried from a downstream usage-limit response."
|
||||
+ " Selects which limit modal the client shows (free → subscribe,"
|
||||
+ " subscribed → raise cap). Null when the downstream body omitted it.")
|
||||
private Boolean errorSubscribed;
|
||||
}
|
||||
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
package stirling.software.proprietary.policy.config;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* Default (non-SaaS) policy context: a global admin may edit policies; scoping uses the current
|
||||
* user's team (typically a single shared team self-hosted). SaaS overrides this with a team-leader
|
||||
* check (see the {@code saas}-profiled implementation).
|
||||
*/
|
||||
@Component
|
||||
@Profile("!saas")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminPolicyManagementAuthority implements PolicyManagementAuthority {
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
@Override
|
||||
public boolean canEditPolicies() {
|
||||
return userService.isCurrentUserAdmin();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long currentUserTeamId() {
|
||||
String username = userService.getCurrentUsername();
|
||||
if (username == null) {
|
||||
return null;
|
||||
}
|
||||
return userService
|
||||
.findByUsername(username)
|
||||
.map(User::getTeam)
|
||||
.map(Team::getId)
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
+26
-12
@@ -13,18 +13,26 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
|
||||
/**
|
||||
* Authority on which filesystem locations a policy may read/write. Checked at save time and again
|
||||
* at run time, fail-closed in order:
|
||||
* The single authority on which filesystem locations a policy may read from or write to. Folder
|
||||
* sources and sinks take a configured directory, so without this a user who can save a policy could
|
||||
* point one at Stirling's own config/secrets directory and exfiltrate (or overwrite) it. Every
|
||||
* folder source and sink runs its directory through {@link #requirePermitted(Path)} at save time
|
||||
* and again at run time.
|
||||
*
|
||||
* <ol>
|
||||
* <li>denied entirely under the {@code saas} profile;
|
||||
* <li>Stirling's own config dir always rejected, even if an allowed root were misconfigured to
|
||||
* contain it;
|
||||
* <li>must resolve within {@code policies.allowedFolderRoots}; none configured means all denied.
|
||||
* </ol>
|
||||
* <p>Enforced fail-closed, in order:
|
||||
*
|
||||
* <p>Compared after normalisation so {@code ..} cannot escape a root. Symlink escape is not
|
||||
* defended: an operator who roots an allowlist on a symlink to a sensitive location is trusted.
|
||||
* <ul>
|
||||
* <li><b>Disabled in SaaS</b> - folder access is never allowed when the {@code saas} profile is
|
||||
* active; a tenant must not reach the host filesystem at all.
|
||||
* <li><b>Protected paths</b> - Stirling's own config directory (settings, database, keys,
|
||||
* backups) is always rejected, even if an allowed root were misconfigured to contain it.
|
||||
* <li><b>Allowlist</b> - the directory must resolve within one of {@code
|
||||
* policies.allowedFolderRoots}; with none configured, all folder access is refused.
|
||||
* </ul>
|
||||
*
|
||||
* <p>Paths are compared after normalisation, so {@code ..} segments cannot walk out of an allowed
|
||||
* root. (Symlink escape is not defended here; an operator who configures an allowed root containing
|
||||
* a symlink to a sensitive location is trusted.)
|
||||
*/
|
||||
@Component
|
||||
public class FolderAccessGuard {
|
||||
@@ -42,7 +50,13 @@ public class FolderAccessGuard {
|
||||
this.protectedRoots = List.of(normalize(Path.of(InstallationPathConfig.getConfigPath())));
|
||||
}
|
||||
|
||||
/** Returns the normalised absolute path; throws if not permitted. */
|
||||
/**
|
||||
* Check that {@code dir} is a permitted folder location, returning its normalised absolute
|
||||
* form.
|
||||
*
|
||||
* @throws IllegalArgumentException if folder access is disabled (SaaS or no roots configured),
|
||||
* the path is inside a protected directory, or it falls outside every allowed root
|
||||
*/
|
||||
public Path requirePermitted(Path dir) {
|
||||
if (saasActive) {
|
||||
throw new IllegalArgumentException(
|
||||
@@ -67,7 +81,7 @@ public class FolderAccessGuard {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Whether this policy touches a folder source/sink, and so is subject to these rules. */
|
||||
/** Whether this policy reads from or writes to a folder, and so is subject to these rules. */
|
||||
public boolean usesFolderAccess(Policy policy) {
|
||||
boolean readsFolder =
|
||||
policy.sources().stream().anyMatch(spec -> FOLDER_TYPE.equals(spec.type()));
|
||||
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
package stirling.software.proprietary.policy.config;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
|
||||
/**
|
||||
* Policies are scoped to a team: a user may view, run, edit, and delete only the policies belonging
|
||||
* to their own team (the team a policy is stamped with at creation). This binds everyone — admins
|
||||
* included — so no one sees or touches another team's policies. <em>Whether</em> a user may edit
|
||||
* (vs only view/run) is a separate check gated at the controller ({@code
|
||||
* PolicyController#requirePolicyEditingAllowed} → team leader). Enforced only when login is
|
||||
* enabled; single-user deployments (login disabled) pass every check.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PolicyAccessGuard {
|
||||
|
||||
private final UserServiceInterface userService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
|
||||
/** Owner for a new policy: the current user, or {@code null} when login is disabled. */
|
||||
public String ownerForNewPolicy() {
|
||||
return enforced() ? userService.getCurrentUsername() : null;
|
||||
}
|
||||
|
||||
/** Team a new policy is stamped with — the creator's team. {@code null} when login disabled. */
|
||||
public Long teamForNewPolicy() {
|
||||
return enforced() ? policyManagementAuthority.currentUserTeamId() : null;
|
||||
}
|
||||
|
||||
/** Whether the policy belongs to the current user's team (so they may view/run/edit it). */
|
||||
public boolean canAccess(Policy policy) {
|
||||
if (!enforced()) {
|
||||
return true;
|
||||
}
|
||||
return Objects.equals(policy.teamId(), policyManagementAuthority.currentUserTeamId());
|
||||
}
|
||||
|
||||
/** The subset of {@code policies} scoped to the current user's team. */
|
||||
public List<Policy> visible(List<Policy> policies) {
|
||||
if (!enforced()) {
|
||||
return policies;
|
||||
}
|
||||
Long teamId = policyManagementAuthority.currentUserTeamId();
|
||||
return policies.stream().filter(policy -> Objects.equals(policy.teamId(), teamId)).toList();
|
||||
}
|
||||
|
||||
private boolean enforced() {
|
||||
return applicationProperties.getSecurity().isEnableLogin();
|
||||
}
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
package stirling.software.proprietary.policy.config;
|
||||
|
||||
/**
|
||||
* The current user's policy context, pluggable per deployment so the policy layer (proprietary)
|
||||
* needn't know the team model. SaaS: a user may edit policies only if they lead their team, and
|
||||
* every user is scoped to their own team. Self-hosted: a global admin may edit, scoped to their
|
||||
* (typically single) team. Policies are isolated per team — nobody, admins included, sees or edits
|
||||
* another team's policies.
|
||||
*/
|
||||
public interface PolicyManagementAuthority {
|
||||
|
||||
/** Whether the current user may create, edit, or delete policies (for their own team). */
|
||||
boolean canEditPolicies();
|
||||
|
||||
/**
|
||||
* The team that scopes the current user's policies — the team a new policy is stamped with and
|
||||
* the only team whose policies the user may see/run/edit. {@code null} when it can't be
|
||||
* resolved (e.g. login disabled / no team), in which case access falls back to the unteamed
|
||||
* ({@code null}-team) policies.
|
||||
*/
|
||||
Long currentUserTeamId();
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
package stirling.software.proprietary.policy.controller;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* A supporting file paired with the asset key a pipeline step references from its {@code
|
||||
* fileParameters}. The same key may appear on more than one asset to supply multiple files.
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "A supporting file bound to the asset key a pipeline step references")
|
||||
public class NamedAsset {
|
||||
|
||||
@NotBlank
|
||||
@Schema(
|
||||
description = "Asset key referenced by a step's fileParameters",
|
||||
example = "company-logo")
|
||||
private String key;
|
||||
|
||||
@NotNull
|
||||
@Schema(description = "The supporting file", format = "binary")
|
||||
private MultipartFile file;
|
||||
}
|
||||
+103
-129
@@ -11,16 +11,17 @@ import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
@@ -29,17 +30,15 @@ import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.job.JobResponse;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
import stirling.software.proprietary.policy.config.FolderAccessGuard;
|
||||
import stirling.software.proprietary.policy.engine.PolicyRunHandle;
|
||||
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
|
||||
import stirling.software.proprietary.policy.engine.PolicyRunner;
|
||||
@@ -52,15 +51,25 @@ import stirling.software.proprietary.policy.model.PolicyRunStatus;
|
||||
import stirling.software.proprietary.policy.model.PolicyRunView;
|
||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
import stirling.software.proprietary.security.config.PremiumEndpoint;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code
|
||||
* GET /run/{runId}} for status, download outputs via {@code GET /api/v1/general/files/{fileId}}.
|
||||
* Manages policies and runs pipelines. The premium backend entry point: CRUD for stored {@code
|
||||
* Policy} objects, running a stored policy by id, and running an ad-hoc pipeline (for AI/Automate
|
||||
* one-offs).
|
||||
*
|
||||
* <p>Runs execute asynchronously and return a run id immediately. Poll {@code GET /run/{runId}} for
|
||||
* status, and download outputs via the existing {@code GET /api/v1/general/files/{fileId}} using
|
||||
* the file ids in the run view.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/policies")
|
||||
@Hidden
|
||||
@PremiumEndpoint
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Policies", description = "Run tool pipelines on the backend")
|
||||
public class PolicyController {
|
||||
@@ -69,9 +78,10 @@ public class PolicyController {
|
||||
private final PolicyRunRegistry runRegistry;
|
||||
private final PolicyStore policyStore;
|
||||
private final PolicyValidator policyValidator;
|
||||
private final PolicyAccessGuard policyAccessGuard;
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
private final FolderAccessGuard folderAccessGuard;
|
||||
private final UserServiceInterface userService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@PostMapping(value = "/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -79,16 +89,15 @@ public class PolicyController {
|
||||
summary = "Run a tool pipeline",
|
||||
description =
|
||||
"Accepts the documents to process (multipart field 'fileInput'), any supporting"
|
||||
+ " files (under 'assets[i].key' / 'assets[i].file'), and the pipeline"
|
||||
+ " definition as an application/json part named 'json'. Runs the steps"
|
||||
+ " in order asynchronously and returns a run id. Poll the run status"
|
||||
+ " endpoint and download outputs via /api/v1/general/files/{id}.")
|
||||
+ " files (each under a multipart field named as its asset key, e.g."
|
||||
+ " 'company-logo'), and a JSON pipeline definition ('json'). Runs the"
|
||||
+ " steps in order asynchronously and returns a run id. Poll the run"
|
||||
+ " status endpoint and download outputs via /api/v1/general/files/{id}.")
|
||||
public ResponseEntity<JobResponse<Void>> run(
|
||||
@RequestPart("json") PipelineDefinition definition,
|
||||
@Valid @ModelAttribute PolicyRunFiles files)
|
||||
@RequestParam("json") String json, MultipartHttpServletRequest request)
|
||||
throws IOException {
|
||||
requireRunnable(definition);
|
||||
PolicyInputs inputs = toInputs(files);
|
||||
PipelineDefinition definition = parseDefinition(json);
|
||||
PolicyInputs inputs = collectInputs(request);
|
||||
String runId =
|
||||
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP).runId();
|
||||
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
|
||||
@@ -102,19 +111,18 @@ public class PolicyController {
|
||||
+ " starts and completes, then a terminal 'completed', 'failed',"
|
||||
+ " 'cancelled', or 'waiting' event carrying the final run view.")
|
||||
public SseEmitter runStream(
|
||||
@RequestPart("json") PipelineDefinition definition,
|
||||
@Valid @ModelAttribute PolicyRunFiles files)
|
||||
@RequestParam("json") String json, MultipartHttpServletRequest request)
|
||||
throws IOException {
|
||||
requireRunnable(definition);
|
||||
PolicyInputs inputs = toInputs(files);
|
||||
PipelineDefinition definition = parseDefinition(json);
|
||||
PolicyInputs inputs = collectInputs(request);
|
||||
|
||||
SseEmitter emitter =
|
||||
new SseEmitter(applicationProperties.getPolicies().getStreamTimeoutMs());
|
||||
emitter.onError(e -> log.warn("Policy run SSE emitter error", e));
|
||||
|
||||
PolicyRunHandle handle = policyRunner.runAdHoc(definition, inputs, streamListener(emitter));
|
||||
// whenComplete runs on the worker thread after the run finishes, so the terminal event
|
||||
// never races the step events.
|
||||
// Close the stream with a terminal event once the run finishes. whenComplete runs on the
|
||||
// engine's worker thread after the run is done, so this never races the step events.
|
||||
handle.completion()
|
||||
.whenComplete(
|
||||
(run, throwable) -> {
|
||||
@@ -151,80 +159,41 @@ public class PolicyController {
|
||||
description =
|
||||
"Stores a policy (trigger config + steps + output + metadata). A blank id is"
|
||||
+ " assigned; returns the stored policy with its id.")
|
||||
public ResponseEntity<Policy> savePolicy(@RequestBody Policy policy) {
|
||||
requirePolicyEditingAllowed();
|
||||
Policy owned = resolveOwnership(policy);
|
||||
public ResponseEntity<Policy> savePolicy(@RequestBody String json) {
|
||||
Policy policy = parsePolicy(json);
|
||||
requireAuthorizedForFolderAccess(policy);
|
||||
try {
|
||||
policyValidator.validate(owned);
|
||||
policyValidator.validate(policy);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
||||
}
|
||||
return ResponseEntity.ok(policyStore.save(owned));
|
||||
return ResponseEntity.ok(policyStore.save(policy));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign owner + owning team server-side. Create stamps the current user and their team; update
|
||||
* preserves the existing owner and team after verifying the policy belongs to the caller's team
|
||||
* — so the client can neither forge ownership/team on create nor reach across teams on update
|
||||
* (a policy in another team reads as not-found).
|
||||
* A policy that reads from or writes to a server folder grants whoever saves it access to that
|
||||
* path, so restrict it to administrators on multi-user deployments. Single-user deployments
|
||||
* (login disabled, e.g. desktop) trust the local operator. The {@link FolderAccessGuard} still
|
||||
* enforces SaaS-off and the path allowlist during validation regardless of who saves.
|
||||
*/
|
||||
private Policy resolveOwnership(Policy incoming) {
|
||||
String id = incoming.id();
|
||||
if (id != null && !id.isBlank()) {
|
||||
Policy existing = policyStore.get(id).orElse(null);
|
||||
if (existing != null) {
|
||||
if (!policyAccessGuard.canAccess(existing)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No policy: " + id);
|
||||
}
|
||||
return withOwnerAndTeam(incoming, existing.owner(), existing.teamId());
|
||||
}
|
||||
private void requireAuthorizedForFolderAccess(Policy policy) {
|
||||
if (!folderAccessGuard.usesFolderAccess(policy)) {
|
||||
return;
|
||||
}
|
||||
return withOwnerAndTeam(
|
||||
incoming,
|
||||
policyAccessGuard.ownerForNewPolicy(),
|
||||
policyAccessGuard.teamForNewPolicy());
|
||||
}
|
||||
|
||||
private static Policy withOwnerAndTeam(Policy policy, String owner, Long teamId) {
|
||||
return new Policy(
|
||||
policy.id(),
|
||||
policy.name(),
|
||||
owner,
|
||||
policy.enabled(),
|
||||
policy.trigger(),
|
||||
policy.sources(),
|
||||
policy.steps(),
|
||||
policy.output(),
|
||||
teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creating, editing, pausing/resuming, and deleting policies requires the editor role for the
|
||||
* caller's team — a team leader on SaaS (see {@link PolicyManagementAuthority}); the global
|
||||
* admin gets no say on SaaS. Team scoping (which team's policies) is enforced separately by
|
||||
* {@link PolicyAccessGuard}. Every mutation routes through {@link #savePolicy} (pause/resume
|
||||
* re-save with a flipped {@code enabled} flag) or {@link #deletePolicy}, so gating those two
|
||||
* covers them all; runs ({@code /run}) stay open to the team. Single-user deployments (login
|
||||
* disabled) have no such role, so they trust the local operator. The path allowlist for folder
|
||||
* sources/outputs is enforced separately by {@link PolicyValidator} at validation time.
|
||||
*/
|
||||
private void requirePolicyEditingAllowed() {
|
||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||
return;
|
||||
}
|
||||
if (!policyManagementAuthority.canEditPolicies()) {
|
||||
if (!userService.isCurrentUserAdmin()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"Policies may only be created or modified by a team leader");
|
||||
"Folder sources and outputs may only be configured by an administrator");
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "List policies",
|
||||
description = "Lists the policies belonging to the caller's team.")
|
||||
@Operation(summary = "List policies")
|
||||
public List<Policy> listPolicies() {
|
||||
return policyAccessGuard.visible(policyStore.all());
|
||||
return policyStore.all();
|
||||
}
|
||||
|
||||
@GetMapping("/{policyId}")
|
||||
@@ -232,7 +201,6 @@ public class PolicyController {
|
||||
public ResponseEntity<Policy> getPolicy(@PathVariable String policyId) {
|
||||
return policyStore
|
||||
.get(policyId)
|
||||
.filter(policyAccessGuard::canAccess)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
@@ -240,14 +208,9 @@ public class PolicyController {
|
||||
@DeleteMapping("/{policyId}")
|
||||
@Operation(summary = "Delete a policy by id")
|
||||
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)) {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
return ResponseEntity.notFound().build();
|
||||
return policyStore.delete(policyId)
|
||||
? ResponseEntity.noContent().build()
|
||||
: ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
@PostMapping(value = "/{policyId}/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -255,52 +218,70 @@ public class PolicyController {
|
||||
summary = "Run a stored policy",
|
||||
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.")
|
||||
+ " under 'fileInput', supporting files under their asset-key fields)."
|
||||
+ " 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 {
|
||||
@PathVariable String policyId, MultipartHttpServletRequest request) throws IOException {
|
||||
Policy policy =
|
||||
policyStore
|
||||
.get(policyId)
|
||||
.filter(policyAccessGuard::canAccess)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "No policy: " + policyId));
|
||||
PolicyInputs inputs = toInputs(files);
|
||||
PolicyInputs inputs = collectInputs(request);
|
||||
String runId = policyRunner.runWith(policy, inputs, PolicyProgressListener.NOOP).runId();
|
||||
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
|
||||
}
|
||||
|
||||
private static void requireRunnable(PipelineDefinition definition) {
|
||||
private Policy parsePolicy(String json) {
|
||||
try {
|
||||
return objectMapper.readValue(json, Policy.class);
|
||||
} catch (JacksonException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid policy JSON");
|
||||
}
|
||||
}
|
||||
|
||||
private PipelineDefinition parseDefinition(String json) {
|
||||
PipelineDefinition definition;
|
||||
try {
|
||||
definition = objectMapper.readValue(json, PipelineDefinition.class);
|
||||
} catch (JacksonException e) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Invalid pipeline definition JSON");
|
||||
}
|
||||
if (definition.steps().isEmpty()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Pipeline definition has no steps");
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the typed run files into engine {@link PolicyInputs}: the primary documents plus the
|
||||
* named supporting-file store, where each asset's {@code key} is the name a step references
|
||||
* from its {@code fileParameters}. Assets sharing a key are grouped, so a key may carry several
|
||||
* files.
|
||||
* Split the multipart file parts into the primary document stream ("fileInput") and the named
|
||||
* supporting-file store: every other file field becomes an asset keyed by its field name, which
|
||||
* a step references from {@code fileParameters}.
|
||||
*/
|
||||
private PolicyInputs toInputs(PolicyRunFiles files) throws IOException {
|
||||
List<Resource> primary = toResources(files.getFileInput());
|
||||
private PolicyInputs collectInputs(MultipartHttpServletRequest request) throws IOException {
|
||||
MultiValueMap<String, MultipartFile> fileMap = request.getMultiFileMap();
|
||||
List<Resource> primary = toResources(fileMap.get("fileInput"));
|
||||
Map<String, List<Resource>> supportingFiles = new LinkedHashMap<>();
|
||||
for (NamedAsset asset : files.getAssets()) {
|
||||
Resource resource = toResource(asset.getFile());
|
||||
if (resource != null) {
|
||||
supportingFiles
|
||||
.computeIfAbsent(asset.getKey(), key -> new ArrayList<>())
|
||||
.add(resource);
|
||||
for (Map.Entry<String, List<MultipartFile>> entry : fileMap.entrySet()) {
|
||||
if ("fileInput".equals(entry.getKey())) {
|
||||
continue;
|
||||
}
|
||||
List<Resource> assets = toResources(entry.getValue());
|
||||
if (!assets.isEmpty()) {
|
||||
supportingFiles.put(entry.getKey(), assets);
|
||||
}
|
||||
}
|
||||
return new PolicyInputs(primary, supportingFiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* A progress listener that forwards each step transition to the SSE stream as a "step" event.
|
||||
*/
|
||||
private PolicyProgressListener streamListener(SseEmitter emitter) {
|
||||
return new PolicyProgressListener() {
|
||||
@Override
|
||||
@@ -339,8 +320,8 @@ public class PolicyController {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name(name).data(data, MediaType.APPLICATION_JSON));
|
||||
} catch (IOException | IllegalStateException e) {
|
||||
// Client gone or emitter closed. The run continues and outputs stay downloadable via
|
||||
// the job endpoints.
|
||||
// Client disconnected or the emitter already closed. The run continues and its results
|
||||
// remain downloadable via the job endpoints; nothing useful left to stream.
|
||||
log.debug("Dropping policy SSE event '{}': {}", name, e.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -351,27 +332,20 @@ public class PolicyController {
|
||||
return resources;
|
||||
}
|
||||
for (MultipartFile file : files) {
|
||||
Resource resource = toResource(file);
|
||||
if (resource != null) {
|
||||
resources.add(resource);
|
||||
if (file == null || file.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
TempFile tempFile = tempFileManager.createManagedTempFile("policy-run");
|
||||
file.transferTo(tempFile.getPath());
|
||||
final String originalName = Filenames.toSimpleFileName(file.getOriginalFilename());
|
||||
resources.add(
|
||||
new FileSystemResource(tempFile.getFile()) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return originalName;
|
||||
}
|
||||
});
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
|
||||
/** Spool a single uploaded file to a managed temp file, preserving its name; null if empty. */
|
||||
private Resource toResource(MultipartFile file) throws IOException {
|
||||
if (file == null || file.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
TempFile tempFile = tempFileManager.createManagedTempFile("policy-run");
|
||||
file.transferTo(tempFile.getPath());
|
||||
final String originalName = Filenames.toSimpleFileName(file.getOriginalFilename());
|
||||
return new FileSystemResource(tempFile.getFile()) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return originalName;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
package stirling.software.proprietary.policy.controller;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* The files supplied to a policy run: the primary documents and any keyed supporting assets. Bound
|
||||
* from the multipart request via {@code @ModelAttribute}; the pipeline definition itself travels as
|
||||
* a separate typed {@code json} part.
|
||||
*
|
||||
* <p>Wire form: {@code fileInput} (repeated) for primaries, and {@code assets[i].key} / {@code
|
||||
* assets[i].file} for each supporting asset.
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "Files for a policy run: primary documents plus keyed supporting assets")
|
||||
public class PolicyRunFiles {
|
||||
|
||||
@Schema(description = "Primary input documents", format = "binary")
|
||||
private List<MultipartFile> fileInput = new ArrayList<>();
|
||||
|
||||
@Valid
|
||||
@Schema(description = "Supporting files, each bound to the asset key its step references")
|
||||
private List<NamedAsset> assets = new ArrayList<>();
|
||||
}
|
||||
+49
-150
@@ -8,13 +8,9 @@ import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -27,7 +23,6 @@ import stirling.software.common.service.JobQueue;
|
||||
import stirling.software.common.service.ResourceMonitor;
|
||||
import stirling.software.common.service.TaskManager;
|
||||
import stirling.software.common.util.ExecutorFactory;
|
||||
import stirling.software.common.util.JobContext;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineDefinition;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
@@ -36,27 +31,33 @@ import stirling.software.proprietary.policy.model.PolicyRun;
|
||||
import stirling.software.proprietary.policy.model.WaitState;
|
||||
import stirling.software.proprietary.policy.output.PolicyOutputSink;
|
||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
import stirling.software.proprietary.service.DownstreamEntitlementError;
|
||||
|
||||
/**
|
||||
* Runs pipelines asynchronously as tracked jobs. {@link #submit} returns a run id immediately; the
|
||||
* pipeline runs on a virtual thread (so a step blocked on a slow tool does not hold a platform
|
||||
* thread). Drives {@link PolicyExecutor} for the step loop, projects status/outputs into {@link
|
||||
* TaskManager} (existing job endpoints work unchanged), and keeps live state in {@link
|
||||
* PolicyRunRegistry}.
|
||||
* Runs pipelines asynchronously as tracked jobs.
|
||||
*
|
||||
* <p>Manages its own virtual-thread execution rather than {@code JobExecutorService}, which
|
||||
* force-completes a job once its work returns: incompatible with a run that suspends in {@code
|
||||
* WAITING_FOR_INPUT}. Still applies the shared {@link ResourceMonitor}/{@link JobQueue} admission
|
||||
* control so heavy runs queue under load.
|
||||
* <p>Each run is the unit of async work: {@link #submit} returns a run id immediately and the
|
||||
* pipeline executes on a virtual thread, so a step blocking on a slow tool does not tie up a
|
||||
* platform thread. The run drives {@link PolicyExecutor} for the actual step loop, registers its
|
||||
* outputs and progress with {@link TaskManager} (so the existing job status/download endpoints work
|
||||
* unchanged), and keeps rich state in {@link PolicyRunRegistry}.
|
||||
*
|
||||
* <p>The engine deliberately manages its own virtual-thread execution rather than routing through
|
||||
* {@code JobExecutorService}: that path force-completes a job once its work returns, which is
|
||||
* incompatible with a run that suspends in {@code WAITING_FOR_INPUT}. It still applies the shared
|
||||
* {@link ResourceMonitor}/{@link JobQueue} admission control, so heavy runs queue under load
|
||||
* instead of oversubscribing.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PolicyEngine {
|
||||
|
||||
// Admission weight for one run. Weighted heavy: a run chains many tools and holds intermediate
|
||||
// files. See ResourceMonitor#shouldQueueJob(int).
|
||||
/**
|
||||
* Resource weight of a pipeline run for admission control. A run chains many tools and holds
|
||||
* intermediate files, so it is weighted as heavy work: the shared {@link ResourceMonitor}
|
||||
* should let it start while the system is healthy but hold it back under memory/CPU pressure.
|
||||
* See {@link ResourceMonitor#shouldQueueJob(int)} for how a weight maps to that decision.
|
||||
*/
|
||||
private static final int RUN_RESOURCE_WEIGHT = 50;
|
||||
|
||||
private final PolicyExecutor stepExecutor;
|
||||
@@ -71,65 +72,27 @@ public class PolicyEngine {
|
||||
private final ExecutorService asyncExecutor = ExecutorFactory.newVirtualThreadExecutor();
|
||||
|
||||
/**
|
||||
* Submit a pipeline to run asynchronously. The handle's run id scopes a {@link TaskManager} job
|
||||
* (status/notes/results observable via the job endpoints); its future resolves when the run
|
||||
* reaches a terminal or paused state.
|
||||
* Submit a pipeline to run asynchronously. The returned handle's run id scopes a job in {@link
|
||||
* TaskManager}, so progress (notes), status, and result files are observable via the existing
|
||||
* job endpoints as well as via {@link #getRun(String)}; its completion future resolves when the
|
||||
* run reaches a terminal or paused state.
|
||||
*/
|
||||
public PolicyRunHandle submit(
|
||||
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener) {
|
||||
// Ad-hoc run (no stored policy): bill whoever kicked it off and own the outputs as them
|
||||
// too.
|
||||
// Capture the principal on this (request) thread — it does not survive the hop onto the
|
||||
// async
|
||||
// worker.
|
||||
String principal = currentActingPrincipal();
|
||||
return submitForPrincipal(principal, principal, definition, inputs, listener);
|
||||
}
|
||||
|
||||
/** Run a stored policy on demand. {@code enabled} gates triggers, not explicit runs. */
|
||||
public PolicyRunHandle runPolicy(
|
||||
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
|
||||
// Bill the policy owner: trigger-fired runs have no security context, and the async worker
|
||||
// doesn't inherit the caller's, so the owner (stamped at policy creation) is the reliable
|
||||
// billing identity — and for org-wide policies the org/owner is meant to pay. But own the
|
||||
// OUTPUT files as the user who triggered the run (captured here on the request thread) so
|
||||
// they can download their enforced file; otherwise an org-wide policy's output is owned by
|
||||
// the admin and the triggering user is denied it. Trigger-fired runs have no such user, so
|
||||
// the owner owns those outputs.
|
||||
String triggeringUser = currentActingPrincipal();
|
||||
String fileOwner = triggeringUser != null ? triggeringUser : policy.owner();
|
||||
return submitForPrincipal(
|
||||
policy.owner(), fileOwner, policy.toDefinition(), inputs, listener);
|
||||
}
|
||||
|
||||
private PolicyRunHandle submitForPrincipal(
|
||||
String billingPrincipal,
|
||||
String fileOwner,
|
||||
PipelineDefinition definition,
|
||||
PolicyInputs inputs,
|
||||
PolicyProgressListener listener) {
|
||||
// Scope the run id to the current user (this request thread) so the file-download
|
||||
// ownership check passes. No-op when security is off.
|
||||
// Scope the run id to the current user (on this request thread) so the file-download
|
||||
// ownership check passes; NoOpJobOwnershipService returns the id unchanged when security
|
||||
// is off.
|
||||
String runId = jobOwnershipService.createScopedJobKey(UUID.randomUUID().toString());
|
||||
taskManager.createTask(runId);
|
||||
PolicyRun run = new PolicyRun(runId, definition);
|
||||
registry.register(run);
|
||||
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
|
||||
PolicyProgressListener tracking = trackingListener(runId, run, listener);
|
||||
// Re-establish the acting principal as the audit principal on the worker thread. Each tool
|
||||
// step dispatches via InternalApiClient, which resolves the caller from
|
||||
// UserService.getCurrentUsername() — that has an MDC `auditPrincipal` fallback for async
|
||||
// threads. Without this the worker has no identity, tool calls fall back to the
|
||||
// INTERNAL_API_USER, and PAYG charges that system account instead of the owner's team.
|
||||
Runnable task =
|
||||
() ->
|
||||
runAsPrincipal(
|
||||
billingPrincipal,
|
||||
fileOwner,
|
||||
() -> runToCompletion(run, inputs, tracking, completion));
|
||||
Runnable task = () -> runToCompletion(run, inputs, tracking, completion);
|
||||
|
||||
// One admission unit per run; steps run synchronously within it, so this gates heavy work
|
||||
// without the pool-within-pool risk of queueing each tool call.
|
||||
// Each run is one admission unit; steps run synchronously within it, so this gates heavy
|
||||
// work under load without the pool-within-pool risk of queueing each tool call. Under
|
||||
// resource pressure the run waits in the shared JobQueue; otherwise it starts immediately.
|
||||
if (resourceMonitor.shouldQueueJob(RUN_RESOURCE_WEIGHT)) {
|
||||
log.debug("Queueing policy run {} under resource pressure", runId);
|
||||
jobQueue.queueJob(
|
||||
@@ -147,12 +110,22 @@ public class PolicyEngine {
|
||||
return new PolicyRunHandle(runId, completion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a stored policy on demand. Builds the policy's pipeline and submits it. {@code enabled}
|
||||
* gates automatic triggering, not explicit runs, so this runs regardless of that flag.
|
||||
*/
|
||||
public PolicyRunHandle runPolicy(
|
||||
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
|
||||
return submit(policy.toDefinition(), inputs, listener);
|
||||
}
|
||||
|
||||
public PolicyRun getRun(String runId) {
|
||||
return registry.get(runId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a run cancelled if not already finished. Does not yet interrupt an in-flight tool call.
|
||||
* Request cancellation of a run. Stage 1 marks the run cancelled in the registry if it has not
|
||||
* already finished; interrupting an in-flight tool call lands in a later stage.
|
||||
*/
|
||||
public boolean cancel(String runId) {
|
||||
PolicyRun run = registry.get(runId);
|
||||
@@ -166,7 +139,10 @@ public class PolicyEngine {
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
/** Resume a run paused in {@code WAITING_FOR_INPUT}. Not yet implemented. */
|
||||
/**
|
||||
* Resume a run paused in {@code WAITING_FOR_INPUT}. Not yet implemented; the run shape and
|
||||
* {@link WaitState} snapshot are in place so this can be added without reworking the engine.
|
||||
*/
|
||||
public String resume(String runId, List<Resource> additionalInputs) {
|
||||
throw new UnsupportedOperationException("Pause/resume is not yet implemented");
|
||||
}
|
||||
@@ -187,8 +163,8 @@ public class PolicyEngine {
|
||||
taskManager.setComplete(runId);
|
||||
run.complete(outputs);
|
||||
} catch (PolicyInputRequiredException e) {
|
||||
// Expected path: suspend rather than fail. Persist intermediates as fileIds so the run
|
||||
// can resume after this worker thread is gone.
|
||||
// Designed-for path: suspend the run rather than fail it. Persist intermediates as
|
||||
// fileIds so the run can resume after this worker thread is gone.
|
||||
WaitState wait = suspend(e);
|
||||
run.waitForInput(wait);
|
||||
taskManager.addNote(runId, "Waiting for input: " + e.getMessage());
|
||||
@@ -201,41 +177,21 @@ public class PolicyEngine {
|
||||
e.getMessage());
|
||||
run.fail(message);
|
||||
taskManager.setError(runId, message);
|
||||
} catch (RestClientResponseException e) {
|
||||
// A downstream tool call returned an error status. When it's a structured entitlement
|
||||
// response (401/402 with a JSON `error` sentinel), surface that code onto the run so
|
||||
// the
|
||||
// client can react — e.g. pop the usage-limit modal — instead of only seeing a generic
|
||||
// failure. We don't interpret the code here (that would couple this module to the saas
|
||||
// billing layer); we just pass it through for the client to map. Other statuses fall
|
||||
// through to the generic failure below.
|
||||
String code = DownstreamEntitlementError.extractCode(e);
|
||||
if (code != null) {
|
||||
log.info("Policy run {} blocked by downstream entitlement gate ({})", runId, code);
|
||||
String message = "Usage limit reached";
|
||||
run.failWithCode(message, code, DownstreamEntitlementError.extractSubscribed(e));
|
||||
taskManager.setError(runId, message);
|
||||
} else {
|
||||
String message = "Policy run failed: " + e.getMessage();
|
||||
log.error("Policy run {} failed (downstream HTTP error)", runId, e);
|
||||
run.fail(message);
|
||||
taskManager.setError(runId, message);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
String message = "Policy run failed: " + e.getMessage();
|
||||
log.error("Policy run {} failed", runId, e);
|
||||
run.fail(message);
|
||||
taskManager.setError(runId, message);
|
||||
} finally {
|
||||
// Always resolve so stream/await callers unblock.
|
||||
// Always resolve the handle with the run's final state so stream/await callers unblock.
|
||||
completion.complete(run);
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<?> failRejectedRun(
|
||||
PolicyRun run, CompletableFuture<PolicyRun> completion, Throwable ex) {
|
||||
// Only reached if the run never started (e.g. queue full); a started run resolves its own
|
||||
// completion in runToCompletion.
|
||||
// Only reached if the run never started (e.g. the queue was full). A run that started
|
||||
// always resolves its own completion in runToCompletion.
|
||||
if (!completion.isDone()) {
|
||||
String message = "Policy run could not be queued: " + ex.getMessage();
|
||||
log.error("Policy run {} was not admitted: {}", run.getRunId(), ex.getMessage());
|
||||
@@ -302,61 +258,4 @@ public class PolicyEngine {
|
||||
"The %s tool did not respond within %d seconds and was aborted.",
|
||||
e.getEndpointPath(), e.getReadTimeout().toSeconds());
|
||||
}
|
||||
|
||||
/**
|
||||
* MDC key {@code UserService.getCurrentUsername()} reads as its async fallback (stamped by the
|
||||
* controller audit aspect on request threads). We reuse it to carry the billing identity onto
|
||||
* the policy worker thread.
|
||||
*/
|
||||
private static final String AUDIT_PRINCIPAL_MDC_KEY = "auditPrincipal";
|
||||
|
||||
/**
|
||||
* The username to bill an ad-hoc run to, captured on the submitting (request) thread. Prefers
|
||||
* the audit principal the controller aspect already stamped; falls back to the security context
|
||||
* name. {@code anonymousUser} (and no identity) resolve to null so we don't try to bill it.
|
||||
*/
|
||||
private static String currentActingPrincipal() {
|
||||
String mdc = MDC.get(AUDIT_PRINCIPAL_MDC_KEY);
|
||||
if (mdc != null && !mdc.isBlank()) {
|
||||
return mdc;
|
||||
}
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null) {
|
||||
return null;
|
||||
}
|
||||
String name = auth.getName();
|
||||
return "anonymousUser".equals(name) ? null : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run {@code body} with {@code principal} set as the audit principal in MDC, so async tool
|
||||
* dispatch attributes (and charges) usage to that user. A null/blank principal runs as-is.
|
||||
* Restores the previous MDC value afterward (defensive — worker threads aren't pooled).
|
||||
*/
|
||||
private static void runAsPrincipal(String billingPrincipal, String fileOwner, Runnable body) {
|
||||
// Billing identity (MDC auditPrincipal) and output-file ownership (JobContext owner) are
|
||||
// set
|
||||
// independently: usage is charged to billingPrincipal, but stored output files are owned by
|
||||
// fileOwner — the user who triggered an org-wide policy — so they can fetch their results.
|
||||
// Either may be null (e.g. login disabled, or a trigger-fired run); each is applied only
|
||||
// when present and restored afterward (defensive — worker threads aren't pooled).
|
||||
String previousPrincipal = MDC.get(AUDIT_PRINCIPAL_MDC_KEY);
|
||||
String previousOwner = JobContext.getOwner();
|
||||
if (billingPrincipal != null && !billingPrincipal.isBlank()) {
|
||||
MDC.put(AUDIT_PRINCIPAL_MDC_KEY, billingPrincipal);
|
||||
}
|
||||
if (fileOwner != null && !fileOwner.isBlank()) {
|
||||
JobContext.setOwner(fileOwner);
|
||||
}
|
||||
try {
|
||||
body.run();
|
||||
} finally {
|
||||
if (previousPrincipal != null) {
|
||||
MDC.put(AUDIT_PRINCIPAL_MDC_KEY, previousPrincipal);
|
||||
} else {
|
||||
MDC.remove(AUDIT_PRINCIPAL_MDC_KEY);
|
||||
}
|
||||
JobContext.setOwner(previousOwner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -7,8 +7,11 @@ import org.springframework.core.io.Resource;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* Result of a {@link PolicyExecutor} run. {@code files} are final temp files (not yet stored).
|
||||
* {@code report}/{@code reportTool} carry the last step's structured report and its operation, or
|
||||
* null if no step produced one.
|
||||
* Result of running a pipeline through {@link PolicyExecutor}.
|
||||
*
|
||||
* <p>{@code files} are the final output resources (temp files, not yet stored to {@code
|
||||
* FileStorage}). {@code report} is the structured metadata payload captured from the last step that
|
||||
* produced one (a JSON body, or an {@code X-Stirling-Tool-Report} header), with {@code reportTool}
|
||||
* naming the step it came from; both are null when no step produced a report.
|
||||
*/
|
||||
public record PolicyExecutionResult(List<Resource> files, JsonNode report, String reportTool) {}
|
||||
|
||||
+55
-27
@@ -35,11 +35,15 @@ import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Runs an ordered chain of tool steps, feeding each step's output files into the next.
|
||||
* Runs an ordered chain of tool steps, chaining each step's output files into the next step's
|
||||
* input.
|
||||
*
|
||||
* <p>Steps dispatch synchronously via {@link InternalApiClient} loopback HTTP (each tool runs in
|
||||
* its own handler, returns its file inline). The caller controls threading. Files cross step
|
||||
* boundaries as {@link Resource} temp files and are only persisted at the run boundaries by the
|
||||
* <p>This is the single execution loop for the proprietary surface (AI plans now;
|
||||
* manually-triggered runs and watched folders later). Each step is dispatched synchronously via
|
||||
* {@link InternalApiClient} loopback HTTP: the tool runs in its own handler and returns its file
|
||||
* inline. The caller decides how to run the executor itself (the AI turn loop calls it directly;
|
||||
* the engine runs it on a virtual thread for async runs). Files cross step boundaries as {@link
|
||||
* Resource} temp files; they are only persisted to durable storage at the run boundaries by the
|
||||
* caller.
|
||||
*/
|
||||
@Slf4j
|
||||
@@ -54,16 +58,25 @@ public class PolicyExecutor {
|
||||
private final TempFileManager tempFileManager;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
// files: result files (one, or many for ZIP-response tools). report: optional structured
|
||||
// payload the tool surfaced alongside or instead of a file.
|
||||
/**
|
||||
* Internal value-class for tool responses. {@code files} holds any result files (typically one;
|
||||
* multiple for ZIP-response tools). {@code report} holds an optional structured metadata
|
||||
* payload the tool chose to surface alongside (or instead of) a file.
|
||||
*/
|
||||
private record ToolResult(List<Resource> files, JsonNode report) {}
|
||||
|
||||
/**
|
||||
* Run every step in order, feeding each step's output into the next. Supporting files in {@code
|
||||
* inputs} bind to named file fields and never enter the document stream.
|
||||
* Execute every step in {@code definition} in order, feeding each step's output into the next.
|
||||
* Supporting files supplied in {@code inputs} are bound to steps' named file fields and never
|
||||
* enter the document stream.
|
||||
*
|
||||
* @param definition the pipeline to run (must have at least one step)
|
||||
* @param inputs the primary documents plus the named supporting-file store
|
||||
* @param listener receives per-step progress
|
||||
* @return the final output files plus the last structured report produced, if any
|
||||
* @throws InternalApiTimeoutException if a tool does not respond within its read timeout
|
||||
* @throws IOException on a non-OK tool response, a missing supporting file, or a read failure
|
||||
* @throws IOException if a tool returns a non-OK response, references a missing supporting
|
||||
* file, or a file cannot be read
|
||||
*/
|
||||
public PolicyExecutionResult execute(
|
||||
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener)
|
||||
@@ -75,7 +88,7 @@ public class PolicyExecutor {
|
||||
|
||||
List<Resource> currentFiles = inputs.primary();
|
||||
Map<String, List<Resource>> supportingFiles = inputs.supportingFiles();
|
||||
// Last non-null report wins: the terminal step defines the output.
|
||||
// Propagate the *last* non-null report; the terminal step defines the output.
|
||||
JsonNode lastReport = null;
|
||||
String lastReportTool = null;
|
||||
|
||||
@@ -100,9 +113,13 @@ public class PolicyExecutor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-input endpoints get all files in one call; others are called once per file. ZIP
|
||||
* responses are unpacked so each inner file is its own result (e.g. split). For per-file
|
||||
* dispatch the first non-null report wins.
|
||||
* Execute a single tool step. If the endpoint accepts multiple files, all files are sent in one
|
||||
* call. Otherwise, the endpoint is called once per file. ZIP responses are unpacked so each
|
||||
* inner file is treated as its own result (e.g. split outputs a ZIP of pages).
|
||||
*
|
||||
* <p>A structured {@code report} may be returned alongside (or instead of) files; see {@link
|
||||
* ToolResult}. For per-file dispatch (single-input endpoints called once per input), the first
|
||||
* non-null report wins.
|
||||
*/
|
||||
private ToolResult executeStep(
|
||||
PipelineStep step,
|
||||
@@ -129,10 +146,17 @@ public class PolicyExecutor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an endpoint, returning result files and optional report. Response handling: JSON body is
|
||||
* the report with no file; a file body returns the file plus any {@link
|
||||
* AiToolResponseHeaders#TOOL_REPORT} header report; ZIP responses (per tool metadata) are
|
||||
* unpacked to a flat file list.
|
||||
* Call an endpoint and return its result files and optional report.
|
||||
*
|
||||
* <ul>
|
||||
* <li>JSON body (Content-Type: application/json): the entire body is the report, no files are
|
||||
* returned.
|
||||
* <li>File body (PDF etc.): the file is returned; if an {@link
|
||||
* AiToolResponseHeaders#TOOL_REPORT} header is present, its (minified JSON) value is
|
||||
* parsed as the report.
|
||||
* <li>ZIP responses declared by the tool metadata service are unpacked so callers always see
|
||||
* a flat list of result files.
|
||||
* </ul>
|
||||
*/
|
||||
private ToolResult callEndpoint(
|
||||
PipelineStep step, List<Resource> files, Map<String, List<Resource>> supportingFiles)
|
||||
@@ -142,8 +166,8 @@ public class PolicyExecutor {
|
||||
for (Resource file : files) {
|
||||
body.add("fileInput", file);
|
||||
}
|
||||
// Bind supporting files to named tool fields (e.g. stampImage); from the asset store, not
|
||||
// the document stream.
|
||||
// Bind supporting files to their named tool fields (e.g. stampImage, overlayFiles). These
|
||||
// come from the run's named asset store, not the document stream.
|
||||
for (Map.Entry<String, String> binding : step.fileParameters().entrySet()) {
|
||||
String fieldName = binding.getKey();
|
||||
String assetKey = binding.getValue();
|
||||
@@ -165,9 +189,9 @@ public class PolicyExecutor {
|
||||
for (Map.Entry<String, Object> entry : step.parameters().entrySet()) {
|
||||
if (entry.getValue() instanceof List<?> list) {
|
||||
if (containsStructuredElements(list)) {
|
||||
// These endpoints (e.g. /security/redact redactions, /general/edit-text edits)
|
||||
// bind a list of structured objects from a single JSON string field via a
|
||||
// property editor, so pre-serialize the whole list.
|
||||
// Endpoints binding lists of structured objects (e.g. /security/redact's
|
||||
// redactions, /general/edit-text's edits) parse a single JSON string field via
|
||||
// a property editor. Pre-serialize the whole list so binding succeeds.
|
||||
body.add(entry.getKey(), objectMapper.writeValueAsString(list));
|
||||
} else {
|
||||
for (Object item : list) {
|
||||
@@ -185,8 +209,8 @@ public class PolicyExecutor {
|
||||
}
|
||||
Resource resource = response.getBody();
|
||||
|
||||
// Filter ops return an empty body to mean "filtered out": drop it rather than forward a
|
||||
// zero-byte document.
|
||||
// Filter operations return an empty body to signal the file was filtered out: drop it
|
||||
// rather than forwarding a zero-byte document.
|
||||
if (isFilterOperation(endpointPath) && isEmpty(resource)) {
|
||||
return new ToolResult(List.of(), null);
|
||||
}
|
||||
@@ -194,7 +218,7 @@ public class PolicyExecutor {
|
||||
HttpHeaders headers = response.getHeaders();
|
||||
MediaType contentType = headers.getContentType();
|
||||
|
||||
// JSON-only response: whole body is the report, no file.
|
||||
// JSON-only response: the whole body is the structured report, no result file.
|
||||
if (contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
|
||||
try (InputStream is = resource.getInputStream()) {
|
||||
JsonNode report = objectMapper.readTree(is);
|
||||
@@ -209,7 +233,10 @@ public class PolicyExecutor {
|
||||
return new ToolResult(List.of(resource), report);
|
||||
}
|
||||
|
||||
/** Parse the optional {@link AiToolResponseHeaders#TOOL_REPORT} header, or null. */
|
||||
/**
|
||||
* Parse the optional {@link AiToolResponseHeaders#TOOL_REPORT} header into a {@link JsonNode},
|
||||
* or return null.
|
||||
*/
|
||||
private JsonNode parseReportHeader(HttpHeaders headers, String endpointPath) {
|
||||
String raw = headers.getFirst(AiToolResponseHeaders.TOOL_REPORT);
|
||||
if (raw == null || raw.isBlank()) {
|
||||
@@ -237,7 +264,8 @@ public class PolicyExecutor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail if any primary-stream file is a type the step rejects. No declared type means anything.
|
||||
* Fail the run if any document in the primary stream is not a file type the step accepts. An
|
||||
* endpoint that declares no specific input type accepts anything.
|
||||
*/
|
||||
private void requireAcceptedTypes(String operation, List<Resource> files) throws IOException {
|
||||
List<String> accepted = toolMetadataService.getExtensionTypes(false, operation);
|
||||
|
||||
+9
-3
@@ -7,9 +7,15 @@ import org.springframework.core.io.Resource;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Thrown by a step that needs further user input, pausing the run in {@code WAITING_FOR_INPUT}
|
||||
* instead of failing. Carries the resume reason, 0-based resume step index, and intermediate files;
|
||||
* the engine persists those and suspends. Not yet thrown by any step.
|
||||
* Thrown by a step to signal that the run cannot proceed without further user input, pausing the
|
||||
* run in {@code WAITING_FOR_INPUT} rather than failing it.
|
||||
*
|
||||
* <p>Carries everything needed to resume: a human-readable reason, the 0-based index of the step to
|
||||
* resume from, and the intermediate files produced so far. The engine persists those files and
|
||||
* suspends the run.
|
||||
*
|
||||
* <p>Defined now to fix the run shape; no step throws it yet, and the resume handshake is
|
||||
* implemented in a later stage.
|
||||
*/
|
||||
@Getter
|
||||
public class PolicyInputRequiredException extends RuntimeException {
|
||||
|
||||
+7
-4
@@ -5,9 +5,12 @@ import java.util.concurrent.CompletableFuture;
|
||||
import stirling.software.proprietary.policy.model.PolicyRun;
|
||||
|
||||
/**
|
||||
* Returned by {@link PolicyEngine#submit}: the run id (status polling, result download) plus a
|
||||
* future that resolves when the run reaches a terminal or paused state. The future carries the
|
||||
* {@link PolicyRun} whose status describes the outcome; it does not complete exceptionally for
|
||||
* ordinary run failures.
|
||||
* Returned by {@link PolicyEngine#submit}: the run id (for status polling and result download) plus
|
||||
* a future that resolves when the run reaches a terminal or paused state.
|
||||
*
|
||||
* <p>The completion future lets callers react to the end of a run (e.g. an SSE endpoint sending a
|
||||
* final event and closing the stream) without polling. It carries the {@link PolicyRun} whose
|
||||
* status describes the outcome (completed, failed, cancelled, or waiting for input); it does not
|
||||
* complete exceptionally for ordinary run failures.
|
||||
*/
|
||||
public record PolicyRunHandle(String runId, CompletableFuture<PolicyRun> completion) {}
|
||||
|
||||
+13
-8
@@ -19,12 +19,16 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.policy.model.PolicyRun;
|
||||
|
||||
/**
|
||||
* In-memory store of live {@link PolicyRun} state, keyed by runId. Authoritative run state machine;
|
||||
* durable status/files are projected separately into {@code TaskManager}.
|
||||
* In-memory store of live {@link PolicyRun} state, keyed by runId. Holds the authoritative run
|
||||
* state machine; durable status/files for download are projected separately into {@code
|
||||
* TaskManager}.
|
||||
*
|
||||
* <p>A scheduled sweep evicts only terminal runs aged past {@code policies.runExpiryMinutes};
|
||||
* active and paused runs are kept regardless of age. Eviction frees only this map's entry: the
|
||||
* shared {@code TaskManager} job owns file-lifecycle cleanup.
|
||||
* <p>Finished runs are evicted on a fixed interval once they age past {@code
|
||||
* policies.runExpiryMinutes}, mirroring the job-result expiry in {@code TaskManager} so a run's
|
||||
* rich in-memory state does not outlive the process. Only terminal runs are evicted; active and
|
||||
* paused ({@code WAITING_FOR_INPUT}) runs are retained regardless of age. Result files are not
|
||||
* touched here: a run shares its runId with a {@code TaskManager} job, which owns file-lifecycle
|
||||
* cleanup, so eviction only frees this map's entry.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -57,7 +61,7 @@ public class PolicyRunRegistry {
|
||||
return runs.values();
|
||||
}
|
||||
|
||||
/** Scheduled sweep entry point. */
|
||||
/** Scheduled hook: evict terminal runs that finished before the expiry window. */
|
||||
private void evictExpiredRuns() {
|
||||
try {
|
||||
evictExpired(Instant.now().minus(runExpiry));
|
||||
@@ -67,8 +71,9 @@ public class PolicyRunRegistry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict terminal runs last updated before {@code cutoff}, returning the count. Package-visible
|
||||
* so the sweep and tests share one path with an explicit cutoff.
|
||||
* Remove every terminal run last updated before {@code cutoff}; active and paused runs are kept
|
||||
* regardless of age. Returns the number evicted. Package-visible so the scheduled sweep and
|
||||
* tests exercise the same path with an explicit cutoff.
|
||||
*/
|
||||
int evictExpired(Instant cutoff) {
|
||||
int removed = 0;
|
||||
|
||||
+17
-6
@@ -20,8 +20,14 @@ import stirling.software.proprietary.policy.model.PolicyRunStatus;
|
||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
|
||||
/**
|
||||
* Turns a policy's configured {@link InputSpec sources} into runs. Triggers decide <em>when</em>
|
||||
* and call {@link #run(Policy)}; the controller uses the supplied-input and ad-hoc entry points.
|
||||
* Runs policies, and is the one place that knows how to turn a policy's configured {@link InputSpec
|
||||
* sources} into actual runs. Triggers (schedule, and future webhook/folder-watch) decide
|
||||
* <em>when</em> to run and call {@link #run(Policy)}; they never touch sources themselves. The
|
||||
* controller uses the supplied-input and ad-hoc entry points for on-demand work.
|
||||
*
|
||||
* <p>This is the seam that keeps triggers and sources independent: a trigger depends on the runner,
|
||||
* the runner depends on the {@link InputSource} beans, and a source depends on neither - it just
|
||||
* yields {@link ResolvedInput units of work}, each carrying its own completion hook.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -32,9 +38,10 @@ public class PolicyRunner {
|
||||
private final List<InputSource> inputSources;
|
||||
|
||||
/**
|
||||
* Trigger entry point. Pulls every configured source; each yielded unit becomes its own run so
|
||||
* one failure does not affect the others. No sources means one run with no input (generator
|
||||
* pipeline).
|
||||
* Run a policy by pulling from every source it configures: each source yields zero or more
|
||||
* units of work, and each unit becomes its own run so one failure does not affect the others. A
|
||||
* policy with no sources runs once with no input files (a generator pipeline). Used by
|
||||
* automatic triggers.
|
||||
*/
|
||||
public void run(Policy policy) {
|
||||
List<InputSpec> sources = policy.sources();
|
||||
@@ -47,7 +54,11 @@ public class PolicyRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/** Run a stored policy on caller-supplied files (e.g. manual upload), bypassing its sources. */
|
||||
/**
|
||||
* Run a stored policy on files supplied directly by the caller (e.g. a manual run with
|
||||
* uploads), bypassing its configured sources. Returns the run handle so callers can stream
|
||||
* progress.
|
||||
*/
|
||||
public PolicyRunHandle runWith(
|
||||
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
|
||||
return policyEngine.runPolicy(policy, inputs, listener);
|
||||
|
||||
+8
-4
@@ -15,9 +15,12 @@ import stirling.software.proprietary.policy.output.PolicyOutputSink;
|
||||
import stirling.software.proprietary.policy.trigger.PolicyTrigger;
|
||||
|
||||
/**
|
||||
* Validates a policy at save time by delegating each facet (trigger, sources, output) to the bean
|
||||
* that handles its type, so a misconfiguration fails fast rather than at run time. A null trigger
|
||||
* is a manual-only policy and skips trigger validation.
|
||||
* Validates a policy's trigger, sources, and output configuration by delegating each facet to the
|
||||
* bean that handles its type. Called when a policy is saved so a misconfigured schedule, missing
|
||||
* folder directory, or unknown type fails fast instead of silently misbehaving at run time.
|
||||
*
|
||||
* <p>The trigger is optional (a {@code null} trigger is a manual-only policy and needs no
|
||||
* validation); every configured source is validated.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -28,7 +31,8 @@ public class PolicyValidator {
|
||||
private final List<PolicyOutputSink> outputSinks;
|
||||
|
||||
/**
|
||||
* @throws IllegalArgumentException if any facet's type is unknown or its config is invalid
|
||||
* @throws IllegalArgumentException if any facet's type is unknown or its configuration is
|
||||
* invalid
|
||||
*/
|
||||
public void validate(Policy policy) {
|
||||
if (policy.trigger() != null) {
|
||||
|
||||
+17
-8
@@ -22,13 +22,21 @@ import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
|
||||
/**
|
||||
* Reads input files from a directory; each ready file is its own unit of work so one failure does
|
||||
* not affect the others.
|
||||
* Reads input files from a directory. Each ready file becomes its own unit of work (one run per
|
||||
* file) so a failure on one file does not affect the others.
|
||||
*
|
||||
* <p>Mode option: "consume" (default) claims each file by moving it into {@code
|
||||
* .stirling/processing} then routes it to {@code .stirling/done} or {@code .stirling/error}, so
|
||||
* each file runs once; "snapshot" reads without moving, so every run sees the full set. Readiness
|
||||
* is checked first so files mid-write are skipped.
|
||||
* <p>Two modes via the {@code mode} option:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code "consume"} (default) - claim each file by moving it into {@code
|
||||
* .stirling/processing}, then route it to {@code .stirling/done} or {@code .stirling/error}
|
||||
* when its run finishes. Each file is processed once; right for "process new arrivals" (and
|
||||
* the basis of watched folders).
|
||||
* <li>{@code "snapshot"} - read the directory's current files without moving them; every run sees
|
||||
* the full set again. Right for "always regenerate from a fixed input set".
|
||||
* </ul>
|
||||
*
|
||||
* Readiness is checked first (via {@link FileReadinessChecker}) so files mid-write are skipped.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -36,7 +44,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
public class FolderInputSource implements InputSource {
|
||||
|
||||
private static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
|
||||
// Bookkeeping lives under one hidden dir so the watched folder stays tidy.
|
||||
// Bookkeeping lives under one hidden namespace dir so the watched folder stays tidy.
|
||||
private static final String WORK_SUBDIR = ".stirling";
|
||||
private static final String PROCESSING_SUBDIR = "processing";
|
||||
private static final String DONE_SUBDIR = "done";
|
||||
@@ -99,7 +107,6 @@ public class FolderInputSource implements InputSource {
|
||||
return work;
|
||||
}
|
||||
|
||||
// Atomic move into processing/: only one sweep can win the claim, the rest see the file gone.
|
||||
private Path claim(Path inputDir, Path file) {
|
||||
try {
|
||||
Path processingDir = workDir(inputDir, PROCESSING_SUBDIR);
|
||||
@@ -128,6 +135,7 @@ public class FolderInputSource implements InputSource {
|
||||
}
|
||||
}
|
||||
|
||||
/** A bookkeeping subdirectory under the watched folder's {@code .stirling} namespace. */
|
||||
private static Path workDir(Path inputDir, String subdir) {
|
||||
return inputDir.resolve(WORK_SUBDIR).resolve(subdir);
|
||||
}
|
||||
@@ -158,6 +166,7 @@ public class FolderInputSource implements InputSource {
|
||||
}
|
||||
}
|
||||
|
||||
/** The typed, validated form of a folder source's options: the directory and dedup mode. */
|
||||
record FolderConfig(Path directory, boolean snapshot) {
|
||||
|
||||
private static final String DIRECTORY_OPTION = "directory";
|
||||
|
||||
+19
-8
@@ -7,9 +7,14 @@ import java.util.List;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
|
||||
/**
|
||||
* Resolves a policy {@link InputSpec} into the files to run on. Implementations are beans selected
|
||||
* by {@link #supports(InputSpec)}, so a new source kind (folder, S3) is just a new bean. A manual
|
||||
* run may supply files directly and bypass sources entirely.
|
||||
* Resolves one of a policy's {@link InputSpec sources} into the files to run on - answering
|
||||
* <em>where</em> a run's files come from, independent of <em>when</em> it runs. The counterpart of
|
||||
* {@code PolicyOutputSink}: implementations are beans selected by {@link #supports(InputSpec)}, so
|
||||
* a new source kind (folder, S3) is just a new bean.
|
||||
*
|
||||
* <p>Driven by the {@code PolicyRunner}, which a trigger calls when a policy is due; a source is
|
||||
* passive and knows nothing about what triggered the run. A manual run may instead supply files
|
||||
* directly and bypass sources entirely.
|
||||
*/
|
||||
public interface InputSource {
|
||||
|
||||
@@ -19,18 +24,24 @@ public interface InputSource {
|
||||
/** Whether this source can handle the given spec. */
|
||||
boolean supports(InputSpec spec);
|
||||
|
||||
/** Throws {@link IllegalArgumentException} on bad config. Called on save to fail fast. */
|
||||
/**
|
||||
* Check that an input spec is usable, throwing {@link IllegalArgumentException} if not. Called
|
||||
* when a policy is saved so misconfiguration fails fast rather than at run time.
|
||||
*/
|
||||
default void validate(InputSpec spec) {}
|
||||
|
||||
/**
|
||||
* Resolve the spec into zero or more units of work, each carrying one run's files and a
|
||||
* completion hook. Empty list means nothing to run right now.
|
||||
* Resolve the spec into zero or more units of work, each carrying the files for one run and a
|
||||
* completion hook. Returning an empty list means there is nothing to run right now.
|
||||
*/
|
||||
List<ResolvedInput> resolve(InputSpec spec) throws IOException;
|
||||
|
||||
/**
|
||||
* Filesystem dirs this source draws from, for the folder-watch trigger. Advisory: resolving is
|
||||
* still done by {@link #resolve}. Non-filesystem sources return empty and are not watchable.
|
||||
* The local filesystem directories this source draws from, if any, for triggers that want to
|
||||
* react to changes there (the folder-watch trigger) rather than poll. Advisory only: it merely
|
||||
* tells a trigger <em>where</em> to watch; resolving the spec into files is still this source's
|
||||
* job via {@link #resolve}. Non-filesystem sources (S3, ...) return an empty list and so are
|
||||
* simply not watchable. Default: nothing to watch.
|
||||
*/
|
||||
default List<Path> watchTargets(InputSpec spec) {
|
||||
return List.of();
|
||||
|
||||
+5
-4
@@ -5,9 +5,10 @@ import java.util.function.Consumer;
|
||||
import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
|
||||
/**
|
||||
* One unit of work from an {@link InputSource}: the files to run plus a completion callback invoked
|
||||
* with the run's success (e.g. a folder source routes the input to done/error). A source may return
|
||||
* several of these, one per file.
|
||||
* One unit of work produced by an {@link InputSource}: the files to run plus a completion callback
|
||||
* invoked with the run's success once it finishes (e.g. a folder source routes the input to {@code
|
||||
* .stirling/done} or {@code .stirling/error}). A source may return several of these (e.g. one per
|
||||
* file).
|
||||
*/
|
||||
public record ResolvedInput(PolicyInputs inputs, Consumer<Boolean> onComplete) {
|
||||
|
||||
@@ -15,7 +16,7 @@ public record ResolvedInput(PolicyInputs inputs, Consumer<Boolean> onComplete) {
|
||||
onComplete = onComplete == null ? success -> {} : onComplete;
|
||||
}
|
||||
|
||||
/** No completion side effect. */
|
||||
/** A unit of work with no completion side effect. */
|
||||
public static ResolvedInput of(PolicyInputs inputs) {
|
||||
return new ResolvedInput(inputs, success -> {});
|
||||
}
|
||||
|
||||
+9
-2
@@ -3,8 +3,15 @@ package stirling.software.proprietary.policy.model;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* One input source for a policy. {@code type} keys an {@code InputSource} bean; a run pulls from
|
||||
* every source.
|
||||
* One source a policy's input files come from. {@code type} selects an {@code InputSource}
|
||||
* ("folder", and in future "s3", ...); {@code options} carries source-specific configuration (a
|
||||
* directory, a bucket, a dedup mode, ...).
|
||||
*
|
||||
* <p>A policy holds a list of these; a run pulls from every one. An empty list means the policy
|
||||
* runs with no input files (a generator pipeline, or files supplied directly to a manual run).
|
||||
*
|
||||
* <p>Data-driven and parallel to {@link OutputSpec}/{@link TriggerConfig}: a new source kind is a
|
||||
* new {@code type} handled by a new {@code InputSource} bean.
|
||||
*/
|
||||
public record InputSpec(String type, Map<String, Object> options) {
|
||||
|
||||
|
||||
+8
-2
@@ -2,13 +2,19 @@ package stirling.software.proprietary.policy.model;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/** Where a run's outputs are delivered. {@code type} keys a {@code PolicyOutputSink} bean. */
|
||||
/**
|
||||
* Describes where a pipeline run's output files should be delivered. {@code type} selects a {@code
|
||||
* PolicyOutputSink} (e.g. "inline"); {@code options} carries sink-specific configuration.
|
||||
*
|
||||
* <p>New destinations (folder, S3) are added as new sink beans keyed on a new {@code type} without
|
||||
* changing this shape or the engine.
|
||||
*/
|
||||
public record OutputSpec(String type, Map<String, Object> options) {
|
||||
public OutputSpec {
|
||||
options = options == null ? Map.of() : options;
|
||||
}
|
||||
|
||||
/** Default sink: store outputs and return them to the caller for download. */
|
||||
/** The default destination: store outputs and return them to the caller for download. */
|
||||
public static OutputSpec inline() {
|
||||
return new OutputSpec("inline", Map.of());
|
||||
}
|
||||
|
||||
+4
-3
@@ -3,10 +3,11 @@ package stirling.software.proprietary.policy.model;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* An ordered chain of tool steps plus an output destination; the unit the engine executes.
|
||||
* An ordered chain of tool steps plus where the output should go.
|
||||
*
|
||||
* <p>{@code output} may be null for callers that handle result files themselves (e.g. the AI
|
||||
* workflow, which builds its own response payload).
|
||||
* <p>This is the single shape executed by the policy engine, shared by AI plans, manually-triggered
|
||||
* runs, and (later) watched folders. {@code output} may be null for callers that handle result
|
||||
* files themselves (e.g. the AI workflow, which builds its own response payload).
|
||||
*/
|
||||
public record PipelineDefinition(String name, List<PipelineStep> steps, OutputSpec output) {
|
||||
public PipelineDefinition {
|
||||
|
||||
+10
-6
@@ -3,13 +3,17 @@ package stirling.software.proprietary.policy.model;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A single tool invocation. {@code operation} is a Stirling endpoint path (e.g. {@code
|
||||
* /api/v1/misc/compress-pdf}) per the {@code InternalApiClient} convention; {@code parameters} are
|
||||
* scalar form fields.
|
||||
* A single tool invocation in a pipeline: the API endpoint path to call and the inputs to pass.
|
||||
*
|
||||
* <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.
|
||||
* <p>{@code operation} is a Stirling tool endpoint path (e.g. {@code /api/v1/misc/compress-pdf}),
|
||||
* matching the dispatch convention used by {@code InternalApiClient}. {@code parameters} are the
|
||||
* tool-specific scalar form fields.
|
||||
*
|
||||
* <p>{@code fileParameters} binds a tool's named file fields (beyond the primary {@code fileInput}
|
||||
* stream) to supporting files supplied with the run: it maps the form field name (e.g. {@code
|
||||
* stampImage}, {@code overlayFiles}) to an asset key in the run's supporting-file store. This keeps
|
||||
* supporting inputs (a stamp image, a certificate, an overlay) out of the document stream that
|
||||
* flows step to step.
|
||||
*/
|
||||
public record PipelineStep(
|
||||
String operation, Map<String, Object> parameters, Map<String, String> fileParameters) {
|
||||
|
||||
+12
-24
@@ -3,11 +3,16 @@ package stirling.software.proprietary.policy.model;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A stored automation: ordered tool steps, input sources, and an output destination.
|
||||
* A stored automation: an ordered chain of tool steps, the sources its input files come from, and
|
||||
* an output destination for the results.
|
||||
*
|
||||
* <p>Always runnable on demand. An optional {@link TriggerConfig} fires it automatically; a {@code
|
||||
* null} trigger means manual-only. Trigger decides when, {@link InputSpec sources} decide where
|
||||
* files come from; a run pulls from every source.
|
||||
* <p>Every policy can always be run on demand (manually). It may additionally carry one automatic
|
||||
* {@link TriggerConfig} - usually a schedule - that fires it without a person asking; a {@code
|
||||
* null} trigger means manual-only. A trigger decides <em>when</em> a run happens and a {@link
|
||||
* InputSpec source} decides <em>where</em> its files come from; the two are independent, and a run
|
||||
* pulls from every configured source.
|
||||
*
|
||||
* <p>This is the feature's central configuration object - what a user defines and the engine runs.
|
||||
*/
|
||||
public record Policy(
|
||||
String id,
|
||||
@@ -17,8 +22,7 @@ public record Policy(
|
||||
TriggerConfig trigger,
|
||||
List<InputSpec> sources,
|
||||
List<PipelineStep> steps,
|
||||
OutputSpec output,
|
||||
Long teamId) {
|
||||
OutputSpec output) {
|
||||
|
||||
public Policy {
|
||||
sources = sources == null ? List.of() : List.copyOf(sources);
|
||||
@@ -26,22 +30,6 @@ public record Policy(
|
||||
output = output == null ? OutputSpec.inline() : output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Without an explicit owning team. Kept for the engine and tests; the controller always stamps
|
||||
* a {@code teamId} on stored policies so they stay scoped to the creating user's team.
|
||||
*/
|
||||
public Policy(
|
||||
String id,
|
||||
String name,
|
||||
String owner,
|
||||
boolean enabled,
|
||||
TriggerConfig trigger,
|
||||
List<InputSpec> sources,
|
||||
List<PipelineStep> steps,
|
||||
OutputSpec output) {
|
||||
this(id, name, owner, enabled, trigger, sources, steps, output, null);
|
||||
}
|
||||
|
||||
/** A policy with no configured sources (a generator, or files supplied directly to a run). */
|
||||
public Policy(
|
||||
String id,
|
||||
@@ -51,10 +39,10 @@ public record Policy(
|
||||
TriggerConfig trigger,
|
||||
List<PipelineStep> steps,
|
||||
OutputSpec output) {
|
||||
this(id, name, owner, enabled, trigger, List.of(), steps, output, null);
|
||||
this(id, name, owner, enabled, trigger, List.of(), steps, output);
|
||||
}
|
||||
|
||||
/** This policy's pipeline as the engine sees it. */
|
||||
/** The engine-level, trigger-agnostic view of this policy's pipeline. */
|
||||
public PipelineDefinition toDefinition() {
|
||||
return new PipelineDefinition(name, steps, output);
|
||||
}
|
||||
|
||||
+11
-3
@@ -6,9 +6,17 @@ import java.util.Map;
|
||||
import org.springframework.core.io.Resource;
|
||||
|
||||
/**
|
||||
* A run's files. {@code primary} documents flow step to step; {@code supportingFiles} are auxiliary
|
||||
* assets bound by key via {@link PipelineStep#fileParameters()} and never enter the document
|
||||
* stream. Asset values are lists so one key can carry a multi-file field (e.g. attachments).
|
||||
* The files a run operates on, split into two roles:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code primary} - the documents that flow through the pipeline, each step's output becoming
|
||||
* the next step's input.
|
||||
* <li>{@code supportingFiles} - a named store of auxiliary files (a stamp image, certificate,
|
||||
* overlay, attachments) that steps bind to their named file fields via {@link
|
||||
* PipelineStep#fileParameters()}. These never enter the document stream.
|
||||
* </ul>
|
||||
*
|
||||
* Asset values are lists so a single key can carry multi-file fields (e.g. attachments).
|
||||
*/
|
||||
public record PolicyInputs(List<Resource> primary, Map<String, List<Resource>> supportingFiles) {
|
||||
|
||||
|
||||
+9
-31
@@ -8,10 +8,12 @@ import lombok.Getter;
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
|
||||
/**
|
||||
* Live, mutable state of one pipeline run, held in memory by {@code PolicyRunRegistry} and the
|
||||
* authoritative source of the state machine. Carries execution state ({@code JobResult} does not
|
||||
* model status/step cursor/wait state); also projected into {@code TaskManager} for cluster-visible
|
||||
* status and download.
|
||||
* Live, mutable state of a single pipeline run, held in memory by {@code PolicyRunRegistry}.
|
||||
*
|
||||
* <p>This carries the rich execution state (status, step cursor, wait state) that the job system's
|
||||
* {@code JobResult} does not model. The run is also projected into {@code TaskManager} for
|
||||
* cluster-visible status, progress notes, and file download; this object is the authoritative
|
||||
* source of the state machine.
|
||||
*/
|
||||
@Getter
|
||||
public class PolicyRun {
|
||||
@@ -27,21 +29,6 @@ public class PolicyRun {
|
||||
|
||||
private volatile WaitState waitState;
|
||||
private volatile String error;
|
||||
|
||||
/**
|
||||
* Stable, machine-readable failure code the client can branch on — e.g. an entitlement-limit
|
||||
* sentinel ({@code PAYG_LIMIT_REACHED} / {@code FEATURE_DEGRADED}) propagated from a downstream
|
||||
* tool call's 402 — alongside the human-readable {@link #error}. Null unless set on failure.
|
||||
*/
|
||||
private volatile String errorCode;
|
||||
|
||||
/**
|
||||
* For an entitlement-limit failure, whether the team was subscribed (over its spending cap) vs
|
||||
* un-subscribed (free allowance spent) — taken from the blocking 402 body. Drives which
|
||||
* usage-limit modal the client shows. Null unless {@link #errorCode} is an entitlement code.
|
||||
*/
|
||||
private volatile Boolean errorSubscribed;
|
||||
|
||||
private volatile List<ResultFile> outputs = List.of();
|
||||
private volatile Instant updatedAt = Instant.now();
|
||||
|
||||
@@ -76,24 +63,15 @@ public class PolicyRun {
|
||||
touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail with a stable {@code errorCode} the client can branch on (e.g. an entitlement-limit
|
||||
* sentinel from a downstream 402), plus the optional {@code subscribed} flag from that
|
||||
* response, in addition to the human-readable message.
|
||||
*/
|
||||
public synchronized void failWithCode(String message, String errorCode, Boolean subscribed) {
|
||||
this.errorCode = errorCode;
|
||||
this.errorSubscribed = subscribed;
|
||||
fail(message);
|
||||
}
|
||||
|
||||
public synchronized void waitForInput(WaitState wait) {
|
||||
this.waitState = wait;
|
||||
this.status = PolicyRunStatus.WAITING_FOR_INPUT;
|
||||
touch();
|
||||
}
|
||||
|
||||
/** Cancels unless already terminal; returns whether it transitioned. */
|
||||
/**
|
||||
* Mark cancelled if the run has not already reached a terminal state. Returns whether it did.
|
||||
*/
|
||||
public synchronized boolean cancel() {
|
||||
if (status.isTerminal()) {
|
||||
return false;
|
||||
|
||||
+5
-2
@@ -1,8 +1,11 @@
|
||||
package stirling.software.proprietary.policy.model;
|
||||
|
||||
/**
|
||||
* Lifecycle states of a {@link PolicyRun}. {@code WAITING_FOR_INPUT} models a thread-free pause;
|
||||
* the resume handshake lands in a later stage.
|
||||
* Lifecycle states of a {@link PolicyRun}.
|
||||
*
|
||||
* <p>{@code WAITING_FOR_INPUT} is modelled now so the engine and run shape support pausing a run
|
||||
* (e.g. a step that blocks for a human decision) without holding a thread; the resume handshake is
|
||||
* implemented in a later stage.
|
||||
*/
|
||||
public enum PolicyRunStatus {
|
||||
PENDING,
|
||||
|
||||
+2
-6
@@ -5,8 +5,8 @@ import java.util.List;
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
|
||||
/**
|
||||
* Read-only view of a {@link PolicyRun} for the status endpoint. Outputs are {@link ResultFile}s,
|
||||
* downloadable via {@code GET /api/v1/general/files/{id}}.
|
||||
* Read-only view of a {@link PolicyRun} returned by the status endpoint. Output files are surfaced
|
||||
* as {@link ResultFile} so the caller can download each via {@code GET /api/v1/general/files/{id}}.
|
||||
*/
|
||||
public record PolicyRunView(
|
||||
String runId,
|
||||
@@ -14,8 +14,6 @@ public record PolicyRunView(
|
||||
int currentStep,
|
||||
int stepCount,
|
||||
String error,
|
||||
String errorCode,
|
||||
Boolean errorSubscribed,
|
||||
List<ResultFile> outputs) {
|
||||
|
||||
public static PolicyRunView of(PolicyRun run) {
|
||||
@@ -25,8 +23,6 @@ public record PolicyRunView(
|
||||
run.getCurrentStep(),
|
||||
run.stepCount(),
|
||||
run.getError(),
|
||||
run.getErrorCode(),
|
||||
run.getErrorSubscribed(),
|
||||
run.getOutputs());
|
||||
}
|
||||
}
|
||||
|
||||
+16
-5
@@ -11,9 +11,16 @@ import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
|
||||
/**
|
||||
* A scheduled policy's firing cadence; {@code type} is the JSON discriminator. Wall-clock kinds
|
||||
* ({@link Daily}, {@link Weekly}, {@link Monthly}) evaluate in the {@code after} argument's zone;
|
||||
* {@link Every} is a fixed offset and ignores wall-clock time.
|
||||
* When a scheduled policy should fire, expressed as intent ("every day at 02:00") rather than a
|
||||
* cron string. A frontend builds one of these from a friendly picker; the schedule trigger asks it
|
||||
* for the next firing after a given moment.
|
||||
*
|
||||
* <p>Sealed so every cadence is an explicit, self-validating shape and adding a new one forces a
|
||||
* new subtype rather than another string convention to learn. The {@code type} discriminator stays
|
||||
* on the wire so the frontend can switch on it without knowing the Java hierarchy.
|
||||
*
|
||||
* <p>Wall-clock kinds ({@link Daily}, {@link Weekly}, {@link Monthly}) are evaluated in the zone of
|
||||
* the {@code after} argument; {@link Every} is a fixed offset and ignores wall-clock time.
|
||||
*/
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
|
||||
@JsonSubTypes({
|
||||
@@ -35,7 +42,10 @@ public sealed interface Schedule {
|
||||
DAYS
|
||||
}
|
||||
|
||||
/** A fixed offset from {@code after}: "every 15 minutes", "every 6 hours". No time of day. */
|
||||
/**
|
||||
* A fixed cadence repeating from when the policy was last seen: "every 15 minutes", "every 6
|
||||
* hours". Time of day is irrelevant.
|
||||
*/
|
||||
record Every(long count, Unit unit) implements Schedule {
|
||||
public Every {
|
||||
if (count <= 0) {
|
||||
@@ -81,7 +91,8 @@ public sealed interface Schedule {
|
||||
|
||||
@Override
|
||||
public ZonedDateTime nextAfter(ZonedDateTime after) {
|
||||
// Soonest of the next 7 days landing on a chosen weekday, at the configured time.
|
||||
// The soonest of the next 7 days that lands on a chosen weekday, at the configured
|
||||
// time.
|
||||
for (int i = 0; i <= 7; i++) {
|
||||
ZonedDateTime candidate = after.plusDays(i).with(at);
|
||||
if (candidate.isAfter(after) && days.contains(candidate.getDayOfWeek())) {
|
||||
|
||||
+11
-3
@@ -3,9 +3,17 @@ package stirling.software.proprietary.policy.model;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A {@link Policy}'s automatic trigger; {@code type} keys a trigger bean (e.g. "schedule"). Manual
|
||||
* running is not a trigger kind: a manual-only policy carries a {@code null} {@code TriggerConfig}.
|
||||
* Answers only "when"; file sources are the policy's {@link InputSpec}s.
|
||||
* A {@link Policy}'s optional automatic trigger - what fires it without a person asking. {@code
|
||||
* type} selects a trigger kind ("schedule", and in future "webhook", "folder-watch", ...); {@code
|
||||
* options} carries type-specific configuration (a {@link Schedule}, a webhook secret, ...).
|
||||
*
|
||||
* <p>Manual running is <em>not</em> a trigger kind: every policy can always be run on demand, so a
|
||||
* policy with no automatic trigger simply has a {@code null} {@code TriggerConfig}. A trigger
|
||||
* answers only "when"; where a run's files come from is a separate concern owned by the policy's
|
||||
* {@link InputSpec sources}.
|
||||
*
|
||||
* <p>Data-driven and parallel to {@link OutputSpec}: new trigger kinds are new {@code type} values
|
||||
* handled by a new trigger bean, with no change to the model.
|
||||
*/
|
||||
public record TriggerConfig(String type, Map<String, Object> options) {
|
||||
|
||||
|
||||
+8
-4
@@ -3,10 +3,14 @@ package stirling.software.proprietary.policy.model;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Resumable snapshot captured when a run pauses ({@link PolicyRunStatus#WAITING_FOR_INPUT}). {@code
|
||||
* resumeStepIndex} is the 0-based step to continue from; {@code pendingFileIds} are intermediate
|
||||
* files held in {@code FileStorage} (not in-memory resources) so a pause survives the worker thread
|
||||
* ending or a node restart.
|
||||
* Captured when a run pauses in {@link PolicyRunStatus#WAITING_FOR_INPUT}. Together with the run's
|
||||
* {@link PipelineDefinition} this is the resumable snapshot: {@code resumeStepIndex} is the 0-based
|
||||
* step to continue from, and {@code pendingFileIds} are the intermediate files (stored in {@code
|
||||
* FileStorage}, so they survive the worker thread ending or a node restart) that become the input
|
||||
* to the resumed run.
|
||||
*
|
||||
* <p>Stored as fileIds rather than in-memory resources by design: a paused run must be resumable
|
||||
* long after its worker thread has gone.
|
||||
*/
|
||||
public record WaitState(String reason, int resumeStepIndex, List<String> pendingFileIds) {
|
||||
public WaitState {
|
||||
|
||||
+13
-6
@@ -22,10 +22,13 @@ import stirling.software.proprietary.policy.config.FolderAccessGuard;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
|
||||
/**
|
||||
* Writes a run's outputs to the {@code directory} given in the {@link OutputSpec}. Files are
|
||||
* streamed (not buffered) and uniquely named to avoid clobbering. Returned {@link ResultFile}s
|
||||
* carry a synthetic id since the deliverable is the file on disk, not a {@code FileStorage} entry,
|
||||
* so folder outputs are not downloadable via {@code /files/{id}}.
|
||||
* Writes a run's output files to a directory on disk. The destination is the {@code directory}
|
||||
* option of the {@link OutputSpec}.
|
||||
*
|
||||
* <p>Files are streamed to disk (so large outputs are not buffered) and given unique names to avoid
|
||||
* clobbering existing files. The returned {@link ResultFile}s describe what was written (path +
|
||||
* size); they carry a synthetic id because the deliverable is the file on disk, not a {@code
|
||||
* FileStorage} entry, so folder outputs are not downloadable via {@code /files/{id}}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -92,7 +95,11 @@ public class FolderOutputSink implements PolicyOutputSink {
|
||||
return Path.of(directory.toString());
|
||||
}
|
||||
|
||||
// Strip any directory component / "../" so a crafted output name cannot escape targetDir.
|
||||
/**
|
||||
* The resource's filename reduced to a bare, traversal-free name: any directory component or
|
||||
* "../" is stripped so a crafted output name cannot escape {@code targetDir}. Falls back to a
|
||||
* synthetic name when the filename is absent or reduces to nothing usable.
|
||||
*/
|
||||
private static String safeName(String filename, int index) {
|
||||
if (filename == null || filename.isBlank()) {
|
||||
return "output-" + index;
|
||||
@@ -104,7 +111,7 @@ public class FolderOutputSink implements PolicyOutputSink {
|
||||
return name;
|
||||
}
|
||||
|
||||
// Non-colliding path, appending " (n)" before the extension.
|
||||
/** Resolve a non-colliding path in {@code dir}, appending " (n)" before the extension. */
|
||||
private static Path uniqueTarget(Path dir, String filename) {
|
||||
Path candidate = dir.resolve(filename);
|
||||
if (!Files.exists(candidate)) {
|
||||
|
||||
+3
-2
@@ -17,8 +17,9 @@ import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
|
||||
/**
|
||||
* Default sink: stores each output in {@code FileStorage} so it is downloadable via {@code GET
|
||||
* /api/v1/general/files/{fileId}}. Used for manual runs whose results return to the caller.
|
||||
* Default output sink: stores each output file in {@code FileStorage} so it is downloadable via
|
||||
* {@code GET /api/v1/general/files/{fileId}}. This is the destination for manually-triggered runs
|
||||
* whose results are returned to the caller.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
|
||||
+16
-5
@@ -9,9 +9,11 @@ import stirling.software.common.model.job.ResultFile;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
|
||||
/**
|
||||
* Delivers a finished run's outputs to a destination, returning {@link ResultFile} descriptors for
|
||||
* the run record. Implementations are beans selected by {@link #supports(OutputSpec)}, so a new
|
||||
* destination (folder, S3) is just a new bean.
|
||||
* Delivers a finished run's output files to a destination, returning durable {@link ResultFile}
|
||||
* descriptors (fileId + metadata) for the run record.
|
||||
*
|
||||
* <p>Implementations are Spring beans selected by {@link #supports(OutputSpec)}. New destinations
|
||||
* (folder, S3) are added as new beans without changing the engine.
|
||||
*/
|
||||
public interface PolicyOutputSink {
|
||||
|
||||
@@ -21,10 +23,19 @@ public interface PolicyOutputSink {
|
||||
/** Whether this sink can handle the given output spec. */
|
||||
boolean supports(OutputSpec spec);
|
||||
|
||||
/** Throws {@link IllegalArgumentException} on bad config. Called on save to fail fast. */
|
||||
/**
|
||||
* Check that an output spec is usable, throwing {@link IllegalArgumentException} if not. Called
|
||||
* when a policy is saved so misconfiguration fails fast rather than at run time.
|
||||
*/
|
||||
default void validate(OutputSpec spec) {}
|
||||
|
||||
/** Persist/deliver the output files and return their descriptors. */
|
||||
/**
|
||||
* Persist/deliver the output files and return their descriptors.
|
||||
*
|
||||
* @param runId the run these outputs belong to
|
||||
* @param outputs the final pipeline output resources
|
||||
* @param spec the requested destination
|
||||
*/
|
||||
List<ResultFile> deliver(String runId, List<Resource> outputs, OutputSpec spec)
|
||||
throws IOException;
|
||||
}
|
||||
|
||||
+8
-3
@@ -1,17 +1,22 @@
|
||||
package stirling.software.proprietary.policy.progress;
|
||||
|
||||
/**
|
||||
* Receives live progress as a pipeline run executes (SSE stream, job notes, or both). Step indices
|
||||
* are 1-based. All methods default to no-ops.
|
||||
* Receives live progress as a pipeline run executes. Implementations forward to an SSE stream,
|
||||
* write job notes for polling, or both. Step indices are 1-based.
|
||||
*
|
||||
* <p>All methods default to no-ops so callers implement only what they surface.
|
||||
*/
|
||||
public interface PolicyProgressListener {
|
||||
|
||||
/** A listener that ignores all progress. */
|
||||
PolicyProgressListener NOOP = new PolicyProgressListener() {};
|
||||
|
||||
/** Called immediately before step {@code stepIndex} of {@code stepCount} begins. */
|
||||
default void onStepStart(int stepIndex, int stepCount, String operation) {}
|
||||
|
||||
/** Called immediately after step {@code stepIndex} of {@code stepCount} completes. */
|
||||
default void onStepComplete(int stepIndex, int stepCount, String operation) {}
|
||||
|
||||
/** Keep-alive tick so downstream connections can detect disconnects promptly. */
|
||||
/** Called on a keep-alive tick so downstream connections can detect disconnects promptly. */
|
||||
default void onHeartbeat() {}
|
||||
}
|
||||
|
||||
+4
-4
@@ -9,8 +9,9 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
|
||||
/**
|
||||
* In-memory {@link PolicyStore} for tests and any future no-database mode. {@link JpaPolicyStore}
|
||||
* is the runtime bean.
|
||||
* In-memory {@link PolicyStore}. Not the runtime bean - {@link JpaPolicyStore} is the durable
|
||||
* store. Kept as a lightweight, dependency-free implementation for tests and for any future no-
|
||||
* database mode.
|
||||
*/
|
||||
public class InProcessPolicyStore implements PolicyStore {
|
||||
|
||||
@@ -31,8 +32,7 @@ public class InProcessPolicyStore implements PolicyStore {
|
||||
policy.trigger(),
|
||||
policy.sources(),
|
||||
policy.steps(),
|
||||
policy.output(),
|
||||
policy.teamId());
|
||||
policy.output());
|
||||
policies.put(id, stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
+4
-4
@@ -13,8 +13,9 @@ import stirling.software.proprietary.policy.model.Policy;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Durable {@link PolicyStore} backed by JPA; the runtime store. Policies are persisted as JSON via
|
||||
* {@link PolicyEntity}, with scalar columns kept in sync for querying.
|
||||
* Durable {@link PolicyStore} backed by JPA. The runtime store whenever the proprietary module runs
|
||||
* (a datasource is always present). Policies are persisted as JSON via {@link PolicyEntity}; the
|
||||
* scalar columns are kept in sync for querying.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -38,8 +39,7 @@ public class JpaPolicyStore implements PolicyStore {
|
||||
policy.trigger(),
|
||||
policy.sources(),
|
||||
policy.steps(),
|
||||
policy.output(),
|
||||
policy.teamId());
|
||||
policy.output());
|
||||
|
||||
PolicyEntity entity = new PolicyEntity();
|
||||
entity.setId(id);
|
||||
|
||||
+7
-5
@@ -12,11 +12,13 @@ import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* JPA row for a {@link stirling.software.proprietary.policy.model.Policy}. The whole policy lives
|
||||
* as JSON in {@code policyJson} (authoritative on read); the scalar columns are denormalized copies
|
||||
* for querying, notably {@code triggerType} + {@code enabled} so background triggers can fetch
|
||||
* their policies. {@code owner} is a plain string, not a foreign key, to stay decoupled from the
|
||||
* security entities.
|
||||
* JPA row for a {@link stirling.software.proprietary.policy.model.Policy}.
|
||||
*
|
||||
* <p>The whole policy is stored as JSON in {@code policyJson} (authoritative on read, and the same
|
||||
* serialization the API uses); the scalar columns are denormalized copies for querying - notably
|
||||
* {@code triggerType} + {@code enabled} so background triggers can fetch their policies. Ownership
|
||||
* is a plain {@code owner} string rather than a foreign key, to stay decoupled from the security
|
||||
* entities; richer team scoping can be layered on later.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "policies")
|
||||
|
||||
+9
-4
@@ -5,19 +5,24 @@ import java.util.Optional;
|
||||
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
|
||||
/** Stores {@link Policy} definitions. */
|
||||
/**
|
||||
* Stores {@link Policy} definitions. The in-memory implementation backs simple deployments now; a
|
||||
* durable (JPA) implementation can replace it behind this interface without touching callers.
|
||||
*/
|
||||
public interface PolicyStore {
|
||||
|
||||
/** Create or update; a blank/absent id is assigned. Returns the stored policy. */
|
||||
/** Create or update a policy. A blank/absent id is assigned; returns the stored policy. */
|
||||
Policy save(Policy policy);
|
||||
|
||||
Optional<Policy> get(String id);
|
||||
|
||||
List<Policy> all();
|
||||
|
||||
/** Enabled policies with the given trigger type, for background triggers. */
|
||||
/**
|
||||
* Enabled policies whose automatic trigger is of the given type (used by background triggers).
|
||||
*/
|
||||
List<Policy> findByTriggerType(String triggerType);
|
||||
|
||||
/** Returns whether the policy existed. */
|
||||
/** Remove a policy; returns whether it existed. */
|
||||
boolean delete(String id);
|
||||
}
|
||||
|
||||
+28
-14
@@ -33,14 +33,20 @@ import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
|
||||
/**
|
||||
* Fires policies when a file lands in one of their folder sources, rather than polling on a timer.
|
||||
* Fires policies the moment a file lands in one of their folder sources, instead of polling on a
|
||||
* timer. The trigger only reads that location; turning it into files is still the source's job.
|
||||
*
|
||||
* <p>The watch is a latency optimisation, not a source of truth: a periodic reconcile sweep ({@code
|
||||
* watchReconcileSeconds}) re-syncs watched dirs and re-runs every policy, covering files that
|
||||
* pre-dated the watch, dropped events, and filesystems that emit none (NFS, bind mounts). Redundant
|
||||
* runs are harmless since {@link InputSource} does the claiming.
|
||||
* <p>The watcher is a latency optimisation, not a source of truth, so this pairs an event watch
|
||||
* with a low-frequency <b>reconcile</b> sweep ({@code watchReconcileSeconds}). The reconcile both
|
||||
* (a) re-syncs which directories are watched as policies are created/edited/deleted and folders
|
||||
* appear on disk, and (b) runs every folder-watch policy once, catching files that pre-dated the
|
||||
* watch, events lost to inotify-queue overflow, and changes on filesystems that do not deliver
|
||||
* events at all (NFS, many container bind mounts). Both paths just call {@link PolicyRunner#run};
|
||||
* the {@link InputSource} does the claiming, so a redundant run finds nothing to claim and is
|
||||
* harmless.
|
||||
*
|
||||
* <p>Watch state is in memory, so this assumes a single node and rebuilds registrations on restart.
|
||||
* <p>Like {@link ScheduleTrigger}, watch state is per-node and in memory; this assumes a single
|
||||
* node and rebuilds its registrations on restart from the {@link PolicyStore}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -59,7 +65,7 @@ public class FolderWatchTrigger implements PolicyTrigger {
|
||||
|
||||
private volatile boolean running;
|
||||
|
||||
// Package-visible so tests can drive syncRegistrations() against a real service.
|
||||
// Package-visible (not private) so tests can drive syncRegistrations() against a real service.
|
||||
volatile WatchService watchService;
|
||||
|
||||
private volatile ScheduledExecutorService reconciler;
|
||||
@@ -119,7 +125,8 @@ public class FolderWatchTrigger implements PolicyTrigger {
|
||||
}
|
||||
|
||||
private void watchLoop() {
|
||||
// Capture once: stop() may null the field; close() still wakes take()/poll() on this local.
|
||||
// Capture the service once: stop() may null the field, and a local avoids racing that to an
|
||||
// NPE (close() still wakes take()/poll() on this same instance).
|
||||
WatchService watcher = watchService;
|
||||
if (watcher == null) {
|
||||
return;
|
||||
@@ -139,8 +146,9 @@ public class FolderWatchTrigger implements PolicyTrigger {
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesce a burst of file-system events into one set of affected directories: drain everything
|
||||
* arriving within the quiet period. Event kinds are irrelevant; any event means "go look".
|
||||
* Collect the directories touched by {@code first} and any further events that arrive within
|
||||
* the quiet period, so a burst of file-system events becomes a single set of affected
|
||||
* directories. The event kinds are irrelevant: any event on a watched dir just means "go look".
|
||||
*/
|
||||
private Set<Path> drainBurst(WatchService watcher, WatchKey first) {
|
||||
long quietPeriodMs = applicationProperties.getPolicies().getWatchQuietPeriodMs();
|
||||
@@ -192,7 +200,7 @@ public class FolderWatchTrigger implements PolicyTrigger {
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconcile safety net: run every folder-watch policy regardless of watch events. */
|
||||
/** The reconcile safety net: run every folder-watch policy regardless of watch events. */
|
||||
void runAll() {
|
||||
for (Policy policy : policyStore.findByTriggerType(TYPE)) {
|
||||
try {
|
||||
@@ -206,7 +214,10 @@ public class FolderWatchTrigger implements PolicyTrigger {
|
||||
}
|
||||
}
|
||||
|
||||
/** Register newly-wanted dirs that exist on disk, cancel ones no longer wanted. */
|
||||
/**
|
||||
* Bring the set of watched directories in line with the current folder-watch policies: register
|
||||
* newly required directories that exist on disk, and cancel ones no longer wanted.
|
||||
*/
|
||||
synchronized void syncRegistrations() {
|
||||
if (watchService == null) {
|
||||
return;
|
||||
@@ -263,8 +274,11 @@ public class FolderWatchTrigger implements PolicyTrigger {
|
||||
return dirs;
|
||||
}
|
||||
|
||||
// Absolute + normalised so registration keys and event-time matching compare regardless of how
|
||||
// the path was configured.
|
||||
/**
|
||||
* The normalised, absolute directories this policy's sources expose to watch. Normalisation
|
||||
* makes registration keys and event-time matching comparable regardless of how the path was
|
||||
* configured.
|
||||
*/
|
||||
private List<Path> watchDirsOf(Policy policy) {
|
||||
List<Path> dirs = new ArrayList<>();
|
||||
for (InputSpec spec : policy.sources()) {
|
||||
|
||||
+20
-5
@@ -3,21 +3,36 @@ package stirling.software.proprietary.policy.trigger;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
|
||||
/**
|
||||
* Decides <em>when</em> a policy runs. On firing it hands the policy to {@code PolicyRunner}; it
|
||||
* never resolves sources itself. New trigger kinds are just new beans of this type.
|
||||
* An automatic trigger: the thing that decides <em>when</em> a policy runs without a person asking.
|
||||
* A trigger owns a {@link #type()} (matching {@code TriggerConfig.type()}); when its condition
|
||||
* fires it hands the policy to the {@code PolicyRunner}, which pulls the policy's sources and
|
||||
* starts the runs. A trigger never resolves sources itself.
|
||||
*
|
||||
* <p>Triggers are background, configuration-driven beans (schedule, and in future webhook or
|
||||
* folder-watch): on {@link #start()} they begin watching/scheduling for the policies returned by
|
||||
* {@code PolicyStore.findByTriggerType(type())}, and stop on {@link #stop()}. New trigger kinds are
|
||||
* new beans of this type; the runner and the {@code Policy} model do not change.
|
||||
*
|
||||
* <p>Manual running is not a trigger - every policy can always be run on demand via the {@code
|
||||
* PolicyRunner} regardless of whether it has a trigger.
|
||||
*/
|
||||
public interface PolicyTrigger {
|
||||
|
||||
/** Matches {@code TriggerConfig.type()}. */
|
||||
/** Stable identifier for this trigger kind, matching {@code TriggerConfig.type()}. */
|
||||
String type();
|
||||
|
||||
/**
|
||||
* Validate at save time so misconfiguration fails fast, not at fire time. Receives the whole
|
||||
* {@link Policy} so triggers that depend on the policy's sources (folder-watch) can check that.
|
||||
* Check that this trigger is usable for the given policy, throwing {@link
|
||||
* IllegalArgumentException} if not. Called when a policy is saved so misconfiguration fails
|
||||
* fast rather than at fire time. Receives the whole {@link Policy} (not just its {@code
|
||||
* TriggerConfig}) so a trigger whose firing depends on the policy's sources (folder-watch) can
|
||||
* assert that relationship; most triggers only inspect {@code policy.trigger()}.
|
||||
*/
|
||||
default void validate(Policy policy) {}
|
||||
|
||||
/** Begin activating policies of this type (e.g. start the schedule sweep). */
|
||||
default void start() {}
|
||||
|
||||
/** Stop activating and release any resources. */
|
||||
default void stop() {}
|
||||
}
|
||||
|
||||
+8
-1
@@ -8,7 +8,14 @@ import org.springframework.stereotype.Service;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/** Starts and stops every {@link PolicyTrigger} with the application lifecycle. */
|
||||
/**
|
||||
* Starts and stops every {@link PolicyTrigger} with the application lifecycle. Background triggers
|
||||
* (schedule, and future folder/S3) begin watching on {@link #start()} and release resources on
|
||||
* {@link #stop()}; request-driven triggers (manual) are no-ops.
|
||||
*
|
||||
* <p>This is the single activation point for triggers - a new background trigger only has to be a
|
||||
* {@link PolicyTrigger} bean.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
|
||||
+14
-5
@@ -24,9 +24,16 @@ import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Fires policies on a {@link Schedule}: a fixed-interval sweep runs each due "schedule" policy.
|
||||
* Fires policies on a {@link Schedule}. On {@link #start()} it sweeps on a fixed interval; each
|
||||
* sweep finds the enabled "schedule" policies and runs any whose next firing has come due since it
|
||||
* last fired.
|
||||
*
|
||||
* <p>Last-fire times are in memory, so this assumes a single node and resets on restart.
|
||||
* <p>The trigger only decides <em>when</em>: once a policy is due it hands it to the {@link
|
||||
* PolicyRunner}, which pulls from the policy's configured sources and starts the runs. The trigger
|
||||
* knows nothing about folders, buckets, or how many runs a sweep produces.
|
||||
*
|
||||
* <p>Caveat: last-fire times are tracked <b>in memory</b>, so this assumes a single node and resets
|
||||
* on restart; cluster-wide coordination (leader election) is a follow-up.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -94,7 +101,8 @@ public class ScheduleTrigger implements PolicyTrigger {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Baseline a newly-seen policy to now so it does not fire immediately.
|
||||
// First time we see a policy, baseline its last-fire to now so it does not fire
|
||||
// immediately; subsequent sweeps fire it once its next firing has passed.
|
||||
Instant last = lastFiredByPolicy.computeIfAbsent(policy.id(), id -> now);
|
||||
ZonedDateTime next = config.schedule().nextAfter(last.atZone(config.zone()));
|
||||
if (!next.toInstant().isAfter(now)) {
|
||||
@@ -106,8 +114,9 @@ public class ScheduleTrigger implements PolicyTrigger {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validated schedule-trigger options: the {@link Schedule} and the zone it runs in (UTC by
|
||||
* default).
|
||||
* The typed, validated form of a schedule trigger's options: the {@link Schedule} and the zone
|
||||
* its wall-clock kinds are evaluated in (default UTC). Construction fails for a missing/invalid
|
||||
* schedule or zone.
|
||||
*/
|
||||
record ScheduleConfig(Schedule schedule, ZoneId zone) {
|
||||
|
||||
|
||||
+2
-21
@@ -1,13 +1,11 @@
|
||||
package stirling.software.proprietary.security;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
@@ -39,17 +37,6 @@ public class InitialSecuritySetup {
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final DatabaseServiceInterface databaseService;
|
||||
private final UserLicenseSettingsService licenseSettingsService;
|
||||
private final Environment environment;
|
||||
|
||||
/**
|
||||
* SaaS manages identity in Supabase and billing via PAYG, so the self-host bootstrap steps that
|
||||
* scan/rewrite the whole user table (default-team backfill, seat-license grandfathering) don't
|
||||
* apply - and against a large SaaS user table they stall startup with full-table loads +
|
||||
* per-row saveAll. Per-user team assignment happens in SupabaseAuthenticationFilter instead.
|
||||
*/
|
||||
private boolean isSaas() {
|
||||
return Arrays.asList(environment.getActiveProfiles()).contains("saas");
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
@@ -64,15 +51,9 @@ public class InitialSecuritySetup {
|
||||
}
|
||||
|
||||
configureJWTSettings();
|
||||
assignUsersToDefaultTeamIfMissing();
|
||||
initializeInternalApiUser();
|
||||
if (isSaas()) {
|
||||
log.info(
|
||||
"SaaS profile active - skipping self-host user-table bootstrap"
|
||||
+ " (default-team backfill, seat-license grandfathering).");
|
||||
} else {
|
||||
assignUsersToDefaultTeamIfMissing();
|
||||
initializeUserLicenseSettings();
|
||||
}
|
||||
initializeUserLicenseSettings();
|
||||
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
|
||||
log.error("Failed to initialize security setup.", e);
|
||||
System.exit(1);
|
||||
|
||||
+10
-22
@@ -978,35 +978,23 @@ public class UserController {
|
||||
}
|
||||
}
|
||||
|
||||
// Lists enabled users for the signing user picker, scoped by storage.signing.userListScope:
|
||||
// 'org' (default) = whole instance, anything else = caller's team only (fail-closed).
|
||||
/**
|
||||
* List all enabled users for selection in signing workflows.
|
||||
*
|
||||
* @param principal The authenticated user
|
||||
* @return List of user summaries
|
||||
*/
|
||||
@GetMapping("/users")
|
||||
public ResponseEntity<List<UserSummaryDTO>> listUsers(Principal principal) {
|
||||
if (principal == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
}
|
||||
|
||||
// Fail-closed: only literal "org" opens the whole instance; anything else scopes to team.
|
||||
String scope = applicationProperties.getStorage().getSigning().getUserListScope();
|
||||
boolean teamScoped = !"org".equalsIgnoreCase(scope == null ? "" : scope.trim());
|
||||
|
||||
List<User> source;
|
||||
if (teamScoped) {
|
||||
Optional<User> callerOpt = userService.findByUsernameIgnoreCase(principal.getName());
|
||||
if (callerOpt.isEmpty() || callerOpt.get().getTeam() == null) {
|
||||
// No team: return only the caller rather than leak the org.
|
||||
source = callerOpt.map(List::of).orElse(List.of());
|
||||
} else {
|
||||
// KNOWN LIMITATION: scopes the team via the single User.team FK - correct while
|
||||
// acceptInvitation() collapses users to one team; revisit if multi-team enabled.
|
||||
source = userRepository.findAllByTeamId(callerOpt.get().getTeam().getId());
|
||||
}
|
||||
} else {
|
||||
source = userRepository.findAll();
|
||||
}
|
||||
|
||||
List<UserSummaryDTO> users =
|
||||
source.stream().filter(User::isEnabled).map(this::toUserSummaryDTO).toList();
|
||||
userRepository.findAll().stream()
|
||||
.filter(User::isEnabled)
|
||||
.map(this::toUserSummaryDTO)
|
||||
.toList();
|
||||
|
||||
return ResponseEntity.ok(users);
|
||||
}
|
||||
|
||||
+1
-5
@@ -87,11 +87,7 @@ public class User implements UserDetails, Serializable {
|
||||
private String email;
|
||||
|
||||
// SaaS-only: Supabase user UUID. Null in OSS / proprietary deployments.
|
||||
// Column is `supabase_auth_id` (canonical name from the initial Supabase remote
|
||||
// schema migration). An earlier Flyway V2 (PR #6384) accidentally introduced a
|
||||
// parallel `supabase_id` column that was used by Java; V17 backfilled and dropped
|
||||
// it. Field name is kept as `supabaseId` to avoid a wide refactor of callers.
|
||||
@Column(name = "supabase_auth_id", unique = true)
|
||||
@Column(name = "supabase_id", unique = true)
|
||||
private UUID supabaseId;
|
||||
|
||||
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
|
||||
|
||||
-50
@@ -18,7 +18,6 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.HttpServerErrorException;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
@@ -386,13 +385,6 @@ public class AiWorkflowService {
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue(toolTimeoutMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
|
||||
} catch (Exception e) {
|
||||
AiWorkflowResponse limit = paygLimitResponseOrNull(e);
|
||||
if (limit != null) {
|
||||
log.info(
|
||||
"AI markdown conversion blocked by downstream entitlement gate ({})",
|
||||
limit.getErrorCode());
|
||||
return new WorkflowState.Terminal(limit);
|
||||
}
|
||||
log.error("Failed to convert PDF to Markdown: {}", e.getMessage(), e);
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue(toolFailureMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
|
||||
@@ -478,14 +470,6 @@ public class AiWorkflowService {
|
||||
log.error("Tool {} timed out: {}", endpointPath, e.getMessage());
|
||||
return new WorkflowState.Terminal(cannotContinue(toolTimeoutMessage(endpointPath, e)));
|
||||
} catch (Exception e) {
|
||||
AiWorkflowResponse limit = paygLimitResponseOrNull(e);
|
||||
if (limit != null) {
|
||||
log.info(
|
||||
"AI workflow tool {} blocked by downstream entitlement gate ({})",
|
||||
endpointPath,
|
||||
limit.getErrorCode());
|
||||
return new WorkflowState.Terminal(limit);
|
||||
}
|
||||
log.error("Failed to execute tool {}: {}", endpointPath, e.getMessage(), e);
|
||||
return new WorkflowState.Terminal(cannotContinue(toolFailureMessage(endpointPath, e)));
|
||||
}
|
||||
@@ -601,13 +585,6 @@ public class AiWorkflowService {
|
||||
log.error("Plan step failed (HTTP {}): {}", e.getStatusCode(), reason);
|
||||
return new WorkflowState.Terminal(cannotContinue(reason));
|
||||
} catch (Exception e) {
|
||||
AiWorkflowResponse limit = paygLimitResponseOrNull(e);
|
||||
if (limit != null) {
|
||||
log.info(
|
||||
"AI workflow plan blocked by downstream entitlement gate ({})",
|
||||
limit.getErrorCode());
|
||||
return new WorkflowState.Terminal(limit);
|
||||
}
|
||||
log.error("Failed to execute plan: {}", e.getMessage(), e);
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue("Plan execution failed: " + e.getMessage()));
|
||||
@@ -745,33 +722,6 @@ public class AiWorkflowService {
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* If {@code e} is a downstream usage-limit block — a 401/402 from a tool call carrying the saas
|
||||
* EntitlementGuard's {@code error} sentinel — build a terminal response that carries the
|
||||
* structured code (+ {@code subscribed}) through to the client, so it can pop the matching
|
||||
* usage-limit modal instead of surfacing the raw "tool failed: 402…" text. Returns null for any
|
||||
* other failure, so the caller falls back to its normal tool-failure handling.
|
||||
*
|
||||
* <p>The agent's tool calls run server-side (loopback HTTP via {@link PolicyExecutor}), so this
|
||||
* 402 never reaches the frontend's API-client interceptor that pops the modal for direct calls
|
||||
* — same gap the policy auto-run path bridges in {@code PolicyEngine}.
|
||||
*/
|
||||
private AiWorkflowResponse paygLimitResponseOrNull(Throwable e) {
|
||||
if (!(e instanceof RestClientResponseException rce)) {
|
||||
return null;
|
||||
}
|
||||
String code = DownstreamEntitlementError.extractCode(rce);
|
||||
if (code == null) {
|
||||
return null;
|
||||
}
|
||||
AiWorkflowResponse response = new AiWorkflowResponse();
|
||||
response.setOutcome(AiWorkflowOutcome.CANNOT_CONTINUE);
|
||||
response.setReason("You've reached your current usage limit.");
|
||||
response.setErrorCode(code);
|
||||
response.setErrorSubscribed(DownstreamEntitlementError.extractSubscribed(rce));
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the engine's streaming orchestrator endpoint. Progress events are forwarded to {@code
|
||||
* listener} as they arrive (each one keeps the SSE connection to the frontend alive too). The
|
||||
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
|
||||
/**
|
||||
* Reads the {@code error} sentinel and {@code subscribed} flag out of a downstream 401/402 JSON
|
||||
* body — e.g. the saas EntitlementGuard's {@code
|
||||
* {"error":"PAYG_LIMIT_REACHED","subscribed":false}}.
|
||||
*
|
||||
* <p>Server-side run paths (policy auto-run, AI agent workflows) execute tool calls via loopback
|
||||
* HTTP, so a usage-limit 402 surfaces as a {@link RestClientResponseException} rather than reaching
|
||||
* the frontend's API-client interceptor. These helpers let those paths pass the structured code
|
||||
* through to the client, which maps it to the right usage-limit modal instead of showing a generic
|
||||
* failure.
|
||||
*
|
||||
* <p>Regex (not a JSON parse) on purpose: the body is a small, server-controlled shape and this
|
||||
* keeps the proprietary module free of any billing-layer (saas) coupling.
|
||||
*/
|
||||
public final class DownstreamEntitlementError {
|
||||
|
||||
private DownstreamEntitlementError() {}
|
||||
|
||||
/** Matches the {@code "error":"CODE"} field of a small JSON error body. */
|
||||
private static final Pattern ERROR_CODE_FIELD =
|
||||
Pattern.compile("\"error\"\\s*:\\s*\"([^\"]+)\"");
|
||||
|
||||
/** Matches the {@code "subscribed":true|false} field of a small JSON error body. */
|
||||
private static final Pattern SUBSCRIBED_FIELD =
|
||||
Pattern.compile("\"subscribed\"\\s*:\\s*(true|false)");
|
||||
|
||||
/**
|
||||
* Pull the {@code error} sentinel out of a downstream 401/402 JSON body. Returns null for other
|
||||
* statuses or an unmatched body, in which case the caller treats it as a generic failure.
|
||||
*/
|
||||
public static String extractCode(RestClientResponseException e) {
|
||||
int status = e.getStatusCode().value();
|
||||
if (status != 401 && status != 402) {
|
||||
return null;
|
||||
}
|
||||
String body = e.getResponseBodyAsString();
|
||||
if (body == null || body.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
Matcher m = ERROR_CODE_FIELD.matcher(body);
|
||||
return m.find() ? m.group(1) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the {@code subscribed} flag out of the body (present on the saas {@code
|
||||
* PAYG_LIMIT_REACHED}/{@code FEATURE_DEGRADED} responses). Null when absent — the client then
|
||||
* defaults to the free-limit modal.
|
||||
*/
|
||||
public static Boolean extractSubscribed(RestClientResponseException e) {
|
||||
String body = e.getResponseBodyAsString();
|
||||
if (body == null || body.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
Matcher m = SUBSCRIBED_FIELD.matcher(body);
|
||||
return m.find() ? Boolean.valueOf(m.group(1)) : null;
|
||||
}
|
||||
}
|
||||
-24
@@ -53,30 +53,6 @@ class McpAudienceValidatorTest {
|
||||
assertThat(result.hasErrors()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptedAudience_isAccepted_alongsideResourceId() {
|
||||
// Supabase-style IdP: every token carries aud=authenticated, never the resource id.
|
||||
McpAudienceValidator relaxed = new McpAudienceValidator(RESOURCE, List.of("authenticated"));
|
||||
assertThat(relaxed.validate(tokenWithAudience(List.of("authenticated"))).hasErrors())
|
||||
.isFalse();
|
||||
assertThat(relaxed.validate(tokenWithAudience(List.of(RESOURCE))).hasErrors()).isFalse();
|
||||
assertThat(relaxed.validate(tokenWithAudience(List.of("something-else"))).hasErrors())
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankAcceptedAudienceEntries_areIgnored() {
|
||||
McpAudienceValidator relaxed = new McpAudienceValidator(RESOURCE, List.of("", " "));
|
||||
assertThat(relaxed.validate(tokenWithAudience(List.of(""))).hasErrors()).isTrue();
|
||||
assertThat(relaxed.validate(tokenWithAudience(List.of(RESOURCE))).hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankResourceIdWithOnlyBlankAccepted_failsClosed() {
|
||||
McpAudienceValidator blank = new McpAudienceValidator("", List.of(" "));
|
||||
assertThat(blank.validate(tokenWithAudience(List.of(RESOURCE))).hasErrors()).isTrue();
|
||||
}
|
||||
|
||||
private static Jwt tokenWithAudience(List<String> audience) {
|
||||
return new Jwt(
|
||||
"header.payload.signature",
|
||||
|
||||
+1
-20
@@ -116,8 +116,7 @@ class McpOAuthIntegrationTest {
|
||||
assertThat(response.statusCode()).isEqualTo(401);
|
||||
String wwwAuth = response.headers().firstValue("WWW-Authenticate").orElse("");
|
||||
assertThat(wwwAuth).contains("resource_metadata=");
|
||||
// The advertised URL must be the RFC 9728 path-inserted form for the /mcp resource.
|
||||
assertThat(wwwAuth).contains("/.well-known/oauth-protected-resource/mcp");
|
||||
assertThat(wwwAuth).contains("/.well-known/oauth-protected-resource");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -248,24 +247,6 @@ class McpOAuthIntegrationTest {
|
||||
assertThat(response.body()).contains("mcp.tools.read");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pathInsertedMetadataEndpoint_servesCustomizedMetadata() throws Exception {
|
||||
// RFC 9728 path-inserted form for the /mcp resource. Must be served by the MCP chain
|
||||
// with authorization_servers populated; a default/uncustomized document here makes MCP
|
||||
// clients fall back to treating this server as its own authorization server.
|
||||
HttpRequest request =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(URI.create(base() + "/.well-known/oauth-protected-resource/mcp"))
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
assertThat(response.body()).contains(RESOURCE_ID);
|
||||
assertThat(response.body()).contains("authorization_servers");
|
||||
assertThat(response.body()).contains(ISSUER);
|
||||
assertThat(response.body()).contains("mcp.tools.read");
|
||||
}
|
||||
|
||||
private HttpResponse<String> postMcp(String token, String body) throws Exception {
|
||||
HttpRequest.Builder builder =
|
||||
HttpRequest.newBuilder()
|
||||
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
package stirling.software.proprietary.mcp.security;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.mcp.McpServerController;
|
||||
import stirling.software.proprietary.mcp.tools.DescribeOperationTool;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* Regression test for the protected-resource metadata when {@code mcp.scopes-enabled=false} (the
|
||||
* SaaS/Supabase setup, where the IdP cannot mint {@code mcp.tools.*} scopes). Advertising scopes
|
||||
* the authorization server can't issue makes spec-compliant MCP clients request them and get
|
||||
* bounced with {@code invalid_request}, so the metadata must omit them when scopes are not
|
||||
* enforced.
|
||||
*/
|
||||
@SpringBootTest(
|
||||
classes = McpScopeMetadataDisabledTest.TestApp.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
class McpScopeMetadataDisabledTest {
|
||||
|
||||
private static final String ISSUER = "https://test-issuer.example.com";
|
||||
private static final String RESOURCE_ID = "http://localhost/mcp";
|
||||
|
||||
@LocalServerPort private int port;
|
||||
|
||||
private final HttpClient http = HttpClient.newHttpClient();
|
||||
|
||||
// McpSecurityConfig is @ConditionalOnProperty("mcp.enabled"); that condition reads the Spring
|
||||
// Environment, so it must be set here (the ApplicationProperties bean alone is not enough to
|
||||
// register the chain).
|
||||
@DynamicPropertySource
|
||||
static void mcpProperties(DynamicPropertyRegistry registry) {
|
||||
registry.add("mcp.enabled", () -> "true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void metadata_omitsToolScopes_whenScopesDisabled() throws Exception {
|
||||
String body = getMetadata("/.well-known/oauth-protected-resource");
|
||||
assertThat(body).contains(RESOURCE_ID);
|
||||
assertThat(body).contains(ISSUER);
|
||||
assertThat(body).doesNotContain("mcp.tools.read");
|
||||
assertThat(body).doesNotContain("mcp.tools.write");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pathInsertedMetadata_omitsToolScopes_whenScopesDisabled() throws Exception {
|
||||
String body = getMetadata("/.well-known/oauth-protected-resource/mcp");
|
||||
assertThat(body).contains(RESOURCE_ID);
|
||||
assertThat(body).contains("authorization_servers");
|
||||
assertThat(body).doesNotContain("mcp.tools.read");
|
||||
assertThat(body).doesNotContain("mcp.tools.write");
|
||||
}
|
||||
|
||||
private String getMetadata(String path) throws Exception {
|
||||
HttpRequest request =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://localhost:" + port + path))
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
return response.body();
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration
|
||||
@Import({McpSecurityConfig.class, McpServerController.class, DescribeOperationTool.class})
|
||||
static class TestApp {
|
||||
|
||||
@Bean
|
||||
ApplicationProperties applicationProperties() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getMcp().setEnabled(true);
|
||||
props.getMcp().setScopesEnabled(false);
|
||||
props.getMcp().getAuth().setIssuerUri(ISSUER);
|
||||
// No real JWKS fetch happens for the permitAll metadata endpoint; a placeholder URI is
|
||||
// fine because NimbusJwtDecoder.withJwkSetUri(...) resolves the key set lazily.
|
||||
props.getMcp().getAuth().setJwksUri(ISSUER + "/jwks");
|
||||
props.getMcp().getAuth().setResourceId(RESOURCE_ID);
|
||||
props.getAutomaticallyGenerated().setAppVersion("test");
|
||||
return props;
|
||||
}
|
||||
|
||||
@Bean
|
||||
UserService userService() {
|
||||
return org.mockito.Mockito.mock(UserService.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
package stirling.software.proprietary.policy.config;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/** Self-hosted policy context: a global admin may edit; scoping uses the current user's team. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AdminPolicyManagementAuthorityTest {
|
||||
|
||||
@Mock private UserService userService;
|
||||
|
||||
private AdminPolicyManagementAuthority authority() {
|
||||
return new AdminPolicyManagementAuthority(userService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminMayEditPolicies() {
|
||||
when(userService.isCurrentUserAdmin()).thenReturn(true);
|
||||
assertTrue(authority().canEditPolicies());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonAdminMayNot() {
|
||||
when(userService.isCurrentUserAdmin()).thenReturn(false);
|
||||
assertFalse(authority().canEditPolicies());
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentUserTeamIdResolvesFromTheCurrentUsersTeam() {
|
||||
Team team = new Team();
|
||||
team.setId(42L);
|
||||
User user = new User();
|
||||
user.setTeam(team);
|
||||
when(userService.getCurrentUsername()).thenReturn("alice");
|
||||
when(userService.findByUsername("alice")).thenReturn(Optional.of(user));
|
||||
assertEquals(42L, authority().currentUserTeamId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentUserTeamIdIsNullWhenNoCurrentUser() {
|
||||
when(userService.getCurrentUsername()).thenReturn(null);
|
||||
assertNull(authority().currentUserTeamId());
|
||||
}
|
||||
}
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
package stirling.software.proprietary.policy.config;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
|
||||
/**
|
||||
* {@link PolicyAccessGuard}: policies are scoped to the caller's team. A user sees/accesses only
|
||||
* their own team's policies (admins included — there is no cross-team escape). Login disabled
|
||||
* (single-user) bypasses scoping.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PolicyAccessGuardTest {
|
||||
|
||||
@Mock private UserServiceInterface userService;
|
||||
@Mock private PolicyManagementAuthority policyManagementAuthority;
|
||||
|
||||
private PolicyAccessGuard guard(boolean loginEnabled) {
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
properties.getSecurity().setEnableLogin(loginEnabled);
|
||||
return new PolicyAccessGuard(userService, properties, policyManagementAuthority);
|
||||
}
|
||||
|
||||
@Test
|
||||
void visibleFiltersToTheCallersTeam() {
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(1L);
|
||||
List<Policy> all = List.of(inTeam(1L), inTeam(2L), inTeam(1L), inTeam(null));
|
||||
List<Policy> visible = guard(true).visible(all);
|
||||
assertEquals(2, visible.size());
|
||||
assertTrue(visible.stream().allMatch(p -> Long.valueOf(1L).equals(p.teamId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void visibleReturnsEverythingWhenLoginDisabled() {
|
||||
List<Policy> all = List.of(inTeam(1L), inTeam(2L));
|
||||
assertEquals(all, guard(false).visible(all));
|
||||
}
|
||||
|
||||
@Test
|
||||
void canAccessOnlyOwnTeamsPolicy() {
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(1L);
|
||||
assertTrue(guard(true).canAccess(inTeam(1L)));
|
||||
assertFalse(guard(true).canAccess(inTeam(2L)));
|
||||
assertFalse(guard(true).canAccess(inTeam(null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void canAccessAnythingWhenLoginDisabled() {
|
||||
assertTrue(guard(false).canAccess(inTeam(2L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerAndTeamForNewPolicyComeFromTheCurrentUserWhenLoginEnabled() {
|
||||
when(userService.getCurrentUsername()).thenReturn("alice");
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(7L);
|
||||
assertEquals("alice", guard(true).ownerForNewPolicy());
|
||||
assertEquals(7L, guard(true).teamForNewPolicy());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerAndTeamForNewPolicyAreNullWhenLoginDisabled() {
|
||||
assertNull(guard(false).ownerForNewPolicy());
|
||||
assertNull(guard(false).teamForNewPolicy());
|
||||
}
|
||||
|
||||
private static Policy inTeam(Long teamId) {
|
||||
return new Policy(
|
||||
"p1", "p", "owner", true, null, List.of(), List.of(), OutputSpec.inline(), teamId);
|
||||
}
|
||||
}
|
||||
-113
@@ -28,13 +28,9 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
@@ -174,35 +170,6 @@ class PolicyEngineTest {
|
||||
verify(taskManager, never()).setComplete(runId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runBlockedByUsageLimit_surfacesErrorCodeAndSubscribed() throws Exception {
|
||||
// A downstream tool call gets a 402 entitlement block. The run fails, but its errorCode +
|
||||
// subscribed are taken from the 402 body so the client can pop the right usage-limit modal
|
||||
// (the policy 402 happens server-side, out of reach of the apiClient interceptor).
|
||||
when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false);
|
||||
String body = "{\"error\":\"PAYG_LIMIT_REACHED\",\"subscribed\":true}";
|
||||
when(internalApiClient.post(eq(ROTATE), any()))
|
||||
.thenThrow(
|
||||
HttpClientErrorException.create(
|
||||
HttpStatus.PAYMENT_REQUIRED,
|
||||
"Payment Required",
|
||||
HttpHeaders.EMPTY,
|
||||
body.getBytes(java.nio.charset.StandardCharsets.UTF_8),
|
||||
java.nio.charset.StandardCharsets.UTF_8));
|
||||
|
||||
PolicyRun run =
|
||||
engine.submit(
|
||||
definition(new PipelineStep(ROTATE, Map.of())),
|
||||
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
|
||||
PolicyProgressListener.NOOP)
|
||||
.completion()
|
||||
.get(10, TimeUnit.SECONDS);
|
||||
|
||||
assertEquals(PolicyRunStatus.FAILED, run.getStatus());
|
||||
assertEquals("PAYG_LIMIT_REACHED", run.getErrorCode());
|
||||
assertEquals(Boolean.TRUE, run.getErrorSubscribed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runPolicyExecutesThePolicysPipeline() throws Exception {
|
||||
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
|
||||
@@ -237,86 +204,6 @@ class PolicyEngineTest {
|
||||
verify(internalApiClient).post(eq(ROTATE), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runPolicyDispatchesToolCallsAsTheOwner() throws Exception {
|
||||
// Billing-attribution regression: the pipeline runs on a background worker thread, but the
|
||||
// policy owner must be propagated as the audit principal so InternalApiClient (and thus
|
||||
// PAYG) attributes each tool call to the owner — not the INTERNAL_API_USER fallback.
|
||||
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
|
||||
int[] counter = {0};
|
||||
when(fileStorage.storeInputStream(any(InputStream.class), anyString()))
|
||||
.thenAnswer(
|
||||
inv ->
|
||||
new StoredFile(
|
||||
"file-" + ++counter[0],
|
||||
((InputStream) inv.getArgument(0)).readAllBytes().length));
|
||||
|
||||
String[] principalAtDispatch = {"<none>"};
|
||||
when(internalApiClient.post(eq(ROTATE), any()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
principalAtDispatch[0] = MDC.get("auditPrincipal");
|
||||
return ResponseEntity.ok(pdf("rotated", "rotated.pdf"));
|
||||
});
|
||||
|
||||
Policy policy =
|
||||
new Policy(
|
||||
"p1",
|
||||
"rotate",
|
||||
"alice",
|
||||
true,
|
||||
null,
|
||||
List.of(new PipelineStep(ROTATE, Map.of())),
|
||||
OutputSpec.inline());
|
||||
|
||||
engine.runPolicy(
|
||||
policy,
|
||||
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
|
||||
PolicyProgressListener.NOOP)
|
||||
.completion()
|
||||
.get(10, TimeUnit.SECONDS);
|
||||
|
||||
assertEquals("alice", principalAtDispatch[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void adHocRunDispatchesToolCallsAsTheSubmittingUser() throws Exception {
|
||||
// Ad-hoc runs (no stored policy) bill whoever kicked them off; the principal is captured on
|
||||
// the request thread (here simulated via MDC) and re-established on the worker thread.
|
||||
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
|
||||
int[] counter = {0};
|
||||
when(fileStorage.storeInputStream(any(InputStream.class), anyString()))
|
||||
.thenAnswer(
|
||||
inv ->
|
||||
new StoredFile(
|
||||
"file-" + ++counter[0],
|
||||
((InputStream) inv.getArgument(0)).readAllBytes().length));
|
||||
|
||||
String[] principalAtDispatch = {"<none>"};
|
||||
when(internalApiClient.post(eq(ROTATE), any()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
principalAtDispatch[0] = MDC.get("auditPrincipal");
|
||||
return ResponseEntity.ok(pdf("rotated", "rotated.pdf"));
|
||||
});
|
||||
|
||||
MDC.put("auditPrincipal", "bob"); // the request thread's audit principal
|
||||
try {
|
||||
engine.submit(
|
||||
definition(new PipelineStep(ROTATE, Map.of())),
|
||||
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
|
||||
PolicyProgressListener.NOOP)
|
||||
.completion()
|
||||
.get(10, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
MDC.remove("auditPrincipal");
|
||||
}
|
||||
|
||||
assertEquals("bob", principalAtDispatch[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runIsQueuedUnderResourcePressure() {
|
||||
when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(true);
|
||||
|
||||
+1
-5
@@ -16,7 +16,6 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
@@ -37,7 +36,6 @@ class InitialSecuritySetupTest {
|
||||
@Mock private TeamService teamService;
|
||||
@Mock private DatabaseServiceInterface databaseService;
|
||||
@Mock private UserLicenseSettingsService licenseSettingsService;
|
||||
@Mock private Environment environment;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private InitialSecuritySetup initialSecuritySetup;
|
||||
@@ -55,15 +53,13 @@ class InitialSecuritySetupTest {
|
||||
when(userService.findByUsernameIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
|
||||
.thenReturn(Optional.of(internalUser));
|
||||
when(teamService.getOrCreateInternalTeam()).thenReturn(internalTeam);
|
||||
when(environment.getActiveProfiles()).thenReturn(new String[] {});
|
||||
initialSecuritySetup =
|
||||
new InitialSecuritySetup(
|
||||
userService,
|
||||
teamService,
|
||||
applicationProperties,
|
||||
databaseService,
|
||||
licenseSettingsService,
|
||||
environment);
|
||||
licenseSettingsService);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
-186
@@ -1,17 +1,13 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -154,186 +150,4 @@ class UserControllerTest {
|
||||
|
||||
verify(loginAttemptService).resetAttempts("lockeduser");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// GET /api/v1/user/users - storage.signing.userListScope scoping
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private static User user(long id, String username, boolean enabled, Team team) {
|
||||
User u = new User();
|
||||
u.setId(id);
|
||||
u.setUsername(username);
|
||||
u.setEnabled(enabled);
|
||||
u.setTeam(team);
|
||||
return u;
|
||||
}
|
||||
|
||||
private static Team team(long id, String name) {
|
||||
Team t = new Team();
|
||||
t.setId(id);
|
||||
t.setName(name);
|
||||
return t;
|
||||
}
|
||||
|
||||
private static Authentication auth(String username) {
|
||||
return new UsernamePasswordAuthenticationToken(username, "pw");
|
||||
}
|
||||
|
||||
@Test
|
||||
void listUsersDefaultScopeIsOrgWide() throws Exception {
|
||||
// Default "org" scope returns every enabled user via findAll(), no team lookup.
|
||||
Team alpha = team(1L, "alpha");
|
||||
when(userRepository.findAll())
|
||||
.thenReturn(
|
||||
List.of(
|
||||
user(1L, "[email protected]", true, alpha),
|
||||
user(2L, "[email protected]", true, alpha)));
|
||||
|
||||
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.length()").value(2))
|
||||
.andExpect(jsonPath("$[0].username").value("[email protected]"))
|
||||
.andExpect(jsonPath("$[1].username").value("[email protected]"));
|
||||
|
||||
verify(userRepository, never()).findAllByTeamId(any());
|
||||
verify(userService, never()).findByUsernameIgnoreCase(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listUsersOrgScopeFiltersDisabledUsers() throws Exception {
|
||||
Team alpha = team(1L, "alpha");
|
||||
when(userRepository.findAll())
|
||||
.thenReturn(
|
||||
List.of(
|
||||
user(1L, "[email protected]", true, alpha),
|
||||
user(2L, "[email protected]", false, alpha)));
|
||||
|
||||
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.length()").value(1))
|
||||
.andExpect(jsonPath("$[0].username").value("[email protected]"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listUsersTeamScopeReturnsOnlyCallerTeam() throws Exception {
|
||||
applicationProperties.getStorage().getSigning().setUserListScope("team");
|
||||
Team alpha = team(7L, "alpha");
|
||||
User caller = user(1L, "[email protected]", true, alpha);
|
||||
when(userService.findByUsernameIgnoreCase("[email protected]"))
|
||||
.thenReturn(Optional.of(caller));
|
||||
when(userRepository.findAllByTeamId(7L))
|
||||
.thenReturn(List.of(caller, user(2L, "[email protected]", true, alpha)));
|
||||
|
||||
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.length()").value(2))
|
||||
.andExpect(jsonPath("$[0].teamName").value("alpha"));
|
||||
|
||||
verify(userRepository).findAllByTeamId(7L);
|
||||
verify(userRepository, never()).findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listUsersTeamScopeWithMissingCallerReturnsEmpty() throws Exception {
|
||||
applicationProperties.getStorage().getSigning().setUserListScope("team");
|
||||
when(userService.findByUsernameIgnoreCase("[email protected]")).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.length()").value(0));
|
||||
|
||||
verify(userRepository, never()).findAllByTeamId(any());
|
||||
verify(userRepository, never()).findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listUsersTeamScopeWithNullTeamReturnsSelfOnly() throws Exception {
|
||||
applicationProperties.getStorage().getSigning().setUserListScope("team");
|
||||
User caller = user(1L, "[email protected]", true, null);
|
||||
when(userService.findByUsernameIgnoreCase("[email protected]"))
|
||||
.thenReturn(Optional.of(caller));
|
||||
|
||||
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.length()").value(1))
|
||||
.andExpect(jsonPath("$[0].username").value("[email protected]"));
|
||||
|
||||
verify(userRepository, never()).findAllByTeamId(any());
|
||||
verify(userRepository, never()).findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listUsersFailsClosedOnUnrecognisedScope() throws Exception {
|
||||
// Any non-"org" value must restrict to the caller's team, not leak the instance.
|
||||
applicationProperties.getStorage().getSigning().setUserListScope("tewm");
|
||||
Team alpha = team(3L, "alpha");
|
||||
when(userService.findByUsernameIgnoreCase("[email protected]"))
|
||||
.thenReturn(Optional.of(user(1L, "[email protected]", true, alpha)));
|
||||
when(userRepository.findAllByTeamId(3L))
|
||||
.thenReturn(List.of(user(1L, "[email protected]", true, alpha)));
|
||||
|
||||
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(userRepository).findAllByTeamId(3L);
|
||||
verify(userRepository, never()).findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listUsersFailsClosedOnBlankScope() throws Exception {
|
||||
applicationProperties.getStorage().getSigning().setUserListScope(" ");
|
||||
Team alpha = team(4L, "alpha");
|
||||
when(userService.findByUsernameIgnoreCase("[email protected]"))
|
||||
.thenReturn(Optional.of(user(1L, "[email protected]", true, alpha)));
|
||||
when(userRepository.findAllByTeamId(4L)).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(userRepository).findAllByTeamId(4L);
|
||||
verify(userRepository, never()).findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listUsersFailsClosedOnNullScope() throws Exception {
|
||||
// A null value must also fail closed to the caller's team.
|
||||
applicationProperties.getStorage().getSigning().setUserListScope(null);
|
||||
Team alpha = team(9L, "alpha");
|
||||
when(userService.findByUsernameIgnoreCase("[email protected]"))
|
||||
.thenReturn(Optional.of(user(1L, "[email protected]", true, alpha)));
|
||||
when(userRepository.findAllByTeamId(9L)).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(userRepository).findAllByTeamId(9L);
|
||||
verify(userRepository, never()).findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listUsersOrgScopeIsCaseInsensitive() throws Exception {
|
||||
applicationProperties.getStorage().getSigning().setUserListScope("ORG");
|
||||
when(userRepository.findAll()).thenReturn(List.of(user(1L, "[email protected]", true, null)));
|
||||
|
||||
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(userRepository).findAll();
|
||||
verify(userRepository, never()).findAllByTeamId(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listUsersRequiresAuthentication() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/user/users")).andExpect(status().isUnauthorized());
|
||||
|
||||
verify(userRepository, never()).findAll();
|
||||
verify(userRepository, never()).findAllByTeamId(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void signingUserListScopeDefaultsToOrg() {
|
||||
// Self-host backward-compat: default must stay "org" (saas profile flips it to "team").
|
||||
assertEquals(
|
||||
"org", new ApplicationProperties().getStorage().getSigning().getUserListScope());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user