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..40ccdaaff9 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalApiKeysController.java @@ -0,0 +1,60 @@ +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.config.EnterpriseEndpoint; +import stirling.software.proprietary.security.service.ApiKeyManagementService; + +/** + * Real backing for the portal Infrastructure → API Keys tab: list/create/revoke named keys, scoped + * personal or to the caller's team. Replaces the former portal-only mock endpoint. + */ +@ProprietaryUiDataApi +@RequiredArgsConstructor +@EnterpriseEndpoint +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 = + "Keys the caller may see: their personal keys plus any team keys in scope.") + 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 or team-scoped key and returns its one-time secret. Team keys" + + " require team-leader rights.") + 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 manages.") + 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/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..bfd0ada738 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/CreateApiKeyRequest.java @@ -0,0 +1,7 @@ +package stirling.software.proprietary.model.api.apikey; + +/** + * Create-key request body from the portal. {@code scope} is "personal", "team-lead", or + * "team-members"; team scoping resolves to the caller's own team server-side. + */ +public record CreateApiKeyRequest(String name, String scope) {} 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..f6649fa423 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/PortalApiKeyDto.java @@ -0,0 +1,25 @@ +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, + /** "personal" | "team-lead" | "team-members". */ + String scope, + /** Team name for a team-scoped key, else null. */ + String teamName, + String created, + String lastUsed, + /** "active" | "revoked". */ + String status, + long usageToday, + long usageMonth, + /** Whether the current caller may revoke this key. */ + boolean canManage) {} 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..005bcddc01 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/PortalApiKeysResponse.java @@ -0,0 +1,13 @@ +package stirling.software.proprietary.model.api.apikey; + +import java.util.List; + +import lombok.Builder; + +/** + * Payload for the API Keys tab: the keys the caller may see, plus whether they may create + * team-scoped keys (a team leader / admin) and the team those keys would belong to. + */ +@Builder +public record PortalApiKeysResponse( + List keys, boolean canCreateTeamKeys, String teamName) {} 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..fe1940dc23 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,9 @@ 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. + 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 +101,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..619354b4c1 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKey.java @@ -0,0 +1,81 @@ +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 API key belonging to a user (and optionally scoped to a team). 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 personal key for backward compatibility. + */ +@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"), + @Index(name = "idx_api_key_team", columnList = "team_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; a team key still authenticates as this user. */ + @Column(name = "owner_user_id", nullable = false) + private Long ownerUserId; + + /** Team the key is scoped to, or null for a personal key. */ + @Column(name = "team_id") + private Long teamId; + + @Enumerated(EnumType.STRING) + @Column(name = "scope", nullable = false) + private ApiKeyScope scope; + + @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/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/model/ApiKeyScope.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyScope.java new file mode 100644 index 0000000000..c58c980bad --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyScope.java @@ -0,0 +1,25 @@ +package stirling.software.proprietary.security.model; + +/** + * Who a {@link ApiKey} belongs to and who may see/manage it in the portal. The key itself is always + * a bearer credential that authenticates as its owner; the scope governs portal visibility and + * management, never a change of the acting principal. + * + *
    + *
  • {@code PERSONAL} - owned by one user, only that user sees or manages it; {@code teamId} is + * null. Every key that predates this feature is treated as PERSONAL so it can never leak to a + * team. + *
  • {@code TEAM_LEAD} - a team key visible to and managed by the team's leaders only. + *
  • {@code TEAM_MEMBERS} - a team key visible to every member of the team; still created and + * revoked by a leader. + *
+ */ +public enum ApiKeyScope { + PERSONAL, + TEAM_LEAD, + TEAM_MEMBERS; + + public boolean isTeamScoped() { + return this == TEAM_LEAD || this == TEAM_MEMBERS; + } +} 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..2f6e599ebe --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyDailyUsageRepository.java @@ -0,0 +1,39 @@ +package stirling.software.proprietary.security.repository; + +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); + + 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..5d219e4988 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyRepository.java @@ -0,0 +1,21 @@ +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); + + List findByTeamIdOrderByCreatedAtDesc(Long teamId); +} 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..d4d36d0d96 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationService.java @@ -0,0 +1,137 @@ +package stirling.software.proprietary.security.service; + +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +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.ApiKeyScope; +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 and are always + * treated as personal to their user. + */ +@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 authorities the + * request should run with - which for a team key are capped below admin (see {@link + * #authoritiesFor}). + */ + 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), authoritiesFor(owner, key))); + } + + // Legacy single per-user key: keep working, always personal to its user. + return userRepository + .findByApiKey(rawKey) + .filter(User::isEnabled) + .map(user -> new ApiKeyAuthentication(user, null, user.getAuthorities())); + } + + /** + * Authorities the resolved key authenticates with. A PERSONAL key is the owner using their own + * credential, so it carries the owner's authorities unchanged. A TEAM key is a SHARED + * credential (visible to leaders or all members), so it must never confer admin: the owner's + * authorities are capped below {@code ROLE_ADMIN}, falling back to {@code ROLE_USER}. This + * blocks the escalation where an admin-owned team key would hand admin rights to everyone who + * can see it. + */ + private static Collection authoritiesFor(User owner, ApiKey key) { + if (key.getScope() == ApiKeyScope.PERSONAL) { + return owner.getAuthorities(); + } + String adminRole = Role.ADMIN.getRoleId(); + List capped = + owner.getAuthorities().stream() + .filter(a -> !adminRole.equals(a.getAuthority())) + .collect(Collectors.toList()); + if (capped.isEmpty()) { + capped.add(new SimpleGrantedAuthority(Role.USER.getRoleId())); + } + return capped; + } + + /** "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/ApiKeyManagementService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyManagementService.java new file mode 100644 index 0000000000..e1cbd5b854 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyManagementService.java @@ -0,0 +1,292 @@ +package stirling.software.proprietary.security.service; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +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.Team; +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.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.ApiKey; +import stirling.software.proprietary.security.model.ApiKeyScope; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository; +import stirling.software.proprietary.security.repository.ApiKeyRepository; +import stirling.software.proprietary.security.repository.TeamRepository; + +/** + * Portal-facing CRUD for named API keys: lists what the caller may see, creates personal or + * team-scoped keys, and revokes them. Team scoping reuses {@link PolicyManagementAuthority} (a team + * leader on SaaS, a global admin self-hosted) so it can't drift from the policy/source rules. + * + *

Personal keys are strictly owner-only. Every pre-existing single {@code users.apiKey} is + * lazily represented as a PERSONAL key owned by that user, so historic keys can never surface to a + * team. + */ +@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 TeamRepository teamRepository; + private final UserService userService; + private final PolicyManagementAuthority policyAuthority; + + /** All keys the caller may see, plus whether they may mint team keys. */ + @Transactional + public PortalApiKeysResponse listVisibleKeys() { + User caller = requireCaller(); + migrateLegacyKey(caller); + + boolean isManager = policyAuthority.canEditPolicies(); + Long teamId = policyAuthority.currentUserTeamId(); + String teamName = teamId == null ? null : teamNameFor(teamId); + + List visible = new ArrayList<>(); + // Personal keys: only the caller's own. + apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(caller.getId()).stream() + .filter(k -> k.getScope() == ApiKeyScope.PERSONAL) + .forEach(visible::add); + // Team keys: members see TEAM_MEMBERS; only managers additionally see TEAM_LEAD. + if (teamId != null) { + apiKeyRepository.findByTeamIdOrderByCreatedAtDesc(teamId).stream() + .filter(k -> k.getScope().isTeamScoped()) + .filter(k -> k.getScope() == ApiKeyScope.TEAM_MEMBERS || isManager) + .forEach(visible::add); + } + + List keys = + visible.stream().map(k -> toDto(caller, k, isManager, teamName)).toList(); + return PortalApiKeysResponse.builder() + .keys(keys) + .canCreateTeamKeys(isManager && teamId != null) + .teamName(teamName) + .build(); + } + + /** Create a 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"); + } + ApiKeyScope scope = parseScope(request.scope()); + + Long teamId = null; + String teamName = null; + if (scope.isTeamScoped()) { + if (!policyAuthority.canEditPolicies()) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, "Only a team leader may create team API keys"); + } + teamId = policyAuthority.currentUserTeamId(); + if (teamId == null) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "No team is available to scope this key to"); + } + teamName = teamNameFor(teamId); + } + + String rawKey = ApiKeyHasher.generateRawKey(); + ApiKey saved = + apiKeyRepository.save( + ApiKey.builder() + .name(name.trim()) + .keyHash(ApiKeyHasher.hash(rawKey)) + .prefix(ApiKeyHasher.displayPrefix(rawKey)) + .ownerUserId(caller.getId()) + .teamId(teamId) + .scope(scope) + .enabled(true) + .createdAt(Instant.now()) + .build()); + + return CreatedApiKeyDto.builder() + .key(toDto(caller, saved, policyAuthority.canEditPolicies(), teamName)) + .secret(rawKey) + .build(); + } + + /** Soft-revoke a key the caller manages; 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 (!canManage(caller, key)) { + // Not-found rather than forbidden so a caller can't probe other teams' key ids. + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No key"); + } + key.setEnabled(false); + key.setRevokedAt(Instant.now()); + apiKeyRepository.save(key); + clearLegacyColumnIfMatches(key); + } + + /** Whether the caller may revoke a given key. */ + private boolean canManage(User caller, ApiKey key) { + if (key.getScope() == ApiKeyScope.PERSONAL) { + return key.getOwnerUserId().equals(caller.getId()); + } + return policyAuthority.canEditPolicies() + && key.getTeamId() != null + && key.getTeamId().equals(policyAuthority.currentUserTeamId()); + } + + /** Represent a user's pre-existing single key as a PERSONAL 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 { + apiKeyRepository.save( + ApiKey.builder() + .name("Default key") + .keyHash(hash) + .prefix(ApiKeyHasher.displayPrefix(legacy)) + .ownerUserId(user.getId()) + .teamId(null) + .scope(ApiKeyScope.PERSONAL) + .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(User caller, ApiKey key, boolean isManager, String teamName) { + long today = Instant.now().atZone(ZoneOffset.UTC).toLocalDate().toEpochDay(); + Long todayCount = usageRepository.countForDay(key.getId(), today); + long usageMonth = usageRepository.sumSince(key.getId(), today - (MONTH_WINDOW_DAYS - 1)); + return PortalApiKeyDto.builder() + .id(String.valueOf(key.getId())) + .name(key.getName()) + .prefix(key.getPrefix()) + .scope(scopeLabel(key.getScope())) + .teamName(key.getScope().isTeamScoped() ? teamName : null) + .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(todayCount == null ? 0 : todayCount) + .usageMonth(usageMonth) + .canManage(canManage(caller, key)) + .build(); + } + + private static String scopeLabel(ApiKeyScope scope) { + return switch (scope) { + case PERSONAL -> "personal"; + case TEAM_LEAD -> "team-lead"; + case TEAM_MEMBERS -> "team-members"; + }; + } + + private static ApiKeyScope parseScope(String raw) { + if (raw == null || raw.isBlank()) { + return ApiKeyScope.PERSONAL; + } + return switch (raw.trim().toLowerCase(Locale.ROOT)) { + case "personal" -> ApiKeyScope.PERSONAL; + case "team-lead", "team_lead", "teamlead" -> ApiKeyScope.TEAM_LEAD; + case "team-members", "team_members", "teammembers", "team" -> ApiKeyScope.TEAM_MEMBERS; + default -> + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Unknown scope: " + raw); + }; + } + + private String teamNameFor(Long teamId) { + return teamRepository.findById(teamId).map(Team::getName).orElse(null); + } + + 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..de4eb75f16 --- /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.dao.DataIntegrityViolationException; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.security.model.ApiKeyDailyUsage; +import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository; +import stirling.software.proprietary.security.repository.ApiKeyRepository; + +/** + * 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. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ApiKeyUsageRecorder { + + private final ApiKeyRepository apiKeyRepository; + private final ApiKeyDailyUsageRepository usageRepository; + + /** Bump today's tally for the key and stamp last-used. */ + @Async("auditExecutor") + @Transactional + public void record(Long apiKeyId) { + if (apiKeyId == null) { + return; + } + try { + long epochDay = Instant.now().atZone(ZoneOffset.UTC).toLocalDate().toEpochDay(); + if (usageRepository.incrementIfPresent(apiKeyId, epochDay) == 0) { + try { + usageRepository.save(new ApiKeyDailyUsage(apiKeyId, epochDay, 1)); + } catch (DataIntegrityViolationException raced) { + // A concurrent first-write already inserted today's row; increment it instead + // of dropping this request's count. + usageRepository.incrementIfPresent(apiKeyId, epochDay); + } + } + apiKeyRepository + .findById(apiKeyId) + .ifPresent( + key -> { + key.setLastUsedAt(Instant.now()); + apiKeyRepository.save(key); + }); + } catch (Exception e) { + log.debug("Failed to record API key usage for id={}", apiKeyId, e); + } + } +} 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..70c95f2402 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( @@ -173,6 +174,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 +224,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 8784d5c5b6..7e8314efc7 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 @@ -61,8 +61,9 @@ public class PortalDocumentsService { continue; } String path = asString(data.get("path")); - String source = sourceLabel(asString(data.get("__origin"))); - String product = "API integration".equals(source) ? "API" : "Editor"; + String origin = asString(data.get("__origin")); + String source = sourceLabel(origin, asString(data.get("__apiKeyLabel"))); + String product = "API".equals(origin) ? "API" : "Editor"; String action = prettyTool(path); boolean failed = isFailure(data); Instant ts = event.timestamp(); @@ -164,9 +165,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"; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/UserAuthenticationFilterTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/UserAuthenticationFilterTest.java index c1900ce908..df87c30e1b 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/UserAuthenticationFilterTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/UserAuthenticationFilterTest.java @@ -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); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationServiceTest.java new file mode 100644 index 0000000000..0b68834f87 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationServiceTest.java @@ -0,0 +1,180 @@ +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.ApiKeyScope; +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) + .scope(ApiKeyScope.PERSONAL) + .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 team key drops admin from the acting authorities (shared credential)") + void teamKeyCapsAdminAuthority() { + String raw = "raw-team"; + User owner = user(7, true); + owner.addAuthority(new Authority(Role.ADMIN.getRoleId(), owner)); + ApiKey teamKey = + ApiKey.builder() + .id(5L) + .name("Team key") + .keyHash(ApiKeyHasher.hash(raw)) + .prefix("sk_demo0000") + .ownerUserId(7L) + .teamId(3L) + .scope(ApiKeyScope.TEAM_MEMBERS) + .enabled(true) + .createdAt(Instant.now()) + .build(); + when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw))) + .thenReturn(Optional.of(teamKey)); + when(userRepository.findById(7L)).thenReturn(Optional.of(owner)); + + var result = service.authenticate(raw); + + assertThat(result).isPresent(); + List auths = + result.get().authorities().stream().map(GrantedAuthority::getAuthority).toList(); + assertThat(auths).doesNotContain(Role.ADMIN.getRoleId()).contains(Role.USER.getRoleId()); + } + + @Test + @DisplayName("a personal key keeps the owner's authorities (owner acts as self)") + void personalKeyKeepsOwnerAuthorities() { + 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 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); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyHasherTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyHasherTest.java new file mode 100644 index 0000000000..ec7836a8af --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyHasherTest.java @@ -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); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyManagementServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyManagementServiceTest.java new file mode 100644 index 0000000000..8d032a1f5f --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyManagementServiceTest.java @@ -0,0 +1,277 @@ +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.Team; +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.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.ApiKey; +import stirling.software.proprietary.security.model.ApiKeyScope; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository; +import stirling.software.proprietary.security.repository.ApiKeyRepository; +import stirling.software.proprietary.security.repository.TeamRepository; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ApiKeyManagementService") +class ApiKeyManagementServiceTest { + + @Mock private ApiKeyRepository apiKeyRepository; + @Mock private ApiKeyDailyUsageRepository usageRepository; + @Mock private UserRepository userRepository; + @Mock private TeamRepository teamRepository; + @Mock private UserService userService; + @Mock private PolicyManagementAuthority policyAuthority; + @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.countForDay(any(), anyLong())).thenReturn(0L); + lenient().when(usageRepository.sumSince(any(), anyLong())).thenReturn(0L); + } + + private ApiKey personalKey(long id, long ownerId) { + return ApiKey.builder() + .id(id) + .name("Key " + id) + .keyHash("hash" + id) + .prefix("sk_demo0000") + .ownerUserId(ownerId) + .scope(ApiKeyScope.PERSONAL) + .enabled(true) + .createdAt(Instant.now()) + .build(); + } + + private ApiKey teamKey(long id, long teamId, ApiKeyScope scope) { + return ApiKey.builder() + .id(id) + .name("Team key " + id) + .keyHash("hash" + id) + .prefix("sk_demo0000") + .ownerUserId(99L) + .teamId(teamId) + .scope(scope) + .enabled(true) + .createdAt(Instant.now()) + .build(); + } + + // ---- migration safety --------------------------------------------------- + + @Test + @DisplayName("an existing legacy key migrates to a PERSONAL, owner-only row - never a team") + void legacyKeyMigratesAsPersonal() { + caller.setApiKey("legacy-raw-key"); + when(apiKeyRepository.existsByKeyHash(ApiKeyHasher.hash("legacy-raw-key"))) + .thenReturn(false); + when(policyAuthority.currentUserTeamId()).thenReturn(null); + when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L)).thenReturn(List.of()); + + service.listVisibleKeys(); + + ArgumentCaptor saved = ArgumentCaptor.forClass(ApiKey.class); + verify(apiKeyRepository).save(saved.capture()); + ApiKey migrated = saved.getValue(); + assertThat(migrated.getScope()).isEqualTo(ApiKeyScope.PERSONAL); + assertThat(migrated.getOwnerUserId()).isEqualTo(1L); + assertThat(migrated.getTeamId()).isNull(); + } + + @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(policyAuthority.currentUserTeamId()).thenReturn(null); + when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L)).thenReturn(List.of()); + + service.listVisibleKeys(); + + verify(apiKeyRepository, never()).save(any()); + } + + // ---- personal isolation ------------------------------------------------- + + @Test + @DisplayName("listing scopes personal keys to the caller by owner id") + void personalKeysScopedToOwner() { + when(policyAuthority.currentUserTeamId()).thenReturn(null); + when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L)) + .thenReturn(List.of(personalKey(10, 1L))); + + PortalApiKeysResponse res = service.listVisibleKeys(); + + assertThat(res.keys()) + .singleElement() + .satisfies(k -> assertThat(k.scope()).isEqualTo("personal")); + // Isolation: the query is keyed by the caller's id, never a broad scan. + verify(apiKeyRepository).findByOwnerUserIdOrderByCreatedAtDesc(1L); + } + + @Test + @DisplayName("a member sees TEAM_MEMBERS keys but not TEAM_LEAD keys") + void memberSeesTeamMembersNotTeamLead() { + when(policyAuthority.canEditPolicies()).thenReturn(false); + when(policyAuthority.currentUserTeamId()).thenReturn(5L); + when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L)).thenReturn(List.of()); + when(apiKeyRepository.findByTeamIdOrderByCreatedAtDesc(5L)) + .thenReturn( + List.of( + teamKey(20, 5L, ApiKeyScope.TEAM_LEAD), + teamKey(21, 5L, ApiKeyScope.TEAM_MEMBERS))); + Team team = new Team(); + team.setName("Acme"); + lenient().when(teamRepository.findById(5L)).thenReturn(Optional.of(team)); + + PortalApiKeysResponse res = service.listVisibleKeys(); + + assertThat(res.keys()).singleElement().satisfies(k -> assertThat(k.id()).isEqualTo("21")); + assertThat(res.canCreateTeamKeys()).isFalse(); + } + + // ---- creation ----------------------------------------------------------- + + @Test + @DisplayName("any user can create a personal key and gets a one-time secret") + void createPersonalKey() { + CreatedApiKeyDto created = service.createKey(new CreateApiKeyRequest("My key", "personal")); + + assertThat(created.secret()).startsWith("sk_"); + ArgumentCaptor saved = ArgumentCaptor.forClass(ApiKey.class); + verify(apiKeyRepository).save(saved.capture()); + assertThat(saved.getValue().getScope()).isEqualTo(ApiKeyScope.PERSONAL); + assertThat(saved.getValue().getTeamId()).isNull(); + assertThat(saved.getValue().getOwnerUserId()).isEqualTo(1L); + } + + @Test + @DisplayName("rejects an over-long key name") + void rejectsLongName() { + String longName = "a".repeat(101); + assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest(longName, "personal"))) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("characters or fewer"); + 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", "personal"))) + .isInstanceOf(ResponseStatusException.class); + verify(apiKeyRepository, never()).save(any()); + } + + @Test + @DisplayName("creating a team key without leader rights is forbidden") + void createTeamKeyRequiresLeader() { + when(policyAuthority.canEditPolicies()).thenReturn(false); + + assertThatThrownBy( + () -> + service.createKey( + new CreateApiKeyRequest("Team key", "team-members"))) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("team leader"); + verify(apiKeyRepository, never()).save(any()); + } + + @Test + @DisplayName("a leader creates a team key scoped to their own team") + void createTeamKeyAsLeader() { + when(policyAuthority.canEditPolicies()).thenReturn(true); + when(policyAuthority.currentUserTeamId()).thenReturn(5L); + Team team = new Team(); + team.setName("Acme"); + when(teamRepository.findById(5L)).thenReturn(Optional.of(team)); + + service.createKey(new CreateApiKeyRequest("Team key", "team-members")); + + ArgumentCaptor saved = ArgumentCaptor.forClass(ApiKey.class); + verify(apiKeyRepository).save(saved.capture()); + assertThat(saved.getValue().getScope()).isEqualTo(ApiKeyScope.TEAM_MEMBERS); + assertThat(saved.getValue().getTeamId()).isEqualTo(5L); + } + + // ---- revocation --------------------------------------------------------- + + @Test + @DisplayName("owner revokes their personal 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 personal key") + void revokeForeignPersonalKeyForbidden() { + when(apiKeyRepository.findById(31L)).thenReturn(Optional.of(personalKey(31, 999L))); + + assertThatThrownBy(() -> service.revokeKey(31L)) + .isInstanceOf(ResponseStatusException.class); + verify(apiKeyRepository, never()).save(any()); + } + + @Test + @DisplayName("a non-leader cannot revoke a team key") + void revokeTeamKeyRequiresLeader() { + when(apiKeyRepository.findById(32L)) + .thenReturn(Optional.of(teamKey(32, 5L, ApiKeyScope.TEAM_MEMBERS))); + when(policyAuthority.canEditPolicies()).thenReturn(false); + + assertThatThrownBy(() -> service.revokeKey(32L)) + .isInstanceOf(ResponseStatusException.class); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceMoreTest.java index bc516172a6..6064f1cd4f 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceMoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceMoreTest.java @@ -76,6 +76,7 @@ class UserServiceMoreTest { integrationConfigRepository; @Mock private TeamMembershipService teamMembershipService; + @Mock private ApiKeyAuthenticationService apiKeyAuthenticationService; @InjectMocks private UserService userService; @@ -99,7 +100,7 @@ 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.resolveUser("k")).thenReturn(Optional.of(u)); assertThat(userService.getAuthentication("k")).isNotNull(); } @@ -107,7 +108,7 @@ class UserServiceMoreTest { @Test @DisplayName("getAuthentication throws when key is unknown") void getAuthenticationInvalid() { - when(userRepository.findByApiKey("bad")).thenReturn(Optional.empty()); + when(apiKeyAuthenticationService.resolveUser("bad")).thenReturn(Optional.empty()); assertThatThrownBy(() -> userService.getAuthentication("bad")) .isInstanceOf(UsernameNotFoundException.class); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java index 95ec72d962..1f718789f8 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java @@ -71,6 +71,7 @@ class UserServiceTest { integrationConfigRepository; @Mock private TeamMembershipService teamMembershipService; + @Mock private ApiKeyAuthenticationService apiKeyAuthenticationService; @Spy @InjectMocks private UserService userService; diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java index aa678f7cbf..6e1f0af685 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java @@ -18,6 +18,7 @@ import java.util.Map; import java.util.Optional; import java.util.UUID; +import org.slf4j.MDC; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; @@ -43,6 +44,8 @@ import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.ApiKeyAuthenticationService; +import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.saas.model.AmrMethod; @@ -65,6 +68,7 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { private final SupabaseUserService supabaseUserService; private final SaasTeamService saasTeamService; private final JwtDecoder jwtDecoder; + private final ApiKeyAuthenticationService apiKeyAuthenticationService; private final AuthenticationEntryPoint authenticationEntryPoint = new BearerTokenAuthenticationEntryPoint(); @@ -73,12 +77,14 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { UserService userService, SupabaseUserService supabaseUserService, SaasTeamService saasTeamService, - JwtDecoder jwtDecoder) { + JwtDecoder jwtDecoder, + ApiKeyAuthenticationService apiKeyAuthenticationService) { this.teamService = teamService; this.userService = userService; this.supabaseUserService = supabaseUserService; this.saasTeamService = saasTeamService; this.jwtDecoder = jwtDecoder; + this.apiKeyAuthenticationService = apiKeyAuthenticationService; } @Override @@ -86,6 +92,9 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + // Start clean so a pooled thread can't inherit a prior request's API-key label. + MDC.remove(ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY); + if (isStaticResource(request.getContextPath(), request.getRequestURI())) { filterChain.doFilter(request, response); return; @@ -406,16 +415,22 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { return false; } - Optional user = 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 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; } diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java index 5fe27a11e8..ce0c937d6e 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java @@ -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 -> diff --git a/app/saas/src/main/resources/db/migration/saas/V33__api_keys.sql b/app/saas/src/main/resources/db/migration/saas/V33__api_keys.sql new file mode 100644 index 0000000000..580c8beb33 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V33__api_keys.sql @@ -0,0 +1,27 @@ +-- Named, multi-key API keys with optional team scoping, 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, + team_id BIGINT, + scope VARCHAR(32) 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 INDEX IF NOT EXISTS idx_api_key_team ON api_keys (team_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) +); diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java index 448bafae6b..f34a80459d 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java @@ -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"); diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterTest.java index e906655edc..58cf2d72a0 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterTest.java @@ -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"); diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java index 54b6e56391..da719e7ba5 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java @@ -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 diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index f3fe7bfefd..a5ab913f6d 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -6969,16 +6969,12 @@ 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 and team-scoped 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" @@ -7072,14 +7068,22 @@ 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" +scopeLabel = "Scope" +scopePersonal = "Personal" +scopePersonalHelp = "Only you can see and use this key." +scopeTeamLead = "Team leaders" +scopeTeamLeadHelp = "Visible to leaders of {{team}} only." +scopeTeamMembers = "Team members" +scopeTeamMembersHelp = "Visible to everyone in {{team}}." 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 = "Scope the key to the minimum it needs. You can revoke it at any time." subtitleCreated = "Copy this secret now — it won't be shown again." +teamScopeLeaderOnly = "Only team leaders can create team-scoped keys." title = "Create API key" titleCreated = "Key created" +yourTeam = "your team" [portal.infrastructure.deployLabel] live = "Live" @@ -7125,7 +7129,11 @@ title = "No regions deployed" [portal.infrastructure.keyLabel] active = "Active" revoked = "Revoked" -rotateSoon = "Rotate soon" + +[portal.infrastructure.keyScopeLabel] +personal = "Personal" +teamLead = "Team leaders" +teamMembers = "Team members" [portal.infrastructure.modelLabel] active = "Active" diff --git a/frontend/editor/src/portal/api/infrastructure.ts b/frontend/editor/src/portal/api/infrastructure.ts index 073409e6c6..91166a58c8 100644 --- a/frontend/editor/src/portal/api/infrastructure.ts +++ b/frontend/editor/src/portal/api/infrastructure.ts @@ -43,23 +43,47 @@ export interface RecentDeployment { /* API Keys */ /* ──────────────────────────────────────────────────────────────────────── */ -export type ApiKeyStatus = "active" | "revoked" | "rotate-soon"; -export type ApiKeyPermission = "Read" | "Write" | "Admin"; +export type ApiKeyStatus = "active" | "revoked"; + +/** + * Who a key belongs to and who can see it. Personal keys are owner-only; team + * keys are visible to a team's leaders ({@code team-lead}) or all its members + * ({@code team-members}). Mirrors the backend {@code ApiKeyScope}. + */ +export type ApiKeyScope = "personal" | "team-lead" | "team-members"; 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; + scope: ApiKeyScope; + /** Team name for a team-scoped key, else null. */ + teamName: string | null; 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; + /** Whether the current user may revoke this key. */ + canManage: boolean; +} + +export interface ApiKeysResponse { + keys: ApiKey[]; + /** True when the caller (a team leader / admin) may mint team-scoped keys. */ + canCreateTeamKeys: boolean; + /** The team team-scoped keys would belong to, or null. */ + teamName: string | null; +} + +/** Returned once on creation: the listed row plus the plaintext secret, shown once. */ +export interface CreatedApiKey { + key: ApiKey; + secret: string; } /* ──────────────────────────────────────────────────────────────────────── */ @@ -276,11 +300,35 @@ export async function fetchDeployments( ); } -/** GET /v1/infrastructure/api-keys?tier=… */ -export async function fetchApiKeys(tier: Tier): Promise { - return apiClient.local.json( - `/v1/infrastructure/api-keys${q(tier)}`, - ); +const API_KEYS_PATH = "/api/v1/proprietary/ui-data/infrastructure/api-keys"; + +/** GET the caller's visible API keys; SaaS or local, scoped server-side per user/team. */ +export async function fetchApiKeys(): Promise { + return apiClient.saas.isConfigured() + ? apiClient.saas.json(API_KEYS_PATH) + : apiClient.local.json(API_KEYS_PATH); +} + +/** POST a new key; the response carries the one-time secret. */ +export async function createApiKey(body: { + name: string; + scope: ApiKeyScope; +}): Promise { + const opts = { method: "POST" as const, body }; + return apiClient.saas.isConfigured() + ? apiClient.saas.json(API_KEYS_PATH, opts) + : apiClient.local.json(API_KEYS_PATH, opts); +} + +/** DELETE (revoke) a key the caller manages. */ +export async function revokeApiKey(id: string): Promise { + const path = `${API_KEYS_PATH}/${encodeURIComponent(id)}`; + const opts = { method: "DELETE" as const }; + if (apiClient.saas.isConfigured()) { + await apiClient.saas.json(path, opts); + } else { + await apiClient.local.json(path, opts); + } } /** GET /v1/infrastructure/security?tier=… */ diff --git a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx index 23c06bae3a..321e60a0f9 100644 --- a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx +++ b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx @@ -6,21 +6,22 @@ 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", + scope: "personal", + teamName: null, + 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, + canManage: true, }; const meta: Meta = { title: "Portal/Infrastructure/ApiKeyCard", component: ApiKeyCard, parameters: { layout: "padded" }, + args: { onRevoke: (id: string) => console.log("revoke", id) }, decorators: [ (S) => (

@@ -32,16 +33,26 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Active: Story = { args: { apiKey: BASE } }; +export const Personal: Story = { args: { apiKey: BASE } }; -export const RotateSoon: Story = { +export const TeamMembers: Story = { args: { apiKey: { ...BASE, - name: "Ops · admin (legacy)", - status: "rotate-soon", - permissions: ["Read", "Write", "Admin"], - allowedIps: ["203.0.113.7/32"], + name: "Team · shared ingest", + scope: "team-members", + teamName: "Acme Corp", + }, + }, +}; + +export const TeamLeadOnly: Story = { + args: { + apiKey: { + ...BASE, + name: "Ops · leaders only", + scope: "team-lead", + teamName: "Acme Corp", usageToday: 0, }, }, @@ -52,17 +63,11 @@ export const Revoked: Story = { 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 221ad0543a..af0d0526e0 100644 --- a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx +++ b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx @@ -4,13 +4,22 @@ import { useTranslation } from "react-i18next"; import type { ApiKey } from "@portal/api/infrastructure"; import { KEY_LABEL, + KEY_SCOPE_LABEL, + KEY_SCOPE_TONE, KEY_TONE, } 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; + onRevoke: (id: string) => void; +}) { const { t } = useTranslation(); const [open, setOpen] = useState(false); + const revocable = apiKey.canManage && 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")}
@@ -67,36 +73,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..c4cd441e53 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: [], canCreateTeamKeys: false, teamName: null }; + 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.tsx b/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.tsx index a93b2c0c56..dd33c0905e 100644 --- a/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.tsx +++ b/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.tsx @@ -1,20 +1,39 @@ 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, Skeleton } from "@app/ui"; +import { useAsync } from "@portal/hooks/useAsync"; +import { + fetchApiKeys, + revokeApiKey, + 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 state = useAsync(() => fetchApiKeys(), [reloadKey]); + const { data, loading } = state; + + const reload = () => setReloadKey((n) => n + 1); + const keys = data?.keys ?? []; + const isLoading = loading && data === null; + const isEmpty = !loading && keys.length === 0; + + async function revoke(id: string) { + setError(null); + try { + await revokeApiKey(id); + reload(); + } catch (e) { + setError(errorMessage(e)); + } + } return (
@@ -32,6 +51,8 @@ export function ApiKeysTab() {
+ {error && } + {isLoading && (
{Array.from({ length: 3 }).map((_, i) => ( @@ -48,15 +69,21 @@ export function ApiKeysTab() { /> )} - {keys && keys.length > 0 && ( + {keys.length > 0 && (
{keys.map((k) => ( - + ))}
)} - setModalOpen(false)} /> + setModalOpen(false)} + canCreateTeamKeys={data?.canCreateTeamKeys ?? false} + teamName={data?.teamName ?? null} + onCreated={reload} + />
); } diff --git a/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx index f5e21035a6..d69d5ba4d8 100644 --- a/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx +++ b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx @@ -6,11 +6,21 @@ 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"), + canCreateTeamKeys: true, + teamName: "Acme Corp", + }, }; export default meta; type Story = StoryObj; export const Form: Story = {}; +export const PersonalOnly: Story = { + args: { canCreateTeamKeys: false, teamName: null }, +}; + export const Closed: Story = { args: { open: false } }; 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..50b1e1d9a8 --- /dev/null +++ b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.test.tsx @@ -0,0 +1,79 @@ +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={() => {}} + canCreateTeamKeys + teamName="Acme Corp" + {...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()); + // Personal is the default scope when the leader doesn't pick a team scope. + expect(createApiKey).toHaveBeenCalledWith({ + name: "Production ingest", + scope: "personal", + }); + }); + + it("disables team scopes when the caller cannot create team keys", () => { + renderModal({ canCreateTeamKeys: false, teamName: null }); + expect(screen.getByText(`${K}.teamScopeLeaderOnly`)).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.tsx b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.tsx index 8461b9dd43..d1a52866df 100644 --- a/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.tsx +++ b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.tsx @@ -3,38 +3,49 @@ import { useTranslation } from "react-i18next"; import { Banner, Button, - Checkbox, CodeBlock, FormField, Input, Modal, + RadioGroup, + type RadioOption, } 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 { + createApiKey, + type ApiKeyScope, + type CreatedApiKey, +} from "@portal/api/infrastructure"; +import { errorMessage } from "@portal/api/http"; export function CreateKeyModal({ open, onClose, + canCreateTeamKeys, + teamName, + onCreated, }: { open: boolean; onClose: () => void; + /** Whether the caller (a team leader / admin) may mint team-scoped keys. */ + canCreateTeamKeys: boolean; + /** Team those keys would belong to, for the option descriptions. */ + teamName: string | null; + /** 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 [scope, setScope] = useState("personal"); + const [created, setCreated] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); function reset() { setName(""); - setPerms(["Read"]); - setIps(""); - setCreated(false); + setScope("personal"); + setCreated(null); + setSubmitting(false); + setError(null); } function close() { @@ -43,17 +54,44 @@ export function CreateKeyModal({ setTimeout(reset, 200); } - function togglePerm(p: ApiKeyPermission) { - setPerms((prev) => - prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p], - ); + async function createKey() { + setSubmitting(true); + setError(null); + try { + const result = await createApiKey({ name: name.trim(), scope }); + setCreated(result); + onCreated(); + } catch (e) { + setError(errorMessage(e)); + } finally { + setSubmitting(false); + } } - 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); - } + const teamLabel = teamName ?? t("portal.infrastructure.createKey.yourTeam"); + const scopeOptions: RadioOption[] = [ + { + value: "personal", + label: t("portal.infrastructure.createKey.scopePersonal"), + description: t("portal.infrastructure.createKey.scopePersonalHelp"), + }, + { + value: "team-members", + label: t("portal.infrastructure.createKey.scopeTeamMembers"), + description: t("portal.infrastructure.createKey.scopeTeamMembersHelp", { + team: teamLabel, + }), + disabled: !canCreateTeamKeys, + }, + { + value: "team-lead", + label: t("portal.infrastructure.createKey.scopeTeamLead"), + description: t("portal.infrastructure.createKey.scopeTeamLeadHelp", { + team: teamLabel, + }), + disabled: !canCreateTeamKeys, + }, + ]; return ( {t("portal.infrastructure.createKey.createKey")} @@ -95,7 +133,7 @@ export function CreateKeyModal({ {created ? (
@@ -106,6 +144,8 @@ export function CreateKeyModal({
) : (
+ {error && } + - -
- {PERMISSION_OPTS.map((p) => ( - togglePerm(p)} - /> - ))} + +
+ + name="apiKeyScope" + value={scope} + onChange={setScope} + options={scopeOptions} + /> + {!canCreateTeamKeys && ( +

+ {t("portal.infrastructure.createKey.teamScopeLeaderOnly")} +

+ )}
- - - setIps(e.target.value)} - placeholder="52.14.0.0/16, 203.0.113.7/32" - /> -
)} diff --git a/frontend/editor/src/portal/components/infrastructure/infraFormat.ts b/frontend/editor/src/portal/components/infrastructure/infraFormat.ts index 975a3a61e4..f2eaa4e1b8 100644 --- a/frontend/editor/src/portal/components/infrastructure/infraFormat.ts +++ b/frontend/editor/src/portal/components/infrastructure/infraFormat.ts @@ -1,6 +1,7 @@ import type { TFunction } from "i18next"; import type { ChipAccent, StatusTone } from "@app/ui"; import type { + ApiKeyScope, ApiKeyStatus, AttestationStatus, AuditCategory, @@ -55,13 +56,24 @@ export const DEPLOY_LABEL: Record = { export const KEY_TONE: Record = { active: "success", revoked: "danger", - "rotate-soon": "warning", }; export const KEY_LABEL: Record = { active: "portal.infrastructure.keyLabel.active", revoked: "portal.infrastructure.keyLabel.revoked", - "rotate-soon": "portal.infrastructure.keyLabel.rotateSoon", +}; + +// Scope chip labels (i18n keys), resolved via t(KEY_SCOPE_LABEL[value]). +export const KEY_SCOPE_LABEL: Record = { + personal: "portal.infrastructure.keyScopeLabel.personal", + "team-lead": "portal.infrastructure.keyScopeLabel.teamLead", + "team-members": "portal.infrastructure.keyScopeLabel.teamMembers", +}; + +export const KEY_SCOPE_TONE: Record = { + personal: "default", + "team-lead": "premium", + "team-members": "brand", }; export const CERT_TONE: Record = { diff --git a/frontend/editor/src/portal/mocks/handlers/infrastructure.ts b/frontend/editor/src/portal/mocks/handlers/infrastructure.ts index f885a39a1f..4c62ac974b 100644 --- a/frontend/editor/src/portal/mocks/handlers/infrastructure.ts +++ b/frontend/editor/src/portal/mocks/handlers/infrastructure.ts @@ -25,10 +25,55 @@ 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; + scope?: string; + }; + const scope = (body.scope ?? "personal") as + | "personal" + | "team-lead" + | "team-members"; + return HttpResponse.json({ + key: { + id: `key-${Date.now()}`, + name: body.name ?? "New key", + prefix: "sk_demo0000", + scope, + teamName: scope === "personal" ? null : "Acme Corp", + created: "2026-07-10", + lastUsed: "Never", + status: "active", + usageToday: 0, + usageMonth: 0, + canManage: true, + }, + 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); diff --git a/frontend/editor/src/portal/mocks/infrastructure.ts b/frontend/editor/src/portal/mocks/infrastructure.ts index 85aac3eeea..cfb384f71d 100644 --- a/frontend/editor/src/portal/mocks/infrastructure.ts +++ b/frontend/editor/src/portal/mocks/infrastructure.ts @@ -15,6 +15,7 @@ import type { Tier } from "@portal/contexts/TierContext"; import type { ApiKey, + ApiKeysResponse, AuditEvent, AuditLogResponse, AuditSummary, @@ -166,61 +167,65 @@ 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", + scope: "personal", + teamName: null, + 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, + canManage: true, }, { id: "key-2", - name: "Analytics · read-only", - prefix: "sk_live_77be…", - created: "Jan 18, 2026", - lastUsed: "41m ago", + name: "Team · shared ingest", + prefix: "sk_77be0f42", + scope: "team-members", + teamName: "Acme Corp", + created: "2026-01-18", + lastUsed: "2026-07-10 08:33", status: "active", - rateLimit: 300, - permissions: ["Read"], - allowedIps: [], usageToday: 6120, usageMonth: 188400, + canManage: true, }, { 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: "Ops · leaders only", + prefix: "sk_d9013ab7", + scope: "team-lead", + teamName: "Acme Corp", + created: "2025-08-09", + lastUsed: "2026-07-04 17:02", + status: "active", usageToday: 0, usageMonth: 14200, + canManage: true, }, { id: "key-4", name: "Sandbox · webhook tester", - prefix: "sk_test_2c4a…", - created: "May 30, 2026", - lastUsed: "never", + prefix: "sk_2c4a91de", + scope: "personal", + teamName: null, + created: "2026-05-30", + lastUsed: "Never", status: "revoked", - rateLimit: 60, - permissions: ["Read"], - allowedIps: [], usageToday: 0, usageMonth: 0, + canManage: true, }, ]; -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, canCreateTeamKeys: tier !== "free", teamName: "Acme Corp" }; } /* ──────────────────────────────────────────────────────────────────────── */ diff --git a/testing/cucumber/features/enterprise/api_keys.feature b/testing/cucumber/features/enterprise/api_keys.feature new file mode 100644 index 0000000000..224f7844f7 --- /dev/null +++ b/testing/cucumber/features/enterprise/api_keys.feature @@ -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 API keys - personal or team-scoped. + + 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 or exposed to a team. + + 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", "scope": "personal"}' + 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": "", "scope": "personal"}' + 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