Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7500150fbd | ||
|
|
d7df87b684 | ||
|
|
6e4e3e138c | ||
|
|
13ca7bd52e | ||
|
|
456a96e15c | ||
|
|
7c8a46255b | ||
|
|
f1332ebe4f | ||
|
|
d4a3df79e5 | ||
|
|
376c9aeb31 | ||
|
|
19377cc688 | ||
|
|
b22ffb3b3b | ||
|
|
6319936720 | ||
|
|
aca0be7b71 | ||
|
|
1c6e9e8158 | ||
|
|
e0e20559b6 | ||
|
|
af694d506d | ||
|
|
d5806b56dd | ||
|
|
49dbf76670 | ||
|
|
0a7cd1185d | ||
|
|
e67bb77a59 | ||
|
|
ae1f905975 |
@@ -208,18 +208,6 @@
|
||||
"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"
|
||||
|
||||
@@ -66,20 +66,6 @@ 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}"
|
||||
|
||||
+13
-2
@@ -54,10 +54,21 @@ 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) {
|
||||
if (rid != null || apiKeyLabel != null) {
|
||||
clean = new java.util.HashMap<>(clean);
|
||||
clean.put("requestId", rid);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
String source = MDC.get("auditSource");
|
||||
|
||||
+17
-38
@@ -8,14 +8,12 @@ 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;
|
||||
@@ -26,24 +24,18 @@ 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 document model to a PDF via WeasyPrint.
|
||||
* Dispatchable tool that converts an AI-generated HTML string 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 engine supplies the document as
|
||||
* structured fields; the HTML is built here from a fixed template.
|
||||
* emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The HTML comes from a trusted Jinja
|
||||
* template so sanitization is intentionally skipped.
|
||||
*/
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@@ -56,9 +48,6 @@ 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
|
||||
@@ -85,42 +74,32 @@ public class CreatePdfAgentController {
|
||||
value = "/create-pdf-from-html-agent",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Convert an AI-generated document to a PDF",
|
||||
summary = "Convert AI-generated HTML to a PDF",
|
||||
description =
|
||||
"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)
|
||||
"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)
|
||||
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 document to PDF via WeasyPrint — html_bytes={}",
|
||||
html.length());
|
||||
"[create-pdf-agent] converting HTML to PDF via WeasyPrint — html_bytes={}",
|
||||
htmlContent.length());
|
||||
|
||||
try (TempFile htmlFile = tempFileManager.createManagedTempFile(".html");
|
||||
TempFile pdfFile = tempFileManager.createManagedTempFile(".pdf")) {
|
||||
|
||||
Files.writeString(htmlFile.getPath(), html, StandardCharsets.UTF_8);
|
||||
Files.writeString(htmlFile.getPath(), htmlContent, 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());
|
||||
|
||||
@@ -147,8 +126,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 pdDocument = pdfDocumentFactory.load(pdfFile.getPath())) {
|
||||
pdDocument.save(tempOut.getPath().toFile());
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile.getPath())) {
|
||||
document.save(tempOut.getPath().toFile());
|
||||
} catch (Exception e) {
|
||||
tempOut.close();
|
||||
throw e;
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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 per-user API key and binds the request to
|
||||
* that user with the MCP scopes.
|
||||
* API-key auth for the MCP endpoint: validates a Stirling API key and binds the request to that
|
||||
* user with the MCP scopes.
|
||||
*/
|
||||
@Slf4j
|
||||
public class McpApiKeyAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
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) {}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
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) {}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
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) {}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
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) {}
|
||||
+5
-2
@@ -57,6 +57,7 @@ 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;
|
||||
@@ -484,12 +485,14 @@ public class SecurityConfiguration {
|
||||
}
|
||||
|
||||
@Bean
|
||||
public JwtAuthenticationFilter jwtAuthenticationFilter() {
|
||||
public JwtAuthenticationFilter jwtAuthenticationFilter(
|
||||
ApiKeyAuthenticationService apiKeyAuthenticationService) {
|
||||
return new JwtAuthenticationFilter(
|
||||
jwtService,
|
||||
userService,
|
||||
userDetailsService,
|
||||
jwtAuthenticationEntryPoint,
|
||||
securityProperties);
|
||||
securityProperties,
|
||||
apiKeyAuthenticationService);
|
||||
}
|
||||
}
|
||||
|
||||
+20
-4
@@ -11,6 +11,7 @@ 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;
|
||||
@@ -33,8 +34,9 @@ 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;
|
||||
@@ -48,11 +50,15 @@ 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;
|
||||
@@ -131,9 +137,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
try {
|
||||
Optional<User> user = userService.getUserByApiKey(apiKey);
|
||||
// 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);
|
||||
|
||||
if (user.isEmpty()) {
|
||||
if (resolved.isEmpty()) {
|
||||
handleAuthenticationFailure(
|
||||
request,
|
||||
response,
|
||||
@@ -143,8 +154,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
authentication =
|
||||
new ApiKeyAuthenticationToken(
|
||||
user.get(), apiKey, user.get().getAuthorities());
|
||||
resolved.get().user(), apiKey, resolved.get().authorities());
|
||||
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(
|
||||
|
||||
+27
-5
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -33,6 +34,8 @@ 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;
|
||||
|
||||
@@ -41,18 +44,24 @@ 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;
|
||||
}
|
||||
@@ -62,6 +71,14 @@ 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);
|
||||
@@ -89,18 +106,23 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
String apiKey = request.getHeader("X-API-KEY");
|
||||
if (apiKey != null && !apiKey.trim().isEmpty()) {
|
||||
try {
|
||||
// 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()) {
|
||||
// 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()) {
|
||||
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||
response.getWriter().write("Invalid API Key.");
|
||||
return;
|
||||
}
|
||||
User user = resolved.get().user();
|
||||
authentication =
|
||||
new ApiKeyAuthenticationToken(
|
||||
user.get(), apiKey, user.get().getAuthorities());
|
||||
user, apiKey, resolved.get().authorities());
|
||||
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());
|
||||
|
||||
+15
-14
@@ -11,7 +11,6 @@ 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;
|
||||
|
||||
@@ -58,22 +57,24 @@ 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;
|
||||
// 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();
|
||||
}
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null
|
||||
&& authentication.isAuthenticated()
|
||||
&& !"anonymousUser".equals(authentication.getName())) {
|
||||
identifier = authentication.getName();
|
||||
}
|
||||
// If neither API key nor an authenticated user is present, use IP address
|
||||
if (identifier == null) {
|
||||
identifier = request.getRemoteAddr();
|
||||
String apiKey = request.getHeader("X-API-KEY");
|
||||
if (apiKey != null && !apiKey.trim().isEmpty()) {
|
||||
identifier = "API_KEY_" + apiKey;
|
||||
} else {
|
||||
identifier = request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
Role userRole =
|
||||
getRoleFromAuthentication(SecurityContextHolder.getContext().getAuthentication());
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+1
@@ -5,6 +5,7 @@ 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;
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
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);
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
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);
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
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();
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
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) {}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
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"));
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+16
-10
@@ -95,6 +95,7 @@ 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(
|
||||
@@ -147,15 +148,16 @@ public class UserService implements UserServiceInterface {
|
||||
}
|
||||
|
||||
public Authentication getAuthentication(String apiKey) {
|
||||
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()));
|
||||
// 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
|
||||
}
|
||||
|
||||
private Collection<? extends GrantedAuthority> getAuthorities(User user) {
|
||||
@@ -173,6 +175,9 @@ 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();
|
||||
@@ -220,7 +225,8 @@ public class UserService implements UserServiceInterface {
|
||||
}
|
||||
|
||||
public Optional<User> getUserByApiKey(String apiKey) {
|
||||
return userRepository.findByApiKey(apiKey);
|
||||
// Resolves the multi-key api_keys table first, then the legacy per-user column.
|
||||
return apiKeyAuthenticationService.resolveUser(apiKey);
|
||||
}
|
||||
|
||||
public Optional<User> loadUserByApiKey(String apiKey) {
|
||||
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-4
@@ -65,12 +65,13 @@ 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(asString(data.get("__origin")));
|
||||
: sourceLabel(origin, asString(data.get("__apiKeyLabel")));
|
||||
String product = automation ? "Automation" : productLabel(source);
|
||||
String action = prettyTool(path);
|
||||
boolean failed = isFailure(data);
|
||||
@@ -173,9 +174,12 @@ public class PortalDocumentsService {
|
||||
return code instanceof Number n && n.intValue() >= 400;
|
||||
}
|
||||
|
||||
private static String sourceLabel(String origin) {
|
||||
private static String sourceLabel(String origin, String apiKeyLabel) {
|
||||
if ("API".equals(origin)) {
|
||||
return "API integration";
|
||||
// Attribute to the specific named key when known, else the generic API channel.
|
||||
return apiKeyLabel != null && !apiKeyLabel.isBlank()
|
||||
? "API key · " + apiKeyLabel
|
||||
: "API integration";
|
||||
}
|
||||
if ("SYSTEM".equals(origin)) {
|
||||
return "System";
|
||||
@@ -184,7 +188,8 @@ public class PortalDocumentsService {
|
||||
}
|
||||
|
||||
private static String productLabel(String source) {
|
||||
return "API integration".equals(source) ? "API" : "Editor";
|
||||
// Covers both the generic "API integration" and per-key "API key · <label>" sources.
|
||||
return source != null && source.startsWith("API") ? "API" : "Editor";
|
||||
}
|
||||
|
||||
private static boolean isAutomation(Map<String, Object> data) {
|
||||
|
||||
+2
-2
@@ -166,8 +166,8 @@ class PolicyExecutorTest {
|
||||
new PipelineStep(
|
||||
createPdf,
|
||||
Map.of(
|
||||
"document",
|
||||
"{\"title\":\"PO\",\"sections\":[]}",
|
||||
"htmlContent",
|
||||
"<p>hi</p>",
|
||||
"filename",
|
||||
"purchase-order.pdf"))),
|
||||
PolicyInputs.of(List.of()),
|
||||
|
||||
+4
-1
@@ -29,6 +29,7 @@ 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;
|
||||
@@ -160,7 +161,9 @@ class SecurityConfigurationTest {
|
||||
@Test
|
||||
@DisplayName("jwtAuthenticationFilter is created")
|
||||
void jwtAuthenticationFilter() {
|
||||
JwtAuthenticationFilter filter = newConfig(true).jwtAuthenticationFilter();
|
||||
JwtAuthenticationFilter filter =
|
||||
newConfig(true)
|
||||
.jwtAuthenticationFilter(mock(ApiKeyAuthenticationService.class));
|
||||
assertThat(filter).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
+14
-3
@@ -29,6 +29,8 @@ 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;
|
||||
|
||||
@@ -37,6 +39,7 @@ 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;
|
||||
@@ -60,7 +63,11 @@ class UserAuthenticationFilterTest {
|
||||
|
||||
private UserAuthenticationFilter filter(boolean loginEnabled) {
|
||||
return new UserAuthenticationFilter(
|
||||
securityProp, userService, sessionPersistentRegistry, loginEnabled);
|
||||
securityProp,
|
||||
userService,
|
||||
apiKeyAuthenticationService,
|
||||
sessionPersistentRegistry,
|
||||
loginEnabled);
|
||||
}
|
||||
|
||||
private static User enabledUser(String username) {
|
||||
@@ -99,7 +106,11 @@ class UserAuthenticationFilterTest {
|
||||
User user = enabledUser("api-user");
|
||||
user.addAuthority(
|
||||
new stirling.software.proprietary.security.model.Authority("ROLE_USER", user));
|
||||
when(userService.getUserByApiKey("good-key")).thenReturn(Optional.of(user));
|
||||
when(apiKeyAuthenticationService.authenticate("good-key"))
|
||||
.thenReturn(
|
||||
Optional.of(
|
||||
new ApiKeyAuthentication(
|
||||
user, "Prod (sk_demo0000)", user.getAuthorities())));
|
||||
when(userService.usernameExistsIgnoreCase("api-user")).thenReturn(true);
|
||||
when(userService.isUserDisabled("api-user")).thenReturn(false);
|
||||
when(sessionPersistentRegistry.getAllSessions(any(), anyBoolean()))
|
||||
@@ -117,7 +128,7 @@ class UserAuthenticationFilterTest {
|
||||
void invalidApiKeyRejected() throws Exception {
|
||||
request.setRequestURI("/api/v1/some/protected");
|
||||
request.addHeader("X-API-KEY", "bad-key");
|
||||
when(userService.getUserByApiKey("bad-key")).thenReturn(Optional.empty());
|
||||
when(apiKeyAuthenticationService.authenticate("bad-key")).thenReturn(Optional.empty());
|
||||
|
||||
filter(true).doFilter(request, response, filterChain);
|
||||
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -41,6 +41,7 @@ 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;
|
||||
@@ -76,6 +77,7 @@ class UserServiceMoreTest {
|
||||
integrationConfigRepository;
|
||||
|
||||
@Mock private TeamMembershipService teamMembershipService;
|
||||
@Mock private ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
|
||||
@InjectMocks private UserService userService;
|
||||
|
||||
@@ -99,7 +101,8 @@ class UserServiceMoreTest {
|
||||
void getAuthenticationValid() {
|
||||
User u = user("api");
|
||||
u.addAuthority(new Authority("ROLE_USER", u));
|
||||
when(userRepository.findByApiKey("k")).thenReturn(Optional.of(u));
|
||||
when(apiKeyAuthenticationService.authenticate("k"))
|
||||
.thenReturn(Optional.of(new ApiKeyAuthentication(u, null, u.getAuthorities())));
|
||||
|
||||
assertThat(userService.getAuthentication("k")).isNotNull();
|
||||
}
|
||||
@@ -107,7 +110,7 @@ class UserServiceMoreTest {
|
||||
@Test
|
||||
@DisplayName("getAuthentication throws when key is unknown")
|
||||
void getAuthenticationInvalid() {
|
||||
when(userRepository.findByApiKey("bad")).thenReturn(Optional.empty());
|
||||
when(apiKeyAuthenticationService.authenticate("bad")).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> userService.getAuthentication("bad"))
|
||||
.isInstanceOf(UsernameNotFoundException.class);
|
||||
|
||||
+22
@@ -71,6 +71,7 @@ class UserServiceTest {
|
||||
integrationConfigRepository;
|
||||
|
||||
@Mock private TeamMembershipService teamMembershipService;
|
||||
@Mock private ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
|
||||
@Spy @InjectMocks private UserService userService;
|
||||
|
||||
@@ -196,6 +197,27 @@ 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();
|
||||
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
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("<b>"));
|
||||
}
|
||||
|
||||
@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;"));
|
||||
}
|
||||
}
|
||||
+14
@@ -70,4 +70,18 @@ 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)");
|
||||
}
|
||||
}
|
||||
|
||||
+20
-5
@@ -18,6 +18,7 @@ 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;
|
||||
@@ -43,6 +44,8 @@ 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;
|
||||
@@ -65,6 +68,7 @@ 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();
|
||||
|
||||
@@ -73,12 +77,14 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
|
||||
UserService userService,
|
||||
SupabaseUserService supabaseUserService,
|
||||
SaasTeamService saasTeamService,
|
||||
JwtDecoder jwtDecoder) {
|
||||
JwtDecoder jwtDecoder,
|
||||
ApiKeyAuthenticationService apiKeyAuthenticationService) {
|
||||
this.teamService = teamService;
|
||||
this.userService = userService;
|
||||
this.supabaseUserService = supabaseUserService;
|
||||
this.saasTeamService = saasTeamService;
|
||||
this.jwtDecoder = jwtDecoder;
|
||||
this.apiKeyAuthenticationService = apiKeyAuthenticationService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -86,6 +92,9 @@ 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;
|
||||
@@ -406,16 +415,22 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
|
||||
return false;
|
||||
}
|
||||
|
||||
Optional<User> user = userService.getUserByApiKey(apiKey);
|
||||
if (user.isEmpty()) {
|
||||
// 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()) {
|
||||
throw new InvalidBearerTokenException("Invalid API Key.");
|
||||
}
|
||||
User user = resolved.get().user();
|
||||
|
||||
userService.trackApiKeyFirstUse(user.get());
|
||||
userService.trackApiKeyFirstUse(user);
|
||||
|
||||
ApiKeyAuthenticationToken authToken =
|
||||
new ApiKeyAuthenticationToken(user.get(), apiKey, user.get().getAuthorities());
|
||||
new ApiKeyAuthenticationToken(user, apiKey, resolved.get().authorities());
|
||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||
if (resolved.get().auditLabel() != null) {
|
||||
MDC.put(ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY, resolved.get().auditLabel());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ 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;
|
||||
@@ -69,6 +70,7 @@ 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;
|
||||
@@ -125,7 +127,8 @@ public class SupabaseSecurityConfig {
|
||||
userService,
|
||||
supabaseUserService,
|
||||
saasTeamService,
|
||||
jwtDecoder),
|
||||
jwtDecoder,
|
||||
apiKeyAuthenticationService),
|
||||
BearerTokenAuthenticationFilter.class)
|
||||
.exceptionHandling(
|
||||
ex ->
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- 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)
|
||||
);
|
||||
+16
-2
@@ -58,6 +58,10 @@ 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;
|
||||
@@ -68,7 +72,12 @@ class SupabaseAuthenticationFilterMoreTest {
|
||||
SecurityContextHolder.clearContext();
|
||||
filter =
|
||||
new SupabaseAuthenticationFilter(
|
||||
teamService, userService, supabaseUserService, saasTeamService, jwtDecoder);
|
||||
teamService,
|
||||
userService,
|
||||
supabaseUserService,
|
||||
saasTeamService,
|
||||
jwtDecoder,
|
||||
apiKeyAuthenticationService);
|
||||
request = new MockHttpServletRequest();
|
||||
response = new MockHttpServletResponse();
|
||||
chain = new MockFilterChain();
|
||||
@@ -234,7 +243,12 @@ 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(userService.getUserByApiKey("k1")).thenReturn(Optional.of(user));
|
||||
when(apiKeyAuthenticationService.authenticate("k1"))
|
||||
.thenReturn(
|
||||
Optional.of(
|
||||
new stirling.software.proprietary.security.service
|
||||
.ApiKeyAuthenticationService.ApiKeyAuthentication(
|
||||
user, null, user.getAuthorities())));
|
||||
|
||||
request.setRequestURI("/api/v1/something");
|
||||
request.setMethod("POST");
|
||||
|
||||
+17
-3
@@ -48,6 +48,10 @@ 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;
|
||||
@@ -58,7 +62,12 @@ class SupabaseAuthenticationFilterTest {
|
||||
SecurityContextHolder.clearContext();
|
||||
filter =
|
||||
new SupabaseAuthenticationFilter(
|
||||
teamService, userService, supabaseUserService, saasTeamService, jwtDecoder);
|
||||
teamService,
|
||||
userService,
|
||||
supabaseUserService,
|
||||
saasTeamService,
|
||||
jwtDecoder,
|
||||
apiKeyAuthenticationService);
|
||||
request = new MockHttpServletRequest();
|
||||
response = new MockHttpServletResponse();
|
||||
chain = new MockFilterChain();
|
||||
@@ -85,7 +94,12 @@ class SupabaseAuthenticationFilterTest {
|
||||
void apiKeyHeaderPopulatesSecurityContext() throws Exception {
|
||||
User user = newUser("alice");
|
||||
user.setApiKey("api-key-123");
|
||||
when(userService.getUserByApiKey("api-key-123")).thenReturn(Optional.of(user));
|
||||
when(apiKeyAuthenticationService.authenticate("api-key-123"))
|
||||
.thenReturn(
|
||||
Optional.of(
|
||||
new stirling.software.proprietary.security.service
|
||||
.ApiKeyAuthenticationService.ApiKeyAuthentication(
|
||||
user, null, user.getAuthorities())));
|
||||
|
||||
request.setRequestURI("/api/v1/something");
|
||||
request.setMethod("POST");
|
||||
@@ -103,7 +117,7 @@ class SupabaseAuthenticationFilterTest {
|
||||
|
||||
@Test
|
||||
void invalidApiKeyTriggers401() throws Exception {
|
||||
when(userService.getUserByApiKey("nope")).thenReturn(Optional.empty());
|
||||
when(apiKeyAuthenticationService.authenticate("nope")).thenReturn(Optional.empty());
|
||||
|
||||
request.setRequestURI("/api/v1/something");
|
||||
request.setMethod("POST");
|
||||
|
||||
+10
-1
@@ -43,9 +43,18 @@ 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);
|
||||
userService,
|
||||
teamService,
|
||||
supabaseUserService,
|
||||
saasTeamService,
|
||||
props,
|
||||
apiKeyAuthenticationService);
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
||||
+22
@@ -21,6 +21,7 @@ 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;
|
||||
@@ -107,6 +108,27 @@ 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.
|
||||
|
||||
@@ -36,8 +36,6 @@ 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"
|
||||
@@ -224,13 +222,6 @@ 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}"
|
||||
|
||||
@@ -5,6 +5,7 @@ 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",
|
||||
|
||||
@@ -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. The assembled document is emitted as structured fields. The LLM never writes HTML.
|
||||
6. Jinja renders the document to HTML. 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,7 +22,9 @@ 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
|
||||
|
||||
@@ -49,6 +51,8 @@ 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.
|
||||
@@ -162,13 +166,10 @@ 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"). 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.
|
||||
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.
|
||||
|
||||
- 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
|
||||
@@ -298,6 +299,15 @@ 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("-")
|
||||
@@ -310,6 +320,7 @@ 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,
|
||||
@@ -390,12 +401,14 @@ class PdfCreateAgent:
|
||||
sections=all_sections,
|
||||
)
|
||||
|
||||
# ── Phase 6: emit ──────────────────────────────────────────────────────
|
||||
# ── Phase 6: render ────────────────────────────────────────────────────
|
||||
logger.info("[pdf-create] phase 6/6: rendering HTML")
|
||||
html = self._render(doc)
|
||||
filename = _safe_filename(plan.title)
|
||||
logger.info(
|
||||
"[pdf-create] done — filename=%r sections=%d",
|
||||
"[pdf-create] done — filename=%r html_bytes=%d",
|
||||
filename,
|
||||
len(all_sections),
|
||||
len(html),
|
||||
)
|
||||
|
||||
return EditPlanResponse(
|
||||
@@ -404,7 +417,7 @@ class PdfCreateAgent:
|
||||
ToolOperationStep(
|
||||
tool=AgentToolId.CREATE_PDF_FROM_HTML_AGENT,
|
||||
parameters=CreatePdfFromHtmlAgentParams(
|
||||
document=doc.model_dump_json(),
|
||||
html_content=html,
|
||||
filename=filename,
|
||||
),
|
||||
)
|
||||
@@ -424,3 +437,7 @@ 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)
|
||||
|
||||
+19
-21
@@ -1,4 +1,3 @@
|
||||
{%- autoescape true -%}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -176,18 +175,18 @@
|
||||
color: var(--color-label);
|
||||
}
|
||||
</style>
|
||||
{%- if style_primary or style_background or style_body %}
|
||||
{%- if doc.style %}
|
||||
<style>
|
||||
:root {
|
||||
{%- if style_primary %}
|
||||
--color-primary: {{ style_primary }};
|
||||
{%- if doc.style.primary_color %}
|
||||
--color-primary: {{ doc.style.primary_color }};
|
||||
{%- endif %}
|
||||
{%- if style_background %}
|
||||
--color-bg: {{ style_background }};
|
||||
{%- if doc.style.background_color %}
|
||||
--color-bg: {{ doc.style.background_color }};
|
||||
{%- endif %}
|
||||
{%- if style_body %}
|
||||
--color-body: {{ style_body }};
|
||||
--color-label: {{ style_body }};
|
||||
{%- if doc.style.body_text_color %}
|
||||
--color-body: {{ doc.style.body_text_color }};
|
||||
--color-label: {{ doc.style.body_text_color }};
|
||||
{%- endif %}
|
||||
}
|
||||
</style>
|
||||
@@ -196,16 +195,16 @@
|
||||
<body>
|
||||
|
||||
<div class="doc-header">
|
||||
<div class="doc-title">{{ title }}</div>
|
||||
{%- if subtitle %}
|
||||
<div class="doc-subtitle">{{ subtitle }}</div>
|
||||
<div class="doc-title">{{ doc.title }}</div>
|
||||
{%- if doc.subtitle %}
|
||||
<div class="doc-subtitle">{{ doc.subtitle }}</div>
|
||||
{%- endif %}
|
||||
{%- if reference_number %}
|
||||
<div class="doc-reference">{{ reference_number }}</div>
|
||||
{%- if doc.reference_number %}
|
||||
<div class="doc-reference">{{ doc.reference_number }}</div>
|
||||
{%- endif %}
|
||||
</div>
|
||||
|
||||
{%- for section in sections %}
|
||||
{%- for section in doc.sections %}
|
||||
|
||||
{%- if section.type == "text" %}
|
||||
<section>
|
||||
@@ -213,8 +212,8 @@
|
||||
<h2>{{ section.heading }}</h2>
|
||||
{%- endif %}
|
||||
<div class="text-body">
|
||||
{%- for para in section.paragraphs %}
|
||||
<p>{{ para }}</p>
|
||||
{%- for para in section.body.split('\n\n') %}
|
||||
<p>{{ para | replace('\n', ' ') }}</p>
|
||||
{%- endfor %}
|
||||
</div>
|
||||
</section>
|
||||
@@ -226,10 +225,10 @@
|
||||
{%- endif %}
|
||||
<table class="kv-table">
|
||||
<tbody>
|
||||
{%- for pair in section.pairs %}
|
||||
{%- for label, value in section.pairs %}
|
||||
<tr>
|
||||
<td class="kv-label">{{ pair.label }}</td>
|
||||
<td class="kv-value">{{ pair.value }}</td>
|
||||
<td class="kv-label">{{ label }}</td>
|
||||
<td class="kv-value">{{ value }}</td>
|
||||
</tr>
|
||||
{%- endfor %}
|
||||
</tbody>
|
||||
@@ -300,4 +299,3 @@
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{%- endautoescape %}
|
||||
@@ -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 assembled document.
|
||||
CREATE_PDF_FROM_HTML_AGENT plan step carrying the rendered HTML.
|
||||
|
||||
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. The document is emitted as structured fields. The LLM never writes HTML.
|
||||
5. Jinja renders GeneratedDocument → HTML. The LLM never writes HTML.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -81,12 +81,14 @@ type DocumentSection = Annotated[
|
||||
]
|
||||
|
||||
|
||||
# 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}$")
|
||||
# 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}$")
|
||||
|
||||
|
||||
class DocumentStyle(ApiModel):
|
||||
"""Document colours, inferred by the meta planner. Non-hex values are dropped to ``None``."""
|
||||
"""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``."""
|
||||
|
||||
primary_color: str | None = Field(default=None)
|
||||
background_color: str | None = Field(default=None)
|
||||
@@ -101,7 +103,7 @@ class DocumentStyle(ApiModel):
|
||||
|
||||
|
||||
class GeneratedDocument(ApiModel):
|
||||
"""The full document model emitted for rendering."""
|
||||
"""The full document model passed to Jinja for HTML rendering."""
|
||||
|
||||
title: str
|
||||
subtitle: str | None = None
|
||||
|
||||
@@ -29,7 +29,7 @@ class PdfCommentAgentParams(ApiModel):
|
||||
|
||||
|
||||
class CreatePdfFromHtmlAgentParams(ApiModel):
|
||||
document: str
|
||||
html_content: str
|
||||
filename: str = Field(pattern=r"^.+\.pdf$")
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Mapping
|
||||
from contextvars import ContextVar
|
||||
@@ -30,8 +29,6 @@ from posthog.client import Client as PostHogClient
|
||||
from stirling.config import AppSettings
|
||||
from stirling.models import UserId
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Per-request user ID, set by middleware from the X-User-Id header.
|
||||
# When not set, PostHog generates a random ID and marks the event as personless.
|
||||
current_user_id: ContextVar[UserId | None] = ContextVar("current_user_id", default=None)
|
||||
@@ -130,27 +127,17 @@ class PostHogSpanProcessor(SpanProcessor):
|
||||
pass
|
||||
|
||||
def on_end(self, span: ReadableSpan) -> None:
|
||||
# OpenTelemetry calls this synchronously from ``Span.end()`` with no
|
||||
# exception handling of its own (see SynchronousMultiSpanProcessor),
|
||||
# and pydantic-ai ends the model span inside a ``with`` block. So any
|
||||
# exception raised here propagates straight into the model request and
|
||||
# breaks the agent run. A telemetry defect must never take down the
|
||||
# assistant or silently halt emission for every subsequent span, so we
|
||||
# translate and capture defensively and swallow (but log) any failure.
|
||||
try:
|
||||
attrs = dict(span.attributes or {})
|
||||
if attrs.get(GEN_AI_OPERATION_NAME) != GenAiOperationNameValues.CHAT.value:
|
||||
return
|
||||
attrs = dict(span.attributes or {})
|
||||
if attrs.get(GEN_AI_OPERATION_NAME) != GenAiOperationNameValues.CHAT.value:
|
||||
return
|
||||
|
||||
properties = self._build_generation_properties(span, attrs)
|
||||
self._maybe_emit_trace_event(span, attrs, properties)
|
||||
self._client.capture(
|
||||
distinct_id=current_user_id.get(),
|
||||
event="$ai_generation",
|
||||
properties=properties,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to emit $ai_generation for span; dropping this event")
|
||||
properties = self._build_generation_properties(span, attrs)
|
||||
self._maybe_emit_trace_event(span, attrs, properties)
|
||||
self._client.capture(
|
||||
distinct_id=current_user_id.get(),
|
||||
event="$ai_generation",
|
||||
properties=properties,
|
||||
)
|
||||
|
||||
def _build_generation_properties(self, span: ReadableSpan, attrs: Mapping[str, Any]) -> dict[str, object]:
|
||||
"""Build the $ai_generation event properties from span data."""
|
||||
@@ -244,19 +231,6 @@ class PostHogSpanProcessor(SpanProcessor):
|
||||
return True
|
||||
|
||||
|
||||
def _log_delivery_error(error: Exception, _batch: Any) -> None:
|
||||
"""PostHog client ``on_error`` callback.
|
||||
|
||||
The PostHog SDK delivers events from a background consumer thread and its
|
||||
``capture`` method is decorated ``@no_throw``, so without this hook a
|
||||
failing (or dead) delivery pipeline is completely invisible: events simply
|
||||
stop arriving with nothing in our logs. Surfacing the error lets a halt be
|
||||
noticed in minutes instead of sitting silently at zero. Kept lightweight so
|
||||
a transient upload error can't spam at capture volume.
|
||||
"""
|
||||
logger.warning("PostHog event delivery failed: %s", error)
|
||||
|
||||
|
||||
def setup_posthog_tracking(settings: AppSettings) -> TracerProvider | None:
|
||||
"""Configure OpenTelemetry with a PostHog span processor for LLM analytics.
|
||||
|
||||
@@ -264,17 +238,11 @@ def setup_posthog_tracking(settings: AppSettings) -> TracerProvider | None:
|
||||
or None when tracking is disabled.
|
||||
"""
|
||||
if not settings.posthog_enabled or not settings.posthog_api_key:
|
||||
logger.info("PostHog LLM tracking is disabled (posthog_enabled=%s)", settings.posthog_enabled)
|
||||
return None
|
||||
|
||||
client = PostHogClient(
|
||||
project_api_key=settings.posthog_api_key,
|
||||
host=settings.posthog_host,
|
||||
on_error=_log_delivery_error,
|
||||
)
|
||||
client = PostHogClient(project_api_key=settings.posthog_api_key, host=settings.posthog_host)
|
||||
processor = PostHogSpanProcessor(client)
|
||||
|
||||
provider = TracerProvider()
|
||||
provider.add_span_processor(processor)
|
||||
logger.info("PostHog LLM tracking enabled (host=%s)", settings.posthog_host)
|
||||
return provider
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Coverage:
|
||||
1. Section model validation (each section type round-trips correctly)
|
||||
2. orchestrate() emits the assembled document as structured JSON
|
||||
2. Jinja rendering (_render produces valid HTML for each section type)
|
||||
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,8 +12,6 @@ Coverage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from conftest import build_app_settings
|
||||
from pydantic_ai.models.test import TestModel
|
||||
@@ -66,6 +64,37 @@ 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",
|
||||
@@ -170,6 +199,82 @@ 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 "<script>" 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 ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -291,9 +396,8 @@ 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")
|
||||
parsed = json.loads(step.parameters.document)
|
||||
assert parsed["title"] == "Invoice"
|
||||
assert parsed["sections"]
|
||||
assert "<!DOCTYPE html>" in step.parameters.html_content
|
||||
assert "Invoice" in step.parameters.html_content
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -364,9 +468,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)
|
||||
document = result.steps[0].parameters.document # type: ignore[union-attr]
|
||||
assert "Introduction text." in document
|
||||
assert "Details" in document
|
||||
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||
assert "Introduction text." in html
|
||||
assert "Details" in html
|
||||
|
||||
|
||||
# ── Style inference ───────────────────────────────────────────────────────────────────────────────
|
||||
@@ -378,7 +482,7 @@ async def test_orchestrate_applies_planner_inferred_style(agent: PdfCreateAgent)
|
||||
meta = DocumentMeta(
|
||||
title="Styled Doc",
|
||||
tone_brief="Professional.",
|
||||
style_primary_color="#ff00ff",
|
||||
style_primary_color="magenta",
|
||||
)
|
||||
sections = _simple_sections()
|
||||
written = _written_sections()
|
||||
@@ -395,35 +499,50 @@ 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)
|
||||
document = result.steps[0].parameters.document # type: ignore[union-attr]
|
||||
assert json.loads(document)["style"]["primaryColor"] == "#ff00ff"
|
||||
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||
assert "magenta" in html
|
||||
|
||||
|
||||
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")
|
||||
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")
|
||||
assert (safe.primary_color, safe.background_color, safe.body_text_color) == (
|
||||
"navy",
|
||||
"#1e3a5f",
|
||||
"#ffffff",
|
||||
"#1A1A1A",
|
||||
"#fff",
|
||||
)
|
||||
|
||||
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
|
||||
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
|
||||
# A trailing newline must not slip a value through (fullmatch, not $-before-newline).
|
||||
assert DocumentStyle(primary_color="#1e3a5f\n").primary_color is None
|
||||
assert DocumentStyle(primary_color="navy\n").primary_color is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
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."""
|
||||
async def test_orchestrate_drops_unsafe_planner_color(agent: PdfCreateAgent) -> None:
|
||||
"""An unsafe colour inferred by the meta planner never reaches the rendered HTML."""
|
||||
meta = DocumentMeta(
|
||||
title="Doc",
|
||||
tone_brief="Professional.",
|
||||
style_primary_color="rgb(0, 0, 255)",
|
||||
style_primary_color="blue; background: url(http://evil.test/)",
|
||||
)
|
||||
sections = _simple_sections()
|
||||
written = _written_sections()
|
||||
@@ -440,6 +559,6 @@ async def test_orchestrate_drops_non_hex_planner_colour(agent: PdfCreateAgent) -
|
||||
result = await agent.orchestrate(_orchestrator_request("make it blue"))
|
||||
|
||||
assert isinstance(result, EditPlanResponse)
|
||||
document = result.steps[0].parameters.document # type: ignore[union-attr]
|
||||
assert "rgb(" not in document
|
||||
assert json.loads(document)["style"]["primaryColor"] is None
|
||||
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||
assert "evil.test" not in html
|
||||
assert "url(" not in html
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import (
|
||||
GEN_AI_INPUT_MESSAGES,
|
||||
GEN_AI_OPERATION_NAME,
|
||||
GEN_AI_SYSTEM,
|
||||
GenAiOperationNameValues,
|
||||
)
|
||||
|
||||
from stirling.config import AppSettings
|
||||
from stirling.services.tracking import PostHogSpanProcessor, current_user_id, setup_posthog_tracking
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeContext:
|
||||
trace_id: int
|
||||
span_id: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeParent:
|
||||
span_id: int
|
||||
|
||||
|
||||
class _FakeSpan:
|
||||
"""Minimal stand-in for the OpenTelemetry ReadableSpan fields the processor reads."""
|
||||
|
||||
def __init__(self, attributes: dict[str, Any], trace_id: int = 0x1234, span_id: int = 0x9) -> None:
|
||||
self.attributes = attributes
|
||||
self.context = _FakeContext(trace_id=trace_id, span_id=span_id)
|
||||
self.parent = _FakeParent(span_id=0x1)
|
||||
self.start_time = 1_000_000_000
|
||||
self.end_time = 2_000_000_000
|
||||
|
||||
|
||||
class _RecordingClient:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def capture(self, *, distinct_id: str | None, event: str, properties: dict[str, Any]) -> None:
|
||||
self.events.append((event, properties))
|
||||
|
||||
|
||||
class _RaisingClient:
|
||||
def capture(self, **_kwargs: Any) -> None:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
def _processor(client: object) -> PostHogSpanProcessor:
|
||||
# The processor only calls ``capture`` on its client; the test doubles model
|
||||
# that surface without being real PostHog clients (which start threads).
|
||||
return PostHogSpanProcessor(client) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _on_end(processor: PostHogSpanProcessor, span: object) -> None:
|
||||
processor.on_end(span) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _chat_attrs() -> dict[str, Any]:
|
||||
return {
|
||||
GEN_AI_OPERATION_NAME: GenAiOperationNameValues.CHAT.value,
|
||||
GEN_AI_SYSTEM: "anthropic",
|
||||
GEN_AI_INPUT_MESSAGES: json.dumps([{"role": "user", "parts": [{"type": "text", "content": "hello"}]}]),
|
||||
}
|
||||
|
||||
|
||||
def test_chat_span_emits_generation_and_trace() -> None:
|
||||
client = _RecordingClient()
|
||||
|
||||
_on_end(_processor(client), _FakeSpan(_chat_attrs()))
|
||||
|
||||
emitted = [event for event, _ in client.events]
|
||||
assert "$ai_generation" in emitted
|
||||
assert "$ai_trace" in emitted
|
||||
|
||||
|
||||
def test_trace_event_deduplicated_per_trace() -> None:
|
||||
client = _RecordingClient()
|
||||
processor = _processor(client)
|
||||
|
||||
_on_end(processor, _FakeSpan(_chat_attrs(), trace_id=0x42, span_id=0x1))
|
||||
_on_end(processor, _FakeSpan(_chat_attrs(), trace_id=0x42, span_id=0x2))
|
||||
|
||||
trace_events = [event for event, _ in client.events if event == "$ai_trace"]
|
||||
generation_events = [event for event, _ in client.events if event == "$ai_generation"]
|
||||
assert len(trace_events) == 1
|
||||
assert len(generation_events) == 2
|
||||
|
||||
|
||||
def test_non_chat_span_is_ignored() -> None:
|
||||
client = _RecordingClient()
|
||||
|
||||
_on_end(_processor(client), _FakeSpan({GEN_AI_OPERATION_NAME: "embeddings"}))
|
||||
|
||||
assert client.events == []
|
||||
|
||||
|
||||
def test_on_end_never_raises_when_delivery_fails() -> None:
|
||||
# A telemetry failure must not propagate into Span.end() and break the
|
||||
# model request, nor wedge emission for every subsequent span.
|
||||
_on_end(_processor(_RaisingClient()), _FakeSpan(_chat_attrs())) # must not raise
|
||||
|
||||
|
||||
def test_on_end_never_raises_on_malformed_span() -> None:
|
||||
processor = _processor(_RecordingClient())
|
||||
|
||||
broken = _FakeSpan(_chat_attrs())
|
||||
broken.context = None # type: ignore[assignment]
|
||||
|
||||
_on_end(processor, broken) # must not raise
|
||||
|
||||
|
||||
def test_distinct_id_pulled_from_context_var() -> None:
|
||||
client = _RecordingClient()
|
||||
processor = _processor(client)
|
||||
|
||||
token = current_user_id.set("user-123") # type: ignore[arg-type]
|
||||
try:
|
||||
_on_end(processor, _FakeSpan(_chat_attrs()))
|
||||
finally:
|
||||
current_user_id.reset(token)
|
||||
|
||||
assert client.events, "expected at least one captured event"
|
||||
|
||||
|
||||
def test_setup_returns_none_when_disabled(app_settings: AppSettings) -> None:
|
||||
# conftest builds settings with posthog disabled; setup must no-op.
|
||||
assert setup_posthog_tracking(app_settings) is None
|
||||
Generated
+2
@@ -604,6 +604,7 @@ version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "pgvector" },
|
||||
{ name = "posthog" },
|
||||
@@ -630,6 +631,7 @@ 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" },
|
||||
|
||||
@@ -6951,31 +6951,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 = "Scoped credentials with per-key rate limits, permissions, and IP allowlists."
|
||||
subheading = "Personal credentials, each with its own usage tracking."
|
||||
|
||||
[portal.infrastructure.apiKeys.card]
|
||||
allowedIps = "Allowed IPs"
|
||||
anyIp = "Any IP (no allowlist)"
|
||||
created = "Created"
|
||||
lastUsed = "Last used"
|
||||
permissions = "Permissions"
|
||||
rateLimit = "Rate limit"
|
||||
rateLimitValue = "{{value}} req/min"
|
||||
revoke = "Revoke key"
|
||||
usageMonth = "Usage this month"
|
||||
usageToday = "Usage today"
|
||||
|
||||
[portal.infrastructure.apiKeys.empty]
|
||||
description = "Create a scoped key to start calling the Stirling API."
|
||||
description = "Create a 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,14 +7061,11 @@ 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"
|
||||
permissionsLabel = "Permissions"
|
||||
keyNamePlaceholder = "e.g. Production ingest"
|
||||
secretKeyCaption = "Secret key"
|
||||
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."
|
||||
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."
|
||||
subtitleCreated = "Copy this secret now — it won't be shown again."
|
||||
title = "Create API key"
|
||||
titleCreated = "Key created"
|
||||
@@ -7117,7 +7114,6 @@ title = "No regions deployed"
|
||||
[portal.infrastructure.keyLabel]
|
||||
active = "Active"
|
||||
revoked = "Revoked"
|
||||
rotateSoon = "Rotate soon"
|
||||
|
||||
[portal.infrastructure.modelLabel]
|
||||
active = "Active"
|
||||
@@ -8022,6 +8018,7 @@ title = "Sources"
|
||||
[portal.sources.actions]
|
||||
agentBuilder = "Agent Builder"
|
||||
connectSource = "Connect source"
|
||||
createApiKey = "Create API key"
|
||||
|
||||
[portal.sources.builder]
|
||||
back = "Back to sources"
|
||||
|
||||
@@ -43,23 +43,33 @@ export interface RecentDeployment {
|
||||
/* API Keys */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type ApiKeyStatus = "active" | "revoked" | "rotate-soon";
|
||||
export type ApiKeyPermission = "Read" | "Write" | "Admin";
|
||||
export type ApiKeyStatus = "active" | "revoked";
|
||||
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Masked prefix shown in the list, e.g. "sk_live_a3f8…". */
|
||||
/** Non-secret leading fragment, e.g. "sk_a3f81b2c". */
|
||||
prefix: string;
|
||||
created: string;
|
||||
/** Formatted last-use time, or "Never". */
|
||||
lastUsed: string;
|
||||
status: ApiKeyStatus;
|
||||
/** Requests/min ceiling. */
|
||||
rateLimit: number;
|
||||
permissions: ApiKeyPermission[];
|
||||
allowedIps: string[];
|
||||
/** Requests made today (UTC). */
|
||||
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;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
@@ -278,11 +288,34 @@ export async function fetchDeployments(
|
||||
);
|
||||
}
|
||||
|
||||
/** 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)}`,
|
||||
);
|
||||
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/security?tier=… */
|
||||
|
||||
@@ -6,21 +6,20 @@ import "@portal/views/Infrastructure.css";
|
||||
const BASE: ApiKey = {
|
||||
id: "key-1",
|
||||
name: "Production · ingest",
|
||||
prefix: "sk_live_a3f8…",
|
||||
created: "Mar 2, 2026",
|
||||
lastUsed: "2m ago",
|
||||
prefix: "sk_a3f81b2c",
|
||||
created: "2026-03-02",
|
||||
lastUsed: "2026-07-10 09:14",
|
||||
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" }}>
|
||||
@@ -32,37 +31,18 @@ const meta: Meta<typeof ApiKeyCard> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ApiKeyCard>;
|
||||
|
||||
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 Personal: Story = { args: { apiKey: BASE } };
|
||||
|
||||
export const Revoked: Story = {
|
||||
args: {
|
||||
apiKey: {
|
||||
...BASE,
|
||||
name: "Sandbox · webhook tester",
|
||||
prefix: "sk_test_2c4a…",
|
||||
prefix: "sk_2c4a91de",
|
||||
status: "revoked",
|
||||
lastUsed: "never",
|
||||
permissions: ["Read"],
|
||||
allowedIps: [],
|
||||
lastUsed: "Never",
|
||||
usageToday: 0,
|
||||
usageMonth: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoIpAllowlist: Story = {
|
||||
args: { apiKey: { ...BASE, allowedIps: [] } },
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Card, Chip, StatusBadge } from "@app/ui";
|
||||
import { Button, Card, StatusBadge } from "@app/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ApiKey } from "@portal/api/infrastructure";
|
||||
import {
|
||||
@@ -8,9 +8,17 @@ import {
|
||||
} from "@portal/components/infrastructure/infraFormat";
|
||||
|
||||
/** Collapsible row for a single API key: header summary + expandable detail grid. */
|
||||
export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
|
||||
export function ApiKeyCard({
|
||||
apiKey,
|
||||
onRevoke,
|
||||
}: {
|
||||
apiKey: ApiKey;
|
||||
/** Ask to revoke this key; the parent confirms before the destructive call. */
|
||||
onRevoke: (key: ApiKey) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const revocable = apiKey.status === "active";
|
||||
return (
|
||||
<Card padding="default" className="portal-infra__key">
|
||||
<Button
|
||||
@@ -51,14 +59,6 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
|
||||
<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,36 +71,20 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
|
||||
{apiKey.usageMonth.toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -20,14 +20,19 @@ type Story = StoryObj<typeof ApiKeysTab>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
const EMPTY = { keys: [] };
|
||||
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/v1/infrastructure/api-keys", async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
http.get(
|
||||
"*/api/v1/proprietary/ui-data/infrastructure/api-keys",
|
||||
async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json(EMPTY);
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -37,7 +42,9 @@ export const Empty: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/v1/infrastructure/api-keys", () => HttpResponse.json([])),
|
||||
http.get("*/api/v1/proprietary/ui-data/infrastructure/api-keys", () =>
|
||||
HttpResponse.json(EMPTY),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,20 +1,48 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { 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 { 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 state = useAsync<ApiKey[]>(() => fetchApiKeys(tier), [tier]);
|
||||
const { data: keys } = state;
|
||||
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portal-infra__stack">
|
||||
@@ -32,6 +60,14 @@ 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) => (
|
||||
@@ -48,15 +84,52 @@ export function ApiKeysTab() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{keys && keys.length > 0 && (
|
||||
{keys.length > 0 && (
|
||||
<div className="portal-infra__keys">
|
||||
{keys.map((k) => (
|
||||
<ApiKeyCard key={k.id} apiKey={k} />
|
||||
<ApiKeyCard key={k.id} apiKey={k} onRevoke={setPendingRevoke} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateKeyModal open={modalOpen} onClose={() => setModalOpen(false)} />
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,11 @@ const meta: Meta<typeof CreateKeyModal> = {
|
||||
title: "Portal/Infrastructure/CreateKeyModal",
|
||||
component: CreateKeyModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: { open: true, onClose: () => console.log("close") },
|
||||
args: {
|
||||
open: true,
|
||||
onClose: () => console.log("close"),
|
||||
onCreated: () => console.log("created"),
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CreateKeyModal>;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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,40 +1,30 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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";
|
||||
import { Banner, Button, CodeBlock, FormField, Input, Modal } from "@app/ui";
|
||||
import { createApiKey, type CreatedApiKey } from "@portal/api/infrastructure";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
|
||||
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 [perms, setPerms] = useState<ApiKeyPermission[]>(["Read"]);
|
||||
const [ips, setIps] = useState("");
|
||||
const [created, setCreated] = useState(false);
|
||||
const [created, setCreated] = useState<CreatedApiKey | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function reset() {
|
||||
setName("");
|
||||
setPerms(["Read"]);
|
||||
setIps("");
|
||||
setCreated(false);
|
||||
setCreated(null);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function close() {
|
||||
@@ -43,16 +33,18 @@ export function CreateKeyModal({
|
||||
setTimeout(reset, 200);
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -72,7 +64,7 @@ export function CreateKeyModal({
|
||||
}
|
||||
footer={
|
||||
created ? (
|
||||
<Button variant="primary" accent="premium" onClick={close}>
|
||||
<Button variant="primary" onClick={close}>
|
||||
{t("portal.infrastructure.createKey.done")}
|
||||
</Button>
|
||||
) : (
|
||||
@@ -82,8 +74,7 @@ export function CreateKeyModal({
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
accent="premium"
|
||||
disabled={name.trim() === "" || perms.length === 0}
|
||||
disabled={name.trim() === "" || submitting}
|
||||
onClick={createKey}
|
||||
>
|
||||
{t("portal.infrastructure.createKey.createKey")}
|
||||
@@ -95,7 +86,7 @@ export function CreateKeyModal({
|
||||
{created ? (
|
||||
<div className="portal-infra__stack">
|
||||
<CodeBlock
|
||||
code={DEMO_NEW_KEY_SECRET}
|
||||
code={created.secret}
|
||||
lang="bash"
|
||||
caption={t("portal.infrastructure.createKey.secretKeyCaption")}
|
||||
/>
|
||||
@@ -106,6 +97,8 @@ export function CreateKeyModal({
|
||||
</div>
|
||||
) : (
|
||||
<div className="portal-infra__form">
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
|
||||
<FormField
|
||||
label={t("portal.infrastructure.createKey.keyNameLabel")}
|
||||
required
|
||||
@@ -118,35 +111,6 @@ 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,13 +55,11 @@ 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> = {
|
||||
|
||||
@@ -25,10 +25,48 @@ export const infrastructureHandlers = [
|
||||
});
|
||||
}),
|
||||
|
||||
http.get("/v1/infrastructure/api-keys", async ({ request }) => {
|
||||
await delay(120);
|
||||
return HttpResponse.json(apiKeysFor(tierFrom(request)));
|
||||
}),
|
||||
// Real backend route; wildcard prefix intercepts both local (same-origin) and
|
||||
// SaaS (absolute) callers.
|
||||
http.get(
|
||||
"*/api/v1/proprietary/ui-data/infrastructure/api-keys",
|
||||
async ({ request }) => {
|
||||
await delay(120);
|
||||
return HttpResponse.json(apiKeysFor(tierFrom(request)));
|
||||
},
|
||||
),
|
||||
|
||||
// Create returns a one-time secret; the mock is non-persistent (dev/Storybook only).
|
||||
http.post(
|
||||
"*/api/v1/proprietary/ui-data/infrastructure/api-keys",
|
||||
async ({ request }) => {
|
||||
await delay(120);
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
name?: string;
|
||||
};
|
||||
return HttpResponse.json({
|
||||
key: {
|
||||
id: `key-${Date.now()}`,
|
||||
name: body.name ?? "New key",
|
||||
prefix: "sk_demo0000",
|
||||
created: "2026-07-10",
|
||||
lastUsed: "Never",
|
||||
status: "active",
|
||||
usageToday: 0,
|
||||
usageMonth: 0,
|
||||
usageTotal: 0,
|
||||
},
|
||||
secret: "sk_live_demo_key_rotate_in_prod",
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
http.delete(
|
||||
"*/api/v1/proprietary/ui-data/infrastructure/api-keys/:id",
|
||||
async () => {
|
||||
await delay(120);
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
},
|
||||
),
|
||||
|
||||
http.get("/v1/infrastructure/security", async ({ request }) => {
|
||||
await delay(120);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import type {
|
||||
ApiKey,
|
||||
ApiKeysResponse,
|
||||
AuditEvent,
|
||||
AuditLogResponse,
|
||||
AuditSummary,
|
||||
@@ -166,61 +167,57 @@ const API_KEYS_ALL: ApiKey[] = [
|
||||
{
|
||||
id: "key-1",
|
||||
name: "Production · ingest",
|
||||
prefix: "sk_live_a3f8…",
|
||||
created: "Mar 2, 2026",
|
||||
lastUsed: "2m ago",
|
||||
prefix: "sk_a3f81b2c",
|
||||
created: "2026-03-02",
|
||||
lastUsed: "2026-07-10 09:14",
|
||||
status: "active",
|
||||
rateLimit: 1200,
|
||||
permissions: ["Read", "Write"],
|
||||
allowedIps: ["52.14.0.0/16", "18.221.0.0/16"],
|
||||
usageToday: 84210,
|
||||
usageMonth: 2410933,
|
||||
usageTotal: 2410933,
|
||||
},
|
||||
{
|
||||
id: "key-2",
|
||||
name: "Analytics · read-only",
|
||||
prefix: "sk_live_77be…",
|
||||
created: "Jan 18, 2026",
|
||||
lastUsed: "41m ago",
|
||||
name: "Nightly batch",
|
||||
prefix: "sk_77be0f42",
|
||||
created: "2026-01-18",
|
||||
lastUsed: "2026-07-10 08:33",
|
||||
status: "active",
|
||||
rateLimit: 300,
|
||||
permissions: ["Read"],
|
||||
allowedIps: [],
|
||||
usageToday: 6120,
|
||||
usageMonth: 188400,
|
||||
usageTotal: 188400,
|
||||
},
|
||||
{
|
||||
id: "key-3",
|
||||
name: "Ops · admin (legacy)",
|
||||
prefix: "sk_live_d901…",
|
||||
created: "Aug 9, 2025",
|
||||
lastUsed: "6d ago",
|
||||
status: "rotate-soon",
|
||||
rateLimit: 600,
|
||||
permissions: ["Read", "Write", "Admin"],
|
||||
allowedIps: ["203.0.113.7/32"],
|
||||
name: "CI pipeline",
|
||||
prefix: "sk_d9013ab7",
|
||||
created: "2025-08-09",
|
||||
lastUsed: "2026-07-04 17:02",
|
||||
status: "active",
|
||||
usageToday: 0,
|
||||
usageMonth: 14200,
|
||||
usageTotal: 14200,
|
||||
},
|
||||
{
|
||||
id: "key-4",
|
||||
name: "Sandbox · webhook tester",
|
||||
prefix: "sk_test_2c4a…",
|
||||
created: "May 30, 2026",
|
||||
lastUsed: "never",
|
||||
prefix: "sk_2c4a91de",
|
||||
created: "2026-05-30",
|
||||
lastUsed: "Never",
|
||||
status: "revoked",
|
||||
rateLimit: 60,
|
||||
permissions: ["Read"],
|
||||
allowedIps: [],
|
||||
usageToday: 0,
|
||||
usageMonth: 0,
|
||||
usageTotal: 0,
|
||||
},
|
||||
];
|
||||
|
||||
export function apiKeysFor(tier: Tier): ApiKey[] {
|
||||
if (tier === "free") return API_KEYS_ALL.slice(0, 1);
|
||||
if (tier === "pro") return API_KEYS_ALL.slice(0, 3);
|
||||
return API_KEYS_ALL;
|
||||
export function apiKeysFor(tier: Tier): ApiKeysResponse {
|
||||
const keys =
|
||||
tier === "free"
|
||||
? API_KEYS_ALL.slice(0, 1)
|
||||
: tier === "pro"
|
||||
? API_KEYS_ALL.slice(0, 3)
|
||||
: API_KEYS_ALL;
|
||||
return { keys };
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
@jwt @auth @apikey
|
||||
Feature: API Keys management API
|
||||
|
||||
Tests for the portal API-keys REST API, which lets a user mint, list, and
|
||||
revoke named personal API keys.
|
||||
|
||||
Endpoints (all @EnterpriseEndpoint, JWT/ROLE required):
|
||||
- GET /api/v1/proprietary/ui-data/infrastructure/api-keys (list)
|
||||
- POST /api/v1/proprietary/ui-data/infrastructure/api-keys (create)
|
||||
- DELETE /api/v1/proprietary/ui-data/infrastructure/api-keys/{id} (revoke)
|
||||
|
||||
Because these are @EnterpriseEndpoint, authenticated responses may be 200
|
||||
(enterprise enabled) or 403 (feature not in this build). Unauthenticated
|
||||
requests must always be rejected with 401.
|
||||
|
||||
The legacy single per-user key (the global API key) must keep working so no
|
||||
key created before multi-key support is ever lost.
|
||||
|
||||
Admin credentials: username=admin, password=stirling
|
||||
Global API key: 123456789
|
||||
|
||||
# =========================================================================
|
||||
# LIST
|
||||
# =========================================================================
|
||||
|
||||
@positive
|
||||
Scenario: Admin can list API keys
|
||||
Given I am logged in as admin
|
||||
When I send a GET request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication
|
||||
Then the response status code should be one of "200, 403"
|
||||
|
||||
@negative
|
||||
Scenario: Unauthenticated list request returns 401
|
||||
When I send a GET request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with no authentication
|
||||
Then the response status code should be 401
|
||||
|
||||
# =========================================================================
|
||||
# CREATE
|
||||
# =========================================================================
|
||||
|
||||
@positive
|
||||
Scenario: Admin can create a personal API key
|
||||
Given I am logged in as admin
|
||||
When I send a JSON POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication and body '{"name": "bdd_personal_key"}'
|
||||
Then the response status code should be one of "200, 403"
|
||||
|
||||
@negative
|
||||
Scenario: Creating a key without a name is rejected
|
||||
Given I am logged in as admin
|
||||
When I send a JSON POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication and body '{"name": ""}'
|
||||
Then the response status code should be one of "400, 403"
|
||||
|
||||
@negative
|
||||
Scenario: Unauthenticated create request returns 401
|
||||
When I send a POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with no authentication
|
||||
Then the response status code should be 401
|
||||
|
||||
# =========================================================================
|
||||
# REVOKE
|
||||
# =========================================================================
|
||||
|
||||
@negative
|
||||
Scenario: Unauthenticated revoke request returns 401
|
||||
When I send a DELETE request to "/api/v1/proprietary/ui-data/infrastructure/api-keys/1" with no authentication
|
||||
Then the response status code should be 401
|
||||
|
||||
@positive
|
||||
Scenario: Admin revoking a non-existent key is handled, not a bypass
|
||||
Given I am logged in as admin
|
||||
When I send a DELETE request to "/api/v1/proprietary/ui-data/infrastructure/api-keys/999999" with JWT authentication
|
||||
Then the response status code should be one of "204, 403, 404"
|
||||
|
||||
# =========================================================================
|
||||
# LEGACY KEY BACKWARD COMPATIBILITY
|
||||
# =========================================================================
|
||||
|
||||
@positive
|
||||
Scenario: The legacy global API key still authenticates
|
||||
When I send a GET request to "/api/v1/auth/me" with API key "123456789"
|
||||
Then the response status code should be 200
|
||||
And the response JSON should have field "username"
|
||||
|
||||
@negative
|
||||
Scenario: An unknown API key is rejected
|
||||
When I send a GET request to "/api/v1/auth/me" with API key "not-a-real-api-key-000"
|
||||
Then the response status code should be 401
|
||||
Reference in New Issue
Block a user