Compare commits

..
Author SHA1 Message Date
posthog-eu[bot]andGitHub ca52e0b352 Surface recent-files load failures with a retry instead of a silent empty state
The file-add modal's "Recent" list treated a failed or stalled load
identically to an empty history: loadRecentFiles swallowed errors into an
empty array (console.error/warn only) and FileListArea rendered just
"Loading files..." or "No recent files", with no error message, no retry,
and no telemetry.

- Track a loadError flag in useFileManager and thread it through
  FileManagerContext to FileListArea, which now renders four distinct
  states: loading / loaded / error (with a "Try again" retry) / empty.
- Capture the previously-swallowed errors as PostHog $exception events via
  a new analytics.captureException helper (top-level, server-files, and
  share-links failures) so the failure stops being invisible.
- Add a 30s timeout to the storage metadata GETs so a stalled connection
  surfaces as an error/retry instead of an indefinite spinner.
- Add FileListArea state-machine tests.

Generated-By: PostHog Code
Task-Id: 63a8e289-0cd7-4a4c-9914-43504209029d
2026-07-15 12:02:05 +00:00
EthanHealy01andGitHub 7f01bcdc44 Classifier setup as a processor policy (#7012)
## Overview

Adds a **Classification policy** to the processor's policy catalogue,
set up the same way as the Security policy. This moves classifier
configuration out of the editor (where the labels UI landed in #6898 and
was then removed with the rest of the editor's policy-management surface
in #6932) and into the processor, which is now the single place policies
are configured.

## What it does

- **Classification card** in the processor policy catalogue. Always
shown, but **setup is locked until the backend reports the AI engine is
on** — so admins can see the capability they're missing rather than it
being hidden entirely.
- **Setup wizard** mirrors Security: the workflow step shows the team's
**classification label editor** (reused
`LabelsEditor`/`LabelsEditorModal` — add box, chip grid, per-label icon
picker, import/export, reset) instead of tool toggles, since classify is
a single non-configurable step.
- On enable, the team's label vocabulary is **seeded with the 268
built-in defaults** (clobber-safe: only when the team has none). On
upload the document is classified against the team's labels and tagged;
on SaaS with the engine on, files group by category in the editor
sidebar.

## Reuse & consolidation

- Reuses the existing labels table, `labelsFile` helpers, and default
vocabulary. Labels read/write through the processor's own
`apiClient.local` (not the editor's axios client) so auth/base routing
stays explicit; the wire shape is shared.
- Consolidates policy-category icons into a shared, **id-keyed**
`policyCategoryIcon` util (outline glyphs) used by both the editor and
the processor, replacing the processor's emoji-glyph map (and the stray
`schedule` key that rendered a bare dot).

## Testing

- `task frontend:typecheck:{core,proprietary,portal}`,
`frontend:lint:eslint`, `frontend:test` (156 files / 1305 tests) — all
green.
- Verified in Storybook: the Classification card renders, the setup
wizard shows the label editor (268 defaults), and the full labels editor
opens with icons/import/export/reset. Added an MSW handler for the
app-config + labels endpoints and a `Classification` wizard story.

## Notes for reviewers

- The AI-engine gate reads the public `/api/v1/config/app-config`;
classification labels use `/api/v1/classification/labels` (team-scoped,
team-lead/admin-gated, `policies.enabled`); the classify step hits
`/api/v1/ai/tools/classify-and-label` — all pre-existing backend from
#6898.
- Known parity behavior (matches the editor hook): a transient failure
loading team labels falls back to showing the defaults; not changed here
to avoid diverging the two hooks.
2026-07-15 11:04:30 +00:00
EthanHealy01andGitHub 0570c4c4d9 Create-PDF engine: render from a structured document (#7018) 2026-07-14 12:30:33 +00:00
137 changed files with 3219 additions and 5554 deletions
+12
View File
@@ -208,6 +208,18 @@
"moduleName": ".*",
"moduleLicense": "The W3C License"
},
{
"moduleName": "com.google.re2j:re2j",
"moduleLicense": "Go License"
},
{
"moduleName": "com.hubspot:algebra",
"moduleLicense": null
},
{
"moduleName": "com.hubspot.immutables:immutables-exceptions",
"moduleLicense": null
},
{
"moduleName": ".*",
"moduleLicense": "UnRar License"
+14
View File
@@ -66,6 +66,20 @@ dependencies {
implementation "com.google.code.gson:gson:${gsonVersion}"
// jinjava/jjwt transitively request older Jackson 2 versions; declare the current
// version directly so it is selected consistently (root build.gradle pins are the fallback).
runtimeOnly "com.fasterxml.jackson.core:jackson-core:${jackson2Version}"
runtimeOnly "com.fasterxml.jackson.core:jackson-databind:${jackson2Version}"
implementation("com.hubspot.jinjava:jinjava:${jinjavaVersion}") {
// Compile-time-only annotation artifacts (class-retention annotations, not needed at
// runtime) whose declared licences (LGPL / none) fail the licence compatibility check.
exclude group: 'com.google.code.findbugs', module: 'annotations'
exclude group: 'org.derive4j', module: 'derive4j-annotation'
exclude group: 'com.hubspot.immutables', module: 'hubspot-style'
exclude group: 'com.hubspot.immutables', module: 'immutable-collection-encodings'
}
api 'io.micrometer:micrometer-registry-prometheus'
api "io.jsonwebtoken:jjwt-api:${jwtVersion}"
@@ -0,0 +1,61 @@
package stirling.software.proprietary.classification;
import java.io.InputStream;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.classification.model.ClassificationLabel;
import stirling.software.proprietary.classification.model.ClassificationLabels;
import tools.jackson.databind.ObjectMapper;
/**
* Supplies the classification vocabulary the classify tool sends to the AI engine. The set is a
* fixed, built-in list bundled with the application ({@code
* classification/classification-labels.json}) and shared by everyone — there is no per-team
* customization or database. Loaded once at startup.
*/
@Slf4j
@Component
public class ClassificationLabelProvider {
private static final String RESOURCE = "classification/classification-labels.json";
private final List<ClassificationLabel> labels;
// Explicit @Autowired: the class has a second (private) constructor for tests, so Spring
// can't infer which to use without it.
@Autowired
public ClassificationLabelProvider(ObjectMapper objectMapper) {
this(load(objectMapper));
}
private ClassificationLabelProvider(List<ClassificationLabel> labels) {
this.labels = List.copyOf(labels);
}
/** Build a provider with an explicit label set (tests). */
public static ClassificationLabelProvider withLabels(List<ClassificationLabel> labels) {
return new ClassificationLabelProvider(labels);
}
/** The built-in vocabulary, in file order. */
public List<ClassificationLabel> labels() {
return labels;
}
private static List<ClassificationLabel> load(ObjectMapper objectMapper) {
try (InputStream in = new ClassPathResource(RESOURCE).getInputStream()) {
ClassificationLabels parsed = objectMapper.readValue(in, ClassificationLabels.class);
return parsed.labels();
} catch (Exception e) {
log.error("Failed to load classification labels from {}", RESOURCE, e);
return List.of();
}
}
}
@@ -1,136 +0,0 @@
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<ClassificationLabels> 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<ClassificationLabels> 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<Void> 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();
}
}
@@ -3,10 +3,10 @@ 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.
* A flat multi-label classification vocabulary — the set of labels a document may be assigned. The
* classifier runs against these label names. The vocabulary is a fixed, built-in set shared by
* everyone (see {@link stirling.software.proprietary.classification.ClassificationLabelProvider});
* this record is the JSON parse target for that bundled resource.
*/
public record ClassificationLabels(List<ClassificationLabel> labels) {
@@ -1,71 +0,0 @@
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<String> ids = new HashSet<>();
Set<String> 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)");
}
}
}
@@ -1,22 +0,0 @@
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<ClassificationLabels> 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);
}
@@ -1,36 +0,0 @@
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<Long, ClassificationLabels> byTeam = new ConcurrentHashMap<>();
@Override
public Optional<ClassificationLabels> 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;
}
}
@@ -1,76 +0,0 @@
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<ClassificationLabels> 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<ClassificationLabels> 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;
}
}
@@ -1,47 +0,0 @@
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;
}
@@ -1,7 +0,0 @@
package stirling.software.proprietary.classification.store;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface TeamLabelsRepository extends JpaRepository<TeamLabelsEntity, Long> {}
@@ -54,21 +54,10 @@ public class CustomAuditEventRepository implements AuditEventRepository {
return;
}
String rid = MDC.get("requestId");
String apiKeyLabel =
MDC.get(
stirling.software.proprietary.security.service
.ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY);
if (rid != null || apiKeyLabel != null) {
if (rid != null) {
clean = new java.util.HashMap<>(clean);
if (rid != null) {
clean.put("requestId", rid);
}
// Named key that made the request; surfaces as the doc source in the processor
// feed.
if (apiKeyLabel != null) {
clean.put("__apiKeyLabel", apiKeyLabel);
}
clean.put("requestId", rid);
}
String source = MDC.get("auditSource");
@@ -31,10 +31,9 @@ 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.ClassificationLabelProvider;
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;
@@ -46,7 +45,7 @@ import tools.jackson.databind.node.ObjectNode;
* Dispatchable tool that classifies a PDF and writes the result into its metadata.
*
* <p>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
* engine to classify the document against the built-in 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.
@@ -72,13 +71,9 @@ public class ClassifyLabelController {
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.
* The fixed, built-in vocabulary shared by everyone — see {@link ClassificationLabelProvider}.
*/
private final ClassificationLabelStore labelStore;
private final PolicyManagementAuthority policyManagementAuthority;
private final ClassificationLabelProvider labelProvider;
public ClassifyLabelController(
CustomPDFDocumentFactory pdfDocumentFactory,
@@ -87,18 +82,16 @@ public class ClassifyLabelController {
PdfMetadataService pdfMetadataService,
AiEngineClient aiEngineClient,
ObjectMapper objectMapper,
@Autowired(required = false) UserServiceInterface userService,
@Autowired(required = false) ClassificationLabelStore labelStore,
@Autowired(required = false) PolicyManagementAuthority policyManagementAuthority) {
ClassificationLabelProvider labelProvider,
@Autowired(required = false) UserServiceInterface userService) {
this.pdfDocumentFactory = pdfDocumentFactory;
this.tempFileManager = tempFileManager;
this.pdfContentExtractor = pdfContentExtractor;
this.pdfMetadataService = pdfMetadataService;
this.aiEngineClient = aiEngineClient;
this.objectMapper = objectMapper;
this.labelProvider = labelProvider;
this.userService = userService;
this.labelStore = labelStore;
this.policyManagementAuthority = policyManagementAuthority;
}
@PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -116,9 +109,9 @@ public class ClassifyLabelController {
List<EngineLabel> 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);
// No vocabulary to classify against: pass the file through unlabelled rather than
// ask the engine to classify against nothing.
log.debug("[classify-and-label] {} has no labels; skipping", fileName);
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
}
@@ -175,24 +168,13 @@ public class ClassifyLabelController {
}
/**
* 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.
* The built-in vocabulary 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). The engine
* holds no default vocabulary of its own, so this bundled set is the only source.
*/
private List<EngineLabel> resolveAllowedLabels() {
if (labelStore == null) {
return List.of();
}
Long teamId =
policyManagementAuthority == null
? null
: policyManagementAuthority.currentUserTeamId();
Map<String, EngineLabel> byId = new LinkedHashMap<>();
labelStore.findByTeam(teamId).ifPresent(labels -> collectLabels(labels.labels(), byId));
collectLabels(labelProvider.labels(), byId);
return List.copyOf(byId.values());
}
@@ -8,12 +8,14 @@ import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
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.server.ResponseStatusException;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Hidden;
@@ -24,18 +26,24 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.model.api.ai.create.AiDocument;
import stirling.software.proprietary.service.AiDocumentHtmlRenderer;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/**
* Dispatchable tool that converts an AI-generated HTML string to a PDF via WeasyPrint.
* Dispatchable tool that converts an AI-generated document model to a PDF via WeasyPrint.
*
* <p>Called by {@link stirling.software.proprietary.service.AiWorkflowService} when the engine
* emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The HTML comes from a trusted Jinja
* template so sanitization is intentionally skipped.
* emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The engine supplies the document as
* structured fields; the HTML is built here from a fixed template.
*/
@Slf4j
@Hidden
@@ -48,6 +56,9 @@ public class CreatePdfAgentController {
private final TempFileManager tempFileManager;
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final RuntimePathConfig runtimePathConfig;
private final ApplicationProperties applicationProperties;
private final ObjectMapper objectMapper;
private final AiDocumentHtmlRenderer htmlRenderer;
/**
* Returns true only when WeasyPrint is definitively unavailable — either the binary could not
@@ -74,32 +85,42 @@ public class CreatePdfAgentController {
value = "/create-pdf-from-html-agent",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Convert AI-generated HTML to a PDF",
summary = "Convert an AI-generated document to a PDF",
description =
"Accepts an HTML document as a plain-text parameter and returns a PDF."
+ " This endpoint is dispatched by the AI workflow orchestrator as a"
+ " plan step; it is not intended for direct client use.")
public ResponseEntity<Resource> createPdfFromHtml(
@RequestParam("htmlContent") String htmlContent,
@RequestParam("filename") String filename)
"Accepts a structured document as a JSON parameter and returns a PDF. This"
+ " endpoint is dispatched by the AI workflow orchestrator as a plan"
+ " step; it is not intended for direct client use.")
public ResponseEntity<Resource> createPdf(
@RequestParam("document") String document, @RequestParam("filename") String filename)
throws Exception {
if (!applicationProperties.getAiEngine().isEnabled()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
AiDocument model;
try {
model = objectMapper.readValue(document, AiDocument.class);
} catch (JacksonException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
String html = htmlRenderer.render(model);
log.info(
"[create-pdf-agent] converting HTML to PDF via WeasyPrint — html_bytes={}",
htmlContent.length());
"[create-pdf-agent] converting document to PDF via WeasyPrint — html_bytes={}",
html.length());
try (TempFile htmlFile = tempFileManager.createManagedTempFile(".html");
TempFile pdfFile = tempFileManager.createManagedTempFile(".pdf")) {
Files.writeString(htmlFile.getPath(), htmlContent, StandardCharsets.UTF_8);
Files.writeString(htmlFile.getPath(), html, StandardCharsets.UTF_8);
List<String> command = new ArrayList<>();
command.add(runtimePathConfig.getWeasyPrintPath());
command.add("-e");
command.add("utf-8");
command.add("-v");
// SSRF: the HTML is self-contained and the engine validates style colours, so no
// external url() reaches WeasyPrint. For full isolation, run it network-isolated.
command.add(htmlFile.getAbsolutePath());
command.add(pdfFile.getAbsolutePath());
@@ -126,8 +147,8 @@ public class CreatePdfAgentController {
// avoids materialising the whole document as a byte[] twice (read-all + re-serialise),
// which matters for large generated documents.
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
try (PDDocument document = pdfDocumentFactory.load(pdfFile.getPath())) {
document.save(tempOut.getPath().toFile());
try (PDDocument pdDocument = pdfDocumentFactory.load(pdfFile.getPath())) {
pdDocument.save(tempOut.getPath().toFile());
} catch (Exception e) {
tempOut.close();
throw e;
@@ -1,54 +0,0 @@
package stirling.software.proprietary.controller.api;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
import stirling.software.proprietary.model.api.apikey.CreateApiKeyRequest;
import stirling.software.proprietary.model.api.apikey.CreatedApiKeyDto;
import stirling.software.proprietary.model.api.apikey.PortalApiKeysResponse;
import stirling.software.proprietary.security.service.ApiKeyManagementService;
/**
* Real backing for the portal Infrastructure → API Keys tab: list/create/revoke named, personal API
* keys. Replaces the former portal-only mock endpoint. Not gated behind an Enterprise license - API
* keys are a core auth feature available on every self-hosted instance.
*/
@ProprietaryUiDataApi
@RequiredArgsConstructor
public class PortalApiKeysController {
private final ApiKeyManagementService apiKeyManagementService;
// tier accepted for endpoint symmetry with the other infra tabs; ignored here.
@GetMapping("/infrastructure/api-keys")
@Operation(summary = "List API keys", description = "The caller's personal API keys.")
public ResponseEntity<PortalApiKeysResponse> list(
@RequestParam(value = "tier", required = false) String tier) {
return ResponseEntity.ok(apiKeyManagementService.listVisibleKeys());
}
@PostMapping("/infrastructure/api-keys")
@Operation(
summary = "Create an API key",
description = "Mints a personal key and returns its one-time secret.")
public ResponseEntity<CreatedApiKeyDto> create(@RequestBody CreateApiKeyRequest request) {
return ResponseEntity.ok(apiKeyManagementService.createKey(request));
}
@DeleteMapping("/infrastructure/api-keys/{id}")
@Operation(summary = "Revoke an API key", description = "Disables a key the caller owns.")
public ResponseEntity<Void> revoke(@PathVariable("id") Long id) {
apiKeyManagementService.revokeKey(id);
return ResponseEntity.noContent().build();
}
}
@@ -24,8 +24,8 @@ import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
/**
* API-key auth for the MCP endpoint: validates a Stirling API key and binds the request to that
* user with the MCP scopes.
* API-key auth for the MCP endpoint: validates a Stirling per-user API key and binds the request to
* that user with the MCP scopes.
*/
@Slf4j
public class McpApiKeyAuthFilter extends OncePerRequestFilter {
@@ -0,0 +1,35 @@
package stirling.software.proprietary.model.api.ai.create;
import java.util.List;
import lombok.Data;
@Data
public class AiDocument {
private String title;
private String subtitle;
private String referenceNumber;
private Style style;
private List<Section> sections;
@Data
public static class Style {
private String primaryColor;
private String backgroundColor;
private String bodyTextColor;
}
@Data
public static class Section {
private String type;
private String heading;
private String body;
private List<List<String>> pairs;
private List<String> columns;
private List<List<String>> rows;
private List<String> totalRow;
private List<String> items;
private List<String> signatories;
}
}
@@ -1,4 +0,0 @@
package stirling.software.proprietary.model.api.apikey;
/** Create-key request body from the portal: just a display name for the new personal key. */
public record CreateApiKeyRequest(String name) {}
@@ -1,7 +0,0 @@
package stirling.software.proprietary.model.api.apikey;
import lombok.Builder;
/** Returned once when a key is created: the row plus the plaintext secret, never persisted. */
@Builder
public record CreatedApiKeyDto(PortalApiKeyDto key, String secret) {}
@@ -1,21 +0,0 @@
package stirling.software.proprietary.model.api.apikey;
import lombok.Builder;
/**
* One API key as shown in the portal Infrastructure → API Keys tab. Never carries the secret; that
* is returned once from {@link CreatedApiKeyDto} at creation time.
*/
@Builder
public record PortalApiKeyDto(
String id,
String name,
String prefix,
String created,
String lastUsed,
/** "active" | "revoked". */
String status,
long usageToday,
long usageMonth,
/** Lifetime request count for the key. */
long usageTotal) {}
@@ -1,9 +0,0 @@
package stirling.software.proprietary.model.api.apikey;
import java.util.List;
import lombok.Builder;
/** Payload for the API Keys tab: the personal keys the caller owns. */
@Builder
public record PortalApiKeysResponse(List<PortalApiKeyDto> keys) {}
@@ -37,8 +37,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.policy.ledger",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.access.repository",
"stirling.software.proprietary.integration.repository",
"stirling.software.proprietary.classification.store"
"stirling.software.proprietary.integration.repository"
})
@EntityScan({
"stirling.software.proprietary.security.model",
@@ -50,8 +49,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.policy.ledger",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.access.model",
"stirling.software.proprietary.integration.model",
"stirling.software.proprietary.classification.store"
"stirling.software.proprietary.integration.model"
})
public class DatabaseConfig {
@@ -57,7 +57,6 @@ import stirling.software.proprietary.security.oauth2.TauriAuthorizationRequestRe
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationSuccessHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2ResponseAuthenticationConverter;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
import stirling.software.proprietary.security.service.CustomOAuth2UserService;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
@@ -485,14 +484,12 @@ public class SecurityConfiguration {
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter(
ApiKeyAuthenticationService apiKeyAuthenticationService) {
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(
jwtService,
userService,
userDetailsService,
jwtAuthenticationEntryPoint,
securityProperties,
apiKeyAuthenticationService);
securityProperties);
}
}
@@ -11,7 +11,6 @@ import java.sql.SQLException;
import java.util.Map;
import java.util.Optional;
import org.slf4j.MDC;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
@@ -34,9 +33,8 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.UserService;
@@ -50,15 +48,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final CustomUserDetailsService userDetailsService;
private final AuthenticationEntryPoint authenticationEntryPoint;
private final ApplicationProperties.Security securityProperties;
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// Start clean so a pooled thread can't inherit a prior request's key label. This filter
// runs before UserAuthenticationFilter, so in JWT mode it owns the API-key label lifecycle.
MDC.remove(ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY);
if (!jwtService.isJwtEnabled()) {
filterChain.doFilter(request, response);
return;
@@ -137,14 +131,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
if (apiKey != null && !apiKey.isBlank()) {
try {
// Resolve through the shared service so the multi-key table (then the legacy
// per-user key) is consulted and per-key usage is recorded; the key runs as its
// owner. It also yields a per-key label for the processor's document
// attribution.
Optional<ApiKeyAuthentication> resolved =
apiKeyAuthenticationService.authenticate(apiKey);
Optional<User> user = userService.getUserByApiKey(apiKey);
if (resolved.isEmpty()) {
if (user.isEmpty()) {
handleAuthenticationFailure(
request,
response,
@@ -154,13 +143,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
authentication =
new ApiKeyAuthenticationToken(
resolved.get().user(), apiKey, resolved.get().authorities());
user.get(), apiKey, user.get().getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
if (resolved.get().auditLabel() != null) {
MDC.put(
ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY,
resolved.get().auditLabel());
}
return true;
} catch (AuthenticationException e) {
handleAuthenticationFailure(
@@ -6,7 +6,6 @@ import java.io.IOException;
import java.util.List;
import java.util.Optional;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Profile;
@@ -34,8 +33,6 @@ import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@@ -44,24 +41,18 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@Profile("!saas")
public class UserAuthenticationFilter extends OncePerRequestFilter {
/** MDC key carrying the resolved key's label into audit events for the processor feed. */
public static final String API_KEY_LABEL_MDC = ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY;
private final ApplicationProperties.Security securityProp;
private final UserService userService;
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
private final SessionPersistentRegistry sessionPersistentRegistry;
private final boolean loginEnabledValue;
public UserAuthenticationFilter(
@Lazy ApplicationProperties.Security securityProp,
@Lazy UserService userService,
ApiKeyAuthenticationService apiKeyAuthenticationService,
SessionPersistentRegistry sessionPersistentRegistry,
@Qualifier("loginEnabled") boolean loginEnabledValue) {
this.securityProp = securityProp;
this.userService = userService;
this.apiKeyAuthenticationService = apiKeyAuthenticationService;
this.sessionPersistentRegistry = sessionPersistentRegistry;
this.loginEnabledValue = loginEnabledValue;
}
@@ -71,14 +62,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// Start each request clean so a pooled thread can't inherit a prior request's key label -
// but keep a label an upstream filter (JwtAuthenticationFilter) already set for a request
// it API-key-authenticated, otherwise per-key attribution is lost on the JWT path.
if (!(SecurityContextHolder.getContext().getAuthentication()
instanceof ApiKeyAuthenticationToken)) {
MDC.remove(API_KEY_LABEL_MDC);
}
if (!loginEnabledValue) {
// If login is not enabled, just pass all requests without authentication
filterChain.doFilter(request, response);
@@ -106,23 +89,18 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
String apiKey = request.getHeader("X-API-KEY");
if (apiKey != null && !apiKey.trim().isEmpty()) {
try {
// Resolves the multi-key table then the legacy key, records usage, and yields a
// per-key label for the processor's document-source attribution.
Optional<ApiKeyAuthentication> resolved =
apiKeyAuthenticationService.authenticate(apiKey);
if (resolved.isEmpty()) {
// Use API key to authenticate. This requires you to have an authentication
// provider for API keys.
Optional<User> user = userService.getUserByApiKey(apiKey);
if (user.isEmpty()) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write("Invalid API Key.");
return;
}
User user = resolved.get().user();
authentication =
new ApiKeyAuthenticationToken(
user, apiKey, resolved.get().authorities());
user.get(), apiKey, user.get().getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
if (resolved.get().auditLabel() != null) {
MDC.put(API_KEY_LABEL_MDC, resolved.get().auditLabel());
}
} catch (AuthenticationException e) {
// If API key authentication fails, deny the request
response.setStatus(HttpStatus.UNAUTHORIZED.value());
@@ -11,6 +11,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@@ -57,25 +58,23 @@ public class UserBasedRateLimitingFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
return;
}
// Bucket by the resolved user (the auth filter runs first and populates the context, even
// for X-API-KEY requests), so all of a user's API keys share ONE per-user quota - minting
// extra keys can't multiply the daily limit. Fall back to the raw key / IP only when the
// request is unauthenticated.
String identifier = null;
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null
&& authentication.isAuthenticated()
&& !"anonymousUser".equals(authentication.getName())) {
identifier = authentication.getName();
}
if (identifier == null) {
String apiKey = request.getHeader("X-API-KEY");
if (apiKey != null && !apiKey.trim().isEmpty()) {
identifier = "API_KEY_" + apiKey;
} else {
identifier = request.getRemoteAddr();
// Check for API key in the request headers
String apiKey = request.getHeader("X-API-KEY");
if (apiKey != null && !apiKey.trim().isEmpty()) {
identifier = // Prefix to distinguish between API keys and usernames
"API_KEY_" + apiKey;
} else {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
identifier = userDetails.getUsername();
}
}
// If neither API key nor an authenticated user is present, use IP address
if (identifier == null) {
identifier = request.getRemoteAddr();
}
Role userRole =
getRoleFromAuthentication(SecurityContextHolder.getContext().getAuthentication());
if (request.getHeader("X-API-KEY") != null) {
@@ -1,72 +0,0 @@
package stirling.software.proprietary.security.model;
import java.io.Serializable;
import java.time.Instant;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* A named, personal API key belonging to a user. The raw secret is shown once at creation and never
* stored; only its SHA-256 hash is persisted, so a leaked database row cannot be replayed. Distinct
* from the legacy single {@code users.apiKey} column, which stays a per-user key for backward
* compatibility and is lazily represented here.
*/
@Entity
@Table(
name = "api_keys",
indexes = {
@Index(name = "idx_api_key_hash", columnList = "key_hash", unique = true),
@Index(name = "idx_api_key_owner", columnList = "owner_user_id")
})
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ApiKey implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@Column(name = "name", nullable = false, length = 100)
private String name;
/** SHA-256 hex of the raw key; the raw value is never persisted. */
@Column(name = "key_hash", nullable = false, unique = true, length = 64)
private String keyHash;
/** Non-secret leading fragment of the raw key, shown in listings (e.g. {@code sk_a1b2c3d4}). */
@Column(name = "prefix", nullable = false, length = 32)
private String prefix;
/** The user who created and owns the key; the key authenticates as this user. */
@Column(name = "owner_user_id", nullable = false)
private Long ownerUserId;
@Column(name = "enabled", nullable = false)
private boolean enabled;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "last_used_at")
private Instant lastUsedAt;
@Column(name = "revoked_at")
private Instant revokedAt;
/** Active = enabled and not revoked; only active keys authenticate. */
public boolean isActive() {
return enabled && revokedAt == null;
}
}
@@ -5,7 +5,6 @@ import java.util.Collection;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
/** Authentication produced from an {@code X-API-KEY} header; runs as the key's owner. */
public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
private final Object principal;
@@ -1,45 +0,0 @@
package stirling.software.proprietary.security.model;
import java.io.Serializable;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* One UTC day's request tally for an API key. Rolling "today"/"this month" usage is summed from
* these rows, keeping the table at one row per key per active day rather than one per request.
*/
@Entity
@Table(name = "api_key_daily_usage")
@IdClass(ApiKeyDailyUsageId.class)
@Getter
@Setter
@NoArgsConstructor
public class ApiKeyDailyUsage implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@Column(name = "api_key_id")
private Long apiKeyId;
@Id
@Column(name = "epoch_day")
private long epochDay;
@Column(name = "count")
private long count;
public ApiKeyDailyUsage(Long apiKeyId, long epochDay, long count) {
this.apiKeyId = apiKeyId;
this.epochDay = epochDay;
this.count = count;
}
}
@@ -1,36 +0,0 @@
package stirling.software.proprietary.security.model;
import java.io.Serializable;
import java.util.Objects;
/** Composite key for {@link ApiKeyDailyUsage}: one row per key per UTC day. */
public class ApiKeyDailyUsageId implements Serializable {
private static final long serialVersionUID = 1L;
private Long apiKeyId;
private long epochDay;
public ApiKeyDailyUsageId() {}
public ApiKeyDailyUsageId(Long apiKeyId, long epochDay) {
this.apiKeyId = apiKeyId;
this.epochDay = epochDay;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof ApiKeyDailyUsageId other)) {
return false;
}
return epochDay == other.epochDay && Objects.equals(apiKeyId, other.apiKeyId);
}
@Override
public int hashCode() {
return Objects.hash(apiKeyId, epochDay);
}
}
@@ -1,55 +0,0 @@
package stirling.software.proprietary.security.repository;
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 stirling.software.proprietary.security.model.ApiKeyDailyUsage;
import stirling.software.proprietary.security.model.ApiKeyDailyUsageId;
@Repository
public interface ApiKeyDailyUsageRepository
extends JpaRepository<ApiKeyDailyUsage, ApiKeyDailyUsageId> {
/** Atomically bump today's tally; returns 0 when no row exists yet (caller then inserts). */
@Modifying
@Query(
"UPDATE ApiKeyDailyUsage u SET u.count = u.count + 1 "
+ "WHERE u.apiKeyId = :apiKeyId AND u.epochDay = :epochDay")
int incrementIfPresent(@Param("apiKeyId") Long apiKeyId, @Param("epochDay") long epochDay);
@Query(
"SELECT COALESCE(SUM(u.count), 0) FROM ApiKeyDailyUsage u "
+ "WHERE u.apiKeyId = :apiKeyId AND u.epochDay >= :fromDayInclusive")
long sumSince(
@Param("apiKeyId") Long apiKeyId, @Param("fromDayInclusive") long fromDayInclusive);
@Query(
"SELECT u.count FROM ApiKeyDailyUsage u "
+ "WHERE u.apiKeyId = :apiKeyId AND u.epochDay = :epochDay")
Long countForDay(@Param("apiKeyId") Long apiKeyId, @Param("epochDay") long epochDay);
/** Batched today-count for many keys in one query (avoids N+1 when listing keys). */
@Query(
"SELECT u.apiKeyId AS apiKeyId, u.count AS total FROM ApiKeyDailyUsage u "
+ "WHERE u.apiKeyId IN :ids AND u.epochDay = :epochDay")
List<ApiKeyUsageSum> countForDayByIds(
@Param("ids") Collection<Long> ids, @Param("epochDay") long epochDay);
/** Batched trailing-window sum for many keys in one query. */
@Query(
"SELECT u.apiKeyId AS apiKeyId, SUM(u.count) AS total FROM ApiKeyDailyUsage u "
+ "WHERE u.apiKeyId IN :ids AND u.epochDay >= :fromDayInclusive "
+ "GROUP BY u.apiKeyId")
List<ApiKeyUsageSum> sumSinceByIds(
@Param("ids") Collection<Long> ids, @Param("fromDayInclusive") long fromDayInclusive);
void deleteByApiKeyId(Long apiKeyId);
List<ApiKeyDailyUsage> findByApiKeyId(Long apiKeyId);
}
@@ -1,19 +0,0 @@
package stirling.software.proprietary.security.repository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import stirling.software.proprietary.security.model.ApiKey;
@Repository
public interface ApiKeyRepository extends JpaRepository<ApiKey, Long> {
Optional<ApiKey> findByKeyHash(String keyHash);
boolean existsByKeyHash(String keyHash);
List<ApiKey> findByOwnerUserIdOrderByCreatedAtDesc(Long ownerUserId);
}
@@ -1,8 +0,0 @@
package stirling.software.proprietary.security.repository;
/** Projection: a key id and a usage total, for batching per-key usage into one query. */
public interface ApiKeyUsageSum {
Long getApiKeyId();
Long getTotal();
}
@@ -1,107 +0,0 @@
package stirling.software.proprietary.security.service;
import java.time.Instant;
import java.util.Collection;
import java.util.Optional;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKey;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.ApiKeyRepository;
/**
* Resolves an incoming {@code X-API-KEY} to its owning user and records per-key usage. Depends only
* on repositories (never {@code UserService}) so {@code UserService} can delegate here without a
* bean cycle.
*
* <p>Resolution order: the multi-key {@code api_keys} table first (by hash), then the legacy
* per-user {@code users.apiKey} column. Legacy keys therefore keep working unchanged. Every key is
* personal and authenticates as its owner with the owner's authorities.
*/
@Service
@RequiredArgsConstructor
public class ApiKeyAuthenticationService {
/**
* MDC key that carries the resolved key's label into audit events so the processor's Documents
* feed can attribute a document to the specific key. Set by the auth filters (both flavors),
* read by {@code CustomAuditEventRepository}.
*/
public static final String AUDIT_LABEL_MDC_KEY = "apiKeyLabel";
private final ApiKeyRepository apiKeyRepository;
private final ApiKeyUsageRecorder usageRecorder;
private final UserRepository userRepository;
/** The user a raw key authenticates as, or empty if it matches no active key. */
public Optional<User> resolveUser(String rawKey) {
return authenticate(rawKey).map(ApiKeyAuthentication::user);
}
/**
* Resolve a raw key, recording usage as a side effect. Returns the owning user, a display label
* for the resolved key ({@code null} for the legacy per-user key), and the owner's authorities.
*/
public Optional<ApiKeyAuthentication> authenticate(String rawKey) {
if (rawKey == null || rawKey.isBlank()) {
return Optional.empty();
}
ApiKey key = apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(rawKey)).orElse(null);
if (key != null) {
if (!key.isActive()) {
return Optional.empty();
}
User owner = userRepository.findById(key.getOwnerUserId()).orElse(null);
if (owner == null || !owner.isEnabled()) {
return Optional.empty();
}
usageRecorder.record(key.getId());
return Optional.of(
new ApiKeyAuthentication(owner, auditLabel(key), owner.getAuthorities()));
}
// Legacy single per-user key: keep working, always a personal key for its user.
return userRepository
.findByApiKey(rawKey)
.filter(User::isEnabled)
.map(user -> new ApiKeyAuthentication(user, null, user.getAuthorities()));
}
/** "Production ingest (sk_a1b2c3d4)" - shown against API-sourced docs in the processor feed. */
private static String auditLabel(ApiKey key) {
return key.getName() + " (" + key.getPrefix() + ")";
}
/**
* Revoke the {@code api_keys} row that mirrors a given raw key, if any. Called when the legacy
* per-user key is rotated so the migrated shadow row can't keep authenticating the old secret.
*/
@Transactional
public void revokeMigratedKey(String rawKey) {
if (rawKey == null || rawKey.isBlank()) {
return;
}
apiKeyRepository
.findByKeyHash(ApiKeyHasher.hash(rawKey))
.filter(ApiKey::isActive)
.ifPresent(
k -> {
k.setEnabled(false);
k.setRevokedAt(Instant.now());
apiKeyRepository.save(k);
});
}
/**
* A resolved key: the user, an optional processor-feed label, and the authorities to run as.
*/
public record ApiKeyAuthentication(
User user, String auditLabel, Collection<? extends GrantedAuthority> authorities) {}
}
@@ -1,50 +0,0 @@
package stirling.software.proprietary.security.service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.HexFormat;
/** Generates opaque API-key secrets and hashes them for storage/lookup. */
public final class ApiKeyHasher {
/** Human-recognisable prefix so a leaked string is identifiable as a Stirling API key. */
public static final String KEY_PREFIX = "sk_";
/** Chars of the raw key kept for non-secret display (includes the {@code sk_} prefix). */
private static final int DISPLAY_PREFIX_LENGTH = 11;
private static final SecureRandom RANDOM = new SecureRandom();
private ApiKeyHasher() {}
/** A fresh opaque secret: {@code sk_} followed by 40 hex chars of cryptographic randomness. */
public static String generateRawKey() {
byte[] bytes = new byte[20];
RANDOM.nextBytes(bytes);
return KEY_PREFIX + HexFormat.of().formatHex(bytes);
}
/** SHA-256 hex of a raw key; the value stored and looked up, never the raw key. */
public static String hash(String rawKey) {
try {
byte[] digest =
MessageDigest.getInstance("SHA-256")
.digest(rawKey.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
/** Leading, non-secret fragment shown in listings (e.g. {@code sk_a1b2c3d4}). */
public static String displayPrefix(String rawKey) {
if (rawKey == null) {
return "";
}
return rawKey.length() <= DISPLAY_PREFIX_LENGTH
? rawKey
: rawKey.substring(0, DISPLAY_PREFIX_LENGTH);
}
}
@@ -1,31 +0,0 @@
package stirling.software.proprietary.security.service;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.model.ApiKey;
import stirling.software.proprietary.security.repository.ApiKeyRepository;
/**
* Inserts the shadow {@code api_keys} row that mirrors a user's legacy {@code users.apiKey} in its
* OWN ({@code REQUIRES_NEW}) transaction. Kept a separate bean so the write is isolated from the
* caller's listing transaction: when two concurrent first-loads race to insert the same hash, the
* loser's unique-key clash rolls back only this insert instead of poisoning the caller's
* transaction (on Postgres a failed statement aborts the whole transaction). The {@code
* DataIntegrityViolationException} is left to propagate so the caller can treat it as "already
* migrated".
*/
@Component
@RequiredArgsConstructor
class ApiKeyLegacyMigrator {
private final ApiKeyRepository apiKeyRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void insertMigratedKey(ApiKey key) {
apiKeyRepository.saveAndFlush(key);
}
}
@@ -1,235 +0,0 @@
package stirling.software.proprietary.security.service;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.api.apikey.CreateApiKeyRequest;
import stirling.software.proprietary.model.api.apikey.CreatedApiKeyDto;
import stirling.software.proprietary.model.api.apikey.PortalApiKeyDto;
import stirling.software.proprietary.model.api.apikey.PortalApiKeysResponse;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKey;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository;
import stirling.software.proprietary.security.repository.ApiKeyRepository;
/**
* Portal-facing CRUD for named, personal API keys: lists, creates, and revokes the caller's own
* keys. Every key belongs to exactly one user and authenticates as that user; there is no sharing.
*
* <p>Every pre-existing single {@code users.apiKey} is lazily represented as a key owned by that
* user, so historic keys list uniformly.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ApiKeyManagementService {
private static final DateTimeFormatter CREATED_FORMAT =
DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneOffset.UTC);
private static final DateTimeFormatter LAST_USED_FORMAT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneOffset.UTC);
private static final int MONTH_WINDOW_DAYS = 30;
/** Bounds a key name so it can't bloat storage or the audit/processor feed. */
private static final int MAX_NAME_LENGTH = 100;
/** Caps active keys per user so key creation can't be used to multiply rate-limit budget. */
private static final int MAX_ACTIVE_KEYS_PER_USER = 50;
private final ApiKeyRepository apiKeyRepository;
private final ApiKeyDailyUsageRepository usageRepository;
private final UserRepository userRepository;
private final UserService userService;
private final ApiKeyLegacyMigrator legacyMigrator;
/** All keys the caller owns. */
@Transactional
public PortalApiKeysResponse listVisibleKeys() {
User caller = requireCaller();
migrateLegacyKey(caller);
List<ApiKey> visible =
apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(caller.getId());
// Batch usage for all keys into three queries rather than two-per-key (avoids N+1).
long today = Instant.now().atZone(ZoneOffset.UTC).toLocalDate().toEpochDay();
List<Long> ids = visible.stream().map(ApiKey::getId).toList();
Map<Long, Long> todayById = new HashMap<>();
Map<Long, Long> monthById = new HashMap<>();
Map<Long, Long> totalById = new HashMap<>();
if (!ids.isEmpty()) {
usageRepository
.countForDayByIds(ids, today)
.forEach(r -> todayById.put(r.getApiKeyId(), r.getTotal()));
usageRepository
.sumSinceByIds(ids, today - (MONTH_WINDOW_DAYS - 1))
.forEach(r -> monthById.put(r.getApiKeyId(), r.getTotal()));
usageRepository
.sumSinceByIds(ids, Long.MIN_VALUE)
.forEach(r -> totalById.put(r.getApiKeyId(), r.getTotal()));
}
List<PortalApiKeyDto> keys =
visible.stream()
.map(
k ->
toDto(
k,
zeroIfNull(todayById.get(k.getId())),
zeroIfNull(monthById.get(k.getId())),
zeroIfNull(totalById.get(k.getId()))))
.toList();
return PortalApiKeysResponse.builder().keys(keys).build();
}
private static long zeroIfNull(Long value) {
return value == null ? 0L : value;
}
/** Create a personal key and return its one-time secret. */
@Transactional
public CreatedApiKeyDto createKey(CreateApiKeyRequest request) {
User caller = requireCaller();
String name = request == null ? null : request.name();
if (name == null || name.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Key name is required");
}
if (name.trim().length() > MAX_NAME_LENGTH) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Key name must be " + MAX_NAME_LENGTH + " characters or fewer");
}
long activeOwned =
apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(caller.getId()).stream()
.filter(ApiKey::isActive)
.count();
if (activeOwned >= MAX_ACTIVE_KEYS_PER_USER) {
throw new ResponseStatusException(
HttpStatus.TOO_MANY_REQUESTS,
"You have reached the maximum of "
+ MAX_ACTIVE_KEYS_PER_USER
+ " active API keys; revoke one before creating another");
}
String rawKey = ApiKeyHasher.generateRawKey();
ApiKey saved =
apiKeyRepository.save(
ApiKey.builder()
.name(name.trim())
.keyHash(ApiKeyHasher.hash(rawKey))
.prefix(ApiKeyHasher.displayPrefix(rawKey))
.ownerUserId(caller.getId())
.enabled(true)
.createdAt(Instant.now())
.build());
return CreatedApiKeyDto.builder().key(toDto(saved, 0L, 0L, 0L)).secret(rawKey).build();
}
/** Soft-revoke a key the caller owns; also clears the legacy column if it is that key. */
@Transactional
public void revokeKey(Long id) {
User caller = requireCaller();
ApiKey key =
apiKeyRepository
.findById(id)
.orElseThrow(
() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "No key"));
if (!key.getOwnerUserId().equals(caller.getId())) {
// Not-found rather than forbidden so a caller can't probe other users' key ids.
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No key");
}
key.setEnabled(false);
key.setRevokedAt(Instant.now());
apiKeyRepository.save(key);
clearLegacyColumnIfMatches(key);
}
/** Represent a user's pre-existing single key as a row so it lists uniformly. */
private void migrateLegacyKey(User user) {
String legacy = user.getApiKey();
if (legacy == null || legacy.isBlank()) {
return;
}
String hash = ApiKeyHasher.hash(legacy);
if (apiKeyRepository.existsByKeyHash(hash)) {
return;
}
try {
// Insert in its own transaction so a concurrent-insert clash can't poison this
// listing transaction (see ApiKeyLegacyMigrator).
legacyMigrator.insertMigratedKey(
ApiKey.builder()
.name("Default key")
.keyHash(hash)
.prefix(ApiKeyHasher.displayPrefix(legacy))
.ownerUserId(user.getId())
.enabled(true)
.createdAt(Instant.now())
.build());
} catch (DataIntegrityViolationException alreadyMigrated) {
// A concurrent first-load won the race and inserted the same hash; that's fine.
log.debug("Legacy key already migrated concurrently for user {}", user.getId());
}
}
/**
* If a revoked key is the owner's legacy {@code users.apiKey}, null it so it stops resolving.
*/
private void clearLegacyColumnIfMatches(ApiKey key) {
userRepository
.findById(key.getOwnerUserId())
.ifPresent(
owner -> {
String legacy = owner.getApiKey();
if (legacy != null
&& ApiKeyHasher.hash(legacy).equals(key.getKeyHash())) {
owner.setApiKey(null);
userRepository.save(owner);
}
});
}
private PortalApiKeyDto toDto(ApiKey key, long usageToday, long usageMonth, long usageTotal) {
return PortalApiKeyDto.builder()
.id(String.valueOf(key.getId()))
.name(key.getName())
.prefix(key.getPrefix())
.created(
key.getCreatedAt() == null ? "" : CREATED_FORMAT.format(key.getCreatedAt()))
.lastUsed(
key.getLastUsedAt() == null
? "Never"
: LAST_USED_FORMAT.format(key.getLastUsedAt()))
.status(key.isActive() ? "active" : "revoked")
.usageToday(usageToday)
.usageMonth(usageMonth)
.usageTotal(usageTotal)
.build();
}
private User requireCaller() {
String username = userService.getCurrentUsername();
if (username == null || username.isBlank() || "anonymousUser".equalsIgnoreCase(username)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
}
return userService
.findByUsernameIgnoreCase(username)
.orElseThrow(
() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unknown user"));
}
}
@@ -1,60 +0,0 @@
package stirling.software.proprietary.security.service;
import java.time.Instant;
import java.time.ZoneOffset;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Records per-key usage off the request thread. Kept a separate bean so the {@code @Async} proxy is
* honoured (a self-invocation from the resolver would run inline). Best-effort: never fails a
* request. The actual writes go through {@link ApiKeyUsageWriter} so each step commits in its own
* transaction and a first-write race can't drop a count.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ApiKeyUsageRecorder {
private final ApiKeyUsageWriter writer;
/** Bump today's tally for the key and stamp last-used. */
@Async("auditExecutor")
public void record(Long apiKeyId) {
if (apiKeyId == null) {
return;
}
try {
long epochDay = Instant.now().atZone(ZoneOffset.UTC).toLocalDate().toEpochDay();
// First writer of the day inserts the row; everyone else (and the loser of an insert
// race) increments. Separate transactions mean a unique-key clash never rolls back an
// already-counted request.
if (writer.increment(apiKeyId, epochDay) == 0
&& !firstUseInserted(apiKeyId, epochDay)) {
writer.increment(apiKeyId, epochDay);
}
writer.stampLastUsed(apiKeyId);
} catch (Exception e) {
log.debug("Failed to record API key usage for id={}", apiKeyId, e);
}
}
/**
* Whether we inserted the day's first row. A lost insert race can surface either as a {@code
* false} return or - when the failed flush marked the REQUIRES_NEW transaction rollback-only,
* so its commit throws - as an exception; both mean "someone else inserted", so we treat any
* failure as not-inserted and let the caller fall back to an increment rather than dropping the
* count.
*/
private boolean firstUseInserted(Long apiKeyId, long epochDay) {
try {
return writer.tryInsertFirstUse(apiKeyId, epochDay);
} catch (RuntimeException raced) {
return false;
}
}
}
@@ -1,59 +0,0 @@
package stirling.software.proprietary.security.service;
import java.time.Instant;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.model.ApiKeyDailyUsage;
import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository;
import stirling.software.proprietary.security.repository.ApiKeyRepository;
/**
* Per-step transactional writes for {@link ApiKeyUsageRecorder}. Each method runs in its own
* ({@code REQUIRES_NEW}) transaction so a unique-key clash when two requests race to insert the
* day's first row rolls back only that failed insert - never an already-counted request or the
* last-used stamp.
*/
@Component
@RequiredArgsConstructor
class ApiKeyUsageWriter {
private final ApiKeyRepository apiKeyRepository;
private final ApiKeyDailyUsageRepository usageRepository;
/** Bump today's tally if the row already exists; returns rows updated (0 if none yet). */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public int increment(Long apiKeyId, long epochDay) {
return usageRepository.incrementIfPresent(apiKeyId, epochDay);
}
/**
* Insert today's row with a count of 1. Flushes so a concurrent first-write's unique-key clash
* surfaces here (returning false) instead of at commit; the caller then increments instead.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean tryInsertFirstUse(Long apiKeyId, long epochDay) {
try {
usageRepository.saveAndFlush(new ApiKeyDailyUsage(apiKeyId, epochDay, 1));
return true;
} catch (DataIntegrityViolationException raced) {
return false;
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void stampLastUsed(Long apiKeyId) {
apiKeyRepository
.findById(apiKeyId)
.ifPresent(
key -> {
key.setLastUsedAt(Instant.now());
apiKeyRepository.save(key);
});
}
}
@@ -95,7 +95,6 @@ public class UserService implements UserServiceInterface {
private final ResourceGrantRepository resourceGrantRepository;
private final IntegrationConfigRepository integrationConfigRepository;
private final TeamMembershipService teamMembershipService;
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
@Transactional
public void processSSOPostLogin(
@@ -148,16 +147,15 @@ public class UserService implements UserServiceInterface {
}
public Authentication getAuthentication(String apiKey) {
// Resolve through the shared service (multi-key table, then the legacy per-user column).
// The key runs as its owner with the owner's authorities.
var resolved =
apiKeyAuthenticationService
.authenticate(apiKey)
.orElseThrow(() -> new UsernameNotFoundException("API key is not valid"));
return new UsernamePasswordAuthenticationToken(
resolved.user(), // principal
null, // credentials (we don't expose the password or API key here)
resolved.authorities()); // the owner's authorities
Optional<User> user = getUserByApiKey(apiKey);
if (user.isEmpty()) {
throw new UsernameNotFoundException("API key is not valid");
}
// Convert the user into an Authentication object
return new UsernamePasswordAuthenticationToken( // principal (typically the user)
user, // credentials (we don't expose the password or API key here)
null, // user's authorities (roles/permissions)
getAuthorities(user.get()));
}
private Collection<? extends GrantedAuthority> getAuthorities(User user) {
@@ -175,9 +173,6 @@ public class UserService implements UserServiceInterface {
public User addApiKeyToUser(String username) {
Optional<User> userOpt = findByUsernameIgnoreCase(username);
// Rotating/regenerating the legacy key must also revoke its migrated api_keys shadow row,
// otherwise the old secret keeps authenticating (it resolves from api_keys first).
userOpt.map(User::getApiKey).ifPresent(apiKeyAuthenticationService::revokeMigratedKey);
User user = saveUser(userOpt, generateApiKey());
try {
databaseService.exportDatabase();
@@ -225,8 +220,7 @@ public class UserService implements UserServiceInterface {
}
public Optional<User> getUserByApiKey(String apiKey) {
// Resolves the multi-key api_keys table first, then the legacy per-user column.
return apiKeyAuthenticationService.resolveUser(apiKey);
return userRepository.findByApiKey(apiKey);
}
public Optional<User> loadUserByApiKey(String apiKey) {
@@ -0,0 +1,135 @@
package stirling.software.proprietary.service;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import com.hubspot.jinjava.Jinjava;
import com.hubspot.jinjava.JinjavaConfig;
import stirling.software.proprietary.model.api.ai.create.AiDocument;
/** Renders an {@link AiDocument} to HTML using a Jinja template loaded from the classpath. */
@Component
public class AiDocumentHtmlRenderer {
private static final String TEMPLATE_PATH = "templates/ai/create/document.html.jinja2";
private static final Pattern SAFE_COLOR = Pattern.compile("^#[0-9a-fA-F]{6}$");
private final Jinjava jinjava;
private final String template;
public AiDocumentHtmlRenderer() {
JinjavaConfig config =
JinjavaConfig.newBuilder().withNestedInterpretationEnabled(false).build();
this.jinjava = new Jinjava(config);
this.template = loadTemplate();
}
public String render(AiDocument doc) {
return jinjava.render(template, buildContext(doc));
}
private static Map<String, Object> buildContext(AiDocument doc) {
Map<String, Object> context = new LinkedHashMap<>();
context.put("title", doc.getTitle());
context.put("subtitle", doc.getSubtitle());
context.put("reference_number", doc.getReferenceNumber());
AiDocument.Style style = doc.getStyle();
if (style != null) {
context.put("style_primary", safeColor(style.getPrimaryColor()));
context.put("style_background", safeColor(style.getBackgroundColor()));
context.put("style_body", safeColor(style.getBodyTextColor()));
}
List<Map<String, Object>> sections = new ArrayList<>();
if (doc.getSections() != null) {
for (AiDocument.Section section : doc.getSections()) {
if (section != null && section.getType() != null) {
sections.add(buildSection(section));
}
}
}
context.put("sections", sections);
return context;
}
private static Map<String, Object> buildSection(AiDocument.Section section) {
Map<String, Object> node = new LinkedHashMap<>();
node.put("type", section.getType());
node.put("heading", section.getHeading());
switch (section.getType()) {
case "text" -> node.put("paragraphs", paragraphs(section.getBody()));
case "key_value" -> node.put("pairs", pairs(section.getPairs()));
case "line_items" -> {
node.put("columns", orEmpty(section.getColumns()));
node.put("rows", orEmptyRows(section.getRows()));
node.put("total_row", emptyToNull(section.getTotalRow()));
}
case "bullet_list" -> node.put("items", orEmpty(section.getItems()));
case "signature" -> node.put("signatories", orEmpty(section.getSignatories()));
default -> {}
}
return node;
}
private static List<String> paragraphs(String body) {
String text = body == null ? "" : body;
List<String> out = new ArrayList<>();
for (String paragraph : text.split("\n\n")) {
out.add(paragraph.replace("\n", " "));
}
return out;
}
private static List<Map<String, String>> pairs(List<List<String>> pairs) {
List<Map<String, String>> out = new ArrayList<>();
if (pairs != null) {
for (List<String> pair : pairs) {
Map<String, String> node = new LinkedHashMap<>();
node.put("label", pair.isEmpty() ? "" : pair.get(0));
node.put("value", pair.size() < 2 ? "" : pair.get(1));
out.add(node);
}
}
return out;
}
private static List<String> orEmpty(List<String> values) {
return values == null ? List.of() : values;
}
private static List<List<String>> orEmptyRows(List<List<String>> rows) {
return rows == null ? List.of() : rows;
}
private static List<String> emptyToNull(List<String> values) {
return values == null || values.isEmpty() ? null : values;
}
private static String safeColor(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return SAFE_COLOR.matcher(trimmed).matches() ? trimmed : null;
}
private static String loadTemplate() {
try {
return new ClassPathResource(TEMPLATE_PATH).getContentAsString(StandardCharsets.UTF_8);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
@@ -65,13 +65,12 @@ public class PortalDocumentsService {
// "API". The automation marker distinguishes a policy-run step from real API traffic.
boolean automation = isAutomation(data);
String policyName = asString(data.get("policyName"));
String origin = asString(data.get("__origin"));
String source =
automation
? (policyName != null && !policyName.isBlank()
? "Policy: " + policyName
: "Policy automation")
: sourceLabel(origin, asString(data.get("__apiKeyLabel")));
: sourceLabel(asString(data.get("__origin")));
String product = automation ? "Automation" : productLabel(source);
String action = prettyTool(path);
boolean failed = isFailure(data);
@@ -174,12 +173,9 @@ public class PortalDocumentsService {
return code instanceof Number n && n.intValue() >= 400;
}
private static String sourceLabel(String origin, String apiKeyLabel) {
private static String sourceLabel(String origin) {
if ("API".equals(origin)) {
// Attribute to the specific named key when known, else the generic API channel.
return apiKeyLabel != null && !apiKeyLabel.isBlank()
? "API key · " + apiKeyLabel
: "API integration";
return "API integration";
}
if ("SYSTEM".equals(origin)) {
return "System";
@@ -188,8 +184,7 @@ public class PortalDocumentsService {
}
private static String productLabel(String source) {
// Covers both the generic "API integration" and per-key "API key · <label>" sources.
return source != null && source.startsWith("API") ? "API" : "Editor";
return "API integration".equals(source) ? "API" : "Editor";
}
private static boolean isAutomation(Map<String, Object> data) {
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,4 @@
{%- autoescape true -%}
<!DOCTYPE html>
<html lang="en">
<head>
@@ -175,18 +176,18 @@
color: var(--color-label);
}
</style>
{%- if doc.style %}
{%- if style_primary or style_background or style_body %}
<style>
:root {
{%- if doc.style.primary_color %}
--color-primary: {{ doc.style.primary_color }};
{%- if style_primary %}
--color-primary: {{ style_primary }};
{%- endif %}
{%- if doc.style.background_color %}
--color-bg: {{ doc.style.background_color }};
{%- if style_background %}
--color-bg: {{ style_background }};
{%- endif %}
{%- if doc.style.body_text_color %}
--color-body: {{ doc.style.body_text_color }};
--color-label: {{ doc.style.body_text_color }};
{%- if style_body %}
--color-body: {{ style_body }};
--color-label: {{ style_body }};
{%- endif %}
}
</style>
@@ -195,16 +196,16 @@
<body>
<div class="doc-header">
<div class="doc-title">{{ doc.title }}</div>
{%- if doc.subtitle %}
<div class="doc-subtitle">{{ doc.subtitle }}</div>
<div class="doc-title">{{ title }}</div>
{%- if subtitle %}
<div class="doc-subtitle">{{ subtitle }}</div>
{%- endif %}
{%- if doc.reference_number %}
<div class="doc-reference">{{ doc.reference_number }}</div>
{%- if reference_number %}
<div class="doc-reference">{{ reference_number }}</div>
{%- endif %}
</div>
{%- for section in doc.sections %}
{%- for section in sections %}
{%- if section.type == "text" %}
<section>
@@ -212,8 +213,8 @@
<h2>{{ section.heading }}</h2>
{%- endif %}
<div class="text-body">
{%- for para in section.body.split('\n\n') %}
<p>{{ para | replace('\n', ' ') }}</p>
{%- for para in section.paragraphs %}
<p>{{ para }}</p>
{%- endfor %}
</div>
</section>
@@ -225,10 +226,10 @@
{%- endif %}
<table class="kv-table">
<tbody>
{%- for label, value in section.pairs %}
{%- for pair in section.pairs %}
<tr>
<td class="kv-label">{{ label }}</td>
<td class="kv-value">{{ value }}</td>
<td class="kv-label">{{ pair.label }}</td>
<td class="kv-value">{{ pair.value }}</td>
</tr>
{%- endfor %}
</tbody>
@@ -299,3 +300,4 @@
</body>
</html>
{%- endautoescape %}
@@ -1,133 +0,0 @@
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<ClassificationLabels> 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<ClassificationLabels> 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<Void> response = controller.resetTeamLabels();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
assertThat(controller.getTeamLabels().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
}
@@ -1,146 +0,0 @@
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<ClassificationLabel> 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");
}
}
@@ -14,7 +14,6 @@ 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;
@@ -27,10 +26,8 @@ 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.ClassificationLabelProvider;
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;
@@ -42,22 +39,16 @@ import tools.jackson.databind.json.JsonMapper;
@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();
private void withLabels(List<ClassificationLabel> labels) {
controller =
new ClassifyLabelController(
pdfDocumentFactory,
@@ -66,9 +57,8 @@ class ClassifyLabelControllerTest {
pdfMetadataService,
aiEngineClient,
objectMapper,
null,
labelStore,
policyManagementAuthority);
ClassificationLabelProvider.withLabels(labels),
null);
}
private void stubSinglePageDocument() throws Exception {
@@ -98,12 +88,7 @@ class ClassifyLabelControllerTest {
@Test
void classifyAndLabel_writesClassificationWithoutOutcome() throws Exception {
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
labelStore.save(
TEAM,
new ClassificationLabels(
List.of(new ClassificationLabel("invoice", "Invoice", null))),
"admin");
withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
stubSinglePageDocument();
@@ -118,16 +103,12 @@ class ClassifyLabelControllerTest {
}
@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");
void classifyAndLabel_sendsLabelIdsAndNames() throws Exception {
withLabels(
List.of(
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
new ClassificationLabel("contract", "Contract", null),
new ClassificationLabel("timesheet", "Timesheet", null)));
stubSinglePageDocument();
@@ -152,13 +133,13 @@ class ClassifyLabelControllerTest {
}
@Test
void classifyAndLabel_skipsClassificationWhenNothingStored() throws Exception {
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
void classifyAndLabel_skipsClassificationWhenNoLabels() throws Exception {
withLabels(List.of());
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.
// No vocabulary, 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());
@@ -166,8 +166,8 @@ class PolicyExecutorTest {
new PipelineStep(
createPdf,
Map.of(
"htmlContent",
"<p>hi</p>",
"document",
"{\"title\":\"PO\",\"sections\":[]}",
"filename",
"purchase-order.pdf"))),
PolicyInputs.of(List.of()),
@@ -29,7 +29,6 @@ import stirling.software.proprietary.security.database.repository.PersistentLogi
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
@@ -161,9 +160,7 @@ class SecurityConfigurationTest {
@Test
@DisplayName("jwtAuthenticationFilter is created")
void jwtAuthenticationFilter() {
JwtAuthenticationFilter filter =
newConfig(true)
.jwtAuthenticationFilter(mock(ApiKeyAuthenticationService.class));
JwtAuthenticationFilter filter = newConfig(true).jwtAuthenticationFilter();
assertThat(filter).isNotNull();
}
@@ -29,8 +29,6 @@ import org.springframework.security.core.session.SessionInformation;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@@ -39,7 +37,6 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
class UserAuthenticationFilterTest {
@Mock private UserService userService;
@Mock private ApiKeyAuthenticationService apiKeyAuthenticationService;
@Mock private SessionPersistentRegistry sessionPersistentRegistry;
private ApplicationProperties.Security securityProp;
@@ -63,11 +60,7 @@ class UserAuthenticationFilterTest {
private UserAuthenticationFilter filter(boolean loginEnabled) {
return new UserAuthenticationFilter(
securityProp,
userService,
apiKeyAuthenticationService,
sessionPersistentRegistry,
loginEnabled);
securityProp, userService, sessionPersistentRegistry, loginEnabled);
}
private static User enabledUser(String username) {
@@ -106,11 +99,7 @@ class UserAuthenticationFilterTest {
User user = enabledUser("api-user");
user.addAuthority(
new stirling.software.proprietary.security.model.Authority("ROLE_USER", user));
when(apiKeyAuthenticationService.authenticate("good-key"))
.thenReturn(
Optional.of(
new ApiKeyAuthentication(
user, "Prod (sk_demo0000)", user.getAuthorities())));
when(userService.getUserByApiKey("good-key")).thenReturn(Optional.of(user));
when(userService.usernameExistsIgnoreCase("api-user")).thenReturn(true);
when(userService.isUserDisabled("api-user")).thenReturn(false);
when(sessionPersistentRegistry.getAllSessions(any(), anyBoolean()))
@@ -128,7 +117,7 @@ class UserAuthenticationFilterTest {
void invalidApiKeyRejected() throws Exception {
request.setRequestURI("/api/v1/some/protected");
request.addHeader("X-API-KEY", "bad-key");
when(apiKeyAuthenticationService.authenticate("bad-key")).thenReturn(Optional.empty());
when(userService.getUserByApiKey("bad-key")).thenReturn(Optional.empty());
filter(true).doFilter(request, response, filterChain);
@@ -1,95 +0,0 @@
package stirling.software.proprietary.security.filter;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
@ExtendWith(MockitoExtension.class)
@DisplayName("UserBasedRateLimitingFilter")
class UserBasedRateLimitingFilterTest {
@AfterEach
void clear() {
SecurityContextHolder.clearContext();
}
private void authenticateAs(String username) {
User u = new User();
u.setUsername(username);
u.setEnabled(true);
SecurityContextHolder.getContext()
.setAuthentication(
new ApiKeyAuthenticationToken(
u,
"irrelevant",
List.of(new SimpleGrantedAuthority(Role.USER.getRoleId()))));
}
private long remainingAfterApiPost(UserBasedRateLimitingFilter filter, String apiKey)
throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/x");
req.addHeader("X-API-KEY", apiKey);
MockHttpServletResponse res = new MockHttpServletResponse();
filter.doFilter(req, res, new MockFilterChain());
return Long.parseLong(res.getHeader("X-Rate-Limit-Remaining"));
}
@Test
@DisplayName("all of a user's keys share ONE bucket - minting keys can't multiply the quota")
void keysShareOnePerUserBucket() throws Exception {
UserBasedRateLimitingFilter filter = new UserBasedRateLimitingFilter(true);
authenticateAs("alice");
long afterKeyA = remainingAfterApiPost(filter, "key-A");
long afterKeyB = remainingAfterApiPost(filter, "key-B"); // different key, same user
// The second (different) key drew from the SAME per-user bucket, so remaining fell by one.
// If it were keyed per-API-key, both would report the same remaining.
assertThat(afterKeyB).isEqualTo(afterKeyA - 1);
}
@Test
@DisplayName("non-POST requests are not rate limited")
void nonPostPassesThrough() throws Exception {
UserBasedRateLimitingFilter filter = new UserBasedRateLimitingFilter(true);
authenticateAs("alice");
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/general/x");
req.addHeader("X-API-KEY", "key-A");
MockHttpServletResponse res = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(req, res, chain);
assertThat(res.getHeader("X-Rate-Limit-Remaining")).isNull();
assertThat(chain.getRequest()).isNotNull(); // passed down the chain
}
@Test
@DisplayName("rate limiting disabled: passes through untouched")
void disabledPassesThrough() throws Exception {
UserBasedRateLimitingFilter filter = new UserBasedRateLimitingFilter(false);
authenticateAs("alice");
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/x");
req.addHeader("X-API-KEY", "key-A");
MockHttpServletResponse res = new MockHttpServletResponse();
filter.doFilter(req, res, new MockFilterChain());
assertThat(res.getHeader("X-Rate-Limit-Remaining")).isNull();
}
}
@@ -1,148 +0,0 @@
package stirling.software.proprietary.security.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.DisplayName;
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.core.GrantedAuthority;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKey;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.ApiKeyRepository;
@ExtendWith(MockitoExtension.class)
@DisplayName("ApiKeyAuthenticationService")
class ApiKeyAuthenticationServiceTest {
@Mock private ApiKeyRepository apiKeyRepository;
@Mock private ApiKeyUsageRecorder usageRecorder;
@Mock private UserRepository userRepository;
@InjectMocks private ApiKeyAuthenticationService service;
private User user(long id, boolean enabled) {
User u = new User();
u.setId(id);
u.setUsername("user" + id);
u.setEnabled(enabled);
return u;
}
private ApiKey key(long id, long ownerId, boolean enabled, Instant revoked) {
return ApiKey.builder()
.id(id)
.name("Production ingest")
.keyHash(ApiKeyHasher.hash("raw-" + id))
.prefix("sk_demo0000")
.ownerUserId(ownerId)
.enabled(enabled)
.revokedAt(revoked)
.createdAt(Instant.now())
.build();
}
@Test
@DisplayName("resolves an active multi-key to its owner and records usage")
void resolvesActiveKey() {
String raw = "raw-1";
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
.thenReturn(Optional.of(key(1, 7, true, null)));
when(userRepository.findById(7L)).thenReturn(Optional.of(user(7, true)));
var result = service.authenticate(raw);
assertThat(result).isPresent();
assertThat(result.get().user().getId()).isEqualTo(7L);
assertThat(result.get().auditLabel()).isEqualTo("Production ingest (sk_demo0000)");
verify(usageRecorder).record(1L);
}
@Test
@DisplayName("rejects a revoked key without recording usage")
void rejectsRevokedKey() {
String raw = "raw-2";
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
.thenReturn(Optional.of(key(2, 7, true, Instant.now())));
assertThat(service.authenticate(raw)).isEmpty();
verifyNoInteractions(usageRecorder);
}
@Test
@DisplayName("rejects a key whose owner is disabled")
void rejectsDisabledOwner() {
String raw = "raw-3";
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
.thenReturn(Optional.of(key(3, 8, true, null)));
when(userRepository.findById(8L)).thenReturn(Optional.of(user(8, false)));
assertThat(service.authenticate(raw)).isEmpty();
}
@Test
@DisplayName("falls back to the legacy per-user column, with no per-key label")
void legacyFallback() {
String raw = "legacy-key";
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw))).thenReturn(Optional.empty());
when(userRepository.findByApiKey(raw)).thenReturn(Optional.of(user(9, true)));
var result = service.authenticate(raw);
assertThat(result).isPresent();
assertThat(result.get().user().getId()).isEqualTo(9L);
assertThat(result.get().auditLabel()).isNull();
verifyNoInteractions(usageRecorder);
}
@Test
@DisplayName("blank keys resolve to nothing")
void blankKey() {
assertThat(service.authenticate(" ")).isEmpty();
assertThat(service.resolveUser(null)).isEmpty();
}
@Test
@DisplayName("a key authenticates with its owner's authorities (owner acts as self)")
void keyKeepsOwnerAuthorities() {
String raw = "raw-6";
User owner = user(8, true);
owner.addAuthority(new Authority(Role.ADMIN.getRoleId(), owner));
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
.thenReturn(Optional.of(key(6, 8, true, null)));
when(userRepository.findById(8L)).thenReturn(Optional.of(owner));
var result = service.authenticate(raw);
List<String> auths =
result.get().authorities().stream().map(GrantedAuthority::getAuthority).toList();
assertThat(auths).contains(Role.ADMIN.getRoleId());
}
@Test
@DisplayName("revokeMigratedKey disables the shadow row so a rotated legacy key stops working")
void revokeMigratedKeyRevokesRow() {
String raw = "raw-9";
ApiKey shadow = key(9, 1, true, null);
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
.thenReturn(Optional.of(shadow));
service.revokeMigratedKey(raw);
assertThat(shadow.isEnabled()).isFalse();
assertThat(shadow.getRevokedAt()).isNotNull();
verify(apiKeyRepository).save(shadow);
}
}
@@ -1,38 +0,0 @@
package stirling.software.proprietary.security.service;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@DisplayName("ApiKeyHasher")
class ApiKeyHasherTest {
@Test
@DisplayName("generated keys are unique, prefixed, and hash deterministically")
void generateAndHash() {
String a = ApiKeyHasher.generateRawKey();
String b = ApiKeyHasher.generateRawKey();
assertThat(a).startsWith("sk_").isNotEqualTo(b);
// Same input hashes the same; SHA-256 hex is 64 chars.
assertThat(ApiKeyHasher.hash(a)).isEqualTo(ApiKeyHasher.hash(a)).hasSize(64);
assertThat(ApiKeyHasher.hash(a)).isNotEqualTo(ApiKeyHasher.hash(b));
}
@Test
@DisplayName("hash never returns the raw key")
void hashHidesRaw() {
String raw = ApiKeyHasher.generateRawKey();
assertThat(ApiKeyHasher.hash(raw)).isNotEqualTo(raw);
}
@Test
@DisplayName("display prefix is a short non-secret leading fragment")
void displayPrefix() {
String raw = ApiKeyHasher.generateRawKey();
String prefix = ApiKeyHasher.displayPrefix(raw);
assertThat(prefix).hasSize(11).startsWith("sk_");
assertThat(raw).startsWith(prefix);
}
}
@@ -1,198 +0,0 @@
package stirling.software.proprietary.security.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.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
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.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.proprietary.model.api.apikey.CreateApiKeyRequest;
import stirling.software.proprietary.model.api.apikey.CreatedApiKeyDto;
import stirling.software.proprietary.model.api.apikey.PortalApiKeysResponse;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKey;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository;
import stirling.software.proprietary.security.repository.ApiKeyRepository;
@ExtendWith(MockitoExtension.class)
@DisplayName("ApiKeyManagementService")
class ApiKeyManagementServiceTest {
@Mock private ApiKeyRepository apiKeyRepository;
@Mock private ApiKeyDailyUsageRepository usageRepository;
@Mock private UserRepository userRepository;
@Mock private UserService userService;
@Mock private ApiKeyLegacyMigrator legacyMigrator;
@InjectMocks private ApiKeyManagementService service;
private User caller;
@BeforeEach
void setUp() {
caller = new User();
caller.setId(1L);
caller.setUsername("alice");
lenient().when(userService.getCurrentUsername()).thenReturn("alice");
lenient()
.when(userService.findByUsernameIgnoreCase("alice"))
.thenReturn(Optional.of(caller));
lenient().when(apiKeyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
lenient().when(usageRepository.countForDayByIds(any(), anyLong())).thenReturn(List.of());
lenient().when(usageRepository.sumSinceByIds(any(), anyLong())).thenReturn(List.of());
}
private ApiKey personalKey(long id, long ownerId) {
return ApiKey.builder()
.id(id)
.name("Key " + id)
.keyHash("hash" + id)
.prefix("sk_demo0000")
.ownerUserId(ownerId)
.enabled(true)
.createdAt(Instant.now())
.build();
}
// ---- migration safety ---------------------------------------------------
@Test
@DisplayName("an existing legacy key migrates to an owner-only row")
void legacyKeyMigratesAsPersonal() {
caller.setApiKey("legacy-raw-key");
when(apiKeyRepository.existsByKeyHash(ApiKeyHasher.hash("legacy-raw-key")))
.thenReturn(false);
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L)).thenReturn(List.of());
service.listVisibleKeys();
// Migration insert is isolated in its own transaction (ApiKeyLegacyMigrator).
ArgumentCaptor<ApiKey> saved = ArgumentCaptor.forClass(ApiKey.class);
verify(legacyMigrator).insertMigratedKey(saved.capture());
ApiKey migrated = saved.getValue();
assertThat(migrated.getOwnerUserId()).isEqualTo(1L);
}
@Test
@DisplayName("migration is idempotent - an already-migrated legacy key is not re-saved")
void legacyKeyMigrationIdempotent() {
caller.setApiKey("legacy-raw-key");
when(apiKeyRepository.existsByKeyHash(ApiKeyHasher.hash("legacy-raw-key")))
.thenReturn(true);
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L)).thenReturn(List.of());
service.listVisibleKeys();
verify(legacyMigrator, never()).insertMigratedKey(any());
}
// ---- personal isolation -------------------------------------------------
@Test
@DisplayName("listing scopes keys to the caller by owner id")
void personalKeysScopedToOwner() {
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L))
.thenReturn(List.of(personalKey(10, 1L)));
PortalApiKeysResponse res = service.listVisibleKeys();
assertThat(res.keys()).singleElement().satisfies(k -> assertThat(k.id()).isEqualTo("10"));
// Isolation: the query is keyed by the caller's id, never a broad scan.
verify(apiKeyRepository).findByOwnerUserIdOrderByCreatedAtDesc(1L);
}
// ---- creation -----------------------------------------------------------
@Test
@DisplayName("a user creates a personal key and gets a one-time secret")
void createPersonalKey() {
CreatedApiKeyDto created = service.createKey(new CreateApiKeyRequest("My key"));
assertThat(created.secret()).startsWith("sk_");
ArgumentCaptor<ApiKey> saved = ArgumentCaptor.forClass(ApiKey.class);
verify(apiKeyRepository).save(saved.capture());
assertThat(saved.getValue().getOwnerUserId()).isEqualTo(1L);
assertThat(saved.getValue().getName()).isEqualTo("My key");
}
@Test
@DisplayName("rejects an over-long key name")
void rejectsLongName() {
String longName = "a".repeat(101);
assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest(longName)))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("characters or fewer");
verify(apiKeyRepository, never()).save(any());
}
@Test
@DisplayName("rejects a blank key name")
void rejectsBlankName() {
assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest(" ")))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("required");
verify(apiKeyRepository, never()).save(any());
}
@Test
@DisplayName("rejects creating a key past the per-user active-key cap")
void rejectsPastActiveKeyCap() {
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L))
.thenReturn(java.util.Collections.nCopies(50, personalKey(100, 1L)));
assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest("One too many")))
.isInstanceOf(ResponseStatusException.class);
verify(apiKeyRepository, never()).save(any());
}
// ---- revocation ---------------------------------------------------------
@Test
@DisplayName("owner revokes their key and the legacy column is cleared")
void revokePersonalClearsLegacy() {
caller.setApiKey("legacy-raw-key");
ApiKey legacyRow = personalKey(30, 1L);
legacyRow.setKeyHash(ApiKeyHasher.hash("legacy-raw-key"));
when(apiKeyRepository.findById(30L)).thenReturn(Optional.of(legacyRow));
when(userRepository.findById(1L)).thenReturn(Optional.of(caller));
service.revokeKey(30L);
assertThat(legacyRow.isEnabled()).isFalse();
assertThat(legacyRow.getRevokedAt()).isNotNull();
assertThat(caller.getApiKey()).isNull();
verify(userRepository).save(caller);
}
@Test
@DisplayName(
"a non-owner cannot revoke someone else's key (404, not 403, so ids can't be probed)")
void revokeForeignKeyForbidden() {
when(apiKeyRepository.findById(31L)).thenReturn(Optional.of(personalKey(31, 999L)));
assertThatThrownBy(() -> service.revokeKey(31L))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(404));
verify(apiKeyRepository, never()).save(any());
}
}
@@ -1,88 +0,0 @@
package stirling.software.proprietary.security.service;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
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 org.junit.jupiter.api.DisplayName;
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;
/**
* Unit tests for the increment/insert/increment race protocol. {@code @Async} has no proxy in a
* plain Mockito test, so {@code record()} runs inline and is directly testable.
*/
@ExtendWith(MockitoExtension.class)
@DisplayName("ApiKeyUsageRecorder")
class ApiKeyUsageRecorderTest {
private static final long KEY = 7L;
@Mock private ApiKeyUsageWriter writer;
@InjectMocks private ApiKeyUsageRecorder recorder;
@Test
@DisplayName("a null key id is a no-op")
void nullIdIsNoOp() {
recorder.record(null);
verifyNoInteractions(writer);
}
@Test
@DisplayName("row already exists: one increment, never inserts")
void rowExistsFastPath() {
when(writer.increment(eq(KEY), anyLong())).thenReturn(1);
recorder.record(KEY);
verify(writer, times(1)).increment(eq(KEY), anyLong());
verify(writer, never()).tryInsertFirstUse(anyLong(), anyLong());
verify(writer).stampLastUsed(KEY);
}
@Test
@DisplayName("first writer of the day: increment misses, insert wins, no second increment")
void firstWriterInserts() {
when(writer.increment(eq(KEY), anyLong())).thenReturn(0);
when(writer.tryInsertFirstUse(eq(KEY), anyLong())).thenReturn(true);
recorder.record(KEY);
verify(writer, times(1)).increment(eq(KEY), anyLong());
verify(writer).tryInsertFirstUse(eq(KEY), anyLong());
verify(writer).stampLastUsed(KEY);
}
@Test
@DisplayName(
"lost the insert race: falls back to a second increment so the count is not dropped")
void lostInsertRaceReincrements() {
when(writer.increment(eq(KEY), anyLong())).thenReturn(0);
when(writer.tryInsertFirstUse(eq(KEY), anyLong())).thenReturn(false);
recorder.record(KEY);
verify(writer, times(2)).increment(eq(KEY), anyLong());
verify(writer).stampLastUsed(KEY);
}
@Test
@DisplayName("insert throws (rollback-only commit): still re-increments, count not dropped")
void insertThrowsStillReincrements() {
when(writer.increment(eq(KEY), anyLong())).thenReturn(0);
when(writer.tryInsertFirstUse(eq(KEY), anyLong()))
.thenThrow(new RuntimeException("UnexpectedRollbackException"));
recorder.record(KEY);
verify(writer, times(2)).increment(eq(KEY), anyLong());
verify(writer).stampLastUsed(KEY);
}
}
@@ -41,7 +41,6 @@ import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.storage.repository.FileShareAccessRepository;
import stirling.software.proprietary.storage.repository.FileShareRepository;
@@ -77,7 +76,6 @@ class UserServiceMoreTest {
integrationConfigRepository;
@Mock private TeamMembershipService teamMembershipService;
@Mock private ApiKeyAuthenticationService apiKeyAuthenticationService;
@InjectMocks private UserService userService;
@@ -101,8 +99,7 @@ class UserServiceMoreTest {
void getAuthenticationValid() {
User u = user("api");
u.addAuthority(new Authority("ROLE_USER", u));
when(apiKeyAuthenticationService.authenticate("k"))
.thenReturn(Optional.of(new ApiKeyAuthentication(u, null, u.getAuthorities())));
when(userRepository.findByApiKey("k")).thenReturn(Optional.of(u));
assertThat(userService.getAuthentication("k")).isNotNull();
}
@@ -110,7 +107,7 @@ class UserServiceMoreTest {
@Test
@DisplayName("getAuthentication throws when key is unknown")
void getAuthenticationInvalid() {
when(apiKeyAuthenticationService.authenticate("bad")).thenReturn(Optional.empty());
when(userRepository.findByApiKey("bad")).thenReturn(Optional.empty());
assertThatThrownBy(() -> userService.getAuthentication("bad"))
.isInstanceOf(UsernameNotFoundException.class);
@@ -71,7 +71,6 @@ class UserServiceTest {
integrationConfigRepository;
@Mock private TeamMembershipService teamMembershipService;
@Mock private ApiKeyAuthenticationService apiKeyAuthenticationService;
@Spy @InjectMocks private UserService userService;
@@ -197,27 +196,6 @@ class UserServiceTest {
verify(userRepository).save(user);
}
@Test
void addApiKeyToUserRevokesOldMigratedShadowRow() {
User user = new User();
user.setUsername("user");
user.setApiKey("old-secret");
when(userRepository.findByUsernameIgnoreCase("user")).thenReturn(Optional.of(user));
when(userRepository.findByApiKey(any())).thenReturn(Optional.empty());
when(userRepository.save(any(User.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
User updated = userService.addApiKeyToUser("user");
// Rotating a legacy key must revoke its migrated api_keys shadow row with the OLD secret,
// and do so before the new key is generated - otherwise the old secret keeps
// authenticating.
org.mockito.InOrder inOrder = inOrder(apiKeyAuthenticationService, userRepository);
inOrder.verify(apiKeyAuthenticationService).revokeMigratedKey("old-secret");
inOrder.verify(userRepository).save(user);
assertNotEquals("old-secret", updated.getApiKey());
}
@Test
void getApiKeyForUserCreatesWhenMissing() {
User user = new User();
@@ -0,0 +1,139 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
import stirling.software.proprietary.model.api.ai.create.AiDocument;
class AiDocumentHtmlRendererTest {
private final AiDocumentHtmlRenderer renderer = new AiDocumentHtmlRenderer();
private static AiDocument.Section section(String type) {
AiDocument.Section s = new AiDocument.Section();
s.setType(type);
return s;
}
private static AiDocument document(String title, List<AiDocument.Section> sections) {
AiDocument doc = new AiDocument();
doc.setTitle(title);
doc.setSections(sections);
return doc;
}
@Test
void rendersAllSectionTypes() {
AiDocument.Section text = section("text");
text.setBody("Some prose text.");
AiDocument.Section kv = section("key_value");
kv.setPairs(List.of(List.of("Key", "Value")));
AiDocument.Section items = section("line_items");
items.setColumns(List.of("A", "B"));
items.setRows(List.of(List.of("1", "2")));
AiDocument.Section bullets = section("bullet_list");
bullets.setItems(List.of("item one"));
AiDocument.Section sign = section("signature");
sign.setSignatories(List.of("Alice"));
String html = renderer.render(document("All", List.of(text, kv, items, bullets, sign)));
assertTrue(html.contains("<!DOCTYPE html>"));
assertTrue(html.contains("Some prose text."));
assertTrue(html.contains("Key") && html.contains("Value"));
assertTrue(html.contains("<th>"));
assertTrue(html.contains("item one"));
assertTrue(html.contains("Alice"));
}
@Test
void rendersMarkupCharactersAsText() {
AiDocument.Section text = section("text");
text.setBody("a <b>x</b> & y");
String html = renderer.render(document("Doc", List.of(text)));
assertFalse(html.contains("<b>"));
assertTrue(html.contains("&lt;b&gt;"));
}
@Test
void totalRowRenderedWhenPresent() {
AiDocument.Section items = section("line_items");
items.setColumns(List.of("Item", "Total"));
items.setRows(List.of(List.of("Widget", "$10")));
items.setTotalRow(List.of("Total", "$10"));
assertTrue(
renderer.render(document("Table", List.of(items)))
.contains("<tr class=\"total-row\">"));
}
@Test
void totalRowAbsentWhenNotProvided() {
AiDocument.Section items = section("line_items");
items.setColumns(List.of("Item"));
items.setRows(List.of(List.of("Widget")));
assertFalse(
renderer.render(document("Table", List.of(items)))
.contains("<tr class=\"total-row\">"));
}
@Test
void rendersSubtitleAndReference() {
AiDocument doc = document("My Doc", List.of());
doc.setSubtitle("Subtitle Here");
doc.setReferenceNumber("REF-42");
String html = renderer.render(doc);
assertTrue(html.contains("Subtitle Here"));
assertTrue(html.contains("REF-42"));
}
@Test
void appliesHexColourOverride() {
AiDocument doc = document("Styled", List.of());
AiDocument.Style style = new AiDocument.Style();
style.setPrimaryColor("#ff00ff");
style.setBackgroundColor("#111111");
doc.setStyle(style);
String html = renderer.render(doc);
assertTrue(html.contains("--color-primary: #ff00ff"));
assertTrue(html.contains("--color-bg: #111111"));
}
@Test
void ignoresColourWithDisallowedCharacters() {
AiDocument doc = document("Styled", List.of());
AiDocument.Style style = new AiDocument.Style();
style.setPrimaryColor("rgb(255, 0, 0)");
doc.setStyle(style);
String html = renderer.render(doc);
assertFalse(html.contains("rgb("));
assertTrue(html.contains("<!DOCTYPE html>"));
}
@Test
void ignoresNonHexColour() {
AiDocument doc = document("Styled", List.of());
AiDocument.Style style = new AiDocument.Style();
style.setPrimaryColor("magenta");
style.setBackgroundColor("#fff");
doc.setStyle(style);
String html = renderer.render(doc);
assertFalse(html.contains("--color-primary: magenta"));
assertFalse(html.contains("--color-bg: #fff;"));
}
}
@@ -70,18 +70,4 @@ class PortalDocumentsServiceTest {
assertThat(doc.getProduct()).isEqualTo("API");
assertThat(doc.getSource()).isEqualTo("API integration");
}
@Test
void apiDocumentIsAttributedToItsNamedKey() {
PortalReviewDocumentDto doc =
onlyDoc(
"{\"path\":\"/api/v1/misc/compress-pdf\",\"__origin\":\"API\","
+ "\"__apiKeyLabel\":\"Production ingest (sk_demo0000)\","
+ "\"files\":[{\"name\":\"a.pdf\",\"type\":\"application/pdf\"}],"
+ "\"statusCode\":200}");
// The specific key label surfaces as the source; product stays "API".
assertThat(doc.getProduct()).isEqualTo("API");
assertThat(doc.getSource()).isEqualTo("API key · Production ingest (sk_demo0000)");
}
}
@@ -18,7 +18,6 @@ import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.slf4j.MDC;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
@@ -44,8 +43,6 @@ import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.saas.model.AmrMethod;
@@ -68,7 +65,6 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
private final SupabaseUserService supabaseUserService;
private final SaasTeamService saasTeamService;
private final JwtDecoder jwtDecoder;
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
private final AuthenticationEntryPoint authenticationEntryPoint =
new BearerTokenAuthenticationEntryPoint();
@@ -77,14 +73,12 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
UserService userService,
SupabaseUserService supabaseUserService,
SaasTeamService saasTeamService,
JwtDecoder jwtDecoder,
ApiKeyAuthenticationService apiKeyAuthenticationService) {
JwtDecoder jwtDecoder) {
this.teamService = teamService;
this.userService = userService;
this.supabaseUserService = supabaseUserService;
this.saasTeamService = saasTeamService;
this.jwtDecoder = jwtDecoder;
this.apiKeyAuthenticationService = apiKeyAuthenticationService;
}
@Override
@@ -92,9 +86,6 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// Start clean so a pooled thread can't inherit a prior request's API-key label.
MDC.remove(ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY);
if (isStaticResource(request.getContextPath(), request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
@@ -415,22 +406,16 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
return false;
}
// Resolves the multi-key table then the legacy key, records per-key usage, and yields a
// label for the processor's document-source attribution.
Optional<ApiKeyAuthentication> resolved = apiKeyAuthenticationService.authenticate(apiKey);
if (resolved.isEmpty()) {
Optional<User> user = userService.getUserByApiKey(apiKey);
if (user.isEmpty()) {
throw new InvalidBearerTokenException("Invalid API Key.");
}
User user = resolved.get().user();
userService.trackApiKeyFirstUse(user);
userService.trackApiKeyFirstUse(user.get());
ApiKeyAuthenticationToken authToken =
new ApiKeyAuthenticationToken(user, apiKey, resolved.get().authorities());
new ApiKeyAuthenticationToken(user.get(), apiKey, user.get().getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
if (resolved.get().auditLabel() != null) {
MDC.put(ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY, resolved.get().auditLabel());
}
return true;
}
@@ -48,7 +48,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.saas.accountlink.DeviceCredentialAuthenticationFilter;
@@ -70,7 +69,6 @@ public class SupabaseSecurityConfig {
private final SupabaseUserService supabaseUserService;
private final SaasTeamService saasTeamService;
private final ApplicationProperties applicationProperties;
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
@Value("${app.supabase.issuer:}")
private String issuer;
@@ -127,8 +125,7 @@ public class SupabaseSecurityConfig {
userService,
supabaseUserService,
saasTeamService,
jwtDecoder,
apiKeyAuthenticationService),
jwtDecoder),
BearerTokenAuthenticationFilter.class)
.exceptionHandling(
ex ->
@@ -1,24 +0,0 @@
-- Named, multi-key personal API keys, plus per-key daily usage.
-- Idempotent: Hibernate ddl-auto=update may already have created these on some deployments.
CREATE TABLE IF NOT EXISTS api_keys (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
key_hash VARCHAR(64) NOT NULL,
prefix VARCHAR(32) NOT NULL,
owner_user_id BIGINT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_api_key_hash ON api_keys (key_hash);
CREATE INDEX IF NOT EXISTS idx_api_key_owner ON api_keys (owner_user_id);
CREATE TABLE IF NOT EXISTS api_key_daily_usage (
api_key_id BIGINT NOT NULL,
epoch_day BIGINT NOT NULL,
count BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (api_key_id, epoch_day)
);
@@ -0,0 +1,10 @@
-- Classification labels are now a fixed, built-in set bundled with the app and sent to the engine
-- per request (see ClassificationLabelProvider); the per-team classification_labels table (created
-- in V30) is no longer read or written. Drop it.
--
-- Forward migration: V30 is kept so any DB that already applied it still validates. This runs after
-- V30 in every case, so it drops the table whether V30 just created it (fresh DB) or it was created
-- and populated on an earlier deploy. IF EXISTS only guards the edge case where the table is already
-- absent, keeping the migration safe to apply regardless of prior state.
DROP TABLE IF EXISTS classification_labels;
@@ -58,10 +58,6 @@ class SupabaseAuthenticationFilterMoreTest {
@Mock private SaasTeamService saasTeamService;
@Mock private JwtDecoder jwtDecoder;
@Mock
private stirling.software.proprietary.security.service.ApiKeyAuthenticationService
apiKeyAuthenticationService;
private SupabaseAuthenticationFilter filter;
private MockHttpServletRequest request;
private MockHttpServletResponse response;
@@ -72,12 +68,7 @@ class SupabaseAuthenticationFilterMoreTest {
SecurityContextHolder.clearContext();
filter =
new SupabaseAuthenticationFilter(
teamService,
userService,
supabaseUserService,
saasTeamService,
jwtDecoder,
apiKeyAuthenticationService);
teamService, userService, supabaseUserService, saasTeamService, jwtDecoder);
request = new MockHttpServletRequest();
response = new MockHttpServletResponse();
chain = new MockFilterChain();
@@ -243,12 +234,7 @@ class SupabaseAuthenticationFilterMoreTest {
@DisplayName("returns true and skips lookup when an api key sets an authenticated context")
void apiKeyValidStillAuthenticates() throws Exception {
User user = newUser("alice");
when(apiKeyAuthenticationService.authenticate("k1"))
.thenReturn(
Optional.of(
new stirling.software.proprietary.security.service
.ApiKeyAuthenticationService.ApiKeyAuthentication(
user, null, user.getAuthorities())));
when(userService.getUserByApiKey("k1")).thenReturn(Optional.of(user));
request.setRequestURI("/api/v1/something");
request.setMethod("POST");
@@ -48,10 +48,6 @@ class SupabaseAuthenticationFilterTest {
@Mock private stirling.software.saas.service.SaasTeamService saasTeamService;
@Mock private JwtDecoder jwtDecoder;
@Mock
private stirling.software.proprietary.security.service.ApiKeyAuthenticationService
apiKeyAuthenticationService;
private SupabaseAuthenticationFilter filter;
private MockHttpServletRequest request;
private MockHttpServletResponse response;
@@ -62,12 +58,7 @@ class SupabaseAuthenticationFilterTest {
SecurityContextHolder.clearContext();
filter =
new SupabaseAuthenticationFilter(
teamService,
userService,
supabaseUserService,
saasTeamService,
jwtDecoder,
apiKeyAuthenticationService);
teamService, userService, supabaseUserService, saasTeamService, jwtDecoder);
request = new MockHttpServletRequest();
response = new MockHttpServletResponse();
chain = new MockFilterChain();
@@ -94,12 +85,7 @@ class SupabaseAuthenticationFilterTest {
void apiKeyHeaderPopulatesSecurityContext() throws Exception {
User user = newUser("alice");
user.setApiKey("api-key-123");
when(apiKeyAuthenticationService.authenticate("api-key-123"))
.thenReturn(
Optional.of(
new stirling.software.proprietary.security.service
.ApiKeyAuthenticationService.ApiKeyAuthentication(
user, null, user.getAuthorities())));
when(userService.getUserByApiKey("api-key-123")).thenReturn(Optional.of(user));
request.setRequestURI("/api/v1/something");
request.setMethod("POST");
@@ -117,7 +103,7 @@ class SupabaseAuthenticationFilterTest {
@Test
void invalidApiKeyTriggers401() throws Exception {
when(apiKeyAuthenticationService.authenticate("nope")).thenReturn(Optional.empty());
when(userService.getUserByApiKey("nope")).thenReturn(Optional.empty());
request.setRequestURI("/api/v1/something");
request.setMethod("POST");
@@ -43,18 +43,9 @@ class SupabaseSecurityConfigMoreTest {
@Mock private SupabaseUserService supabaseUserService;
@Mock private SaasTeamService saasTeamService;
@Mock
private stirling.software.proprietary.security.service.ApiKeyAuthenticationService
apiKeyAuthenticationService;
private SupabaseSecurityConfig config(ApplicationProperties props) {
return new SupabaseSecurityConfig(
userService,
teamService,
supabaseUserService,
saasTeamService,
props,
apiKeyAuthenticationService);
userService, teamService, supabaseUserService, saasTeamService, props);
}
@Nested
@@ -21,7 +21,6 @@ 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.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.proprietary.security.service.UserService;
@@ -108,27 +107,6 @@ class TeamSecurityExpressionsTest {
assertNull(expressions().currentUserTeamId());
}
private User leaderUser() {
User leader = new User();
leader.setId(USER_ID);
Team team = new Team();
team.setId(TEAM_ID);
leader.setTeam(team);
return leader;
}
@Test
void apiKeyOfALeaderStillLeads() {
// A key acts as the owner; if they lead the team, the key leads.
SecurityContextHolder.getContext()
.setAuthentication(
new ApiKeyAuthenticationToken(leaderUser(), "sk_personal", List.of()));
when(membershipRepository.findByTeamIdAndUserId(TEAM_ID, USER_ID))
.thenReturn(Optional.of(membershipWithRole(TeamRole.LEADER)));
assertTrue(expressions().isCurrentUserTeamLeader());
}
@Test
void unauthenticatedIsNotLeader() {
// No authentication set on the context.
+9
View File
@@ -36,6 +36,8 @@ ext {
okhttpBomVersion = "5.3.2"
gsonVersion = "2.14.0"
guavaVersion = "33.6.0-jre"
jinjavaVersion = "2.8.3"
jackson2Version = "2.21.2"
bucket4jVersion = "8.19.0"
archunitVersion = "1.4.2"
batikVersion = "1.19"
@@ -222,6 +224,13 @@ subprojects {
resolutionStrategy.force "org.apache.commons:commons-lang3:${commonsLang3}"
// CVE-2024-47554: commons-io DoS prevention
resolutionStrategy.force "commons-io:commons-io:${commonsIoVersion}"
// Jackson 2 is transitive-only here (jinjava, opensaml, jjwt request older versions);
// pin the family to a current release and keep modules aligned.
resolutionStrategy.force "com.fasterxml.jackson.core:jackson-core:${jackson2Version}"
resolutionStrategy.force "com.fasterxml.jackson.core:jackson-databind:${jackson2Version}"
resolutionStrategy.force "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${jackson2Version}"
resolutionStrategy.force "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:${jackson2Version}"
resolutionStrategy.force "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${jackson2Version}"
// Keep BouncyCastle modules aligned to avoid runtime linkage errors
resolutionStrategy.force "org.bouncycastle:bcprov-jdk18on:${bouncycastleVersion}"
resolutionStrategy.force "org.bouncycastle:bcpkix-jdk18on:${bouncycastleVersion}"
-1
View File
@@ -5,7 +5,6 @@ description = "AI Document Engine"
requires-python = ">=3.13"
dependencies = [
"fastapi>=0.116.0",
"jinja2>=3.1.0",
"pgvector>=0.3.6",
"psycopg[binary,pool]>=3.2",
"pydantic>=2.0.0",
@@ -116,8 +116,8 @@ class DocumentClassifierAgent:
)
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.
# The caller (the backend) always supplies the allowed vocabulary — its
# fixed built-in label set — 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)
+12 -29
View File
@@ -10,7 +10,7 @@ Flow:
4. SectionWriterAgents (smart_model) run in parallel via asyncio.gather.
Each returns a WrittenSections with fully populated DocumentSection objects.
5. The assembler collects sections in plan order → GeneratedDocument.
6. Jinja renders the document to HTML. The LLM never writes HTML.
6. The assembled document is emitted as structured fields. The LLM never writes HTML.
The planner is split into two calls (meta then sections) so each LLM output schema
stays small enough for grammar compilation on all model tiers including Haiku.
@@ -22,9 +22,7 @@ import asyncio
import logging
import re
from dataclasses import dataclass
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
@@ -51,8 +49,6 @@ from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
_TEMPLATES_DIR = Path(__file__).parent / "templates"
# ── Token budget ──────────────────────────────────────────────────────────────────────────────────
# Conservative per-section token estimates mapped from planner-assigned depth.
@@ -166,10 +162,13 @@ Analyse the user's request and produce a DocumentMeta with:
document, if the user provides one. Leave empty if the user provides no such context.
- style_primary_color: accent and heading colour. Set ONLY when the user explicitly names a
colour or colour scheme (e.g. "make it red", "use navy blue"). Use CSS named colours
(e.g. "magenta", "navy", "crimson") or hex values. Leave null if no colour is stated.
- style_background_color: page background colour. Set only if explicitly requested.
- style_body_text_color: body text colour. Set only if explicitly requested.
colour or colour scheme (e.g. "make it red", "use navy blue"). Express it as a 6-digit hex
code in #RRGGBB format (map any named colour to its hex value yourself, e.g. "navy" →
"#000080"). No other format is accepted. Leave null if no colour is stated.
- style_background_color: page background colour, same #RRGGBB format. Set only if explicitly
requested.
- style_body_text_color: body text colour, same #RRGGBB format. Set only if explicitly
requested.
- cannot_do_reason: set this ONLY when the request is not asking to create a document at all
(e.g. a question, a greeting, an edit request to an existing document). Never set it
@@ -299,15 +298,6 @@ def _build_writer_prompt(plan: DocumentPlan, chunk: _Chunk) -> str:
# ── Helpers ───────────────────────────────────────────────────────────────────────────────────────
def _build_jinja_env() -> Environment:
return Environment(
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
autoescape=True,
trim_blocks=True,
lstrip_blocks=True,
)
def _safe_filename(title: str) -> str:
slug = re.sub(r"[^\w\s-]", "", title.lower())
slug = re.sub(r"[\s_-]+", "-", slug).strip("-")
@@ -320,7 +310,6 @@ def _safe_filename(title: str) -> str:
class PdfCreateAgent:
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
self._jinja_env = _build_jinja_env()
self._meta_planner: Agent[None, DocumentMeta] = Agent(
model=runtime.smart_model,
@@ -401,14 +390,12 @@ class PdfCreateAgent:
sections=all_sections,
)
# ── Phase 6: render ────────────────────────────────────────────────────
logger.info("[pdf-create] phase 6/6: rendering HTML")
html = self._render(doc)
# ── Phase 6: emit ──────────────────────────────────────────────────────
filename = _safe_filename(plan.title)
logger.info(
"[pdf-create] done — filename=%r html_bytes=%d",
"[pdf-create] done — filename=%r sections=%d",
filename,
len(html),
len(all_sections),
)
return EditPlanResponse(
@@ -417,7 +404,7 @@ class PdfCreateAgent:
ToolOperationStep(
tool=AgentToolId.CREATE_PDF_FROM_HTML_AGENT,
parameters=CreatePdfFromHtmlAgentParams(
html_content=html,
document=doc.model_dump_json(),
filename=filename,
),
)
@@ -437,7 +424,3 @@ class PdfCreateAgent:
len(result.output.sections),
)
return result.output
def _render(self, doc: GeneratedDocument) -> str:
template = self._jinja_env.get_template("document.html.jinja2")
return template.render(doc=doc)
+6 -8
View File
@@ -1,14 +1,14 @@
"""Contracts for the PDF Create Agent.
The agent accepts a natural-language prompt and returns a single
CREATE_PDF_FROM_HTML_AGENT plan step carrying the rendered HTML.
CREATE_PDF_FROM_HTML_AGENT plan step carrying the assembled document.
Pipeline:
1. PlannerAgent (smart_model) → DocumentPlan: structured skeleton, no body text.
2. Python chunks the plan by token budget.
3. SectionWriterAgents (smart_model, parallel) → WrittenSections per chunk.
4. Assembler collects sections in plan order → GeneratedDocument.
5. Jinja renders GeneratedDocument → HTML. The LLM never writes HTML.
5. The document is emitted as structured fields. The LLM never writes HTML.
"""
from __future__ import annotations
@@ -81,14 +81,12 @@ type DocumentSection = Annotated[
]
# Named colour or hex only — anything else is dropped so a colour can't inject CSS into the
# <style> block (which would let WeasyPrint fetch an attacker-controlled url() → SSRF).
_SAFE_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$|^[a-zA-Z]{1,30}$")
# Colours must be a 6-digit hex code (#RRGGBB); anything else is dropped to None.
_SAFE_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
class DocumentStyle(ApiModel):
"""Document colours, inferred by the meta planner and rendered into the engine's Jinja
template (never sent to Java). Unsafe colours are dropped to ``None``."""
"""Document colours, inferred by the meta planner. Non-hex values are dropped to ``None``."""
primary_color: str | None = Field(default=None)
background_color: str | None = Field(default=None)
@@ -103,7 +101,7 @@ class DocumentStyle(ApiModel):
class GeneratedDocument(ApiModel):
"""The full document model passed to Jinja for HTML rendering."""
"""The full document model emitted for rendering."""
title: str
subtitle: str | None = None
@@ -29,7 +29,7 @@ class PdfCommentAgentParams(ApiModel):
class CreatePdfFromHtmlAgentParams(ApiModel):
html_content: str
document: str
filename: str = Field(pattern=r"^.+\.pdf$")
+29 -148
View File
@@ -2,7 +2,7 @@
Coverage:
1. Section model validation (each section type round-trips correctly)
2. Jinja rendering (_render produces valid HTML for each section type)
2. orchestrate() emits the assembled document as structured JSON
3. _safe_filename produces clean slugs
4. _make_chunks groups sections correctly by token budget
5. orchestrate() produces the correct EditPlanResponse via planner + writer mocks
@@ -12,6 +12,8 @@ Coverage:
from __future__ import annotations
import json
import pytest
from conftest import build_app_settings
from pydantic_ai.models.test import TestModel
@@ -64,37 +66,6 @@ def agent(runtime: AppRuntime) -> PdfCreateAgent:
# ── Helpers ───────────────────────────────────────────────────────────────────────────────────────
def _invoice_doc() -> GeneratedDocument:
return GeneratedDocument(
title="Invoice",
subtitle="Acme Corp",
reference_number="Invoice #INV-001",
sections=[
KeyValueSection(
heading="Details",
pairs=[("Date", "2026-05-06"), ("Due", "2026-06-06"), ("Currency", "USD")],
),
LineItemsSection(
heading="Line Items",
columns=["Description", "Qty", "Unit Price", "Total"],
rows=[
["Consulting services", "10", "$500.00", "$5,000.00"],
["Expenses", "1", "$200.00", "$200.00"],
],
total_row=["Total", "", "", "$5,200.00"],
),
TextSection(
heading="Payment Terms",
body="Payment is due within 30 days.\n\nPlease reference the invoice number.",
),
SignatureSection(
heading="Authorised By",
signatories=["Jane Smith, CEO", "Bob Jones, CFO"],
),
],
)
def _simple_meta() -> DocumentMeta:
return DocumentMeta(
title="Invoice",
@@ -199,82 +170,6 @@ def test_generated_document_optional_fields() -> None:
assert doc.reference_number is None
# ── Jinja rendering ───────────────────────────────────────────────────────────────────────────────
def test_render_produces_html(agent: PdfCreateAgent) -> None:
doc = _invoice_doc()
html = agent._render(doc)
assert "<!DOCTYPE html>" in html
assert "Invoice" in html
assert "INV-001" in html
def test_render_includes_all_section_types(agent: PdfCreateAgent) -> None:
doc = GeneratedDocument(
title="All Sections",
sections=[
TextSection(body="Some prose text."),
KeyValueSection(pairs=[("Key", "Value")]),
LineItemsSection(columns=["A", "B"], rows=[["1", "2"]]),
BulletListSection(items=["item one"]),
SignatureSection(signatories=["Alice"]),
],
)
html = agent._render(doc)
assert "Some prose text." in html
assert "Key" in html and "Value" in html
assert "<th>" in html
assert "item one" in html
assert "Alice" in html
def test_render_escapes_html_in_content(agent: PdfCreateAgent) -> None:
doc = GeneratedDocument(
title="XSS Test",
sections=[TextSection(body="<script>alert('xss')</script>")],
)
html = agent._render(doc)
assert "<script>" not in html
assert "&lt;script&gt;" in html
def test_render_total_row_present(agent: PdfCreateAgent) -> None:
doc = GeneratedDocument(
title="Table",
sections=[
LineItemsSection(
columns=["Item", "Total"],
rows=[["Widget", "$10"]],
total_row=["Total", "$10"],
)
],
)
html = agent._render(doc)
assert "total-row" in html
def test_render_no_total_row_skips_tfoot(agent: PdfCreateAgent) -> None:
doc = GeneratedDocument(
title="Table",
sections=[LineItemsSection(columns=["Item"], rows=[["Widget"]])],
)
html = agent._render(doc)
assert "<tfoot>" not in html
def test_render_subtitle_and_reference(agent: PdfCreateAgent) -> None:
doc = GeneratedDocument(
title="My Doc",
subtitle="Subtitle Here",
reference_number="REF-42",
sections=[TextSection(body="Content.")],
)
html = agent._render(doc)
assert "Subtitle Here" in html
assert "REF-42" in html
# ── _safe_filename ────────────────────────────────────────────────────────────────────────────────
@@ -396,8 +291,9 @@ async def test_orchestrate_returns_plan_step(agent: PdfCreateAgent) -> None:
assert step.tool == AgentToolId.CREATE_PDF_FROM_HTML_AGENT
assert isinstance(step.parameters, CreatePdfFromHtmlAgentParams)
assert step.parameters.filename.endswith(".pdf")
assert "<!DOCTYPE html>" in step.parameters.html_content
assert "Invoice" in step.parameters.html_content
parsed = json.loads(step.parameters.document)
assert parsed["title"] == "Invoice"
assert parsed["sections"]
@pytest.mark.anyio
@@ -468,9 +364,9 @@ async def test_orchestrate_assembles_multiple_chunks(agent: PdfCreateAgent) -> N
result = await agent.orchestrate(_orchestrator_request("Create a multi-chunk doc"))
assert isinstance(result, EditPlanResponse)
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
assert "Introduction text." in html
assert "Details" in html
document = result.steps[0].parameters.document # type: ignore[union-attr]
assert "Introduction text." in document
assert "Details" in document
# ── Style inference ───────────────────────────────────────────────────────────────────────────────
@@ -482,7 +378,7 @@ async def test_orchestrate_applies_planner_inferred_style(agent: PdfCreateAgent)
meta = DocumentMeta(
title="Styled Doc",
tone_brief="Professional.",
style_primary_color="magenta",
style_primary_color="#ff00ff",
)
sections = _simple_sections()
written = _written_sections()
@@ -499,50 +395,35 @@ async def test_orchestrate_applies_planner_inferred_style(agent: PdfCreateAgent)
result = await agent.orchestrate(_orchestrator_request("Make an invoice, magenta styling"))
assert isinstance(result, EditPlanResponse)
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
assert "magenta" in html
document = result.steps[0].parameters.document # type: ignore[union-attr]
assert json.loads(document)["style"]["primaryColor"] == "#ff00ff"
def test_render_applies_style(agent: PdfCreateAgent) -> None:
"""DocumentStyle fields are injected as CSS custom properties in the rendered HTML."""
doc = GeneratedDocument(
title="Styled",
sections=[TextSection(body="Content.")],
style=DocumentStyle(primary_color="magenta", background_color="#111111"),
)
html = agent._render(doc)
assert "--color-primary: magenta" in html
assert "--color-bg: #111111" in html
def test_document_style_drops_unsafe_colors() -> None:
"""Unsafe colours (not named/hex) are dropped, closing the <style> url() injection."""
safe = DocumentStyle(primary_color="navy", background_color="#1e3a5f", body_text_color="#fff")
def test_document_style_keeps_only_six_digit_hex() -> None:
"""Only #RRGGBB hex is kept; named colours and other formats drop to None."""
safe = DocumentStyle(primary_color="#1e3a5f", background_color="#ffffff", body_text_color="#1A1A1A")
assert (safe.primary_color, safe.background_color, safe.body_text_color) == (
"navy",
"#1e3a5f",
"#fff",
"#ffffff",
"#1A1A1A",
)
unsafe = DocumentStyle(
primary_color="red; background: url(http://evil.test/steal)",
background_color="expression(alert(1))",
body_text_color="navy; }",
)
assert unsafe.primary_color is None
assert unsafe.background_color is None
assert unsafe.body_text_color is None
assert DocumentStyle(primary_color="navy").primary_color is None
assert DocumentStyle(primary_color="#fff").primary_color is None
assert DocumentStyle(primary_color="#1e3a5f00").primary_color is None
assert DocumentStyle(primary_color="rgb(255, 0, 0)").primary_color is None
assert DocumentStyle(background_color="teal darken-2").background_color is None
# A trailing newline must not slip a value through (fullmatch, not $-before-newline).
assert DocumentStyle(primary_color="navy\n").primary_color is None
assert DocumentStyle(primary_color="#1e3a5f\n").primary_color is None
@pytest.mark.anyio
async def test_orchestrate_drops_unsafe_planner_color(agent: PdfCreateAgent) -> None:
"""An unsafe colour inferred by the meta planner never reaches the rendered HTML."""
async def test_orchestrate_drops_non_hex_planner_colour(agent: PdfCreateAgent) -> None:
"""A non-hex colour inferred by the meta planner is dropped before the document is emitted."""
meta = DocumentMeta(
title="Doc",
tone_brief="Professional.",
style_primary_color="blue; background: url(http://evil.test/)",
style_primary_color="rgb(0, 0, 255)",
)
sections = _simple_sections()
written = _written_sections()
@@ -559,6 +440,6 @@ async def test_orchestrate_drops_unsafe_planner_color(agent: PdfCreateAgent) ->
result = await agent.orchestrate(_orchestrator_request("make it blue"))
assert isinstance(result, EditPlanResponse)
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
assert "evil.test" not in html
assert "url(" not in html
document = result.steps[0].parameters.document # type: ignore[union-attr]
assert "rgb(" not in document
assert json.loads(document)["style"]["primaryColor"] is None
-2
View File
@@ -604,7 +604,6 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "jinja2" },
{ name = "opentelemetry-sdk" },
{ name = "pgvector" },
{ name = "posthog" },
@@ -631,7 +630,6 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.116.0" },
{ name = "jinja2", specifier = ">=3.1.0" },
{ name = "opentelemetry-sdk", specifier = ">=1.39.0" },
{ name = "pgvector", specifier = ">=0.3.6" },
{ name = "posthog", specifier = ">=3.0.0" },
@@ -3608,6 +3608,8 @@ lastSynced = "Last synced"
leaveShare = "Remove from my list"
leaveShareFailed = "Could not remove the shared file."
leaveShareSuccess = "Removed from your shared list."
loadFailed = "Couldn't load your recent files"
loadFailedRetry = "Try again"
loadingFiles = "Loading files..."
localOnly = "Local only"
makeCopy = "Make a copy"
@@ -3734,20 +3736,9 @@ 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”."
reset = "Show all"
subtitle = "Show or hide categories in the files sidebar."
title = "Sidebar categories"
[filesPage]
@@ -6014,31 +6005,11 @@ summaryMore = "{{first}}, {{second}} and {{more}} more"
summaryTwo = "{{first}} and {{second}}"
[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"
categoryCount = "categories"
hideCategory = "Hide category"
labelCount = "labels"
sharedNote = "These labels are built in and shared across your whole team."
showCategory = "Show category"
[policies.pii]
account = "Account numbers (labelled)"
@@ -6951,31 +6922,31 @@ subtitle = "Deployments, credentials, security posture, storage, and the audit t
title = "Infrastructure"
# Fixed-enum label maps rendered via t(MAP[value]) in the infrastructure tabs.
[portal.infrastructure.apiKeyPermission]
admin = "Admin"
read = "Read"
write = "Write"
[portal.infrastructure.apiKeys]
createKey = "Create key"
heading = "API keys"
subheading = "Personal credentials, each with its own usage tracking."
subheading = "Scoped credentials with per-key rate limits, permissions, and IP allowlists."
[portal.infrastructure.apiKeys.card]
allowedIps = "Allowed IPs"
anyIp = "Any IP (no allowlist)"
created = "Created"
lastUsed = "Last used"
revoke = "Revoke key"
permissions = "Permissions"
rateLimit = "Rate limit"
rateLimitValue = "{{value}} req/min"
usageMonth = "Usage this month"
usageToday = "Usage today"
[portal.infrastructure.apiKeys.empty]
description = "Create a key to start calling the Stirling API."
description = "Create a scoped key to start calling the Stirling API."
title = "No API keys yet"
[portal.infrastructure.apiKeys.error]
load = "Couldn't load your API keys. Please try again."
[portal.infrastructure.apiKeys.revoke]
body = "Revoke \"{{name}}\"? Any integration still using this key will immediately start receiving 401 errors. This can't be undone."
cancel = "Cancel"
confirm = "Revoke key"
title = "Revoke API key"
[portal.infrastructure.attestationLabel]
attested = "Attested"
inScope = "In scope"
@@ -7061,11 +7032,14 @@ notStarted = "Not started"
cancel = "Cancel"
createKey = "Create key"
done = "Done"
ipAllowlistHelper = "Comma-separated CIDR ranges. Leave blank to allow any IP."
ipAllowlistLabel = "IP allowlist"
keyNameLabel = "Key name"
keyNamePlaceholder = "e.g. Production ingest"
keyNamePlaceholder = "e.g. Production · ingest"
permissionsLabel = "Permissions"
secretKeyCaption = "Secret key"
secretWarning = "Store this in a secrets manager. Stirling only ever stores a hash, so there is no way to recover it later."
subtitle = "Give the key a name so you can recognise it later. You can revoke it at any time."
secretWarning = "Store this in a secrets manager. Stirling only ever stores a hash — there is no way to recover it later."
subtitle = "Scope the key to the minimum it needs. You can rotate or revoke at any time."
subtitleCreated = "Copy this secret now — it won't be shown again."
title = "Create API key"
titleCreated = "Key created"
@@ -7114,6 +7088,7 @@ title = "No regions deployed"
[portal.infrastructure.keyLabel]
active = "Active"
revoked = "Revoked"
rotateSoon = "Rotate soon"
[portal.infrastructure.modelLabel]
active = "Active"
@@ -7447,6 +7422,11 @@ title = "Policies"
[portal.policies.card]
comingSoon = "Upgrade to Enterprise"
notSetUp = "Not set up"
requiresAiEngine = "Requires AI engine"
[portal.policies.categories.classification]
desc = "Identify each document's type on upload and tag its metadata for filing and search."
label = "Classification"
[portal.policies.categories.compliance]
desc = "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document."
@@ -7471,6 +7451,13 @@ label = "Security"
[portal.policies.config]
scopeAll = "All documents"
[portal.policies.config.classification]
summary = "Classifies every uploaded document against your team's labels and tags its metadata."
[portal.policies.config.classification.rules]
0 = "Classify"
1 = "Tag metadata"
[portal.policies.config.compliance]
summary = "Validates documents against regulatory frameworks before they leave the system."
@@ -7565,6 +7552,7 @@ title = "No activity yet"
[portal.policies.endpoints]
addWatermark = "Watermark"
autoRedact = "Redact PII"
classifyAndLabel = "Classify"
compressPdf = "Compress"
flatten = "Flatten"
ocrPdf = "OCR"
@@ -7607,6 +7595,10 @@ continue = "Continue"
enablePolicy = "Enable policy"
saveChanges = "Save changes"
[portal.policies.wizard.capability.classify]
desc = "Identifies the document's type from your team's labels and tags it, so it files and searches by category."
label = "Classify the document"
[portal.policies.wizard.capability.compress]
desc = "Compresses the document to a smaller file size."
label = "Reduce file size"
@@ -7631,6 +7623,10 @@ label = "Strip active content"
desc = "Stamps a visible mark (e.g. “Confidential”) across every page."
label = "Apply a watermark"
[portal.policies.wizard.classification]
description = "Every uploaded document is classified against the built-in labels and tagged with the types that fit. The label set is shared across your whole team."
labelsHeading = "Classification labels"
[portal.policies.wizard.errors]
noTools = "Enable at least one tool in the workflow first."
saveFailed = "Couldn't save the policy. Please try again."
@@ -8018,7 +8014,6 @@ title = "Sources"
[portal.sources.actions]
agentBuilder = "Agent Builder"
connectSource = "Connect source"
createApiKey = "Create API key"
[portal.sources.builder]
back = "Back to sources"
@@ -42,7 +42,8 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const [isDragging, setIsDragging] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
const { loadRecentFiles, handleRemoveFile, loading, loadError } =
useFileManager();
const { actions: fileActions } = useFileActions();
// Get active file IDs from FileContext to show which files are already loaded
@@ -240,6 +241,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
modalHeight={modalHeight}
refreshRecentFiles={refreshRecentFiles}
isLoading={loading}
loadError={loadError}
activeFileIds={activeFileIds}
>
{isMobile ? <MobileLayout /> : <DesktopLayout />}
@@ -0,0 +1,131 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { MantineProvider } from "@mantine/core";
// Mock i18n so assertions can rely on the English default strings.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
}),
}));
// Isolate FileListArea's branching logic from its heavy children — we only
// care which state it renders, not how each child looks.
vi.mock("@app/components/fileManager/EmptyFilesState", () => ({
default: () => <div>No recent files</div>,
}));
vi.mock("@app/components/fileManager/FileListItem", () => ({
default: ({ file }: { file: { name: string } }) => <div>{file.name}</div>,
}));
vi.mock("@app/components/fileManager/FileHistoryGroup", () => ({
default: () => null,
}));
vi.mock("@app/ui/Button", () => ({
Button: ({
children,
onClick,
}: {
children: React.ReactNode;
onClick?: () => void;
}) => <button onClick={onClick}>{children}</button>,
}));
const contextValue = vi.hoisted(() => ({
current: {} as Record<string, unknown>,
}));
vi.mock("@app/contexts/FileManagerContext", () => ({
useFileManagerContext: () => contextValue.current,
}));
import FileListArea from "@app/components/fileManager/FileListArea";
function baseContext(overrides: Record<string, unknown>) {
return {
activeSource: "recent",
recentFiles: [],
filteredFiles: [],
selectedFilesSet: new Set(),
expandedFileIds: new Set(),
loadedHistoryFiles: new Map(),
onFileSelect: vi.fn(),
onFileRemove: vi.fn(),
onHistoryFileRemove: vi.fn(),
onFileDoubleClick: vi.fn(),
onDownloadSingle: vi.fn(),
isLoading: false,
loadError: false,
activeFileIds: [],
refreshRecentFiles: vi.fn(),
...overrides,
};
}
describe("FileListArea recent-files states", () => {
beforeEach(() => {
contextValue.current = baseContext({});
});
it("shows the empty state when there are no files and no error", () => {
contextValue.current = baseContext({ isLoading: false, loadError: false });
render(
<MantineProvider>
<FileListArea scrollAreaHeight="200px" />
</MantineProvider>,
);
expect(screen.getByText("No recent files")).toBeInTheDocument();
expect(screen.queryByText("Try again")).not.toBeInTheDocument();
});
it("shows the loading state while loading with no files yet", () => {
contextValue.current = baseContext({ isLoading: true });
render(
<MantineProvider>
<FileListArea scrollAreaHeight="200px" />
</MantineProvider>,
);
expect(screen.getByText("Loading files...")).toBeInTheDocument();
expect(screen.queryByText("No recent files")).not.toBeInTheDocument();
});
it("shows an error state with a retry action when the load fails (not the empty state)", () => {
const refreshRecentFiles = vi.fn();
contextValue.current = baseContext({
isLoading: false,
loadError: true,
refreshRecentFiles,
});
render(
<MantineProvider>
<FileListArea scrollAreaHeight="200px" />
</MantineProvider>,
);
// The failed load must be visually distinct from a genuinely empty history.
expect(
screen.getByText("Couldn't load your recent files"),
).toBeInTheDocument();
expect(screen.queryByText("No recent files")).not.toBeInTheDocument();
const retry = screen.getByText("Try again");
fireEvent.click(retry);
expect(refreshRecentFiles).toHaveBeenCalledTimes(1);
});
it("renders the file list when files are present even if a partial load error occurred", () => {
contextValue.current = baseContext({
recentFiles: [{ id: "a" }],
filteredFiles: [{ id: "a", name: "doc.pdf" }],
loadError: true,
});
render(
<MantineProvider>
<FileListArea scrollAreaHeight="200px" />
</MantineProvider>,
);
expect(screen.getByText("doc.pdf")).toBeInTheDocument();
expect(
screen.queryByText("Couldn't load your recent files"),
).not.toBeInTheDocument();
expect(screen.queryByText("No recent files")).not.toBeInTheDocument();
});
});
@@ -1,10 +1,12 @@
import React from "react";
import { Center, ScrollArea, Text, Stack } from "@mantine/core";
import CloudIcon from "@mui/icons-material/Cloud";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlineRounded";
import { useTranslation } from "react-i18next";
import FileListItem from "@app/components/fileManager/FileListItem";
import FileHistoryGroup from "@app/components/fileManager/FileHistoryGroup";
import EmptyFilesState from "@app/components/fileManager/EmptyFilesState";
import { Button } from "@app/ui/Button";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
interface FileListAreaProps {
@@ -29,7 +31,9 @@ const FileListArea: React.FC<FileListAreaProps> = ({
onFileDoubleClick,
onDownloadSingle,
isLoading,
loadError,
activeFileIds,
refreshRecentFiles,
} = useFileManagerContext();
const { t } = useTranslation();
@@ -44,15 +48,7 @@ const FileListArea: React.FC<FileListAreaProps> = ({
scrollbarSize={8}
>
<Stack gap={0}>
{recentFiles.length === 0 && !isLoading ? (
<EmptyFilesState />
) : recentFiles.length === 0 && isLoading ? (
<Center style={{ height: "12.5rem" }}>
<Text c="dimmed" ta="center">
{t("fileManager.loadingFiles", "Loading files...")}
</Text>
</Center>
) : (
{recentFiles.length > 0 ? (
filteredFiles.map((file, index) => {
// All files in filteredFiles are now leaf files only
const historyFiles = loadedHistoryFiles.get(file.id) || [];
@@ -84,6 +80,39 @@ const FileListArea: React.FC<FileListAreaProps> = ({
</React.Fragment>
);
})
) : isLoading ? (
<Center style={{ height: "12.5rem" }}>
<Text c="dimmed" ta="center">
{t("fileManager.loadingFiles", "Loading files...")}
</Text>
</Center>
) : loadError ? (
<Center style={{ height: "12.5rem" }}>
<Stack align="center" gap="sm">
<ErrorOutlineIcon
style={{
fontSize: "3rem",
color: "var(--mantine-color-gray-5)",
}}
/>
<Text c="dimmed" ta="center">
{t(
"fileManager.loadFailed",
"Couldn't load your recent files",
)}
</Text>
<Button
variant="secondary"
onClick={() => {
void refreshRecentFiles();
}}
>
{t("fileManager.loadFailedRetry", "Try again")}
</Button>
</Stack>
</Center>
) : (
<EmptyFilesState />
)}
</Stack>
</ScrollArea>
@@ -0,0 +1,36 @@
// Shared source of truth for a policy category's outline icon, keyed by category
// id (not a parallel icon-name vocabulary). Used by the editor's policy
// definitions and the portal's catalogue cards, summaries, and setup wizard.
import type { ReactNode } from "react";
import type { SxProps, Theme } from "@mui/material";
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import LabelOutlinedIcon from "@mui/icons-material/LabelOutlined";
import CheckCircleOutlinedIcon from "@mui/icons-material/CheckCircleOutlined";
import AltRouteOutlinedIcon from "@mui/icons-material/AltRouteOutlined";
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
type MuiIcon = React.ComponentType<{ sx?: SxProps<Theme>; className?: string }>;
/** Policy category id → outline glyph. */
const POLICY_CATEGORY_ICONS: Record<string, MuiIcon> = {
ingestion: LayersOutlinedIcon,
security: ShieldOutlinedIcon,
classification: LabelOutlinedIcon,
compliance: CheckCircleOutlinedIcon,
routing: AltRouteOutlinedIcon,
retention: ScheduleOutlinedIcon,
};
const FALLBACK_ICON = LabelOutlinedIcon;
// Defaults to inheriting the surrounding font-size so a wrapping box controls size.
export function policyCategoryIcon(
categoryId: string,
sx: SxProps<Theme> = { fontSize: "inherit" },
className?: string,
): ReactNode {
const Icon = POLICY_CATEGORY_ICONS[categoryId] ?? FALLBACK_ICON;
return <Icon sx={sx} className={className} />;
}
@@ -42,6 +42,7 @@ interface FileManagerContextValue {
fileGroups: Map<FileId, StirlingFileStub[]>;
loadedHistoryFiles: Map<FileId, StirlingFileStub[]>;
isLoading: boolean;
loadError: boolean;
activeFileIds: FileId[];
// Handlers
@@ -96,6 +97,7 @@ interface FileManagerProviderProps {
modalHeight: string;
refreshRecentFiles: () => Promise<void>;
isLoading: boolean;
loadError: boolean;
activeFileIds: FileId[];
maxSelectable?: number | null;
}
@@ -115,6 +117,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
modalHeight,
refreshRecentFiles,
isLoading,
loadError,
activeFileIds,
maxSelectable = null,
}) => {
@@ -1089,6 +1092,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
fileGroups,
loadedHistoryFiles,
isLoading,
loadError,
activeFileIds,
// Handlers
@@ -1131,6 +1135,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
fileGroups,
loadedHistoryFiles,
isLoading,
loadError,
activeFileIds,
handleSourceChange,
handleStorageFilterChange,
@@ -5,6 +5,7 @@ import { StirlingFileStub, StirlingFile } from "@app/types/fileContext";
import { FileId } from "@app/types/fileContext";
import apiClient from "@app/services/apiClient";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { captureException } from "@app/services/analytics";
interface StoredFileResponse {
id: number;
@@ -33,6 +34,7 @@ interface AccessedShareLinkResponse {
export const useFileManager = () => {
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState(false);
const indexedDB = useIndexedDB();
const { config } = useAppConfig();
@@ -99,6 +101,11 @@ export const useFileManager = () => {
const loadRecentFiles = useCallback(async (): Promise<StirlingFileStub[]> => {
setLoading(true);
setLoadError(false);
// Track whether any part of the load failed so callers can distinguish a
// failed/partial load from a genuinely empty history instead of silently
// falling through to an empty list.
let encounteredError = false;
try {
if (!indexedDB) {
return [];
@@ -122,6 +129,10 @@ export const useFileManager = () => {
{
suppressErrorToast: true,
skipAuthRedirect: true,
// Metadata listing, not a file transfer — bound the wait so a
// stalled connection surfaces as an error/retry instead of an
// indefinite "Loading files..." spinner.
timeout: 30000,
} as any,
);
const serverFiles = Array.isArray(response.data) ? response.data : [];
@@ -242,6 +253,8 @@ export const useFileManager = () => {
combinedStubs = [...updatedLocalStubs, ...serverStubs];
} catch (error) {
encounteredError = true;
captureException(error, { context: "loadRecentFiles.serverFiles" });
console.warn("Failed to load server files:", error);
}
@@ -252,6 +265,8 @@ export const useFileManager = () => {
>("/api/v1/storage/share-links/accessed", {
suppressErrorToast: true,
skipAuthRedirect: true,
// Bound the wait — see server-files fetch above.
timeout: 30000,
} as any);
const sharedLinks = Array.isArray(sharedResponse.data)
? sharedResponse.data
@@ -346,6 +361,8 @@ export const useFileManager = () => {
combinedStubs = [...combinedStubs, ...sharedStubs];
} catch (error) {
encounteredError = true;
captureException(error, { context: "loadRecentFiles.shareLinks" });
console.warn("Failed to load shared links:", error);
}
}
@@ -358,9 +375,12 @@ export const useFileManager = () => {
return sortedFiles;
} catch (error) {
encounteredError = true;
captureException(error, { context: "loadRecentFiles" });
console.error("Failed to load recent files:", error);
return [];
} finally {
setLoadError(encounteredError);
setLoading(false);
}
}, [
@@ -505,6 +525,7 @@ export const useFileManager = () => {
return {
loading,
loadError,
convertToFile,
loadRecentFiles,
handleRemoveFile,
@@ -38,3 +38,21 @@ export function trackEditorOperation(toolId: string, fileCount: number): void {
if (DEV) console.warn("[analytics] trackEditorOperation failed", error);
}
}
/**
* Report a caught error to PostHog so otherwise-swallowed failures become
* visible as `$exception` events instead of vanishing into a console.warn.
*/
export function captureException(
error: unknown,
context?: Record<string, unknown>,
): void {
try {
if (!canCapture()) return;
const normalized =
error instanceof Error ? error : new Error(String(error));
posthog.captureException(normalized, context);
} catch (captureError) {
if (DEV) console.warn("[analytics] captureException failed", captureError);
}
}
-57
View File
@@ -1,57 +0,0 @@
/* Shared classification-label pill (team labels editor + sidebar category manager). */
.sui-labelchip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 0.2rem 0.3rem 0.2rem 0.25rem;
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
background: var(--color-surface);
max-width: 16rem;
}
.sui-labelchip-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
color: var(--color-text-2);
}
.sui-labelchip-name {
font-size: 0.8125rem;
color: var(--color-text-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sui-labelchip-count {
font-size: 0.6875rem;
font-weight: 500;
color: var(--color-text-3);
padding-left: 0.1rem;
}
.sui-labelchip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border: none;
border-radius: 999px;
background: transparent;
color: var(--color-text-3);
cursor: pointer;
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.sui-labelchip-remove:hover {
background: var(--color-bg-hover);
color: var(--color-red);
}
@@ -1,52 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LabelChip } from "@app/ui/LabelChip";
const meta: Meta<typeof LabelChip> = {
title: "Primitives/LabelChip",
component: LabelChip,
tags: ["autodocs"],
parameters: { layout: "centered" },
args: { label: "Invoice", icon: "receipt-long" },
argTypes: {
label: { control: "text" },
icon: { control: "text" },
count: { control: "number" },
onRemove: { action: "removed" },
},
};
export default meta;
type Story = StoryObj<typeof LabelChip>;
/** The classification-label pill shared by the labels editor and the sidebar category manager. */
export const Playground: Story = {};
/** With a file count, as shown in the sidebar category manager. */
export const WithCount: Story = {
args: { label: "Contract", icon: "handshake", count: 12 },
};
/** Removable — the trailing × appears when `onRemove` is set. */
export const Removable: Story = {
args: { label: "NDA", icon: "lock", onRemove: () => {} },
};
/** Falls back to the default "sell" icon when none is given. */
export const DefaultIcon: Story = {
args: { label: "Uncategorised label", icon: undefined },
};
/** Long names truncate rather than overflow the pill. */
export const LongName: Story = {
args: { label: "Memorandum of understanding and mutual agreement", count: 3 },
};
export const Row: Story = {
render: () => (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", maxWidth: 420 }}>
<LabelChip label="Invoice" icon="receipt-long" count={12} />
<LabelChip label="Contract" icon="handshake" onRemove={() => {}} />
<LabelChip label="Lab report" icon="science" count={2} />
<LabelChip label="Payslip" icon="payments" />
</div>
),
};
-60
View File
@@ -1,60 +0,0 @@
import type { ReactNode } from "react";
import CloseIcon from "@mui/icons-material/Close";
import { LocalIcon } from "@app/components/shared/LocalIcon";
import "@app/ui/LabelChip.css";
export interface LabelChipProps {
/** The label text. */
label: string;
/** Material Symbols key for a leading icon; ignored when `leading` is given. */
icon?: string;
/** Custom leading node (e.g. an icon picker), overriding `icon`. */
leading?: ReactNode;
/** Optional trailing count (e.g. how many files carry this label). */
count?: number;
/** Show a trailing `×`; called on click. */
onRemove?: () => void;
/** Accessible name for the remove button. */
removeAriaLabel?: string;
}
/**
* A classification-label pill: leading icon (or a custom control like an icon
* picker) + name, with an optional count and remove button. The shared look for
* every place labels are shown as chips — the team labels editor and the
* sidebar category manager both render this so they stay visually identical.
*/
export function LabelChip({
label,
icon,
leading,
count,
onRemove,
removeAriaLabel,
}: LabelChipProps) {
return (
<span className="sui-labelchip" role="listitem">
{leading ?? (
<span className="sui-labelchip-icon">
<LocalIcon icon={icon || "sell"} width="1rem" />
</span>
)}
<span className="sui-labelchip-name" title={label}>
{label}
</span>
{count != null && count > 0 && (
<span className="sui-labelchip-count">{count}</span>
)}
{onRemove && (
<button
type="button"
className="sui-labelchip-remove"
onClick={onRemove}
aria-label={removeAriaLabel ?? `Remove ${label}`}
>
<CloseIcon sx={{ fontSize: "0.85rem" }} />
</button>
)}
</span>
);
}
@@ -43,33 +43,23 @@ export interface RecentDeployment {
/* API Keys */
/* ──────────────────────────────────────────────────────────────────────── */
export type ApiKeyStatus = "active" | "revoked";
export type ApiKeyStatus = "active" | "revoked" | "rotate-soon";
export type ApiKeyPermission = "Read" | "Write" | "Admin";
export interface ApiKey {
id: string;
name: string;
/** Non-secret leading fragment, e.g. "sk_a3f81b2c". */
/** Masked prefix shown in the list, e.g. "sk_live_a3f8…". */
prefix: string;
created: string;
/** Formatted last-use time, or "Never". */
lastUsed: string;
status: ApiKeyStatus;
/** Requests made today (UTC). */
/** Requests/min ceiling. */
rateLimit: number;
permissions: ApiKeyPermission[];
allowedIps: string[];
usageToday: number;
/** Requests in the trailing 30 days. */
usageMonth: number;
/** Lifetime request count. */
usageTotal: number;
}
export interface ApiKeysResponse {
keys: ApiKey[];
}
/** Returned once on creation: the listed row plus the plaintext secret, shown once. */
export interface CreatedApiKey {
key: ApiKey;
secret: string;
}
/* ──────────────────────────────────────────────────────────────────────── */
@@ -288,34 +278,11 @@ export async function fetchDeployments(
);
}
const API_KEYS_PATH = "/api/v1/proprietary/ui-data/infrastructure/api-keys";
/** GET the caller's personal API keys; SaaS or local, scoped server-side per user. */
export async function fetchApiKeys(): Promise<ApiKeysResponse> {
return apiClient.saas.isConfigured()
? apiClient.saas.json<ApiKeysResponse>(API_KEYS_PATH)
: apiClient.local.json<ApiKeysResponse>(API_KEYS_PATH);
}
/** POST a new key; the response carries the one-time secret. */
export async function createApiKey(body: {
name: string;
}): Promise<CreatedApiKey> {
const opts = { method: "POST" as const, body };
return apiClient.saas.isConfigured()
? apiClient.saas.json<CreatedApiKey>(API_KEYS_PATH, opts)
: apiClient.local.json<CreatedApiKey>(API_KEYS_PATH, opts);
}
/** DELETE (revoke) a key the caller owns. */
export async function revokeApiKey(id: string): Promise<void> {
const path = `${API_KEYS_PATH}/${encodeURIComponent(id)}`;
const opts = { method: "DELETE" as const };
if (apiClient.saas.isConfigured()) {
await apiClient.saas.json<void>(path, opts);
} else {
await apiClient.local.json<void>(path, opts);
}
/** GET /v1/infrastructure/api-keys?tier=… */
export async function fetchApiKeys(tier: Tier): Promise<ApiKey[]> {
return apiClient.local.json<ApiKey[]>(
`/v1/infrastructure/api-keys${q(tier)}`,
);
}
/** GET /v1/infrastructure/security?tier=… */
+28 -8
View File
@@ -56,11 +56,11 @@ export interface PolicyField {
export interface PolicyCategory {
id: string;
label: string;
icon: string;
tone: "neutral" | "blue" | "purple" | "green" | "amber" | "red";
desc: string;
providesClassification?: boolean;
comingSoon?: boolean;
requiresAiEngine?: boolean;
}
export interface PolicyConfigDef {
@@ -133,14 +133,21 @@ export interface CatalogueEntry {
/* Endpoint display labels */
/* ──────────────────────────────────────────────────────────────────────── */
/** i18n keys keyed by {@link ToolEndpoint}; labels stored steps in the detail view. */
export const ENDPOINT_LABELS: Partial<Record<ToolEndpoint, string>> = {
/**
* i18n keys keyed by endpoint; labels stored steps in the detail view. Mostly
* {@link ToolEndpoint}s, plus the AI classify endpoint, which isn't part of the generated union.
*/
export const ENDPOINT_LABELS: Partial<
Record<ToolEndpoint | "/api/v1/ai/tools/classify-and-label", string>
> = {
"/api/v1/security/auto-redact": "portal.policies.endpoints.autoRedact",
"/api/v1/security/sanitize-pdf": "portal.policies.endpoints.sanitizePdf",
"/api/v1/security/add-watermark": "portal.policies.endpoints.addWatermark",
"/api/v1/misc/ocr-pdf": "portal.policies.endpoints.ocrPdf",
"/api/v1/misc/flatten": "portal.policies.endpoints.flatten",
"/api/v1/misc/compress-pdf": "portal.policies.endpoints.compressPdf",
"/api/v1/ai/tools/classify-and-label":
"portal.policies.endpoints.classifyAndLabel",
};
export function humanizeEndpoint(
@@ -170,7 +177,6 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
{
id: "ingestion",
label: "portal.policies.categories.ingestion.label",
icon: "layers",
tone: "blue",
desc: "portal.policies.categories.ingestion.desc",
providesClassification: true,
@@ -179,14 +185,20 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
{
id: "security",
label: "portal.policies.categories.security.label",
icon: "shield",
tone: "purple",
desc: "portal.policies.categories.security.desc",
},
{
id: "classification",
label: "portal.policies.categories.classification.label",
tone: "blue",
desc: "portal.policies.categories.classification.desc",
providesClassification: true,
requiresAiEngine: true,
},
{
id: "compliance",
label: "portal.policies.categories.compliance.label",
icon: "check",
tone: "amber",
desc: "portal.policies.categories.compliance.desc",
comingSoon: true,
@@ -194,7 +206,6 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
{
id: "routing",
label: "portal.policies.categories.routing.label",
icon: "route",
tone: "green",
desc: "portal.policies.categories.routing.desc",
comingSoon: true,
@@ -202,7 +213,6 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
{
id: "retention",
label: "portal.policies.categories.retention.label",
icon: "schedule",
tone: "neutral",
desc: "portal.policies.categories.retention.desc",
comingSoon: true,
@@ -264,6 +274,16 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
],
fields: [],
},
classification: {
summary: "portal.policies.config.classification.summary",
rules: [
"portal.policies.config.classification.rules.0",
"portal.policies.config.classification.rules.1",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [policyStep("classify")],
fields: [],
},
compliance: {
summary: "portal.policies.config.compliance.summary",
rules: [
@@ -15,7 +15,7 @@ import {
type CatalogueEntry,
type PoliciesResponse,
} from "@portal/api/policies";
import { policyIcon } from "@portal/components/policies/policyIcons";
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
import "@portal/components/PolicySummary.css";
/**
@@ -64,7 +64,7 @@ export function PolicySummary() {
render: ({ entry }) => (
<div className="portal-policysum__cat">
<span className="portal-policysum__icon" aria-hidden>
{policyIcon(entry.category.icon)}
{policyCategoryIcon(entry.category.id)}
</span>
<div className="portal-policysum__cat-text">
<strong>{t(entry.category.label)}</strong>
@@ -6,20 +6,21 @@ import "@portal/views/Infrastructure.css";
const BASE: ApiKey = {
id: "key-1",
name: "Production · ingest",
prefix: "sk_a3f81b2c",
created: "2026-03-02",
lastUsed: "2026-07-10 09:14",
prefix: "sk_live_a3f8…",
created: "Mar 2, 2026",
lastUsed: "2m ago",
status: "active",
rateLimit: 1200,
permissions: ["Read", "Write"],
allowedIps: ["52.14.0.0/16", "18.221.0.0/16"],
usageToday: 84210,
usageMonth: 2410933,
usageTotal: 9820145,
};
const meta: Meta<typeof ApiKeyCard> = {
title: "Portal/Infrastructure/ApiKeyCard",
component: ApiKeyCard,
parameters: { layout: "padded" },
args: { onRevoke: (key: ApiKey) => console.log("revoke", key.id) },
decorators: [
(S) => (
<div style={{ maxWidth: "44rem" }}>
@@ -31,18 +32,37 @@ const meta: Meta<typeof ApiKeyCard> = {
export default meta;
type Story = StoryObj<typeof ApiKeyCard>;
export const Personal: Story = { args: { apiKey: BASE } };
export const Active: Story = { args: { apiKey: BASE } };
export const RotateSoon: Story = {
args: {
apiKey: {
...BASE,
name: "Ops · admin (legacy)",
status: "rotate-soon",
permissions: ["Read", "Write", "Admin"],
allowedIps: ["203.0.113.7/32"],
usageToday: 0,
},
},
};
export const Revoked: Story = {
args: {
apiKey: {
...BASE,
name: "Sandbox · webhook tester",
prefix: "sk_2c4a91de",
prefix: "sk_test_2c4a…",
status: "revoked",
lastUsed: "Never",
lastUsed: "never",
permissions: ["Read"],
allowedIps: [],
usageToday: 0,
usageMonth: 0,
},
},
};
export const NoIpAllowlist: Story = {
args: { apiKey: { ...BASE, allowedIps: [] } },
};
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Button, Card, StatusBadge } from "@app/ui";
import { Button, Card, Chip, StatusBadge } from "@app/ui";
import { useTranslation } from "react-i18next";
import type { ApiKey } from "@portal/api/infrastructure";
import {
@@ -8,17 +8,9 @@ import {
} from "@portal/components/infrastructure/infraFormat";
/** Collapsible row for a single API key: header summary + expandable detail grid. */
export function ApiKeyCard({
apiKey,
onRevoke,
}: {
apiKey: ApiKey;
/** Ask to revoke this key; the parent confirms before the destructive call. */
onRevoke: (key: ApiKey) => void;
}) {
export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const revocable = apiKey.status === "active";
return (
<Card padding="default" className="portal-infra__key">
<Button
@@ -59,6 +51,14 @@ export function ApiKeyCard({
<dt>{t("portal.infrastructure.apiKeys.card.lastUsed")}</dt>
<dd>{apiKey.lastUsed}</dd>
</div>
<div>
<dt>{t("portal.infrastructure.apiKeys.card.rateLimit")}</dt>
<dd className="portal-infra__mono">
{t("portal.infrastructure.apiKeys.card.rateLimitValue", {
value: apiKey.rateLimit.toLocaleString(),
})}
</dd>
</div>
<div>
<dt>{t("portal.infrastructure.apiKeys.card.usageToday")}</dt>
<dd className="portal-infra__mono">
@@ -71,20 +71,36 @@ export function ApiKeyCard({
{apiKey.usageMonth.toLocaleString()}
</dd>
</div>
</dl>
{revocable && (
<div className="portal-infra__modal-actions">
<Button
variant="secondary"
accent="danger"
size="sm"
onClick={() => onRevoke(apiKey)}
>
{t("portal.infrastructure.apiKeys.card.revoke")}
</Button>
<div>
<dt>{t("portal.infrastructure.apiKeys.card.permissions")}</dt>
<dd className="portal-infra__chips">
{apiKey.permissions.map((p) => (
<Chip key={p} size="sm">
{t(
`portal.infrastructure.apiKeyPermission.${p.toLowerCase()}`,
p,
)}
</Chip>
))}
</dd>
</div>
)}
<div className="portal-infra__kv-wide">
<dt>{t("portal.infrastructure.apiKeys.card.allowedIps")}</dt>
<dd className="portal-infra__chips">
{apiKey.allowedIps.length === 0 ? (
<span className="portal-infra__muted">
{t("portal.infrastructure.apiKeys.card.anyIp")}
</span>
) : (
apiKey.allowedIps.map((ip) => (
<Chip key={ip} size="sm">
<span className="portal-infra__mono">{ip}</span>
</Chip>
))
)}
</dd>
</div>
</dl>
</div>
)}
</Card>
@@ -20,19 +20,14 @@ type Story = StoryObj<typeof ApiKeysTab>;
export const Default: Story = {};
const EMPTY = { keys: [] };
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get(
"*/api/v1/proprietary/ui-data/infrastructure/api-keys",
async () => {
await delay("infinite");
return HttpResponse.json(EMPTY);
},
),
http.get("/v1/infrastructure/api-keys", async () => {
await delay("infinite");
return HttpResponse.json([]);
}),
],
},
},
@@ -42,9 +37,7 @@ export const Empty: Story = {
parameters: {
msw: {
handlers: [
http.get("*/api/v1/proprietary/ui-data/infrastructure/api-keys", () =>
HttpResponse.json(EMPTY),
),
http.get("/v1/infrastructure/api-keys", () => HttpResponse.json([])),
],
},
},
@@ -1,48 +1,20 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Banner, Button, EmptyState, Modal, Skeleton } from "@app/ui";
import { useAsync } from "@portal/hooks/useAsync";
import {
fetchApiKeys,
revokeApiKey,
type ApiKey,
type ApiKeysResponse,
} from "@portal/api/infrastructure";
import { errorMessage } from "@portal/api/http";
import { Button, EmptyState, Skeleton } from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import { fetchApiKeys, type ApiKey } from "@portal/api/infrastructure";
import { ApiKeyCard } from "@portal/components/infrastructure/ApiKeyCard";
import { CreateKeyModal } from "@portal/components/infrastructure/CreateKeyModal";
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
export function ApiKeysTab() {
const { t } = useTranslation();
const { tier } = useTier();
const [modalOpen, setModalOpen] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [error, setError] = useState<string | null>(null);
const [pendingRevoke, setPendingRevoke] = useState<ApiKey | null>(null);
const [revoking, setRevoking] = useState(false);
const state = useAsync<ApiKeysResponse>(() => fetchApiKeys(), [reloadKey]);
const { data, loading, error: loadError } = state;
const reload = () => setReloadKey((n) => n + 1);
const keys = data?.keys ?? [];
const isLoading = loading && data === null;
// A failed load must not masquerade as a genuinely empty list.
const isEmpty = !loading && !loadError && keys.length === 0;
async function confirmRevoke() {
if (!pendingRevoke) return;
setError(null);
setRevoking(true);
try {
await revokeApiKey(pendingRevoke.id);
setPendingRevoke(null);
reload();
} catch (e) {
setError(errorMessage(e));
} finally {
setRevoking(false);
}
}
const state = useAsync<ApiKey[]>(() => fetchApiKeys(tier), [tier]);
const { data: keys } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
return (
<div className="portal-infra__stack">
@@ -60,14 +32,6 @@ export function ApiKeysTab() {
</Button>
</div>
{error && <Banner tone="danger" description={error} />}
{!loading && loadError && (
<Banner
tone="danger"
description={t("portal.infrastructure.apiKeys.error.load")}
/>
)}
{isLoading && (
<div className="portal-infra__stack" aria-hidden>
{Array.from({ length: 3 }).map((_, i) => (
@@ -84,52 +48,15 @@ export function ApiKeysTab() {
/>
)}
{keys.length > 0 && (
{keys && keys.length > 0 && (
<div className="portal-infra__keys">
{keys.map((k) => (
<ApiKeyCard key={k.id} apiKey={k} onRevoke={setPendingRevoke} />
<ApiKeyCard key={k.id} apiKey={k} />
))}
</div>
)}
<CreateKeyModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onCreated={reload}
/>
<Modal
open={pendingRevoke !== null}
onClose={() => !revoking && setPendingRevoke(null)}
width="sm"
title={t("portal.infrastructure.apiKeys.revoke.title")}
footer={
<div className="portal-infra__modal-actions">
<Button
variant="tertiary"
size="sm"
disabled={revoking}
onClick={() => setPendingRevoke(null)}
>
{t("portal.infrastructure.apiKeys.revoke.cancel")}
</Button>
<Button
size="sm"
accent="danger"
loading={revoking}
onClick={confirmRevoke}
>
{t("portal.infrastructure.apiKeys.revoke.confirm")}
</Button>
</div>
}
>
<p>
{t("portal.infrastructure.apiKeys.revoke.body", {
name: pendingRevoke?.name ?? "",
})}
</p>
</Modal>
<CreateKeyModal open={modalOpen} onClose={() => setModalOpen(false)} />
</div>
);
}
@@ -6,11 +6,7 @@ const meta: Meta<typeof CreateKeyModal> = {
title: "Portal/Infrastructure/CreateKeyModal",
component: CreateKeyModal,
parameters: { layout: "fullscreen" },
args: {
open: true,
onClose: () => console.log("close"),
onCreated: () => console.log("created"),
},
args: { open: true, onClose: () => console.log("close") },
};
export default meta;
type Story = StoryObj<typeof CreateKeyModal>;
@@ -1,63 +0,0 @@
import type { ComponentProps } from "react";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
// Deterministic i18n: keys returned verbatim, so assertions are stable.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn() },
}),
}));
// Stub the API layer so no real request is made; capture the create payload.
// vi.hoisted keeps the mock fn defined before the hoisted vi.mock factory runs.
const { createApiKey } = vi.hoisted(() => ({ createApiKey: vi.fn() }));
vi.mock("@portal/api/infrastructure", () => ({ createApiKey }));
import { CreateKeyModal } from "@portal/components/infrastructure/CreateKeyModal";
const K = "portal.infrastructure.createKey";
function renderModal(props: Partial<ComponentProps<typeof CreateKeyModal>>) {
return render(
<MantineProvider>
<CreateKeyModal open onClose={() => {}} onCreated={() => {}} {...props} />
</MantineProvider>,
);
}
describe("CreateKeyModal", () => {
it("gates the create button on a non-empty name", () => {
renderModal({});
const cta = screen.getByRole("button", { name: `${K}.createKey` });
expect(cta).toBeDisabled();
fireEvent.change(screen.getByPlaceholderText(`${K}.keyNamePlaceholder`), {
target: { value: "Production ingest" },
});
expect(cta).toBeEnabled();
});
it("creates a key and reveals the returned secret", async () => {
createApiKey.mockResolvedValueOnce({
key: { id: "1", name: "Production ingest" },
secret: "sk_live_demo_key_rotate_in_prod",
});
const onCreated = vi.fn();
renderModal({ onCreated });
fireEvent.change(screen.getByPlaceholderText(`${K}.keyNamePlaceholder`), {
target: { value: "Production ingest" },
});
fireEvent.click(screen.getByRole("button", { name: `${K}.createKey` }));
expect(await screen.findByText(`${K}.secretWarning`)).toBeInTheDocument();
expect(
screen.getByText("sk_live_demo_key_rotate_in_prod"),
).toBeInTheDocument();
await waitFor(() => expect(onCreated).toHaveBeenCalled());
expect(createApiKey).toHaveBeenCalledWith({ name: "Production ingest" });
});
});
@@ -1,30 +1,40 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Banner, Button, CodeBlock, FormField, Input, Modal } from "@app/ui";
import { createApiKey, type CreatedApiKey } from "@portal/api/infrastructure";
import { errorMessage } from "@portal/api/http";
import {
Banner,
Button,
Checkbox,
CodeBlock,
FormField,
Input,
Modal,
} from "@app/ui";
import type { ApiKeyPermission } from "@portal/api/infrastructure";
const PERMISSION_OPTS: ApiKeyPermission[] = ["Read", "Write", "Admin"];
// Shown once after a key is created. TODO(backend): use the one-time secret
// returned by POST /v1/infrastructure/api-keys — it is never persisted server-side.
const DEMO_NEW_KEY_SECRET = "sk_live_demo_key_rotate_in_prod";
export function CreateKeyModal({
open,
onClose,
onCreated,
}: {
open: boolean;
onClose: () => void;
/** Called after a successful create so the tab can refresh its list. */
onCreated: () => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [created, setCreated] = useState<CreatedApiKey | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [perms, setPerms] = useState<ApiKeyPermission[]>(["Read"]);
const [ips, setIps] = useState("");
const [created, setCreated] = useState(false);
function reset() {
setName("");
setCreated(null);
setSubmitting(false);
setError(null);
setPerms(["Read"]);
setIps("");
setCreated(false);
}
function close() {
@@ -33,18 +43,16 @@ export function CreateKeyModal({
setTimeout(reset, 200);
}
async function createKey() {
setSubmitting(true);
setError(null);
try {
const result = await createApiKey({ name: name.trim() });
setCreated(result);
onCreated();
} catch (e) {
setError(errorMessage(e));
} finally {
setSubmitting(false);
}
function togglePerm(p: ApiKeyPermission) {
setPerms((prev) =>
prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p],
);
}
function createKey() {
// TODO(backend): POST /v1/infrastructure/api-keys { name, perms, ips }
// and render the one-time secret from the response instead of the fixture.
setCreated(true);
}
return (
@@ -64,7 +72,7 @@ export function CreateKeyModal({
}
footer={
created ? (
<Button variant="primary" onClick={close}>
<Button variant="primary" accent="premium" onClick={close}>
{t("portal.infrastructure.createKey.done")}
</Button>
) : (
@@ -74,7 +82,8 @@ export function CreateKeyModal({
</Button>
<Button
variant="primary"
disabled={name.trim() === "" || submitting}
accent="premium"
disabled={name.trim() === "" || perms.length === 0}
onClick={createKey}
>
{t("portal.infrastructure.createKey.createKey")}
@@ -86,7 +95,7 @@ export function CreateKeyModal({
{created ? (
<div className="portal-infra__stack">
<CodeBlock
code={created.secret}
code={DEMO_NEW_KEY_SECRET}
lang="bash"
caption={t("portal.infrastructure.createKey.secretKeyCaption")}
/>
@@ -97,8 +106,6 @@ export function CreateKeyModal({
</div>
) : (
<div className="portal-infra__form">
{error && <Banner tone="danger" description={error} />}
<FormField
label={t("portal.infrastructure.createKey.keyNameLabel")}
required
@@ -111,6 +118,35 @@ export function CreateKeyModal({
)}
/>
</FormField>
<FormField
label={t("portal.infrastructure.createKey.permissionsLabel")}
>
<div className="portal-infra__perm-row">
{PERMISSION_OPTS.map((p) => (
<Checkbox
key={p}
label={t(
`portal.infrastructure.apiKeyPermission.${p.toLowerCase()}`,
p,
)}
checked={perms.includes(p)}
onChange={() => togglePerm(p)}
/>
))}
</div>
</FormField>
<FormField
label={t("portal.infrastructure.createKey.ipAllowlistLabel")}
helperText={t("portal.infrastructure.createKey.ipAllowlistHelper")}
>
<Input
value={ips}
onChange={(e) => setIps(e.target.value)}
placeholder="52.14.0.0/16, 203.0.113.7/32"
/>
</FormField>
</div>
)}
</Modal>
@@ -55,11 +55,13 @@ export const DEPLOY_LABEL: Record<DeploymentStatus, string> = {
export const KEY_TONE: Record<ApiKeyStatus, StatusTone> = {
active: "success",
revoked: "danger",
"rotate-soon": "warning",
};
export const KEY_LABEL: Record<ApiKeyStatus, string> = {
active: "portal.infrastructure.keyLabel.active",
revoked: "portal.infrastructure.keyLabel.revoked",
"rotate-soon": "portal.infrastructure.keyLabel.rotateSoon",
};
export const CERT_TONE: Record<CertStatus, StatusTone> = {
@@ -0,0 +1,53 @@
/* Read-only classification-vocabulary viewer in the policy wizard. */
.classification-summary {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.classification-summary-stats {
display: flex;
gap: var(--space-3);
font-size: 0.8125rem;
color: var(--color-text-2);
}
.classification-summary-note {
font-size: 0.75rem;
color: var(--color-text-3);
}
/* Expandable category → labels list. */
.classification-categories {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.classification-category {
border-top: 1px solid var(--border-default);
}
.classification-category:last-child {
border-bottom: 1px solid var(--border-default);
}
.classification-category-header {
min-height: 2.5rem;
}
.classification-category-lead {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.classification-category-name {
font-weight: 600;
font-size: 0.875rem;
}
.classification-category-count {
font-size: 0.75rem;
color: var(--color-text-3);
}
.classification-category-labels {
display: flex;
flex-wrap: wrap;
gap: var(--space-1_5);
padding: 0 var(--space-2) var(--space-2) 2rem;
}
@@ -0,0 +1,97 @@
// Read-only view of the classification vocabulary shown in the policy wizard. The labels and their
// categories are a fixed, built-in set shared across the whole team — there's nothing to edit, but
// the full vocabulary is browsable: expand a category to see the labels it groups.
import { useState } from "react";
import { useTranslation } from "react-i18next";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
import { Button, Card, Chip } from "@app/ui";
import { LocalIcon } from "@app/components/shared/LocalIcon";
import {
DEFAULT_CLASSIFICATION_LABELS,
LABEL_FAMILIES,
} from "@app/data/classificationLabels";
import "@portal/components/policies/ClassificationLabelsSection.css";
export function ClassificationLabelsSection() {
const { t } = useTranslation();
const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
const toggle = (id: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
return (
<Card>
<div className="classification-summary">
<div className="classification-summary-stats">
<span>
<strong>{DEFAULT_CLASSIFICATION_LABELS.length}</strong>{" "}
{t("policies.labels.labelCount", "labels")}
</span>
<span>
<strong>{LABEL_FAMILIES.length}</strong>{" "}
{t("policies.labels.categoryCount", "categories")}
</span>
</div>
<ul className="classification-categories">
{LABEL_FAMILIES.map((family) => {
const open = expanded.has(family.id);
return (
<li key={family.id} className="classification-category">
<Button
variant="quiet"
fullWidth
justify="between"
className="classification-category-header"
aria-expanded={open}
onClick={() => toggle(family.id)}
leftSection={
<span className="classification-category-lead">
{open ? (
<KeyboardArrowDownIcon sx={{ fontSize: "1.1rem" }} />
) : (
<KeyboardArrowRightIcon sx={{ fontSize: "1.1rem" }} />
)}
<LocalIcon icon={family.icon} width="1.1rem" />
<span className="classification-category-name">
{family.name}
</span>
</span>
}
rightSection={
<span className="classification-category-count">
{family.labels.length}
</span>
}
/>
{open && (
<div className="classification-category-labels">
{family.labels.map((label) => (
<Chip key={label.id} accent="neutral" size="sm">
{t(`classification.labels.${label.id}`, label.name)}
</Chip>
))}
</div>
)}
</li>
);
})}
</ul>
<span className="classification-summary-note">
{t(
"policies.labels.sharedNote",
"These labels are built in and shared across your whole team.",
)}
</span>
</div>
</Card>
);
}
@@ -1,19 +1,28 @@
import { useTranslation } from "react-i18next";
import { Card, Chip, StatusBadge } from "@app/ui";
import type { CatalogueEntry } from "@portal/api/policies";
import { policyIcon } from "@portal/components/policies/policyIcons";
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
import "@portal/views/Policies.css";
interface PolicyCategoryCardProps {
entry: CatalogueEntry;
onOpen: (entry: CatalogueEntry) => void;
/** Setup is unavailable (e.g. the AI engine is off): shown, but not openable. */
locked?: boolean;
/** Chip text explaining why setup is locked (e.g. "Requires AI engine"). */
lockedLabel?: string;
}
export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
export function PolicyCategoryCard({
entry,
onOpen,
locked = false,
lockedLabel,
}: PolicyCategoryCardProps) {
const { t } = useTranslation();
const { category, config, policy } = entry;
const comingSoon = category.comingSoon === true;
const openable = !comingSoon;
const openable = !comingSoon && !locked;
const status = policy?.state.status;
const enforces = config.rules.map((r) => t(r)).join(" · ");
@@ -21,7 +30,7 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
<Card
className={
"portal-policies__card" +
(comingSoon ? " portal-policies__card--locked" : "")
(comingSoon || locked ? " portal-policies__card--locked" : "")
}
interactive={openable}
onClick={openable ? () => onOpen(entry) : undefined}
@@ -39,7 +48,7 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
}
>
<span className="portal-policies__cat-icon" aria-hidden>
{policyIcon(category.icon)}
{policyCategoryIcon(category.id)}
</span>
<div className="portal-policies__card-identity">
@@ -53,6 +62,10 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
<Chip accent="neutral" size="sm">
{t("portal.policies.card.comingSoon")}
</Chip>
) : locked ? (
<Chip accent="neutral" size="sm">
{lockedLabel ?? t("portal.policies.card.requiresAiEngine")}
</Chip>
) : policy ? (
<div className="portal-policies__card-meta">
<span className="portal-policies__card-statpair">

Some files were not shown because too many files have changed in this diff Show More