diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 31135cfb4b..173b4db9df 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -342,9 +342,70 @@ jobs: # Set port for output echo "v2_port=${V2_PORT}" >> $GITHUB_OUTPUT + # ---- Storybook preview (only when this PR touches stories/.storybook) ---- + # Runs inside the same approved-contributor-gated deploy job, so it deploys + # under the exact same access rules as the app preview. + - name: Detect Storybook changes + id: sb-changes + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + with: + list-files: json + filters: | + storybook: + - 'frontend/**/*.stories.@(ts|tsx|mdx)' + - 'frontend/**/*.mdx' + - 'frontend/.storybook/**' + + - name: Set up Node.js for Storybook + if: steps.sb-changes.outputs.storybook == 'true' + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install Task for Storybook + if: steps.sb-changes.outputs.storybook == 'true' + uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + + - name: Build and deploy Storybook + id: storybook + if: steps.sb-changes.outputs.storybook == 'true' + env: + VPS_HOST: ${{ secrets.NEW_VPS_HOST }} + VPS_USER: ${{ secrets.NEW_VPS_USERNAME }} + run: | + set -euo pipefail + # `prepare` generates the icon set stories import (not committed). + task frontend:prepare + task frontend:storybook:build + PR=${{ needs.check-pr.outputs.pr_number }} + # Served at the ROOT of its own port so Storybook's global MSW worker + # (/mockServiceWorker.js) resolves. Port = PR + 20000 (bijective, offset + # from the app preview's bare-PR-number port). + SB_PORT=$((PR + 20000)) + DIR=/stirling/SB-PR-$PR + tar czf storybook.tgz -C frontend/storybook-static . + scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + storybook.tgz "$VPS_USER@$VPS_HOST:/tmp/storybook-$PR.tgz" + ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T \ + "$VPS_USER@$VPS_HOST" << ENDSSH + set -e + rm -rf "$DIR" && mkdir -p "$DIR" + tar xzf /tmp/storybook-$PR.tgz -C "$DIR" + rm -f /tmp/storybook-$PR.tgz + docker rm -f storybook-pr-$PR 2>/dev/null || true + docker run -d --name storybook-pr-$PR --restart unless-stopped \ + -p $SB_PORT:80 -v "$DIR":/usr/share/nginx/html:ro nginx:alpine + ENDSSH + echo "url=http://$VPS_HOST:$SB_PORT/" >> "$GITHUB_OUTPUT" + - name: Post V2 deployment URL to PR if: success() uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + SB_URL: ${{ steps.storybook.outputs.url }} + SB_FILES: ${{ steps.sb-changes.outputs.storybook_files }} with: github-token: ${{ steps.setup-bot.outputs.token }} script: | @@ -376,11 +437,32 @@ jobs: ? `🧩 **Admin portal** included - try it at [${deploymentUrl}/portal](${deploymentUrl}/portal).\n\n` : ``; + // Storybook preview: only present when this PR changed stories/config. + const sbUrl = process.env.SB_URL; + let storybookNote = ""; + if (sbUrl) { + const files = JSON.parse(process.env.SB_FILES || "[]"); + const stories = files.filter((f) => /\.stories\.(ts|tsx|mdx)$/.test(f)); + const config = files.filter((f) => f.startsWith("frontend/.storybook/")); + const shorten = (f) => + f.replace(/^frontend\/editor\/src\//, "").replace(/^frontend\//, ""); + const storyList = stories.map((f) => `- \`${shorten(f)}\``).join("\n"); + const configList = config.map((f) => `- \`${shorten(f)}\``).join("\n"); + const summary = + `${stories.length} stor${stories.length === 1 ? "y" : "ies"} changed` + + (config.length ? ` (+${config.length} config file${config.length === 1 ? "" : "s"})` : ""); + storybookNote = + `πŸ“š **Storybook:** [${sbUrl}](${sbUrl})\n\n` + + `
\n${summary}\n\n` + + (storyList ? `**Stories**\n${storyList}\n\n` : "") + + (configList ? `**Config**\n${configList}\n` : "") + + `
\n\n`; + } + const commentBody = `## πŸš€ V2 Auto-Deployment Complete!\n\n` + - `Your V2 PR with embedded architecture has been deployed!\n\n` + `πŸ”— **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` + - `πŸ” **Secure HTTPS URL**: unsupported currently\n\n` + portalNote + + storybookNote + `_This deployment will be automatically cleaned up when the PR is closed._\n\n` + `πŸ”„ **Auto-deployed** for approved V2 contributors.`; @@ -476,7 +558,11 @@ jobs: else echo "V2 PR directory not found, nothing to clean up" fi - + + # Remove this PR's Storybook preview (container + files), if any. + docker rm -f storybook-pr-${{ github.event.pull_request.number }} 2>/dev/null || true + rm -rf /stirling/SB-PR-${{ github.event.pull_request.number }} + # Clean up old unused images (older than 2 weeks) but keep recent ones for reuse docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true diff --git a/AGENTS.md b/AGENTS.md index 9afdad5937..7e529e4be9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -453,6 +453,7 @@ The frontend is organized with a clear separation of concerns: - **CRITICAL**: Always update translations in `en-US` only - all other languages (including `en-GB`) are handled separately - Translation files are located in `frontend/editor/public/locales/` +- After changing any translation file, run `task pre-commit:fix` ## Important Notes diff --git a/app/common/src/main/java/stirling/software/common/service/PdfMetadataService.java b/app/common/src/main/java/stirling/software/common/service/PdfMetadataService.java index 7b74111870..aa521bf942 100644 --- a/app/common/src/main/java/stirling/software/common/service/PdfMetadataService.java +++ b/app/common/src/main/java/stirling/software/common/service/PdfMetadataService.java @@ -7,6 +7,7 @@ import java.time.format.DateTimeFormatter; import java.util.Calendar; import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentInformation; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; @@ -17,6 +18,9 @@ import stirling.software.common.model.PdfMetadata; @Service public class PdfMetadataService { + /** ({@code {labels}}). Written by the classify-and-label tool. */ + public static final String CLASSIFICATION_KEY = "StirlingPDFClassification"; + private final ApplicationProperties applicationProperties; private final String stirlingPDFLabel; private final UserServiceInterface userService; @@ -177,4 +181,14 @@ public class PdfMetadataService { } pdf.getDocumentInformation().setAuthor(author); } + + /** + * Write the document classifier's JSON result into the custom Info-dictionary field {@link + * #CLASSIFICATION_KEY}, leaving all other metadata untouched. + */ + public void setClassificationMetadata(PDDocument pdf, String classificationJson) { + PDDocumentInformation info = pdf.getDocumentInformation(); + info.setCustomMetadataValue(CLASSIFICATION_KEY, classificationJson); + pdf.setDocumentInformation(info); + } } diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index faf71c2f97..4db9f118ec 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -57,12 +57,13 @@ public class RequestUriUtils { return true; } - // Admin portal SPA shell. Served publicly like the editor root so a direct - // nav / refresh to /portal loads the app (the JWT lives in localStorage, not - // a cookie, so the server can't authenticate the navigation itself). The + // Admin portal SPA shell (mounted at /processor β€” must match the frontend + // PORTAL_BASENAME). Served publicly like the editor root so a direct nav / + // refresh to /processor loads the app (the JWT lives in localStorage, not a + // cookie, so the server can't authenticate the navigation itself). The // portal gates access via its own auth gate + RequirePortalAccess, and its // data APIs stay protected, so serving the shell pre-auth is safe. - if (normalizedUri.equals("/portal") || normalizedUri.startsWith("/portal/")) { + if (normalizedUri.equals("/processor") || normalizedUri.startsWith("/processor/")) { return true; } diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index a13aeac82b..a6fefd1091 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -75,10 +75,10 @@ class RequestUriUtilsTest { @Test void testIsStaticResource_portalShell() { - // The admin portal SPA shell is served pre-auth so it's directly navigable. - assertTrue(RequestUriUtils.isStaticResource("/portal")); - assertTrue(RequestUriUtils.isStaticResource("/portal/users")); - assertTrue(RequestUriUtils.isStaticResource("/app", "/app/portal")); + // The admin portal SPA shell (/processor) is served pre-auth so it's directly navigable. + assertTrue(RequestUriUtils.isStaticResource("/processor")); + assertTrue(RequestUriUtils.isStaticResource("/processor/users")); + assertTrue(RequestUriUtils.isStaticResource("/app", "/app/processor")); } // --- isFrontendRoute tests --- diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java index f61a1ce8fe..a66b168163 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java @@ -305,6 +305,23 @@ public class GetInfoOnPDF { } } + /** + * Info-dictionary keys exposed above via typed getters; any other key in the dictionary is + * surfaced as custom metadata (e.g. the classification policy's StirlingPDFClassification + * entry). + */ + private static final java.util.Set STANDARD_INFO_KEYS = + java.util.Set.of( + "Title", + "Author", + "Subject", + "Keywords", + "Producer", + "Creator", + "CreationDate", + "ModDate", + "Trapped"); + private static ObjectNode extractMetadata(PDDocument document) { ObjectNode metadata = objectMapper.createObjectNode(); @@ -335,6 +352,18 @@ public class GetInfoOnPDF { if (modificationDate != null) { metadata.put("ModificationDate", modificationDate); } + + // Surface custom Info-dictionary entries (anything beyond the + // standard fields above) β€” e.g. StirlingPDFClassification + for (String key : info.getMetadataKeys()) { + if (STANDARD_INFO_KEYS.contains(key)) { + continue; + } + String value = info.getCustomMetadataValue(key); + if (value != null && !value.isBlank()) { + metadata.put(key, value); + } + } } } catch (Exception e) { log.error("Error extracting metadata: {}", e.getMessage()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/config/AccessConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/config/AccessConfig.java index b2d6d3d12d..d763c146ee 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/config/AccessConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/config/AccessConfig.java @@ -4,7 +4,9 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import stirling.software.proprietary.access.service.DefaultPrincipalResolver; import stirling.software.proprietary.access.service.DefaultTeamLeadLookup; +import stirling.software.proprietary.access.service.PrincipalResolver; import stirling.software.proprietary.access.service.TeamLeadLookup; /** Access-layer bean wiring. */ @@ -17,4 +19,11 @@ public class AccessConfig { TeamLeadLookup defaultTeamLeadLookup() { return new DefaultTeamLeadLookup(); } + + /** USER/TEAM projection unless another bean is defined (e.g. the saas resolver). */ + @Bean + @ConditionalOnMissingBean(PrincipalResolver.class) + PrincipalResolver defaultPrincipalResolver() { + return new DefaultPrincipalResolver(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/controller/ResourceGrantController.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/controller/ResourceGrantController.java index 5b69c6878e..cc66020670 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/controller/ResourceGrantController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/controller/ResourceGrantController.java @@ -25,7 +25,9 @@ import stirling.software.proprietary.access.model.PrincipalType; import stirling.software.proprietary.access.model.ResourceGrant; import stirling.software.proprietary.access.model.ResourceType; import stirling.software.proprietary.access.service.ResourceAccessService; +import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamRepository; /** Admin endpoints to grant/revoke access to gated resources (the portal, integration configs). */ @RestController @@ -36,6 +38,8 @@ import stirling.software.proprietary.security.model.User; public class ResourceGrantController { private final ResourceAccessService accessService; + private final UserRepository userRepository; + private final TeamRepository teamRepository; @GetMapping("/grants") public ResponseEntity list( @@ -45,6 +49,14 @@ public class ResourceGrantController { return ResponseEntity.ok(grants.stream().map(this::toDto).toList()); } + @GetMapping("/grants/by-principal") + public ResponseEntity listByPrincipal( + @RequestParam PrincipalType principalType, @RequestParam Long principalId) { + List grants = + accessService.listGrantsForPrincipal(principalType, principalId); + return ResponseEntity.ok(grants.stream().map(this::toDto).toList()); + } + @PostMapping("/grants") public ResponseEntity create( @RequestBody GrantRequest request, @AuthenticationPrincipal User admin) { @@ -57,17 +69,26 @@ public class ResourceGrantController { "error", "resourceType, principalType and principalId are required")); } + // PORTAL is a singleton (empty resourceId); every other type must name a resource. + boolean portal = request.resourceType() == ResourceType.PORTAL; + if (!portal && (request.resourceId() == null || request.resourceId().isBlank())) { + return ResponseEntity.badRequest() + .body(Map.of("error", "resourceId is required for " + request.resourceType())); + } + Long principalId = request.principalId(); + String principalError = validatePrincipalExists(request.principalType(), principalId); + if (principalError != null) { + return ResponseEntity.badRequest().body(Map.of("error", principalError)); + } AccessPermission permission = request.permission() == null ? AccessPermission.USE : request.permission(); - // PORTAL is a singleton resource; its grants always target the whole type. - String resourceId = - request.resourceType() == ResourceType.PORTAL ? "" : request.resourceId(); + String resourceId = portal ? "" : request.resourceId(); ResourceGrant grant = accessService.grant( request.resourceType(), resourceId, request.principalType(), - request.principalId(), + principalId, permission, admin); return ResponseEntity.ok(toDto(grant)); @@ -79,6 +100,14 @@ public class ResourceGrantController { return ResponseEntity.ok(Map.of("message", "Grant revoked")); } + // Rejects grants to nonexistent principals (dead rows otherwise). + private String validatePrincipalExists(PrincipalType type, Long id) { + return switch (type) { + case USER -> userRepository.existsById(id) ? null : "User " + id + " does not exist"; + case TEAM -> teamRepository.existsById(id) ? null : "Team " + id + " does not exist"; + }; + } + private Map toDto(ResourceGrant g) { Map m = new HashMap<>(); m.put("id", g.getId()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnedResource.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnedResource.java index 1bca14a420..46d6e17602 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnedResource.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnedResource.java @@ -54,4 +54,15 @@ public abstract class OwnedResource { public Long getOwnerTeamId() { return ownerTeam != null ? ownerTeam.getId() : null; } + + /** Owner as a principal ref; null when server-owned (admin-only ownership). */ + public PrincipalRef getOwnerRef() { + if (getOwnerUserId() != null) { + return PrincipalRef.user(getOwnerUserId()); + } + if (getOwnerTeamId() != null) { + return PrincipalRef.team(getOwnerTeamId()); + } + return null; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalRef.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalRef.java new file mode 100644 index 0000000000..e66da7a435 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalRef.java @@ -0,0 +1,20 @@ +package stirling.software.proprietary.access.model; + +import java.util.Locale; + +/** A (type, id) principal pair; the atom grants and ownership are expressed in. */ +public record PrincipalRef(PrincipalType type, Long id) { + + public static PrincipalRef user(Long id) { + return new PrincipalRef(PrincipalType.USER, id); + } + + public static PrincipalRef team(Long id) { + return new PrincipalRef(PrincipalType.TEAM, id); + } + + /** Canonical engine wire form, e.g. "user:12". */ + public String token() { + return type.name().toLowerCase(Locale.ROOT) + ":" + id; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalType.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalType.java index 9019b32845..b29b1df4c4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalType.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalType.java @@ -1,6 +1,6 @@ package stirling.software.proprietary.access.model; -/** Who a {@link ResourceGrant} is granted to. Org-wide access is expressed via default policy. */ +/** Who a {@link ResourceGrant} is granted to. */ public enum PrincipalType { USER, TEAM diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/security/ResourceAccessSecurity.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/security/ResourceAccessSecurity.java index 5189b703a1..0f35965ad4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/security/ResourceAccessSecurity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/security/ResourceAccessSecurity.java @@ -11,7 +11,12 @@ import stirling.software.proprietary.access.service.ResourceAccessService; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.service.UserService; -/** {@code @PreAuthorize} bean for portal-access checks. Active in self-hosted and saas. */ +/** + * {@code @PreAuthorize} bean for portal-access checks. Active in self-hosted and saas. Convention: + * every portal-exclusive endpoint is gated with + * {@code @PreAuthorize("@resourceAccess.canUsePortal()")}; endpoints shared with the editor (e.g. + * the policies API) must NOT be. + */ @Component("resourceAccess") @RequiredArgsConstructor public class ResourceAccessSecurity { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/DefaultPrincipalResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/DefaultPrincipalResolver.java new file mode 100644 index 0000000000..61a70a7336 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/DefaultPrincipalResolver.java @@ -0,0 +1,32 @@ +package stirling.software.proprietary.access.service; + +import java.util.HashSet; +import java.util.Set; + +import stirling.software.proprietary.access.model.PrincipalRef; +import stirling.software.proprietary.security.model.User; + +/** + * Self-hosted projection: the user and their team. One deployment = one org, so ORG_ALL is open. + */ +public class DefaultPrincipalResolver implements PrincipalResolver { + + @Override + public Set principalsOf(User user) { + if (user == null) { + return Set.of(); + } + Set principals = new HashSet<>(); + principals.add(PrincipalRef.user(user.getId())); + if (user.getTeam() != null) { + principals.add(PrincipalRef.team(user.getTeam().getId())); + } + return principals; + } + + // Self-hosted is a single deployment-wide org, so ORG_ALL admits every authenticated user. + @Override + public boolean allowsDeploymentWideAccess() { + return true; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/MembershipTeamLeadLookup.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/MembershipTeamLeadLookup.java new file mode 100644 index 0000000000..9a5bece4ca --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/MembershipTeamLeadLookup.java @@ -0,0 +1,33 @@ +package stirling.software.proprietary.access.service; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; + +/** Real lookup backed by team_memberships LEADER rows; wins over the no-op default bean. */ +@Component +@RequiredArgsConstructor +public class MembershipTeamLeadLookup implements TeamLeadLookup { + + private final TeamMembershipRepository memberships; + + @Override + public boolean isAnyTeamLeader(User user) { + return user != null + && user.getId() != null + && memberships.existsByUserIdAndRole(user.getId(), TeamRole.LEADER); + } + + @Override + public boolean isLeaderOfTeam(User user, Long teamId) { + return user != null + && user.getId() != null + && teamId != null + && memberships.existsByTeamIdAndUserIdAndRole( + teamId, user.getId(), TeamRole.LEADER); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/OwnershipService.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/OwnershipService.java index 8db87f2c0e..5b62d8e890 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/OwnershipService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/OwnershipService.java @@ -36,15 +36,19 @@ public class OwnershipService { return accessService.canUseResource( type, String.valueOf(resource.getId()), - resource.getOwnerUserId(), + resource.getOwnerRef(), resource.getDefaultAccess(), user); } /** Whether the user may manage the resource. */ public boolean canManage(ResourceType type, OwnedResource resource, User user) { + // Disabled resources bypass grants for MANAGE too: admin/owner only. + if (!resource.isEnabled()) { + return isAdmin(user) || isOwner(resource, user); + } return accessService.canManageResource( - type, String.valueOf(resource.getId()), resource.getOwnerUserId(), user); + type, String.valueOf(resource.getId()), resource.getOwnerRef(), user); } /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/PrincipalResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/PrincipalResolver.java new file mode 100644 index 0000000000..94fdd990da --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/PrincipalResolver.java @@ -0,0 +1,28 @@ +package stirling.software.proprietary.access.service; + +import java.util.Set; +import java.util.stream.Collectors; + +import stirling.software.proprietary.access.model.PrincipalRef; +import stirling.software.proprietary.security.model.User; + +/** Projects a user onto the set of principals they act as. */ +public interface PrincipalResolver { + + /** Every principal the user acts as; empty for a null user. */ + Set principalsOf(User user); + + /** + * Whether this deployment treats every authenticated user as one org, so the {@code ORG_ALL} + * default policy admits anyone. Self-hosted: true. Multi-tenant saas: false, so an {@code + * ORG_ALL} resource can't leak across tenants. Defaults to false (deny) for safety. + */ + default boolean allowsDeploymentWideAccess() { + return false; + } + + /** Canonical wire tokens for the engine, e.g. "user:12". */ + default Set principalTokens(User user) { + return principalsOf(user).stream().map(PrincipalRef::token).collect(Collectors.toSet()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java index ada4c95290..72159829a4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java @@ -14,6 +14,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.enumeration.Role; import stirling.software.proprietary.access.model.AccessPermission; import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.model.PrincipalRef; import stirling.software.proprietary.access.model.PrincipalType; import stirling.software.proprietary.access.model.ResourceGrant; import stirling.software.proprietary.access.model.ResourceType; @@ -29,6 +30,7 @@ public class ResourceAccessService { private final ResourceGrantRepository grantRepository; private final TeamLeadLookup teamLeadLookup; + private final PrincipalResolver principalResolver; @Value("${security.portal.defaultAccess:ADMINS_AND_TEAM_LEADS}") private DefaultAccessPolicy portalDefaultPolicy; @@ -44,28 +46,28 @@ public class ResourceAccessService { public boolean canUseResource( ResourceType type, String resourceId, - Long ownerUserId, + PrincipalRef owner, DefaultAccessPolicy defaultPolicy, User user) { if (user == null) { return false; } - if (isOwner(ownerUserId, user) || isAdmin(user)) { + if (isOwner(owner, user) || isAdmin(user)) { return true; } if (hasGrant(type, normalize(resourceId), user, AccessPermission.USE)) { return true; } - return matchesDefault(defaultPolicy, user); + return matchesDefault(defaultPolicy, owner, user); } /** Whether the user may manage (edit/delete/share) a resource. No default-policy fallback. */ public boolean canManageResource( - ResourceType type, String resourceId, Long ownerUserId, User user) { + ResourceType type, String resourceId, PrincipalRef owner, User user) { if (user == null) { return false; } - if (isOwner(ownerUserId, user) || isAdmin(user)) { + if (isOwner(owner, user) || isAdmin(user)) { return true; } return hasGrant(type, normalize(resourceId), user, AccessPermission.MANAGE); @@ -110,21 +112,22 @@ public class ResourceAccessService { return grantRepository.findByResourceTypeAndResourceId(type, normalize(resourceId)); } - /** Resource ids of the given type that this user (or their team) holds any grant on. */ + /** Every grant a principal holds, for the per-user/per-team manage-access view. */ + public List listGrantsForPrincipal( + PrincipalType principalType, Long principalId) { + return grantRepository.findByPrincipalTypeAndPrincipalId(principalType, principalId); + } + + /** Resource ids of the given type that any of the user's principals holds a grant on. */ public Set grantedResourceIds(ResourceType type, User user) { if (user == null) { return Set.of(); } Set ids = new HashSet<>(); - for (ResourceGrant g : - grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId( - type, PrincipalType.USER, user.getId())) { - ids.add(g.getResourceId()); - } - if (user.getTeam() != null) { + for (PrincipalRef principal : principalResolver.principalsOf(user)) { for (ResourceGrant g : grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId( - type, PrincipalType.TEAM, user.getTeam().getId())) { + type, principal.type(), principal.id())) { ids.add(g.getResourceId()); } } @@ -135,18 +138,12 @@ public class ResourceAccessService { private boolean hasGrant( ResourceType type, String resourceId, User user, AccessPermission required) { - Long teamId = user.getTeam() != null ? user.getTeam().getId() : null; + Set principals = principalResolver.principalsOf(user); for (ResourceGrant g : grantRepository.findByResourceTypeAndResourceId(type, resourceId)) { if (!permissionSatisfies(g.getPermission(), required)) { continue; } - if (g.getPrincipalType() == PrincipalType.USER - && g.getPrincipalId().equals(user.getId())) { - return true; - } - if (g.getPrincipalType() == PrincipalType.TEAM - && teamId != null - && g.getPrincipalId().equals(teamId)) { + if (principals.contains(new PrincipalRef(g.getPrincipalType(), g.getPrincipalId()))) { return true; } } @@ -161,20 +158,40 @@ public class ResourceAccessService { return held == AccessPermission.MANAGE; } - private boolean matchesDefault(DefaultAccessPolicy policy, User user) { + private boolean matchesDefault(DefaultAccessPolicy policy, PrincipalRef owner, User user) { if (policy == null) { return false; } return switch (policy) { - case ORG_ALL -> true; - // Admins already pass above; only team leads here. - case ADMINS_AND_TEAM_LEADS -> teamLeadLookup.isAnyTeamLeader(user); + // Deployment-wide only where the resolver treats everyone as one org; saas resolvers + // return false, so ORG_ALL cannot leak a tenant's resource to another tenant's users. + case ORG_ALL -> principalResolver.allowsDeploymentWideAccess(); + // Admins already pass above; only team leads here, scoped to the owning team. + case ADMINS_AND_TEAM_LEADS -> matchesTeamLeadDefault(owner, user); case EXPLICIT_ONLY -> false; }; } - private boolean isOwner(Long ownerUserId, User user) { - return ownerUserId != null && ownerUserId.equals(user.getId()); + // Portal (no owner) admits any team lead; a team-owned resource admits only that team's + // leads; a user-owned resource admits no extra leads. + private boolean matchesTeamLeadDefault(PrincipalRef owner, User user) { + if (owner == null) { + return teamLeadLookup.isAnyTeamLeader(user); + } + return owner.type() == PrincipalType.TEAM + && owner.id() != null + && teamLeadLookup.isLeaderOfTeam(user, owner.id()); + } + + // Team owners are the owning team's leaders; plain members are not. + private boolean isOwner(PrincipalRef owner, User user) { + if (owner == null || owner.id() == null) { + return false; + } + return switch (owner.type()) { + case USER -> owner.id().equals(user.getId()); + case TEAM -> teamLeadLookup.isLeaderOfTeam(user, owner.id()); + }; } private boolean isAdmin(User user) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java index 917c180116..f160b92759 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java @@ -18,15 +18,26 @@ public class SecretMasker { // Cap recursion so a pathologically nested payload cannot overflow the stack. private static final int MAX_DEPTH = 32; + // Key-name substrings that mark a value sensitive. Over-masking a non-secret is + // safe; leaking a secret is not, so this errs broad - but a per-type schema + // whitelist would be a stronger boundary for free-form config (follow-up). private static final Set SENSITIVE_HINTS = Set.of( "secret", "password", + "passphrase", + "pwd", "token", "apikey", "accesskey", "credential", - "privatekey"); + "privatekey", + "authorization", + "cookie", + "session", + "connectionstring", + "bearer", + "signature"); /** Replace sensitive values with the mask (recursively) for safe display. */ public Map mask(Map config) { @@ -73,18 +84,24 @@ public class SecretMasker { private Map merge( Map stored, Map incoming, int depth) { - Map out = new LinkedHashMap<>(stored); + // Replace semantics (PUT): the result is the incoming document, except a redacted secret + // keeps its stored value. Keys absent from incoming are dropped, so edits can remove them. + Map out = new LinkedHashMap<>(); for (Map.Entry e : incoming.entrySet()) { String key = e.getKey(); Object value = e.getValue(); if (isSensitive(key)) { - if (!isRedacted(value, depth)) { + if (isRedacted(value, depth)) { + if (stored.containsKey(key)) { + out.put(key, stored.get(key)); // keep the stored secret + } + } else { out.put(key, value); // a real new secret replaces the stored one } - continue; // redacted (blank / mask) -> keep stored + continue; } if (depth < MAX_DEPTH - && out.get(key) instanceof Map s + && stored.get(key) instanceof Map s && value instanceof Map i) { out.put(key, merge(castMap(s), castMap(i), depth + 1)); } else { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationLabelsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationLabelsController.java new file mode 100644 index 0000000000..6dcd19e724 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationLabelsController.java @@ -0,0 +1,136 @@ +package stirling.software.proprietary.classification; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.UserServiceInterface; +import stirling.software.proprietary.classification.model.ClassificationLabels; +import stirling.software.proprietary.classification.model.LabelsValidator; +import stirling.software.proprietary.classification.store.ClassificationLabelStore; +import stirling.software.proprietary.classification.store.TeamLabelsEntity; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; + +/** + * Read/write the team's classification label set β€” the flat vocabulary the document classifier runs + * against. Shared and team-scoped exactly like policies: every user reads their own team's labels, + * and only a user who may edit policies (a team leader on SaaS, the global admin self-hosted; see + * {@link PolicyManagementAuthority}) may change it β€” gated only when login is enabled, since + * single-user deployments trust the local operator. A team with no stored labels reads as {@code + * 204}; that team has no vocabulary, so its documents are not classified (there is no built-in + * default on the backend or the engine β€” the label data lives only in the frontend). + */ +@RestController +@RequestMapping("/api/v1/classification/labels") +@Hidden +@RequiredArgsConstructor +@Tag(name = "Classification", description = "Team-scoped document-classification labels") +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class ClassificationLabelsController { + + private final ClassificationLabelStore labelStore; + private final PolicyManagementAuthority policyManagementAuthority; + private final ApplicationProperties applicationProperties; + private final UserServiceInterface userService; + + @GetMapping + @Operation( + summary = "Get the team's classification labels", + description = + "Returns the caller's team label set, or 204 when the team has none (its" + + " documents are then not classified).") + public ResponseEntity getTeamLabels() { + return labelStore + .findByTeam(currentTeamId()) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.noContent().build()); + } + + @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Save the team's classification labels", + description = + "Validates and stores the label set for the caller's team, shared by everyone" + + " on the team. Requires the policy-editor role for the team.") + public ResponseEntity saveTeamLabels( + @RequestBody ClassificationLabels labels) { + requireEditingAllowed(); + validate(labels); + ClassificationLabels saved = labelStore.save(currentTeamId(), labels, currentUsername()); + return ResponseEntity.ok(saved); + } + + @DeleteMapping + @Operation( + summary = "Reset the team's classification labels", + description = + "Removes the team's stored label set; its documents are then not classified" + + " until labels are saved again. Requires the policy-editor role for the" + + " team.") + public ResponseEntity resetTeamLabels() { + requireEditingAllowed(); + labelStore.deleteByTeam(currentTeamId()); + return ResponseEntity.noContent().build(); + } + + private static void validate(ClassificationLabels labels) { + try { + LabelsValidator.validate(labels); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + } + } + + /** + * Editing the team labels requires the editor role for the caller's team β€” the same gate + * policies use (team leader on SaaS, global admin self-hosted). Single-user deployments (login + * disabled) have no such role, so they trust the local operator. + */ + private void requireEditingAllowed() { + if (!applicationProperties.getSecurity().isEnableLogin()) { + return; + } + if (!policyManagementAuthority.canEditPolicies()) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, + "The team classification labels may only be changed by a team leader"); + } + } + + /** + * The caller's team key. With login disabled the single operator owns the {@link + * TeamLabelsEntity#NO_TEAM} sentinel row; with login enabled a caller with no resolvable team + * is an error rather than being dropped into the shared sentinel bucket (which would let + * unteamed users read and overwrite each other's "team" labels). + */ + private Long currentTeamId() { + Long teamId = policyManagementAuthority.currentUserTeamId(); + if (teamId != null) { + return teamId; + } + if (!applicationProperties.getSecurity().isEnableLogin()) { + return TeamLabelsEntity.NO_TEAM; + } + throw new ResponseStatusException( + HttpStatus.UNAUTHORIZED, "Could not resolve the current user's team"); + } + + private String currentUsername() { + return userService == null ? null : userService.getCurrentUsername(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/ClassificationLabel.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/ClassificationLabel.java new file mode 100644 index 0000000000..6601cba7e5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/ClassificationLabel.java @@ -0,0 +1,9 @@ +package stirling.software.proprietary.classification.model; + +/** + * One entry in the classification vocabulary. {@code id} is the label's stable identity (a slug, + * unique within a set): it is what the engine returns and what is stored on the document. {@code + * name} is the human display text the classifier model reasons over. {@code icon} is an optional + * presentational key (a Material Symbols name shown in the file sidebar); the engine never sees it. + */ +public record ClassificationLabel(String id, String name, String icon) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/ClassificationLabels.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/ClassificationLabels.java new file mode 100644 index 0000000000..ea7a1269f0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/ClassificationLabels.java @@ -0,0 +1,16 @@ +package stirling.software.proprietary.classification.model; + +import java.util.List; + +/** + * A flat multi-label classification vocabulary β€” the set of labels a document may be assigned. + * Stored per team (admin-edited, shared by everyone on the team); the classifier runs against these + * label names. A team with no stored set has no vocabulary, so its documents are not classified β€” + * neither the backend nor the engine holds a default of its own. + */ +public record ClassificationLabels(List labels) { + + public ClassificationLabels { + labels = labels == null ? List.of() : List.copyOf(labels); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/LabelsValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/LabelsValidator.java new file mode 100644 index 0000000000..722e8b417b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/LabelsValidator.java @@ -0,0 +1,71 @@ +package stirling.software.proprietary.classification.model; + +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Structural validation for a user- or admin-supplied label set, run before it is stored so a + * malformed vocabulary can never reach the classifier. Mirrors the invariants the engine relies on: + * non-blank ids and names, each unique within the set (ids exactly, names case-insensitively). + */ +public final class LabelsValidator { + + private LabelsValidator() {} + + // Generous upper bounds so a legitimate label set is never blocked, but a single team or user + // can't store an unbounded blob that would bloat the row, balloon the classifier prompt, or + // exhaust memory on deserialize. + static final int MAX_LABELS = 500; + static final int MAX_TEXT_LENGTH = 128; + + // Icon is a Material Symbols key (lowercase, digits, hyphens). Enforce the SHAPE server-side β€” + // the exact allowlist lives in the frontend β€” so a client bypassing the UI can't store + // arbitrary + // text that would render as garbage (or worse) in every teammate's sidebar. + private static final Pattern ICON_KEY = Pattern.compile("^[a-z0-9-]+$"); + + /** + * @throws IllegalArgumentException with a human-readable message when the label set is invalid. + */ + public static void validate(ClassificationLabels labels) { + if (labels == null || labels.labels() == null) { + throw new IllegalArgumentException("Labels are required"); + } + if (labels.labels().size() > MAX_LABELS) { + throw new IllegalArgumentException("Too many labels (max " + MAX_LABELS + ")"); + } + Set ids = new HashSet<>(); + Set names = new HashSet<>(); + for (ClassificationLabel label : labels.labels()) { + requireText(label.id(), "Label id"); + requireText(label.name(), "Label name"); + if (label.icon() != null && !label.icon().isEmpty()) { + if (label.icon().length() > MAX_TEXT_LENGTH) { + throw new IllegalArgumentException( + "Label icon is too long (max " + MAX_TEXT_LENGTH + " characters)"); + } + if (!ICON_KEY.matcher(label.icon()).matches()) { + throw new IllegalArgumentException("Invalid label icon: " + label.icon()); + } + } + if (!ids.add(label.id().trim())) { + throw new IllegalArgumentException("Duplicate label id: " + label.id()); + } + if (!names.add(label.name().trim().toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException("Duplicate label name: " + label.name()); + } + } + } + + private static void requireText(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + if (value.trim().length() > MAX_TEXT_LENGTH) { + throw new IllegalArgumentException( + field + " is too long (max " + MAX_TEXT_LENGTH + " characters)"); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/ClassificationLabelStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/ClassificationLabelStore.java new file mode 100644 index 0000000000..a604854c60 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/ClassificationLabelStore.java @@ -0,0 +1,22 @@ +package stirling.software.proprietary.classification.store; + +import java.util.Optional; + +import stirling.software.proprietary.classification.model.ClassificationLabels; + +/** + * Stores one {@link ClassificationLabels} set per team. A {@code null} teamId addresses the + * unteamed set (login disabled / no resolvable team), mirroring how the policy store treats a null + * team. + */ +public interface ClassificationLabelStore { + + /** The team's stored labels, or empty when it has none (callers then skip classification). */ + Optional findByTeam(Long teamId); + + /** Create or replace the team's labels. Returns the stored value. */ + ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy); + + /** Remove the team's labels (reset to default). Returns whether a set existed. */ + boolean deleteByTeam(Long teamId); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/InProcessClassificationLabelStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/InProcessClassificationLabelStore.java new file mode 100644 index 0000000000..6846902eab --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/InProcessClassificationLabelStore.java @@ -0,0 +1,36 @@ +package stirling.software.proprietary.classification.store; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import stirling.software.proprietary.classification.model.ClassificationLabels; + +/** + * In-memory {@link ClassificationLabelStore} for tests and any future no-database mode. {@link + * JpaClassificationLabelStore} is the runtime bean. + */ +public class InProcessClassificationLabelStore implements ClassificationLabelStore { + + private final Map byTeam = new ConcurrentHashMap<>(); + + @Override + public Optional findByTeam(Long teamId) { + return Optional.ofNullable(byTeam.get(key(teamId))); + } + + @Override + public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) { + byTeam.put(key(teamId), labels); + return labels; + } + + @Override + public boolean deleteByTeam(Long teamId) { + return byTeam.remove(key(teamId)) != null; + } + + private static long key(Long teamId) { + return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/JpaClassificationLabelStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/JpaClassificationLabelStore.java new file mode 100644 index 0000000000..724e335fc4 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/JpaClassificationLabelStore.java @@ -0,0 +1,76 @@ +package stirling.software.proprietary.classification.store; + +import java.time.Instant; +import java.util.Optional; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.classification.model.ClassificationLabels; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +/** + * Durable {@link ClassificationLabelStore} backed by JPA; the runtime store. Gated on {@code + * policies.enabled} β€” stored labels only matter when the Classification policy can run β€” so it + * shares the policy subsystem's on/off switch. Each label set is persisted as JSON via {@link + * TeamLabelsEntity}. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class JpaClassificationLabelStore implements ClassificationLabelStore { + + private final TeamLabelsRepository teamRepository; + private final ObjectMapper objectMapper; + + @Override + public Optional findByTeam(Long teamId) { + return teamRepository + .findById(key(teamId)) + .flatMap(entity -> parse(entity.getLabelsJson(), "team " + teamId)); + } + + @Override + public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) { + TeamLabelsEntity entity = new TeamLabelsEntity(); + entity.setTeamId(key(teamId)); + entity.setLabelsJson(objectMapper.writeValueAsString(labels)); + entity.setUpdatedAt(Instant.now()); + entity.setUpdatedBy(updatedBy); + teamRepository.save(entity); + return labels; + } + + @Override + public boolean deleteByTeam(Long teamId) { + long id = key(teamId); + if (!teamRepository.existsById(id)) { + return false; + } + teamRepository.deleteById(id); + return true; + } + + private Optional parse(String json, String owner) { + try { + return Optional.of(objectMapper.readValue(json, ClassificationLabels.class)); + } catch (JacksonException e) { + // A stored label set that no longer parses (corruption / manual DB edit) must not break + // classification: drop it so the caller treats the team as having no labels (and skips + // classification) rather than surfacing a 500 on every upload. + log.warn("Discarding unparseable stored labels for {}: {}", owner, e.getMessage()); + return Optional.empty(); + } + } + + /** Map the nullable team id onto the entity's non-null key (sentinel for the unteamed case). */ + private static long key(Long teamId) { + return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TeamLabelsEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TeamLabelsEntity.java new file mode 100644 index 0000000000..25f2e5b259 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TeamLabelsEntity.java @@ -0,0 +1,47 @@ +package stirling.software.proprietary.classification.store; + +import java.io.Serializable; +import java.time.Instant; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * JPA row for a team's classification labels β€” one row per team. The label set lives as JSON in + * {@code labelsJson} (authoritative on read). {@code teamId} is the natural key; the sentinel + * {@link #NO_TEAM} stands in for the unteamed (login-disabled / self-hosted single-team) case, + * since a primary key can't be null (policies store a nullable {@code team_id}, but this table is + * keyed one-per-team). Kept decoupled from the security entities β€” {@code teamId} is a plain value, + * not a foreign key β€” so classification can be enabled or disabled without touching them. + */ +@Entity +@Table(name = "classification_labels") +@NoArgsConstructor +@Getter +@Setter +public class TeamLabelsEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + /** Sentinel key for the unteamed label set (login disabled / no resolvable team). */ + public static final long NO_TEAM = 0L; + + @Id + @Column(name = "team_id") + private long teamId; + + @Column(name = "labels_json", columnDefinition = "text") + private String labelsJson; + + @Column(name = "updated_at") + private Instant updatedAt; + + @Column(name = "updated_by") + private String updatedBy; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TeamLabelsRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TeamLabelsRepository.java new file mode 100644 index 0000000000..7fc9589c39 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TeamLabelsRepository.java @@ -0,0 +1,7 @@ +package stirling.software.proprietary.classification.store; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TeamLabelsRepository extends JpaRepository {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java new file mode 100644 index 0000000000..629a377cf3 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java @@ -0,0 +1,218 @@ +package stirling.software.proprietary.controller.api; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import io.github.pixee.security.Filenames; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.PdfMetadataService; +import stirling.software.common.service.UserServiceInterface; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.WebResponseUtils; +import stirling.software.proprietary.classification.model.ClassificationLabel; +import stirling.software.proprietary.classification.store.ClassificationLabelStore; +import stirling.software.proprietary.model.api.ai.AiPageText; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.service.AiEngineClient; +import stirling.software.proprietary.service.PdfContentExtractor; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * Dispatchable tool that classifies a PDF and writes the result into its metadata. + * + *

Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI + * engine to classify the document against the caller's team label set, and stores the engine's JSON + * answer β€” minus the transport-only {@code outcome} field β€” in the custom Info-dictionary key + * {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF. Not intended for direct + * client use. + */ +@Slf4j +@Hidden +@RestController +@RequestMapping("/api/v1/ai/tools") +@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.") +public class ClassifyLabelController { + + /** Pages read from each end of the document β€” mirrors the engine's window. */ + private static final int WINDOW_PAGES = 2; + + private static final String CLASSIFY_ENDPOINT = "/api/v1/documents/classify"; + + private final CustomPDFDocumentFactory pdfDocumentFactory; + private final TempFileManager tempFileManager; + private final PdfContentExtractor pdfContentExtractor; + private final PdfMetadataService pdfMetadataService; + private final AiEngineClient aiEngineClient; + private final ObjectMapper objectMapper; + private final UserServiceInterface userService; + + /** + * Present only when the policy subsystem is enabled ({@code policies.enabled}); the store and + * team authority are gated on it. Null otherwise, in which case there are no team labels to + * classify against and the document is passed through unlabelled. + */ + private final ClassificationLabelStore labelStore; + + private final PolicyManagementAuthority policyManagementAuthority; + + public ClassifyLabelController( + CustomPDFDocumentFactory pdfDocumentFactory, + TempFileManager tempFileManager, + PdfContentExtractor pdfContentExtractor, + PdfMetadataService pdfMetadataService, + AiEngineClient aiEngineClient, + ObjectMapper objectMapper, + @Autowired(required = false) UserServiceInterface userService, + @Autowired(required = false) ClassificationLabelStore labelStore, + @Autowired(required = false) PolicyManagementAuthority policyManagementAuthority) { + this.pdfDocumentFactory = pdfDocumentFactory; + this.tempFileManager = tempFileManager; + this.pdfContentExtractor = pdfContentExtractor; + this.pdfMetadataService = pdfMetadataService; + this.aiEngineClient = aiEngineClient; + this.objectMapper = objectMapper; + this.userService = userService; + this.labelStore = labelStore; + this.policyManagementAuthority = policyManagementAuthority; + } + + @PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Classify a PDF and label its metadata", + description = + "Reads the first two and last two pages, classifies the document via the AI" + + " engine, and stores the result in the StirlingPDFClassification" + + " metadata field. Dispatched by the Classification policy; not" + + " intended for direct client use.") + public ResponseEntity classifyAndLabel( + @RequestParam("fileInput") MultipartFile fileInput) throws IOException { + try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) { + String fileName = safeFileName(fileInput.getOriginalFilename()); + + List allowed = resolveAllowedLabels(); + if (allowed.isEmpty()) { + // No vocabulary to classify against (the team stored no labels): pass the file + // through unlabelled rather than ask the engine to classify against nothing. + log.debug("[classify-and-label] {} has no team labels; skipping", fileName); + return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager); + } + + List pages = extractWindow(document); + String requestBody = + objectMapper.writeValueAsString( + new ClassifyEngineRequest(fileName, pages, allowed)); + + String userId = userService != null ? userService.getCurrentUsername() : null; + String responseJson = aiEngineClient.post(CLASSIFY_ENDPOINT, requestBody, userId); + + pdfMetadataService.setClassificationMetadata(document, toMetadataValue(responseJson)); + log.debug("[classify-and-label] labelled {} ({} window pages)", fileName, pages.size()); + + return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager); + } + } + + private List extractWindow(PDDocument document) throws IOException { + List pages = new ArrayList<>(); + for (int pageNumber : windowPageNumbers(document.getNumberOfPages(), WINDOW_PAGES)) { + String text = pdfContentExtractor.extractPageTextRaw(document, pageNumber); + if (text != null && !text.isBlank()) { + pages.add(new AiPageText(pageNumber, text)); + } + } + return pages; + } + + /** First and last {@code window} page numbers (1-based), de-duplicated and in order. */ + static List windowPageNumbers(int pageCount, int window) { + Set numbers = new LinkedHashSet<>(); + for (int page = 1; page <= Math.min(window, pageCount); page++) { + numbers.add(page); + } + for (int page = Math.max(1, pageCount - window + 1); page <= pageCount; page++) { + numbers.add(page); + } + return new ArrayList<>(numbers); + } + + /** Drop the transport-only {@code outcome} discriminator; keep the rest verbatim. */ + private String toMetadataValue(String engineResponseJson) { + JsonNode node = objectMapper.readTree(engineResponseJson); + if (node instanceof ObjectNode object) { + object.remove("outcome"); + } + return objectMapper.writeValueAsString(node); + } + + private static String safeFileName(String originalFilename) { + String name = Filenames.toSimpleFileName(originalFilename); + return (name == null || name.isBlank()) ? "classified.pdf" : name; + } + + /** + * The allowed labels for the caller's team as {@code {id, name}} pairs, de-duplicated by id. + * The engine shows the model the names and returns the ids (icons are presentational and never + * sent). Returns an empty list β€” the caller then skips classification β€” when the policy + * subsystem is disabled (no store) or the team has no stored labels. The engine holds no + * default vocabulary of its own, so a team's stored labels are the only source. + */ + private List resolveAllowedLabels() { + if (labelStore == null) { + return List.of(); + } + Long teamId = + policyManagementAuthority == null + ? null + : policyManagementAuthority.currentUserTeamId(); + + Map byId = new LinkedHashMap<>(); + labelStore.findByTeam(teamId).ifPresent(labels -> collectLabels(labels.labels(), byId)); + + return List.copyOf(byId.values()); + } + + private static void collectLabels( + List labels, Map into) { + for (ClassificationLabel label : labels) { + if (label.id() == null + || label.id().isBlank() + || label.name() == null + || label.name().isBlank()) { + continue; + } + into.putIfAbsent(label.id(), new EngineLabel(label.id(), label.name())); + } + } + + /** One allowed label sent to the engine: stable id + the name the model reasons over. */ + private record EngineLabel(String id, String name) {} + + /** Request body for the engine's {@code /api/v1/documents/classify} endpoint. */ + private record ClassifyEngineRequest( + String fileName, List pages, List labels) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java index 161c1d7055..8db5f48174 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -5,6 +5,7 @@ import static stirling.software.common.util.ProviderUtils.validateProvider; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.*; +import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.ResponseEntity; @@ -28,13 +29,16 @@ import stirling.software.common.model.ApplicationProperties.Security.OAUTH2.Clie import stirling.software.common.model.ApplicationProperties.Security.SAML2; import stirling.software.common.model.FileInfo; import stirling.software.common.model.enumeration.Role; +import stirling.software.common.model.enumeration.TeamRole; import stirling.software.common.model.oauth2.GitHubProvider; import stirling.software.common.model.oauth2.GoogleProvider; import stirling.software.common.model.oauth2.KeycloakProvider; +import stirling.software.proprietary.access.service.ResourceAccessService; import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.config.AuditConfigurationProperties; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.model.dto.TeamWithUserCountDTO; import stirling.software.proprietary.repository.PersistentAuditEventRepository; import stirling.software.proprietary.security.config.EnterpriseEndpoint; @@ -44,6 +48,7 @@ import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.SessionEntity; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.dto.AdminUserSummary; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; import stirling.software.proprietary.security.service.DatabaseServiceInterface; @@ -65,6 +70,7 @@ public class ProprietaryUIDataController { private final SessionPersistentRegistry sessionPersistentRegistry; private final UserRepository userRepository; private final TeamRepository teamRepository; + private final TeamMembershipRepository teamMembershipRepository; private final SessionRepository sessionRepository; private final DatabaseServiceInterface databaseService; private final boolean runningEE; @@ -73,6 +79,7 @@ public class ProprietaryUIDataController { private final PersistentAuditEventRepository auditRepository; private final MfaService mfaService; private final LoginAttemptService loginAttemptService; + private final ResourceAccessService resourceAccessService; public ProprietaryUIDataController( ApplicationProperties applicationProperties, @@ -80,6 +87,7 @@ public class ProprietaryUIDataController { SessionPersistentRegistry sessionPersistentRegistry, UserRepository userRepository, TeamRepository teamRepository, + TeamMembershipRepository teamMembershipRepository, SessionRepository sessionRepository, DatabaseServiceInterface databaseService, ObjectMapper objectMapper, @@ -87,12 +95,14 @@ public class ProprietaryUIDataController { UserLicenseSettingsService licenseSettingsService, PersistentAuditEventRepository auditRepository, MfaService mfaService, - LoginAttemptService loginAttemptService) { + LoginAttemptService loginAttemptService, + ResourceAccessService resourceAccessService) { this.applicationProperties = applicationProperties; this.auditConfig = auditConfig; this.sessionPersistentRegistry = sessionPersistentRegistry; this.userRepository = userRepository; this.teamRepository = teamRepository; + this.teamMembershipRepository = teamMembershipRepository; this.sessionRepository = sessionRepository; this.databaseService = databaseService; this.objectMapper = objectMapper; @@ -101,6 +111,7 @@ public class ProprietaryUIDataController { this.auditRepository = auditRepository; this.mfaService = mfaService; this.loginAttemptService = loginAttemptService; + this.resourceAccessService = resourceAccessService; } /** @@ -370,8 +381,11 @@ public class ProprietaryUIDataController { boolean premiumEnabled = applicationProperties.getPremium().isEnabled(); // Convert User entities to AdminUserSummary DTOs to exclude sensitive fields + Set leaderUserIds = leaderUserIds(); List userSummaries = - sortedUsers.stream().map(this::convertUserToSummary).toList(); + sortedUsers.stream() + .map(user -> convertUserToSummary(user, leaderUserIds)) + .toList(); AdminSettingsData data = new AdminSettingsData(); data.setUsers(userSummaries); @@ -390,6 +404,10 @@ public class ProprietaryUIDataController { data.setLicenseMaxUsers(licenseMaxUsers); data.setPremiumEnabled(premiumEnabled); data.setMailEnabled(applicationProperties.getMail().isEnabled()); + // Email invites need the invites toggle AND SMTP on; matches the inviteUsers precondition. + data.setEmailInvitesEnabled( + applicationProperties.getMail().isEnableInvites() + && applicationProperties.getMail().isEnabled()); data.setUserSettings(userSettings); data.setLockedUsers(loginAttemptService.getAllBlockedUsers()); @@ -468,9 +486,18 @@ public class ProprietaryUIDataController { teamLastRequest.put(teamId, lastActivity); } + Map> teamOwners = new HashMap<>(); + for (TeamMembership row : + teamMembershipRepository.findByRoleFetchingUserAndTeam(TeamRole.LEADER)) { + teamOwners + .computeIfAbsent(row.getTeam().getId(), id -> new ArrayList<>()) + .add(row.getUser().getUsername()); + } + TeamsData data = new TeamsData(); data.setTeamsWithCounts(teamsWithCounts); data.setTeamLastRequest(teamLastRequest); + data.setTeamOwners(teamOwners); return ResponseEntity.ok(data); } @@ -510,11 +537,17 @@ public class ProprietaryUIDataController { userLastRequest.put(username, lastRequest); } + Set ownerUserIds = + teamMembershipRepository.findByTeamIdAndRole(id, TeamRole.LEADER).stream() + .map(row -> row.getUser().getId()) + .collect(Collectors.toSet()); + TeamDetailsData data = new TeamDetailsData(); data.setTeam(team); data.setTeamUsers(teamUsers); data.setAvailableUsers(availableUsers); data.setUserLastRequest(userLastRequest); + data.setOwnerUserIds(ownerUserIds); return ResponseEntity.ok(data); } @@ -535,13 +568,24 @@ public class ProprietaryUIDataController { return ResponseEntity.ok(data); } + /** User ids holding a LEADER membership on any team. */ + private Set leaderUserIds() { + return teamMembershipRepository.findByRoleFetchingUserAndTeam(TeamRole.LEADER).stream() + .map(row -> row.getUser().getId()) + .collect(Collectors.toSet()); + } + /** * Convert User entity to AdminUserSummary DTO, excluding sensitive fields like password and * apiKey. */ - private AdminUserSummary convertUserToSummary(User user) { + private AdminUserSummary convertUserToSummary(User user, Set leaderUserIds) { AdminUserSummary summary = new AdminUserSummary(); summary.setId(user.getId()); + summary.setTeamLead(leaderUserIds.contains(user.getId())); + // Authoritative portal access, same call /me uses, so the roster honors the configured + // policy instead of the frontend guessing from role/team-leadership. + summary.setPortalAccess(resourceAccessService.canAccessPortal(user)); summary.setUsername(user.getUsername()); summary.setEmail(user.getUsername()); // Use username as email for consistency summary.setRoleName(user.getRoleName()); @@ -609,6 +653,7 @@ public class ProprietaryUIDataController { private int licenseMaxUsers; private boolean premiumEnabled; private boolean mailEnabled; + private boolean emailInvitesEnabled; private Map> userSettings; private List lockedUsers; } @@ -629,6 +674,7 @@ public class ProprietaryUIDataController { public static class TeamsData { private List teamsWithCounts; private Map teamLastRequest; + private Map> teamOwners; } @Data @@ -637,6 +683,7 @@ public class ProprietaryUIDataController { private List teamUsers; private List availableUsers; private Map userLastRequest; + private Set ownerUserIds; } @Data diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java index 220c4e8d65..4e79fc5e7a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java @@ -29,7 +29,9 @@ import stirling.software.proprietary.security.model.User; @RestController @RequestMapping("/api/v1/integrations") @RequiredArgsConstructor -@PreAuthorize("isAuthenticated()") +// Portal-exclusive: server-side portal-access boundary, not just isAuthenticated. Per-config +// ownership is still enforced in the service layer. +@PreAuthorize("@resourceAccess.canUsePortal()") @Tag(name = "Integrations", description = "Manage S3/MCP/API integration configurations") public class IntegrationConfigController { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java index 1fff2dd321..96e20f55cb 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java @@ -1,12 +1,16 @@ package stirling.software.proprietary.integration.crypto; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; import java.security.GeneralSecurityException; import java.security.SecureRandom; import java.util.Arrays; import java.util.Base64; +import java.util.EnumSet; import javax.crypto.Cipher; import javax.crypto.KeyGenerator; @@ -74,7 +78,7 @@ public class CredentialEncryption { generator.init(256); SecretKey generated = generator.generateKey(); Files.createDirectories(path.getParent()); - Files.writeString(path, Base64.getEncoder().encodeToString(generated.getEncoded())); + writeOwnerOnly(path, Base64.getEncoder().encodeToString(generated.getEncoded())); log.warn( "Generated a new credential encryption key at {}. Back this file up: losing it" + " makes stored integration secrets unrecoverable.", @@ -85,6 +89,25 @@ public class CredentialEncryption { } } + // The master key decrypts every stored integration secret, so create it 0600 + // (owner-only) atomically. On non-POSIX filesystems (Windows) the config-dir + // ACL is the protection; we still create the file, just without POSIX perms. + private static void writeOwnerOnly(Path path, String content) throws IOException { + EnumSet ownerOnly = + EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); + try { + Files.createFile(path, PosixFilePermissions.asFileAttribute(ownerOnly)); + } catch (UnsupportedOperationException e) { + Files.createFile(path); + } + Files.writeString(path, content); + try { + Files.setPosixFilePermissions(path, ownerOnly); + } catch (UnsupportedOperationException ignored) { + // Non-POSIX filesystem: nothing to tighten here. + } + } + public static String encrypt(String plaintext) { if (plaintext == null) { return null; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/repository/IntegrationConfigRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/repository/IntegrationConfigRepository.java index 5d3e8056c6..b036d5900c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/repository/IntegrationConfigRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/repository/IntegrationConfigRepository.java @@ -16,4 +16,13 @@ public interface IntegrationConfigRepository extends JpaRepository findByOwnerTeam(Team ownerTeam); List findByScope(OwnerScope scope); + + // Nested path: OwnedResource has a getOwnerTeamId() convenience getter but no such persistent + // attribute, so the plain "...OwnerTeamId" derivation resolves to a phantom property and throws + // UnknownPathException. The underscore forces the real ownerTeam.id association path. + boolean existsByOwnerTeam_Id(Long teamId); + + void deleteByOwnerUser(User ownerUser); + + void deleteByOwnerTeam_Id(Long teamId); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java index b384cc48e6..9e44b35523 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java @@ -16,6 +16,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.access.model.DefaultAccessPolicy; import stirling.software.proprietary.access.model.OwnerScope; import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.access.repository.ResourceGrantRepository; import stirling.software.proprietary.access.service.OwnershipService; import stirling.software.proprietary.access.service.SecretMasker; import stirling.software.proprietary.integration.dto.IntegrationConfigRequest; @@ -41,6 +42,7 @@ public class IntegrationConfigService { private final IntegrationConfigRepository repository; private final OwnershipService ownership; private final SecretMasker secretMasker; + private final ResourceGrantRepository grantRepository; // ---- commands ---- @@ -49,6 +51,13 @@ public class IntegrationConfigService { OwnerScope scope = request.scope() == null ? OwnerScope.USER : request.scope(); IntegrationConfig cfg = new IntegrationConfig(); cfg.setIntegrationType(require(request.integrationType(), "integrationType")); + // S3 is infrastructure, not self-serve: no personal S3 for regular users. TEAM/SERVER + // scopes are already restricted to admins/team owners by assignOwnership. + if (cfg.getIntegrationType() == IntegrationType.S3 + && scope == OwnerScope.USER + && !ownership.isAdmin(currentUser)) { + throw forbidden("S3 connections can only be created by administrators or team owners"); + } cfg.setName(require(request.name(), "name")); cfg.setEnabled(request.enabled() == null || request.enabled()); cfg.setLocked(request.locked() != null && request.locked()); @@ -104,6 +113,8 @@ public class IntegrationConfigService { if (!ownership.canManage(TYPE, cfg, currentUser)) { throw forbidden("You cannot manage this integration"); } + // Drop grants sharing this config so they do not dangle as dead rows. + grantRepository.deleteByResourceTypeAndResourceId(TYPE, String.valueOf(cfg.getId())); repository.delete(cfg); } diff --git a/app/saas/src/main/java/stirling/software/saas/model/TeamMembership.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamMembership.java similarity index 96% rename from app/saas/src/main/java/stirling/software/saas/model/TeamMembership.java rename to app/proprietary/src/main/java/stirling/software/proprietary/model/TeamMembership.java index 4221643432..de121939bb 100644 --- a/app/saas/src/main/java/stirling/software/saas/model/TeamMembership.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamMembership.java @@ -1,4 +1,4 @@ -package stirling.software.saas.model; +package stirling.software.proprietary.model; import java.io.Serializable; import java.time.LocalDateTime; @@ -15,7 +15,6 @@ import lombok.Setter; import lombok.ToString; import stirling.software.common.model.enumeration.TeamRole; -import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.model.User; /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowResultFile.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowResultFile.java index 57207d2bc9..c80615f03e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowResultFile.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowResultFile.java @@ -21,4 +21,11 @@ public class AiWorkflowResultFile { @Schema(description = "MIME type of the file", example = "application/pdf") private String contentType; + + @Schema( + description = + "Index into the request's fileInputs that this output was derived from, or null" + + " when it has no single source (e.g. a merge, or a generated file)." + + " Lets the client replace that input in place as a new version.") + private Integer sourceIndex; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index 64e6aa0523..9494317751 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -18,6 +18,7 @@ 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.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestPart; @@ -47,6 +48,7 @@ import stirling.software.proprietary.policy.engine.PolicyRunHandle; import stirling.software.proprietary.policy.engine.PolicyRunRegistry; import stirling.software.proprietary.policy.engine.PolicyRunner; import stirling.software.proprietary.policy.engine.PolicyValidator; +import stirling.software.proprietary.policy.ledger.ProcessedLedger; import stirling.software.proprietary.policy.model.PipelineDefinition; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyInputs; @@ -86,6 +88,7 @@ public class PolicyController { private final PolicyManagementAuthority policyManagementAuthority; private final PolicyTriggerManager policyTriggerManager; private final PolicyOverviewService policyOverviewService; + private final ProcessedLedger processedLedger; private final List policyTriggers; private final ApplicationProperties applicationProperties; private final TempFileManager tempFileManager; @@ -213,6 +216,20 @@ public class PolicyController { return ResponseEntity.ok(saved); } + @PutMapping("/order") + @Operation( + summary = "Set the team's policy run order", + description = + "Persists the team-wide order policies run in, from the given ordered list of" + + " policy ids (position β†’ order). The per-trigger order shown in the UI" + + " is this one sequence filtered by trigger. Team-leader/admin only;" + + " ids outside the caller's team are ignored.") + public ResponseEntity reorderPolicies(@RequestBody List orderedPolicyIds) { + requirePolicyEditingAllowed(); + policyStore.reorder(policyAccessGuard.teamForNewPolicy(), orderedPolicyIds); + return ResponseEntity.noContent().build(); + } + /** * Every {@code sourceId} a policy references must resolve to a source in the caller's team, so * a client can neither reference a non-existent source nor reach across teams to use another @@ -337,6 +354,7 @@ public class PolicyController { boolean accessible = policyStore.get(policyId).filter(policyAccessGuard::canAccess).isPresent(); if (accessible && policyStore.delete(policyId)) { + processedLedger.clearPolicy(policyId); // Cancel any now-orphaned folder watch promptly rather than leaving the WatchKey open // until the next reconcile sweep. policyTriggerManager.notifyPoliciesChanged(); @@ -345,6 +363,25 @@ public class PolicyController { return ResponseEntity.notFound().build(); } + @DeleteMapping("/{policyId}/processed-history") + @Operation( + summary = "Clear a policy's processed-file history", + description = + "Forgets which source files this policy has already processed, so its next" + + " sweep reprocesses everything currently in its sources. Does not" + + " touch the files themselves.") + public ResponseEntity clearProcessedHistory(@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) { + return ResponseEntity.notFound().build(); + } + processedLedger.clearPolicy(policyId); + return ResponseEntity.noContent().build(); + } + @PostMapping(value = "/{policyId}/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation( summary = "Run a stored policy", diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index ff5d73efb3..98a6f90096 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -35,6 +35,7 @@ import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyInputs; import stirling.software.proprietary.policy.model.PolicyRun; import stirling.software.proprietary.policy.model.WaitState; +import stirling.software.proprietary.policy.output.OutputDelivery; import stirling.software.proprietary.policy.output.PolicyOutputSink; import stirling.software.proprietary.policy.progress.PolicyProgressListener; import stirling.software.proprietary.service.DownstreamEntitlementError; @@ -203,7 +204,12 @@ public class PolicyEngine { PolicyExecutionResult result = stepExecutor.execute(run.getDefinition(), inputs, listener); OutputSpec output = run.getDefinition().output(); - List outputs = sinkFor(output).deliver(runId, result.files(), output); + List outputs = + sinkFor(output) + .deliver( + new OutputDelivery(runId, run.getPolicyId()), + result.files(), + output); taskManager.setMultipleFileResults(runId, outputs); taskManager.setComplete(runId); run.complete(outputs); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutionResult.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutionResult.java index 8bfb78410b..2d5552517c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutionResult.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutionResult.java @@ -8,7 +8,11 @@ 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. + * {@code origins} is parallel to {@code files}: each entry is the index into the original pipeline + * inputs that the output traces back to, or {@code null} when it has no single source (e.g. a merge + * combining several inputs, or a generated file). Callers use it to map an output back onto the + * file it came from. {@code report}/{@code reportTool} carry the last step's structured report and + * its operation, or null if no step produced one. */ -public record PolicyExecutionResult(List files, JsonNode report, String reportTool) {} +public record PolicyExecutionResult( + List files, List origins, JsonNode report, String reportTool) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java index fe88b40391..d6202b520e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java @@ -58,6 +58,11 @@ public class PolicyExecutor { // payload the tool surfaced alongside or instead of a file. private record ToolResult(List files, JsonNode report) {} + // A step's output files paired with each file's origin (the index into the original pipeline + // inputs it traces back to, or null when it has no single source). Origins compose across steps + // so the final result can be mapped back onto the files that entered the pipeline. + private record StepOutput(List files, List origins, 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. @@ -75,6 +80,12 @@ public class PolicyExecutor { List currentFiles = inputs.primary(); Map> supportingFiles = inputs.supportingFiles(); + // Seed each input with its own index as origin; steps carry these through so the final + // outputs can be traced back to the files that entered the pipeline. + List currentOrigins = new ArrayList<>(); + for (int k = 0; k < currentFiles.size(); k++) { + currentOrigins.add(k); + } // Last non-null report wins: the terminal step defines the output. JsonNode lastReport = null; String lastReportTool = null; @@ -87,8 +98,10 @@ public class PolicyExecutor { "Pipeline step " + (i + 1) + " has no operation"); } listener.onStepStart(i + 1, steps.size(), operation); - ToolResult stepResult = executeStep(step, currentFiles, supportingFiles); + StepOutput stepResult = + executeStep(step, currentFiles, currentOrigins, supportingFiles); currentFiles = stepResult.files(); + currentOrigins = stepResult.origins(); if (stepResult.report() != null) { lastReport = stepResult.report(); lastReportTool = operation; @@ -96,7 +109,7 @@ public class PolicyExecutor { listener.onStepComplete(i + 1, steps.size(), operation); } - return new PolicyExecutionResult(currentFiles, lastReport, lastReportTool); + return new PolicyExecutionResult(currentFiles, currentOrigins, lastReport, lastReportTool); } /** @@ -104,32 +117,50 @@ public class PolicyExecutor { * responses are unpacked so each inner file is its own result (e.g. split). For per-file * dispatch the first non-null report wins. */ - private ToolResult executeStep( + private StepOutput executeStep( PipelineStep step, List inputFiles, + List inputOrigins, Map> supportingFiles) throws IOException { requireAcceptedTypes(step.operation(), inputFiles); List files = new ArrayList<>(); + List origins = new ArrayList<>(); JsonNode report = null; if (toolMetadataService.isMultiInput(step.operation())) { + // One call over all inputs. The outputs derive from a single input only when exactly + // one entered; otherwise (a genuine merge) there is no single source. ToolResult r = callEndpoint(step, inputFiles, supportingFiles); - files.addAll(r.files()); + Integer origin = inputOrigins.size() == 1 ? inputOrigins.get(0) : null; + for (Resource file : r.files()) { + files.add(file); + origins.add(origin); + } report = r.report(); } else if (inputFiles.isEmpty()) { ToolResult r = callEndpoint(step, List.of(), supportingFiles); - files.addAll(r.files()); + for (Resource file : r.files()) { + files.add(file); + origins.add(null); + } report = r.report(); } else { - for (Resource file : inputFiles) { - ToolResult r = callEndpoint(step, List.of(file), supportingFiles); - files.addAll(r.files()); + // One call per file: every output of this call inherits that input's origin, so a 1:1 + // op keeps its chain and a split (one input, many outputs) tags each output with the + // same source. + for (int k = 0; k < inputFiles.size(); k++) { + Integer origin = inputOrigins.get(k); + ToolResult r = callEndpoint(step, List.of(inputFiles.get(k)), supportingFiles); + for (Resource file : r.files()) { + files.add(file); + origins.add(origin); + } if (report == null) { report = r.report(); } } } - return new ToolResult(files, report); + return new StepOutput(files, origins, report); } /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java index fa2a190e8f..5a35b879c0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java @@ -13,6 +13,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.input.ResolvedInput; +import stirling.software.proprietary.policy.ledger.ProcessedLedger; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.PipelineDefinition; import stirling.software.proprietary.policy.model.Policy; @@ -27,7 +28,8 @@ import stirling.software.proprietary.policy.source.SourceStore; /** * Turns a policy's referenced sources into runs: each {@code sourceId} is resolved live to its * persisted {@link Source}, then to an {@link InputSpec}. Triggers decide when and call - * {@link #run(Policy)}; the controller uses the supplied-input and ad-hoc entry points. + * {@link #run(Policy)}; the controller uses the supplied-input and ad-hoc entry points. A {@link + * SweepKind#FULL} sweep also reconciles the processed-file ledger against what is present. */ @Slf4j @Service @@ -39,6 +41,12 @@ public class PolicyRunner { private final List inputSources; private final SourceStore sourceStore; private final SourceDocCounter docCounter; + private final ProcessedLedger processedLedger; + + /** Full-listing sweep: resolve every source, then reconcile the ledger. */ + public List run(Policy policy) { + return run(policy, SweepKind.FULL); + } /** * Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so @@ -47,15 +55,21 @@ public class PolicyRunner { * rest. Returns the ids of the runs it started (empty when sources yielded no work), so a * manual trigger can report back which runs to follow. */ - public List run(Policy policy) { + public List run(Policy policy, SweepKind sweep) { + long sweepStart = System.currentTimeMillis(); + PolicySweep context = new PolicySweep(policy.id(), sweep, processedLedger); + List runIds = new ArrayList<>(); List sourceIds = policy.sourceIds(); if (sourceIds.isEmpty()) { - return List.of(startRun(policy, PolicyInputs.of(List.of()), unused -> {})); + // Generator pipeline: one run with no input. Still fall through to the cleanup + // below so rows recorded for its folder outputs are pruned like anything else, + // instead of accumulating until the policy is deleted. + runIds.add(startRun(policy, PolicyInputs.of(List.of()), unused -> {})); } - List runIds = new ArrayList<>(); for (String sourceId : sourceIds) { Source source = sourceStore.get(sourceId).orElse(null); if (source == null) { + // No veto: a deleted source's rows should age out via the cleanup below. log.warn("Policy {} references missing source {}; skipping", policy.id(), sourceId); continue; } @@ -65,9 +79,21 @@ public class PolicyRunner { sourceId, source.name(), policy.id()); + // Veto: a paused source's files cannot be stamped, so they must not be pruned. + context.vetoCleanup(); continue; } - runIds.addAll(pullAndRun(policy, sourceId, source.toInputSpec())); + runIds.addAll(pullAndRun(policy, sourceId, source.toInputSpec(), context)); + } + if (context.cleanupAllowed()) { + processedLedger.markSeen(policy.id(), context.presentIdentities()); + int removed = processedLedger.deleteUnseen(policy.id(), sweepStart); + if (removed > 0) { + log.debug( + "Pruned {} ledger row(s) for files no longer present (policy {})", + removed, + policy.id()); + } } return runIds; } @@ -86,26 +112,33 @@ public class PolicyRunner { /** * Resolves the source and starts a run per unit; records how many documents the source fed and - * returns the ids of the runs started. + * returns the ids of the runs started. Any source that could not be listed completely vetoes + * this sweep's ledger cleanup. */ - private List pullAndRun(Policy policy, String sourceId, InputSpec spec) { + private List pullAndRun( + Policy policy, String sourceId, InputSpec spec, PolicySweep context) { InputSource source = sourceFor(spec); if (source == null) { log.warn( "No input source for type '{}' (policy {}); skipping", spec.type(), policy.id()); + context.vetoCleanup(); return List.of(); } + if (!source.listsExhaustively()) { + context.vetoCleanup(); + } List work; try { - work = source.resolve(spec); + work = source.resolve(spec, context); } catch (IOException | RuntimeException e) { log.warn( "Failed to resolve source '{}' for policy {}: {}", spec.type(), policy.id(), e.getMessage()); + context.vetoCleanup(); return List.of(); } List runIds = new ArrayList<>(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicySweep.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicySweep.java new file mode 100644 index 0000000000..5302bacdb3 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicySweep.java @@ -0,0 +1,89 @@ +package stirling.software.proprietary.policy.engine; + +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; + +import stirling.software.proprietary.policy.input.ResolveContext; +import stirling.software.proprietary.policy.ledger.ClaimState; +import stirling.software.proprietary.policy.ledger.ProcessedFileStatus; +import stirling.software.proprietary.policy.ledger.ProcessedLedger; + +/** + * The {@link ResolveContext} for one policy sweep: scopes ledger calls to the policy, gathers the + * present-identity union across sources, prefetches claim state in bulk so per-file claims skip + * their row lookup, and vetoes presence cleanup when any source could not be listed completely + * (pruning would wrongly forget its files). + */ +final class PolicySweep implements ResolveContext { + + private final String policyId; + private final SweepKind kind; + private final ProcessedLedger ledger; + private final Set present = new HashSet<>(); + // Claim states loaded in bulk at reportPresent; a claim outside the prefetch falls back to a + // single lookup. A stale entry cannot double-claim (the ledger re-checks every transition), + // it can only defer a file to the next sweep. + private final Map prefetched = new HashMap<>(); + private final Set prefetchedIdentities = new HashSet<>(); + private boolean cleanupVetoed; + + PolicySweep(String policyId, SweepKind kind, ProcessedLedger ledger) { + this.policyId = policyId; + this.kind = kind; + this.ledger = ledger; + } + + @Override + public synchronized boolean claim(String identity, String gate, Supplier contentHash) { + ClaimState observed = + prefetchedIdentities.contains(identity) + ? prefetched.get(identity) + : ledger.statesFor(policyId, List.of(identity)).get(identity); + boolean claimed = ledger.claim(policyId, identity, gate, contentHash, observed); + if (claimed) { + // A nested source surfacing the same file later in this sweep sees it in flight + // without another lookup. + prefetchedIdentities.add(identity); + prefetched.put(identity, new ClaimState(ProcessedFileStatus.PROCESSING, gate, null)); + } + return claimed; + } + + @Override + public void settle( + String identity, String finalGate, String finalContentHash, boolean success) { + ledger.settle(policyId, identity, finalGate, finalContentHash, success); + } + + @Override + public boolean allSettledDone(String identity) { + // Deliberately not policy-scoped: consume deletion needs every claimant's consensus. + return ledger.allSettledDone(identity); + } + + @Override + public synchronized void reportPresent(Collection identities) { + if (kind == SweepKind.FULL) { + present.addAll(identities); + } + prefetched.putAll(ledger.statesFor(policyId, identities)); + prefetchedIdentities.addAll(identities); + } + + synchronized void vetoCleanup() { + cleanupVetoed = true; + } + + synchronized boolean cleanupAllowed() { + return kind == SweepKind.FULL && !cleanupVetoed; + } + + synchronized Set presentIdentities() { + return Set.copyOf(present); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/SweepKind.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/SweepKind.java new file mode 100644 index 0000000000..c2455749ae --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/SweepKind.java @@ -0,0 +1,10 @@ +package stirling.software.proprietary.policy.engine; + +/** + * How thorough a policy sweep is: {@link #FULL} (complete listing; also stamps presence and prunes + * the ledger) or {@link #LIGHT} (event-driven; claims only, cost proportional to what changed). + */ +public enum SweepKind { + FULL, + LIGHT +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java index c00f5b4a33..9270cc0c1b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java @@ -1,12 +1,17 @@ package stirling.software.proprietary.policy.input; import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.FileVisitResult; import java.nio.file.Files; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; -import java.nio.file.StandardCopyOption; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.function.Supplier; import java.util.stream.Stream; import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; @@ -19,17 +24,20 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.util.FileReadinessChecker; import stirling.software.proprietary.policy.config.FolderAccessGuard; +import stirling.software.proprietary.policy.ledger.FolderIdentities; 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. - * - *

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. + * Reads input files from a directory; each ready file is its own unit of work, claimed through the + * {@link ResolveContext} ledger rather than moved aside, so nothing accumulates in a work + * directory. Options: "mode" is "consume" (default: a processed file is removed once every policy + * that claimed it has settled successfully and it is still the version that ran; failures stay in + * place and are not retried until they change) or "snapshot" (stateless, every run sees the full + * set); "recursive" descends into subdirectories; "identity" is "stat" (default, any size/mtime + * change is a new version) or "hash" (content-verified, so a touch does not reprocess). Hidden + * files and directories, including the legacy {@code .stirling} work dir, are never picked up, and + * files mid-write are skipped by the readiness check. */ @Slf4j @Service @@ -38,11 +46,6 @@ 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. - private static final String WORK_SUBDIR = ".stirling"; - private static final String PROCESSING_SUBDIR = "processing"; - private static final String DONE_SUBDIR = "done"; - private static final String ERROR_SUBDIR = "error"; private final FileReadinessChecker readinessChecker; private final FolderAccessGuard accessGuard; @@ -68,70 +71,195 @@ public class FolderInputSource implements InputSource { } @Override - public List resolve(InputSpec spec) throws IOException { + public List resolve(InputSpec spec, ResolveContext ctx) throws IOException { FolderConfig config = FolderConfig.from(spec.options()); Path inputDir = accessGuard.requirePermitted(config.directory()); if (!Files.isDirectory(inputDir)) { - log.debug("Folder input dir does not exist: {}", inputDir); - return List.of(); + // Fail rather than return empty: an unmounted drive must read as "could not list", + // which vetoes the sweep's presence cleanup, not as "verifiably no files", which + // would wipe the policy's history and reprocess everything on remount. + throw new NoSuchFileException( + inputDir.toString(), null, "input directory does not exist"); + } + Path canonicalDir = FolderIdentities.canonicalDir(inputDir); + List present = listFiles(inputDir, config.recursive()); + + if (config.snapshot()) { + List work = new ArrayList<>(); + for (Path file : present) { + if (readinessChecker.isReady(file)) { + work.add(ResolvedInput.of(PolicyInputs.of(List.of(fileResource(file))))); + } + } + return work; } - List ready = new ArrayList<>(); - try (Stream entries = Files.list(inputDir)) { - entries.filter(Files::isRegularFile) - .filter(readinessChecker::isReady) - .forEach(ready::add); - } + ctx.reportPresent( + present.stream() + .map(file -> FolderIdentities.identity(canonicalDir, inputDir, file)) + .toList()); List work = new ArrayList<>(); - for (Path file : ready) { - if (config.snapshot()) { - work.add(ResolvedInput.of(PolicyInputs.of(List.of(fileResource(file))))); - } else { - Path claimed = claim(inputDir, file); - if (claimed == null) { - continue; // another sweep/process grabbed it - } - work.add( - new ResolvedInput( - PolicyInputs.of(List.of(fileResource(claimed))), - success -> route(inputDir, claimed, success))); + for (Path file : present) { + if (!readinessChecker.isReady(file)) { + continue; } + String identity = FolderIdentities.identity(canonicalDir, inputDir, file); + MemoizedContentHash contentHash = + config.hashIdentity() ? new MemoizedContentHash(file) : null; + String gate; + boolean claimed; + try { + gate = FolderIdentities.statGate(file); + claimed = ctx.claim(identity, gate, contentHash); + } catch (IOException | UncheckedIOException e) { + log.debug("Could not read {} for its version: {}", file, e.getMessage()); + continue; // vanished or unreadable mid-sweep; the next sweep sees the truth + } + if (!claimed) { + continue; + } + work.add( + new ResolvedInput( + PolicyInputs.of(List.of(fileResource(file))), + success -> + completeConsumed( + ctx, identity, file, gate, contentHash, success))); } 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) { + /** + * Settle at the version this run claimed - never a re-read, so a file replaced mid-run reads as + * a new unclaimed version next sweep instead of being marked processed. Then remove the input + * only when it is still the processed version (a mid-run replacement must survive) and every + * policy that claimed it has settled DONE, so co-watching policies all read the original and + * one failure parks the file for everyone. A failed run settles ERROR and never deletes; the + * DONE row of a file that could not be deleted still stops reprocessing. + */ + private static void completeConsumed( + ResolveContext ctx, + String identity, + Path file, + String claimGate, + MemoizedContentHash contentHash, + boolean success) { + ctx.settle(identity, claimGate, claimedHash(file, claimGate, contentHash), success); + if (!success) { + return; + } try { - Path processingDir = workDir(inputDir, PROCESSING_SUBDIR); - Files.createDirectories(processingDir); - Path claimed = uniqueTarget(processingDir, file.getFileName().toString()); - Files.move(file, claimed, StandardCopyOption.ATOMIC_MOVE); - return claimed; + if (FolderIdentities.statGate(file).equals(claimGate) && ctx.allSettledDone(identity)) { + Files.deleteIfExists(file); + } + } catch (NoSuchFileException alreadyGone) { + // Removed by the user or a co-watching policy's own consensus delete: nothing to do. } catch (IOException e) { - log.debug("Could not claim {}: {}", file, e.getMessage()); + log.warn("Could not remove consumed input {}: {}", file, e.getMessage()); + } + } + + /** + * The claimed version's content hash: the value computed during the claim when the ledger + * consulted the verifier, else computed now while the file is still at the claimed gate (so the + * hash describes what actually ran), else null. Always null in stat mode. + */ + private static String claimedHash(Path file, String claimGate, MemoizedContentHash hash) { + if (hash == null) { return null; } + String computed = hash.valueIfComputed(); + if (computed != null) { + return computed; + } + try { + if (FolderIdentities.statGate(file).equals(claimGate)) { + return hash.get(); + } + } catch (IOException | UncheckedIOException e) { + log.debug("Could not hash {} at settle: {}", file, e.getMessage()); + } + return null; } - private void route(Path inputDir, Path claimed, boolean success) { - String subdir = success ? DONE_SUBDIR : ERROR_SUBDIR; - try { - Path destDir = workDir(inputDir, subdir); - Files.createDirectories(destDir); - Files.move( - claimed, - uniqueTarget(destDir, claimed.getFileName().toString()), - StandardCopyOption.ATOMIC_MOVE); - } catch (IOException e) { - log.warn( - "Could not move processed input {} to {}: {}", claimed, subdir, e.getMessage()); + /** Lazy verification tier: invoked at most once by the ledger, retained for the settle. */ + private static final class MemoizedContentHash implements Supplier { + + private final Path file; + private volatile String value; + + private MemoizedContentHash(Path file) { + this.file = file; + } + + @Override + public String get() { + if (value == null) { + try { + value = FolderIdentities.contentHash(file); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + return value; + } + + String valueIfComputed() { + return value; } } - private static Path workDir(Path inputDir, String subdir) { - return inputDir.resolve(WORK_SUBDIR).resolve(subdir); + /** Every non-hidden regular file in the source, readable or not. */ + private static List listFiles(Path inputDir, boolean recursive) throws IOException { + List files = new ArrayList<>(); + if (!recursive) { + try (Stream entries = Files.list(inputDir)) { + entries.filter(Files::isRegularFile) + .filter(file -> !hidden(file)) + .forEach(files::add); + } + return files; + } + // Hidden subtrees are pruned wholesale; symlinked directories are not followed. + Files.walkFileTree( + inputDir, + new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory( + Path dir, BasicFileAttributes attributes) { + if (!dir.equals(inputDir) && hidden(dir)) { + return FileVisitResult.SKIP_SUBTREE; + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) { + if (attributes.isRegularFile() && !hidden(file)) { + files.add(file); + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException e) { + log.debug("Skipping unreadable entry {}: {}", file, e.getMessage()); + return FileVisitResult.CONTINUE; + } + }); + return files; + } + + private static boolean hidden(Path path) { + Path name = path.getFileName(); + if (name != null && name.toString().startsWith(".")) { + return true; + } + try { + return Files.isHidden(path); + } catch (IOException e) { + return false; + } } private static Resource fileResource(Path path) { @@ -144,27 +272,15 @@ public class FolderInputSource implements InputSource { }; } - private static Path uniqueTarget(Path dir, String filename) { - Path candidate = dir.resolve(filename); - if (!Files.exists(candidate)) { - return candidate; - } - int dot = filename.lastIndexOf('.'); - String base = dot < 0 ? filename : filename.substring(0, dot); - String ext = dot < 0 ? "" : filename.substring(dot); - for (int n = 1; ; n++) { - Path next = dir.resolve(base + " (" + n + ")" + ext); - if (!Files.exists(next)) { - return next; - } - } - } - - record FolderConfig(Path directory, boolean snapshot) { + record FolderConfig(Path directory, boolean snapshot, boolean recursive, boolean hashIdentity) { private static final String DIRECTORY_OPTION = "directory"; private static final String MODE_OPTION = "mode"; private static final String MODE_SNAPSHOT = "snapshot"; + private static final String RECURSIVE_OPTION = "recursive"; + private static final String IDENTITY_OPTION = "identity"; + private static final String IDENTITY_STAT = "stat"; + private static final String IDENTITY_HASH = "hash"; static FolderConfig from(Map options) { Object directory = options.get(DIRECTORY_OPTION); @@ -173,7 +289,17 @@ public class FolderInputSource implements InputSource { } Object mode = options.get(MODE_OPTION); boolean snapshot = mode != null && MODE_SNAPSHOT.equals(mode.toString()); - return new FolderConfig(Path.of(directory.toString()), snapshot); + Object recursive = options.get(RECURSIVE_OPTION); + boolean recurse = recursive != null && Boolean.parseBoolean(recursive.toString()); + Object identity = options.get(IDENTITY_OPTION); + boolean hash = identity != null && IDENTITY_HASH.equals(identity.toString()); + if (identity != null + && !IDENTITY_STAT.equals(identity.toString()) + && !IDENTITY_HASH.equals(identity.toString())) { + throw new IllegalArgumentException( + "folder input 'identity' must be 'stat' or 'hash'"); + } + return new FolderConfig(Path.of(directory.toString()), snapshot, recurse, hash); } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java index 436f6c5263..d32c2fc546 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java @@ -24,9 +24,21 @@ public interface InputSource { /** * 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. + * completion hook. Empty list means nothing to run right now. Discovery is read-only - files + * stay where the user put them; "already processed" is tracked through {@code ctx} (claim on + * pickup, settle on completion, report what is present so stale ledger rows can be pruned). */ - List resolve(InputSpec spec) throws IOException; + List resolve(InputSpec spec, ResolveContext ctx) throws IOException; + + /** + * Whether {@link #resolve} observes everything in the source (a complete listing) rather than + * e.g. only what events surfaced. Presence cleanup of the ledger is skipped for the whole + * policy unless every enabled source says true - wrongly pruning history would reprocess a + * whole folder, while keeping a few stale rows costs nothing. + */ + default boolean listsExhaustively() { + return true; + } /** * Filesystem dirs this source draws from, for the folder-watch trigger. Advisory: resolving is diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/ResolveContext.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/ResolveContext.java new file mode 100644 index 0000000000..9282b44d5a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/ResolveContext.java @@ -0,0 +1,36 @@ +package stirling.software.proprietary.policy.input; + +import java.util.Collection; +import java.util.function.Supplier; + +/** + * A source's policy-scoped window onto the processed-file ledger for one sweep. Thread-safe and + * valid for the lifetime of the work units the source issued ({@link #settle} fires from async run + * completions). + */ +public interface ResolveContext { + + /** + * Atomically claim a file at its current version; true means this sweep runs it. A null {@code + * contentHash} makes any gate change a new version; a non-null supplier is invoked at most + * once, only on a gate mismatch, and a matching hash refreshes the stored gate instead of + * reprocessing. Supplier exceptions propagate. + */ + boolean claim(String identity, String gate, Supplier contentHash); + + /** Record a claimed file's outcome at its final version ({@code finalContentHash} nullable). */ + void settle(String identity, String finalGate, String finalContentHash, boolean success); + + /** + * Whether every policy holding a ledger row for this identity has settled it DONE. Cross-policy + * by design: consume-mode deletion is a consensus of all claimants, so a shared input is + * removed only once nobody still needs it (in-flight, failed, and interrupted rows all veto). + */ + boolean allSettledDone(String identity); + + /** + * Report every identity present right now, readable or not; feeds presence cleanup of rows + * whose file is gone. + */ + void reportPresent(Collection identities); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ClaimState.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ClaimState.java new file mode 100644 index 0000000000..4f3dc0312f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ClaimState.java @@ -0,0 +1,9 @@ +package stirling.software.proprietary.policy.ledger; + +/** + * A row's claim-relevant state as read by {@link ProcessedLedger#statesFor}: what a sweep observed + * before deciding a claim. May be stale by the time the claim runs; every ledger transition + * re-checks the observed state in its WHERE clause, so staleness defers a claim to a later sweep + * rather than double-running one. + */ +public record ClaimState(ProcessedFileStatus status, String gate, String contentHash) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/FolderIdentities.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/FolderIdentities.java new file mode 100644 index 0000000000..8ece937147 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/FolderIdentities.java @@ -0,0 +1,41 @@ +package stirling.software.proprietary.policy.ledger; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; + +import stirling.software.proprietary.billing.ContentHasher; + +/** + * The folder backend's identity and version scheme, shared by {@code FolderInputSource} and {@code + * FolderOutputSink} so outputs are recorded under exactly the identity the next scan derives. + * Directories are canonicalised with {@code toRealPath()} so symlinked aliases agree. + */ +public final class FolderIdentities { + + private FolderIdentities() {} + + /** Canonical form of a configured directory; resolves symlinks, so the dir must exist. */ + public static Path canonicalDir(Path dir) throws IOException { + return dir.toRealPath(); + } + + /** Identity of {@code file} under {@code dir}: its path re-rooted onto the canonical dir. */ + public static String identity(Path canonicalDir, Path dir, Path file) { + return canonicalDir.resolve(dir.relativize(file)).normalize().toString(); + } + + /** The cheap version gate: a change to content length or mtime means "look closer". */ + public static String statGate(Path file) throws IOException { + BasicFileAttributes attributes = Files.readAttributes(file, BasicFileAttributes.class); + return attributes.size() + ":" + attributes.lastModifiedTime().toMillis(); + } + + /** + * The strong version token: distinguishes a real change from a touch, at the cost of a read. + */ + public static String contentHash(Path file) throws IOException { + return ContentHasher.sha256(file); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/IdentityHasher.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/IdentityHasher.java new file mode 100644 index 0000000000..32c30c0254 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/IdentityHasher.java @@ -0,0 +1,19 @@ +package stirling.software.proprietary.policy.ledger; + +import java.nio.charset.StandardCharsets; + +import stirling.software.proprietary.billing.ContentHasher; + +/** + * Fixed-width key form of a source-owned identity, so any identity length fits the ledger's primary + * key. Backend-agnostic: every source type's identities are keyed through here, which is why this + * does not live with the folder backend's {@link FolderIdentities}. + */ +public final class IdentityHasher { + + private IdentityHasher() {} + + public static String identityHash(String identity) { + return ContentHasher.sha256(identity.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/InProcessProcessedLedger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/InProcessProcessedLedger.java new file mode 100644 index 0000000000..436e62f2c8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/InProcessProcessedLedger.java @@ -0,0 +1,229 @@ +package stirling.software.proprietary.policy.ledger; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; + +/** + * In-memory {@link ProcessedLedger} for tests and DB-less wiring; kept semantically identical to + * {@code JpaProcessedLedger} by the shared contract test. + */ +public class InProcessProcessedLedger implements ProcessedLedger { + + private final Map> rowsByPolicy = new HashMap<>(); + private final Supplier nowMillis; + + public InProcessProcessedLedger() { + this(System::currentTimeMillis); + } + + public InProcessProcessedLedger(Supplier nowMillis) { + this.nowMillis = nowMillis; + } + + @Override + public synchronized Map statesFor( + String policyId, Collection identities) { + Map rows = rowsByPolicy.getOrDefault(policyId, Map.of()); + Map states = new HashMap<>(); + for (String identity : identities) { + Row row = rows.get(identity); + if (row != null) { + states.put(identity, new ClaimState(row.status, row.gate, row.contentHash)); + } + } + return states; + } + + // Single-lock store: the live row is never staler than any observed snapshot, so decide + // against it directly; the conditional updates of the JPA ledger yield the same outcomes. + @Override + public synchronized boolean claim( + String policyId, + String identity, + String gate, + Supplier contentHash, + ClaimState observed) { + Map rows = rowsByPolicy.computeIfAbsent(policyId, key -> new HashMap<>()); + long now = nowMillis.get(); + Row row = rows.get(identity); + if (row == null) { + String hash = contentHash == null ? null : contentHash.get(); + rows.put(identity, new Row(gate, hash, ProcessedFileStatus.PROCESSING, 1, now)); + return true; + } + if (row.status == ProcessedFileStatus.PROCESSING) { + return false; + } + if (gate.equals(row.gate)) { + if (row.status == ProcessedFileStatus.INTERRUPTED && row.attempts < MAX_ATTEMPTS) { + row.status = ProcessedFileStatus.PROCESSING; + row.attempts++; + row.lastSeen = now; + return true; + } + return false; + } + if (contentHash == null) { + row.gate = gate; + row.contentHash = null; + row.status = ProcessedFileStatus.PROCESSING; + row.attempts = 1; + row.lastSeen = now; + return true; + } + String hash = contentHash.get(); + if (Objects.equals(hash, row.contentHash)) { + if (row.status == ProcessedFileStatus.INTERRUPTED && row.attempts < MAX_ATTEMPTS) { + row.gate = gate; + row.status = ProcessedFileStatus.PROCESSING; + row.attempts++; + row.lastSeen = now; + return true; + } + if (row.status != ProcessedFileStatus.INTERRUPTED) { + row.gate = gate; + row.lastSeen = now; + } + return false; + } + row.gate = gate; + row.contentHash = hash; + row.status = ProcessedFileStatus.PROCESSING; + row.attempts = 1; + row.lastSeen = now; + return true; + } + + @Override + public synchronized void settle( + String policyId, + String identity, + String finalGate, + String finalContentHash, + boolean success) { + upsertSettled( + policyId, + identity, + finalGate, + finalContentHash, + success ? ProcessedFileStatus.DONE : ProcessedFileStatus.ERROR); + } + + @Override + public synchronized void recordOutput( + String policyId, String identity, String gate, String contentHash) { + upsertSettled(policyId, identity, gate, contentHash, ProcessedFileStatus.DONE); + } + + @Override + public synchronized void forgetOutput(String policyId, String identity, String gate) { + Map rows = rowsByPolicy.get(policyId); + if (rows == null) { + return; + } + Row row = rows.get(identity); + if (row != null && row.status == ProcessedFileStatus.DONE && gate.equals(row.gate)) { + rows.remove(identity); + } + } + + private void upsertSettled( + String policyId, + String identity, + String gate, + String contentHash, + ProcessedFileStatus status) { + Map rows = rowsByPolicy.computeIfAbsent(policyId, key -> new HashMap<>()); + long now = nowMillis.get(); + Row row = rows.get(identity); + if (row == null) { + rows.put(identity, new Row(gate, contentHash, status, 1, now)); + return; + } + row.gate = gate; + row.contentHash = contentHash; + row.status = status; + row.lastSeen = now; + } + + @Override + public synchronized boolean allSettledDone(String identity) { + for (Map rows : rowsByPolicy.values()) { + Row row = rows.get(identity); + if (row != null && row.status != ProcessedFileStatus.DONE) { + return false; + } + } + return true; + } + + @Override + public synchronized void markSeen(String policyId, Collection identities) { + Map rows = rowsByPolicy.get(policyId); + if (rows == null) { + return; + } + long now = nowMillis.get(); + for (String identity : identities) { + Row row = rows.get(identity); + if (row != null) { + row.lastSeen = now; + } + } + } + + @Override + public synchronized int deleteUnseen(String policyId, long seenSinceMillis) { + Map rows = rowsByPolicy.get(policyId); + if (rows == null) { + return 0; + } + int before = rows.size(); + rows.values() + .removeIf( + row -> + row.lastSeen < seenSinceMillis + && row.status != ProcessedFileStatus.PROCESSING); + return before - rows.size(); + } + + @Override + public synchronized void clearPolicy(String policyId) { + rowsByPolicy.remove(policyId); + } + + @Override + public synchronized void recoverInterrupted() { + for (Map rows : rowsByPolicy.values()) { + for (Row row : rows.values()) { + if (row.status == ProcessedFileStatus.PROCESSING) { + row.status = ProcessedFileStatus.INTERRUPTED; + } + } + } + } + + private static final class Row { + private String gate; + private String contentHash; + private ProcessedFileStatus status; + private int attempts; + private long lastSeen; + + private Row( + String gate, + String contentHash, + ProcessedFileStatus status, + int attempts, + long lastSeen) { + this.gate = gate; + this.contentHash = contentHash; + this.status = status; + this.attempts = attempts; + this.lastSeen = lastSeen; + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java new file mode 100644 index 0000000000..43f0e6795d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java @@ -0,0 +1,212 @@ +package stirling.software.proprietary.policy.ledger; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +/** + * Durable {@link ProcessedLedger}; the runtime bean. A fresh claim is a flushed insert so a + * concurrent winner surfaces as a constraint violation; every other transition is a conditional + * update that re-checks the observed state, so a lost race reports 0 rows and the caller skips. + * Boot recovery assumes the single node the folder-watch trigger assumes: runs live in memory, so + * after a restart every PROCESSING row is stale. + */ +@Slf4j +@Service +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class JpaProcessedLedger implements ProcessedLedger { + + private static final int STAMP_CHUNK = 500; + + private final ProcessedFileRepository repository; + private final Supplier nowMillis; + + @Autowired + public JpaProcessedLedger(ProcessedFileRepository repository) { + this(repository, System::currentTimeMillis); + } + + // Clock seam so tests can pin "now"; the runtime bean uses the wall clock above. + JpaProcessedLedger(ProcessedFileRepository repository, Supplier nowMillis) { + this.repository = repository; + this.nowMillis = nowMillis; + } + + @Override + public Map statesFor(String policyId, Collection identities) { + if (identities.isEmpty()) { + return Map.of(); + } + Map identityByHash = new HashMap<>(); + for (String identity : identities) { + identityByHash.put(IdentityHasher.identityHash(identity), identity); + } + Map states = new HashMap<>(); + List hashes = List.copyOf(identityByHash.keySet()); + for (int from = 0; from < hashes.size(); from += STAMP_CHUNK) { + List rows = + repository.findByPolicyIdAndIdentityHashIn( + policyId, + hashes.subList(from, Math.min(from + STAMP_CHUNK, hashes.size()))); + for (ProcessedFileEntity row : rows) { + states.put( + identityByHash.get(row.getIdentityHash()), + new ClaimState(row.getStatus(), row.getSignature(), row.getContentHash())); + } + } + return states; + } + + @Override + public boolean claim( + String policyId, + String identity, + String gate, + Supplier contentHash, + ClaimState observed) { + String identityHash = IdentityHasher.identityHash(identity); + long now = nowMillis.get(); + if (observed == null) { + try { + repository.saveAndFlush( + new ProcessedFileEntity( + policyId, + identityHash, + identity, + gate, + contentHash == null ? null : contentHash.get(), + ProcessedFileStatus.PROCESSING, + now)); + return true; + } catch (DataIntegrityViolationException concurrentClaim) { + return false; + } + } + if (observed.status() == ProcessedFileStatus.PROCESSING) { + return false; + } + if (gate.equals(observed.gate())) { + if (observed.status() == ProcessedFileStatus.INTERRUPTED) { + return repository.retryInterruptedAtGate( + policyId, identityHash, gate, MAX_ATTEMPTS, now) + > 0; + } + return false; + } + if (contentHash == null) { + return repository.reclaimAtNewGate(policyId, identityHash, gate, now) > 0; + } + String hash = contentHash.get(); + if (hash.equals(observed.contentHash())) { + if (observed.status() == ProcessedFileStatus.INTERRUPTED) { + return repository.retryInterruptedSameContent( + policyId, identityHash, gate, hash, MAX_ATTEMPTS, now) + > 0; + } + repository.refreshGate(policyId, identityHash, gate, hash, now); + return false; + } + return repository.reclaimAtNewContent(policyId, identityHash, gate, hash, now) > 0; + } + + @Override + public void settle( + String policyId, + String identity, + String finalGate, + String finalContentHash, + boolean success) { + upsertSettled( + policyId, + identity, + finalGate, + finalContentHash, + success ? ProcessedFileStatus.DONE : ProcessedFileStatus.ERROR); + } + + @Override + public void recordOutput(String policyId, String identity, String gate, String contentHash) { + upsertSettled(policyId, identity, gate, contentHash, ProcessedFileStatus.DONE); + } + + @Override + public void forgetOutput(String policyId, String identity, String gate) { + repository.deleteDoneAt(policyId, IdentityHasher.identityHash(identity), gate); + } + + /** + * Settle-or-insert: the row may have been presence-cleaned mid-run, and an output row may be + * brand new. + */ + private void upsertSettled( + String policyId, + String identity, + String gate, + String contentHash, + ProcessedFileStatus status) { + String identityHash = IdentityHasher.identityHash(identity); + long now = nowMillis.get(); + if (repository.settle(policyId, identityHash, gate, contentHash, status, now) > 0) { + return; + } + try { + ProcessedFileEntity row = + new ProcessedFileEntity( + policyId, identityHash, identity, gate, contentHash, status, now); + repository.saveAndFlush(row); + } catch (DataIntegrityViolationException concurrentInsert) { + repository.settle(policyId, identityHash, gate, contentHash, status, now); + } + } + + @Override + public boolean allSettledDone(String identity) { + return !repository.existsByIdentityHashAndStatusNot( + IdentityHasher.identityHash(identity), ProcessedFileStatus.DONE); + } + + @Override + public void markSeen(String policyId, Collection identities) { + if (identities.isEmpty()) { + return; + } + List hashes = identities.stream().map(IdentityHasher::identityHash).toList(); + long now = nowMillis.get(); + for (int from = 0; from < hashes.size(); from += STAMP_CHUNK) { + repository.stampSeen( + policyId, + hashes.subList(from, Math.min(from + STAMP_CHUNK, hashes.size())), + now); + } + } + + @Override + public int deleteUnseen(String policyId, long seenSinceMillis) { + return repository.deleteUnseen(policyId, seenSinceMillis); + } + + @Override + public void clearPolicy(String policyId) { + repository.deleteByPolicy(policyId); + } + + @Override + @EventListener(ApplicationReadyEvent.class) + public void recoverInterrupted() { + int recovered = repository.markAllProcessingInterrupted(nowMillis.get()); + if (recovered > 0) { + log.info("Recovered {} policy input file(s) interrupted by shutdown", recovered); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileEntity.java new file mode 100644 index 0000000000..2e98025aff --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileEntity.java @@ -0,0 +1,106 @@ +package stirling.software.proprietary.policy.ledger; + +import java.io.Serializable; + +import org.springframework.data.domain.Persistable; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * One processed-file ledger row: the version a policy last settled a file at, and where it is in + * the claim lifecycle. Keyed by SHA-256 of the source-owned identity so any identity length fits a + * fixed-width index. {@code isNew} is always true: the entity is only saved for fresh inserts + * (everything else is a conditional update), so a lost insert race surfaces as a constraint + * violation rather than a silent merge. + */ +@Entity +@Table( + name = "policy_processed_files", + indexes = { + // presence cleanup: delete this policy's rows unseen since the sweep began + @Index(name = "idx_processed_files_policy_seen", columnList = "policy_id, last_seen"), + // cross-policy deletion consensus: existsByIdentityHashAndStatusNot filters + // identity_hash on its own, so it cannot ride the (policy_id, identity_hash) PK + @Index(name = "idx_processed_files_identity", columnList = "identity_hash") + }) +@IdClass(ProcessedFileId.class) +@NoArgsConstructor +@Getter +@Setter +public class ProcessedFileEntity implements Serializable, Persistable { + + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "policy_id") + private String policyId; + + @Id + @Column(name = "identity_hash", length = 64) + private String identityHash; + + @Column(name = "identity", length = 4096) + private String identity; + + @Column(name = "signature") + private String signature; + + @Column(name = "content_hash", length = 64) + private String contentHash; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 16) + private ProcessedFileStatus status; + + @Column(name = "attempts") + private int attempts; + + @Column(name = "last_seen") + private long lastSeen; + + @Column(name = "updated_at") + private long updatedAt; + + public ProcessedFileEntity( + String policyId, + String identityHash, + String identity, + String signature, + String contentHash, + ProcessedFileStatus status, + long nowMillis) { + this.policyId = policyId; + this.identityHash = identityHash; + this.identity = identity; + this.signature = signature; + this.contentHash = contentHash; + this.status = status; + this.attempts = 1; + this.lastSeen = nowMillis; + this.updatedAt = nowMillis; + } + + @Override + @Transient + public ProcessedFileId getId() { + return new ProcessedFileId(policyId, identityHash); + } + + @Override + @Transient + public boolean isNew() { + return true; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileId.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileId.java new file mode 100644 index 0000000000..fd3d554022 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileId.java @@ -0,0 +1,37 @@ +package stirling.software.proprietary.policy.ledger; + +import java.io.Serializable; +import java.util.Objects; + +/** Composite key for {@link ProcessedFileEntity}: one row per policy per file identity. */ +public class ProcessedFileId implements Serializable { + + private static final long serialVersionUID = 1L; + + private String policyId; + private String identityHash; + + public ProcessedFileId() {} + + public ProcessedFileId(String policyId, String identityHash) { + this.policyId = policyId; + this.identityHash = identityHash; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ProcessedFileId other)) { + return false; + } + return Objects.equals(policyId, other.policyId) + && Objects.equals(identityHash, other.identityHash); + } + + @Override + public int hashCode() { + return Objects.hash(policyId, identityHash); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileRepository.java new file mode 100644 index 0000000000..def89821ae --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileRepository.java @@ -0,0 +1,195 @@ +package stirling.software.proprietary.policy.ledger; + +import java.util.Collection; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +/** + * Conditional updates for the processed-file ledger: each claim variant re-checks in its WHERE + * clause the state it was decided against, so a racing claim loses cleanly with 0 rows updated. + * Transactional per call so the ledger can run them without an enclosing transaction. + */ +@Repository +public interface ProcessedFileRepository + extends JpaRepository { + + /** + * Re-claim a settled row at a new gate without content verification; clears the stored hash, + * which described content this claim never checked. + */ + @Modifying + @Transactional + @Query( + "update ProcessedFileEntity e set e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING," + + " e.signature = :gate, e.contentHash = null, e.attempts = 1," + + " e.lastSeen = :now, e.updatedAt = :now" + + " where e.policyId = :policyId and e.identityHash = :identityHash" + + " and e.status <>" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING" + + " and e.signature <> :gate") + int reclaimAtNewGate( + @Param("policyId") String policyId, + @Param("identityHash") String identityHash, + @Param("gate") String gate, + @Param("now") long now); + + /** Re-claim a settled row whose content verifiably changed (or was never hashed). */ + @Modifying + @Transactional + @Query( + "update ProcessedFileEntity e set e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING," + + " e.signature = :gate, e.contentHash = :contentHash, e.attempts = 1," + + " e.lastSeen = :now, e.updatedAt = :now" + + " where e.policyId = :policyId and e.identityHash = :identityHash" + + " and e.status <>" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING" + + " and (e.contentHash is null or e.contentHash <> :contentHash)") + int reclaimAtNewContent( + @Param("policyId") String policyId, + @Param("identityHash") String identityHash, + @Param("gate") String gate, + @Param("contentHash") String contentHash, + @Param("now") long now); + + /** The gate moved but the content did not: track the new gate without changing status. */ + @Modifying + @Transactional + @Query( + "update ProcessedFileEntity e set e.signature = :gate, e.lastSeen = :now," + + " e.updatedAt = :now" + + " where e.policyId = :policyId and e.identityHash = :identityHash" + + " and e.status <>" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING" + + " and e.contentHash = :contentHash and e.signature <> :gate") + int refreshGate( + @Param("policyId") String policyId, + @Param("identityHash") String identityHash, + @Param("gate") String gate, + @Param("contentHash") String contentHash, + @Param("now") long now); + + /** Bounded retry of an INTERRUPTED row at the same gate. */ + @Modifying + @Transactional + @Query( + "update ProcessedFileEntity e set e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING," + + " e.attempts = e.attempts + 1, e.lastSeen = :now, e.updatedAt = :now" + + " where e.policyId = :policyId and e.identityHash = :identityHash" + + " and e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.INTERRUPTED" + + " and e.signature = :gate and e.attempts < :maxAttempts") + int retryInterruptedAtGate( + @Param("policyId") String policyId, + @Param("identityHash") String identityHash, + @Param("gate") String gate, + @Param("maxAttempts") int maxAttempts, + @Param("now") long now); + + /** Bounded retry of an INTERRUPTED row whose gate moved but whose content is unchanged. */ + @Modifying + @Transactional + @Query( + "update ProcessedFileEntity e set e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING," + + " e.signature = :gate, e.attempts = e.attempts + 1, e.lastSeen = :now," + + " e.updatedAt = :now" + + " where e.policyId = :policyId and e.identityHash = :identityHash" + + " and e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.INTERRUPTED" + + " and e.contentHash = :contentHash and e.attempts < :maxAttempts") + int retryInterruptedSameContent( + @Param("policyId") String policyId, + @Param("identityHash") String identityHash, + @Param("gate") String gate, + @Param("contentHash") String contentHash, + @Param("maxAttempts") int maxAttempts, + @Param("now") long now); + + /** + * Unconditional settle (only the claiming run settles a row); returns 0 when the row was + * removed mid-run so the caller re-inserts. + */ + @Modifying + @Transactional + @Query( + "update ProcessedFileEntity e set e.status = :status, e.signature = :gate," + + " e.contentHash = :contentHash, e.lastSeen = :now, e.updatedAt = :now" + + " where e.policyId = :policyId and e.identityHash = :identityHash") + int settle( + @Param("policyId") String policyId, + @Param("identityHash") String identityHash, + @Param("gate") String gate, + @Param("contentHash") String contentHash, + @Param("status") ProcessedFileStatus status, + @Param("now") long now); + + /** Whether any policy's row at this identity is in a state other than {@code status}. */ + boolean existsByIdentityHashAndStatusNot(String identityHash, ProcessedFileStatus status); + + /** One policy's rows across a chunk of identity hashes, for a sweep's claim snapshot. */ + List findByPolicyIdAndIdentityHashIn( + String policyId, Collection identityHashes); + + /** + * Remove an output record whose rename never landed, only while still settled exactly as + * recorded; a row a claim has since taken over is left alone. + */ + @Modifying + @Transactional + @Query( + "delete from ProcessedFileEntity e where e.policyId = :policyId" + + " and e.identityHash = :identityHash and e.signature = :gate" + + " and e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.DONE") + int deleteDoneAt( + @Param("policyId") String policyId, + @Param("identityHash") String identityHash, + @Param("gate") String gate); + + /** Stamp presence for the given identities; chunked by the caller for very large folders. */ + @Modifying + @Transactional + @Query( + "update ProcessedFileEntity e set e.lastSeen = :now" + + " where e.policyId = :policyId and e.identityHash in :identityHashes") + int stampSeen( + @Param("policyId") String policyId, + @Param("identityHashes") Collection identityHashes, + @Param("now") long now); + + /** + * Presence cleanup: remove rows not stamped since the sweep began, keeping in-flight claims. + */ + @Modifying + @Transactional + @Query( + "delete from ProcessedFileEntity e where e.policyId = :policyId" + + " and e.lastSeen < :cutoff and e.status <>" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING") + int deleteUnseen(@Param("policyId") String policyId, @Param("cutoff") long cutoff); + + @Modifying + @Transactional + @Query("delete from ProcessedFileEntity e where e.policyId = :policyId") + int deleteByPolicy(@Param("policyId") String policyId); + + /** Boot recovery: after a restart every PROCESSING row is stale (single node). */ + @Modifying + @Transactional + @Query( + "update ProcessedFileEntity e set e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.INTERRUPTED," + + " e.updatedAt = :now" + + " where e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING") + int markAllProcessingInterrupted(@Param("now") long now); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileStatus.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileStatus.java new file mode 100644 index 0000000000..68b8b5d00c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileStatus.java @@ -0,0 +1,17 @@ +package stirling.software.proprietary.policy.ledger; + +/** Lifecycle of one {@code (policy, file)} ledger row. */ +public enum ProcessedFileStatus { + + /** Claimed; a run is in flight. */ + PROCESSING, + + /** Run completed at this version. */ + DONE, + + /** Run failed; skipped until the file changes (clear-history is the manual retry). */ + ERROR, + + /** Was PROCESSING when the JVM died; retried a bounded number of times. */ + INTERRUPTED +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedLedger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedLedger.java new file mode 100644 index 0000000000..848a764f60 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedLedger.java @@ -0,0 +1,100 @@ +package stirling.software.proprietary.policy.ledger; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +/** + * Remembers which files a policy has processed, one row per {@code (policy, identity)}, so sources + * track files in place. Identities are opaque source-owned strings; versions are two-tier: a cheap + * gate compared every sweep plus an optional content hash consulted only when the gate moves. + * Presence reconciliation ({@link #markSeen} + {@link #deleteUnseen}) keeps the table bounded. + */ +public interface ProcessedLedger { + + /** + * Claims of one version before an {@link ProcessedFileStatus#INTERRUPTED} row stops retrying. + */ + int MAX_ATTEMPTS = 3; + + /** + * One-query snapshot of the rows for these identities, keyed by identity; identities with no + * row are absent. Feeds the {@code observed} parameter of {@link #claim(String, String, String, + * Supplier, ClaimState)} so a sweep decides its claims without a per-file lookup. + */ + Map statesFor(String policyId, Collection identities); + + /** + * Atomically claim a file at its current version, deciding against {@code observed} (this row's + * entry from {@link #statesFor}; null means no row was seen); true means this caller runs it. A + * stale {@code observed} cannot double-claim - every transition re-checks the observed state, + * so a lost race skips until a later sweep. A null {@code contentHash} makes any gate change a + * new version; a non-null supplier is invoked at most once, only on a gate mismatch, and a + * matching hash refreshes the stored gate instead of reprocessing. Supplier exceptions + * propagate. + */ + boolean claim( + String policyId, + String identity, + String gate, + Supplier contentHash, + ClaimState observed); + + /** Snapshot-then-claim convenience for a single file; sweeps batch via {@link #statesFor}. */ + default boolean claim( + String policyId, String identity, String gate, Supplier contentHash) { + return claim( + policyId, + identity, + gate, + contentHash, + statesFor(policyId, List.of(identity)).get(identity)); + } + + /** Record a claimed file's outcome at its final version ({@code finalContentHash} nullable). */ + void settle( + String policyId, + String identity, + String finalGate, + String finalContentHash, + boolean success); + + /** + * Record a produced file as {@link ProcessedFileStatus#DONE} so the policy skips its own + * outputs. Must be called before the file is visible at this identity; other policies have no + * row and still process it. + */ + void recordOutput(String policyId, String identity, String gate, String contentHash); + + /** + * Remove an output record whose file never became visible (its rename lost the name race to a + * concurrent writer), so whatever file actually owns that identity is claimable at any version. + * A no-op unless the row is still settled exactly as recorded, so a claim that took the row + * over in the meantime is left alone. + */ + void forgetOutput(String policyId, String identity, String gate); + + /** + * Whether every row at this identity - across all policies, by design - is {@link + * ProcessedFileStatus#DONE}. Consume-mode deletion gates on this so a shared input is removed + * only once every claimant has processed it; in-flight, failed, and interrupted rows all veto, + * parking the file. Vacuously true when no rows exist. + */ + boolean allSettledDone(String identity); + + /** Stamp presence for every identity a full-listing sweep observed. */ + void markSeen(String policyId, Collection identities); + + /** + * Remove rows not seen since {@code seenSinceMillis}, keeping in-flight claims. Only call after + * every enabled source listed completely; returns the number of rows removed. + */ + int deleteUnseen(String policyId, long seenSinceMillis); + + /** Forget everything for a policy. */ + void clearPolicy(String policyId); + + /** Boot recovery: flip stale in-flight claims to {@link ProcessedFileStatus#INTERRUPTED}. */ + void recoverInterrupted(); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java index 8821a2940b..ed3cf707ad 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java @@ -2,11 +2,18 @@ package stirling.software.proprietary.policy.output; import java.io.IOException; import java.io.InputStream; +import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.DigestOutputStream; +import java.security.MessageDigest; +import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.stream.Stream; import org.apache.commons.io.FilenameUtils; import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; @@ -19,14 +26,18 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.job.ResultFile; +import stirling.software.proprietary.billing.ContentHasher; import stirling.software.proprietary.policy.config.FolderAccessGuard; +import stirling.software.proprietary.policy.ledger.FolderIdentities; +import stirling.software.proprietary.policy.ledger.ProcessedLedger; 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 outputs to the {@code directory} given in the {@link OutputSpec}. Each output is + * staged under a hidden {@code .stirling/tmp} dir, recorded in the processed-file ledger, then + * atomically renamed into place, so the producing policy's row exists before the file is + * discoverable and half-written outputs are never visible. Returned {@link ResultFile}s carry a + * synthetic id since the deliverable is the file on disk, not a {@code FileStorage} entry. */ @Slf4j @Service @@ -37,7 +48,11 @@ public class FolderOutputSink implements PolicyOutputSink { static final String TYPE = FolderAccessGuard.FOLDER_TYPE; static final String DIRECTORY_OPTION = "directory"; + // Staging entries are renamed away within one delivery; anything older is a crash leftover. + private static final Duration STALE_TMP_AGE = Duration.ofDays(1); + private final FolderAccessGuard accessGuard; + private final ProcessedLedger processedLedger; @Override public String type() { @@ -55,20 +70,25 @@ public class FolderOutputSink implements PolicyOutputSink { } @Override - public List deliver(String runId, List outputs, OutputSpec spec) - throws IOException { + public List deliver( + OutputDelivery delivery, List outputs, OutputSpec spec) throws IOException { Path targetDir = accessGuard.requirePermitted(directoryOf(spec)); Files.createDirectories(targetDir); + Path canonicalDir = FolderIdentities.canonicalDir(targetDir); + Path tmpDir = canonicalDir.resolve(".stirling").resolve("tmp"); + Files.createDirectories(tmpDir); + sweepStaleTmp(tmpDir); List results = new ArrayList<>(); for (int i = 0; i < outputs.size(); i++) { Resource resource = outputs.get(i); String name = safeName(resource.getFilename(), i); - Path target = uniqueTarget(targetDir, name); - try (InputStream is = resource.getInputStream()) { - Files.copy(is, target); - } - long size = Files.size(target); + Path staged = tmpDir.resolve(UUID.randomUUID().toString()); + String contentHash = stage(resource, staged, delivery.policyId() != null); + long size = Files.size(staged); + // Size and mtime survive the rename. + String gate = FolderIdentities.statGate(staged); + Path target = moveIntoPlace(delivery, canonicalDir, name, staged, gate, contentHash); String contentType = MediaTypeFactory.getMediaType(name) .orElse(MediaType.APPLICATION_OCTET_STREAM) @@ -80,11 +100,95 @@ public class FolderOutputSink implements PolicyOutputSink { .contentType(contentType) .fileSize(size) .build()); - log.debug("Wrote policy run {} output to {}", runId, target); + log.debug("Wrote policy run {} output to {}", delivery.runId(), target); } return results; } + /** + * Stream the output to its staging path. For a recorded delivery (stored policy) the content + * hash is digested in the same pass, so the ledger gets both version tiers without re-reading a + * possibly huge output; ad-hoc runs record nothing and skip the digest entirely. + */ + private static String stage(Resource resource, Path staged, boolean hashed) throws IOException { + if (!hashed) { + try (InputStream is = resource.getInputStream()) { + Files.copy(is, staged); + } + return null; + } + MessageDigest digest = ContentHasher.newSha256(); + try (InputStream is = resource.getInputStream(); + DigestOutputStream out = + new DigestOutputStream(Files.newOutputStream(staged), digest)) { + is.transferTo(out); + } + return ContentHasher.toHex(digest.digest()); + } + + /** + * The ledger row must exist before the file is visible at its final path, or a sweep could + * claim the producing policy's own output in the gap. Losing the chosen name to a concurrent + * writer forgets the just-recorded row - whatever file actually owns that name must stay + * claimable at any version - then re-picks. + */ + private Path moveIntoPlace( + OutputDelivery delivery, + Path dir, + String name, + Path staged, + String gate, + String contentHash) + throws IOException { + while (true) { + Path target = uniqueTarget(dir, name); + if (delivery.policyId() != null) { + processedLedger.recordOutput( + delivery.policyId(), target.toString(), gate, contentHash); + } + try { + Files.move(staged, target, StandardCopyOption.ATOMIC_MOVE); + return target; + } catch (FileAlreadyExistsException raced) { + if (delivery.policyId() != null) { + processedLedger.forgetOutput(delivery.policyId(), target.toString(), gate); + } + log.debug("Output name {} taken concurrently; re-picking", target); + } + } + } + + /** Best-effort removal of staging leftovers from crashed deliveries. */ + private static void sweepStaleTmp(Path tmpDir) { + Instant cutoff = Instant.now().minus(STALE_TMP_AGE); + try (Stream entries = Files.list(tmpDir)) { + entries.filter(Files::isRegularFile) + .filter( + entry -> { + try { + return Files.getLastModifiedTime(entry) + .toInstant() + .isBefore(cutoff); + } catch (IOException e) { + return false; + } + }) + .forEach( + entry -> { + try { + Files.deleteIfExists(entry); + } catch (IOException e) { + log.debug( + "Could not remove stale staging file {}: {}", + entry, + e.getMessage()); + } + }); + } catch (IOException e) { + log.debug("Could not sweep staging dir {}: {}", tmpDir, e.getMessage()); + } + } + private static Path directoryOf(OutputSpec spec) { Object directory = spec.options().get(DIRECTORY_OPTION); if (directory == null || directory.toString().isBlank()) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java index 0fcd7323ff..904eb20523 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java @@ -41,8 +41,8 @@ public class InlineOutputSink implements PolicyOutputSink { } @Override - public List deliver(String runId, List outputs, OutputSpec spec) - throws IOException { + public List deliver( + OutputDelivery delivery, List outputs, OutputSpec spec) throws IOException { List results = new ArrayList<>(); for (int i = 0; i < outputs.size(); i++) { Resource resource = outputs.get(i); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/OutputDelivery.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/OutputDelivery.java new file mode 100644 index 0000000000..ef3b59bebe --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/OutputDelivery.java @@ -0,0 +1,8 @@ +package stirling.software.proprietary.policy.output; + +/** + * Context for one run's output delivery. {@code policyId} is null for ad-hoc pipelines; when + * present, sinks record outputs in the processed-file ledger so the producing policy does not + * re-ingest them. + */ +public record OutputDelivery(String runId, String policyId) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputSink.java index 6e6c80ba4d..7a9744f9e7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputSink.java @@ -25,6 +25,6 @@ public interface PolicyOutputSink { default void validate(OutputSpec spec) {} /** Persist/deliver the output files and return their descriptors. */ - List deliver(String runId, List outputs, OutputSpec spec) + List deliver(OutputDelivery delivery, List outputs, OutputSpec spec) throws IOException; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java index b6d882bd6d..685ddb21db 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.policy.store; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Objects; @@ -16,6 +17,8 @@ import stirling.software.proprietary.policy.model.Policy; public class InProcessPolicyStore implements PolicyStore { private final Map policies = new ConcurrentHashMap<>(); + // Run-order position per policy id, mirroring JpaPolicyStore's sort_order column. + private final Map sortOrders = new ConcurrentHashMap<>(); @Override public Policy save(Policy policy) { @@ -35,9 +38,25 @@ public class InProcessPolicyStore implements PolicyStore { policy.output(), policy.teamId()); policies.put(id, stored); + // Existing policy keeps its position; a new one appends to the end of its team's queue. + sortOrders.computeIfAbsent(id, key -> nextSortOrder(stored.teamId())); return stored; } + private int nextSortOrder(Long teamId) { + return policies.values().stream() + .filter(policy -> Objects.equals(policy.teamId(), teamId)) + .map(policy -> sortOrders.getOrDefault(policy.id(), 0)) + .max(Comparator.naturalOrder()) + .orElse(-1) + + 1; + } + + private Comparator byRunOrder() { + return Comparator.comparingInt(policy -> sortOrders.getOrDefault(policy.id(), 0)) + .thenComparing(Policy::id); + } + @Override public Optional get(String id) { return Optional.ofNullable(policies.get(id)); @@ -45,13 +64,14 @@ public class InProcessPolicyStore implements PolicyStore { @Override public List all() { - return List.copyOf(policies.values()); + return policies.values().stream().sorted(byRunOrder()).toList(); } @Override public List findByTeam(Long teamId) { return policies.values().stream() .filter(policy -> Objects.equals(policy.teamId(), teamId)) + .sorted(byRunOrder()) .toList(); } @@ -64,8 +84,21 @@ public class InProcessPolicyStore implements PolicyStore { .toList(); } + @Override + public void reorder(Long teamId, List orderedIds) { + int position = 0; + for (String id : orderedIds) { + Policy policy = policies.get(id); + if (policy == null || !Objects.equals(policy.teamId(), teamId)) { + continue; + } + sortOrders.put(id, position++); + } + } + @Override public boolean delete(String id) { + sortOrders.remove(id); return policies.remove(id) != null; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java index 4de6ee2f3a..5089b8d419 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java @@ -1,11 +1,13 @@ package stirling.software.proprietary.policy.store; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.UUID; import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; @@ -26,6 +28,7 @@ public class JpaPolicyStore implements PolicyStore { private final ObjectMapper objectMapper; @Override + @Transactional public Policy save(Policy policy) { String id = policy.id() == null || policy.id().isBlank() @@ -50,11 +53,49 @@ public class JpaPolicyStore implements PolicyStore { entity.setEnabled(stored.enabled()); entity.setTriggerType(stored.trigger() == null ? null : stored.trigger().type()); entity.setTeamId(stored.teamId()); + // Preserve an existing policy's run-order position; append a new one to the end of its + // team's queue (max + 1), so setting up a policy adds it last by default. + entity.setSortOrder( + repository + .findById(id) + .map(PolicyEntity::getSortOrder) + .orElseGet(() -> nextSortOrder(stored.teamId()))); entity.setPolicyJson(objectMapper.writeValueAsString(stored)); repository.save(entity); return stored; } + /** + * Append position for a new policy: max(existing) + 1, computed under a pessimistic lock on the + * team's rows (see {@link PolicyRepository#findByTeamForUpdate}) so two concurrent creates + * can't both read the same max and assign a duplicate order. (A brand-new team has no rows to + * lock; a rare simultaneous first-create there ties at 0 β€” harmless, since the ordering query + * breaks ties by id and any later reorder normalises it.) + */ + private int nextSortOrder(Long teamId) { + return repository.findByTeamForUpdate(teamId).stream() + .map(entity -> entity.getSortOrder() == null ? 0 : entity.getSortOrder()) + .max(Integer::compareTo) + .orElse(-1) + + 1; + } + + @Override + @Transactional + public void reorder(Long teamId, List orderedIds) { + int position = 0; + for (String id : orderedIds) { + PolicyEntity entity = repository.findById(id).orElse(null); + // Ignore unknown ids and any policy outside the caller's team β€” a reorder can't reach + // across teams. + if (entity == null || !Objects.equals(entity.getTeamId(), teamId)) { + continue; + } + entity.setSortOrder(position++); + repository.save(entity); + } + } + @Override public Optional get(String id) { return repository.findById(id).map(this::toPolicy); @@ -62,7 +103,7 @@ public class JpaPolicyStore implements PolicyStore { @Override public List all() { - return repository.findAll().stream().map(this::toPolicy).toList(); + return repository.findAllOrdered().stream().map(this::toPolicy).toList(); } @Override diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java index 4b99a90e6c..08e6bd4fbe 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java @@ -47,6 +47,14 @@ public class PolicyEntity implements Serializable { @Column(name = "team_id") private Long teamId; + /** + * Position in the team's run order (ascending). Team-wide and admin-editable; the per-trigger + * order the UI shows is this single sequence filtered by trigger. New policies are appended + * (max + 1). Nullable for pre-existing rows; treated as 0 when sorting. + */ + @Column(name = "sort_order") + private Integer sortOrder; + @Column(name = "policy_json", columnDefinition = "text") private String policyJson; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java index 67796d4a67..ac9e210439 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java @@ -21,6 +21,13 @@ public interface PolicyStore { /** Enabled policies with the given trigger type, for background triggers. */ List findByTriggerType(String triggerType); + /** + * Set the team's run order from {@code orderedIds} (position β†’ sortOrder). Only policies that + * belong to {@code teamId} are touched; unknown/other-team ids are ignored, so a caller can't + * reorder across teams. Ids omitted from the list keep their existing order. + */ + void reorder(Long teamId, List orderedIds); + /** Returns whether the policy existed. */ boolean delete(String id); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java index 79ba3e52e1..dee78e8488 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java @@ -29,6 +29,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.config.FolderAccessGuard; import stirling.software.proprietary.policy.engine.PolicyRunner; +import stirling.software.proprietary.policy.engine.SweepKind; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.Policy; @@ -202,7 +203,8 @@ public class FolderWatchTrigger implements PolicyTrigger { } if (dirs.stream().anyMatch(changedDirs::contains)) { log.debug("Folder-watch policy {} ({}) saw activity", policy.id(), policy.name()); - policyRunner.run(policy); + // Light: the periodic reconcile does the full sweep. + policyRunner.run(policy, SweepKind.LIGHT); } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java index 94dc41a231..5d68bc99e0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java @@ -22,6 +22,7 @@ import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.service.DatabaseServiceInterface; import stirling.software.proprietary.security.service.SaveUserRequest; +import stirling.software.proprietary.security.service.TeamMembershipService; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.service.UserLicenseSettingsService; @@ -40,6 +41,7 @@ public class InitialSecuritySetup { private final DatabaseServiceInterface databaseService; private final UserLicenseSettingsService licenseSettingsService; private final Environment environment; + private final TeamMembershipService teamMembershipService; /** * SaaS manages identity in Supabase and billing via PAYG, so the self-host bootstrap steps that @@ -114,6 +116,7 @@ public class InitialSecuritySetup { } userService.saveAll(usersWithoutTeam); // batch save + usersWithoutTeam.forEach(teamMembershipService::syncMembership); if (usersWithoutTeam != null && !usersWithoutTeam.isEmpty()) { log.info( "Assigned {} user(s) without a team to the default team.", diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java index c53d835ca5..2e9c4d3e5b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java @@ -34,9 +34,11 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.workflow.repository", "stirling.software.proprietary.policy.store", "stirling.software.proprietary.policy.source", + "stirling.software.proprietary.policy.ledger", "stirling.software.proprietary.accountlink", "stirling.software.proprietary.access.repository", - "stirling.software.proprietary.integration.repository" + "stirling.software.proprietary.integration.repository", + "stirling.software.proprietary.classification.store" }) @EntityScan({ "stirling.software.proprietary.security.model", @@ -45,9 +47,11 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.workflow.model", "stirling.software.proprietary.policy.store", "stirling.software.proprietary.policy.source", + "stirling.software.proprietary.policy.ledger", "stirling.software.proprietary.accountlink", "stirling.software.proprietary.access.model", - "stirling.software.proprietary.integration.model" + "stirling.software.proprietary.integration.model", + "stirling.software.proprietary.classification.store" }) public class DatabaseConfig { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index dcea7807b1..86a1c5fe0c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -29,6 +29,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.constants.JwtConstants; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.access.service.ResourceAccessService; +import stirling.software.proprietary.access.service.TeamLeadLookup; import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.audit.Audited; @@ -66,6 +67,7 @@ public class AuthController { private final ApplicationProperties applicationProperties; private final AiUserDataService aiUserDataService; private final ResourceAccessService resourceAccessService; + private final TeamLeadLookup teamLeadLookup; /** * Login endpoint - replaces Supabase signInWithPassword @@ -265,8 +267,11 @@ public class AuthController { .body(Map.of("error", "Not authenticated")); } - UserDetails userDetails = (UserDetails) auth.getPrincipal(); - User user = (User) userDetails; + // Anonymous SaaS sessions carry a raw Jwt principal; treat them as unauthenticated + if (!(auth.getPrincipal() instanceof User user)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Not authenticated")); + } return ResponseEntity.ok(Map.of("user", buildUserResponse(user))); @@ -631,6 +636,12 @@ public class AuthController { userMap.put("role", user.getRolesAsString()); userMap.put("enabled", user.isEnabled()); userMap.put("portalAccess", resourceAccessService.canAccessPortal(user)); + userMap.put("teamLead", teamLeadLookup.isAnyTeamLeader(user)); + // Expose the caller's team so non-admin team owners can scope their own team's resources. + if (user.getTeam() != null) { + userMap.put( + "team", Map.of("id", user.getTeam().getId(), "name", user.getTeam().getName())); + } userMap.put( "authenticationType", user.getAuthenticationType()); // Expose authentication type for SSO detection diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java index d9e43fa848..32a429ea72 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java @@ -14,11 +14,15 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.annotations.api.TeamApi; +import stirling.software.proprietary.access.model.PrincipalType; +import stirling.software.proprietary.access.repository.ResourceGrantRepository; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.config.PremiumEndpoint; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.proprietary.security.service.TeamMembershipService; import stirling.software.proprietary.security.service.TeamService; @TeamApi @@ -29,6 +33,9 @@ public class TeamController { private final TeamRepository teamRepository; private final UserRepository userRepository; + private final ResourceGrantRepository resourceGrantRepository; + private final IntegrationConfigRepository integrationConfigRepository; + private final TeamMembershipService teamMembershipService; @PreAuthorize("hasRole('ADMIN')") @PostMapping("/create") @@ -96,10 +103,72 @@ public class TeamController { "Team must be empty before deletion. Please remove all members first.")); } + if (integrationConfigRepository.existsByOwnerTeam_Id(teamId)) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body( + Map.of( + "error", + "Team still owns integration configurations. Delete or reassign them first.")); + } + + // Team grants and membership rows would dangle once the team row is gone + resourceGrantRepository.deleteByPrincipalTypeAndPrincipalId(PrincipalType.TEAM, teamId); + teamMembershipService.deleteAllForTeam(teamId); teamRepository.delete(team); return ResponseEntity.ok(Map.of("message", "Team deleted successfully")); } + @PreAuthorize("hasRole('ADMIN')") + @PostMapping("/setOwner") + @Transactional + public ResponseEntity setTeamOwner( + @RequestParam("teamId") Long teamId, @RequestParam("userId") Long userId) { + return mutateOwner(teamId, userId, true); + } + + @PreAuthorize("hasRole('ADMIN')") + @PostMapping("/removeOwner") + @Transactional + public ResponseEntity removeTeamOwner( + @RequestParam("teamId") Long teamId, @RequestParam("userId") Long userId) { + return mutateOwner(teamId, userId, false); + } + + private ResponseEntity mutateOwner(Long teamId, Long userId, boolean owner) { + Optional teamOpt = teamRepository.findById(teamId); + if (teamOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "Team not found.")); + } + Team team = teamOpt.get(); + + // System teams have no owners + if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName()) + || TeamService.DEFAULT_TEAM_NAME.equals(team.getName())) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "System teams cannot have owners.")); + } + + Optional userOpt = userRepository.findById(userId); + if (userOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "User not found.")); + } + User user = userOpt.get(); + + if (user.getTeam() == null || !user.getTeam().getId().equals(teamId)) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "User must be a member of the team.")); + } + + if (owner) { + teamMembershipService.setOwner(team, user); + return ResponseEntity.ok(Map.of("message", "Team owner assigned successfully")); + } + teamMembershipService.removeOwner(team, user); + return ResponseEntity.ok(Map.of("message", "Team owner removed successfully")); + } + @PreAuthorize("hasRole('ADMIN')") @PostMapping("/addUser") @Transactional @@ -138,6 +207,7 @@ public class TeamController { // Assign user to team user.setTeam(team); userRepository.save(user); + teamMembershipService.syncMembership(user); return ResponseEntity.ok(Map.of("message", "User added to team successfully")); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index d8500e475f..b19c052ff1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -50,6 +50,7 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin import stirling.software.proprietary.security.service.EmailService; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.SaveUserRequest; +import stirling.software.proprietary.security.service.TeamMembershipService; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.session.SessionPersistentRegistry; @@ -69,6 +70,7 @@ public class UserController { private final Optional emailService; private final UserLicenseSettingsService licenseSettingsService; private final LoginAttemptService loginAttemptService; + private final TeamMembershipService teamMembershipService; @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/register") @@ -644,6 +646,7 @@ public class UserController { user.setTeam(team); userRepository.save(user); + teamMembershipService.syncMembership(user); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/dto/AdminUserSummary.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/dto/AdminUserSummary.java index 2a3e7d2af6..5a503e9436 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/dto/AdminUserSummary.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/dto/AdminUserSummary.java @@ -50,6 +50,14 @@ public class AdminUserSummary { @Schema(description = "Team membership (if any)") private TeamSummary team; + @Schema(description = "Whether the user owns (leads) any team") + private boolean teamLead; + + @Schema( + description = + "Whether the user may access the portal, per the server-side access policy") + private boolean portalAccess; + @Schema(description = "User account creation timestamp") private LocalDateTime createdAt; diff --git a/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/TeamMembershipRepository.java similarity index 77% rename from app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java rename to app/proprietary/src/main/java/stirling/software/proprietary/security/repository/TeamMembershipRepository.java index 36c04f09c3..0662edd67b 100644 --- a/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/TeamMembershipRepository.java @@ -1,15 +1,17 @@ -package stirling.software.saas.repository; +package stirling.software.proprietary.security.repository; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import stirling.software.common.model.enumeration.TeamRole; -import stirling.software.saas.model.TeamMembership; +import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.security.model.User; @Repository public interface TeamMembershipRepository extends JpaRepository { @@ -106,4 +108,24 @@ public interface TeamMembershipRepository extends JpaRepository findByRoleFetchingUserAndTeam(@Param("role") TeamRole role); + + void deleteByTeamId(Long teamId); + + void deleteByUserId(Long userId); + + // Detach invitation references so deleting the inviting user does not hit the FK. + @Modifying + @Query("update TeamMembership tm set tm.invitedBy = null where tm.invitedBy = :user") + void clearInvitedBy(@Param("user") User user); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamMembershipService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamMembershipService.java new file mode 100644 index 0000000000..e8fca904b6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamMembershipService.java @@ -0,0 +1,117 @@ +package stirling.software.proprietary.security.service; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; + +/** + * Keeps team_memberships in step with users.team_id on self-hosted admin flows and holds the + * team-owner (LEADER role) mutations. SaaS owns membership lifecycle (seats, personal teams, + * last-leader guards) via SaasTeamService, so these mutations no-op on the saas profile to avoid + * corrupting its accounting. + */ +@Service +@RequiredArgsConstructor +public class TeamMembershipService { + + private final TeamMembershipRepository membershipRepository; + private final Environment environment; + + private boolean isSaas() { + return Arrays.asList(environment.getActiveProfiles()).contains("saas"); + } + + /** Reflects users.team_id into membership rows, preserving an existing role on the team. */ + @Transactional + public void syncMembership(User user) { + if (isSaas() || user == null || user.getId() == null) { + return; + } + Long teamId = user.getTeam() != null ? user.getTeam().getId() : null; + boolean present = false; + for (TeamMembership row : membershipRepository.findByUserId(user.getId())) { + if (teamId != null && teamId.equals(row.getTeam().getId())) { + present = true; + } else { + membershipRepository.delete(row); + } + } + if (teamId != null && !present) { + membershipRepository.save(newRow(user.getTeam(), user, TeamRole.MEMBER)); + } + } + + /** Promotes a member to team owner, creating the membership row if it is missing. */ + @Transactional + public void setOwner(Team team, User user) { + if (isSaas()) { + return; + } + Optional existing = + membershipRepository.findByTeamIdAndUserId(team.getId(), user.getId()); + if (existing.isPresent()) { + existing.get().setRole(TeamRole.LEADER); + membershipRepository.save(existing.get()); + } else { + membershipRepository.save(newRow(team, user, TeamRole.LEADER)); + } + } + + /** Demotes a team owner back to member; keeps the membership row. */ + @Transactional + public void removeOwner(Team team, User user) { + if (isSaas()) { + return; + } + membershipRepository + .findByTeamIdAndUserId(team.getId(), user.getId()) + .ifPresent( + row -> { + row.setRole(TeamRole.MEMBER); + membershipRepository.save(row); + }); + } + + /** Owner user ids for a team. */ + @Transactional(readOnly = true) + public List ownerUserIds(Long teamId) { + return membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER).stream() + .map(row -> row.getUser().getId()) + .toList(); + } + + @Transactional + public void deleteAllForTeam(Long teamId) { + membershipRepository.deleteByTeamId(teamId); + } + + @Transactional + public void deleteAllForUser(User user) { + membershipRepository.deleteByUserId(user.getId()); + membershipRepository.clearInvitedBy(user); + } + + private TeamMembership newRow(Team team, User user, TeamRole role) { + TeamMembership row = new TeamMembership(); + row.setTeam(team); + row.setUser(user); + row.setRole(role); + // Self-hosted rows are admin-assigned, not invitation-driven. + row.setInvitedAt(LocalDateTime.now()); + row.setAcceptedAt(LocalDateTime.now()); + return row; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index a7aaa573fd..45532978f3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -39,6 +39,11 @@ import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.common.service.UserServiceInterface; import stirling.software.common.util.RegexPatternUtils; +import stirling.software.proprietary.access.model.PrincipalType; +import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.access.repository.ResourceGrantRepository; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.database.repository.AuthorityRepository; import stirling.software.proprietary.security.database.repository.PersistentLoginRepository; @@ -87,6 +92,9 @@ public class UserService implements UserServiceInterface { private final StorageCleanupEntryRepository storageCleanupEntryRepository; private final FileShareRepository fileShareRepository; private final FileShareAccessRepository fileShareAccessRepository; + private final ResourceGrantRepository resourceGrantRepository; + private final IntegrationConfigRepository integrationConfigRepository; + private final TeamMembershipService teamMembershipService; @Transactional public void processSSOPostLogin( @@ -249,6 +257,21 @@ public class UserService implements UserServiceInterface { private void deleteUserRelatedData(User user) { log.info("Deleting all associated data for user: {}", user.getUsername()); + // Drop ACL grants held by this user and detach grants they issued + resourceGrantRepository.deleteByPrincipalTypeAndPrincipalId( + PrincipalType.USER, user.getId()); + resourceGrantRepository.clearGrantedBy(user); + + // Integration configs owned by this user FK the users row; drop them and their grants + for (IntegrationConfig cfg : integrationConfigRepository.findByOwnerUser(user)) { + resourceGrantRepository.deleteByResourceTypeAndResourceId( + ResourceType.INTEGRATION_CONFIG, String.valueOf(cfg.getId())); + } + integrationConfigRepository.deleteByOwnerUser(user); + + // Membership rows and invitation references would dangle once the user row is gone + teamMembershipService.deleteAllForUser(user); + // Delete server certificate (non-nullable OneToOne β†’ User) userServerCertificateService.deleteUserCertificate(user.getId()); @@ -412,6 +435,7 @@ public class UserService implements UserServiceInterface { } user.setTeam(team); userRepository.save(user); + teamMembershipService.syncMembership(user); databaseService.exportDatabase(); } @@ -523,6 +547,7 @@ public class UserService implements UserServiceInterface { // Save user userRepository.save(user); + teamMembershipService.syncMembership(user); // Export database databaseService.exportDatabase(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java index d3b2d21a00..0e9460eca7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java @@ -404,6 +404,7 @@ public class AiWorkflowService { buildCompletedResponse( response.getRationale(), result.files(), + result.origins(), inputFileNames(filesById), result.report())); } catch (InternalApiTimeoutException e) { @@ -465,7 +466,8 @@ public class AiWorkflowService { } }; return new WorkflowState.Terminal( - buildCompletedResponse(response.getSummary(), List.of(resource), List.of(), null)); + buildCompletedResponse( + response.getSummary(), List.of(resource), null, List.of(), null)); } @SuppressWarnings("unchecked") @@ -523,7 +525,11 @@ public class AiWorkflowService { return new WorkflowState.Terminal( buildCompletedResponse( - summary, result.files(), inputFileNames(filesById), result.report())); + summary, + result.files(), + result.origins(), + inputFileNames(filesById), + result.report())); } catch (InternalApiTimeoutException e) { log.error("Plan step on tool {} timed out: {}", e.getEndpointPath(), e.getMessage()); return new WorkflowState.Terminal( @@ -612,19 +618,35 @@ public class AiWorkflowService { private AiWorkflowResponse buildCompletedResponse( String summary, List resultFiles, + List origins, List inputFileNames, JsonNode report) throws IOException { // Store every output file individually so each gets its own Stirling file ID and the // frontend can add them as independent variants without going through a zip. - boolean preserveInputNames = inputFileNames.size() == resultFiles.size(); + // Count outputs per source so only a clean 1:1 transform (one output for a source) reuses + // the input's name; a split (one input β†’ many outputs) keeps each entry's own name. + Map outputsPerOrigin = + origins == null + ? Map.of() + : origins.stream() + .filter(o -> o != null) + .collect(Collectors.groupingBy(o -> o, Collectors.counting())); List descriptors = new ArrayList<>(); for (int i = 0; i < resultFiles.size(); i++) { Resource resource = resultFiles.get(i); String responseName = resource.getFilename(); - String inputName = preserveInputNames ? inputFileNames.get(i) : null; - // Prefer the input name only for 1:1 operations where the output keeps the same - // extension (rotate, compress, etc.). For converters and other extension-changing + // The output's source input (from the executor), used both to name it and to tell the + // client which file to version in place. + Integer origin = origins != null && i < origins.size() ? origins.get(i) : null; + boolean uniqueOrigin = + origin != null && outputsPerOrigin.getOrDefault(origin, 0L) == 1L; + String inputName = + uniqueOrigin && origin >= 0 && origin < inputFileNames.size() + ? inputFileNames.get(origin) + : null; + // Prefer the source input's name only for 1:1 operations where the output keeps the + // same extension (rotate, compress, etc.). For converters and other extension-changing // tools, the response filename from Content-Disposition is authoritative. String name; if (inputName != null @@ -644,7 +666,11 @@ public class AiWorkflowService { try (java.io.InputStream is = resource.getInputStream()) { fileId = fileStorage.storeInputStream(is, name).fileId(); } - descriptors.add(new AiWorkflowResultFile(fileId, name, contentType)); + // Only expose the source when this is a clean 1:1 transform, so the client can treat a + // present sourceIndex as "replace that input in place" without further disambiguation. + descriptors.add( + new AiWorkflowResultFile( + fileId, name, contentType, uniqueOrigin ? origin : null)); } AiWorkflowResponse completed = new AiWorkflowResponse(); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/security/ResourceAccessSecurityTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/security/ResourceAccessSecurityTest.java new file mode 100644 index 0000000000..09c0fbf1f7 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/security/ResourceAccessSecurityTest.java @@ -0,0 +1,61 @@ +package stirling.software.proprietary.access.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import stirling.software.proprietary.access.service.ResourceAccessService; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; + +@ExtendWith(MockitoExtension.class) +class ResourceAccessSecurityTest { + + @Mock private ResourceAccessService accessService; + @Mock private UserService userService; + + @InjectMocks private ResourceAccessSecurity security; + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void userPrincipalDelegatesToPortalCheck() { + User user = new User(); + user.setId(5L); + authenticate(user); + when(accessService.canAccessPortal(user)).thenReturn(true); + + assertThat(security.canUsePortal()).isTrue(); + } + + @Test + void deniedWithoutAuthentication() { + assertThat(security.canUsePortal()).isFalse(); + } + + @Test + void nonUserPrincipalWithoutBackingRowIsDenied() { + // Mirrors an anonymous SaaS session: principal is not a User and resolves to nothing. + authenticate("anonymousUser"); + + assertThat(security.canUsePortal()).isFalse(); + } + + private void authenticate(Object principal) { + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + principal, null, java.util.List.of())); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/DefaultPrincipalResolverTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/DefaultPrincipalResolverTest.java new file mode 100644 index 0000000000..94e99bdbc9 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/DefaultPrincipalResolverTest.java @@ -0,0 +1,54 @@ +package stirling.software.proprietary.access.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.access.model.PrincipalRef; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; + +class DefaultPrincipalResolverTest { + + private final DefaultPrincipalResolver resolver = new DefaultPrincipalResolver(); + + @Test + void userWithoutTeamProjectsUser() { + assertThat(resolver.principalsOf(user(5, null))) + .containsExactlyInAnyOrder(PrincipalRef.user(5L)); + } + + @Test + void userWithTeamProjectsUserAndTeam() { + assertThat(resolver.principalsOf(user(5, 7L))) + .containsExactlyInAnyOrder(PrincipalRef.user(5L), PrincipalRef.team(7L)); + } + + @Test + void nullUserProjectsNothing() { + assertThat(resolver.principalsOf(null)).isEmpty(); + assertThat(resolver.principalTokens(null)).isEmpty(); + } + + @Test + void tokensUseTheCanonicalWireForm() { + assertThat(resolver.principalTokens(user(5, 7L))) + .containsExactlyInAnyOrder("user:5", "team:7"); + } + + @Test + void selfHostedAllowsDeploymentWideAccess() { + assertThat(resolver.allowsDeploymentWideAccess()).isTrue(); + } + + private User user(long id, Long teamId) { + User u = new User(); + u.setId(id); + if (teamId != null) { + Team t = new Team(); + t.setId(teamId); + u.setTeam(t); + } + return u; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/MembershipTeamLeadLookupTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/MembershipTeamLeadLookupTest.java new file mode 100644 index 0000000000..f485bcec52 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/MembershipTeamLeadLookupTest.java @@ -0,0 +1,55 @@ +package stirling.software.proprietary.access.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; + +@ExtendWith(MockitoExtension.class) +class MembershipTeamLeadLookupTest { + + @Mock private TeamMembershipRepository memberships; + + @InjectMocks private MembershipTeamLeadLookup lookup; + + @Test + void leaderMembershipMakesTeamLeader() { + when(memberships.existsByTeamIdAndUserIdAndRole(7L, 5L, TeamRole.LEADER)).thenReturn(true); + assertThat(lookup.isLeaderOfTeam(user(5), 7L)).isTrue(); + } + + @Test + void memberOnlyIsNotTeamLeader() { + when(memberships.existsByTeamIdAndUserIdAndRole(7L, 5L, TeamRole.LEADER)).thenReturn(false); + assertThat(lookup.isLeaderOfTeam(user(5), 7L)).isFalse(); + } + + @Test + void anyLeadershipDetectedAcrossTeams() { + when(memberships.existsByUserIdAndRole(5L, TeamRole.LEADER)).thenReturn(true); + assertThat(lookup.isAnyTeamLeader(user(5))).isTrue(); + } + + @Test + void nullInputsNeverQueryAndDeny() { + assertThat(lookup.isAnyTeamLeader(null)).isFalse(); + assertThat(lookup.isLeaderOfTeam(null, 7L)).isFalse(); + assertThat(lookup.isLeaderOfTeam(user(5), null)).isFalse(); + verifyNoInteractions(memberships); + } + + private User user(long id) { + User u = new User(); + u.setId(id); + return u; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/OwnershipServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/OwnershipServiceTest.java index 3f70058d0b..f2d86e783d 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/OwnershipServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/OwnershipServiceTest.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.access.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import java.util.Optional; @@ -150,6 +151,27 @@ class OwnershipServiceTest { assertThat(ownership.canUse(TYPE, r, user(8))).isFalse(); // not a lead } + @Test + void disabledResourceManageableOnlyByOwnerOrAdmin() { + TestResource r = new TestResource(1L); + r.setEnabled(false); + r.setOwnerUser(user(7)); + + assertThat(ownership.canManage(TYPE, r, user(7))).isTrue(); // owner + assertThat(ownership.canManage(TYPE, r, user(8))).isFalse(); // would-be MANAGE grantee + assertThat(ownership.canManage(TYPE, r, admin(2))).isTrue(); // admin + // Grants are bypassed entirely while disabled. + verifyNoInteractions(accessService); + } + + @Test + void enabledResourceManageDelegatesToTheAcl() { + TestResource r = new TestResource(1L); + when(accessService.canManageResource(any(), any(), any(), any())).thenReturn(true); + + assertThat(ownership.canManage(TYPE, r, user(7))).isTrue(); + } + // ---- helpers ---- private void assertForbidden(org.assertj.core.api.ThrowableAssert.ThrowingCallable call) { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java index 230c207eb0..baa6755493 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java @@ -1,20 +1,22 @@ package stirling.software.proprietary.access.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import java.lang.reflect.Field; import java.util.List; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import stirling.software.proprietary.access.model.AccessPermission; import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.model.PrincipalRef; import stirling.software.proprietary.access.model.PrincipalType; import stirling.software.proprietary.access.model.ResourceGrant; import stirling.software.proprietary.access.model.ResourceType; @@ -32,13 +34,20 @@ class ResourceAccessServiceTest { @Mock private ResourceGrantRepository grantRepository; @Mock private TeamLeadLookup teamLeadLookup; - @InjectMocks private ResourceAccessService service; + private ResourceAccessService service; @BeforeEach - void setPortalDefault() throws Exception { + void setUp() throws Exception { + service = newService(new DefaultPrincipalResolver()); + } + + private ResourceAccessService newService(PrincipalResolver resolver) throws Exception { + ResourceAccessService s = + new ResourceAccessService(grantRepository, teamLeadLookup, resolver); Field f = ResourceAccessService.class.getDeclaredField("portalDefaultPolicy"); f.setAccessible(true); - f.set(service, DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS); + f.set(s, DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS); + return s; } // ---- owner / admin short-circuits ---- @@ -55,15 +64,56 @@ class ResourceAccessServiceTest { void ownerMayUseEvenWithExplicitOnly() { assertThat( service.canUseResource( - TYPE, RID, 5L, DefaultAccessPolicy.EXPLICIT_ONLY, user(5))) + TYPE, + RID, + PrincipalRef.user(5L), + DefaultAccessPolicy.EXPLICIT_ONLY, + user(5))) .isTrue(); } @Test void nullUserIsAlwaysDenied() { - assertThat(service.canUseResource(TYPE, RID, 5L, DefaultAccessPolicy.ORG_ALL, null)) + assertThat( + service.canUseResource( + TYPE, + RID, + PrincipalRef.user(5L), + DefaultAccessPolicy.ORG_ALL, + null)) + .isFalse(); + assertThat(service.canManageResource(TYPE, RID, PrincipalRef.user(5L), null)).isFalse(); + } + + // ---- owner refs ---- + + @Test + void teamOwnerRefAllowsLeaderOfThatTeam() { + User leader = userInTeam(5, 7); + when(teamLeadLookup.isLeaderOfTeam(leader, 7L)).thenReturn(true); + assertThat( + service.canUseResource( + TYPE, + RID, + PrincipalRef.team(7L), + DefaultAccessPolicy.EXPLICIT_ONLY, + leader)) + .isTrue(); + assertThat(service.canManageResource(TYPE, RID, PrincipalRef.team(7L), leader)).isTrue(); + } + + @Test + void teamOwnerRefDeniesPlainTeamMember() { + stubGrants(); + User member = userInTeam(5, 7); + assertThat( + service.canUseResource( + TYPE, + RID, + PrincipalRef.team(7L), + DefaultAccessPolicy.EXPLICIT_ONLY, + member)) .isFalse(); - assertThat(service.canManageResource(TYPE, RID, 5L, null)).isFalse(); } // ---- explicit grants ---- @@ -124,15 +174,60 @@ class ResourceAccessServiceTest { assertThat(service.canManageResource(TYPE, RID, null, user(5))).isTrue(); } + // ---- upsert semantics ---- + + @Test + void grantOverwritesPermissionEvenDowngrading() { + // Upsert key is (type, resourceId, principalType, principalId); permission is overwritten. + ResourceGrant existing = grant(PrincipalType.USER, 5L, AccessPermission.MANAGE); + stubGrants(existing); + when(grantRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + ResourceGrant saved = + service.grant(TYPE, RID, PrincipalType.USER, 5L, AccessPermission.USE, null); + + assertThat(saved).isSameAs(existing); + assertThat(saved.getPermission()).isEqualTo(AccessPermission.USE); + } + + // ---- granted resource ids ---- + + @Test + void grantedResourceIdsCollectsAcrossAllPrincipals() { + when(grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId( + TYPE, PrincipalType.USER, 5L)) + .thenReturn(List.of(grantOn("a"))); + when(grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId( + TYPE, PrincipalType.TEAM, 7L)) + .thenReturn(List.of(grantOn("b"))); + + assertThat(service.grantedResourceIds(TYPE, userInTeam(5, 7))) + .containsExactlyInAnyOrder("a", "b"); + } + // ---- default policies ---- @Test - void orgAllDefaultAllowsAnyUser() { + void orgAllDefaultAllowsAnyUserWhenDeploymentWide() { + // Self-hosted resolver allows deployment-wide access, so ORG_ALL admits anyone. stubGrants(); assertThat(service.canUseResource(TYPE, RID, null, DefaultAccessPolicy.ORG_ALL, user(5))) .isTrue(); } + @Test + void orgAllDefaultDeniedWhenNotDeploymentWide() throws Exception { + // Mirrors the saas resolver (USER/TEAM only, no deployment-wide access): ORG_ALL must not + // leak a resource to a tenant's users, so an ungranted user is denied. + ResourceAccessService tenantScoped = + newService(u -> u == null ? Set.of() : Set.of(PrincipalRef.user(u.getId()))); + stubGrants(); + assertThat( + tenantScoped.canUseResource( + TYPE, RID, null, DefaultAccessPolicy.ORG_ALL, user(5))) + .isFalse(); + } + @Test void explicitOnlyDefaultDeniesUngrantedUser() { stubGrants(); @@ -194,6 +289,12 @@ class ResourceAccessServiceTest { return g; } + private ResourceGrant grantOn(String resourceId) { + ResourceGrant g = grant(PrincipalType.USER, 5L, AccessPermission.USE); + g.setResourceId(resourceId); + return g; + } + private User user(long id) { User u = new User(); u.setId(id); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java index ea5597cb12..4f76b06b5c 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java @@ -49,6 +49,23 @@ class SecretMaskerTest { assertThat(merged.get("secretKey")).isEqualTo("NEW"); } + @Test + void mergeDropsKeysAbsentFromIncoming() { + // PUT/replace semantics: a key removed in the edit is removed from storage. + Map stored = new java.util.LinkedHashMap<>(); + stored.put("bucket", "b"); + stored.put("endpoint", "https://old"); + stored.put("secretKey", "REAL"); + Map incoming = new java.util.LinkedHashMap<>(); + incoming.put("bucket", "b"); + incoming.put("secretKey", SecretMasker.MASK); + + Map merged = masker.merge(stored, incoming); + + assertThat(merged).doesNotContainKey("endpoint"); + assertThat(merged.get("secretKey")).isEqualTo("REAL"); // masked secret retained + } + @Test void sanitizeDropsBlankSecretsOnCreate() { Map incoming = new LinkedHashMap<>(); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/classification/ClassificationLabelsControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/classification/ClassificationLabelsControllerTest.java new file mode 100644 index 0000000000..221d567c31 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/classification/ClassificationLabelsControllerTest.java @@ -0,0 +1,133 @@ +package stirling.software.proprietary.classification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.UserServiceInterface; +import stirling.software.proprietary.classification.model.ClassificationLabel; +import stirling.software.proprietary.classification.model.ClassificationLabels; +import stirling.software.proprietary.classification.store.ClassificationLabelStore; +import stirling.software.proprietary.classification.store.InProcessClassificationLabelStore; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ClassificationLabelsController") +class ClassificationLabelsControllerTest { + + private static final Long TEAM = 7L; + + @Mock private PolicyManagementAuthority policyManagementAuthority; + @Mock private UserServiceInterface userService; + + private ClassificationLabelStore store; + private ApplicationProperties applicationProperties; + private ClassificationLabelsController controller; + + @BeforeEach + void setUp() { + store = new InProcessClassificationLabelStore(); + applicationProperties = new ApplicationProperties(); + controller = + new ClassificationLabelsController( + store, policyManagementAuthority, applicationProperties, userService); + } + + private static ClassificationLabels sample() { + return new ClassificationLabels( + List.of( + new ClassificationLabel("invoice", "Invoice", "receipt-long"), + new ClassificationLabel("contract", "Contract", null))); + } + + private void loginEnabled(boolean enabled) { + applicationProperties.getSecurity().setEnableLogin(enabled); + } + + @Test + @DisplayName("GET returns 204 when the team has no labels") + void getEmpty() { + when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM); + ResponseEntity response = controller.getTeamLabels(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } + + @Test + @DisplayName("PUT then GET round-trips the team's labels (login disabled)") + void saveThenGet() { + loginEnabled(false); + when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM); + + controller.saveTeamLabels(sample()); + ResponseEntity got = controller.getTeamLabels(); + + assertThat(got.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(got.getBody()).isNotNull(); + assertThat(got.getBody().labels()).hasSize(2); + assertThat(got.getBody().labels().getFirst().name()).isEqualTo("Invoice"); + assertThat(got.getBody().labels().getFirst().icon()).isEqualTo("receipt-long"); + } + + @Test + @DisplayName("PUT is scoped per team") + void perTeam() { + loginEnabled(false); + when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM); + controller.saveTeamLabels(sample()); + + when(policyManagementAuthority.currentUserTeamId()).thenReturn(99L); + assertThat(controller.getTeamLabels().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } + + @Test + @DisplayName("PUT is rejected for a non-editor when login is enabled") + void putForbiddenForNonEditor() { + loginEnabled(true); + when(policyManagementAuthority.canEditPolicies()).thenReturn(false); + + assertThatThrownBy(() -> controller.saveTeamLabels(sample())) + .isInstanceOf(ResponseStatusException.class) + .hasFieldOrPropertyWithValue("statusCode", HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("PUT rejects an invalid label set with 400") + void putInvalid() { + loginEnabled(false); + ClassificationLabels duplicate = + new ClassificationLabels( + List.of( + new ClassificationLabel("invoice", "Invoice", null), + new ClassificationLabel("invoice", "Invoice", null))); + + assertThatThrownBy(() -> controller.saveTeamLabels(duplicate)) + .isInstanceOf(ResponseStatusException.class) + .hasFieldOrPropertyWithValue("statusCode", HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("DELETE resets the team back to no stored labels") + void deleteResets() { + loginEnabled(false); + when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM); + controller.saveTeamLabels(sample()); + + ResponseEntity response = controller.resetTeamLabels(); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(controller.getTeamLabels().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/classification/model/LabelsValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/classification/model/LabelsValidatorTest.java new file mode 100644 index 0000000000..b9730370c7 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/classification/model/LabelsValidatorTest.java @@ -0,0 +1,146 @@ +package stirling.software.proprietary.classification.model; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Locale; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("LabelsValidator") +class LabelsValidatorTest { + + private static ClassificationLabels labels(ClassificationLabel... labels) { + return new ClassificationLabels(List.of(labels)); + } + + private static ClassificationLabel label(String name) { + return new ClassificationLabel(slug(name), name, null); + } + + private static String slug(String name) { + return name.trim() + .toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("(^-|-$)", ""); + } + + @Test + @DisplayName("accepts a well-formed label set") + void acceptsValid() { + ClassificationLabels set = + labels( + new ClassificationLabel("invoice", "Invoice", "receipt-long"), + label("Contract")); + assertThatCode(() -> LabelsValidator.validate(set)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("accepts an empty label set (reads as: use the default)") + void acceptsEmpty() { + assertThatCode(() -> LabelsValidator.validate(labels())).doesNotThrowAnyException(); + } + + @Test + @DisplayName("rejects a null label set") + void rejectsNull() { + assertThatThrownBy(() -> LabelsValidator.validate(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Labels are required"); + } + + @Test + @DisplayName("rejects duplicate names (distinct ids)") + void rejectsDuplicateNames() { + ClassificationLabels set = + labels( + new ClassificationLabel("invoice-a", "Invoice", null), + new ClassificationLabel("invoice-b", "Invoice", null)); + assertThatThrownBy(() -> LabelsValidator.validate(set)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Duplicate label name"); + } + + @Test + @DisplayName("rejects duplicate names differing only by case") + void rejectsDuplicateNamesCaseInsensitive() { + ClassificationLabels set = + labels( + new ClassificationLabel("invoice-a", "Invoice", null), + new ClassificationLabel("invoice-b", "INVOICE", null)); + assertThatThrownBy(() -> LabelsValidator.validate(set)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Duplicate label name"); + } + + @Test + @DisplayName("rejects duplicate ids") + void rejectsDuplicateIds() { + ClassificationLabels set = + labels( + new ClassificationLabel("invoice", "Invoice", null), + new ClassificationLabel("invoice", "Sales invoice", null)); + assertThatThrownBy(() -> LabelsValidator.validate(set)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Duplicate label id"); + } + + @Test + @DisplayName("rejects a blank name") + void rejectsBlankName() { + ClassificationLabels set = labels(new ClassificationLabel("blank", " ", null)); + assertThatThrownBy(() -> LabelsValidator.validate(set)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Label name must not be blank"); + } + + @Test + @DisplayName("rejects a blank id") + void rejectsBlankId() { + ClassificationLabels set = labels(new ClassificationLabel(" ", "Invoice", null)); + assertThatThrownBy(() -> LabelsValidator.validate(set)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Label id must not be blank"); + } + + @Test + @DisplayName("rejects an over-long name") + void rejectsOverLongName() { + ClassificationLabels set = + labels( + new ClassificationLabel( + "x", "x".repeat(LabelsValidator.MAX_TEXT_LENGTH + 1), null)); + assertThatThrownBy(() -> LabelsValidator.validate(set)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("too long"); + } + + @Test + @DisplayName("rejects an over-long icon (a null icon is fine)") + void rejectsOverLongIcon() { + ClassificationLabels set = + labels( + new ClassificationLabel( + "invoice", + "Invoice", + "x".repeat(LabelsValidator.MAX_TEXT_LENGTH + 1))); + assertThatThrownBy(() -> LabelsValidator.validate(set)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("icon is too long"); + } + + @Test + @DisplayName("rejects more labels than the cap") + void rejectsTooManyLabels() { + List tooMany = + IntStream.rangeClosed(0, LabelsValidator.MAX_LABELS) + .mapToObj(i -> label("label" + i)) + .toList(); + assertThatThrownBy(() -> LabelsValidator.validate(new ClassificationLabels(tooMany))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Too many labels"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java new file mode 100644 index 0000000000..c5ee39be14 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java @@ -0,0 +1,176 @@ +package stirling.software.proprietary.controller.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.PdfMetadataService; +import stirling.software.common.util.TempFileManager; +import stirling.software.proprietary.classification.model.ClassificationLabel; +import stirling.software.proprietary.classification.model.ClassificationLabels; +import stirling.software.proprietary.classification.store.InProcessClassificationLabelStore; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.service.AiEngineClient; +import stirling.software.proprietary.service.PdfContentExtractor; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ClassifyLabelControllerTest { + + private static final Long TEAM = 7L; + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private TempFileManager tempFileManager; + @Mock private PdfContentExtractor pdfContentExtractor; + @Mock private PdfMetadataService pdfMetadataService; + @Mock private AiEngineClient aiEngineClient; + @Mock private PolicyManagementAuthority policyManagementAuthority; + + private final ObjectMapper objectMapper = JsonMapper.builder().build(); + private InProcessClassificationLabelStore labelStore; + private ClassifyLabelController controller; + + @BeforeEach + void setUp() { + labelStore = new InProcessClassificationLabelStore(); + controller = + new ClassifyLabelController( + pdfDocumentFactory, + tempFileManager, + pdfContentExtractor, + pdfMetadataService, + aiEngineClient, + objectMapper, + null, + labelStore, + policyManagementAuthority); + } + + private void stubSinglePageDocument() throws Exception { + PDDocument document = mock(PDDocument.class); + MultipartFile file = mock(MultipartFile.class); + when(file.getOriginalFilename()).thenReturn("invoice.pdf"); + when(pdfDocumentFactory.load(any(MultipartFile.class), eq(true))).thenReturn(document); + when(document.getNumberOfPages()).thenReturn(1); + when(pdfContentExtractor.extractPageTextRaw(document, 1)) + .thenReturn("Invoice total due 100.00"); + when(aiEngineClient.post(eq("/api/v1/documents/classify"), anyString(), isNull())) + .thenReturn("{\"outcome\":\"classification\",\"labels\":[\"invoice\"]}"); + + try { + controller.classifyAndLabel(file); + } catch (Exception ignored) { + // WebResponseUtils.pdfDocToWebResponse needs a real temp file; the engine call and + // metadata write we assert on have already happened by the time it runs. + } + } + + private JsonNode sentEngineRequest() throws Exception { + ArgumentCaptor body = ArgumentCaptor.forClass(String.class); + verify(aiEngineClient).post(eq("/api/v1/documents/classify"), body.capture(), isNull()); + return objectMapper.readTree(body.getValue()); + } + + @Test + void classifyAndLabel_writesClassificationWithoutOutcome() throws Exception { + when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM); + labelStore.save( + TEAM, + new ClassificationLabels( + List.of(new ClassificationLabel("invoice", "Invoice", null))), + "admin"); + + stubSinglePageDocument(); + + ArgumentCaptor value = ArgumentCaptor.forClass(String.class); + verify(pdfMetadataService) + .setClassificationMetadata(any(PDDocument.class), value.capture()); + + JsonNode written = objectMapper.readTree(value.getValue()); + assertThat(written.has("outcome")).isFalse(); + // The engine returns label ids; they're stored on the document verbatim. + assertThat(written.get("labels").get(0).asText()).isEqualTo("invoice"); + } + + @Test + void classifyAndLabel_sendsTeamLabelIdsAndNames() throws Exception { + when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM); + labelStore.save( + TEAM, + new ClassificationLabels( + List.of( + new ClassificationLabel("invoice", "Invoice", "receipt-long"), + new ClassificationLabel("contract", "Contract", null), + new ClassificationLabel("timesheet", "Timesheet", null))), + "admin"); + + stubSinglePageDocument(); + + JsonNode request = sentEngineRequest(); + assertThat(request.get("fileName").asText()).isEqualTo("invoice.pdf"); + JsonNode labels = request.get("labels"); + assertThat(labels.isArray()).isTrue(); + assertThat(labels.size()).isEqualTo(3); + // Each entry is an {id, name} pair β€” the model reasons over names, we store ids. + assertThat( + List.of( + labels.get(0).get("id").asText(), + labels.get(1).get("id").asText(), + labels.get(2).get("id").asText())) + .containsExactly("invoice", "contract", "timesheet"); + assertThat( + List.of( + labels.get(0).get("name").asText(), + labels.get(1).get("name").asText(), + labels.get(2).get("name").asText())) + .containsExactly("Invoice", "Contract", "Timesheet"); + } + + @Test + void classifyAndLabel_skipsClassificationWhenNothingStored() throws Exception { + when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM); + + stubSinglePageDocument(); + + // No team labels stored, and the engine holds no default of its own, so the file is passed + // through unlabelled: neither the engine nor the metadata write is invoked. + verify(aiEngineClient, never()).post(anyString(), anyString(), any()); + verify(pdfMetadataService, never()) + .setClassificationMetadata(any(PDDocument.class), anyString()); + } + + @Test + void windowPageNumbers_takesFirstAndLastWithoutOverlap() { + assertEquals(List.of(1, 2, 4, 5), ClassifyLabelController.windowPageNumbers(5, 2)); + assertEquals(List.of(1, 2, 3), ClassifyLabelController.windowPageNumbers(3, 2)); + // Short docs clamp + dedupe rather than throwing or going out of range. + assertEquals(List.of(1, 2), ClassifyLabelController.windowPageNumbers(2, 2)); + assertEquals(List.of(1), ClassifyLabelController.windowPageNumbers(1, 2)); + assertEquals(List.of(), ClassifyLabelController.windowPageNumbers(0, 2)); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerMoreTest.java index 3ddd9e9a1e..585edc6e66 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerMoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerMoreTest.java @@ -24,6 +24,7 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.access.service.ResourceAccessService; import stirling.software.proprietary.config.AuditConfigurationProperties; import stirling.software.proprietary.controller.api.ProprietaryUIDataController.AccountData; import stirling.software.proprietary.controller.api.ProprietaryUIDataController.AdminSettingsData; @@ -39,6 +40,7 @@ import stirling.software.proprietary.security.database.repository.SessionReposit import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; import stirling.software.proprietary.security.service.DatabaseServiceInterface; @@ -57,12 +59,14 @@ class ProprietaryUIDataControllerMoreTest { @Mock private SessionPersistentRegistry sessionPersistentRegistry; @Mock private UserRepository userRepository; @Mock private TeamRepository teamRepository; + @Mock private TeamMembershipRepository teamMembershipRepository; @Mock private SessionRepository sessionRepository; @Mock private DatabaseServiceInterface databaseService; @Mock private UserLicenseSettingsService licenseSettingsService; @Mock private PersistentAuditEventRepository auditRepository; @Mock private MfaService mfaService; @Mock private LoginAttemptService loginAttemptService; + @Mock private ResourceAccessService resourceAccessService; private ApplicationProperties applicationProperties; private AuditConfigurationProperties auditConfig; @@ -87,6 +91,7 @@ class ProprietaryUIDataControllerMoreTest { sessionPersistentRegistry, userRepository, teamRepository, + teamMembershipRepository, sessionRepository, databaseService, objectMapper, @@ -94,7 +99,8 @@ class ProprietaryUIDataControllerMoreTest { licenseSettingsService, auditRepository, mfaService, - loginAttemptService); + loginAttemptService, + resourceAccessService); } private static User normalUser(Long id, String username) { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java index b8552e274c..0b01c9bcf5 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java @@ -18,6 +18,7 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.access.service.ResourceAccessService; import stirling.software.proprietary.config.AuditConfigurationProperties; import stirling.software.proprietary.controller.api.ProprietaryUIDataController.AccountData; import stirling.software.proprietary.controller.api.ProprietaryUIDataController.DatabaseData; @@ -27,6 +28,7 @@ import stirling.software.proprietary.security.database.repository.SessionReposit import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.service.DatabaseService; import stirling.software.proprietary.security.service.LoginAttemptService; @@ -43,12 +45,14 @@ class ProprietaryUIDataControllerTest { @Mock private SessionPersistentRegistry sessionPersistentRegistry; @Mock private UserRepository userRepository; @Mock private TeamRepository teamRepository; + @Mock private TeamMembershipRepository teamMembershipRepository; @Mock private SessionRepository sessionRepository; @Mock private DatabaseService databaseService; @Mock private UserLicenseSettingsService licenseSettingsService; @Mock private PersistentAuditEventRepository auditRepository; @Mock private MfaService mfaService; @Mock private LoginAttemptService loginAttemptService; + @Mock private ResourceAccessService resourceAccessService; private ApplicationProperties applicationProperties; private AuditConfigurationProperties auditConfig; @@ -75,6 +79,7 @@ class ProprietaryUIDataControllerTest { sessionPersistentRegistry, userRepository, teamRepository, + teamMembershipRepository, sessionRepository, databaseService, objectMapper, @@ -82,7 +87,8 @@ class ProprietaryUIDataControllerTest { licenseSettingsService, auditRepository, mfaService, - loginAttemptService); + loginAttemptService, + resourceAccessService); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java index 9f3b2d84af..8e0ef200bb 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java @@ -49,6 +49,9 @@ class IntegrationConfigServiceTest { @Mock private OwnershipService ownership; @Mock private SecretMasker secretMasker; + @Mock + private stirling.software.proprietary.access.repository.ResourceGrantRepository grantRepository; + @InjectMocks private IntegrationConfigService service; @Test @@ -58,9 +61,9 @@ class IntegrationConfigServiceTest { User user = user(7); IntegrationConfig created = - service.create(request(IntegrationType.S3, OwnerScope.USER, null), user); + service.create(request(IntegrationType.API, OwnerScope.USER, null), user); - assertThat(created.getIntegrationType()).isEqualTo(IntegrationType.S3); + assertThat(created.getIntegrationType()).isEqualTo(IntegrationType.API); assertThat(created.getName()).isEqualTo("name"); verify(ownership) .assignOwnership(eq(created), eq(OwnerScope.USER), isNull(), eq(user), any()); @@ -156,6 +159,60 @@ class IntegrationConfigServiceTest { .isEqualTo(HttpStatus.FORBIDDEN); } + // ---- S3 type policy ---- + + @Test + void s3PersonalCreateForbiddenForRegularUser() { + User user = user(7); + when(ownership.isAdmin(user)).thenReturn(false); + + assertThatThrownBy( + () -> + service.create( + request(IntegrationType.S3, OwnerScope.USER, null), user)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void s3PersonalCreateAllowedForAdmin() { + when(secretMasker.sanitize(any())).thenReturn(Map.of("bucket", "b")); + when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + User admin = user(1); + when(ownership.isAdmin(admin)).thenReturn(true); + + IntegrationConfig created = + service.create(request(IntegrationType.S3, OwnerScope.USER, null), admin); + + assertThat(created.getIntegrationType()).isEqualTo(IntegrationType.S3); + } + + @Test + void s3TeamScopeCreateDelegatesLeadershipToOwnership() { + // TEAM scope skips the personal-S3 gate; assignOwnership enforces admin/team-owner. + when(secretMasker.sanitize(any())).thenReturn(Map.of("bucket", "b")); + when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + User leader = user(7); + + IntegrationConfig created = + service.create(request(IntegrationType.S3, OwnerScope.TEAM, 3L), leader); + + verify(ownership) + .assignOwnership(eq(created), eq(OwnerScope.TEAM), eq(3L), eq(leader), any()); + } + + @Test + void mcpPersonalCreateAllowedForRegularUser() { + when(secretMasker.sanitize(any())).thenReturn(Map.of("token", "t")); + when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + IntegrationConfig created = + service.create(request(IntegrationType.MCP, OwnerScope.USER, null), user(7)); + + assertThat(created.getIntegrationType()).isEqualTo(IntegrationType.MCP); + } + // ---- helpers ---- private IntegrationConfig config(long id) { diff --git a/app/saas/src/test/java/stirling/software/saas/model/TeamMembershipTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/model/TeamMembershipTest.java similarity index 98% rename from app/saas/src/test/java/stirling/software/saas/model/TeamMembershipTest.java rename to app/proprietary/src/test/java/stirling/software/proprietary/model/TeamMembershipTest.java index a94e8b9373..653fcd0ef1 100644 --- a/app/saas/src/test/java/stirling/software/saas/model/TeamMembershipTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/model/TeamMembershipTest.java @@ -1,4 +1,4 @@ -package stirling.software.saas.model; +package stirling.software.proprietary.model; import static org.assertj.core.api.Assertions.assertThat; @@ -9,7 +9,6 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import stirling.software.common.model.enumeration.TeamRole; -import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.model.User; /** Constructor, accessor, equals/hashCode/toString, and role-helper tests for TeamMembership. */ diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java index 8a9a085653..f38263ad3e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -34,6 +34,7 @@ import stirling.software.proprietary.policy.engine.PolicyRunHandle; import stirling.software.proprietary.policy.engine.PolicyRunRegistry; import stirling.software.proprietary.policy.engine.PolicyRunner; import stirling.software.proprietary.policy.engine.PolicyValidator; +import stirling.software.proprietary.policy.ledger.ProcessedLedger; import stirling.software.proprietary.policy.model.PipelineDefinition; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; @@ -62,6 +63,8 @@ class PolicyControllerTest { private stirling.software.proprietary.policy.overview.PolicyOverviewService policyOverviewService; + @Mock private ProcessedLedger processedLedger; + @Mock private TempFileManager tempFileManager; @Mock private JobOwnershipService jobOwnershipService; @@ -89,6 +92,7 @@ class PolicyControllerTest { policyManagementAuthority, policyTriggerManager, policyOverviewService, + processedLedger, policyTriggers, applicationProperties, tempFileManager, @@ -398,6 +402,7 @@ class PolicyControllerTest { ResponseEntity response = controller.deletePolicy("a"); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(processedLedger).clearPolicy("a"); verify(policyTriggerManager).notifyPoliciesChanged(); } @@ -431,6 +436,53 @@ class PolicyControllerTest { } } + @Nested + @DisplayName("clearProcessedHistory") + class ClearProcessedHistory { + + @Test + @DisplayName("clears an accessible policy's history") + void clears() { + applicationProperties.getSecurity().setEnableLogin(false); + Policy p = policy("a", 1L); + when(policyStore.get("a")).thenReturn(Optional.of(p)); + when(policyAccessGuard.canAccess(p)).thenReturn(true); + + ResponseEntity response = controller.clearProcessedHistory("a"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(processedLedger).clearPolicy("a"); + } + + @Test + @DisplayName("returns 404 when policy is not accessible") + void notAccessible() { + applicationProperties.getSecurity().setEnableLogin(false); + Policy p = policy("a", 1L); + when(policyStore.get("a")).thenReturn(Optional.of(p)); + when(policyAccessGuard.canAccess(p)).thenReturn(false); + + ResponseEntity response = controller.clearProcessedHistory("a"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + verify(processedLedger, never()).clearPolicy(any()); + } + + @Test + @DisplayName("forbidden when login enabled and caller cannot edit") + void forbidden() { + applicationProperties.getSecurity().setEnableLogin(true); + when(policyManagementAuthority.canEditPolicies()).thenReturn(false); + + assertThatThrownBy(() -> controller.clearProcessedHistory("a")) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN)); + } + } + @Nested @DisplayName("runStoredPolicy") class RunStoredPolicy { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java index b1ab05f8ba..13189b2481 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java @@ -4,15 +4,19 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import java.io.IOException; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; @@ -24,7 +28,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import stirling.software.proprietary.policy.input.InputSource; +import stirling.software.proprietary.policy.input.ResolveContext; import stirling.software.proprietary.policy.input.ResolvedInput; +import stirling.software.proprietary.policy.ledger.ProcessedLedger; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineStep; @@ -39,15 +45,15 @@ import stirling.software.proprietary.policy.source.Source; import stirling.software.proprietary.policy.source.SourceStore; /** - * Tests for {@link PolicyRunner}: the one place that turns a policy's sources into runs. Verifies - * it pulls every source, runs one job per unit of work, feeds each unit's completion hook the run - * outcome, and that a generator (no sources) still runs once. + * Tests for {@link PolicyRunner}: the one place that turns a policy's sources into runs, and the + * orchestrator of ledger hygiene (presence stamping + cleanup on complete FULL sweeps). */ @ExtendWith(MockitoExtension.class) class PolicyRunnerTest { @Mock private PolicyEngine policyEngine; @Mock private InputSource folderSource; + @Mock private ProcessedLedger processedLedger; private final SourceStore sourceStore = new InProcessSourceStore(); private PolicyRunner runner; @@ -59,7 +65,8 @@ class PolicyRunnerTest { policyEngine, List.of(folderSource), sourceStore, - new InProcessSourceDocCounter()); + new InProcessSourceDocCounter(), + processedLedger); } @Test @@ -73,6 +80,9 @@ class PolicyRunnerTest { ArgumentCaptor inputs = ArgumentCaptor.forClass(PolicyInputs.class); verify(policyEngine).runPolicy(eq(policy), inputs.capture(), any()); assertTrue(inputs.getValue().primary().isEmpty()); + // Ledger hygiene still runs: rows recorded for a generator policy's folder outputs + // are pruned by its own sweeps rather than accumulating until the policy is deleted. + verify(processedLedger).deleteUnseen(eq("p1"), anyLong()); } @Test @@ -80,7 +90,7 @@ class PolicyRunnerTest { InputSpec spec = InputSpec.folder("/in"); Policy policy = policy(List.of(spec)); when(folderSource.supports(spec)).thenReturn(true); - when(folderSource.resolve(spec)) + when(folderSource.resolve(eq(spec), any())) .thenReturn( List.of( ResolvedInput.of(PolicyInputs.of(List.of())), @@ -100,7 +110,7 @@ class PolicyRunnerTest { AtomicBoolean outcome = new AtomicBoolean(false); ResolvedInput unit = new ResolvedInput(PolicyInputs.of(List.of()), outcome::set); when(folderSource.supports(spec)).thenReturn(true); - when(folderSource.resolve(spec)).thenReturn(List.of(unit)); + when(folderSource.resolve(eq(spec), any())).thenReturn(List.of(unit)); CompletableFuture completion = new CompletableFuture<>(); when(policyEngine.runPolicy(any(), any(), any())) .thenReturn(new PolicyRunHandle("r", completion)); @@ -121,7 +131,7 @@ class PolicyRunnerTest { AtomicBoolean outcome = new AtomicBoolean(true); ResolvedInput unit = new ResolvedInput(PolicyInputs.of(List.of()), outcome::set); when(folderSource.supports(spec)).thenReturn(true); - when(folderSource.resolve(spec)).thenReturn(List.of(unit)); + when(folderSource.resolve(eq(spec), any())).thenReturn(List.of(unit)); CompletableFuture completion = new CompletableFuture<>(); when(policyEngine.runPolicy(any(), any(), any())) .thenReturn(new PolicyRunHandle("r", completion)); @@ -143,6 +153,98 @@ class PolicyRunnerTest { verifyNoInteractions(policyEngine); } + @Test + void aFullSweepStampsPresenceAndPrunesUnseenRows() throws Exception { + InputSpec spec = InputSpec.folder("/in"); + Policy policy = policy(List.of(spec)); + when(folderSource.supports(spec)).thenReturn(true); + when(folderSource.listsExhaustively()).thenReturn(true); + when(folderSource.resolve(eq(spec), any())) + .thenAnswer( + invocation -> { + ResolveContext ctx = invocation.getArgument(1); + ctx.reportPresent(List.of("/in/a.pdf", "/in/b.pdf")); + return List.of(); + }); + + runner.run(policy); + + // Presence reporting also bulk-prefetches claim state: one lookup for the whole listing. + verify(processedLedger).statesFor(eq("p1"), eq(List.of("/in/a.pdf", "/in/b.pdf"))); + verify(processedLedger).markSeen("p1", Set.of("/in/a.pdf", "/in/b.pdf")); + verify(processedLedger).deleteUnseen(eq("p1"), anyLong()); + } + + @Test + void aLightSweepClaimsButSkipsLedgerHygiene() throws Exception { + InputSpec spec = InputSpec.folder("/in"); + Policy policy = policy(List.of(spec)); + when(folderSource.supports(spec)).thenReturn(true); + when(folderSource.resolve(eq(spec), any())) + .thenReturn(List.of(ResolvedInput.of(PolicyInputs.of(List.of())))); + when(policyEngine.runPolicy(any(), any(), any())) + .thenReturn(new PolicyRunHandle("r", new CompletableFuture<>())); + + runner.run(policy, SweepKind.LIGHT); + + verify(policyEngine).runPolicy(eq(policy), any(), any()); + verify(processedLedger, never()).markSeen(any(), any()); + verify(processedLedger, never()).deleteUnseen(any(), anyLong()); + } + + @Test + void aSourceThatFailsToResolveVetoesCleanupButOthersStillRun() throws Exception { + InputSpec broken = InputSpec.folder("/broken"); + InputSpec healthy = InputSpec.folder("/healthy"); + Policy policy = policy(List.of(broken, healthy)); + when(folderSource.supports(any())).thenReturn(true); + when(folderSource.listsExhaustively()).thenReturn(true); + when(folderSource.resolve(eq(broken), any())).thenThrow(new IOException("mount gone")); + when(folderSource.resolve(eq(healthy), any())) + .thenReturn(List.of(ResolvedInput.of(PolicyInputs.of(List.of())))); + when(policyEngine.runPolicy(any(), any(), any())) + .thenReturn(new PolicyRunHandle("r", new CompletableFuture<>())); + + runner.run(policy); + + verify(policyEngine).runPolicy(eq(policy), any(), any()); // healthy source still ran + verify(processedLedger, never()).deleteUnseen(any(), anyLong()); // history preserved + } + + @Test + void aDisabledSourceVetoesCleanup() { + InputSpec spec = InputSpec.folder("/in"); + String pausedId = sourceStore.save(disabledSourceFrom(spec)).id(); + Policy policy = policyReferencing(List.of(pausedId)); + + runner.run(policy); + + verify(processedLedger, never()).deleteUnseen(any(), anyLong()); + } + + @Test + void aNonExhaustiveSourceVetoesCleanup() throws Exception { + InputSpec spec = InputSpec.folder("/in"); + Policy policy = policy(List.of(spec)); + when(folderSource.supports(spec)).thenReturn(true); + when(folderSource.listsExhaustively()).thenReturn(false); + when(folderSource.resolve(eq(spec), any())).thenReturn(List.of()); + + runner.run(policy); + + verify(processedLedger, never()).deleteUnseen(any(), anyLong()); + } + + @Test + void aMissingSourceDoesNotVetoCleanup() { + // A deleted source's rows age out precisely because cleanup still runs. + Policy policy = policyReferencing(List.of("ghost-source-id")); + + runner.run(policy); + + verify(processedLedger).deleteUnseen(eq("p1"), anyLong()); + } + @Test void runWithSuppliedInputsBypassesSources() { Policy policy = policy(List.of(InputSpec.folder("/in"))); @@ -159,6 +261,10 @@ class PolicyRunnerTest { private Policy policy(List sources) { List sourceIds = sources.stream().map(spec -> sourceStore.save(sourceFrom(spec)).id()).toList(); + return policyReferencing(sourceIds); + } + + private static Policy policyReferencing(List sourceIds) { return new Policy( "p1", "p", @@ -173,4 +279,8 @@ class PolicyRunnerTest { private static Source sourceFrom(InputSpec spec) { return new Source(null, "src", spec.type(), spec.options(), true, "owner", null); } + + private static Source disabledSourceFrom(InputSpec spec) { + return new Source(null, "src", spec.type(), spec.options(), false, "owner", null); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java index 62bfa3aabb..64178ce9bb 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java @@ -1,17 +1,23 @@ package stirling.software.proprietary.policy.input; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.function.Supplier; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -24,18 +30,26 @@ import org.springframework.core.env.StandardEnvironment; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.FileReadinessChecker; import stirling.software.proprietary.policy.config.FolderAccessGuard; +import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.source.InProcessSourceStore; -/** Tests for {@link FolderInputSource}: consume (claim + route) and snapshot (read-only) modes. */ +/** + * Tests for {@link FolderInputSource}: consume mode tracks files in place through the ledger, + * snapshot stays stateless, and discovery skips hidden entries and honours the recursive option. + */ @ExtendWith(MockitoExtension.class) class FolderInputSourceTest { + private static final String POLICY = "p1"; + @Mock private FileReadinessChecker readinessChecker; @TempDir Path tempDir; private FolderInputSource source; + private InProcessProcessedLedger ledger; + private RecordingContext ctx; @BeforeEach void setUp() { @@ -45,73 +59,279 @@ class FolderInputSourceTest { new FolderAccessGuard( properties, new StandardEnvironment(), new InProcessSourceStore()); source = new FolderInputSource(readinessChecker, guard); + ledger = new InProcessProcessedLedger(); + ctx = new RecordingContext(); // Lenient: the missing-dir / nonexistent-dir cases return before any readiness check. lenient().when(readinessChecker.isReady(any())).thenReturn(true); } @Test - void consumeClaimsFilesAndRoutesToDoneOnSuccess() throws IOException { + void consumeRemovesTheFileOnceProcessed() throws IOException { Path inputDir = Files.createDirectories(tempDir.resolve("in")); - Files.writeString(inputDir.resolve("doc.pdf"), "data"); + Path file = inputDir.resolve("doc.pdf"); + Files.writeString(file, "data"); - List work = source.resolve(InputSpec.folder(inputDir.toString())); + List work = source.resolve(InputSpec.folder(inputDir.toString()), ctx); assertEquals(1, work.size()); assertEquals(1, work.get(0).inputs().primary().size()); - // Claimed out of the input dir. - assertFalse(Files.exists(inputDir.resolve("doc.pdf"))); - assertTrue( - Files.exists( - inputDir.resolve(".stirling").resolve("processing").resolve("doc.pdf"))); + // In flight: still on disk, but a second sweep does not pick it up again. + assertTrue(Files.exists(file)); + assertTrue(Files.notExists(inputDir.resolve(".stirling"))); + assertTrue(source.resolve(InputSpec.folder(inputDir.toString()), ctx).isEmpty()); work.get(0).onComplete().accept(true); - assertTrue(Files.exists(inputDir.resolve(".stirling").resolve("done").resolve("doc.pdf"))); - assertFalse( - Files.exists( - inputDir.resolve(".stirling").resolve("processing").resolve("doc.pdf"))); + assertTrue(Files.notExists(file)); + assertTrue(source.resolve(InputSpec.folder(inputDir.toString()), ctx).isEmpty()); } @Test - void consumeRoutesToErrorOnFailure() throws IOException { + void aFileReplacedMidRunSurvivesTheDeleteAndRunsAgain() throws IOException { Path inputDir = Files.createDirectories(tempDir.resolve("in")); - Files.writeString(inputDir.resolve("doc.pdf"), "data"); + Path file = inputDir.resolve("doc.pdf"); + Files.writeString(file, "data"); - List work = source.resolve(InputSpec.folder(inputDir.toString())); - work.get(0).onComplete().accept(false); + List work = source.resolve(InputSpec.folder(inputDir.toString()), ctx); + // The user saves a new version while the run is executing. + Files.writeString(file, "new data, different size"); + work.get(0).onComplete().accept(true); - assertTrue(Files.exists(inputDir.resolve(".stirling").resolve("error").resolve("doc.pdf"))); + // The delete is version-guarded: the replacement is not the file that ran, so it stays + // and is claimed as fresh work instead of being marked processed. + assertTrue(Files.exists(file)); + assertEquals(1, source.resolve(InputSpec.folder(inputDir.toString()), ctx).size()); } @Test - void snapshotReadsWithoutClaiming() throws IOException { + void aSharedFileIsRemovedOnlyOnceEveryPolicyHasProcessedIt() throws IOException { + Path inputDir = Files.createDirectories(tempDir.resolve("in")); + Path file = inputDir.resolve("doc.pdf"); + Files.writeString(file, "data"); + InputSpec spec = InputSpec.folder(inputDir.toString()); + RecordingContext other = new RecordingContext("p2"); + + List mine = source.resolve(spec, ctx); + List theirs = source.resolve(spec, other); + assertEquals(1, mine.size()); + assertEquals(1, theirs.size()); + + mine.get(0).onComplete().accept(true); + // The other policy's claim is still in flight, so the first finisher must not delete. + assertTrue(Files.exists(file)); + + theirs.get(0).onComplete().accept(true); + assertTrue(Files.notExists(file)); + } + + @Test + void aSharedFileStaysParkedWhenAnyPolicyFailsOnIt() throws IOException { + Path inputDir = Files.createDirectories(tempDir.resolve("in")); + Path file = inputDir.resolve("doc.pdf"); + Files.writeString(file, "data"); + InputSpec spec = InputSpec.folder(inputDir.toString()); + RecordingContext other = new RecordingContext("p2"); + + List mine = source.resolve(spec, ctx); + List theirs = source.resolve(spec, other); + + theirs.get(0).onComplete().accept(false); + mine.get(0).onComplete().accept(true); + + // The failure parks the file for everyone (retried when it changes), regardless of + // which policy settled last. + assertTrue(Files.exists(file)); + assertTrue(source.resolve(spec, ctx).isEmpty()); + assertTrue(source.resolve(spec, other).isEmpty()); + } + + @Test + void aReDroppedFileIsProcessedAgain() throws IOException { + Path inputDir = Files.createDirectories(tempDir.resolve("in")); + Path file = inputDir.resolve("doc.pdf"); + Files.writeString(file, "data"); + + source.resolve(InputSpec.folder(inputDir.toString()), ctx).get(0).onComplete().accept(true); + Files.writeString(file, "data again"); + + assertEquals(1, source.resolve(InputSpec.folder(inputDir.toString()), ctx).size()); + } + + @Test + void aFailedFileStaysInPlaceAndIsNotRetriedUntilItChanges() throws IOException { + Path inputDir = Files.createDirectories(tempDir.resolve("in")); + Path file = inputDir.resolve("doc.pdf"); + Files.writeString(file, "data"); + + source.resolve(InputSpec.folder(inputDir.toString()), ctx) + .get(0) + .onComplete() + .accept(false); + + assertTrue(Files.exists(file)); + assertTrue(source.resolve(InputSpec.folder(inputDir.toString()), ctx).isEmpty()); + + Files.setLastModifiedTime(file, FileTime.from(Instant.now().plusSeconds(60))); + assertEquals(1, source.resolve(InputSpec.folder(inputDir.toString()), ctx).size()); + } + + @Test + void statModeRetriesAFailureOnATouchButHashModeDoesNot() throws IOException { + Path statDir = Files.createDirectories(tempDir.resolve("stat")); + Path hashDir = Files.createDirectories(tempDir.resolve("hash")); + Path statFile = statDir.resolve("doc.pdf"); + Path hashFile = hashDir.resolve("doc.pdf"); + Files.writeString(statFile, "data"); + Files.writeString(hashFile, "data"); + InputSpec statSpec = InputSpec.folder(statDir.toString()); + InputSpec hashSpec = + new InputSpec( + "folder", Map.of("directory", hashDir.toString(), "identity", "hash")); + + source.resolve(statSpec, ctx).get(0).onComplete().accept(false); + source.resolve(hashSpec, ctx).get(0).onComplete().accept(false); + + FileTime touched = FileTime.from(Instant.now().plusSeconds(60)); + Files.setLastModifiedTime(statFile, touched); + Files.setLastModifiedTime(hashFile, touched); + + // Same content, new mtime: stat mode calls that a new version and retries; hash mode + // verifies the content is unchanged and keeps the failure parked. + assertEquals(1, source.resolve(statSpec, ctx).size()); + assertTrue(source.resolve(hashSpec, ctx).isEmpty()); + } + + @Test + void hashModeRetriesAFailureOnARealContentChange() throws IOException { + Path inputDir = Files.createDirectories(tempDir.resolve("in")); + Path file = inputDir.resolve("doc.pdf"); + Files.writeString(file, "data"); + InputSpec spec = + new InputSpec( + "folder", Map.of("directory", inputDir.toString(), "identity", "hash")); + + source.resolve(spec, ctx).get(0).onComplete().accept(false); + Files.writeString(file, "data v2 - longer"); + + assertEquals(1, source.resolve(spec, ctx).size()); + } + + @Test + void snapshotReadsStatelesslyEverySweep() throws IOException { Path inputDir = Files.createDirectories(tempDir.resolve("in")); Files.writeString(inputDir.resolve("doc.pdf"), "data"); + InputSpec spec = + new InputSpec( + "folder", Map.of("directory", inputDir.toString(), "mode", "snapshot")); - List work = - source.resolve( - new InputSpec( - "folder", - Map.of("directory", inputDir.toString(), "mode", "snapshot"))); + List first = source.resolve(spec, ctx); + first.get(0).onComplete().accept(true); + List second = source.resolve(spec, ctx); + + assertEquals(1, first.size()); + assertEquals(1, second.size()); // no ledger involvement: every run sees the full set + assertTrue(ctx.present.isEmpty()); + assertTrue(Files.exists(inputDir.resolve("doc.pdf"))); + } + + @Test + void hiddenFilesAndTheLegacyWorkDirAreIgnored() throws IOException { + Path inputDir = Files.createDirectories(tempDir.resolve("in")); + Files.writeString(inputDir.resolve("doc.pdf"), "data"); + Files.writeString(inputDir.resolve(".hidden.pdf"), "secret"); + Path legacy = Files.createDirectories(inputDir.resolve(".stirling").resolve("done")); + Files.writeString(legacy.resolve("old.pdf"), "processed long ago"); + + List work = source.resolve(InputSpec.folder(inputDir.toString()), ctx); assertEquals(1, work.size()); - // Not moved, and completing the run is a no-op. - assertTrue(Files.exists(inputDir.resolve("doc.pdf"))); - work.get(0).onComplete().accept(true); - assertTrue(Files.exists(inputDir.resolve("doc.pdf"))); + assertEquals(1, ctx.present.size()); + assertTrue(ctx.present.get(0).endsWith("doc.pdf")); + } + + @Test + void recursiveDiscoversSubdirectoriesButNotHiddenOnes() throws IOException { + Path inputDir = Files.createDirectories(tempDir.resolve("in")); + Files.writeString(inputDir.resolve("top.pdf"), "a"); + Path sub = Files.createDirectories(inputDir.resolve("sub")); + Files.writeString(sub.resolve("nested.pdf"), "b"); + Path hiddenDir = Files.createDirectories(inputDir.resolve(".stirling")); + Files.writeString(hiddenDir.resolve("skipped.pdf"), "c"); + // Sink staging inside a watched subdirectory is pruned at any depth. + Path nestedStaging = Files.createDirectories(sub.resolve(".stirling").resolve("tmp")); + Files.writeString(nestedStaging.resolve("half-delivered"), "d"); + + InputSpec flat = InputSpec.folder(inputDir.toString()); + InputSpec recursive = + new InputSpec( + "folder", Map.of("directory", inputDir.toString(), "recursive", "true")); + + assertEquals(1, source.resolve(flat, ctx).size()); + assertEquals(1, source.resolve(recursive, ctx).size()); // top.pdf already claimed above + assertTrue(ctx.present.stream().anyMatch(identity -> identity.endsWith("nested.pdf"))); + assertTrue(ctx.present.stream().noneMatch(identity -> identity.endsWith("skipped.pdf"))); + assertTrue(ctx.present.stream().noneMatch(identity -> identity.endsWith("half-delivered"))); + } + + @Test + void unreadyFilesAreReportedPresentButNotClaimed() throws IOException { + Path inputDir = Files.createDirectories(tempDir.resolve("in")); + Path file = inputDir.resolve("mid-write.pdf"); + Files.writeString(file, "partial"); + when(readinessChecker.isReady(file)).thenReturn(false); + + List work = source.resolve(InputSpec.folder(inputDir.toString()), ctx); + + assertTrue(work.isEmpty()); + // Reported present so a full sweep does not prune its row while it settles on disk. + assertEquals(1, ctx.present.size()); + assertTrue(ctx.present.get(0).endsWith("mid-write.pdf")); + } + + @Test + void nestedSourcesShareThePolicysLedgerAndDoNotDoubleClaim() throws IOException { + Path parent = Files.createDirectories(tempDir.resolve("in")); + Path child = Files.createDirectories(parent.resolve("sub")); + Files.writeString(child.resolve("doc.pdf"), "data"); + InputSpec parentRecursive = + new InputSpec( + "folder", Map.of("directory", parent.toString(), "recursive", "true")); + InputSpec childFlat = InputSpec.folder(child.toString()); + + // Same sweep, same policy context: whichever source resolves first wins the file. + assertEquals(1, source.resolve(parentRecursive, ctx).size()); + assertTrue(source.resolve(childFlat, ctx).isEmpty()); } @Test void missingDirectoryOptionFails() { assertThrows( IllegalArgumentException.class, - () -> source.resolve(new InputSpec("folder", Map.of()))); + () -> source.resolve(new InputSpec("folder", Map.of()), ctx)); } @Test - void nonexistentDirectoryYieldsNoWork() throws IOException { - List work = - source.resolve(InputSpec.folder(tempDir.resolve("nope").toString())); - assertTrue(work.isEmpty()); + void anUnknownIdentityModeIsRejected() { + assertThrows( + IllegalArgumentException.class, + () -> + source.validate( + new InputSpec( + "folder", + Map.of( + "directory", + tempDir.toString(), + "identity", + "guesswork")))); + } + + @Test + void nonexistentDirectoryFailsResolveSoTheSweepVetoesCleanup() { + // An unreachable directory (e.g. unmounted drive) must surface as a failed listing, not + // an empty one: the runner vetoes presence cleanup on failure, keeping the history that + // an empty listing would wipe. + assertThrows( + NoSuchFileException.class, + () -> source.resolve(InputSpec.folder(tempDir.resolve("nope").toString()), ctx)); } @Test @@ -126,7 +346,7 @@ class FolderInputSourceTest { Path outside = tempDir.resolveSibling("not-allowed"); assertThrows( IllegalArgumentException.class, - () -> source.resolve(InputSpec.folder(outside.toString()))); + () -> source.resolve(InputSpec.folder(outside.toString()), ctx)); assertThrows( IllegalArgumentException.class, () -> source.validate(InputSpec.folder(outside.toString()))); @@ -137,4 +357,40 @@ class FolderInputSourceTest { Path inputDir = tempDir.resolve("in"); assertEquals(List.of(inputDir), source.watchTargets(InputSpec.folder(inputDir.toString()))); } + + /** Policy-scoped context backed by the in-process ledger, recording presence reports. */ + private class RecordingContext implements ResolveContext { + + private final String policyId; + private final List present = new ArrayList<>(); + + private RecordingContext() { + this(POLICY); + } + + private RecordingContext(String policyId) { + this.policyId = policyId; + } + + @Override + public boolean claim(String identity, String gate, Supplier contentHash) { + return ledger.claim(policyId, identity, gate, contentHash); + } + + @Override + public void settle( + String identity, String finalGate, String finalContentHash, boolean success) { + ledger.settle(policyId, identity, finalGate, finalContentHash, success); + } + + @Override + public boolean allSettledDone(String identity) { + return ledger.allSettledDone(identity); + } + + @Override + public void reportPresent(Collection identities) { + present.addAll(identities); + } + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/FolderIdentitiesTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/FolderIdentitiesTest.java new file mode 100644 index 0000000000..9f56411d9b --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/FolderIdentitiesTest.java @@ -0,0 +1,84 @@ +package stirling.software.proprietary.policy.ledger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.time.Instant; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Tests for {@link FolderIdentities}: identity derivation must agree for the same file, even + * through a symlinked alias of the directory. + */ +class FolderIdentitiesTest { + + @TempDir Path tempDir; + + @Test + void identityAgreesAcrossASymlinkedAliasOfTheDirectory() throws IOException { + Path real = Files.createDirectories(tempDir.resolve("real")); + Path alias = Files.createSymbolicLink(tempDir.resolve("alias"), real); + Files.writeString(real.resolve("doc.pdf"), "data"); + + String viaReal = + FolderIdentities.identity( + FolderIdentities.canonicalDir(real), real, real.resolve("doc.pdf")); + String viaAlias = + FolderIdentities.identity( + FolderIdentities.canonicalDir(alias), alias, alias.resolve("doc.pdf")); + + assertEquals(viaReal, viaAlias); + } + + @Test + void identityOfANestedFileKeepsItsRelativePath() throws IOException { + Path dir = Files.createDirectories(tempDir.resolve("in")); + Path nested = Files.createDirectories(dir.resolve("sub")).resolve("doc.pdf"); + Files.writeString(nested, "data"); + + String identity = + FolderIdentities.identity(FolderIdentities.canonicalDir(dir), dir, nested); + + assertTrue(identity.endsWith("sub" + java.io.File.separator + "doc.pdf")); + } + + @Test + void theGateTracksSizeAndMtime() throws IOException { + Path file = tempDir.resolve("doc.pdf"); + Files.writeString(file, "data"); + String before = FolderIdentities.statGate(file); + + Files.setLastModifiedTime(file, FileTime.from(Instant.now().plusSeconds(60))); + + assertNotEquals(before, FolderIdentities.statGate(file)); + } + + @Test + void theContentHashIgnoresMtimeButTracksContent() throws IOException { + Path file = tempDir.resolve("doc.pdf"); + Files.writeString(file, "data"); + String before = FolderIdentities.contentHash(file); + + Files.setLastModifiedTime(file, FileTime.from(Instant.now().plusSeconds(60))); + assertEquals(before, FolderIdentities.contentHash(file)); + + Files.writeString(file, "different"); + assertNotEquals(before, FolderIdentities.contentHash(file)); + } + + @Test + void identityHashIsAStableFixedWidthKey() { + String hash = IdentityHasher.identityHash("/in/doc.pdf"); + + assertEquals(64, hash.length()); + assertEquals(hash, IdentityHasher.identityHash("/in/doc.pdf")); + assertNotEquals(hash, IdentityHasher.identityHash("/in/other.pdf")); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/InProcessProcessedLedgerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/InProcessProcessedLedgerTest.java new file mode 100644 index 0000000000..08ac10061d --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/InProcessProcessedLedgerTest.java @@ -0,0 +1,12 @@ +package stirling.software.proprietary.policy.ledger; + +import java.util.function.Supplier; + +/** {@link InProcessProcessedLedger} against the shared {@link ProcessedLedger} contract. */ +class InProcessProcessedLedgerTest extends ProcessedLedgerContractTest { + + @Override + ProcessedLedger newLedger(Supplier nowMillis) { + return new InProcessProcessedLedger(nowMillis); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedgerDbTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedgerDbTest.java new file mode 100644 index 0000000000..8c1bd85c94 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedgerDbTest.java @@ -0,0 +1,36 @@ +package stirling.software.proprietary.policy.ledger; + +import java.util.function.Supplier; + +import org.junit.jupiter.api.AfterEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurationPackage; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; + +/** + * {@link JpaProcessedLedger} against the shared contract on a real (H2) database. The inherited + * tests run outside {@code @DataJpaTest}'s per-test transaction (the transaction attribute resolves + * against the declaring class, the plain contract base), so every ledger call commits in its own + * transaction as at runtime; state is wiped explicitly instead of relying on rollback. + */ +@DataJpaTest +class JpaProcessedLedgerDbTest extends ProcessedLedgerContractTest { + + @Autowired private ProcessedFileRepository repository; + + @AfterEach + void wipeLedger() { + // deleteAll() skips entities whose isNew() is hardcoded true, so use the bulk form. + repository.deleteAllInBatch(); + } + + @Override + ProcessedLedger newLedger(Supplier nowMillis) { + return new JpaProcessedLedger(repository, nowMillis); + } + + @SpringBootConfiguration + @AutoConfigurationPackage + static class TestApp {} +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/ProcessedLedgerContractTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/ProcessedLedgerContractTest.java new file mode 100644 index 0000000000..800e8a6bde --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/ProcessedLedgerContractTest.java @@ -0,0 +1,360 @@ +package stirling.software.proprietary.policy.ledger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Supplier; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** The {@link ProcessedLedger} contract, run against every implementation so they cannot drift. */ +abstract class ProcessedLedgerContractTest { + + static final String POLICY = "p1"; + static final String OTHER_POLICY = "p2"; + static final String FILE = "/in/doc.pdf"; + static final String GATE = "100:1111"; + static final String NEW_GATE = "100:2222"; + static final String HASH = "hash-aaa"; + static final String NEW_HASH = "hash-bbb"; + + final AtomicLong clock = new AtomicLong(1_000_000L); + + ProcessedLedger ledger; + + abstract ProcessedLedger newLedger(Supplier nowMillis); + + @BeforeEach + void createLedger() { + ledger = newLedger(clock::get); + } + + @Test + void aFileIsClaimedOnceAndSkippedWhileInFlight() { + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); + assertFalse(ledger.claim(POLICY, FILE, GATE, null)); + assertFalse(ledger.claim(POLICY, FILE, NEW_GATE, null)); // new version waits for settle + } + + @Test + void aSettledFileIsSkippedAtTheSameGate() { + ledger.claim(POLICY, FILE, GATE, null); + ledger.settle(POLICY, FILE, GATE, null, true); + + assertFalse(ledger.claim(POLICY, FILE, GATE, null)); + } + + @Test + void aMovedGateIsReclaimedInGateOnlyMode() { + ledger.claim(POLICY, FILE, GATE, null); + ledger.settle(POLICY, FILE, GATE, null, true); + + assertTrue(ledger.claim(POLICY, FILE, NEW_GATE, null)); + } + + @Test + void settlingAtTheOutputsVersionStopsAnInPlaceOverwriteLooping() { + ledger.claim(POLICY, FILE, GATE, null); + // The run overwrote the input; settle re-reads and lands on the produced version. + ledger.settle(POLICY, FILE, NEW_GATE, null, true); + + assertFalse(ledger.claim(POLICY, FILE, NEW_GATE, null)); // own output: skip + assertTrue(ledger.claim(POLICY, FILE, "100:3333", null)); // later user edit: reprocess + } + + @Test + void aFailedFileIsNotRetriedUntilItChanges() { + ledger.claim(POLICY, FILE, GATE, null); + ledger.settle(POLICY, FILE, GATE, null, false); + + assertFalse(ledger.claim(POLICY, FILE, GATE, null)); + assertTrue(ledger.claim(POLICY, FILE, NEW_GATE, null)); + } + + @Test + void aTouchedButUnchangedFileRefreshesTheGateInsteadOfReprocessing() { + ledger.claim(POLICY, FILE, GATE, hash(HASH)); + ledger.settle(POLICY, FILE, GATE, HASH, true); + + // Same content under a new gate (touch / identical re-copy): verified, not reprocessed. + assertFalse(ledger.claim(POLICY, FILE, NEW_GATE, hash(HASH))); + + // The gate was refreshed, so the next sweep takes the cheap path: no content read at all. + CountingSupplier counting = new CountingSupplier(HASH); + assertFalse(ledger.claim(POLICY, FILE, NEW_GATE, counting)); + assertEquals(0, counting.invocations.get()); + } + + @Test + void theVerificationTierIsNotConsultedWhileTheGateMatches() { + ledger.claim(POLICY, FILE, GATE, hash(HASH)); + ledger.settle(POLICY, FILE, GATE, HASH, true); + + CountingSupplier counting = new CountingSupplier(HASH); + assertFalse(ledger.claim(POLICY, FILE, GATE, counting)); + assertEquals(0, counting.invocations.get()); + } + + @Test + void aRealContentChangeUnderANewGateIsReprocessed() { + ledger.claim(POLICY, FILE, GATE, hash(HASH)); + ledger.settle(POLICY, FILE, GATE, HASH, true); + + assertTrue(ledger.claim(POLICY, FILE, NEW_GATE, hash(NEW_HASH))); + } + + @Test + void aGateOnlySettledRowCannotBeContentVerifiedSoItReprocesses() { + ledger.claim(POLICY, FILE, GATE, null); + ledger.settle(POLICY, FILE, GATE, null, true); + + // The row stored no hash, so "same content" is unprovable: reprocess on gate change. + assertTrue(ledger.claim(POLICY, FILE, NEW_GATE, hash(HASH))); + } + + @Test + void aFailedFileStaysParkedThroughATouch() { + ledger.claim(POLICY, FILE, GATE, hash(HASH)); + ledger.settle(POLICY, FILE, GATE, HASH, false); + + // A touch must not resurrect an ERROR row; only a real content change does. + assertFalse(ledger.claim(POLICY, FILE, NEW_GATE, hash(HASH))); + assertTrue(ledger.claim(POLICY, FILE, "100:3333", hash(NEW_HASH))); + } + + @Test + void interruptedRunsAreRetriedABoundedNumberOfTimes() { + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); // attempt 1 dies with the JVM + ledger.recoverInterrupted(); + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); // attempt 2 + ledger.recoverInterrupted(); + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); // attempt 3, the last + ledger.recoverInterrupted(); + + assertFalse(ledger.claim(POLICY, FILE, GATE, null)); // parked: no crash-loop + } + + @Test + void aNewGateResetsTheInterruptRetryBudgetInGateOnlyMode() { + for (int attempt = 0; attempt < ProcessedLedger.MAX_ATTEMPTS; attempt++) { + ledger.claim(POLICY, FILE, GATE, null); + ledger.recoverInterrupted(); + } + assertFalse(ledger.claim(POLICY, FILE, GATE, null)); + + assertTrue(ledger.claim(POLICY, FILE, NEW_GATE, null)); + ledger.recoverInterrupted(); + assertTrue(ledger.claim(POLICY, FILE, NEW_GATE, null)); // fresh budget at the new version + } + + @Test + void aTouchDoesNotResetTheInterruptRetryBudgetWhenContentIsVerified() { + assertTrue(ledger.claim(POLICY, FILE, GATE, hash(HASH))); // attempt 1 + ledger.recoverInterrupted(); + // Same content, moved gate: still the interrupted work, still bounded. + assertTrue(ledger.claim(POLICY, FILE, NEW_GATE, hash(HASH))); // attempt 2 + ledger.recoverInterrupted(); + assertTrue(ledger.claim(POLICY, FILE, "100:3333", hash(HASH))); // attempt 3 + ledger.recoverInterrupted(); + + assertFalse(ledger.claim(POLICY, FILE, "100:4444", hash(HASH))); // parked + assertTrue(ledger.claim(POLICY, FILE, "100:5555", hash(NEW_HASH))); // real change: fresh + } + + @Test + void recoveryOnlyTouchesInFlightRows() { + ledger.claim(POLICY, FILE, GATE, null); + ledger.settle(POLICY, FILE, GATE, null, true); + ledger.recoverInterrupted(); + + assertFalse(ledger.claim(POLICY, FILE, GATE, null)); // still DONE, not retried + } + + @Test + void anOutputIsSkippedByItsProducerButSeenByOtherPolicies() { + ledger.recordOutput(POLICY, FILE, GATE, HASH); + + assertFalse(ledger.claim(POLICY, FILE, GATE, null)); // producer skips its own output + assertTrue(ledger.claim(OTHER_POLICY, FILE, GATE, null)); // chaining still works + } + + @Test + void anOutputIsSkippedByAHashVerifyingProducerEvenIfTheGateMoved() { + ledger.recordOutput(POLICY, FILE, GATE, HASH); + + assertFalse(ledger.claim(POLICY, FILE, NEW_GATE, hash(HASH))); + } + + @Test + void policiesTrackTheSameFileIndependently() { + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); + assertTrue(ledger.claim(OTHER_POLICY, FILE, GATE, null)); + } + + @Test + void statesForSnapshotsOnlyExistingRows() { + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); + + Map states = ledger.statesFor(POLICY, List.of(FILE, "/in/other.pdf")); + + assertEquals(1, states.size()); + assertEquals(ProcessedFileStatus.PROCESSING, states.get(FILE).status()); + assertEquals(GATE, states.get(FILE).gate()); + } + + @Test + void aStaleAbsentSnapshotLosesTheClaimRaceInsteadOfDoubleClaiming() { + ClaimState absent = ledger.statesFor(POLICY, List.of(FILE)).get(FILE); // no row yet + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); // another sweep wins meanwhile + + assertFalse(ledger.claim(POLICY, FILE, GATE, null, absent)); + } + + @Test + void aStaleSettledSnapshotCannotReclaimAnInFlightRow() { + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); + ledger.settle(POLICY, FILE, GATE, null, true); + ClaimState settled = ledger.statesFor(POLICY, List.of(FILE)).get(FILE); + assertTrue(ledger.claim(POLICY, FILE, NEW_GATE, null)); // a fresh sweep reclaims first + + assertFalse(ledger.claim(POLICY, FILE, "100:3333", null, settled)); + } + + @Test + void aForgottenOutputIsClaimableAtAnyVersion() { + ledger.recordOutput(POLICY, FILE, GATE, HASH); + ledger.forgetOutput(POLICY, FILE, GATE); + + // Even a byte-identical file at that identity is fresh work: the record is gone. + assertTrue(ledger.claim(POLICY, FILE, GATE, hash(HASH))); + } + + @Test + void forgetOutputLeavesARowReclaimedInTheMeantime() { + ledger.recordOutput(POLICY, FILE, GATE, HASH); + assertTrue(ledger.claim(POLICY, FILE, NEW_GATE, null)); // a real claim took the row over + + ledger.forgetOutput(POLICY, FILE, GATE); + + assertFalse(ledger.claim(POLICY, FILE, NEW_GATE, null)); // still in flight, not deleted + } + + @Test + void deletionConsensusNeedsEveryClaimantSettledDone() { + assertTrue(ledger.allSettledDone(FILE)); // vacuous: no rows yet + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); + assertTrue(ledger.claim(OTHER_POLICY, FILE, GATE, null)); + ledger.settle(POLICY, FILE, GATE, null, true); + assertFalse(ledger.allSettledDone(FILE)); // the other claim is still in flight + ledger.settle(OTHER_POLICY, FILE, GATE, null, true); + assertTrue(ledger.allSettledDone(FILE)); + } + + @Test + void aFailedClaimVetoesDeletionConsensus() { + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); + assertTrue(ledger.claim(OTHER_POLICY, FILE, GATE, null)); + ledger.settle(POLICY, FILE, GATE, null, true); + ledger.settle(OTHER_POLICY, FILE, GATE, null, false); + assertFalse(ledger.allSettledDone(FILE)); + } + + @Test + void anInterruptedClaimVetoesDeletionConsensus() { + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); + ledger.recoverInterrupted(); + assertFalse(ledger.allSettledDone(FILE)); + } + + @Test + void settleRecreatesARowRemovedMidRun() { + ledger.claim(POLICY, FILE, GATE, null); + ledger.clearPolicy(POLICY); // e.g. a clear-history while the run is in flight + ledger.settle(POLICY, FILE, GATE, null, true); + + assertFalse(ledger.claim(POLICY, FILE, GATE, null)); + } + + @Test + void presenceCleanupRemovesOnlyUnseenSettledRows() { + String inFlight = "/in/in-flight.pdf"; + String stillPresent = "/in/still-present.pdf"; + String deleted = "/in/deleted.pdf"; + ledger.claim(POLICY, inFlight, GATE, null); + ledger.recordOutput(POLICY, stillPresent, GATE, HASH); + ledger.recordOutput(POLICY, deleted, GATE, HASH); + + clock.addAndGet(10_000); + long sweepStart = clock.get(); + ledger.markSeen(POLICY, List.of(inFlight, stillPresent)); // deleted.pdf is gone from disk + assertEquals(1, ledger.deleteUnseen(POLICY, sweepStart)); + + assertFalse(ledger.claim(POLICY, inFlight, GATE, null)); // in-flight row survived + assertFalse(ledger.claim(POLICY, stillPresent, GATE, null)); // stamped row survived + assertTrue(ledger.claim(POLICY, deleted, GATE, null)); // forgotten: a re-drop reprocesses + } + + @Test + void presenceCleanupNeverRemovesInFlightRowsEvenUnstamped() { + ledger.claim(POLICY, FILE, GATE, null); + clock.addAndGet(10_000); + + assertEquals(0, ledger.deleteUnseen(POLICY, clock.get())); + assertFalse(ledger.claim(POLICY, FILE, GATE, null)); + } + + @Test + void rowsWrittenDuringTheSweepSurviveItsCleanup() { + long sweepStart = clock.get(); + // Recorded after the sweep's cutoff: unseen by it, but newer, so it must survive. + clock.addAndGet(5); + ledger.recordOutput(POLICY, FILE, GATE, HASH); + + assertEquals(0, ledger.deleteUnseen(POLICY, sweepStart)); + assertFalse(ledger.claim(POLICY, FILE, GATE, null)); + } + + @Test + void markSeenOnUnknownIdentitiesIsANoOp() { + ledger.markSeen(POLICY, List.of("/never/claimed.pdf")); + assertEquals(0, ledger.deleteUnseen(POLICY, clock.get())); + } + + @Test + void clearPolicyForgetsOnlyThatPolicy() { + ledger.claim(POLICY, FILE, GATE, null); + ledger.settle(POLICY, FILE, GATE, null, true); + ledger.claim(OTHER_POLICY, FILE, GATE, null); + ledger.settle(OTHER_POLICY, FILE, GATE, null, true); + + ledger.clearPolicy(POLICY); + + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); + assertFalse(ledger.claim(OTHER_POLICY, FILE, GATE, null)); + } + + static Supplier hash(String value) { + return () -> value; + } + + static final class CountingSupplier implements Supplier { + final AtomicInteger invocations = new AtomicInteger(); + private final String value; + + CountingSupplier(String value) { + this.value = value; + } + + @Override + public String get() { + invocations.incrementAndGet(); + return value; + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java index f1b8b599ac..f9e1c92403 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java @@ -10,6 +10,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -21,24 +22,35 @@ import org.springframework.core.io.Resource; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.job.ResultFile; import stirling.software.proprietary.policy.config.FolderAccessGuard; +import stirling.software.proprietary.policy.ledger.FolderIdentities; +import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.source.InProcessSourceStore; -/** Tests for {@link FolderOutputSink}: outputs are written to the configured directory on disk. */ +/** + * Tests for {@link FolderOutputSink}: outputs are staged hidden, recorded in the ledger, then + * atomically renamed into the configured directory. + */ class FolderOutputSinkTest { + private static final OutputDelivery AD_HOC = new OutputDelivery("run-1", null); + private static final OutputDelivery POLICY_RUN = new OutputDelivery("run-1", "p1"); + @TempDir Path tempDir; private FolderOutputSink sink; + private InProcessProcessedLedger ledger; @BeforeEach void setUp() { ApplicationProperties properties = new ApplicationProperties(); properties.getPolicies().setAllowedFolderRoots(List.of(tempDir.toString())); + ledger = new InProcessProcessedLedger(); sink = new FolderOutputSink( new FolderAccessGuard( - properties, new StandardEnvironment(), new InProcessSourceStore())); + properties, new StandardEnvironment(), new InProcessSourceStore()), + ledger); } @Test @@ -46,13 +58,80 @@ class FolderOutputSinkTest { Path out = tempDir.resolve("out"); List outputs = List.of(named("a.pdf", "aaa"), named("b.pdf", "bb")); - List results = - sink.deliver("run-1", outputs, OutputSpec.folder(out.toString())); + List results = sink.deliver(AD_HOC, outputs, OutputSpec.folder(out.toString())); assertEquals(2, results.size()); assertTrue(Files.exists(out.resolve("a.pdf"))); assertEquals("aaa", Files.readString(out.resolve("a.pdf"))); assertEquals("bb", Files.readString(out.resolve("b.pdf"))); + // Nothing left behind in the staging dir. + try (Stream staged = Files.list(out.resolve(".stirling").resolve("tmp"))) { + assertEquals(0, staged.count()); + } + } + + @Test + void recordsThePolicysOutputsSoOnlyOtherPoliciesReprocessThem() throws IOException { + Path out = tempDir.resolve("out"); + + sink.deliver(POLICY_RUN, List.of(named("a.pdf", "aaa")), OutputSpec.folder(out.toString())); + + Path delivered = FolderIdentities.canonicalDir(out).resolve("a.pdf"); + String gate = FolderIdentities.statGate(delivered); + assertFalse(ledger.claim("p1", delivered.toString(), gate, null)); // producer skips it + assertTrue(ledger.claim("p2", delivered.toString(), gate, null)); // chaining still works + } + + @Test + void aHashVerifyingProducerSkipsItsOwnOutputEvenIfTheGateMoved() throws IOException { + Path out = tempDir.resolve("out"); + + sink.deliver(POLICY_RUN, List.of(named("a.pdf", "aaa")), OutputSpec.folder(out.toString())); + + // A hash-verifying reader matches on content even when the stat moved. + Path delivered = FolderIdentities.canonicalDir(out).resolve("a.pdf"); + assertFalse( + ledger.claim( + "p1", + delivered.toString(), + "999:12345", + () -> { + try { + return FolderIdentities.contentHash(delivered); + } catch (IOException e) { + throw new java.io.UncheckedIOException(e); + } + })); + } + + @Test + void recordsAnOutputBeforeItBecomesVisible() throws IOException { + Path out = tempDir.resolve("out"); + VisibilityAssertingLedger orderedLedger = new VisibilityAssertingLedger(); + ApplicationProperties properties = new ApplicationProperties(); + properties.getPolicies().setAllowedFolderRoots(List.of(tempDir.toString())); + FolderOutputSink orderedSink = + new FolderOutputSink( + new FolderAccessGuard( + properties, new StandardEnvironment(), new InProcessSourceStore()), + orderedLedger); + + orderedSink.deliver( + POLICY_RUN, List.of(named("a.pdf", "aaa")), OutputSpec.folder(out.toString())); + + assertTrue(orderedLedger.recorded); + assertTrue(Files.exists(out.resolve("a.pdf"))); + } + + @Test + void adHocDeliveriesRecordNothing() throws IOException { + Path out = tempDir.resolve("out"); + + sink.deliver(AD_HOC, List.of(named("a.pdf", "aaa")), OutputSpec.folder(out.toString())); + + Path delivered = FolderIdentities.canonicalDir(out).resolve("a.pdf"); + // No row was recorded, so any policy (including a hypothetical producer) may claim it. + assertTrue(ledger.claim("p1", delivered.toString(), "any-gate", null)); } @Test @@ -60,7 +139,7 @@ class FolderOutputSinkTest { Path out = tempDir.resolve("out"); List outputs = List.of(named("a.pdf", "first"), named("a.pdf", "second")); - sink.deliver("run-1", outputs, OutputSpec.folder(out.toString())); + sink.deliver(AD_HOC, outputs, OutputSpec.folder(out.toString())); assertTrue(Files.exists(out.resolve("a.pdf"))); assertTrue(Files.exists(out.resolve("a (1).pdf"))); @@ -72,7 +151,7 @@ class FolderOutputSinkTest { assertThrows(IllegalArgumentException.class, () -> sink.validate(noDir)); assertThrows( IllegalArgumentException.class, - () -> sink.deliver("run-1", List.of(named("a.pdf", "x")), noDir)); + () -> sink.deliver(AD_HOC, List.of(named("a.pdf", "x")), noDir)); } @Test @@ -81,7 +160,7 @@ class FolderOutputSinkTest { assertThrows(IllegalArgumentException.class, () -> sink.validate(outside)); assertThrows( IllegalArgumentException.class, - () -> sink.deliver("run-1", List.of(named("a.pdf", "x")), outside)); + () -> sink.deliver(AD_HOC, List.of(named("a.pdf", "x")), outside)); } @Test @@ -90,7 +169,7 @@ class FolderOutputSinkTest { List outputs = List.of(named("../escape.pdf", "x"), named("nested/deep.pdf", "y")); - sink.deliver("run-1", outputs, OutputSpec.folder(out.toString())); + sink.deliver(AD_HOC, outputs, OutputSpec.folder(out.toString())); // Each name is reduced to its bare form inside the target dir; nothing escapes. assertTrue(Files.exists(out.resolve("escape.pdf"))); @@ -106,4 +185,20 @@ class FolderOutputSinkTest { } }; } + + /** Fails the delivery if an output is visible at its final path before being recorded. */ + private static class VisibilityAssertingLedger extends InProcessProcessedLedger { + + private boolean recorded; + + @Override + public synchronized void recordOutput( + String policyId, String identity, String gate, String contentHash) { + assertFalse( + Files.exists(Path.of(identity)), + "output must be recorded before it is visible at its final path"); + recorded = true; + super.recordOutput(policyId, identity, gate, contentHash); + } + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java index 58c5123327..2c3adf691e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.trigger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -26,6 +27,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.engine.PolicyRunner; +import stirling.software.proprietary.policy.engine.SweepKind; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; @@ -100,8 +102,8 @@ class FolderWatchTriggerTest { trigger.runForChangedDirs(Set.of(normalized("/in/a"))); - verify(policyRunner).run(a); - verify(policyRunner, never()).run(b); + verify(policyRunner).run(a, SweepKind.LIGHT); + verify(policyRunner, never()).run(eq(b), any()); } @Test @@ -112,8 +114,8 @@ class FolderWatchTriggerTest { trigger.runForChangedDirs(Set.of(normalized("/in/a"))); - verify(policyRunner).run(good); - verify(policyRunner, never()).run(bad); + verify(policyRunner).run(good, SweepKind.LIGHT); + verify(policyRunner, never()).run(eq(bad), any()); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/InitialSecuritySetupTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/InitialSecuritySetupTest.java index adf9486094..3e4c75f645 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/InitialSecuritySetupTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/InitialSecuritySetupTest.java @@ -26,6 +26,7 @@ import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.service.DatabaseServiceInterface; import stirling.software.proprietary.security.service.SaveUserRequest; +import stirling.software.proprietary.security.service.TeamMembershipService; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.service.UserLicenseSettingsService; @@ -38,6 +39,7 @@ class InitialSecuritySetupTest { @Mock private DatabaseServiceInterface databaseService; @Mock private UserLicenseSettingsService licenseSettingsService; @Mock private Environment environment; + @Mock private TeamMembershipService teamMembershipService; private ApplicationProperties applicationProperties; private InitialSecuritySetup initialSecuritySetup; @@ -63,7 +65,8 @@ class InitialSecuritySetupTest { applicationProperties, databaseService, licenseSettingsService, - environment); + environment, + teamMembershipService); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java index 6a27b4d578..6586fc11b6 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java @@ -29,6 +29,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; import stirling.software.proprietary.access.service.ResourceAccessService; +import stirling.software.proprietary.access.service.TeamLeadLookup; import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; @@ -60,6 +61,7 @@ class AuthControllerLoginTest { @Mock private TotpService totpService; @Mock private RefreshRateLimitService refreshRateLimitService; @Mock private ResourceAccessService resourceAccessService; + @Mock private TeamLeadLookup teamLeadLookup; @BeforeEach void setUp() { @@ -83,7 +85,8 @@ class AuthControllerLoginTest { securityProperties, applicationProperties, new stirling.software.proprietary.service.AiUserDataService(null), - resourceAccessService); + resourceAccessService, + teamLeadLookup); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMoreTest.java index 611929b5b9..aaf931f597 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMoreTest.java @@ -29,6 +29,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; import stirling.software.proprietary.access.service.ResourceAccessService; +import stirling.software.proprietary.access.service.TeamLeadLookup; import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; @@ -61,6 +62,7 @@ class AuthControllerMoreTest { @Mock private TotpService totpService; @Mock private RefreshRateLimitService refreshRateLimitService; @Mock private ResourceAccessService resourceAccessService; + @Mock private TeamLeadLookup teamLeadLookup; @BeforeEach void setUp() { @@ -84,7 +86,8 @@ class AuthControllerMoreTest { securityProperties, applicationProperties, new stirling.software.proprietary.service.AiUserDataService(null), - resourceAccessService); + resourceAccessService, + teamLeadLookup); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerMoreTest.java index 75c23b4630..ac2219efa3 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerMoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerMoreTest.java @@ -30,6 +30,7 @@ import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.service.EmailService; import stirling.software.proprietary.security.service.LoginAttemptService; +import stirling.software.proprietary.security.service.TeamMembershipService; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.session.SessionPersistentRegistry; @@ -46,6 +47,7 @@ class UserControllerMoreTest { @Mock private EmailService emailService; @Mock private UserLicenseSettingsService licenseSettingsService; @Mock private LoginAttemptService loginAttemptService; + @Mock private TeamMembershipService teamMembershipService; private ApplicationProperties applicationProperties; private MockMvc mockMvc; @@ -64,7 +66,8 @@ class UserControllerMoreTest { userRepository, Optional.of(emailService), licenseSettingsService, - loginAttemptService); + loginAttemptService, + teamMembershipService); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java index 7bcc0da9f4..b3a1e8de93 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java @@ -33,6 +33,7 @@ import stirling.software.proprietary.security.model.api.user.UsernameAndPass; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.service.EmailService; import stirling.software.proprietary.security.service.LoginAttemptService; +import stirling.software.proprietary.security.service.TeamMembershipService; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.session.SessionPersistentRegistry; @@ -53,6 +54,7 @@ class UserControllerTest { @Mock private EmailService emailService; @Mock private UserLicenseSettingsService licenseSettingsService; @Mock private LoginAttemptService loginAttemptService; + @Mock private TeamMembershipService teamMembershipService; private ApplicationProperties applicationProperties; private MockMvc mockMvc; @@ -72,7 +74,8 @@ class UserControllerTest { userRepository, Optional.of(emailService), licenseSettingsService, - loginAttemptService); + loginAttemptService, + teamMembershipService); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/TeamMembershipServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/TeamMembershipServiceTest.java new file mode 100644 index 0000000000..68bd2d1727 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/TeamMembershipServiceTest.java @@ -0,0 +1,159 @@ +package stirling.software.proprietary.security.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; + +@ExtendWith(MockitoExtension.class) +class TeamMembershipServiceTest { + + @Mock private TeamMembershipRepository membershipRepository; + @Mock private org.springframework.core.env.Environment environment; + + @InjectMocks private TeamMembershipService service; + + @org.junit.jupiter.api.BeforeEach + void notSaas() { + org.mockito.Mockito.lenient() + .when(environment.getActiveProfiles()) + .thenReturn(new String[] {}); + } + + @Test + void syncCreatesMemberRowForUsersTeam() { + User user = userInTeam(5, 7); + when(membershipRepository.findByUserId(5L)).thenReturn(List.of()); + + service.syncMembership(user); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TeamMembership.class); + verify(membershipRepository).save(captor.capture()); + assertThat(captor.getValue().getTeam().getId()).isEqualTo(7L); + assertThat(captor.getValue().getRole()).isEqualTo(TeamRole.MEMBER); + assertThat(captor.getValue().getInvitedAt()).isNotNull(); + } + + @Test + void syncMovesRowsWhenUserChangedTeam() { + User user = userInTeam(5, 8); + TeamMembership oldRow = row(7, user, TeamRole.LEADER); + when(membershipRepository.findByUserId(5L)).thenReturn(List.of(oldRow)); + + service.syncMembership(user); + + verify(membershipRepository).delete(oldRow); + ArgumentCaptor captor = ArgumentCaptor.forClass(TeamMembership.class); + verify(membershipRepository).save(captor.capture()); + assertThat(captor.getValue().getTeam().getId()).isEqualTo(8L); + assertThat(captor.getValue().getRole()).isEqualTo(TeamRole.MEMBER); + } + + @Test + void syncPreservesLeaderRoleOnSameTeam() { + User user = userInTeam(5, 7); + TeamMembership existing = row(7, user, TeamRole.LEADER); + when(membershipRepository.findByUserId(5L)).thenReturn(List.of(existing)); + + service.syncMembership(user); + + verify(membershipRepository, never()).delete(any()); + verify(membershipRepository, never()).save(any()); + } + + @Test + void syncRemovesAllRowsWhenUserHasNoTeam() { + User user = new User(); + user.setId(5L); + TeamMembership stale = row(7, user, TeamRole.MEMBER); + when(membershipRepository.findByUserId(5L)).thenReturn(List.of(stale)); + + service.syncMembership(user); + + verify(membershipRepository).delete(stale); + verify(membershipRepository, never()).save(any()); + } + + @Test + void setOwnerPromotesExistingRow() { + User user = userInTeam(5, 7); + TeamMembership existing = row(7, user, TeamRole.MEMBER); + when(membershipRepository.findByTeamIdAndUserId(7L, 5L)).thenReturn(Optional.of(existing)); + + service.setOwner(user.getTeam(), user); + + assertThat(existing.getRole()).isEqualTo(TeamRole.LEADER); + verify(membershipRepository).save(existing); + } + + @Test + void setOwnerCreatesLeaderRowWhenMissing() { + User user = userInTeam(5, 7); + when(membershipRepository.findByTeamIdAndUserId(7L, 5L)).thenReturn(Optional.empty()); + + service.setOwner(user.getTeam(), user); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TeamMembership.class); + verify(membershipRepository).save(captor.capture()); + assertThat(captor.getValue().getRole()).isEqualTo(TeamRole.LEADER); + } + + @Test + void removeOwnerDemotesToMemberAndKeepsRow() { + User user = userInTeam(5, 7); + TeamMembership existing = row(7, user, TeamRole.LEADER); + when(membershipRepository.findByTeamIdAndUserId(7L, 5L)).thenReturn(Optional.of(existing)); + + service.removeOwner(user.getTeam(), user); + + assertThat(existing.getRole()).isEqualTo(TeamRole.MEMBER); + verify(membershipRepository).save(existing); + verify(membershipRepository, never()).delete(any()); + } + + @Test + void deleteAllForUserDropsRowsAndInvitationRefs() { + User user = userInTeam(5, 7); + + service.deleteAllForUser(user); + + verify(membershipRepository).deleteByUserId(5L); + verify(membershipRepository).clearInvitedBy(user); + } + + private User userInTeam(long userId, long teamId) { + User u = new User(); + u.setId(userId); + Team t = new Team(); + t.setId(teamId); + u.setTeam(t); + return u; + } + + private TeamMembership row(long teamId, User user, TeamRole role) { + Team t = new Team(); + t.setId(teamId); + TeamMembership m = new TeamMembership(); + m.setTeam(t); + m.setUser(user); + m.setRole(role); + return m; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceMoreTest.java index cfb0f2e885..bc516172a6 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceMoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceMoreTest.java @@ -71,6 +71,12 @@ class UserServiceMoreTest { @Mock private FileShareRepository fileShareRepository; @Mock private FileShareAccessRepository fileShareAccessRepository; + @Mock + private stirling.software.proprietary.integration.repository.IntegrationConfigRepository + integrationConfigRepository; + + @Mock private TeamMembershipService teamMembershipService; + @InjectMocks private UserService userService; @AfterEach diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java index 713fd4b9c3..95ec72d962 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java @@ -24,6 +24,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.exception.UnsupportedProviderException; +import stirling.software.proprietary.access.repository.ResourceGrantRepository; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.database.repository.AuthorityRepository; import stirling.software.proprietary.security.database.repository.PersistentLoginRepository; @@ -63,6 +64,13 @@ class UserServiceTest { @Mock private StorageCleanupEntryRepository storageCleanupEntryRepository; @Mock private FileShareRepository fileShareRepository; @Mock private FileShareAccessRepository fileShareAccessRepository; + @Mock private ResourceGrantRepository resourceGrantRepository; + + @Mock + private stirling.software.proprietary.integration.repository.IntegrationConfigRepository + integrationConfigRepository; + + @Mock private TeamMembershipService teamMembershipService; @Spy @InjectMocks private UserService userService; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java index 7dc174c041..15c07bab71 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.service; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; @@ -230,6 +231,57 @@ class AiWorkflowServiceTest { // 1:1 mapping preserves each input's filename. assertEquals("a.pdf", result.getResultFiles().get(0).getFileName()); assertEquals("b.pdf", result.getResultFiles().get(1).getFileName()); + // Each output points back at the input it came from so the client versions it in place. + assertEquals(0, result.getResultFiles().get(0).getSourceIndex()); + assertEquals(1, result.getResultFiles().get(1).getSourceIndex()); + } + + @Test + void mergeOutputHasNoSourceIndex() throws IOException { + MockMultipartFile a = pdf("a.pdf", "a-bytes"); + MockMultipartFile b = pdf("b.pdf", "b-bytes"); + stubOrchestrator( + """ + {"outcome":"tool_call","tool":"%s","parameters":{},"rationale":"Merging"} + """ + .formatted(MERGE_ENDPOINT)); + when(toolMetadataService.isMultiInput(MERGE_ENDPOINT)).thenReturn(true); + when(toolMetadataService.shouldUnpackZipResponse(MERGE_ENDPOINT)).thenReturn(false); + stubEndpoint(MERGE_ENDPOINT, pdfResource("merged-bytes", "merged.pdf")); + stubFileStorage(); + + AiWorkflowResponse result = + service.orchestrate(requestFor(new MockMultipartFile[] {a, b}, "merge these")); + + // A merge draws on several inputs, so there is no single source to version in place. + assertNull(result.getResultFiles().get(0).getSourceIndex()); + } + + @Test + void splitOutputsHaveNoSourceIndex() throws IOException { + MockMultipartFile input = pdf("doc.pdf", "original"); + stubOrchestrator( + """ + {"outcome":"tool_call","tool":"%s","parameters":{},"rationale":"Splitting"} + """ + .formatted(SPLIT_ENDPOINT)); + when(toolMetadataService.isMultiInput(SPLIT_ENDPOINT)).thenReturn(false); + when(toolMetadataService.shouldUnpackZipResponse(SPLIT_ENDPOINT)).thenReturn(true); + stubEndpoint( + SPLIT_ENDPOINT, + zipResource( + "doc.zip", + List.of( + new ZipEntryBytes("page-1.pdf", "page-one"), + new ZipEntryBytes("page-2.pdf", "page-two")))); + stubFileStorage(); + + AiWorkflowResponse result = service.orchestrate(requestFor(input, "split")); + + // One input fanned out to many outputs, so none is a clean 1:1 version β€” the client adds + // them as fresh files and leaves the original in place. + assertNull(result.getResultFiles().get(0).getSourceIndex()); + assertNull(result.getResultFiles().get(1).getSourceIndex()); } @Test diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java index 91974a6d04..490b3d5db1 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java @@ -20,10 +20,10 @@ import io.swagger.v3.oas.annotations.Hidden; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.TeamMembership; -import stirling.software.saas.repository.TeamMembershipRepository; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.util.AuthenticationUtils; /** diff --git a/app/saas/src/main/java/stirling/software/saas/controller/SaasTeamController.java b/app/saas/src/main/java/stirling/software/saas/controller/SaasTeamController.java index 6253d64651..9346e18b4b 100644 --- a/app/saas/src/main/java/stirling/software/saas/controller/SaasTeamController.java +++ b/app/saas/src/main/java/stirling/software/saas/controller/SaasTeamController.java @@ -20,15 +20,15 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.annotations.api.TeamApi; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.saas.model.TeamInvitation; -import stirling.software.saas.model.TeamMembership; import stirling.software.saas.repository.TeamInvitationRepository; -import stirling.software.saas.repository.TeamMembershipRepository; import stirling.software.saas.security.TeamSecurityExpressions; import stirling.software.saas.service.SaasTeamExtensionService; import stirling.software.saas.service.SaasTeamService; diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygInvoicesController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygInvoicesController.java index 6b8bb2c883..80c20902ce 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygInvoicesController.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygInvoicesController.java @@ -20,13 +20,13 @@ import io.swagger.v3.oas.annotations.Hidden; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.TeamMembership; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.payg.policy.PaygTeamExtensions; import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; import stirling.software.saas.payg.stripe.StripeInvoiceDao; -import stirling.software.saas.repository.TeamMembershipRepository; import stirling.software.saas.util.AuthenticationUtils; /** diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java index 724d6715d3..89ab18230f 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java @@ -18,13 +18,13 @@ import io.swagger.v3.oas.annotations.Hidden; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.TeamMembership; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.payg.policy.PaygTeamExtensions; import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; import stirling.software.saas.payg.stripe.StripePaymentMethodDao; -import stirling.software.saas.repository.TeamMembershipRepository; import stirling.software.saas.util.AuthenticationUtils; /** diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java index b79d57117e..90616bea55 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java @@ -32,9 +32,10 @@ import jakarta.validation.constraints.Min; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.TeamMembership; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.payg.api.WalletSnapshotResponse.ActivityRow; import stirling.software.saas.payg.api.WalletSnapshotResponse.CategoryBreakdown; import stirling.software.saas.payg.api.WalletSnapshotResponse.MemberRow; @@ -51,7 +52,6 @@ import stirling.software.saas.payg.repository.WalletLedgerRepository; import stirling.software.saas.payg.repository.WalletPolicyRepository; import stirling.software.saas.payg.wallet.WalletLedgerEntry; import stirling.software.saas.payg.wallet.WalletPolicy; -import stirling.software.saas.repository.TeamMembershipRepository; import stirling.software.saas.util.AuthenticationUtils; /** diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java index f92267da33..57f7e6a3a3 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java @@ -25,16 +25,16 @@ import io.swagger.v3.oas.annotations.Hidden; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.TeamMembership; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.procurement.config.ProcurementConfigurationProperties; import stirling.software.saas.procurement.model.ProcurementDeal; import stirling.software.saas.procurement.model.ProcurementQuote; import stirling.software.saas.procurement.pricing.QuoteConfig; import stirling.software.saas.procurement.pricing.QuoteLineItem; import stirling.software.saas.procurement.service.ProcurementService; -import stirling.software.saas.repository.TeamMembershipRepository; import stirling.software.saas.util.AuthenticationUtils; /** diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java index 57f530cc9a..225e0327e8 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java @@ -17,7 +17,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.enumeration.TeamRole; -import stirling.software.saas.model.TeamMembership; +import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.procurement.config.ProcurementConfigurationProperties; import stirling.software.saas.procurement.license.EnterpriseLicenseService; import stirling.software.saas.procurement.license.LicenseEntitlements; @@ -28,7 +29,6 @@ import stirling.software.saas.procurement.pricing.QuoteBreakdown; import stirling.software.saas.procurement.pricing.QuoteConfig; import stirling.software.saas.procurement.repository.ProcurementDealRepository; import stirling.software.saas.procurement.repository.ProcurementQuoteRepository; -import stirling.software.saas.repository.TeamMembershipRepository; /** * Orchestrates a linked team's procurement journey: start a (mock-licensed) trial, build a diff --git a/app/saas/src/main/java/stirling/software/saas/security/SaasPortalAuditScopeResolver.java b/app/saas/src/main/java/stirling/software/saas/security/SaasPortalAuditScopeResolver.java index 00c7c42757..0d0df35688 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SaasPortalAuditScopeResolver.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SaasPortalAuditScopeResolver.java @@ -11,7 +11,7 @@ import lombok.RequiredArgsConstructor; import stirling.software.proprietary.audit.PortalAuditScope; import stirling.software.proprietary.audit.PortalAuditScopeResolver; -import stirling.software.saas.repository.TeamMembershipRepository; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; /** SaaS audit visibility: admins see the server; team LEADERs see their team (by member email). */ @Component diff --git a/app/saas/src/main/java/stirling/software/saas/security/SaasPrincipalResolver.java b/app/saas/src/main/java/stirling/software/saas/security/SaasPrincipalResolver.java new file mode 100644 index 0000000000..d064c7b59d --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/security/SaasPrincipalResolver.java @@ -0,0 +1,30 @@ +package stirling.software.saas.security; + +import java.util.HashSet; +import java.util.Set; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import stirling.software.proprietary.access.model.PrincipalRef; +import stirling.software.proprietary.access.service.PrincipalResolver; +import stirling.software.proprietary.security.model.User; + +/** USER/TEAM only: SaaS has no org concept, so an ORG grant must never match across tenants. */ +@Component +@Profile("saas") +public class SaasPrincipalResolver implements PrincipalResolver { + + @Override + public Set principalsOf(User user) { + if (user == null) { + return Set.of(); + } + Set principals = new HashSet<>(); + principals.add(PrincipalRef.user(user.getId())); + if (user.getTeam() != null) { + principals.add(PrincipalRef.team(user.getTeam().getId())); + } + return principals; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/security/TeamSecurityExpressions.java b/app/saas/src/main/java/stirling/software/saas/security/TeamSecurityExpressions.java index 3dfdf541cc..5263705986 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/TeamSecurityExpressions.java +++ b/app/saas/src/main/java/stirling/software/saas/security/TeamSecurityExpressions.java @@ -12,8 +12,8 @@ import lombok.RequiredArgsConstructor; import stirling.software.common.model.enumeration.TeamRole; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.service.UserService; -import stirling.software.saas.repository.TeamMembershipRepository; /** * Security expressions for team-based authorization in saas mode. Wired into @@ -65,6 +65,12 @@ public class TeamSecurityExpressions { return currentUser.getTeam().getId(); } + /** The current authenticated user's id, or {@code null} if unauthenticated. */ + public Long currentUserId() { + User currentUser = getCurrentUser(); + return currentUser == null ? null : currentUser.getId(); + } + /** Whether the current authenticated user is any kind of member of the given team. */ public boolean isTeamMember(Long teamId) { User currentUser = getCurrentUser(); diff --git a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java index 6d3e42e806..94996ca01c 100644 --- a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java +++ b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java @@ -16,17 +16,17 @@ import stirling.software.common.model.enumeration.InvitationStatus; import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.enumeration.TeamRole; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.saas.accountlink.LinkedInstanceRepository; import stirling.software.saas.billing.repository.BillingSubscriptionRepository; import stirling.software.saas.config.SupabaseConfigurationProperties; import stirling.software.saas.model.TeamInvitation; -import stirling.software.saas.model.TeamMembership; import stirling.software.saas.repository.SaasTeamExtensionsRepository; import stirling.software.saas.repository.TeamInvitationRepository; -import stirling.software.saas.repository.TeamMembershipRepository; /** SaaS-only team management: invitations, personal teams, seat caps, paid-subscription gating. */ @Service @@ -48,6 +48,17 @@ public class SaasTeamService { private final SaasTeamExtensionsRepository saasTeamExtensionsRepository; private final LinkedInstanceRepository linkedInstanceRepository; private final stirling.software.proprietary.security.service.UserService userService; + private final stirling.software.proprietary.access.repository.ResourceGrantRepository + resourceGrantRepository; + private final stirling.software.proprietary.integration.repository.IntegrationConfigRepository + integrationConfigRepository; + + // Team-owned integration configs + team grants FK the teams row; purge before deleting a team. + private void purgeTeamOwnedResources(Long teamId) { + integrationConfigRepository.deleteByOwnerTeam_Id(teamId); + resourceGrantRepository.deleteByPrincipalTypeAndPrincipalId( + stirling.software.proprietary.access.model.PrincipalType.TEAM, teamId); + } public static final String DEFAULT_TEAM_NAME = "Default"; public static final String INTERNAL_TEAM_NAME = "Internal"; @@ -347,6 +358,7 @@ public class SaasTeamService { "Deleting empty personal team {} after user {} joined another team", teamToDelete.getId(), acceptingUser.getUsername()); + purgeTeamOwnedResources(teamToDelete.getId()); teamRepository.delete(teamToDelete); } @@ -437,6 +449,7 @@ public class SaasTeamService { if (!saasTeamExtensionService.isPersonal(team) && membershipRepository.countByTeamId(teamId) == 0) { log.info("Deleting empty non-personal team {} after last member removed", teamId); + purgeTeamOwnedResources(team.getId()); teamRepository.delete(team); } @@ -551,6 +564,7 @@ public class SaasTeamService { if (!saasTeamExtensionService.isPersonal(team) && membershipRepository.countByTeamId(teamId) == 0) { log.info("Deleting empty non-personal team {} after last member left", teamId); + purgeTeamOwnedResources(team.getId()); teamRepository.delete(team); } diff --git a/app/saas/src/main/resources/db/migration/saas/V30__classification_labels.sql b/app/saas/src/main/resources/db/migration/saas/V30__classification_labels.sql new file mode 100644 index 0000000000..0719d73fb1 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V30__classification_labels.sql @@ -0,0 +1,13 @@ +-- Classification labels (gated by policies.enabled): the flat multi-label vocabulary the document +-- classifier runs against. One admin-editable row per team. The whole label set lives as JSON in +-- labels_json (authoritative on read). team_id is a natural key and a plain value (not a foreign +-- key) to stay decoupled from the security entities, so classification can be enabled or disabled +-- without touching them; the sentinel 0 holds the unteamed (login-disabled) team set. Hibernate +-- ddl-auto would also create this, but this keeps the schema explicit for Flyway-managed deploys. + +CREATE TABLE IF NOT EXISTS classification_labels ( + team_id BIGINT PRIMARY KEY, + labels_json TEXT, + updated_at TIMESTAMP, + updated_by VARCHAR(255) +); diff --git a/app/saas/src/main/resources/db/migration/saas/V31__policy_sort_order.sql b/app/saas/src/main/resources/db/migration/saas/V31__policy_sort_order.sql new file mode 100644 index 0000000000..2b2bdbd25b --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V31__policy_sort_order.sql @@ -0,0 +1,5 @@ +-- Team-wide policy run order (see PolicyEntity.sortOrder). The order policies run in is stored +-- server-side and shared by the whole team (not per-user in the browser), and is admin-editable. +-- Nullable so existing rows keep working; reads treat a null as 0 (coalesce), and the store +-- appends a new policy at max(order)+1 so setting one up adds it to the end of the queue. +ALTER TABLE policies ADD COLUMN sort_order INTEGER; diff --git a/app/saas/src/main/resources/db/migration/saas/V32__policy_processed_files.sql b/app/saas/src/main/resources/db/migration/saas/V32__policy_processed_files.sql new file mode 100644 index 0000000000..4be3fa07d8 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V32__policy_processed_files.sql @@ -0,0 +1,36 @@ +-- Per-policy processed-file ledger: +-- +-- policy_processed_files one row per (policy, file identity) recording the version a policy +-- last settled that file at, so folder sources track files in place +-- instead of moving them into a work directory. signature is a cheap +-- version gate (folder: size:mtime); content_hash an optional strong +-- token consulted only when the gate moves. Rows are claimed into +-- PROCESSING, settled to DONE/ERROR, flipped to INTERRUPTED at boot if +-- a run died with the JVM, and pruned once the file is gone from all of +-- the policy's sources, so the table stays near the set of files +-- currently present. +-- +-- Gated by policies.enabled like the rest of the subsystem; Hibernate ddl-auto would also create +-- this, but the migration keeps the schema explicit for the Flyway-managed deployments. + +CREATE TABLE IF NOT EXISTS policy_processed_files ( + policy_id VARCHAR(255) NOT NULL, + identity_hash VARCHAR(64) NOT NULL, + identity VARCHAR(4096), + signature VARCHAR(255) NOT NULL, + content_hash VARCHAR(64), + status VARCHAR(16) NOT NULL, + attempts SMALLINT NOT NULL DEFAULT 1, + last_seen BIGINT NOT NULL DEFAULT 0, + updated_at BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (policy_id, identity_hash) +); + +CREATE INDEX IF NOT EXISTS idx_processed_files_policy_seen + ON policy_processed_files (policy_id, last_seen); + +-- The cross-policy deletion-consensus check (existsByIdentityHashAndStatusNot) filters identity_hash +-- alone, so it cannot use the (policy_id, identity_hash) primary key; it runs once per successfully +-- consumed file, so index it to avoid a full scan on the hot path. +CREATE INDEX IF NOT EXISTS idx_processed_files_identity + ON policy_processed_files (identity_hash); diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java index 0de790cac0..e12888edb3 100644 --- a/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java @@ -19,12 +19,12 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import stirling.software.common.model.enumeration.TeamRole; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.accountlink.AccountLinkController.RegisterRequest; import stirling.software.saas.accountlink.AccountLinkController.RegisterResponse; -import stirling.software.saas.model.TeamMembership; -import stirling.software.saas.repository.TeamMembershipRepository; import stirling.software.saas.util.AuthenticationUtils; /** diff --git a/app/saas/src/test/java/stirling/software/saas/controller/SaasTeamControllerTest.java b/app/saas/src/test/java/stirling/software/saas/controller/SaasTeamControllerTest.java index ea8721a4c3..4dd8a9670d 100644 --- a/app/saas/src/test/java/stirling/software/saas/controller/SaasTeamControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/controller/SaasTeamControllerTest.java @@ -36,8 +36,10 @@ import stirling.software.common.model.enumeration.InvitationStatus; import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.enumeration.TeamRole; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; @@ -45,9 +47,7 @@ import stirling.software.saas.controller.SaasTeamController.InviteUserRequest; import stirling.software.saas.controller.SaasTeamController.RenameTeamRequest; import stirling.software.saas.controller.SaasTeamController.UpdateSeatsRequest; import stirling.software.saas.model.TeamInvitation; -import stirling.software.saas.model.TeamMembership; import stirling.software.saas.repository.TeamInvitationRepository; -import stirling.software.saas.repository.TeamMembershipRepository; import stirling.software.saas.security.TeamSecurityExpressions; import stirling.software.saas.service.SaasTeamExtensionService; import stirling.software.saas.service.SaasTeamService; diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygInvoicesControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygInvoicesControllerTest.java index 9266b02336..dc29ceb22d 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygInvoicesControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygInvoicesControllerTest.java @@ -23,14 +23,14 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.TeamMembership; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.payg.api.PaygInvoicesController.InvoiceResponse; import stirling.software.saas.payg.policy.PaygTeamExtensions; import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; import stirling.software.saas.payg.stripe.StripeInvoiceDao; -import stirling.software.saas.repository.TeamMembershipRepository; import stirling.software.saas.util.AuthenticationUtils; /** diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygPaymentMethodControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygPaymentMethodControllerTest.java index c4c0780b86..b7b7561a88 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygPaymentMethodControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygPaymentMethodControllerTest.java @@ -24,15 +24,15 @@ import org.springframework.security.oauth2.jwt.Jwt; import stirling.software.common.model.enumeration.TeamRole; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.TeamMembership; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.payg.api.PaygPaymentMethodController.PaymentMethodResponse; import stirling.software.saas.payg.policy.PaygTeamExtensions; import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; import stirling.software.saas.payg.stripe.StripePaymentMethodDao; import stirling.software.saas.payg.stripe.StripePaymentMethodDao.CardSummary; -import stirling.software.saas.repository.TeamMembershipRepository; import stirling.software.saas.security.EnhancedJwtAuthenticationToken; /** diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java index 6cc97d916b..0e02c0ce50 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java @@ -31,9 +31,10 @@ import org.springframework.security.oauth2.jwt.Jwt; import stirling.software.common.model.enumeration.TeamRole; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.TeamMembership; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.payg.api.PaygWalletController.UpdateCapRequest; import stirling.software.saas.payg.api.WalletSnapshotResponse.MemberRow; import stirling.software.saas.payg.billing.TeamBillingContext; @@ -50,7 +51,6 @@ import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; import stirling.software.saas.payg.repository.WalletLedgerRepository; import stirling.software.saas.payg.repository.WalletPolicyRepository; import stirling.software.saas.payg.wallet.WalletPolicy; -import stirling.software.saas.repository.TeamMembershipRepository; import stirling.software.saas.security.EnhancedJwtAuthenticationToken; /** diff --git a/app/saas/src/test/java/stirling/software/saas/security/SaasPrincipalResolverTest.java b/app/saas/src/test/java/stirling/software/saas/security/SaasPrincipalResolverTest.java new file mode 100644 index 0000000000..20acc83e5b --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/security/SaasPrincipalResolverTest.java @@ -0,0 +1,39 @@ +package stirling.software.saas.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.access.model.PrincipalRef; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; + +class SaasPrincipalResolverTest { + + private final SaasPrincipalResolver resolver = new SaasPrincipalResolver(); + + @Test + void projectsUserAndTeamOnlyNeverOrg() { + User u = new User(); + u.setId(5L); + Team t = new Team(); + t.setId(7L); + u.setTeam(t); + + assertThat(resolver.principalsOf(u)) + .containsExactlyInAnyOrder(PrincipalRef.user(5L), PrincipalRef.team(7L)); + } + + @Test + void userWithoutTeamProjectsUserOnly() { + User u = new User(); + u.setId(5L); + + assertThat(resolver.principalsOf(u)).containsExactly(PrincipalRef.user(5L)); + } + + @Test + void nullUserProjectsNothing() { + assertThat(resolver.principalsOf(null)).isEmpty(); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsMoreTest.java b/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsMoreTest.java index 580623c878..364db2855d 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsMoreTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsMoreTest.java @@ -25,10 +25,10 @@ import org.springframework.security.oauth2.jwt.Jwt; import stirling.software.common.model.enumeration.TeamRole; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.service.UserService; -import stirling.software.saas.model.TeamMembership; -import stirling.software.saas.repository.TeamMembershipRepository; /** * Additional branch coverage for {@link TeamSecurityExpressions}: the JWT resolution path, the diff --git a/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java b/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java index 23226e4467..c698daccca 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java @@ -20,10 +20,10 @@ import org.springframework.security.core.context.SecurityContextHolder; import stirling.software.common.model.enumeration.TeamRole; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.service.UserService; -import stirling.software.saas.model.TeamMembership; -import stirling.software.saas.repository.TeamMembershipRepository; /** * {@link TeamSecurityExpressions#isCurrentUserTeamLeader()} β€” used to gate policy editing on SaaS. diff --git a/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java index ac2a4cecba..5f1083cb3f 100644 --- a/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java @@ -30,18 +30,18 @@ import stirling.software.common.model.enumeration.InvitationStatus; import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.enumeration.TeamRole; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.saas.accountlink.LinkedInstanceRepository; import stirling.software.saas.billing.repository.BillingSubscriptionRepository; import stirling.software.saas.config.SupabaseConfigurationProperties; import stirling.software.saas.model.TeamInvitation; -import stirling.software.saas.model.TeamMembership; import stirling.software.saas.repository.SaasTeamExtensionsRepository; import stirling.software.saas.repository.TeamInvitationRepository; -import stirling.software.saas.repository.TeamMembershipRepository; /** * Unit tests for {@link SaasTeamService}. @@ -68,6 +68,14 @@ class SaasTeamServiceTest { @Mock private LinkedInstanceRepository linkedInstanceRepository; @Mock private stirling.software.proprietary.security.service.UserService userService; + @Mock + private stirling.software.proprietary.access.repository.ResourceGrantRepository + resourceGrantRepository; + + @Mock + private stirling.software.proprietary.integration.repository.IntegrationConfigRepository + integrationConfigRepository; + @InjectMocks private SaasTeamService service; private static final UUID SUPABASE_ID = UUID.fromString("11111111-2222-3333-4444-555555555555"); diff --git a/engine/src/stirling/agents/__init__.py b/engine/src/stirling/agents/__init__.py index c22bd6c977..7bc2cb63a2 100644 --- a/engine/src/stirling/agents/__init__.py +++ b/engine/src/stirling/agents/__init__.py @@ -1,5 +1,6 @@ """Agent modules for Stirling AI reasoning flows.""" +from .document_classifier import DocumentClassifierAgent from .execution import ExecutionPlanningAgent from .orchestrator import OrchestratorAgent from .pdf_create import PdfCreateAgent @@ -9,6 +10,7 @@ from .pdf_review import PdfReviewAgent from .user_spec import UserSpecAgent __all__ = [ + "DocumentClassifierAgent", "ExecutionPlanningAgent", "OrchestratorAgent", "PdfCreateAgent", diff --git a/engine/src/stirling/agents/document_classifier.py b/engine/src/stirling/agents/document_classifier.py new file mode 100644 index 0000000000..bb4ab5ef00 --- /dev/null +++ b/engine/src/stirling/agents/document_classifier.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import logging + +from pydantic import Field +from pydantic_ai import Agent +from pydantic_ai.output import NativeOutput + +from stirling.contracts import ( + ClassifyDocumentRequest, + ClassifyDocumentResponse, + DocumentClassificationResponse, + LabelOption, + PageText, +) +from stirling.models import ApiModel +from stirling.services import AppRuntime + +logger = logging.getLogger(__name__) + +# A document carries at most this many labels; anything past the cap is dropped +# in the model's order. +MAX_ASSIGNED_LABELS = 5 +# Pages read from each end of the document. A document's type is evident from +# its opening (and closing) pages, so a fixed window keeps cost and latency flat +# regardless of length. Promote to AppSettings if it ever needs tuning. +WINDOW_PAGES = 2 + + +_SYSTEM_PROMPT = ( + "You identify what a document is by assigning labels, choosing only from a " + "fixed list of allowed labels you are given.\n" + "\n" + "Rules:\n" + f"- Pick up to {MAX_ASSIGNED_LABELS} labels that describe this document's type.\n" + "- Only use labels from the allowed list, spelled exactly as listed.\n" + "- Return an empty list if none fit.\n" + "- Judge from the document's content and structure, not from keywords alone. " + "The document may be in any language.\n" + "- You are shown only the first and last pages; that is enough to identify the type." +) + + +class _ClassifierOutput(ApiModel): + """Raw model answer, before it is validated against the allowed vocabulary.""" + + labels: list[str] = Field( + default_factory=list, + description="Labels from the allowed list that describe this document's type.", + ) + + +def render_labels(labels: list[LabelOption]) -> str: + """Render the allowed vocabulary for the prompt β€” the model reasons over the + human names (it never sees ids).""" + names = [label.name for label in labels] + return f"Allowed labels: {', '.join(names) or '(none)'}" + + +def select_window(pages: list[PageText], window: int = WINDOW_PAGES) -> list[PageText]: + """Return the first and last ``window`` pages, never overlapping. + + Documents short enough that the two ends would meet are returned whole. The + caller usually sends just the window already; this is a defensive trim in + case it sends more. + """ + if window <= 0 or len(pages) <= window * 2: + return list(pages) + return [*pages[:window], *pages[-window:]] + + +def format_window(pages: list[PageText]) -> str: + if not pages: + return "(no extractable text)" + return "\n\n".join(f"[Page {page.page_number}]\n{page.text}" for page in pages) + + +def validate_labels(output: _ClassifierOutput, allowed: list[LabelOption]) -> DocumentClassificationResponse: + """Coerce a raw model answer (names) onto the allowed vocabulary, returning + label **ids**. + + The model answers with names; they are matched case-insensitively to the + allowed vocabulary and returned as that label's id. Anything off-list is + dropped, duplicates collapse to the first occurrence, and the result is + capped at ``MAX_ASSIGNED_LABELS`` in the model's order. The model identifies; + these rules decide what is allowed to stand. + """ + id_by_lower_name = {label.name.lower(): label.id for label in allowed} + + kept: list[str] = [] + for name in output.labels: + label_id = id_by_lower_name.get(name.strip().lower()) + if label_id is not None and label_id not in kept: + kept.append(label_id) + if len(kept) == MAX_ASSIGNED_LABELS: + break + + return DocumentClassificationResponse(labels=kept) + + +class DocumentClassifierAgent: + """Assigns labels to a document from an allowed vocabulary. + + Reads the bounded page window supplied on the request (first/last + ``WINDOW_PAGES``) and runs a single fast-model pass, then validates the + answer against the vocabulary so nothing off-list survives. + """ + + def __init__(self, runtime: AppRuntime) -> None: + self.runtime = runtime + self._agent = Agent( + model=runtime.fast_model, + output_type=NativeOutput(_ClassifierOutput), + system_prompt=_SYSTEM_PROMPT, + model_settings=runtime.fast_model_settings, + ) + + async def classify(self, request: ClassifyDocumentRequest) -> ClassifyDocumentResponse: + # The caller (the backend) always supplies the allowed vocabulary β€” the + # team's stored labels β€” so the engine holds no vocabulary of its own. + allowed = request.labels + window = select_window(request.pages) + prompt = self._build_prompt(request.file_name, allowed, window) + logger.debug("[classify] prompt:\n%s", prompt) + result = await self._agent.run(prompt) + return validate_labels(result.output, allowed) + + @staticmethod + def _build_prompt(file_name: str, allowed: list[LabelOption], window: list[PageText]) -> str: + return ( + f"{render_labels(allowed)}\n\n" + f"Document file name: {file_name}\n" + f"Document content (first and last pages):\n{format_window(window)}" + ) diff --git a/engine/src/stirling/api/app.py b/engine/src/stirling/api/app.py index 7e6272c28b..34b0da36fd 100644 --- a/engine/src/stirling/api/app.py +++ b/engine/src/stirling/api/app.py @@ -10,6 +10,7 @@ from pydantic_ai import Agent from pydantic_ai.models.instrumented import InstrumentationSettings from stirling.agents import ( + DocumentClassifierAgent, ExecutionPlanningAgent, OrchestratorAgent, PdfEditAgent, @@ -24,6 +25,7 @@ from stirling.api.middleware import UserIdMiddleware from stirling.api.routes import ( agent_capabilities_router, agent_draft_router, + document_classifier_router, document_router, execution_router, ledger_router, @@ -95,6 +97,7 @@ async def lifespan(fast_api: FastAPI): fast_api.state.execution_planning_agent = ExecutionPlanningAgent(runtime) fast_api.state.math_auditor_agent = MathAuditorAgent(runtime) fast_api.state.pdf_comment_agent = PdfCommentAgent(runtime) + fast_api.state.document_classifier_agent = DocumentClassifierAgent(runtime) tracer_provider = setup_posthog_tracking(settings) if tracer_provider: Agent.instrument_all(InstrumentationSettings(tracer_provider=tracer_provider)) @@ -131,6 +134,7 @@ app.include_router(document_router, dependencies=_user_gate) app.include_router(ledger_router, dependencies=_user_gate) app.include_router(pdf_comments_router, dependencies=_user_gate) app.include_router(agent_capabilities_router, dependencies=_user_gate) +app.include_router(document_classifier_router, dependencies=_user_gate) @app.get("/health", response_model=HealthResponse) diff --git a/engine/src/stirling/api/dependencies.py b/engine/src/stirling/api/dependencies.py index 780e97c9d7..ed9aa3f041 100644 --- a/engine/src/stirling/api/dependencies.py +++ b/engine/src/stirling/api/dependencies.py @@ -5,6 +5,7 @@ from typing import Annotated from fastapi import Depends, HTTPException, Request, status from stirling.agents import ( + DocumentClassifierAgent, ExecutionPlanningAgent, OrchestratorAgent, PdfEditAgent, @@ -55,6 +56,10 @@ def get_pdf_comment_agent(request: Request) -> PdfCommentAgent: return request.app.state.pdf_comment_agent +def get_document_classifier_agent(request: Request) -> DocumentClassifierAgent: + return request.app.state.document_classifier_agent + + def require_user_id() -> UserId: """FastAPI dependency for routes that touch per-user storage. diff --git a/engine/src/stirling/api/routes/__init__.py b/engine/src/stirling/api/routes/__init__.py index 1c1e4e1d23..f40e9918f8 100644 --- a/engine/src/stirling/api/routes/__init__.py +++ b/engine/src/stirling/api/routes/__init__.py @@ -1,5 +1,6 @@ from .agent_capabilities import router as agent_capabilities_router from .agent_drafts import router as agent_draft_router +from .document_classifier import router as document_classifier_router from .documents import router as document_router from .execution import router as execution_router from .ledger import router as ledger_router @@ -11,6 +12,7 @@ from .pdf_questions import router as pdf_question_router __all__ = [ "agent_capabilities_router", "agent_draft_router", + "document_classifier_router", "document_router", "execution_router", "ledger_router", diff --git a/engine/src/stirling/api/routes/document_classifier.py b/engine/src/stirling/api/routes/document_classifier.py new file mode 100644 index 0000000000..649c0f4e9e --- /dev/null +++ b/engine/src/stirling/api/routes/document_classifier.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends + +from stirling.agents import DocumentClassifierAgent +from stirling.api.dependencies import get_document_classifier_agent +from stirling.contracts import ClassifyDocumentRequest, ClassifyDocumentResponse + +router = APIRouter(prefix="/api/v1/documents/classify", tags=["document-classifier"]) + + +@router.post("", response_model=ClassifyDocumentResponse) +async def classify_document( + request: ClassifyDocumentRequest, + agent: Annotated[DocumentClassifierAgent, Depends(get_document_classifier_agent)], +) -> ClassifyDocumentResponse: + """Classify a document from its supplied page text against the allowed labels. + + The caller sends the bounded page window inline, so no per-user document + storage is touched here β€” the request is self-contained. + """ + return await agent.classify(request) diff --git a/engine/src/stirling/contracts/__init__.py b/engine/src/stirling/contracts/__init__.py index 77ce40301e..513906d610 100644 --- a/engine/src/stirling/contracts/__init__.py +++ b/engine/src/stirling/contracts/__init__.py @@ -35,6 +35,12 @@ from .contradiction import ( ContradictionReport, ContradictionSeverity, ) +from .document_classifier import ( + ClassifyDocumentRequest, + ClassifyDocumentResponse, + DocumentClassificationResponse, + LabelOption, +) from .documents import ( DeleteDocumentResponse, IngestDocumentRequest, @@ -128,6 +134,8 @@ __all__ = [ "AiToolAgentStep", "ArtifactKind", "CannotContinueExecutionAction", + "ClassifyDocumentRequest", + "ClassifyDocumentResponse", "Claim", "CommentSpec", "CompletedExecutionAction", @@ -138,6 +146,8 @@ __all__ = [ "DeleteDocumentResponse", "PurgeOwnerResponse", "Discrepancy", + "DocumentClassificationResponse", + "LabelOption", "DocumentMeta", "DocumentSections", "DiscrepancyKind", diff --git a/engine/src/stirling/contracts/document_classifier.py b/engine/src/stirling/contracts/document_classifier.py new file mode 100644 index 0000000000..d64e79f492 --- /dev/null +++ b/engine/src/stirling/contracts/document_classifier.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from pydantic import Field + +from stirling.models import ApiModel + +from .documents import PageText + + +class LabelOption(ApiModel): + """One allowed label: a stable ``id`` (the identity) plus the human ``name`` + the model actually reasons over. The model picks names; the engine maps its + answer back to ids, so the result is language- and rename-stable.""" + + id: str = Field(min_length=1) + name: str = Field(min_length=1) + + +class ClassifyDocumentRequest(ApiModel): + """Classify one document from its page text. + + The caller sends the page text directly β€” typically just the bounded window + (first/last pages), since the classifier reads no more than that. There is no + ingestion or RAG step. + + ``labels`` is the allowed vocabulary for this request β€” the caller (the + backend) always supplies it from the team's stored label set, each an + ``{id, name}`` pair. The engine holds no vocabulary of its own, so at least + one label is required. + """ + + file_name: str = Field(min_length=1) + pages: list[PageText] = Field(default_factory=list) + labels: list[LabelOption] = Field(min_length=1) + + +class DocumentClassificationResponse(ApiModel): + """Terminal classification result. + + ``labels`` is the subset of the allowed vocabulary the model assigned to the + document, as label **ids**: at most five entries, deduplicated, in the + model's order. An empty list is a valid answer β€” nothing in the vocabulary + fit. This is a plain answer from a dedicated endpoint β€” it carries no + ``outcome`` discriminator (it isn't one of the orchestrator's + WorkflowOutcome-routed union responses). + """ + + labels: list[str] = Field(default_factory=list) + + +# Only one response shape today; kept as a named alias so routes and agents have +# a stable response type to import. +ClassifyDocumentResponse = DocumentClassificationResponse diff --git a/engine/tests/test_document_classifier.py b/engine/tests/test_document_classifier.py new file mode 100644 index 0000000000..869d0dc728 --- /dev/null +++ b/engine/tests/test_document_classifier.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import re +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from stirling.agents.document_classifier import ( + MAX_ASSIGNED_LABELS, + DocumentClassifierAgent, + _ClassifierOutput, + render_labels, + select_window, + validate_labels, +) +from stirling.contracts import ( + ClassifyDocumentRequest, + DocumentClassificationResponse, + LabelOption, + PageText, +) +from stirling.services.runtime import AppRuntime + + +def _page(number: int, text: str = "x") -> PageText: + return PageText(page_number=number, text=text) + + +def _slug(name: str) -> str: + return re.sub(r"^-+|-+$", "", re.sub(r"[^a-z0-9]+", "-", name.lower())) + + +def _opts(*names: str) -> list[LabelOption]: + """Allowed vocabulary from names, with slug ids (mirrors the frontend).""" + return [LabelOption(id=_slug(name), name=name) for name in names] + + +# ── select_window ─────────────────────────────────────────────────────────── + + +def test_select_window_returns_short_documents_whole() -> None: + pages = [_page(1), _page(2), _page(3), _page(4)] + assert select_window(pages, window=2) == pages + + +def test_select_window_takes_both_ends_without_overlap() -> None: + pages = [_page(n) for n in range(1, 6)] # 5 pages + selected = select_window(pages, window=2) + assert [p.page_number for p in selected] == [1, 2, 4, 5] + + +def test_select_window_handles_empty() -> None: + assert select_window([], window=2) == [] + + +def test_select_window_zero_returns_all() -> None: + pages = [_page(1), _page(2), _page(3)] + assert select_window(pages, window=0) == pages + + +# ── validate_labels ────────────────────────────────────────────────────────── + + +def test_valid_labels_are_preserved_in_order() -> None: + # Model answers with names; the result is the matching label ids. + output = _ClassifierOutput(labels=["Invoice", "Receipt"]) + result = validate_labels(output, _opts("Invoice", "Receipt", "Purchase order")) + assert isinstance(result, DocumentClassificationResponse) + assert result.labels == ["invoice", "receipt"] + + +def test_off_list_labels_are_dropped() -> None: + output = _ClassifierOutput(labels=["Invoice", "Warp core", "Receipt"]) + result = validate_labels(output, _opts("Invoice", "Receipt")) + assert result.labels == ["invoice", "receipt"] + + +def test_matching_is_case_insensitive_and_returns_ids() -> None: + output = _ClassifierOutput(labels=["invoice", " CREDIT NOTE "]) + result = validate_labels(output, _opts("Invoice", "Credit note")) + assert result.labels == ["invoice", "credit-note"] + + +def test_duplicates_collapse_to_first_occurrence() -> None: + output = _ClassifierOutput(labels=["Invoice", "invoice", "Receipt", "INVOICE"]) + result = validate_labels(output, _opts("Invoice", "Receipt")) + assert result.labels == ["invoice", "receipt"] + + +def test_result_is_capped_at_max_assigned_labels() -> None: + allowed = _opts(*[f"Label {n}" for n in range(10)]) + output = _ClassifierOutput(labels=[label.name for label in allowed]) + result = validate_labels(output, allowed) + assert result.labels == [label.id for label in allowed[:MAX_ASSIGNED_LABELS]] + + +def test_empty_answer_is_valid() -> None: + result = validate_labels(_ClassifierOutput(labels=[]), _opts("Invoice")) + assert result.labels == [] + + +def test_entirely_off_list_answer_yields_empty_result() -> None: + output = _ClassifierOutput(labels=["Spaceship", "Boarding pass"]) + result = validate_labels(output, _opts("Invoice", "Receipt")) + assert result.labels == [] + + +# ── render_labels ──────────────────────────────────────────────────────────── + + +def test_render_labels_lists_the_vocabulary_names() -> None: + rendered = render_labels(_opts("Invoice", "Receipt")) + assert "Invoice" in rendered + assert "Receipt" in rendered + + +def test_render_labels_handles_empty_vocabulary() -> None: + assert "(none)" in render_labels([]) + + +# ── DocumentClassifierAgent (inline page text) ─────────────────────────────── + + +def _stub_model_answer(agent: DocumentClassifierAgent, labels: list[str]) -> AsyncMock: + mock = AsyncMock(return_value=SimpleNamespace(output=_ClassifierOutput(labels=labels))) + agent._agent.run = mock + return mock + + +@pytest.mark.anyio +async def test_classify_respects_request_supplied_vocabulary(runtime: AppRuntime) -> None: + agent = DocumentClassifierAgent(runtime) + run_mock = _stub_model_answer(agent, ["board minutes", "Invoice"]) + + result = await agent.classify( + ClassifyDocumentRequest( + file_name="minutes.pdf", + pages=[PageText(page_number=1, text="Minutes of the board meeting")], + labels=_opts("Board minutes", "Agenda"), + ) + ) + + # "Invoice" is off this request's vocabulary even though the default knows it. + assert result.labels == ["board-minutes"] + assert run_mock.await_args is not None + prompt = run_mock.await_args.args[0] + assert "Board minutes" in prompt + assert "Agenda" in prompt diff --git a/engine/tests/test_document_classifier_routes.py b/engine/tests/test_document_classifier_routes.py new file mode 100644 index 0000000000..76715d2862 --- /dev/null +++ b/engine/tests/test_document_classifier_routes.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from fastapi.testclient import TestClient + +from stirling.api import app +from stirling.api.dependencies import get_document_classifier_agent +from stirling.contracts import ( + ClassifyDocumentRequest, + ClassifyDocumentResponse, + DocumentClassificationResponse, +) + + +class StubClassifierAgent: + """Stands in for DocumentClassifierAgent so route tests don't call a model.""" + + def __init__(self, response: ClassifyDocumentResponse) -> None: + self._response = response + + async def classify(self, _request: ClassifyDocumentRequest) -> ClassifyDocumentResponse: + return self._response + + +@pytest.fixture +def classification_client() -> Iterator[TestClient]: + app.dependency_overrides[get_document_classifier_agent] = lambda: StubClassifierAgent( + # The result is label ids (the model's name answers, mapped to ids). + DocumentClassificationResponse(labels=["nda", "contract"]) + ) + try: + yield TestClient(app) + finally: + app.dependency_overrides.pop(get_document_classifier_agent, None) + + +_LABELS = [{"id": "nda", "name": "NDA"}, {"id": "contract", "name": "Contract"}] + + +def test_classify_returns_assigned_labels(classification_client: TestClient) -> None: + response = classification_client.post( + "/api/v1/documents/classify", + json={ + "fileName": "nda.pdf", + "pages": [{"pageNumber": 1, "text": "Mutual NDA between A and B."}], + "labels": _LABELS, + }, + ) + assert response.status_code == 200 + assert response.json() == {"labels": ["nda", "contract"]} + + +def test_classify_accepts_allowed_labels_on_the_request(classification_client: TestClient) -> None: + response = classification_client.post( + "/api/v1/documents/classify", + json={ + "fileName": "nda.pdf", + "pages": [], + "labels": [ + {"id": "contract", "name": "Contract"}, + {"id": "invoice", "name": "Invoice"}, + ], + }, + ) + assert response.status_code == 200 + + +def test_classify_accepts_empty_pages(classification_client: TestClient) -> None: + response = classification_client.post( + "/api/v1/documents/classify", + json={"fileName": "blank.pdf", "pages": [], "labels": _LABELS}, + ) + assert response.status_code == 200 + + +def test_classify_rejects_empty_file_name(classification_client: TestClient) -> None: + response = classification_client.post( + "/api/v1/documents/classify", + json={"fileName": "", "pages": [], "labels": _LABELS}, + ) + assert response.status_code == 422 + + +def test_classify_rejects_missing_labels(classification_client: TestClient) -> None: + # The backend always supplies the vocabulary; a request without one is invalid. + response = classification_client.post( + "/api/v1/documents/classify", + json={"fileName": "nda.pdf", "pages": []}, + ) + assert response.status_code == 422 diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 76463001fe..5ec17efb9a 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -19,11 +19,26 @@ import { UIProvider } from "@portal/contexts/UIContext"; import { SuiProvider } from "@portal/theme/SuiProvider"; import { handlers } from "@portal/mocks/handlers"; import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient"; +import i18next from "i18next"; +import { initReactI18next } from "react-i18next"; import "@mantine/core/styles.css"; import "@core/tokens/tokens.css"; import "@core/tokens/base.css"; +// Storybook-only: init react-i18next so t(key, fallback, vars) interpolates its +// English fallback (there's no backend here to load locale files). Without this, +// the default t() returns raw templates like "{{count}} people Β· led by {{owner}}". +if (!i18next.isInitialized) { + void i18next.use(initReactI18next).init({ + lng: "en", + fallbackLng: "en", + resources: { en: { translation: {} } }, + interpolation: { escapeValue: false }, + react: { useSuspense: false }, + }); +} + // Start MSW once. Storybook runs in a browser so this uses the service worker. initialize({ onUnhandledRequest: "bypass" }, handlers); diff --git a/frontend/editor/.env.proprietary b/frontend/editor/.env.proprietary index 0ee7e66bc9..4d25e380da 100644 --- a/frontend/editor/.env.proprietary +++ b/frontend/editor/.env.proprietary @@ -13,10 +13,12 @@ # VITE_EDITOR_URL=http://localhost:5173/). VITE_EDITOR_URL=/ -# Force the portal's MSW mocks on ("true") or off ("false"). Empty = default -# (on in dev, off in production builds). Set "false" to run against the real -# backend. -VITE_PORTAL_MOCKS= +# Hosted SaaS Supabase project for the self-hosted portal's IN-APP account +# linking (both values are public). Set per deploy; absent -> the account-link +# UI shows a "configure" state. For local e2e, point these at the SaaS Supabase +# project the local backend links against. +VITE_SAAS_SUPABASE_URL= +VITE_SAAS_SUPABASE_ANON_KEY= # Hosted SaaS Java backend base URL (e.g. https://api.stirlingpdf.com). Used for # ATTENDED portal -> SaaS reads (wallet, billing, plans, checkout) with the diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index e81f5eaebb..1cc2ad8ff1 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -2812,6 +2812,7 @@ back = "Back" cancel = "Cancel" close = "Close" collapse = "Collapse" +confirm = "Confirm" continue = "Continue" copied = "Copied!" copy = "Copy" @@ -3690,7 +3691,9 @@ workbench = "Workbench" [fileSidebar] addFiles = "Add files" +addingFiles = "Adding files…" collapse = "Collapse sidebar" +customizeGroups = "Customize groups" dropHint = "Open files to get started" dropToAdd = "Drop files to add" expand = "Expand sidebar" @@ -3703,8 +3706,11 @@ noFiles = "No files yet" openFileManager = "Browse all files & folders" openFromComputer = "Open from computer" openSettings = "Open settings" +other = "Other" +recent = "Recent" search = "Search" searchPlaceholder = "Search files..." +viewAll = "View all {{count}} files" [fileSidebar.fileItem] closeViewer = "Close viewer" @@ -3716,6 +3722,23 @@ updateOnServer = "Update on server" uploadToServer = "Upload to server" versionHistory = "Version history" +[fileSidebar.groupsModal] +add = "Add" +addLabel = "Add a label…" +categoryIconAria = "Choose an icon for {{name}}" +createCategory = "Create category" +delete = "Delete category" +done = "Done" +hide = "Hide category" +newCategory = "New category name…" +removeLabel = "Remove {{name}}" +rename = "Rename" +reset = "Reset to defaults" +search = "Search labels…" +show = "Show category" +subtitle = "Group your files into parent categories. Add existing or new labels to a category, rename it, or create your own. Files in none of your categories appear under β€œOther”." +title = "Sidebar categories" + [filesPage] addToWorkspace = "Add to workspace" addToWorkspaceCount = "Add {{count}} to workspace" @@ -3725,6 +3748,7 @@ backToFolder = "Back to {{folder}}" backToMyFiles = "Back to My Files" breadcrumbs = "Folder path" cancel = "Cancel" +classification = "Classification" clearSearch = "Clear search" clearSelection = "Clear selection" closeDetails = "Close details" @@ -3884,10 +3908,10 @@ uploadFilesFailedDetail = "Could not upload files: {{message}}" added = "Added" count = "Files" folder = "Folder" +labels = "Labels" modified = "Modified" name = "Name" size = "Size" -toolHistory = "Tool history" toolHistoryAtVersion = "Cumulative tool chain" totalSize = "Total size" type = "Type" @@ -5956,6 +5980,16 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" +[policies.docType] +Contracts = "Contracts" +"Financial reports" = "Financial reports" +"HR records" = "HR records" +Insurance = "Insurance" +Invoices = "Invoices" +"Legal filings" = "Legal filings" +"Medical / PHI" = "Medical / PHI" +"Tax documents" = "Tax documents" + [policies.enforcement] applying = "Applying {{names}}" applyingProgress = "Applying {{names}} ({{done}} of {{total}})" @@ -5977,9 +6011,95 @@ export = "Enforcing before export" input = "Enforcing on import" print = "Enforcing before print" +[policies.field] +accessLog = "Access log" +archiveAfter = "Archive after" +auditTrail = "Audit trail" +belowThreshold = "Below threshold" +destination = "Destination" +frameworks = "Frameworks" +immutableHold = "Immutable hold" +keepFor = "Keep for" +minConfidence = "Min confidence" +notify = "Notify on route" +onViolation = "When non-compliant" +webhookUrl = "Webhook URL" + +[policies.fieldOption.archiveAfter] +"1 year" = "1 year" +"30 days" = "30 days" +"90 days" = "90 days" +Never = "Never" + +[policies.fieldOption.belowThreshold] +"Flag for review" = "Flag for review" +Hold = "Hold" +"Route to bucket" = "Route to bucket" + +[policies.fieldOption.destination] +Documents = "Documents" +"S3 bucket" = "S3 bucket" +SharePoint = "SharePoint" +Webhook = "Webhook" + +[policies.fieldOption.frameworks] +FedRAMP = "FedRAMP" +GDPR = "GDPR" +HIPAA = "HIPAA" +"ISO 27001" = "ISO 27001" +"PCI DSS" = "PCI DSS" +"SOC 2" = "SOC 2" + +[policies.fieldOption.keepFor] +"1 year" = "1 year" +"3 years" = "3 years" +"30 days" = "30 days" +"7 years" = "7 years" +Indefinite = "Indefinite" + +[policies.fieldOption.minConfidence] +"60%" = "60%" +"70%" = "70%" +"80%" = "80%" +"90%" = "90%" +"95%" = "95%" + +[policies.fieldOption.onViolation] +"Auto-redact PHI" = "Auto-redact PHI" +"Block export" = "Block export" +"Flag for review" = "Flag for review" +"Quarantine document" = "Quarantine document" + [policies.fields] selectedCount = "{{count}} selected" +[policies.labels] +add = "Add" +addPlaceholder = "Add a label…" +customNote = "Customized for your team." +defaultNote = "Using the built-in default, shared with your team." +duplicate = "\"{{name}}\" already exists." +edit = "Edit labels" +empty = "No labels yet." +export = "Export JSON" +iconAria = "Choose an icon for {{name}}" +import = "Import JSON" +importError = "Couldn't import that file." +managedNote = "Team labels are managed by your team leader." +modalSubtitle = "Shared with your whole team. The classifier picks the labels that fit each document." +modalTitle = "Classification labels" +pickIcon = "Use {{label}} icon" +removeAria = "Remove {{name}}" +resetToDefault = "Reset to default" +saveForTeam = "Save for team" +saving = "Saving…" +sectionLabel = "Classification labels" +startFromScratch = "Start from scratch" +teamCount = "team labels" +tooLong = "Labels can be at most {{max}} characters." +ungrouped = "Ungrouped" +view = "View labels" + [policies.pii] account = "Account numbers (labelled)" card = "Credit / debit cards" @@ -5991,17 +6111,30 @@ placeholder = "Select PII types" routing = "US routing numbers (ABA)" ssn = "Social Security numbers" +[policies.settings] +noneExport = "No policies currently run on export." +noneUpload = "No policies currently run on upload." +onExport = "On export" +onUpload = "On upload" +reorderHandle = "Drag to reorder" +runOrderDesc = "When more than one policy runs on the same trigger, they run in this order β€” each on the previous policy's output. Drag to reorder." +title = "Policy settings" + [policies.sidebar] activeCount = "{{count}} active" -infoAriaLabel = "What is a policy?" infoTooltip = "A policy is a fixed set of tools that runs automatically whenever it's triggered β€” for example when a new document arrives β€” enforcing rules like redacting PII with no manual steps." loading = "Loading…" +optionsAriaLabel = "Policy options" +policySettings = "Policy settings" railAriaLabel = "{{label}} policy β€” {{status}}" railSuffixActive = " (Active)" railSuffixPaused = " (Paused)" +retryFailed = "Retry failed policies ({{count}})" +rowProgress = "{{completed}} of {{total}} files processed" setUp = "Set up" title = "Policies" upgradeToEnterprise = "Upgrade to enterprise" +whatIsPolicy = "What is a policy?" [policies.status] active = "Active" @@ -6462,8 +6595,18 @@ componentSpendMtd = "Component spend (MTD)" embedsThisMonth = "Embeds this month" inBeta = "In beta" -[portal.common] -inviteMember = "Invite member" +[portal.components.billingUnit] +approval = "approval" +check = "check" +event = "event" +render = "render" +review = "review" +session = "session" +signature = "signature" + +[portal.components.maturity] +beta = "Beta" +ga = "GA" [portal.componentsView] subtitle = "Embeddable SDK widgets you drop into your own app β€” a viewer, an e-sign flow, an AI review panel. Each is metered per action. Click a card for install, usage and props." @@ -6585,7 +6728,14 @@ subtitle = "Every document your org has processed, with its full processing reco title = "Documents" [portal.documents.audit] +approved = "Approved" +archived = "Archived" +elevation = "Elevation" empty = "No events recorded yet." +extracted = "Processed" +flagged = "Needs Review" +ingested = "Ingested" +reviewed = "In Review" [portal.documents.drawer] sectionsAriaLabel = "Document detail sections" @@ -6636,6 +6786,12 @@ user = "User" description = "As sources feed documents into your pipelines they'll appear here for review." title = "No documents in the queue" +[portal.documents.status] +error = "Error" +flagged = "Needs Review" +inReview = "In Review" +processed = "Processed" + [portal.documents.table] auto = "Auto" editorAction = "Editor" @@ -6710,6 +6866,12 @@ title = "Service token" description = "A new token was issued. Update each self-hosted instance's STIRLING_SERVICE_TOKEN within the 24h grace window or they'll drop offline." title = "Rotate running instances" +[portal.editorAdmin.status] +degraded = "Degraded" +healthy = "Healthy" +offline = "Offline" +pairing = "Pairing" + [portal.editorAdmin.targets] instanceCount_one = "{{count}} instance" instanceCount_other = "{{count}} instances" @@ -6730,23 +6892,6 @@ description = "This view hit an unexpected error. Try again, or pick another sec retry = "Try again" title = "Something went wrong on this page" -[portal.forkWizard] -subtitle = "Clone a proven workflow and tune it β€” every template ships the same four-stage backbone." -title = "Fork a starter pipeline" - -[portal.forkWizard.action] -cancel = "Cancel" -deploy = "Deploy pipeline" -pickAnother = "Pick another" - -[portal.forkWizard.status] -building = "Building…" -ready = "Ready to deploy" - -[portal.home.chart.empty] -description = "Once documents are processed, your 30-day usage appears here." -title = "No usage yet" - [portal.home.editor] activeUsers = "{{n}} active" invite = "Invite teammates" @@ -6764,25 +6909,6 @@ afternoon = "Good afternoon" evening = "Good evening" morning = "Good morning" -[portal.home.kpis.enterprise] -docs30d = "Docs / 30d" -evalPassRate = "Eval pass rate" -p95Latency = "P95 latency" -slaUptime = "SLA uptime (30d)" - -[portal.home.kpis.free] -agents = "Agents" -docsProcessed = "Docs processed" -docsProcessedDescription = "Free plan cap" -operations = "Operations" -pipelines = "Pipelines" - -[portal.home.kpis.pro] -agentsActive = "Agents active" -docs30d = "Docs / 30d" -evalPassRate = "Eval pass rate" -pipelines = "Pipelines" - [portal.home.onboarding] dismiss = "Dismiss setup" notStarted = "Not started" @@ -6810,25 +6936,6 @@ blurb = "{{connected}} connected Β· every PDF governed where it lands" chip = "{{connected}} connected" title = "Connect your sources" -[portal.home.productGrid] -ariaLabel = "Process PDFs at scale" - -[portal.home.productGrid.agents] -blurb = "Wire your agent via MCP, REST, or tool definitions. Deterministic operations, scenarios, evals." -cta = "Connect an agent" -title = "Agents" - -[portal.home.productGrid.pipelines] -badge = "Hero" -blurb = "Compose document workflows from typed operations. Upload a sample to get suggestions or start blank." -cta = "Build a pipeline" -title = "Pipelines" - -[portal.home.productGrid.sources] -blurb = "Attach pipelines where PDFs already live β€” S3, agents, SharePoint, webhooks, batch, email." -cta = "Connect a source" -title = "Sources" - [portal.home.quickActions] subtitle = "Top tasks for today" title = "Quick actions" @@ -6845,18 +6952,6 @@ title = "Connect a source" blurb = "Scoped key with rate limits and IP allowlist" title = "Issue an API key" -[portal.home.quickActions.tryOp] -blurb = "Drop a sample, pick an op, see the JSON" -title = "Try a PDF operation" - -[portal.home.regions] -subtitle = "Real-time status for every deployed Stirling region." -title = "Region health" - -[portal.home.regions.empty] -description = "Once a region is deployed, its health appears here." -title = "No regions yet" - [portal.infrastructure] manageEditorDeployment = "Manage Editor deployment" sectionsAriaLabel = "Infrastructure sections" @@ -7165,14 +7260,6 @@ models = "Models" security = "Security" storage = "Storage" -[portal.mocks.label] -off = "Mocks OFF" -on = "Mocks ON" - -[portal.mocks.tooltip] -off = "Mock data OFF β€” fetch calls go to the real network. Click to re-enable mocks (reloads the page)." -on = "Mock data ON β€” fetch calls are intercepted by MSW. Click to switch to the real network (reloads the page)." - [portal.nav] agent-builder = "Agent Builder" components = "Components" @@ -7209,51 +7296,6 @@ new_other = "{{count}} new" description = "No new notifications." title = "You're all caught up" -[portal.opRunner] -durationMs = "{{ms}} ms" -featuredOps = "Featured ops" -subtitle = "Drop a sample, pick an op, see what Stirling returns." -title = "Try a PDF operation" - -[portal.opRunner.action] -close = "Close" -openBuilder = "Open the pipeline builder" -run = "Run operation" -runAgain = "Run again" -running = "Running…" - -[portal.opRunner.drop] -hint = "or use a sample document." -pickAnother = "Pick another sample" -replaceHint = "Drop again or pick another sample to replace." -title = "Drop a PDF here" -useSample = "Use a sample" - -[portal.opRunner.empty] -description = "Once operations are published, they'll show up here." -title = "No featured ops yet" - -[portal.opRunner.error] -title = "The operation didn't complete" -unknown = "Unknown error" - -[portal.opRunner.hint] -aSample = "a sample" -press = "Press" -ready = "Ready" -toInvoke = "to invoke" - -[portal.opRunner.hint.runOn] -before = "Run" -middle = "on" - -[portal.opRunner.running] -title = "Running {{label}}…" - -[portal.opRunner.status] -completed = "Completed" -failed = "Failed" - [portal.pipelines] subtitle = "Every automated document pipeline on the backend: an ordered chain of operations over a set of sources, run on a trigger. Click a row for its steps and sources." title = "Pipelines" @@ -7365,6 +7407,90 @@ title = "Policies" comingSoon = "Coming soon" notSetUp = "Not set up" +[portal.policies.categories.compliance] +desc = "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document." +label = "Compliance" + +[portal.policies.categories.ingestion] +desc = "Classify documents, extract structured data, enforce naming conventions, and normalize pages." +label = "Ingestion" + +[portal.policies.categories.retention] +desc = "Set how long documents are kept, when to archive, and when to delete." +label = "Retention" + +[portal.policies.categories.routing] +desc = "Auto-route documents to the right team, folder, or system." +label = "Routing" + +[portal.policies.categories.security] +desc = "Detect PII, redact, strip active content, and watermark documents." +label = "Security" + +[portal.policies.config] +scopeAll = "All documents" + +[portal.policies.config.compliance] +summary = "Validates documents against regulatory frameworks before they leave the system." + +[portal.policies.config.compliance.fields] +accessLog = "Access log" +auditTrail = "Audit trail" +frameworks = "Frameworks" +onViolation = "When non-compliant" + +[portal.policies.config.compliance.rules] +0 = "Framework scan" +1 = "Enforce action" +2 = "Audit trail" + +[portal.policies.config.ingestion] +summary = "Classifies documents, extracts structured data, enforces naming, and normalizes pages." + +[portal.policies.config.ingestion.fields] +belowThreshold = "Below threshold" +minConfidence = "Min confidence" + +[portal.policies.config.ingestion.rules] +0 = "Classify" +1 = "Extract" +2 = "Name" +3 = "Normalize" + +[portal.policies.config.retention] +summary = "Enforces how long documents are kept, when to archive, and when to delete." + +[portal.policies.config.retention.fields] +archiveAfter = "Archive after" +immutableHold = "Immutable hold" +keepFor = "Keep for" + +[portal.policies.config.retention.rules] +0 = "Retention hold" +1 = "Auto-archive" +2 = "Deletion block" + +[portal.policies.config.routing] +summary = "Routes documents to the right destination based on type and classification." + +[portal.policies.config.routing.fields] +destination = "Destination" +notify = "Notify on route" +webhookUrl = "Webhook URL" + +[portal.policies.config.routing.rules] +0 = "Auto-classify" +1 = "Route to folder" +2 = "Webhook notify" + +[portal.policies.config.security] +summary = "Detects and redacts PII, strips active content (JavaScript), and watermarks documents." + +[portal.policies.config.security.rules] +0 = "Redact PII" +1 = "Remove JavaScript" +2 = "Watermark" + [portal.policies.detail] enforces = "Enforces" onEveryExport = "On every export" @@ -7378,16 +7504,31 @@ showMore = "Show more" sources = "Sources" [portal.policies.detail.actions] +clearHistory = "Clear processed history" delete = "Delete" editSettings = "Edit settings" pause = "Pause" resume = "Resume" runNow = "Run now" +[portal.policies.detail.clearHistory] +body = "This policy will forget every file it has already processed and reprocess everything currently in its sources on the next run. The files themselves are not changed. This cannot be undone." +cancel = "Cancel" +confirm = "Clear history" +title = "Clear processed history?" + [portal.policies.detail.emptyActivity] description = "Documents will appear here once this policy runs." title = "No activity yet" +[portal.policies.endpoints] +addWatermark = "Watermark" +autoRedact = "Redact PII" +compressPdf = "Compress" +flatten = "Flatten" +ocrPdf = "OCR" +sanitizePdf = "Remove JavaScript" + [portal.policies.offline] description = "Your policies are saved and will appear once the connection is restored." retry = "Retry" @@ -7512,9 +7653,6 @@ soon = "Soon" [portal.processingStatus] managePlan = "Manage plan" -pdfsThisMonth = "PDFs this month" -progressLabel = "{{used}} of {{cap}} PDFs used this month" -upgrade = "Upgrade" volumeSuffix = "PDFs processed Β· last 30 days" [portal.procurement] @@ -7638,6 +7776,31 @@ subtitle = "Your solutions engineer is on every step. One next action at a time; title = "From trial to live, one guided path" trialTitle = "Enterprise trial" +[portal.procurement.journeySteps.agreement] +blurb = "One signature covers MSA, order form, EULA and DPA." +gatingAction = "Review and sign your agreement" +label = "Agreement" + +[portal.procurement.journeySteps.implementation] +blurb = "Provision your workspace and run the go-live playbook." +gatingAction = "Provisioning your workspace" +label = "Implementation" + +[portal.procurement.journeySteps.payment] +blurb = "Pay by card, bank transfer, or against a purchase order." +gatingAction = "Confirm payment" +label = "Payment" + +[portal.procurement.journeySteps.quote] +blurb = "Review committed-volume pricing and contract term." +gatingAction = "Accept your quote" +label = "Quote" + +[portal.procurement.journeySteps.trial] +blurb = "Evaluate Stirling against your documents and workflows." +gatingAction = "Build your quote" +label = "Trial" + [portal.procurement.license] copied = "Copied" copy = "Copy key" @@ -7883,8 +8046,8 @@ appEditor = "Editor" appProcessor = "Processor" brandSuffix = "Stirling Processor" docsCount = "{{docs}} docs" -docsProcessed = "Docs processed" linkAccount = "Link Stirling account" +planEditor = "Editor plan" planEnterprise = "Enterprise plan" planProcessor = "Processor plan" primaryNav = "Primary navigation" @@ -7950,6 +8113,14 @@ helperText = "Absolute path Stirling watches for files to process." label = "Directory path" placeholder = "/data/incoming" +[portal.sources.types.folder.fields.identity] +helperText = "Content check reads each changed file, so renames and touches that don't alter content are not reprocessed." +label = "Change detection" + +[portal.sources.types.folder.fields.identity.options] +hash = "Size, date and content check" +stat = "Size and date modified" + [portal.sources.types.folder.fields.mode] label = "Read mode" @@ -7957,6 +8128,13 @@ label = "Read mode" consume = "Consume: process each file once" snapshot = "Snapshot: re-read the folder every run" +[portal.sources.types.folder.fields.recursive] +label = "Folder depth" + +[portal.sources.types.folder.fields.recursive.options] +all = "Include subfolders" +top = "Top level only" + [portal.sources.types.unknown] label = "Source" @@ -7992,123 +8170,59 @@ action = "Sign in again" body = "Your Stirling account session has expired. Sign in again to view billing β€” your instance stays linked." title = "Session expired" -[portal.usageChart] -defaultLabel = "Docs processed Β· last 30 days" -delta = "{{pct}}% vs prior 30d" -docsValue = "{{value}} docs" -srAnnounce = "{{date}}: {{value}} docs" - -[portal.useCases] -title = "Popular use cases" -viewAll = "View all pipelines" - -[portal.useCases.items.authenticity] -blurb = "Cryptographic checks at the document boundary β€” signature validation, tamper detection, signing flows for outbound documents. Trust decisions in the pipeline, not your app code." -cta = "Try authenticity check" -eyebrow = "AUTHENTICITY" -title = "Verify signatures and detect tampering" - -[portal.useCases.items.autoRouting] -blurb = "One classifier reads what arrived β€” KYC form, invoice, contract, COI β€” and routes to the right downstream pipeline. No manual triage, no docs in the wrong workflow." -cta = "Build a classifier pipeline" -eyebrow = "AUTO-ROUTING" -title = "Auto-classify and route incoming documents" - -[portal.useCases.items.piiRedaction] -blurb = "Strip sensitive fields before storage, indexing, or LLM processing. Schema-aware, per-field audit, BYOK or HYOK keys. Compliance at the document boundary, not per pipeline." -cta = "See redaction pipelines" -eyebrow = "PII REDACTION" -title = "Redact PII before it leaves your stack" - -[portal.useCases.items.trainingData] -blurb = "Batch-import an archive, redact PII, classify, chunk, and emit ready-to-load JSON for fine-tuning, eval sets, or RAG. Self-completing and replayable." -cta = "Build a training-data pipeline" -eyebrow = "TRAINING DATA" -title = "Turn PDFs into training data" - [portal.users] -subtitle = "The people in your organization and the access they hold β€” roles, status and security controls." title = "Users" -[portal.users.access] -subtitle = "Seats, authentication and provisioning for your organization." -title = "Access & security" - -[portal.users.access.auth] -title = "Authentication" - -[portal.users.access.auth.requireMfa] -description = "Members must set up a second factor to sign in." -enforced = "Enforced org-wide on this plan." -label = "Require MFA" - -[portal.users.access.auth.shortSessions] -description = "Sign members out after inactivity (currently {{timeout}})." -label = "Short-lived sessions" - -[portal.users.access.scim] -active = "Active" -directory = "Directory" -lastSync = "Last sync" -note = "Members are created, updated and deactivated automatically from your identity provider." -off = "Off" -title = "SCIM provisioning" - -[portal.users.access.seats] -title = "Seats" -unlimited = "Your plan includes unlimited seats." -usedLabel = "{{used}} of {{limit}} seats used" - -[portal.users.access.sso] -connected = "Connected" -domains = "Domains" -manage = "Manage connection" -notConfigured = "Not configured" -provider = "Provider" -title = "SSO / SAML" - -[portal.users.access.upgrade] -action = "Upgrade plan" -title = "Unlock team access controls" - -[portal.users.empty] -description = "Invite your team to start collaborating on documents and pipelines." -title = "No members yet" - -[portal.users.invite] -cancel = "Cancel" -email = "Email" -emailError = "Enter a valid email address" -emailPlaceholder = "teammate@acme.com" -role = "Role" -roleHelper = "Determines what the member can do once they join." -send = "Send invite" -subtitle = "They'll receive an email to join your organization." - [portal.users.roles] subtitle = "Every role exists on every plan β€” what each one can do is fixed across the org." title = "Roles" -[portal.users.summary] -members = "Members" -pendingInvites = "Pending invites" -seatsUsed = "Seats used" +[portal.users.roles.admin] +label = "Admin (Org owner)" +summary = "Full governance over the workspace, settings and members." -[portal.users.table] -actionsFor = "Actions for {{name}}" -changeRole = "Change role" -lastActive = "Last active" -member = "Member" -remove = "Remove from org" -role = "Role" -status = "Status" -suspend = "Suspend" +[portal.users.roles.admin.permissions] +0 = "Manage users, teams and roles" +1 = "Manage all integrations incl. S3 connections" +2 = "Grant or revoke portal access" +3 = "Everything Team Owner can do" + +[portal.users.roles.guest] +label = "Guest" +summary = "Limited or web-only access; cannot hold personal configs." + +[portal.users.roles.guest.permissions] +0 = "Web-only / demo usage" +1 = "No API keys or integrations" +2 = "No portal access" +3 = "Read-only where shared" + +[portal.users.roles.member] +label = "Member" +summary = "Regular user β€” works with shared resources and their own configs." + +[portal.users.roles.member.permissions] +0 = "Use the editor and shared integrations" +1 = "Create personal API & MCP configs" +2 = "See team configs shared with them" +3 = "No S3 or workspace management" + +[portal.users.roles.team_owner] +label = "Team owner" +summary = "Owns a team β€” manages its members' resources and shared configs." + +[portal.users.roles.team_owner.permissions] +0 = "Create & manage the team's S3 connections" +1 = "Manage team-owned integration configs" +2 = "Portal access via the default policy" +3 = "Everything Member can do" [portal.welcome] ariaLabel = "Welcome to Stirling PDF" badge = "Open-source" installEditor = "Install the Editor" inviteTeammates = "Invite teammates" +openInBrowser = "Open in browser" perks = "Free forever Β· Self-hostable" subtitle = "The world's most secure PDF Editor is free for teams of all sizes. Includes 60+ PDF operations and SSO." title = "Welcome to" @@ -10017,6 +10131,146 @@ title = "Detailed Statistics" unknownEndpoint = "Unknown endpoint" visits = "Visits" +[users] +lastActive = "Last active" +learnMore = "Learn more about roles and access." +locked = "Locked" +roleFor = "Role for {{name}}" +rowActions = "Actions for {{name}}" +showAll = "Show all {{count}}" +showLess = "Show less" +subtitle2 = "Your people, teams, and access levels." +suspended = "Suspended" +teamActions = "Team actions" +title = "Users" +you = "(you)" + +[users.action] +deleteTeam = "Delete team" +disableMfa = "Reset MFA" +move = "Move to team" +reinstate = "Reinstate" +remove = "Remove from org" +rename = "Rename team" +resetPw = "Reset password" +suspend = "Suspend" +unlock = "Unlock account" + +[users.cap] +addProcessor = "+ Processor" +approver = "Approves policy" +editor = "Editor" +processor = "Processor" + +[users.confirm] +deleteTeamBody = "Delete the {{name}} team? The team must be empty first - move its members to another team, and it can't still own any integration configs." +deleteTeamTitle = "Delete team" +disableMfaBody = "Remove {{name}}'s MFA enrolment? They'll set it up again on next login if required." +disableMfaTitle = "Reset MFA" +removeBody = "Permanently remove {{name}} from the organization? This cannot be undone." +removeTitle = "Remove member" + +[users.empty] +description = "Invite your team to start collaborating." +title = "No members yet" + +[users.group] +addToTeam = "Add to team" +guestCount = "{{count}} guest" +guests = "Guests" +guestsDesc = "External collaborators, scoped to what you shared. Editor only." +ledBy = "led by {{owner}}" +org = "Organization" +orgDesc = "Owners with org-wide authority and policy approval" +owners = "{{count}} owner" +team = "{{name}} team" +teamMeta = "{{count}} people" + +[users.invite] +access = "Access" +action = "Invite people" +authOauth = "OAuth2 / SSO" +authSaml = "SAML 2.0" +authType = "Sign-in method" +authWeb = "Password" +create = "Create account" +createSubtitle = "Create a self-hosted account with a password or SSO." +createTitle = "Create account" +editorDesc = "Edit PDFs in the Stirling PDF Editor. Everyone gets this." +email = "Email address" +emailError = "Enter a valid email address" +emailPlaceholder2 = "name@company.com" +forceChange = "Require a password change on first login" +forceMfa = "Require MFA setup on first login" +method = "How to add them" +methodDirect = "Create account directly" +methodEmail = "Invite by email" +password = "Password" +passwordError = "Password must be at least 8 characters" +processorDeferred = "Invite sent, but Processor access couldn't be granted yet - set it from the roster once they've joined." +processorDesc = "The governance surface, run pipelines, agents, and the API." +role = "Role" +send2 = "Send invite" +subtitle2 = "They'll get an email to join your Stirling workspace." +team = "Team" +title = "Invite people" +username = "Username" +usernameError = "Username must be at least 3 characters" +usernamePlaceholder = "jsmith" + +[users.loadError] +description = "Something went wrong reaching the backend, or you don't have access. Try again." +title = "Couldn't load members" + +[users.moveTeam] +apply = "Move" +team = "Team" +title = "Move to team" + +[users.newTeam] +action = "+ New team" +create = "Create team" +emailError = "Enter a valid email address" +name = "Team name" +namePlaceholder = "e.g. Finance" +nameRequired = "Team name is required" +owner = "Team Owner" +ownerFailed = "Team created, but the owner couldn't be invited. Assign one from the roster." +ownerHelper = "Every team has a leader. They'll be invited as Team Owner, with the Processor on, and can add the rest. You can reassign later." +ownerPlaceholder = "owner@company.com" +subtitle = "Group people under a Team Owner who manages their access." +title = "New team" + +[users.renameTeam] +apply = "Rename" +name = "Team name" +title = "Rename team" + +[users.resetPw] +apply = "Reset password" +confirm = "Confirm password" +copied = "Copied to clipboard" +copyHint = "Copy this now - it won't be shown again." +email = "Email the user about the reset" +forceChange = "Require a password change on next login" +generate = "Generate a secure password" +includePw = "Include the new password in the email" +mismatch = "Passwords do not match" +newPassword = "New password" +regen = "Regenerate" +title = "Reset password" +tooShort = "Password must be at least 8 characters" + +[users.role] +guest = "Guest" +member = "Member" +orgOwner = "Org Owner" +teamOwner = "Team Owner" + +[users.team] +grantProcessor = "Grant Processor to team" +revokeProcessor = "Revoke Processor from team" + [validateSignature] date = "Date" downloadCsv = "Download CSV" diff --git a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx index fe834b43d7..e183e45d6e 100644 --- a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -295,6 +295,9 @@ const FileEditorThumbnail = ({ const [showVersionHistory, setShowVersionHistory] = useState(false); const policyEnforcing = policies.some((p) => p.enforcing); + // Accent of the policy currently enforcing, so the overlay's icon/spinner match + // that policy's badge instead of a fixed blue. + const enforcingAccent = policies.find((p) => p.enforcing)?.accentColor; const hoverActions = useMemo(() => { const uploadLabel = isUploaded @@ -536,7 +539,11 @@ const FileEditorThumbnail = ({ )} {/* Policy enforcement overlay β€” shown while any policy is in-flight */} - + {/* Thumbnail image or loading state */} (null); + const [classificationOpen, setClassificationOpen] = useState(false); + const classificationEnabled = useClassificationEnabled(); + const labelName = useLabelName(); // Version chain for the selected file; empty for v1 or multi-select. const [versionChain, setVersionChain] = useState([]); const singleFileForChain = files.length === 1 ? files[0] : null; @@ -103,6 +113,29 @@ export function FileDetailsPanel({ }; }, [singleFileForChain]); + // Show the file's classification labels: the stub's cached copy when present + // (free β€” no byte load), else read the PDF metadata via the shared service. + // Gated on classification being enabled (SaaS + AI): off-feature we never read + // the metadata or show the section, so a PDF that happens to carry the + // StirlingPDFClassification key never reveals the feature in a build without it. + useEffect(() => { + setClassification(null); + if (!classificationEnabled) return; + const stub = singleFileForChain; + if (!stub) return; + if (stub.classificationLabels && stub.classificationLabels.length > 0) { + setClassification(stub.classificationLabels); + return; + } + let cancelled = false; + void readStubClassificationLabels(stub).then((labels) => { + if (!cancelled && labels) setClassification(labels); + }); + return () => { + cancelled = true; + }; + }, [singleFileForChain, classificationEnabled]); + if (files.length === 0) { return null; } @@ -241,17 +274,56 @@ export function FileDetailsPanel({ /> )} - {single.toolHistory && single.toolHistory.length > 0 && ( -

-
- {t("filesPage.field.toolHistory", "Tool history")} -
- -
+ {classification && ( + <> + + {classificationOpen && ( +
+
+ + {t("filesPage.field.labels", "Labels")} + + + {classification.map((label) => ( + + {labelName(label)} + + ))} + +
+
+ )} + )} {/* Version journey. Each tool run writes a new StirlingFile with the same `originalFileId` and an incremented @@ -275,12 +347,41 @@ export function FileDetailsPanel({ )} ) : ( - + <> + + {versionsOpen && ( + + )} + ))} ) : ( diff --git a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx index 0b6a75878b..47495fe61b 100644 --- a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx +++ b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx @@ -57,6 +57,7 @@ export interface VersionTimelineProps { currentId: FileId; onAddToWorkspace: (fileIds: FileId[]) => void; onRemove: (fileIds: FileId[]) => void; + hideHeader?: boolean; } /** Version timeline with per-row tool deltas and collapse-when-long. */ @@ -65,6 +66,7 @@ export function VersionTimeline({ currentId, onAddToWorkspace, onRemove, + hideHeader = false, }: VersionTimelineProps) { const { t } = useTranslation(); const [expandedIds, setExpandedIds] = useState>(new Set()); @@ -122,15 +124,17 @@ export function VersionTimeline({ return (
-
- - {t("filesPage.field.versionHistory", "Version journey")} - - {t("filesPage.versionsCount", "{{count}} versions", { - count: ordered.length, - })} - -
+ {!hideHeader && ( +
+ + {t("filesPage.field.versionHistory", "Version journey")} + + {t("filesPage.versionsCount", "{{count}} versions", { + count: ordered.length, + })} + +
+ )}
    {rows.map((row, idx) => { const isLast = idx === rows.length - 1; diff --git a/frontend/editor/src/core/components/shared/FileDocIcon.tsx b/frontend/editor/src/core/components/shared/FileDocIcon.tsx index 135162c54d..aafaf9aabd 100644 --- a/frontend/editor/src/core/components/shared/FileDocIcon.tsx +++ b/frontend/editor/src/core/components/shared/FileDocIcon.tsx @@ -1,3 +1,5 @@ +import { accentColor } from "@app/utils/accentColors"; + export type FileDocVariant = | "pdf" | "spreadsheet" @@ -7,14 +9,15 @@ export type FileDocVariant = | "code" | "generic"; +// Theme-adaptive accent per file type (shared palette; see utils/accentColors.ts). export const VARIANT_COLORS: Record = { - pdf: "#DC2626", - spreadsheet: "#16a34a", - doc: "#2563eb", - image: "#7c3aed", - archive: "#ea580c", - code: "#0891b2", - generic: "#71717a", + pdf: accentColor("red"), + spreadsheet: accentColor("green"), + doc: accentColor("blue"), + image: accentColor("violet"), + archive: accentColor("orange"), + code: accentColor("cyan"), + generic: accentColor("gray"), }; export function FileDocIcon({ @@ -44,7 +47,7 @@ export function FileDocIcon({ const row1Y = 11, row2Y = 14; interior = ( - + @@ -56,7 +59,7 @@ export function FileDocIcon({ <>