From 357eb77f94546358f0b96592bf6e265fe54a6baa Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:52:57 +0000 Subject: [PATCH] Portal: multiple named personal API keys with per-key usage tracking (#6961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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> --- .../config/CustomAuditEventRepository.java | 15 +- .../api/PortalApiKeysController.java | 54 ++++ .../mcp/security/McpApiKeyAuthFilter.java | 4 +- .../model/api/apikey/CreateApiKeyRequest.java | 4 + .../model/api/apikey/CreatedApiKeyDto.java | 7 + .../model/api/apikey/PortalApiKeyDto.java | 21 ++ .../api/apikey/PortalApiKeysResponse.java | 9 + .../configuration/SecurityConfiguration.java | 7 +- .../filter/JwtAuthenticationFilter.java | 24 +- .../filter/UserAuthenticationFilter.java | 32 ++- .../filter/UserBasedRateLimitingFilter.java | 29 +- .../proprietary/security/model/ApiKey.java | 72 +++++ .../model/ApiKeyAuthenticationToken.java | 1 + .../security/model/ApiKeyDailyUsage.java | 45 +++ .../security/model/ApiKeyDailyUsageId.java | 36 +++ .../ApiKeyDailyUsageRepository.java | 55 ++++ .../security/repository/ApiKeyRepository.java | 19 ++ .../security/repository/ApiKeyUsageSum.java | 8 + .../service/ApiKeyAuthenticationService.java | 107 +++++++ .../security/service/ApiKeyHasher.java | 50 ++++ .../service/ApiKeyLegacyMigrator.java | 31 ++ .../service/ApiKeyManagementService.java | 235 ++++++++++++++++ .../security/service/ApiKeyUsageRecorder.java | 60 ++++ .../security/service/ApiKeyUsageWriter.java | 59 ++++ .../security/service/UserService.java | 26 +- .../service/PortalDocumentsService.java | 13 +- .../SecurityConfigurationTest.java | 5 +- .../filter/UserAuthenticationFilterTest.java | 17 +- .../UserBasedRateLimitingFilterTest.java | 95 +++++++ .../ApiKeyAuthenticationServiceTest.java | 148 ++++++++++ .../security/service/ApiKeyHasherTest.java | 38 +++ .../service/ApiKeyManagementServiceTest.java | 198 +++++++++++++ .../service/ApiKeyUsageRecorderTest.java | 88 ++++++ .../security/service/UserServiceMoreTest.java | 7 +- .../security/service/UserServiceTest.java | 22 ++ .../service/PortalDocumentsServiceTest.java | 14 + .../SupabaseAuthenticationFilter.java | 25 +- .../saas/security/SupabaseSecurityConfig.java | 5 +- .../db/migration/saas/V33__api_keys.sql | 24 ++ .../SupabaseAuthenticationFilterMoreTest.java | 18 +- .../SupabaseAuthenticationFilterTest.java | 20 +- .../SupabaseSecurityConfigMoreTest.java | 11 +- .../security/TeamSecurityExpressionsTest.java | 22 ++ .../public/locales/en-GB/translation.toml | 28 +- .../public/locales/en-US/translation.toml | 34 +-- .../core/tests/stubbed/api-keys-ui.spec.ts | 265 ++++++++++++++++++ .../editor/src/portal/api/infrastructure.ts | 53 +++- .../infrastructure/ApiKeyCard.stories.tsx | 36 +-- .../components/infrastructure/ApiKeyCard.tsx | 62 ++-- .../infrastructure/ApiKeysTab.stories.tsx | 17 +- .../infrastructure/ApiKeysTab.test.tsx | 124 ++++++++ .../components/infrastructure/ApiKeysTab.tsx | 95 ++++++- .../infrastructure/CreateKeyModal.stories.tsx | 6 +- .../infrastructure/CreateKeyModal.test.tsx | 63 +++++ .../infrastructure/CreateKeyModal.tsx | 94 ++----- .../components/infrastructure/infraFormat.ts | 2 - .../portal/mocks/handlers/infrastructure.ts | 46 ++- .../editor/src/portal/mocks/infrastructure.ts | 59 ++-- .../features/enterprise/api_keys.feature | 86 ++++++ 59 files changed, 2559 insertions(+), 291 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalApiKeysController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/CreateApiKeyRequest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/CreatedApiKeyDto.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/PortalApiKeyDto.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/PortalApiKeysResponse.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKey.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyDailyUsage.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyDailyUsageId.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyDailyUsageRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyUsageSum.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyHasher.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyLegacyMigrator.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyManagementService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorder.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageWriter.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/filter/UserBasedRateLimitingFilterTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationServiceTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyHasherTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyManagementServiceTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorderTest.java create mode 100644 app/saas/src/main/resources/db/migration/saas/V33__api_keys.sql create mode 100644 frontend/editor/src/core/tests/stubbed/api-keys-ui.spec.ts create mode 100644 frontend/editor/src/portal/components/infrastructure/ApiKeysTab.test.tsx create mode 100644 frontend/editor/src/portal/components/infrastructure/CreateKeyModal.test.tsx create mode 100644 testing/cucumber/features/enterprise/api_keys.feature diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java index f83bf0afdc..53596ebdb4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java @@ -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"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalApiKeysController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalApiKeysController.java new file mode 100644 index 0000000000..af0f4d055a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalApiKeysController.java @@ -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 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 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 revoke(@PathVariable("id") Long id) { + apiKeyManagementService.revokeKey(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java index e45dadb0c0..9c76978ccb 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java @@ -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 { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/CreateApiKeyRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/CreateApiKeyRequest.java new file mode 100644 index 0000000000..14aa43093c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/CreateApiKeyRequest.java @@ -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) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/CreatedApiKeyDto.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/CreatedApiKeyDto.java new file mode 100644 index 0000000000..aa285344c8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/CreatedApiKeyDto.java @@ -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) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/PortalApiKeyDto.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/PortalApiKeyDto.java new file mode 100644 index 0000000000..062c1e0a9f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/PortalApiKeyDto.java @@ -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) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/PortalApiKeysResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/PortalApiKeysResponse.java new file mode 100644 index 0000000000..dd21af7226 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/PortalApiKeysResponse.java @@ -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 keys) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 20a9cb2628..eab9fc6fd0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -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); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java index 92bbcab89c..ed8001df5a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java @@ -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 = 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 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( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index 5777b093e8..8fe351ccde 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -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 = 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 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()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserBasedRateLimitingFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserBasedRateLimitingFilter.java index e4a15ae7b4..a07b5c9a97 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserBasedRateLimitingFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserBasedRateLimitingFilter.java @@ -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()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKey.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKey.java new file mode 100644 index 0000000000..fea66144d2 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKey.java @@ -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; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationToken.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationToken.java index c969704bad..b09ba20aab 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationToken.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationToken.java @@ -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; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyDailyUsage.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyDailyUsage.java new file mode 100644 index 0000000000..48055fc7d6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyDailyUsage.java @@ -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; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyDailyUsageId.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyDailyUsageId.java new file mode 100644 index 0000000000..77bbbf43f1 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyDailyUsageId.java @@ -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); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyDailyUsageRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyDailyUsageRepository.java new file mode 100644 index 0000000000..4db1dd34b8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyDailyUsageRepository.java @@ -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 { + + /** 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 countForDayByIds( + @Param("ids") Collection 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 sumSinceByIds( + @Param("ids") Collection ids, @Param("fromDayInclusive") long fromDayInclusive); + + void deleteByApiKeyId(Long apiKeyId); + + List findByApiKeyId(Long apiKeyId); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyRepository.java new file mode 100644 index 0000000000..62e2cc1408 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyRepository.java @@ -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 { + + Optional findByKeyHash(String keyHash); + + boolean existsByKeyHash(String keyHash); + + List findByOwnerUserIdOrderByCreatedAtDesc(Long ownerUserId); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyUsageSum.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyUsageSum.java new file mode 100644 index 0000000000..bff5b9304c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyUsageSum.java @@ -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(); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationService.java new file mode 100644 index 0000000000..ab223f929b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationService.java @@ -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. + * + *

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 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 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 authorities) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyHasher.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyHasher.java new file mode 100644 index 0000000000..3807781036 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyHasher.java @@ -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); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyLegacyMigrator.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyLegacyMigrator.java new file mode 100644 index 0000000000..413c0c0d72 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyLegacyMigrator.java @@ -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); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyManagementService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyManagementService.java new file mode 100644 index 0000000000..e30bce7845 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyManagementService.java @@ -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. + * + *

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 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 ids = visible.stream().map(ApiKey::getId).toList(); + Map todayById = new HashMap<>(); + Map monthById = new HashMap<>(); + Map 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 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")); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorder.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorder.java new file mode 100644 index 0000000000..5f36678e22 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorder.java @@ -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; + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageWriter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageWriter.java new file mode 100644 index 0000000000..94bec676d7 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageWriter.java @@ -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); + }); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index 45532978f3..0cb4653ef1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -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 = 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 getAuthorities(User user) { @@ -173,6 +175,9 @@ public class UserService implements UserServiceInterface { public User addApiKeyToUser(String username) { Optional 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 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 loadUserByApiKey(String apiKey) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalDocumentsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalDocumentsService.java index fcae36f61b..3e519da5ed 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalDocumentsService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalDocumentsService.java @@ -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 ·

@@ -32,37 +31,18 @@ const meta: Meta = { export default meta; type Story = StoryObj; -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: [] } }, -}; diff --git a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx index 69e23cb406..fb5fd5d104 100644 --- a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx +++ b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx @@ -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 (
-
-
{t("portal.infrastructure.apiKeys.card.rateLimit")}
-
- {t("portal.infrastructure.apiKeys.card.rateLimitValue", { - value: apiKey.rateLimit.toLocaleString(), - })} -
-
{t("portal.infrastructure.apiKeys.card.usageToday")}
@@ -71,36 +71,20 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) { {apiKey.usageMonth.toLocaleString()}
-
-
{t("portal.infrastructure.apiKeys.card.permissions")}
-
- {apiKey.permissions.map((p) => ( - - {t( - `portal.infrastructure.apiKeyPermission.${p.toLowerCase()}`, - p, - )} - - ))} -
-
-
-
{t("portal.infrastructure.apiKeys.card.allowedIps")}
-
- {apiKey.allowedIps.length === 0 ? ( - - {t("portal.infrastructure.apiKeys.card.anyIp")} - - ) : ( - apiKey.allowedIps.map((ip) => ( - - {ip} - - )) - )} -
-
+ + {revocable && ( +
+ +
+ )} )} diff --git a/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx b/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx index 7743dd4fdf..7b271f135d 100644 --- a/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx +++ b/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx @@ -20,14 +20,19 @@ type Story = StoryObj; 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), + ), ], }, }, diff --git a/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.test.tsx b/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.test.tsx new file mode 100644 index 0000000000..955124dea7 --- /dev/null +++ b/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.test.tsx @@ -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 { + 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( + + + , + ); +} + +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); + }); +}); diff --git a/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.tsx b/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.tsx index a93b2c0c56..6a2817d12d 100644 --- a/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.tsx +++ b/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.tsx @@ -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(() => fetchApiKeys(tier), [tier]); - const { data: keys } = state; - const { isLoading, isEmpty } = useSectionFlags(state); + const [reloadKey, setReloadKey] = useState(0); + const [error, setError] = useState(null); + const [pendingRevoke, setPendingRevoke] = useState(null); + const [revoking, setRevoking] = useState(false); + const state = useAsync(() => 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 (
@@ -32,6 +60,14 @@ export function ApiKeysTab() {
+ {error && } + {!loading && loadError && ( + + )} + {isLoading && (
{Array.from({ length: 3 }).map((_, i) => ( @@ -48,15 +84,52 @@ export function ApiKeysTab() { /> )} - {keys && keys.length > 0 && ( + {keys.length > 0 && (
{keys.map((k) => ( - + ))}
)} - setModalOpen(false)} /> + setModalOpen(false)} + onCreated={reload} + /> + + !revoking && setPendingRevoke(null)} + width="sm" + title={t("portal.infrastructure.apiKeys.revoke.title")} + footer={ +
+ + +
+ } + > +

+ {t("portal.infrastructure.apiKeys.revoke.body", { + name: pendingRevoke?.name ?? "", + })} +

+
); } diff --git a/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx index f5e21035a6..ba5e3bc6a5 100644 --- a/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx +++ b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx @@ -6,7 +6,11 @@ const meta: Meta = { 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; diff --git a/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.test.tsx b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.test.tsx new file mode 100644 index 0000000000..435307823a --- /dev/null +++ b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.test.tsx @@ -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>) { + return render( + + {}} onCreated={() => {}} {...props} /> + , + ); +} + +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" }); + }); +}); diff --git a/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.tsx b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.tsx index 8461b9dd43..9a51fb176b 100644 --- a/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.tsx +++ b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.tsx @@ -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(["Read"]); - const [ips, setIps] = useState(""); - const [created, setCreated] = useState(false); + const [created, setCreated] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ? ( - ) : ( @@ -82,8 +74,7 @@ export function CreateKeyModal({