Compare commits

...
Author SHA1 Message Date
Anthony Stirling 7500150fbd Merge remote-tracking branch 'origin/main' into pr6961
# Conflicts:
#	frontend/editor/public/locales/en-US/translation.toml
#	frontend/editor/src/portal/components/sources/SourceDetailCard.tsx
#	frontend/editor/src/portal/views/Sources.test.tsx
#	frontend/editor/src/portal/views/Sources.tsx
2026-07-14 12:55:22 +01:00
James BruntonandGitHub 776749277c Redesign policies to use typed mappings properly (#7017)
# Description of Changes
The Policies page and all the frontend logic for running Policies is not
making use of the bidirectional type mappings that we now have to safely
convert from frontend to backend param models and vice versa. This
changes the way we track the types throughout so we use the mappings
properly.

Because of this, the Add Watermark settings in Policies now actually
pre-populate with the defaults instead of with nothing like they
previously did.

<img width="791" height="725" alt="image"
src="https://github.com/user-attachments/assets/cbdf4ae0-35af-4792-bf64-89216e48d304"
/>
2026-07-14 09:58:04 +00:00
James BruntonandGitHub 41b1b89fcb Fix Policies page showing the Editor as a source twice (#7022)
# Description of Changes
The Policies page currently hard-codes the Editor to be available as a
source, but we now also have a virtual Editor source on the backend,
which the Policies page also renders. This removes the now-unnecessary
hard-coded Editor source.

## Before

<img width="842" height="640" alt="image"
src="https://github.com/user-attachments/assets/d78b33a3-fed4-4bb0-a02f-489ca2ae0614"
/>

## After

<img width="785" height="586" alt="image"
src="https://github.com/user-attachments/assets/37aa664f-74f2-41c7-b89a-9b483bffc3a2"
/>
2026-07-14 09:43:07 +00:00
James BruntonandGitHub 4d4e994562 Fix crash in Processor when loading tool settings with tooltips (#7015)
# Description of Changes
Some of the tool settings make use of editor preferences indirectly, but
the Processor never gets that provider, so it crashes when trying to
load them.
2026-07-14 09:09:23 +00:00
Frooodle d7df87b684 Merge branch 'main' into multi-api-keys-team-scope 2026-07-13 10:11:41 +01:00
Anthony Stirling 6e4e3e138c Fix per-key attribution on JWT path; scrub stale comments; add tests 2026-07-11 23:53:33 +01:00
Anthony Stirling 13ca7bd52e Reduce API keys to personal-only; drop team sharing 2026-07-11 23:24:06 +01:00
Anthony Stirling 456a96e15c Fix API key review findings: MCP scope guard, migration tx isolation, revoke confirm 2026-07-11 22:54:05 +01:00
Anthony Stirling 7c8a46255b Add API key access levels and processing-only key boundary 2026-07-11 19:30:20 +01:00
Anthony Stirling f1332ebe4f Merge remote-tracking branch 'origin/main' into pr6961-merge
# Conflicts:
#	app/proprietary/src/main/java/stirling/software/proprietary/service/PortalDocumentsService.java
2026-07-11 13:03:58 +01:00
EthanHealy01andGitHub d4a3df79e5 Merge branch 'main' into multi-api-keys-team-scope 2026-07-10 18:41:59 +01:00
Anthony Stirling 376c9aeb31 Cap shared team API keys below team-leader on SaaS 2026-07-10 17:16:24 +01:00
Anthony Stirling 19377cc688 Merge origin/main into multi-api-keys-team-scope 2026-07-10 16:16:15 +01:00
Anthony Stirling b22ffb3b3b Use normal accent for create-key modal buttons 2026-07-10 15:07:09 +01:00
Anthony Stirling 6319936720 Add Create API key action to the Sources view 2026-07-10 15:02:31 +01:00
Anthony Stirling aca0be7b71 Merge remote-tracking branch 'origin/main' into multi-api-keys-team-scope
# Conflicts:
#	frontend/editor/src/portal/components/sources/sourceTypes.ts
2026-07-10 14:14:27 +01:00
Anthony Stirling 1c6e9e8158 Remove unused apiKeyPermission i18n keys 2026-07-10 14:10:30 +01:00
Anthony Stirling e0e20559b6 Show API keys as read-only rows in the Sources section 2026-07-10 13:56:56 +01:00
Anthony Stirling af694d506d Remove Enterprise-license gate from API keys endpoint 2026-07-10 13:54:37 +01:00
Anthony Stirling d5806b56dd Allow admins to create team-scoped API keys 2026-07-10 13:34:11 +01:00
Anthony Stirling 49dbf76670 Remove dead IP-allowlist keys superseded by create-key rewrite 2026-07-10 13:05:32 +01:00
Anthony Stirling 0a7cd1185d Batch API key usage queries to avoid N+1 2026-07-10 13:02:09 +01:00
Anthony Stirling e67bb77a59 Merge remote-tracking branch 'origin/main' into multi-api-keys-team-scope
# Conflicts:
#	frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx
2026-07-10 13:00:36 +01:00
Anthony Stirling ae1f905975 Add multiple team-scoped API keys with per-key usage tracking 2026-07-10 12:47:54 +01:00
71 changed files with 2821 additions and 508 deletions
@@ -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");
@@ -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();
}
}
@@ -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 {
@@ -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) {}
@@ -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) {}
@@ -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) {}
@@ -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) {}
@@ -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);
}
}
@@ -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(
@@ -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());
@@ -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());
@@ -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;
}
}
@@ -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;
@@ -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;
}
}
@@ -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);
}
}
@@ -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);
}
@@ -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);
}
@@ -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();
}
@@ -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) {}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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"));
}
}
@@ -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;
}
}
}
@@ -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);
});
}
}
@@ -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) {
@@ -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) {
@@ -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();
}
@@ -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);
@@ -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();
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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);
}
}
@@ -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);
@@ -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();
@@ -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)");
}
}
@@ -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)
);
@@ -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");
@@ -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");
@@ -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
@@ -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.
@@ -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"
@@ -13,7 +13,6 @@ import {
import {
deserializeToolStep,
getExecutableTools,
serializeStepFromEndpoint,
serializeToolStep,
stepRequiresUpload,
type WorkingToolStep,
@@ -199,41 +198,6 @@ describe("serialize/deserialize round-trip", () => {
});
});
describe("serializeStepFromEndpoint", () => {
test("maps a wizard step's UI params to the backend contract, filling defaults", () => {
// The shape the policy setup wizard holds: an endpoint plus UI-shaped params
// (redact's `wordsToRedact`), with several fields left to their defaults.
const api = serializeStepFromEndpoint(
"/api/v1/security/auto-redact",
{ mode: "automatic", useRegex: true, wordsToRedact: ["ssn", "card"] },
dynamicRegistry,
);
expect(api.operation).toBe("/api/v1/security/auto-redact");
// wordsToRedact -> listOfText (the field the backend actually reads), and the
// frontend-only `mode` is dropped.
expect(api.parameters).toMatchObject({ listOfText: "ssn\ncard" });
expect(api.parameters).not.toHaveProperty("wordsToRedact");
expect(api.parameters).not.toHaveProperty("mode");
// Fields the wizard never set still get their defaults so the body is complete.
expect(api.parameters).toHaveProperty("wholeWordSearch");
expect(api.parameters).toHaveProperty("customPadding");
});
test("passes an unmapped endpoint's params through unchanged", () => {
expect(
serializeStepFromEndpoint(
"/api/v1/unknown/thing",
{ keep: true },
dynamicRegistry,
),
).toEqual({
operation: "/api/v1/unknown/thing",
parameters: { keep: true },
});
});
});
describe("stepRequiresUpload", () => {
const step = (params: Record<string, unknown>): WorkingToolStep => ({
toolId: "compress" as ToolId,
@@ -197,31 +197,6 @@ export function serializeToolStep(
return { operation, parameters };
}
/**
* Serialize a step held as an endpoint path plus frontend-shaped params - the form the policy setup
* wizard keeps, where params match the tool's UI shape (e.g. redact's `wordsToRedact`) rather than
* the backend contract - into the backend step contract, mapping params through the tool's
* `toApiParams` (merged over its defaults, so fields the wizard never set still get their defaults).
* The endpoint maps to a tool by path, so this works for dynamic-endpoint tools whose config
* endpoint is a function. Endpoints that map to no known tool pass through unchanged.
*/
export function serializeStepFromEndpoint(
operation: string,
params: ErasedToolParams,
registry: Partial<ToolRegistry>,
): ToolApiStep {
const match = findToolByEndpoint({ operation, parameters: params }, registry);
const config = match?.[1].operationConfig;
if (!config) return { operation, parameters: params };
const merged = { ...(config.defaultParameters ?? {}), ...params };
return {
operation: resolveEndpoint(config, merged) ?? operation,
parameters: config.toApiParams
? (config.toApiParams(merged) as Record<string, unknown>)
: {},
};
}
/**
* Find the registry tool for a stored step's endpoint: exact match for static endpoints, else
* membership in a dynamic tool's declared `endpoints` set (replaying its function can't recover a
@@ -0,0 +1,37 @@
import { describe, expect, test } from "vitest";
import { describeToolOperation } from "@app/hooks/tools/shared/toolOperationDescriptor";
interface Params {
a: number;
}
// A minimal config that type-checks against the flatten endpoint's model.
const CONFIG = {
endpoint: "/api/v1/misc/flatten" as const,
defaultParameters: { a: 1 } satisfies Params,
toApiParams: (p: Params) => ({ renderDpi: p.a }),
fromApiParams: (api: { renderDpi?: number }) => ({ a: api.renderDpi ?? 0 }),
};
describe("describeToolOperation", () => {
test("wraps the config's mappers and endpoint into a descriptor", () => {
const d = describeToolOperation("/api/v1/misc/flatten", CONFIG);
expect(d.endpoint).toBe("/api/v1/misc/flatten");
expect(d.toApi({ a: 200 })).toEqual({ renderDpi: 200 });
});
test("fromApi merges the mapped values over the defaults", () => {
const d = describeToolOperation("/api/v1/misc/flatten", CONFIG);
expect(d.fromApi({ renderDpi: 72 })).toEqual({ a: 72 });
});
test("throws when the config lacks a mapper", () => {
expect(() =>
describeToolOperation("/api/v1/misc/flatten", {
endpoint: "/api/v1/misc/flatten" as const,
defaultParameters: { a: 1 },
toApiParams: (p: Params) => ({ renderDpi: p.a }),
}),
).toThrow(/mappers/);
});
});
@@ -0,0 +1,59 @@
/**
* Typed wrapper over a tool's `toApiParams`/`fromApiParams` mappers, binding one endpoint to safe
* frontend<->backend parameter conversion.
*/
import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
export interface ToolOperationDescriptor<E extends ToolEndpoint, TParams> {
readonly endpoint: E;
readonly defaultParameters: TParams;
toApi(params: TParams): ToolApiParams[E];
/** Backend model -> full frontend params (defaults merged under the mapped values). */
fromApi(api: ToolApiParams[E]): TParams;
}
/**
* Structural subset of a tool's config. `CE` is the config's declared endpoint type, inferred from
* the `endpoint` field: the literal for static tools, or the whole `ToolEndpoint` union for
* dynamic-endpoint tools (whose endpoint is a function typed against the union).
*/
export interface BidirectionalToolConfig<TParams, CE extends ToolEndpoint> {
endpoint: CE | null | ((params: TParams) => CE | null);
defaultParameters?: TParams;
toApiParams?(params: TParams): ToolApiParams[CE];
fromApiParams?(api: ToolApiParams[CE]): Partial<TParams>;
}
/**
* Pin a config to `endpoint` (passed explicitly, since dynamic-endpoint tools declare `endpoint` as
* a function). `E extends CE` rejects pairing a static tool's config with the wrong endpoint, while
* allowing a dynamic tool whose `CE` is the full union. Throws when mappers or defaults are missing.
*/
export function describeToolOperation<
E extends CE,
CE extends ToolEndpoint,
TParams,
>(
endpoint: E,
config: BidirectionalToolConfig<TParams, CE>,
): ToolOperationDescriptor<E, TParams> {
const { toApiParams, fromApiParams, defaultParameters } = config;
if (!toApiParams || !fromApiParams || defaultParameters === undefined) {
throw new Error(
`describeToolOperation: "${endpoint}" is missing mappers or defaults`,
);
}
return {
endpoint,
defaultParameters,
// A dynamic tool's mapper is typed against the union; narrow to this endpoint (sound - the
// runtime mapper produces this endpoint's model).
toApi: (params) => toApiParams(params) as ToolApiParams[E],
fromApi: (api) =>
({
...defaultParameters,
...fromApiParams(api as ToolApiParams[CE]),
}) as TParams,
};
}
@@ -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=… */
+22 -50
View File
@@ -13,6 +13,8 @@ import type { TFunction } from "i18next";
import { apiClient } from "@portal/api/http";
import { fromWirePolicy, toWirePolicy } from "@app/policies/codec";
import { runsToActivity, runsToStats } from "@app/policies/runs";
import { policyStep, type PolicyToolStep } from "@app/policies/operations";
import type { ToolEndpoint } from "@app/types/toolApiTypes";
import type {
PolicyDecodedState,
PolicyRunView,
@@ -66,7 +68,7 @@ export interface PolicyConfigDef {
rules: string[];
scopeLabel: string;
fields: PolicyField[];
defaultOperations: WirePipelineStep[];
defaultOperations: PolicyToolStep[];
}
export interface PolicyState {
@@ -128,20 +130,11 @@ export interface CatalogueEntry {
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Tool → endpoint registry */
/* Endpoint display labels */
/* ──────────────────────────────────────────────────────────────────────── */
export const TOOL_ENDPOINTS: Record<string, string> = {
redact: "/api/v1/security/auto-redact",
sanitize: "/api/v1/security/sanitize-pdf",
watermark: "/api/v1/security/add-watermark",
ocr: "/api/v1/misc/ocr-pdf",
flatten: "/api/v1/misc/flatten",
compress: "/api/v1/misc/compress-pdf",
};
/** Values are i18n keys — render with t(). */
export const ENDPOINT_LABELS: Record<string, string> = {
/** i18n keys keyed by {@link ToolEndpoint}; labels stored steps in the detail view. */
export const ENDPOINT_LABELS: Partial<Record<ToolEndpoint, string>> = {
"/api/v1/security/auto-redact": "portal.policies.endpoints.autoRedact",
"/api/v1/security/sanitize-pdf": "portal.policies.endpoints.sanitizePdf",
"/api/v1/security/add-watermark": "portal.policies.endpoints.addWatermark",
@@ -154,7 +147,8 @@ export function humanizeEndpoint(
path: string,
t: (key: string) => string,
): string {
if (ENDPOINT_LABELS[path]) return t(ENDPOINT_LABELS[path]);
const label = ENDPOINT_LABELS[path as ToolEndpoint];
if (label) return t(label);
const last = path.split("/").filter(Boolean).pop() ?? path;
return last
.replace(/-/g, " ")
@@ -230,10 +224,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.ingestion.rules.3",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [
{ operation: TOOL_ENDPOINTS.ocr, parameters: {} },
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
],
defaultOperations: [policyStep("ocr"), policyStep("flatten")],
fields: [
{
label: "portal.policies.config.ingestion.fields.minConfidence",
@@ -260,32 +251,16 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [
{
operation: TOOL_ENDPOINTS.redact,
parameters: {
mode: "automatic",
useRegex: true,
convertPDFToImage: true,
wordsToRedact: DEFAULT_PII_PATTERNS,
},
},
{
operation: TOOL_ENDPOINTS.sanitize,
parameters: {
removeJavaScript: true,
removeEmbeddedFiles: false,
removeMetadata: false,
removeLinks: false,
removeFonts: false,
},
},
{
operation: TOOL_ENDPOINTS.watermark,
// convertPDFToImage bakes the watermark in so it can't be stripped
parameters: {
convertPDFToImage: true,
},
},
// Flatten to image so redactions can't be lifted off.
policyStep("redact", {
useRegex: true,
convertPDFToImage: true,
wordsToRedact: DEFAULT_PII_PATTERNS,
}),
// JavaScript removal only; the tool enables removeEmbeddedFiles by default, so turn it off.
policyStep("sanitize", { removeEmbeddedFiles: false }),
// Bake in via image so it can't be stripped.
policyStep("watermark", { convertPDFToImage: true }),
],
fields: [],
},
@@ -297,10 +272,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.compliance.rules.2",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [
{ operation: TOOL_ENDPOINTS.sanitize, parameters: {} },
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
],
defaultOperations: [policyStep("sanitize"), policyStep("flatten")],
fields: [
{
label: "portal.policies.config.compliance.fields.frameworks",
@@ -343,7 +315,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.routing.rules.2",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
defaultOperations: [policyStep("compress")],
fields: [
{
label: "portal.policies.config.routing.fields.destination",
@@ -374,7 +346,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.retention.rules.2",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
defaultOperations: [policyStep("compress")],
fields: [
{
label: "portal.policies.config.retention.fields.keepFor",
@@ -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> = {
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { Tooltip } from "@app/components/shared/Tooltip";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
}),
}));
// A stand-in tool-settings UI that uses the shared editor Tooltip. The Tooltip
// pulls in the Preferences + Sidebar contexts, which the portal does not mount
// app-wide — so this reproduces the "usePreferences must be used within a
// PreferencesProvider" crash unless PipelineStepSettings supplies them.
function TooltipSettings() {
return (
<Tooltip content="help">
<button type="button">field</button>
</Tooltip>
);
}
const step = {
support: "editable",
toolId: "compress",
params: {},
} as unknown as WorkingToolStep;
const registry = {
compress: { automationSettings: TooltipSettings },
} as unknown as Partial<ToolRegistry>;
describe("PipelineStepSettings", () => {
it("renders reused editor tool settings (which use the shared Tooltip) without app-wide Preferences/Sidebar providers", () => {
expect(() =>
render(
<MantineProvider>
<PipelineStepSettings
step={step}
registry={registry}
onChange={() => {}}
/>
</MantineProvider>,
),
).not.toThrow();
expect(screen.getByText("field")).toBeInTheDocument();
});
});
@@ -1,6 +1,8 @@
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { Banner } from "@app/ui";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import { SidebarProvider } from "@app/contexts/SidebarContext";
import { type ToolRegistry } from "@app/data/toolsTaxonomy";
import { type ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes";
import { type WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
@@ -46,14 +48,18 @@ export function PipelineStepSettings({
}
return (
<Suspense fallback={null}>
<Settings
parameters={step.params}
onParameterChange={(key, value) =>
onChange({ ...step.params, [key]: value })
}
disabled={false}
/>
</Suspense>
<PreferencesProvider>
<SidebarProvider>
<Suspense fallback={null}>
<Settings
parameters={step.params}
onParameterChange={(key, value) =>
onChange({ ...step.params, [key]: value })
}
disabled={false}
/>
</Suspense>
</SidebarProvider>
</PreferencesProvider>
);
}
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fireEvent,
render as baseRender,
screen,
waitFor,
} from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
import {
POLICY_CATEGORIES,
POLICY_CONFIG,
type CatalogueEntry,
type DecoratedPolicy,
type PolicySetupResult,
type PipelineStep,
} from "@portal/api/policies";
const render = (ui: Parameters<typeof baseRender>[0]) =>
baseRender(ui, { wrapper: MantineProvider });
// Deterministic i18n: return the fallback when given, else the key. initReactI18next is stubbed
// because the import graph pulls core/i18n.ts, which registers it as a plugin.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
// Second arg is a string fallback in some call sites and an interpolation object in others;
// only treat a string as the fallback.
t: (key: string, fallback?: unknown) =>
typeof fallback === "string" ? fallback : key,
i18n: { changeLanguage: vi.fn() },
}),
initReactI18next: { type: "3rdParty", init: vi.fn() },
}));
const fetchSources = vi.fn();
vi.mock("@portal/api/sources", () => ({
fetchSources: () => fetchSources(),
}));
const CONTINUE = "portal.policies.wizard.actions.continue";
const SAVE_CHANGES = "portal.policies.wizard.actions.saveChanges";
const ENABLE = "portal.policies.wizard.actions.enablePolicy";
const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
const securityConfig = POLICY_CONFIG.security;
function editEntry(steps: PipelineStep[]): CatalogueEntry {
const policy: DecoratedPolicy = {
category: security,
config: securityConfig,
state: {
configured: true,
status: "active",
sources: ["editor"],
scopeTypes: [],
reviewerEmail: "",
fieldValues: {},
runOn: "upload",
outputMode: "new_version",
outputName: "",
outputNamePosition: "suffix",
maxRetries: 0,
retryDelayMinutes: 0,
backendId: "pol-1",
isDefault: true,
},
steps,
stats: { enforced: 0, dataProcessed: "-", activeFor: "-" },
activity: [],
};
return { category: security, config: securityConfig, policy };
}
/** Advance the wizard from the workflow tab to the settings tab and submit. */
async function submitWizard(saveLabel: string) {
fireEvent.click(await screen.findByRole("button", { name: CONTINUE }));
fireEvent.click(await screen.findByRole("button", { name: saveLabel }));
}
describe("PolicySetupWizard", () => {
beforeEach(() => {
fetchSources.mockResolvedValue({ sources: [] });
});
it("round-trips a saved step's backend params on edit", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
const entry = editEntry([
{
operation: "/api/v1/security/auto-redact",
parameters: { listOfText: "foo\nbar", useRegex: true },
},
]);
render(
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={onSubmit} />,
);
await submitWizard(SAVE_CHANGES);
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
const result = onSubmit.mock.calls[0][1] as PolicySetupResult;
// Only the saved tool is enabled on edit, and its patterns survive the wire -> UI -> wire trip.
expect(result.steps).toEqual([
expect.objectContaining({
operation: "/api/v1/security/auto-redact",
parameters: expect.objectContaining({ listOfText: "foo\nbar" }),
}),
]);
});
it("seeds the preset chain for a new policy (redact + sanitize on, watermark off)", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
const entry: CatalogueEntry = {
category: security,
config: securityConfig,
policy: null,
};
render(
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={onSubmit} />,
);
await submitWizard(ENABLE);
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
const result = onSubmit.mock.calls[0][1] as PolicySetupResult;
const endpoints = result.steps.map((s) => s.operation);
expect(endpoints).toEqual([
"/api/v1/security/auto-redact",
"/api/v1/security/sanitize-pdf",
]);
// Redact carries the preset PII patterns as the backend's listOfText.
const redact = result.steps[0].parameters as { listOfText?: string };
expect(redact.listOfText).toBeTruthy();
});
});
@@ -13,23 +13,24 @@ import {
} from "@app/ui";
import { SettingsRow } from "@app/ui/SettingsRow";
import {
TOOL_ENDPOINTS,
humanizeEndpoint,
type CatalogueEntry,
type PipelineStep,
type PolicySetupResult,
} from "@portal/api/policies";
import type { ToolRegistry, ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import {
deserializeToolStep,
serializeStepFromEndpoint,
} from "@app/hooks/tools/shared/toolAutomation";
policyEndpoint,
policyStepFromWire,
policyStepToWire,
type PolicyParams,
type PolicyToolId,
type PolicyToolStep,
} from "@app/policies/operations";
import { fetchSources } from "@portal/api/sources";
import { useAsync } from "@portal/hooks/useAsync";
import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow";
import { policyIcon } from "@portal/components/policies/policyIcons";
import { sourceTypeMeta } from "@portal/components/sources/sourceTypes";
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig";
import "@portal/views/Policies.css";
@@ -47,12 +48,8 @@ interface PolicySetupWizardProps {
type Step = "workflow" | "settings";
/** A configurable tool in the workflow step: whether it runs + its params. */
interface ToolState {
operation: string;
enabled: boolean;
parameters: Record<string, unknown>;
}
/** A policy step plus whether it runs. */
type ToolState = PolicyToolStep & { enabled: boolean };
/** Resolve each field's effective value: saved override, else definition default. */
function resolveFieldValues(
@@ -69,9 +66,8 @@ function resolveFieldValues(
* round-trips); otherwise the category preset's default chain. Each preset step
* starts enabled the user toggles tools off in the workflow.
*/
// Temporary: tracks which tools start disabled until the tool registry lands in
// the portal and can drive this via registry metadata or a defaultEnabled flag.
const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]);
// Temporary until the catalogue carries a defaultEnabled flag.
const DISABLED_BY_DEFAULT = new Set<PolicyToolId>(["watermark"]);
/**
* Policy-facing framing for each capability a policy can include. Labels and
@@ -81,43 +77,43 @@ const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]);
* the humanised endpoint name with no description.
*/
const CAPABILITY_META: Record<
string,
PolicyToolId,
{ labelKey: string; labelEn: string; descKey: string; descEn: string }
> = {
[TOOL_ENDPOINTS.redact]: {
redact: {
labelKey: "portal.policies.wizard.capability.redact.label",
labelEn: "Redact sensitive information",
descKey: "portal.policies.wizard.capability.redact.desc",
descEn:
"Finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read.",
},
[TOOL_ENDPOINTS.sanitize]: {
sanitize: {
labelKey: "portal.policies.wizard.capability.sanitize.label",
labelEn: "Strip active content",
descKey: "portal.policies.wizard.capability.sanitize.desc",
descEn:
"Removes hidden JavaScript so nothing can run automatically when the document is opened.",
},
[TOOL_ENDPOINTS.watermark]: {
watermark: {
labelKey: "portal.policies.wizard.capability.watermark.label",
labelEn: "Apply a watermark",
descKey: "portal.policies.wizard.capability.watermark.desc",
descEn: "Stamps a visible mark (e.g. “Confidential”) across every page.",
},
[TOOL_ENDPOINTS.ocr]: {
ocr: {
labelKey: "portal.policies.wizard.capability.ocr.label",
labelEn: "Make text searchable",
descKey: "portal.policies.wizard.capability.ocr.desc",
descEn: "Runs OCR so scanned pages become selectable, searchable text.",
},
[TOOL_ENDPOINTS.flatten]: {
flatten: {
labelKey: "portal.policies.wizard.capability.flatten.label",
labelEn: "Flatten the document",
descKey: "portal.policies.wizard.capability.flatten.desc",
descEn:
"Merges form fields and annotations into the page so they can't be edited.",
},
[TOOL_ENDPOINTS.compress]: {
compress: {
labelKey: "portal.policies.wizard.capability.compress.label",
labelEn: "Reduce file size",
descKey: "portal.policies.wizard.capability.compress.desc",
@@ -125,29 +121,24 @@ const CAPABILITY_META: Record<
},
};
function seedTools(
entry: CatalogueEntry,
registry: Partial<ToolRegistry>,
): ToolState[] {
function seedTools(entry: CatalogueEntry): ToolState[] {
const savedSteps = entry.policy?.steps ?? [];
const savedByOp = new Map(savedSteps.map((s) => [s.operation, s]));
// Always use defaultOperations as the canonical list so tools added after a
// policy was first saved still appear when editing.
return entry.config.defaultOperations.map((s) => {
const saved = savedByOp.get(s.operation);
const savedByTool = new Map<PolicyToolId, PolicyToolStep>();
for (const wire of savedSteps) {
const step = policyStepFromWire(wire);
if (step) savedByTool.set(step.toolId, step);
}
// defaultOperations is the canonical list (so tools added later still show on edit); a saved
// step's params win over the preset.
return entry.config.defaultOperations.map((preset) => {
const saved = savedByTool.get(preset.toolId);
return {
operation: s.operation,
...(saved ?? preset),
enabled: saved
? true
: savedSteps.length > 0
? false
: !DISABLED_BY_DEFAULT.has(s.operation),
// Saved steps are in the backend contract shape; map them back to the UI
// shape the config controls edit (e.g. `listOfText` -> `wordsToRedact`).
// Presets are already authored in the UI shape, so use them as-is.
parameters: saved
? deserializeToolStep(saved, registry).params
: s.parameters,
: !DISABLED_BY_DEFAULT.has(preset.toolId),
};
});
}
@@ -185,26 +176,12 @@ function PolicySetupWizardBody({
onSubmit: (entry: CatalogueEntry, result: PolicySetupResult) => Promise<void>;
}) {
const { t } = useTranslation();
const { allTools: toolRegistry } = useToolRegistry();
// Portal tool operations are endpoint paths (/api/v1/…), not short registry IDs.
// Build a reverse map so we can look up icons and display names by endpoint.
const registryByEndpoint = useMemo(() => {
const map = new Map<string, ToolRegistryEntry>();
for (const entry of Object.values(toolRegistry)) {
const ep = (entry as ToolRegistryEntry).operationConfig?.endpoint;
if (typeof ep === "string") map.set(ep, entry as ToolRegistryEntry);
}
return map;
}, [toolRegistry]);
const { category, config, policy } = entry;
const isEdit = policy != null;
const [step, setStep] = useState<Step>("workflow");
const [tools, setTools] = useState<ToolState[]>(() =>
seedTools(entry, toolRegistry),
);
const [tools, setTools] = useState<ToolState[]>(() => seedTools(entry));
const [fieldValues, setFieldValues] = useState(() =>
resolveFieldValues(entry),
);
@@ -213,22 +190,11 @@ function PolicySetupWizardBody({
);
const sourcesAsync = useAsync(() => fetchSources(), []);
const availableSources = useMemo(() => {
const backendSources = (sourcesAsync.data?.sources ?? []).filter(
(s) => s.status !== "disabled",
);
const editorSource = {
id: "editor",
name: t("portal.sources.types.editor.label"),
type: "editor",
status: "active" as const,
referenceCount: 0,
referencingPolicies: [],
config: [],
docsTotal: null,
};
return [editorSource, ...backendSources];
}, [sourcesAsync.data, t]);
const availableSources = useMemo(
() =>
(sourcesAsync.data?.sources ?? []).filter((s) => s.status !== "disabled"),
[sourcesAsync.data],
);
// Document-type scoping has no UI; preserve any saved scope on edit and
// default new policies to all document types.
const [scopeTypes] = useState<string[]>(policy?.state.scopeTypes ?? []);
@@ -256,9 +222,20 @@ function PolicySetupWizardBody({
const enabledTools = useMemo(() => tools.filter((tl) => tl.enabled), [tools]);
function patchTool(operation: string, patch: Partial<ToolState>) {
function setToolEnabled(toolId: PolicyToolId, enabled: boolean) {
setTools((prev) =>
prev.map((tl) => (tl.operation === operation ? { ...tl, ...patch } : tl)),
prev.map((tl) => (tl.toolId === toolId ? { ...tl, enabled } : tl)),
);
}
function setToolParams<Id extends PolicyToolId>(
toolId: Id,
params: PolicyParams<Id>,
) {
setTools((prev) =>
prev.map((tl) =>
tl.toolId === toolId ? ({ ...tl, params } as ToolState) : tl,
),
);
}
@@ -277,11 +254,8 @@ function PolicySetupWizardBody({
}
setError(null);
setSubmitting(true);
// Map each tool's UI-shaped params (e.g. redact's `wordsToRedact`) into the
// backend step contract (e.g. `listOfText`) via its `toApiParams`; saving the
// UI shape verbatim would drop those fields and the step would run with none.
const steps: PipelineStep[] = enabledTools.map((tl) =>
serializeStepFromEndpoint(tl.operation, tl.parameters, toolRegistry),
policyStepToWire(tl),
);
try {
await onSubmit(entry, {
@@ -386,20 +360,16 @@ function PolicySetupWizardBody({
<Card padding="none">
<div className="portal-policies__capabilities">
{tools.map((tl) => {
const meta = CAPABILITY_META[tl.operation];
const meta = CAPABILITY_META[tl.toolId];
const label = meta
? t(meta.labelKey, meta.labelEn)
: (registryByEndpoint.get(tl.operation)?.name ??
humanizeEndpoint(tl.operation, t));
: humanizeEndpoint(policyEndpoint(tl.toolId), t);
const description = meta
? t(meta.descKey, meta.descEn)
: undefined;
const hasConfig =
tl.operation === TOOL_ENDPOINTS.redact ||
tl.operation === TOOL_ENDPOINTS.watermark;
return (
<div
key={tl.operation}
key={tl.toolId}
className="portal-policies__capability"
data-on={tl.enabled || undefined}
>
@@ -411,27 +381,27 @@ function PolicySetupWizardBody({
size="sm"
checked={tl.enabled}
onChange={(checked) =>
patchTool(tl.operation, { enabled: checked })
setToolEnabled(tl.toolId, checked)
}
label=""
/>
}
/>
{tl.enabled && hasConfig && (
{tl.enabled && (
<div className="portal-policies__capability-config">
{tl.operation === TOOL_ENDPOINTS.redact && (
{tl.toolId === "redact" && (
<PolicyRedactConfig
parameters={tl.parameters}
onChange={(parameters) =>
patchTool(tl.operation, { parameters })
parameters={tl.params}
onChange={(params) =>
setToolParams("redact", params)
}
/>
)}
{tl.operation === TOOL_ENDPOINTS.watermark && (
{tl.toolId === "watermark" && (
<PolicyWatermarkConfig
parameters={tl.parameters}
onChange={(parameters) =>
patchTool(tl.operation, { parameters })
parameters={tl.params}
onChange={(params) =>
setToolParams("watermark", params)
}
/>
)}
@@ -475,9 +445,8 @@ function PolicySetupWizardBody({
{t("portal.policies.wizard.sources.loading")}
</p>
) : (
// The editor is always an available source (unconditionally prepended
// to availableSources), so the list is never empty no "no sources"
// state exists.
// The backend always returns the editor as a virtual source, so the
// loaded list is never empty - no "no sources" state exists.
<div className="portal-policies__sources">
{availableSources.map((src) => (
// A selectable multi-line tile (icon + name + type + check).
@@ -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 };
}
/* ──────────────────────────────────────────────────────────────────────── */
+23 -3
View File
@@ -4,13 +4,33 @@
* only builds seed data for the MSW handlers and tests.
*/
import type { PolicyRunView, WirePolicy } from "@app/policies/types";
import { POLICY_CONFIG } from "@portal/api/policies";
import type {
PolicyRunView,
WirePipelineStep,
WirePolicy,
} from "@app/policies/types";
/* ──────────────────────────────────────────────────────────────────────── */
/* Seed data — real backend wire format */
/* ──────────────────────────────────────────────────────────────────────── */
// Literal wire steps (not derived from the catalogue) so this fixtures module stays independent of
// @portal/api/policies and its heavy tool-operation import graph.
const SECURITY_STEPS: WirePipelineStep[] = [
{
operation: "/api/v1/security/auto-redact",
parameters: {
listOfText: "",
useRegex: true,
convertPDFToImage: true,
},
},
{
operation: "/api/v1/security/sanitize-pdf",
parameters: { removeJavaScript: true },
},
];
export function seedPolicies(): WirePolicy[] {
return [
{
@@ -19,7 +39,7 @@ export function seedPolicies(): WirePolicy[] {
owner: "security@acme.com",
enabled: true,
trigger: null,
steps: POLICY_CONFIG.security.defaultOperations,
steps: SECURITY_STEPS,
output: {
type: "inline",
options: {
@@ -1,6 +1,7 @@
import { MultiSelect } from "@app/ui/MultiSelect";
import { useTranslation } from "react-i18next";
import { PII_PRESETS } from "@app/data/policyDefinitions";
import type { RedactParameters } from "@app/hooks/tools/redact/useRedactParameters";
/** The set of preset regexes — used to separate preset words from custom ones. */
export const PRESET_PATTERNS = new Set(PII_PRESETS.map((p) => p.pattern));
@@ -8,8 +9,8 @@ const PATTERN_BY_VALUE = new Map(PII_PRESETS.map((p) => [p.value, p.pattern]));
const VALUE_BY_PATTERN = new Map(PII_PRESETS.map((p) => [p.pattern, p.value]));
interface PolicyPiiFieldProps {
parameters: Record<string, unknown>;
onChange: (parameters: Record<string, unknown>) => void;
parameters: RedactParameters;
onChange: (parameters: RedactParameters) => void;
disabled?: boolean;
}
@@ -26,9 +27,7 @@ export function PolicyPiiField({
disabled,
}: PolicyPiiFieldProps) {
const { t } = useTranslation();
const words = Array.isArray(parameters.wordsToRedact)
? (parameters.wordsToRedact as string[])
: [];
const words = parameters.wordsToRedact;
const selected = words
.map((w) => VALUE_BY_PATTERN.get(w))
.filter((v): v is string => Boolean(v));
@@ -1,9 +1,10 @@
import { useEffect } from "react";
import { PolicyPiiField } from "@app/components/policies/PolicyPiiField";
import type { RedactParameters } from "@app/hooks/tools/redact/useRedactParameters";
interface PolicyRedactConfigProps {
parameters: Record<string, unknown>;
onChange: (parameters: Record<string, unknown>) => void;
parameters: RedactParameters;
onChange: (parameters: RedactParameters) => void;
disabled?: boolean;
}
@@ -3,8 +3,8 @@ import AddWatermarkSingleStepSettings from "@app/components/tools/addWatermark/A
import type { AddWatermarkParameters } from "@app/hooks/tools/addWatermark/useAddWatermarkParameters";
interface PolicyWatermarkConfigProps {
parameters: Record<string, unknown>;
onChange: (parameters: Record<string, unknown>) => void;
parameters: AddWatermarkParameters;
onChange: (parameters: AddWatermarkParameters) => void;
disabled?: boolean;
}
@@ -20,7 +20,7 @@ export function PolicyWatermarkConfig({
disabled,
}: PolicyWatermarkConfigProps) {
useEffect(() => {
const patch: Record<string, unknown> = {};
const patch: Partial<AddWatermarkParameters> = {};
if (parameters.convertPDFToImage !== true) patch.convertPDFToImage = true;
// Policies only support text watermarks.
if (parameters.watermarkType !== "text") patch.watermarkType = "text";
@@ -29,7 +29,7 @@ export function PolicyWatermarkConfig({
return (
<AddWatermarkSingleStepSettings
parameters={parameters as unknown as AddWatermarkParameters}
parameters={parameters}
onParameterChange={(key, value) =>
onChange({ ...parameters, [key]: value })
}
@@ -0,0 +1,112 @@
import { describe, expect, test } from "vitest";
import {
POLICY_OPERATIONS,
policyEndpoint,
policyStep,
policyStepFromWire,
policyStepToWire,
policyToolIdForEndpoint,
type PolicyToolId,
} from "@app/policies/operations";
const ALL_TOOL_IDS = Object.keys(POLICY_OPERATIONS) as PolicyToolId[];
describe("POLICY_OPERATIONS", () => {
test("every category operation is a typed descriptor with a known endpoint", () => {
// The catalogue uses these six across all categories; each must be wired.
expect(ALL_TOOL_IDS.sort()).toEqual([
"compress",
"flatten",
"ocr",
"redact",
"sanitize",
"watermark",
]);
for (const id of ALL_TOOL_IDS) {
expect(POLICY_OPERATIONS[id].endpoint).toBe(policyEndpoint(id));
expect(typeof POLICY_OPERATIONS[id].toApi).toBe("function");
expect(typeof POLICY_OPERATIONS[id].fromApi).toBe("function");
}
});
test("policyEndpoint returns the pinned endpoint literal", () => {
expect(policyEndpoint("redact")).toBe("/api/v1/security/auto-redact");
expect(policyEndpoint("sanitize")).toBe("/api/v1/security/sanitize-pdf");
expect(policyEndpoint("watermark")).toBe("/api/v1/security/add-watermark");
expect(policyEndpoint("ocr")).toBe("/api/v1/misc/ocr-pdf");
expect(policyEndpoint("flatten")).toBe("/api/v1/misc/flatten");
expect(policyEndpoint("compress")).toBe("/api/v1/misc/compress-pdf");
});
test("policyToolIdForEndpoint maps endpoints back, and rejects non-policy ones", () => {
for (const id of ALL_TOOL_IDS) {
expect(policyToolIdForEndpoint(policyEndpoint(id))).toBe(id);
}
expect(policyToolIdForEndpoint("/api/v1/misc/repair")).toBeNull();
expect(policyToolIdForEndpoint("not-an-endpoint")).toBeNull();
});
});
describe("policyStep", () => {
test("merges partial params over the tool's defaults", () => {
const step = policyStep("redact", {
useRegex: true,
wordsToRedact: ["ssn", "card"],
});
expect(step.toolId).toBe("redact");
// Overrides applied...
expect(step.params.useRegex).toBe(true);
expect(step.params.wordsToRedact).toEqual(["ssn", "card"]);
// ...and untouched fields fall back to the tool's defaults.
expect(step.params.mode).toBe("automatic");
expect(step.params.redactColor).toBe("#000000");
});
});
describe("wire conversion", () => {
test("redact maps frontend params to the backend request model (wordsToRedact -> listOfText)", () => {
const wire = policyStepToWire(
policyStep("redact", {
useRegex: true,
convertPDFToImage: true,
wordsToRedact: ["ssn", "card"],
}),
);
expect(wire.operation).toBe("/api/v1/security/auto-redact");
// The backend field the endpoint actually reads, and no frontend-only `mode`/`wordsToRedact`.
expect(wire.parameters).toMatchObject({
listOfText: "ssn\ncard",
useRegex: true,
convertPDFToImage: true,
});
expect(wire.parameters).not.toHaveProperty("wordsToRedact");
expect(wire.parameters).not.toHaveProperty("mode");
});
test("every policy operation round-trips through wire and back", () => {
for (const id of ALL_TOOL_IDS) {
const step = policyStep(id);
const back = policyStepFromWire(policyStepToWire(step));
expect(back?.toolId).toBe(id);
}
});
test("redact round-trip preserves the configured patterns", () => {
const step = policyStep("redact", {
useRegex: true,
wordsToRedact: ["ssn", "card"],
});
const back = policyStepFromWire(policyStepToWire(step));
expect(back?.toolId).toBe("redact");
if (back?.toolId === "redact") {
expect(back.params.wordsToRedact).toEqual(["ssn", "card"]);
expect(back.params.useRegex).toBe(true);
}
});
test("a non-policy endpoint decodes to null", () => {
expect(
policyStepFromWire({ operation: "/api/v1/misc/repair", parameters: {} }),
).toBeNull();
});
});
@@ -0,0 +1,131 @@
/**
* The tool operations the Policies feature can run, each a typed {@link ToolOperationDescriptor}.
* Source of truth for the catalogue, wizard, and wire conversion. Add a tool here to use it in a
* policy - the catalogue can't reference an untyped operation.
*/
import { describeToolOperation } from "@app/hooks/tools/shared/toolOperationDescriptor";
import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation";
import { sanitizeOperationConfig } from "@app/hooks/tools/sanitize/useSanitizeOperation";
import { addWatermarkOperationConfig } from "@app/hooks/tools/addWatermark/useAddWatermarkOperation";
import { ocrOperationConfig } from "@app/hooks/tools/ocr/useOCROperation";
import { flattenOperationConfig } from "@app/hooks/tools/flatten/useFlattenOperation";
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
import type { ToolOperationDescriptor } from "@app/hooks/tools/shared/toolOperationDescriptor";
import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
import type { WirePipelineStep } from "@app/policies/types";
export const POLICY_OPERATIONS = {
redact: describeToolOperation(
"/api/v1/security/auto-redact",
redactOperationConfig,
),
sanitize: describeToolOperation(
"/api/v1/security/sanitize-pdf",
sanitizeOperationConfig,
),
watermark: describeToolOperation(
"/api/v1/security/add-watermark",
addWatermarkOperationConfig,
),
ocr: describeToolOperation("/api/v1/misc/ocr-pdf", ocrOperationConfig),
flatten: describeToolOperation(
"/api/v1/misc/flatten",
flattenOperationConfig,
),
compress: describeToolOperation(
"/api/v1/misc/compress-pdf",
compressOperationConfig,
),
} as const;
export type PolicyToolId = keyof typeof POLICY_OPERATIONS;
export type PolicyParams<Id extends PolicyToolId> =
(typeof POLICY_OPERATIONS)[Id] extends ToolOperationDescriptor<
ToolEndpoint,
infer P
>
? P
: never;
/** Discriminated on `toolId` so `params` matches the tool. */
export type PolicyToolStep = {
[Id in PolicyToolId]: { toolId: Id; params: PolicyParams<Id> };
}[PolicyToolId];
export type PolicyToolStepOf<Id extends PolicyToolId> = Extract<
PolicyToolStep,
{ toolId: Id }
>;
const POLICY_TOOL_IDS = Object.keys(POLICY_OPERATIONS) as PolicyToolId[];
const TOOL_ID_BY_ENDPOINT = new Map<string, PolicyToolId>(
POLICY_TOOL_IDS.map((id) => [POLICY_OPERATIONS[id].endpoint, id]),
);
export function policyEndpoint(toolId: PolicyToolId): ToolEndpoint {
return POLICY_OPERATIONS[toolId].endpoint;
}
/** Tool id for an endpoint path, or null if it isn't a policy tool. */
export function policyToolIdForEndpoint(endpoint: string): PolicyToolId | null {
return TOOL_ID_BY_ENDPOINT.get(endpoint) ?? null;
}
/** A step for `toolId`, partial params merged over the tool's defaults. */
export function policyStep<Id extends PolicyToolId>(
toolId: Id,
params: Partial<PolicyParams<Id>> = {},
): PolicyToolStepOf<Id> {
const defaults = POLICY_OPERATIONS[toolId].defaultParameters as object;
return {
toolId,
params: { ...defaults, ...(params as object) },
} as PolicyToolStepOf<Id>;
}
export function policyStepToWire(step: PolicyToolStep): WirePipelineStep {
return serializeStep(step);
}
// Generic over the id so `params` stays correlated with the descriptor; TS can't do that through
// the union, so `op` is widened here (a contained cast at the wire boundary).
function serializeStep<Id extends PolicyToolId>(step: {
toolId: Id;
params: PolicyParams<Id>;
}): WirePipelineStep {
const op = POLICY_OPERATIONS[step.toolId] as ToolOperationDescriptor<
ToolEndpoint,
PolicyParams<Id>
>;
return {
operation: op.endpoint,
parameters: op.toApi(step.params) as Record<string, unknown>,
};
}
/** Wire step -> typed policy step, or null if the endpoint isn't a policy tool. */
export function policyStepFromWire(
wire: WirePipelineStep,
): PolicyToolStep | null {
const toolId = policyToolIdForEndpoint(wire.operation);
if (!toolId) return null;
return deserializeStep(toolId, wire.parameters);
}
function deserializeStep<Id extends PolicyToolId>(
toolId: Id,
parameters: Record<string, unknown>,
): PolicyToolStepOf<Id> {
const op = POLICY_OPERATIONS[toolId] as ToolOperationDescriptor<
ToolEndpoint,
PolicyParams<Id>
>;
// Wire params are untyped JSON; this is the one point they enter the typed model.
const params = op.fromApi(
parameters as unknown as ToolApiParams[ToolEndpoint],
);
return { toolId, params } as unknown as PolicyToolStepOf<Id>;
}
@@ -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