Portal: multiple named personal API keys with per-key usage tracking (#6961)

# Description of Changes

Multiple named **personal** API keys per user, replacing the single
opaque per-user key.

- Create (name + one-time secret), list, and revoke named keys from the
portal Infrastructure → API Keys tab. Works self-hosted and SaaS
(`X-API-KEY`).
- Per-key usage stats (today / trailing 30 days / lifetime);
API-processed documents are attributed to the specific key in the
processor's Documents feed.
- The legacy single per-user key keeps working and is lazily represented
as a named key. Rotating it revokes its migrated shadow row so the old
secret stops authenticating.
- Per-user (not per-key) rate limiting plus a per-user active-key cap,
so minting keys can't multiply the daily quota. Name-length cap;
race-safe migration and usage recording.

Keys are strictly personal: one owner, full access, no sharing.
Team-shared / scoped keys and per-key access levels were intentionally
left out of this PR to keep it small and easy to review; they can follow
as a separate, focused change.

> Note: the screenshots from the original revision showed an earlier
team-scoped design and need refreshing.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
This commit is contained in:
Anthony Stirling
2026-07-23 07:52:57 +00:00
committed by GitHub
co-authored by EthanHealy01
parent 718277a934
commit 357eb77f94
59 changed files with 2559 additions and 291 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;
@@ -408,16 +417,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.
@@ -6887,23 +6887,28 @@ title = "Infrastructure"
[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.audit]
filterAriaLabel = "Filter audit events by category"
heading = "Audit logs"
@@ -6964,14 +6969,11 @@ totalEvents = "Total events · 24h"
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"
@@ -6751,31 +6751,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"
@@ -6861,14 +6861,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"
@@ -6917,7 +6914,6 @@ title = "No regions deployed"
[portal.infrastructure.keyLabel]
active = "Active"
revoked = "Revoked"
rotateSoon = "Rotate soon"
[portal.infrastructure.modelLabel]
active = "Active"
@@ -0,0 +1,265 @@
import {
test,
expect,
type APIRequestContext,
type Page,
type Route,
} from "@playwright/test";
import { seedCookieConsent } from "@app/tests/helpers/api-stubs";
/**
* E2E coverage for the portal Infrastructure → API Keys tab (PR #6961:
* multiple named personal API keys with per-key usage tracking). Drives the
* list / create-and-reveal / revoke / empty / error flows through the real
* portal shell.
*
* Auth is real (a genuine admin login against the backend) because the portal's
* Spring AuthProvider doesn't settle under a fully-faked session; only the
* `api-keys` CRUD responses are stubbed via `page.route`, so the assertions stay
* deterministic and no real keys are created.
*
* Requirements to run for real: a backend on :8080 (reachable through the vite
* proxy) AND a portal-enabled frontend (`import.meta.env.DEV`, or a build with
* `VITE_INCLUDE_PORTAL=true`). The CI `vite preview` bundle ships with the
* portal off, so each test skips cleanly there - the same defensive pattern as
* audit-log-ui.spec.ts. Companion unit coverage that always runs in CI lives in
* ApiKeysTab.test.tsx.
*/
const API_KEYS = "**/api/v1/proprietary/ui-data/infrastructure/api-keys";
type StubKey = {
id: string;
name: string;
prefix: string;
created: string;
lastUsed: string;
status: "active" | "revoked";
usageToday: number;
usageMonth: number;
usageTotal: number;
};
function key(overrides: Partial<StubKey> = {}): StubKey {
return {
id: "1",
name: "Production ingest",
prefix: "sk_a1b2c3d4",
created: "2026-07-10",
lastUsed: "2026-07-15 09:30",
status: "active",
usageToday: 12,
usageMonth: 340,
usageTotal: 9001,
...overrides,
};
}
/** Log in as the default admin; returns the JWT, or null when no backend answers. */
async function adminJwt(request: APIRequestContext): Promise<string | null> {
for (const password of ["adminadmin", "stirling"]) {
const res = await request
.post("/api/v1/auth/login", { data: { username: "admin", password } })
.catch(() => null);
if (res?.ok()) {
const body = await res.json().catch(() => null);
const token = body?.session?.access_token;
if (token) return token as string;
}
}
return null;
}
/**
* Real admin session + stubbed API-keys CRUD. `keys` seeds the list; create
* prepends an active key and reveals a one-time secret; delete flips the target
* to `revoked`; `loadStatus` forces the list GET to fail. Returns false (and the
* caller skips) when there's no backend to authenticate against.
*/
async function setUpApiKeys(
page: Page,
request: APIRequestContext,
opts: { keys?: StubKey[]; loadStatus?: number } = {},
): Promise<boolean> {
const token = await adminJwt(request);
if (!token) {
test.skip(true, "No backend on :8080 to authenticate the portal");
return false;
}
const list = [...(opts.keys ?? [])];
await seedCookieConsent(page);
await page.addInitScript((jwt) => {
localStorage.setItem("stirling_jwt", jwt);
}, token);
// Revoke: flip the addressed key to revoked, 204 like the real endpoint.
await page.route(`${API_KEYS}/*`, (route: Route) => {
const id = route.request().url().split("/").pop() ?? "";
const target = list.find((k) => k.id === id);
if (target) target.status = "revoked";
return route.fulfill({ status: 204, body: "" });
});
// List (GET) and create (POST).
await page.route(API_KEYS, async (route: Route) => {
if (route.request().method() === "POST") {
const body = route.request().postDataJSON() as { name: string };
const created = key({
id: String(list.length + 100),
name: body.name,
prefix: "sk_new00000",
created: "2026-07-15",
lastUsed: "Never",
usageToday: 0,
usageMonth: 0,
usageTotal: 0,
});
list.unshift(created);
return route.fulfill({
json: {
key: created,
secret: "sk_test_demo_secret_shown_once",
},
});
}
if (opts.loadStatus && opts.loadStatus >= 400) {
return route.fulfill({
status: opts.loadStatus,
json: { error: "boom" },
});
}
return route.fulfill({ json: { keys: list } });
});
await page.goto("/processor/infrastructure");
return true;
}
/**
* Open the API Keys tab, or skip when the portal isn't in this build (the tab
* never renders - e.g. the CI `vite preview` bundle). Returns false when skipped.
*/
async function openApiKeysTab(page: Page): Promise<boolean> {
const tab = page.getByRole("button", { name: "API Keys" });
if (!(await tab.isVisible({ timeout: 20_000 }).catch(() => false))) {
test.skip(
true,
"Portal (/processor) not available/bootstrapped in this build",
);
return false;
}
await tab.click();
return true;
}
test.describe("Portal API Keys tab", () => {
test("shows the empty state when the caller has no keys", async ({
page,
request,
}) => {
if (!(await setUpApiKeys(page, request, { keys: [] }))) return;
if (!(await openApiKeysTab(page))) return;
await expect(page.getByText("No API keys yet")).toBeVisible({
timeout: 10_000,
});
});
test("lists existing keys with prefix, status and usage", async ({
page,
request,
}) => {
if (
!(await setUpApiKeys(page, request, {
keys: [
key({ id: "1", name: "Production ingest", prefix: "sk_a1b2c3d4" }),
key({
id: "2",
name: "Old key",
prefix: "sk_z9y8x7w6",
status: "revoked",
}),
],
}))
)
return;
if (!(await openApiKeysTab(page))) return;
await expect(page.getByText("Production ingest")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("sk_a1b2c3d4")).toBeVisible();
// The revoked key still lists, tagged as such.
await expect(page.getByText("Old key")).toBeVisible();
});
test("creates a key and reveals the one-time secret once", async ({
page,
request,
}) => {
if (!(await setUpApiKeys(page, request, { keys: [] }))) return;
if (!(await openApiKeysTab(page))) return;
await page.getByRole("button", { name: "Create key" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await dialog.getByRole("textbox").fill("CI smoke key");
await dialog.getByRole("button", { name: "Create key" }).click();
// The plaintext secret is shown exactly once, with a store-it-now warning.
await expect(
dialog.getByText("sk_test_demo_secret_shown_once"),
).toBeVisible({ timeout: 10_000 });
await expect(dialog.getByText(/won't be shown again/i)).toBeVisible();
await dialog.getByRole("button", { name: "Done" }).click();
// Back on the list, the new key is present.
await expect(page.getByText("CI smoke key")).toBeVisible({
timeout: 10_000,
});
});
test("revokes a key after confirmation", async ({ page, request }) => {
if (
!(await setUpApiKeys(page, request, {
keys: [key({ id: "1", name: "Doomed key", status: "active" })],
}))
)
return;
if (!(await openApiKeysTab(page))) return;
// Expand the card, then ask to revoke.
await page.getByRole("button", { name: /Doomed key/ }).click();
await page.getByRole("button", { name: "Revoke key" }).first().click();
// Confirm in the dialog.
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(dialog.getByText(/can't be undone/i)).toBeVisible();
await dialog.getByRole("button", { name: "Revoke key" }).click();
// The dialog closes and the reloaded list shows the key as revoked.
await expect(dialog).toBeHidden({ timeout: 10_000 });
await expect(page.getByText("Revoked").first()).toBeVisible({
timeout: 10_000,
});
});
test("surfaces a load error instead of a false empty state", async ({
page,
request,
}) => {
if (!(await setUpApiKeys(page, request, { keys: [], loadStatus: 500 })))
return;
if (!(await openApiKeysTab(page))) return;
await expect(
page.getByText("Couldn't load your API keys. Please try again."),
).toBeVisible({ timeout: 10_000 });
// A failed load must not masquerade as "no keys yet".
await expect(page.getByText("No API keys yet")).toHaveCount(0);
});
});
@@ -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,30 @@ 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";
// Always this instance's own backend: a key authenticates the instance that issued it, so a
// self-hosted instance manages its own keys even when SaaS-linked (.local is the SaaS backend on SaaS).
/** GET the caller's personal API keys; scoped server-side per user. */
export async function fetchApiKeys(): Promise<ApiKeysResponse> {
return 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> {
return apiClient.local.json<CreatedApiKey>(API_KEYS_PATH, {
method: "POST",
body,
});
}
/** DELETE (revoke) a key the caller owns. */
export async function revokeApiKey(id: string): Promise<void> {
const path = `${API_KEYS_PATH}/${encodeURIComponent(id)}`;
await apiClient.local.json<void>(path, { method: "DELETE" });
}
/** GET /v1/infrastructure/security?tier=… */
@@ -6,21 +6,20 @@ import "@portal/views/Infrastructure.css";
const BASE: ApiKey = {
id: "key-1",
name: "Production · ingest",
prefix: "sk_live_a3f8…",
created: "Mar 2, 2026",
lastUsed: "2m ago",
prefix: "sk_a3f81b2c",
created: "2026-03-02",
lastUsed: "2026-07-10 09:14",
status: "active",
rateLimit: 1200,
permissions: ["Read", "Write"],
allowedIps: ["52.14.0.0/16", "18.221.0.0/16"],
usageToday: 84210,
usageMonth: 2410933,
usageTotal: 9820145,
};
const meta: Meta<typeof ApiKeyCard> = {
title: "Portal/Infrastructure/ApiKeyCard",
component: ApiKeyCard,
parameters: { layout: "padded" },
args: { onRevoke: (key: ApiKey) => console.log("revoke", key.id) },
decorators: [
(S) => (
<div style={{ maxWidth: "44rem" }}>
@@ -32,37 +31,18 @@ const meta: Meta<typeof ApiKeyCard> = {
export default meta;
type Story = StoryObj<typeof ApiKeyCard>;
export const Active: Story = { args: { apiKey: BASE } };
export const RotateSoon: Story = {
args: {
apiKey: {
...BASE,
name: "Ops · admin (legacy)",
status: "rotate-soon",
permissions: ["Read", "Write", "Admin"],
allowedIps: ["203.0.113.7/32"],
usageToday: 0,
},
},
};
export const Personal: Story = { args: { apiKey: BASE } };
export const Revoked: Story = {
args: {
apiKey: {
...BASE,
name: "Sandbox · webhook tester",
prefix: "sk_test_2c4a…",
prefix: "sk_2c4a91de",
status: "revoked",
lastUsed: "never",
permissions: ["Read"],
allowedIps: [],
lastUsed: "Never",
usageToday: 0,
usageMonth: 0,
},
},
};
export const NoIpAllowlist: Story = {
args: { apiKey: { ...BASE, allowedIps: [] } },
};
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Button, Card, Chip, StatusBadge } from "@app/ui";
import { Button, Card, StatusBadge } from "@app/ui";
import { useTranslation } from "react-i18next";
import type { ApiKey } from "@portal/api/infrastructure";
import {
@@ -8,9 +8,17 @@ import {
} from "@portal/components/infrastructure/infraFormat";
/** Collapsible row for a single API key: header summary + expandable detail grid. */
export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
export function ApiKeyCard({
apiKey,
onRevoke,
}: {
apiKey: ApiKey;
/** Ask to revoke this key; the parent confirms before the destructive call. */
onRevoke: (key: ApiKey) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const revocable = apiKey.status === "active";
return (
<Card padding="default" className="portal-infra__key">
<Button
@@ -51,14 +59,6 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
<dt>{t("portal.infrastructure.apiKeys.card.lastUsed")}</dt>
<dd>{apiKey.lastUsed}</dd>
</div>
<div>
<dt>{t("portal.infrastructure.apiKeys.card.rateLimit")}</dt>
<dd className="portal-infra__mono">
{t("portal.infrastructure.apiKeys.card.rateLimitValue", {
value: apiKey.rateLimit.toLocaleString(),
})}
</dd>
</div>
<div>
<dt>{t("portal.infrastructure.apiKeys.card.usageToday")}</dt>
<dd className="portal-infra__mono">
@@ -71,36 +71,20 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
{apiKey.usageMonth.toLocaleString()}
</dd>
</div>
<div>
<dt>{t("portal.infrastructure.apiKeys.card.permissions")}</dt>
<dd className="portal-infra__chips">
{apiKey.permissions.map((p) => (
<Chip key={p} size="sm">
{t(
`portal.infrastructure.apiKeyPermission.${p.toLowerCase()}`,
p,
)}
</Chip>
))}
</dd>
</div>
<div className="portal-infra__kv-wide">
<dt>{t("portal.infrastructure.apiKeys.card.allowedIps")}</dt>
<dd className="portal-infra__chips">
{apiKey.allowedIps.length === 0 ? (
<span className="portal-infra__muted">
{t("portal.infrastructure.apiKeys.card.anyIp")}
</span>
) : (
apiKey.allowedIps.map((ip) => (
<Chip key={ip} size="sm">
<span className="portal-infra__mono">{ip}</span>
</Chip>
))
)}
</dd>
</div>
</dl>
{revocable && (
<div className="portal-infra__modal-actions">
<Button
variant="secondary"
accent="danger"
size="sm"
onClick={() => onRevoke(apiKey)}
>
{t("portal.infrastructure.apiKeys.card.revoke")}
</Button>
</div>
)}
</div>
)}
</Card>
@@ -20,14 +20,19 @@ type Story = StoryObj<typeof ApiKeysTab>;
export const Default: Story = {};
const EMPTY = { keys: [] };
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/infrastructure/api-keys", async () => {
await delay("infinite");
return HttpResponse.json([]);
}),
http.get(
"*/api/v1/proprietary/ui-data/infrastructure/api-keys",
async () => {
await delay("infinite");
return HttpResponse.json(EMPTY);
},
),
],
},
},
@@ -37,7 +42,9 @@ export const Empty: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/infrastructure/api-keys", () => HttpResponse.json([])),
http.get("*/api/v1/proprietary/ui-data/infrastructure/api-keys", () =>
HttpResponse.json(EMPTY),
),
],
},
},
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import type { ApiKey } from "@portal/api/infrastructure";
// 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. vi.hoisted keeps the mock fns
// defined before the hoisted vi.mock factory runs; createApiKey is present
// because the CreateKeyModal child imports it from the same module.
const { fetchApiKeys, revokeApiKey, createApiKey } = vi.hoisted(() => ({
fetchApiKeys: vi.fn(),
revokeApiKey: vi.fn(),
createApiKey: vi.fn(),
}));
vi.mock("@portal/api/infrastructure", () => ({
fetchApiKeys,
revokeApiKey,
createApiKey,
}));
import { ApiKeysTab } from "@portal/components/infrastructure/ApiKeysTab";
const K = "portal.infrastructure.apiKeys";
function apiKey(overrides: Partial<ApiKey> = {}): ApiKey {
return {
id: "1",
name: "Production ingest",
prefix: "sk_a1b2c3d4",
created: "2026-07-10",
lastUsed: "2026-07-15 09:30",
status: "active",
usageToday: 12,
usageMonth: 340,
usageTotal: 9001,
...overrides,
};
}
function renderTab() {
return render(
<MantineProvider>
<ApiKeysTab />
</MantineProvider>,
);
}
describe("ApiKeysTab", () => {
it("renders the empty state when the caller has no keys", async () => {
fetchApiKeys.mockResolvedValueOnce({ keys: [] });
renderTab();
expect(await screen.findByText(`${K}.empty.title`)).toBeInTheDocument();
});
it("lists keys with their prefix, and keeps revoked keys visible", async () => {
fetchApiKeys.mockResolvedValueOnce({
keys: [
apiKey({ id: "1", name: "Production ingest", prefix: "sk_a1b2c3d4" }),
apiKey({
id: "2",
name: "Old key",
prefix: "sk_z9y8x7w6",
status: "revoked",
}),
],
});
renderTab();
expect(await screen.findByText("Production ingest")).toBeInTheDocument();
expect(screen.getByText("sk_a1b2c3d4")).toBeInTheDocument();
expect(screen.getByText("Old key")).toBeInTheDocument();
});
it("surfaces a load error instead of a misleading empty state", async () => {
fetchApiKeys.mockRejectedValueOnce(new Error("boom"));
renderTab();
expect(await screen.findByText(`${K}.error.load`)).toBeInTheDocument();
// A failed load must not render as "no keys yet".
expect(screen.queryByText(`${K}.empty.title`)).not.toBeInTheDocument();
});
it("revokes a key after confirmation and reloads the list", async () => {
fetchApiKeys
.mockResolvedValueOnce({
keys: [apiKey({ id: "7", name: "Doomed key" })],
})
.mockResolvedValueOnce({
keys: [apiKey({ id: "7", name: "Doomed key", status: "revoked" })],
});
revokeApiKey.mockResolvedValueOnce(undefined);
renderTab();
// Expand the card so the revoke action is reachable.
fireEvent.click(await screen.findByText("Doomed key"));
fireEvent.click(
await screen.findByRole("button", { name: `${K}.card.revoke` }),
);
// Confirm in the dialog (a distinct i18n key from the card action).
const confirm = await screen.findByRole("button", {
name: `${K}.revoke.confirm`,
});
fireEvent.click(confirm);
// The revoke targets the right key, then the confirm dialog closes and the
// list re-fetches to reflect the new state.
await waitFor(() => expect(revokeApiKey).toHaveBeenCalledWith("7"));
await waitFor(() =>
expect(
screen.queryByRole("button", { name: `${K}.revoke.confirm` }),
).not.toBeInTheDocument(),
);
expect(fetchApiKeys.mock.calls.length).toBeGreaterThanOrEqual(2);
});
});
@@ -1,20 +1,48 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, EmptyState, Skeleton } from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import { fetchApiKeys, type ApiKey } from "@portal/api/infrastructure";
import { Banner, Button, EmptyState, Modal, Skeleton } from "@app/ui";
import { useAsync } from "@portal/hooks/useAsync";
import {
fetchApiKeys,
revokeApiKey,
type ApiKey,
type ApiKeysResponse,
} from "@portal/api/infrastructure";
import { errorMessage } from "@portal/api/http";
import { ApiKeyCard } from "@portal/components/infrastructure/ApiKeyCard";
import { CreateKeyModal } from "@portal/components/infrastructure/CreateKeyModal";
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
export function ApiKeysTab() {
const { t } = useTranslation();
const { tier } = useTier();
const [modalOpen, setModalOpen] = useState(false);
const state = useAsync<ApiKey[]>(() => fetchApiKeys(tier), [tier]);
const { data: keys } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
const [reloadKey, setReloadKey] = useState(0);
const [error, setError] = useState<string | null>(null);
const [pendingRevoke, setPendingRevoke] = useState<ApiKey | null>(null);
const [revoking, setRevoking] = useState(false);
const state = useAsync<ApiKeysResponse>(() => fetchApiKeys(), [reloadKey]);
const { data, loading, error: loadError } = state;
const reload = () => setReloadKey((n) => n + 1);
const keys = data?.keys ?? [];
const isLoading = loading && data === null;
// A failed load must not masquerade as a genuinely empty list.
const isEmpty = !loading && !loadError && keys.length === 0;
async function confirmRevoke() {
if (!pendingRevoke) return;
setError(null);
setRevoking(true);
try {
await revokeApiKey(pendingRevoke.id);
setPendingRevoke(null);
reload();
} catch (e) {
setError(errorMessage(e));
} finally {
setRevoking(false);
}
}
return (
<div className="portal-infra__stack">
@@ -32,6 +60,14 @@ export function ApiKeysTab() {
</Button>
</div>
{error && <Banner tone="danger" description={error} />}
{!loading && loadError && (
<Banner
tone="danger"
description={t("portal.infrastructure.apiKeys.error.load")}
/>
)}
{isLoading && (
<div className="portal-infra__stack" aria-hidden>
{Array.from({ length: 3 }).map((_, i) => (
@@ -48,15 +84,52 @@ export function ApiKeysTab() {
/>
)}
{keys && keys.length > 0 && (
{keys.length > 0 && (
<div className="portal-infra__keys">
{keys.map((k) => (
<ApiKeyCard key={k.id} apiKey={k} />
<ApiKeyCard key={k.id} apiKey={k} onRevoke={setPendingRevoke} />
))}
</div>
)}
<CreateKeyModal open={modalOpen} onClose={() => setModalOpen(false)} />
<CreateKeyModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onCreated={reload}
/>
<Modal
open={pendingRevoke !== null}
onClose={() => !revoking && setPendingRevoke(null)}
width="sm"
title={t("portal.infrastructure.apiKeys.revoke.title")}
footer={
<div className="portal-infra__modal-actions">
<Button
variant="tertiary"
size="sm"
disabled={revoking}
onClick={() => setPendingRevoke(null)}
>
{t("portal.infrastructure.apiKeys.revoke.cancel")}
</Button>
<Button
size="sm"
accent="danger"
loading={revoking}
onClick={confirmRevoke}
>
{t("portal.infrastructure.apiKeys.revoke.confirm")}
</Button>
</div>
}
>
<p>
{t("portal.infrastructure.apiKeys.revoke.body", {
name: pendingRevoke?.name ?? "",
})}
</p>
</Modal>
</div>
);
}
@@ -6,7 +6,11 @@ const meta: Meta<typeof CreateKeyModal> = {
title: "Portal/Infrastructure/CreateKeyModal",
component: CreateKeyModal,
parameters: { layout: "fullscreen" },
args: { open: true, onClose: () => console.log("close") },
args: {
open: true,
onClose: () => console.log("close"),
onCreated: () => console.log("created"),
},
};
export default meta;
type Story = StoryObj<typeof CreateKeyModal>;
@@ -0,0 +1,63 @@
import type { ComponentProps } from "react";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
// Deterministic i18n: keys returned verbatim, so assertions are stable.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn() },
}),
}));
// Stub the API layer so no real request is made; capture the create payload.
// vi.hoisted keeps the mock fn defined before the hoisted vi.mock factory runs.
const { createApiKey } = vi.hoisted(() => ({ createApiKey: vi.fn() }));
vi.mock("@portal/api/infrastructure", () => ({ createApiKey }));
import { CreateKeyModal } from "@portal/components/infrastructure/CreateKeyModal";
const K = "portal.infrastructure.createKey";
function renderModal(props: Partial<ComponentProps<typeof CreateKeyModal>>) {
return render(
<MantineProvider>
<CreateKeyModal open onClose={() => {}} onCreated={() => {}} {...props} />
</MantineProvider>,
);
}
describe("CreateKeyModal", () => {
it("gates the create button on a non-empty name", () => {
renderModal({});
const cta = screen.getByRole("button", { name: `${K}.createKey` });
expect(cta).toBeDisabled();
fireEvent.change(screen.getByPlaceholderText(`${K}.keyNamePlaceholder`), {
target: { value: "Production ingest" },
});
expect(cta).toBeEnabled();
});
it("creates a key and reveals the returned secret", async () => {
createApiKey.mockResolvedValueOnce({
key: { id: "1", name: "Production ingest" },
secret: "sk_live_demo_key_rotate_in_prod",
});
const onCreated = vi.fn();
renderModal({ onCreated });
fireEvent.change(screen.getByPlaceholderText(`${K}.keyNamePlaceholder`), {
target: { value: "Production ingest" },
});
fireEvent.click(screen.getByRole("button", { name: `${K}.createKey` }));
expect(await screen.findByText(`${K}.secretWarning`)).toBeInTheDocument();
expect(
screen.getByText("sk_live_demo_key_rotate_in_prod"),
).toBeInTheDocument();
await waitFor(() => expect(onCreated).toHaveBeenCalled());
expect(createApiKey).toHaveBeenCalledWith({ name: "Production ingest" });
});
});
@@ -1,40 +1,30 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Banner,
Button,
Checkbox,
CodeBlock,
FormField,
Input,
Modal,
} from "@app/ui";
import type { ApiKeyPermission } from "@portal/api/infrastructure";
const PERMISSION_OPTS: ApiKeyPermission[] = ["Read", "Write", "Admin"];
// Shown once after a key is created. TODO(backend): use the one-time secret
// returned by POST /v1/infrastructure/api-keys — it is never persisted server-side.
const DEMO_NEW_KEY_SECRET = "sk_live_demo_key_rotate_in_prod";
import { Banner, Button, CodeBlock, FormField, Input, Modal } from "@app/ui";
import { createApiKey, type CreatedApiKey } from "@portal/api/infrastructure";
import { errorMessage } from "@portal/api/http";
export function CreateKeyModal({
open,
onClose,
onCreated,
}: {
open: boolean;
onClose: () => void;
/** Called after a successful create so the tab can refresh its list. */
onCreated: () => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [perms, setPerms] = useState<ApiKeyPermission[]>(["Read"]);
const [ips, setIps] = useState("");
const [created, setCreated] = useState(false);
const [created, setCreated] = useState<CreatedApiKey | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
function reset() {
setName("");
setPerms(["Read"]);
setIps("");
setCreated(false);
setCreated(null);
setSubmitting(false);
setError(null);
}
function close() {
@@ -43,16 +33,18 @@ export function CreateKeyModal({
setTimeout(reset, 200);
}
function togglePerm(p: ApiKeyPermission) {
setPerms((prev) =>
prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p],
);
}
function createKey() {
// TODO(backend): POST /v1/infrastructure/api-keys { name, perms, ips }
// and render the one-time secret from the response instead of the fixture.
setCreated(true);
async function createKey() {
setSubmitting(true);
setError(null);
try {
const result = await createApiKey({ name: name.trim() });
setCreated(result);
onCreated();
} catch (e) {
setError(errorMessage(e));
} finally {
setSubmitting(false);
}
}
return (
@@ -72,7 +64,7 @@ export function CreateKeyModal({
}
footer={
created ? (
<Button variant="primary" accent="premium" onClick={close}>
<Button variant="primary" onClick={close}>
{t("portal.infrastructure.createKey.done")}
</Button>
) : (
@@ -82,8 +74,7 @@ export function CreateKeyModal({
</Button>
<Button
variant="primary"
accent="premium"
disabled={name.trim() === "" || perms.length === 0}
disabled={name.trim() === "" || submitting}
onClick={createKey}
>
{t("portal.infrastructure.createKey.createKey")}
@@ -95,7 +86,7 @@ export function CreateKeyModal({
{created ? (
<div className="portal-infra__stack">
<CodeBlock
code={DEMO_NEW_KEY_SECRET}
code={created.secret}
lang="bash"
caption={t("portal.infrastructure.createKey.secretKeyCaption")}
/>
@@ -106,6 +97,8 @@ export function CreateKeyModal({
</div>
) : (
<div className="portal-infra__form">
{error && <Banner tone="danger" description={error} />}
<FormField
label={t("portal.infrastructure.createKey.keyNameLabel")}
required
@@ -118,35 +111,6 @@ export function CreateKeyModal({
)}
/>
</FormField>
<FormField
label={t("portal.infrastructure.createKey.permissionsLabel")}
>
<div className="portal-infra__perm-row">
{PERMISSION_OPTS.map((p) => (
<Checkbox
key={p}
label={t(
`portal.infrastructure.apiKeyPermission.${p.toLowerCase()}`,
p,
)}
checked={perms.includes(p)}
onChange={() => togglePerm(p)}
/>
))}
</div>
</FormField>
<FormField
label={t("portal.infrastructure.createKey.ipAllowlistLabel")}
helperText={t("portal.infrastructure.createKey.ipAllowlistHelper")}
>
<Input
value={ips}
onChange={(e) => setIps(e.target.value)}
placeholder="52.14.0.0/16, 203.0.113.7/32"
/>
</FormField>
</div>
)}
</Modal>
@@ -55,13 +55,11 @@ export const DEPLOY_LABEL: Record<DeploymentStatus, string> = {
export const KEY_TONE: Record<ApiKeyStatus, StatusTone> = {
active: "success",
revoked: "danger",
"rotate-soon": "warning",
};
export const KEY_LABEL: Record<ApiKeyStatus, string> = {
active: "portal.infrastructure.keyLabel.active",
revoked: "portal.infrastructure.keyLabel.revoked",
"rotate-soon": "portal.infrastructure.keyLabel.rotateSoon",
};
export const CERT_TONE: Record<CertStatus, StatusTone> = {
@@ -25,10 +25,48 @@ export const infrastructureHandlers = [
});
}),
http.get("/v1/infrastructure/api-keys", async ({ request }) => {
await delay(120);
return HttpResponse.json(apiKeysFor(tierFrom(request)));
}),
// Real backend route; wildcard prefix intercepts both local (same-origin) and
// SaaS (absolute) callers.
http.get(
"*/api/v1/proprietary/ui-data/infrastructure/api-keys",
async ({ request }) => {
await delay(120);
return HttpResponse.json(apiKeysFor(tierFrom(request)));
},
),
// Create returns a one-time secret; the mock is non-persistent (dev/Storybook only).
http.post(
"*/api/v1/proprietary/ui-data/infrastructure/api-keys",
async ({ request }) => {
await delay(120);
const body = (await request.json().catch(() => ({}))) as {
name?: string;
};
return HttpResponse.json({
key: {
id: `key-${Date.now()}`,
name: body.name ?? "New key",
prefix: "sk_demo0000",
created: "2026-07-10",
lastUsed: "Never",
status: "active",
usageToday: 0,
usageMonth: 0,
usageTotal: 0,
},
secret: "sk_live_demo_key_rotate_in_prod",
});
},
),
http.delete(
"*/api/v1/proprietary/ui-data/infrastructure/api-keys/:id",
async () => {
await delay(120);
return new HttpResponse(null, { status: 204 });
},
),
http.get("/v1/infrastructure/security", async ({ request }) => {
await delay(120);
@@ -15,6 +15,7 @@
import type { Tier } from "@portal/contexts/TierContext";
import type {
ApiKey,
ApiKeysResponse,
AuditEvent,
AuditLogResponse,
AuditSummary,
@@ -166,61 +167,57 @@ const API_KEYS_ALL: ApiKey[] = [
{
id: "key-1",
name: "Production · ingest",
prefix: "sk_live_a3f8…",
created: "Mar 2, 2026",
lastUsed: "2m ago",
prefix: "sk_a3f81b2c",
created: "2026-03-02",
lastUsed: "2026-07-10 09:14",
status: "active",
rateLimit: 1200,
permissions: ["Read", "Write"],
allowedIps: ["52.14.0.0/16", "18.221.0.0/16"],
usageToday: 84210,
usageMonth: 2410933,
usageTotal: 2410933,
},
{
id: "key-2",
name: "Analytics · read-only",
prefix: "sk_live_77be…",
created: "Jan 18, 2026",
lastUsed: "41m ago",
name: "Nightly batch",
prefix: "sk_77be0f42",
created: "2026-01-18",
lastUsed: "2026-07-10 08:33",
status: "active",
rateLimit: 300,
permissions: ["Read"],
allowedIps: [],
usageToday: 6120,
usageMonth: 188400,
usageTotal: 188400,
},
{
id: "key-3",
name: "Ops · admin (legacy)",
prefix: "sk_live_d901…",
created: "Aug 9, 2025",
lastUsed: "6d ago",
status: "rotate-soon",
rateLimit: 600,
permissions: ["Read", "Write", "Admin"],
allowedIps: ["203.0.113.7/32"],
name: "CI pipeline",
prefix: "sk_d9013ab7",
created: "2025-08-09",
lastUsed: "2026-07-04 17:02",
status: "active",
usageToday: 0,
usageMonth: 14200,
usageTotal: 14200,
},
{
id: "key-4",
name: "Sandbox · webhook tester",
prefix: "sk_test_2c4a…",
created: "May 30, 2026",
lastUsed: "never",
prefix: "sk_2c4a91de",
created: "2026-05-30",
lastUsed: "Never",
status: "revoked",
rateLimit: 60,
permissions: ["Read"],
allowedIps: [],
usageToday: 0,
usageMonth: 0,
usageTotal: 0,
},
];
export function apiKeysFor(tier: Tier): ApiKey[] {
if (tier === "free") return API_KEYS_ALL.slice(0, 1);
if (tier === "pro") return API_KEYS_ALL.slice(0, 3);
return API_KEYS_ALL;
export function apiKeysFor(tier: Tier): ApiKeysResponse {
const keys =
tier === "free"
? API_KEYS_ALL.slice(0, 1)
: tier === "pro"
? API_KEYS_ALL.slice(0, 3)
: API_KEYS_ALL;
return { keys };
}
/* ──────────────────────────────────────────────────────────────────────── */
@@ -0,0 +1,86 @@
@jwt @auth @apikey
Feature: API Keys management API
Tests for the portal API-keys REST API, which lets a user mint, list, and
revoke named personal API keys.
Endpoints (all @EnterpriseEndpoint, JWT/ROLE required):
- GET /api/v1/proprietary/ui-data/infrastructure/api-keys (list)
- POST /api/v1/proprietary/ui-data/infrastructure/api-keys (create)
- DELETE /api/v1/proprietary/ui-data/infrastructure/api-keys/{id} (revoke)
Because these are @EnterpriseEndpoint, authenticated responses may be 200
(enterprise enabled) or 403 (feature not in this build). Unauthenticated
requests must always be rejected with 401.
The legacy single per-user key (the global API key) must keep working so no
key created before multi-key support is ever lost.
Admin credentials: username=admin, password=stirling
Global API key: 123456789
# =========================================================================
# LIST
# =========================================================================
@positive
Scenario: Admin can list API keys
Given I am logged in as admin
When I send a GET request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication
Then the response status code should be one of "200, 403"
@negative
Scenario: Unauthenticated list request returns 401
When I send a GET request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with no authentication
Then the response status code should be 401
# =========================================================================
# CREATE
# =========================================================================
@positive
Scenario: Admin can create a personal API key
Given I am logged in as admin
When I send a JSON POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication and body '{"name": "bdd_personal_key"}'
Then the response status code should be one of "200, 403"
@negative
Scenario: Creating a key without a name is rejected
Given I am logged in as admin
When I send a JSON POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication and body '{"name": ""}'
Then the response status code should be one of "400, 403"
@negative
Scenario: Unauthenticated create request returns 401
When I send a POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with no authentication
Then the response status code should be 401
# =========================================================================
# REVOKE
# =========================================================================
@negative
Scenario: Unauthenticated revoke request returns 401
When I send a DELETE request to "/api/v1/proprietary/ui-data/infrastructure/api-keys/1" with no authentication
Then the response status code should be 401
@positive
Scenario: Admin revoking a non-existent key is handled, not a bypass
Given I am logged in as admin
When I send a DELETE request to "/api/v1/proprietary/ui-data/infrastructure/api-keys/999999" with JWT authentication
Then the response status code should be one of "204, 403, 404"
# =========================================================================
# LEGACY KEY BACKWARD COMPATIBILITY
# =========================================================================
@positive
Scenario: The legacy global API key still authenticates
When I send a GET request to "/api/v1/auth/me" with API key "123456789"
Then the response status code should be 200
And the response JSON should have field "username"
@negative
Scenario: An unknown API key is rejected
When I send a GET request to "/api/v1/auth/me" with API key "not-a-real-api-key-000"
Then the response status code should be 401