Reduce API keys to personal-only; drop team sharing

This commit is contained in:
Anthony Stirling
2026-07-11 23:24:06 +01:00
parent 456a96e15c
commit 13ca7bd52e
45 changed files with 109 additions and 1268 deletions
@@ -20,19 +20,12 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.model.ApiKeyAccess;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
/**
* API-key auth for the MCP endpoint: validates a Stirling API key and binds the request to that
* user with the MCP scopes.
*
* <p>Only a full-access key may drive MCP. A processing-only key (every shared team key, plus any
* personal key the owner deliberately limited) is confined to the {@code /api} file-processing
* allowlist by {@code ApiKeyProcessingScopeInterceptor}; because {@code /mcp} sits outside that
* interceptor's path, this filter is where that boundary has to be re-asserted, so it rejects any
* key resolved as {@link ApiKeyAccess#PROCESSING}.
*/
@Slf4j
public class McpApiKeyAuthFilter extends OncePerRequestFilter {
@@ -42,10 +35,10 @@ public class McpApiKeyAuthFilter extends OncePerRequestFilter {
new SimpleGrantedAuthority("SCOPE_mcp.tools.read"),
new SimpleGrantedAuthority("SCOPE_mcp.tools.write"));
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
private final UserService userService;
public McpApiKeyAuthFilter(ApiKeyAuthenticationService apiKeyAuthenticationService) {
this.apiKeyAuthenticationService = apiKeyAuthenticationService;
public McpApiKeyAuthFilter(UserService userService) {
this.userService = userService;
}
@Override
@@ -61,22 +54,17 @@ public class McpApiKeyAuthFilter extends OncePerRequestFilter {
if (unauthenticated) {
String apiKey = extractKey(request);
if (apiKey != null && !apiKey.isBlank()) {
Optional<ApiKeyAuthentication> resolved =
apiKeyAuthenticationService.authenticate(apiKey);
// Reject a processing-only key: it must not reach MCP tools as the owner.
// authenticate()
// already rejects inactive keys and disabled owners.
if (resolved.isPresent() && resolved.get().access() != ApiKeyAccess.PROCESSING) {
Optional<User> user = userService.getUserByApiKey(apiKey);
if (user.isPresent() && user.get().isEnabled()) {
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
resolved.get().user().getUsername(), null, MCP_SCOPES);
user.get().getUsername(), null, MCP_SCOPES);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context);
} else {
log.warn(
"MCP access denied: presented API key did not match an active"
+ " full-access account");
"MCP access denied: presented API key did not match an active account");
}
}
}
@@ -38,7 +38,6 @@ import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
import stirling.software.proprietary.security.service.UserService;
/**
@@ -53,7 +52,6 @@ public class McpSecurityConfig {
private final ApplicationProperties applicationProperties;
private final UserService userService;
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
// Reuse the app's CORS config; ObjectProvider so the chain still wires when no CORS bean
// exists.
@@ -64,11 +62,9 @@ public class McpSecurityConfig {
public McpSecurityConfig(
ApplicationProperties applicationProperties,
@Lazy UserService userService,
ApiKeyAuthenticationService apiKeyAuthenticationService,
ObjectProvider<CorsConfigurationSource> corsConfigurationSource) {
this.applicationProperties = applicationProperties;
this.userService = userService;
this.apiKeyAuthenticationService = apiKeyAuthenticationService;
this.corsConfigurationSource = corsConfigurationSource;
}
@@ -142,8 +138,7 @@ public class McpSecurityConfig {
AuthorizationFilter.class)
// Authenticate before the anonymous filter sets an anonymous token.
.addFilterBefore(
new McpApiKeyAuthFilter(apiKeyAuthenticationService),
AnonymousAuthenticationFilter.class);
new McpApiKeyAuthFilter(userService), AnonymousAuthenticationFilter.class);
return http.build();
}
@@ -1,8 +1,4 @@
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. {@code access} is
* "full" or "processing"; a team-scoped key must be processing-only (full access can't be shared).
*/
public record CreateApiKeyRequest(String name, String scope, String access) {}
/** Create-key request body from the portal: just a display name for the new personal key. */
public record CreateApiKeyRequest(String name) {}
@@ -11,12 +11,6 @@ public record PortalApiKeyDto(
String id,
String name,
String prefix,
/** "personal" | "team-lead" | "team-members". */
String scope,
/** "full" (acts as owner) | "processing" (file/PDF endpoints only). */
String access,
/** Team name for a team-scoped key, else null. */
String teamName,
String created,
String lastUsed,
/** "active" | "revoked". */
@@ -24,6 +18,4 @@ public record PortalApiKeyDto(
long usageToday,
long usageMonth,
/** Lifetime request count for the key. */
long usageTotal,
/** Whether the current caller may revoke this key. */
boolean canManage) {}
long usageTotal) {}
@@ -4,10 +4,6 @@ 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.
*/
/** Payload for the API Keys tab: the personal keys the caller owns. */
@Builder
public record PortalApiKeysResponse(
List<PortalApiKeyDto> keys, boolean canCreateTeamKeys, String teamName) {}
public record PortalApiKeysResponse(List<PortalApiKeyDto> keys) {}
@@ -6,7 +6,6 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.filter.ApiKeyProcessingScopeInterceptor;
import stirling.software.proprietary.security.filter.ParticipantRateLimitInterceptor;
@Configuration
@@ -14,13 +13,10 @@ import stirling.software.proprietary.security.filter.ParticipantRateLimitInterce
public class ProprietaryWebMvcConfig implements WebMvcConfigurer {
private final ParticipantRateLimitInterceptor participantRateLimitInterceptor;
private final ApiKeyProcessingScopeInterceptor apiKeyProcessingScopeInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(participantRateLimitInterceptor)
.addPathPatterns("/api/v1/workflow/participant/**");
// Confine processing-only (and every shared team) API key to the file/PDF endpoints.
registry.addInterceptor(apiKeyProcessingScopeInterceptor).addPathPatterns("/api/**");
}
}
@@ -1,85 +0,0 @@
package stirling.software.proprietary.security.filter;
import java.util.Set;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
/**
* Confines a processing-only API key to the file/PDF endpoints. A key the creator marked {@code
* PROCESSING} (and every shared team key is processing-only) may call the tool namespaces below and
* nothing else - any account, team, admin or portal-management request is rejected with 403.
*
* <p>This is the single, additive boundary the whole shared-key model rests on: because a shared
* key can never leave the data plane, it can never carry the owner's account powers no matter which
* filter authenticated it or what role the owner holds. Full-access and legacy keys are unaffected.
*/
@Slf4j
@Component
public class ApiKeyProcessingScopeInterceptor implements HandlerInterceptor {
/**
* The file/PDF processing namespaces a processing-only key may reach. Deliberately an allowlist
* (default-deny): a new management endpoint is blocked automatically, and the worst a missing
* tool prefix can do is 403 a legitimate processing call - never widen access.
*/
private static final Set<String> ALLOWED_PREFIXES =
Set.of(
"/api/v1/general",
"/api/v1/convert",
"/api/v1/misc",
"/api/v1/filter",
"/api/v1/analysis",
"/api/v1/pipeline",
"/api/v1/security",
"/api/v1/form",
"/api/v1/info",
"/api/v1/mobile-scanner");
@Override
public boolean preHandle(
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (!(auth instanceof ApiKeyAuthenticationToken token) || !token.isProcessingOnly()) {
return true;
}
String path = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) {
path = path.substring(contextPath.length());
}
if (isProcessingPath(path)) {
return true;
}
log.debug("Processing-only API key blocked from non-tool endpoint {}", path);
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json");
response.getWriter()
.write(
"{\"error\":\"Forbidden\",\"message\":\"This API key is limited to"
+ " file/PDF processing endpoints.\",\"status\":403}");
return false;
}
private static boolean isProcessingPath(String path) {
for (String prefix : ALLOWED_PREFIXES) {
if (path.equals(prefix) || path.startsWith(prefix + "/")) {
return true;
}
}
return false;
}
}
@@ -150,10 +150,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
authentication =
new ApiKeyAuthenticationToken(
resolved.get().user(),
apiKey,
resolved.get().authorities(),
resolved.get().access());
resolved.get().user(), apiKey, resolved.get().authorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
return true;
} catch (AuthenticationException e) {
@@ -113,10 +113,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
User user = resolved.get().user();
authentication =
new ApiKeyAuthenticationToken(
user,
apiKey,
resolved.get().authorities(),
resolved.get().access());
user, apiKey, resolved.get().authorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
if (resolved.get().auditLabel() != null) {
MDC.put(API_KEY_LABEL_MDC, resolved.get().auditLabel());
@@ -12,18 +12,17 @@ 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.
* A named, personal API key belonging to a user. The raw secret is shown once at creation and never
* stored; only its SHA-256 hash is persisted, so a leaked database row cannot be replayed. Distinct
* from the legacy single {@code users.apiKey} column, which stays a per-user key for backward
* compatibility and is lazily represented here.
*/
@Entity
@Table(
name = "api_keys",
indexes = {
@Index(name = "idx_api_key_hash", columnList = "key_hash", unique = true),
@Index(name = "idx_api_key_owner", columnList = "owner_user_id"),
@Index(name = "idx_api_key_team", columnList = "team_id")
@Index(name = "idx_api_key_owner", columnList = "owner_user_id")
})
@Getter
@Setter
@@ -50,27 +49,10 @@ public class ApiKey implements Serializable {
@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. */
/** The user who created and owns the key; the key 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;
/**
* How much power the key carries; a shared (team) key is always {@link
* ApiKeyAccess#PROCESSING}.
*/
@Enumerated(EnumType.STRING)
@Column(name = "access", nullable = false)
@Builder.Default
private ApiKeyAccess access = ApiKeyAccess.FULL;
@Column(name = "enabled", nullable = false)
private boolean enabled;
@@ -1,22 +0,0 @@
package stirling.software.proprietary.security.model;
/**
* How much power an {@link ApiKey} carries when it authenticates - chosen by the creator.
*
* <ul>
* <li>{@code FULL} - acts as its owner with the owner's full authorities, including account and
* admin endpoints. A full-access key is the owner's own credential and must never be shared,
* so it is always PERSONAL (never team-scoped).
* <li>{@code PROCESSING} - restricted to the file/PDF processing endpoints; blocked from account,
* team, admin and portal management by {@code ApiKeyProcessingScopeInterceptor}. Safe to
* share regardless of the owner's role, so every team-scoped key is PROCESSING.
* </ul>
*/
public enum ApiKeyAccess {
FULL,
PROCESSING;
public boolean isProcessingOnly() {
return this == PROCESSING;
}
}
@@ -5,57 +5,27 @@ import java.util.Collection;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
/** Authentication produced from an {@code X-API-KEY} header; runs as the key's owner. */
public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
private final Object principal;
private Object credentials;
// How much power the resolving key carries. A PROCESSING key is restricted to the file/PDF
// endpoints (enforced by ApiKeyProcessingScopeInterceptor) and never confers team-leader
// powers,
// regardless of the owner's role - so a shared key is safe.
private final ApiKeyAccess access;
public ApiKeyAuthenticationToken(String apiKey) {
super((Collection<? extends GrantedAuthority>) null);
this.principal = null;
this.credentials = apiKey;
this.access = ApiKeyAccess.FULL;
setAuthenticated(false);
}
public ApiKeyAuthenticationToken(
Object principal, String apiKey, Collection<? extends GrantedAuthority> authorities) {
this(principal, apiKey, authorities, ApiKeyAccess.FULL);
}
public ApiKeyAuthenticationToken(
Object principal,
String apiKey,
Collection<? extends GrantedAuthority> authorities,
ApiKeyAccess access) {
super(authorities);
this.principal = principal; // principal can be a UserDetails object
this.credentials = apiKey;
this.access = access == null ? ApiKeyAccess.FULL : access;
super.setAuthenticated(true); // this authentication is trusted
}
/**
* How much power this key carries (FULL acts as owner; PROCESSING is file/PDF endpoints only).
*/
public ApiKeyAccess getAccess() {
return access;
}
/**
* Whether this key is restricted to file/PDF processing endpoints - true for every shared team
* key and for any personal key the owner chose to limit. Such a key never confers team-leader
* or admin powers.
*/
public boolean isProcessingOnly() {
return access != null && access.isProcessingOnly();
}
@Override
public Object getCredentials() {
return credentials;
@@ -1,25 +0,0 @@
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.
*
* <ul>
* <li>{@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.
* <li>{@code TEAM_LEAD} - a team key visible to and managed by the team's leaders only.
* <li>{@code TEAM_MEMBERS} - a team key visible to every member of the team; still created and
* revoked by a leader.
* </ul>
*/
public enum ApiKeyScope {
PERSONAL,
TEAM_LEAD,
TEAM_MEMBERS;
public boolean isTeamScoped() {
return this == TEAM_LEAD || this == TEAM_MEMBERS;
}
}
@@ -16,6 +16,4 @@ public interface ApiKeyRepository extends JpaRepository<ApiKey, Long> {
boolean existsByKeyHash(String keyHash);
List<ApiKey> findByOwnerUserIdOrderByCreatedAtDesc(Long ownerUserId);
List<ApiKey> findByTeamIdOrderByCreatedAtDesc(Long teamId);
}
@@ -2,21 +2,16 @@ 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.ApiKeyAccess;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.ApiKeyRepository;
@@ -26,8 +21,8 @@ import stirling.software.proprietary.security.repository.ApiKeyRepository;
* bean cycle.
*
* <p>Resolution order: the multi-key {@code api_keys} table first (by hash), then the legacy
* per-user {@code users.apiKey} column. Legacy keys therefore keep working unchanged and are always
* treated as personal to their user.
* per-user {@code users.apiKey} column. Legacy keys therefore keep working unchanged. Every key is
* personal and authenticates as its owner with the owner's authorities.
*/
@Service
@RequiredArgsConstructor
@@ -51,9 +46,7 @@ public class ApiKeyAuthenticationService {
/**
* 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}).
* for the resolved key ({@code null} for the legacy per-user key), and the owner's authorities.
*/
public Optional<ApiKeyAuthentication> authenticate(String rawKey) {
if (rawKey == null || rawKey.isBlank()) {
@@ -71,40 +64,14 @@ public class ApiKeyAuthenticationService {
}
usageRecorder.record(key.getId());
return Optional.of(
new ApiKeyAuthentication(
owner, auditLabel(key), authoritiesFor(owner, key), key.getAccess()));
new ApiKeyAuthentication(owner, auditLabel(key), owner.getAuthorities()));
}
// Legacy single per-user key: keep working, always a full-access personal key for its user.
// Legacy single per-user key: keep working, always a personal key for its user.
return userRepository
.findByApiKey(rawKey)
.filter(User::isEnabled)
.map(
user ->
new ApiKeyAuthentication(
user, null, user.getAuthorities(), ApiKeyAccess.FULL));
}
/**
* Authorities the resolved key authenticates with. A FULL key is the owner using their own
* credential, so it carries the owner's authorities unchanged. A PROCESSING key is restricted
* to the file/PDF endpoints (and is the only kind that can be shared as a team key); as defence
* in depth behind {@code ApiKeyProcessingScopeInterceptor} it can never carry admin, so the
* owner's authorities are capped below {@code ROLE_ADMIN}, falling back to {@code ROLE_USER}.
*/
private static Collection<? extends GrantedAuthority> authoritiesFor(User owner, ApiKey key) {
if (key.getAccess() != ApiKeyAccess.PROCESSING) {
return owner.getAuthorities();
}
String adminRole = Role.ADMIN.getRoleId();
List<GrantedAuthority> 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;
.map(user -> new ApiKeyAuthentication(user, null, user.getAuthorities()));
}
/** "Production ingest (sk_a1b2c3d4)" - shown against API-sourced docs in the processor feed. */
@@ -133,13 +100,8 @@ public class ApiKeyAuthenticationService {
}
/**
* A resolved key: the user, an optional processor-feed label, the authorities to run as, and
* how much power the key carries (a PROCESSING key is confined to file/PDF endpoints and never
* confers team-leader powers).
* A resolved key: the user, an optional processor-feed label, and the authorities to run as.
*/
public record ApiKeyAuthentication(
User user,
String auditLabel,
Collection<? extends GrantedAuthority> authorities,
ApiKeyAccess access) {}
User user, String auditLabel, Collection<? extends GrantedAuthority> authorities) {}
}
@@ -3,10 +3,8 @@ 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.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.dao.DataIntegrityViolationException;
@@ -18,29 +16,22 @@ 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.ApiKeyAccess;
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.
* Portal-facing CRUD for named, personal API keys: lists, creates, and revokes the caller's own
* keys. Every key belongs to exactly one user and authenticates as that user; there is no sharing.
*
* <p>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.
* <p>Every pre-existing single {@code users.apiKey} is lazily represented as a key owned by that
* user, so historic keys list uniformly.
*/
@Slf4j
@Service
@@ -62,35 +53,19 @@ public class ApiKeyManagementService {
private final ApiKeyRepository apiKeyRepository;
private final ApiKeyDailyUsageRepository usageRepository;
private final UserRepository userRepository;
private final TeamRepository teamRepository;
private final UserService userService;
private final PolicyManagementAuthority policyAuthority;
private final ApiKeyLegacyMigrator legacyMigrator;
/** All keys the caller may see, plus whether they may mint team keys. */
/** All keys the caller owns. */
@Transactional
public PortalApiKeysResponse listVisibleKeys() {
User caller = requireCaller();
migrateLegacyKey(caller);
boolean isManager = isTeamKeyManager();
Long teamId = policyAuthority.currentUserTeamId();
String teamName = teamId == null ? null : teamNameFor(teamId);
List<ApiKey> visible =
apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(caller.getId());
List<ApiKey> 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);
}
// Batch usage for all visible keys into two queries rather than two-per-key (avoids N+1).
// Batch usage for all keys into three queries rather than two-per-key (avoids N+1).
long today = Instant.now().atZone(ZoneOffset.UTC).toLocalDate().toEpochDay();
List<Long> ids = visible.stream().map(ApiKey::getId).toList();
Map<Long, Long> todayById = new HashMap<>();
@@ -113,25 +88,19 @@ public class ApiKeyManagementService {
.map(
k ->
toDto(
caller,
k,
teamName,
zeroIfNull(todayById.get(k.getId())),
zeroIfNull(monthById.get(k.getId())),
zeroIfNull(totalById.get(k.getId()))))
.toList();
return PortalApiKeysResponse.builder()
.keys(keys)
.canCreateTeamKeys(isManager && teamId != null)
.teamName(teamName)
.build();
return PortalApiKeysResponse.builder().keys(keys).build();
}
private static long zeroIfNull(Long value) {
return value == null ? 0L : value;
}
/** Create a key and return its one-time secret. */
/** Create a personal key and return its one-time secret. */
@Transactional
public CreatedApiKeyDto createKey(CreateApiKeyRequest request) {
User caller = requireCaller();
@@ -155,24 +124,6 @@ public class ApiKeyManagementService {
+ MAX_ACTIVE_KEYS_PER_USER
+ " active API keys; revoke one before creating another");
}
ApiKeyScope scope = parseScope(request.scope());
ApiKeyAccess access = parseAccess(request.access(), scope);
Long teamId = null;
String teamName = null;
if (scope.isTeamScoped()) {
if (!isTeamKeyManager()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Team API keys can only be created by a team leader or an admin");
}
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 =
@@ -182,20 +133,14 @@ public class ApiKeyManagementService {
.keyHash(ApiKeyHasher.hash(rawKey))
.prefix(ApiKeyHasher.displayPrefix(rawKey))
.ownerUserId(caller.getId())
.teamId(teamId)
.scope(scope)
.access(access)
.enabled(true)
.createdAt(Instant.now())
.build());
return CreatedApiKeyDto.builder()
.key(toDto(caller, saved, teamName, 0L, 0L, 0L))
.secret(rawKey)
.build();
return CreatedApiKeyDto.builder().key(toDto(saved, 0L, 0L, 0L)).secret(rawKey).build();
}
/** Soft-revoke a key the caller manages; also clears the legacy column if it is that key. */
/** Soft-revoke a key the caller owns; also clears the legacy column if it is that key. */
@Transactional
public void revokeKey(Long id) {
User caller = requireCaller();
@@ -204,8 +149,8 @@ public class ApiKeyManagementService {
.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.
if (!key.getOwnerUserId().equals(caller.getId())) {
// Not-found rather than forbidden so a caller can't probe other users' key ids.
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No key");
}
key.setEnabled(false);
@@ -214,25 +159,7 @@ public class ApiKeyManagementService {
clearLegacyColumnIfMatches(key);
}
/**
* Team keys are managed by a team leader (the SaaS rule) or an admin (either flavor) - an admin
* is effectively a leader for this purpose, which the SaaS team-leader-only check misses.
*/
private boolean isTeamKeyManager() {
return policyAuthority.canEditPolicies() || userService.isCurrentUserAdmin();
}
/** 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 isTeamKeyManager()
&& key.getTeamId() != null
&& key.getTeamId().equals(policyAuthority.currentUserTeamId());
}
/** Represent a user's pre-existing single key as a PERSONAL row so it lists uniformly. */
/** Represent a user's pre-existing single key as a row so it lists uniformly. */
private void migrateLegacyKey(User user) {
String legacy = user.getApiKey();
if (legacy == null || legacy.isBlank()) {
@@ -251,9 +178,6 @@ public class ApiKeyManagementService {
.keyHash(hash)
.prefix(ApiKeyHasher.displayPrefix(legacy))
.ownerUserId(user.getId())
.teamId(null)
.scope(ApiKeyScope.PERSONAL)
.access(ApiKeyAccess.FULL)
.enabled(true)
.createdAt(Instant.now())
.build());
@@ -280,20 +204,11 @@ public class ApiKeyManagementService {
});
}
private PortalApiKeyDto toDto(
User caller,
ApiKey key,
String teamName,
long usageToday,
long usageMonth,
long usageTotal) {
private PortalApiKeyDto toDto(ApiKey key, long usageToday, long usageMonth, long usageTotal) {
return PortalApiKeyDto.builder()
.id(String.valueOf(key.getId()))
.name(key.getName())
.prefix(key.getPrefix())
.scope(scopeLabel(key.getScope()))
.access(accessLabel(key.getAccess()))
.teamName(key.getScope().isTeamScoped() ? teamName : null)
.created(
key.getCreatedAt() == null ? "" : CREATED_FORMAT.format(key.getCreatedAt()))
.lastUsed(
@@ -304,69 +219,9 @@ public class ApiKeyManagementService {
.usageToday(usageToday)
.usageMonth(usageMonth)
.usageTotal(usageTotal)
.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 String accessLabel(ApiKeyAccess access) {
return access == ApiKeyAccess.PROCESSING ? "processing" : "full";
}
/**
* Resolve the requested access level, enforcing that a shared (team) key can only ever be
* processing-only - full access acts as the owner and must not be handed to a team. A personal
* key defaults to full (matching the legacy single key) unless the creator limits it.
*/
private static ApiKeyAccess parseAccess(String raw, ApiKeyScope scope) {
ApiKeyAccess requested = null;
if (raw != null && !raw.isBlank()) {
requested =
switch (raw.trim().toLowerCase(Locale.ROOT)) {
case "full" -> ApiKeyAccess.FULL;
case "processing", "processing-only", "processingonly" ->
ApiKeyAccess.PROCESSING;
default ->
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Unknown access: " + raw);
};
}
if (scope.isTeamScoped()) {
if (requested == ApiKeyAccess.FULL) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"A shared team key must be processing-only; full access can't be shared");
}
return ApiKeyAccess.PROCESSING;
}
return requested == null ? ApiKeyAccess.FULL : requested;
}
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)) {
@@ -26,10 +26,7 @@ import stirling.software.proprietary.mcp.tools.StirlingConvertTool;
import stirling.software.proprietary.mcp.tools.StirlingMiscTool;
import stirling.software.proprietary.mcp.tools.StirlingPagesTool;
import stirling.software.proprietary.mcp.tools.StirlingSecurityTool;
import stirling.software.proprietary.security.model.ApiKeyAccess;
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;
/**
@@ -41,7 +38,6 @@ import stirling.software.proprietary.security.service.UserService;
class McpApiKeyIntegrationTest {
private static final String VALID_KEY = "stirling-test-key-abc123";
private static final String PROCESSING_KEY = "stirling-test-processing-xyz";
@LocalServerPort private int port;
private final HttpClient http = HttpClient.newHttpClient();
@@ -87,16 +83,6 @@ class McpApiKeyIntegrationTest {
assertThat(response.statusCode()).isEqualTo(401);
}
@Test
void processingOnlyKey_isRejectedWith401() throws Exception {
// A shared/team (processing-only) key must not reach MCP tools as the owner.
HttpResponse<String> response =
postMcp(
b -> b.header("X-API-KEY", PROCESSING_KEY),
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}");
assertThat(response.statusCode()).isEqualTo(401);
}
@Test
void noOAuthMetadataInApiKeyMode() throws Exception {
HttpRequest req =
@@ -146,38 +132,16 @@ class McpApiKeyIntegrationTest {
return props;
}
// Still a constructor dependency of McpSecurityConfig (used by the OAuth chain); the
// api-key chain now authenticates through ApiKeyAuthenticationService instead.
@Bean
UserService userService() {
return org.mockito.Mockito.mock(UserService.class);
}
@Bean
ApiKeyAuthenticationService apiKeyAuthenticationService() {
ApiKeyAuthenticationService mock =
org.mockito.Mockito.mock(ApiKeyAuthenticationService.class);
UserService mock = org.mockito.Mockito.mock(UserService.class);
User account = org.mockito.Mockito.mock(User.class);
org.mockito.Mockito.when(account.isEnabled()).thenReturn(true);
org.mockito.Mockito.when(account.getUsername()).thenReturn("alice");
org.mockito.Mockito.when(mock.authenticate(org.mockito.ArgumentMatchers.anyString()))
org.mockito.Mockito.when(mock.getUserByApiKey(org.mockito.ArgumentMatchers.anyString()))
.thenReturn(Optional.empty());
org.mockito.Mockito.when(mock.authenticate(VALID_KEY))
.thenReturn(
Optional.of(
new ApiKeyAuthentication(
account,
null,
java.util.List.of(),
ApiKeyAccess.FULL)));
org.mockito.Mockito.when(mock.authenticate(PROCESSING_KEY))
.thenReturn(
Optional.of(
new ApiKeyAuthentication(
account,
"Team key (sk_proc)",
java.util.List.of(),
ApiKeyAccess.PROCESSING)));
org.mockito.Mockito.when(mock.getUserByApiKey(VALID_KEY))
.thenReturn(Optional.of(account));
return mock;
}
}
@@ -352,14 +352,5 @@ class McpOAuthIntegrationTest {
.thenReturn(java.util.Optional.of(account));
return mock;
}
// Constructor dependency of McpSecurityConfig; unused on the OAuth chain.
@Bean
stirling.software.proprietary.security.service.ApiKeyAuthenticationService
apiKeyAuthenticationService() {
return org.mockito.Mockito.mock(
stirling.software.proprietary.security.service.ApiKeyAuthenticationService
.class);
}
}
}
@@ -101,14 +101,5 @@ class McpScopeMetadataDisabledTest {
UserService userService() {
return org.mockito.Mockito.mock(UserService.class);
}
// Constructor dependency of McpSecurityConfig; unused on the OAuth chain.
@Bean
stirling.software.proprietary.security.service.ApiKeyAuthenticationService
apiKeyAuthenticationService() {
return org.mockito.Mockito.mock(
stirling.software.proprietary.security.service.ApiKeyAuthenticationService
.class);
}
}
}
@@ -1,115 +0,0 @@
package stirling.software.proprietary.security.filter;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import stirling.software.proprietary.security.model.ApiKeyAccess;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
/**
* The additive boundary the shared-key model rests on: a processing-only API key may reach the
* file/PDF tool namespaces and nothing else.
*/
class ApiKeyProcessingScopeInterceptorTest {
private final ApiKeyProcessingScopeInterceptor interceptor =
new ApiKeyProcessingScopeInterceptor();
@AfterEach
void clear() {
SecurityContextHolder.clearContext();
}
private void authenticateProcessingKey() {
SecurityContextHolder.getContext()
.setAuthentication(
new ApiKeyAuthenticationToken(
"user", "sk", List.of(), ApiKeyAccess.PROCESSING));
}
private boolean preHandle(String uri) throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("POST", uri);
request.setRequestURI(uri);
MockHttpServletResponse response = new MockHttpServletResponse();
return interceptor.preHandle(request, response, new Object());
}
@Test
void processingKeyAllowedOnToolEndpoints() throws Exception {
authenticateProcessingKey();
assertThat(preHandle("/api/v1/general/merge-pdfs")).isTrue();
assertThat(preHandle("/api/v1/convert/pdf/img")).isTrue();
assertThat(preHandle("/api/v1/security/add-password")).isTrue();
assertThat(preHandle("/api/v1/misc/repair")).isTrue();
}
@Test
void processingKeyBlockedOnManagementEndpoints() throws Exception {
authenticateProcessingKey();
MockHttpServletRequest request =
new MockHttpServletRequest(
"POST", "/api/v1/proprietary/ui-data/infrastructure/api-keys");
request.setRequestURI("/api/v1/proprietary/ui-data/infrastructure/api-keys");
MockHttpServletResponse response = new MockHttpServletResponse();
assertThat(interceptor.preHandle(request, response, new Object())).isFalse();
assertThat(response.getStatus()).isEqualTo(403);
}
@Test
void processingKeyBlockedOnTeamAndAdminEndpoints() throws Exception {
authenticateProcessingKey();
assertThat(preHandle("/api/v1/team/invite")).isFalse();
assertThat(preHandle("/api/v1/admin/settings")).isFalse();
assertThat(preHandle("/api/v1/user/change-role")).isFalse();
}
@Test
void prefixLookalikeIsNotTreatedAsATool() throws Exception {
// "/api/v1/general-admin" must not slip through on a naive startsWith("/api/v1/general").
authenticateProcessingKey();
assertThat(preHandle("/api/v1/general-admin/backup")).isFalse();
}
@Test
void contextPathIsStrippedBeforeMatching() throws Exception {
authenticateProcessingKey();
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/stirling");
request.setContextPath("/stirling");
request.setRequestURI("/stirling/api/v1/general/merge-pdfs");
MockHttpServletResponse response = new MockHttpServletResponse();
assertThat(interceptor.preHandle(request, response, new Object())).isTrue();
}
@Test
void fullAccessKeyIsUnrestricted() throws Exception {
SecurityContextHolder.getContext()
.setAuthentication(
new ApiKeyAuthenticationToken("user", "sk", List.of(), ApiKeyAccess.FULL));
assertThat(preHandle("/api/v1/proprietary/ui-data/infrastructure/api-keys")).isTrue();
assertThat(preHandle("/api/v1/admin/settings")).isTrue();
}
@Test
void nonApiKeyAuthIsIgnored() throws Exception {
SecurityContextHolder.getContext()
.setAuthentication(
new UsernamePasswordAuthenticationToken(
"user", null, List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))));
assertThat(preHandle("/api/v1/admin/settings")).isTrue();
}
@Test
void unauthenticatedIsIgnored() throws Exception {
assertThat(preHandle("/api/v1/admin/settings")).isTrue();
}
}
@@ -27,7 +27,6 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.session.SessionInformation;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.model.ApiKeyAccess;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
@@ -111,10 +110,7 @@ class UserAuthenticationFilterTest {
.thenReturn(
Optional.of(
new ApiKeyAuthentication(
user,
"Prod (sk_demo0000)",
user.getAuthorities(),
ApiKeyAccess.FULL)));
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()))
@@ -20,8 +20,6 @@ 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.ApiKeyAccess;
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;
@@ -50,7 +48,6 @@ class ApiKeyAuthenticationServiceTest {
.keyHash(ApiKeyHasher.hash("raw-" + id))
.prefix("sk_demo0000")
.ownerUserId(ownerId)
.scope(ApiKeyScope.PERSONAL)
.enabled(enabled)
.revokedAt(revoked)
.createdAt(Instant.now())
@@ -118,42 +115,8 @@ class ApiKeyAuthenticationServiceTest {
}
@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)
.access(ApiKeyAccess.PROCESSING)
.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<String> auths =
result.get().authorities().stream().map(GrantedAuthority::getAuthority).toList();
assertThat(auths).doesNotContain(Role.ADMIN.getRoleId()).contains(Role.USER.getRoleId());
// Marked processing-only so SaaS team-leader checks (a membership lookup, not an authority)
// won't treat this shared key as a leader, and the endpoint boundary confines it to tools.
assertThat(result.get().access()).isEqualTo(ApiKeyAccess.PROCESSING);
}
@Test
@DisplayName("a personal key keeps the owner's authorities (owner acts as self)")
void personalKeyKeepsOwnerAuthorities() {
@DisplayName("a key authenticates with its owner's authorities (owner acts as self)")
void keyKeepsOwnerAuthorities() {
String raw = "raw-6";
User owner = user(8, true);
owner.addAuthority(new Authority(Role.ADMIN.getRoleId(), owner));
@@ -166,38 +129,6 @@ class ApiKeyAuthenticationServiceTest {
List<String> auths =
result.get().authorities().stream().map(GrantedAuthority::getAuthority).toList();
assertThat(auths).contains(Role.ADMIN.getRoleId());
// A default personal key is full-access, so it keeps the owner's full role.
assertThat(result.get().access()).isEqualTo(ApiKeyAccess.FULL);
}
@Test
@DisplayName("a personal processing-only key drops admin (cap keys on access, not scope)")
void personalProcessingKeyCapsAdmin() {
String raw = "raw-proc";
User owner = user(9, true);
owner.addAuthority(new Authority(Role.ADMIN.getRoleId(), owner));
ApiKey procKey =
ApiKey.builder()
.id(9L)
.name("Limited")
.keyHash(ApiKeyHasher.hash(raw))
.prefix("sk_demo0000")
.ownerUserId(9L)
.scope(ApiKeyScope.PERSONAL)
.access(ApiKeyAccess.PROCESSING)
.enabled(true)
.createdAt(Instant.now())
.build();
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
.thenReturn(Optional.of(procKey));
when(userRepository.findById(9L)).thenReturn(Optional.of(owner));
var result = service.authenticate(raw);
List<String> auths =
result.get().authorities().stream().map(GrantedAuthority::getAuthority).toList();
assertThat(auths).doesNotContain(Role.ADMIN.getRoleId()).contains(Role.USER.getRoleId());
assertThat(result.get().access()).isEqualTo(ApiKeyAccess.PROCESSING);
}
@Test
@@ -21,22 +21,16 @@ import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
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.ApiKeyAccess;
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")
@@ -45,9 +39,7 @@ 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;
@Mock private ApiKeyLegacyMigrator legacyMigrator;
@InjectMocks private ApiKeyManagementService service;
@@ -63,8 +55,8 @@ class ApiKeyManagementServiceTest {
.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);
lenient().when(usageRepository.countForDayByIds(any(), anyLong())).thenReturn(List.of());
lenient().when(usageRepository.sumSinceByIds(any(), anyLong())).thenReturn(List.of());
}
private ApiKey personalKey(long id, long ownerId) {
@@ -74,21 +66,6 @@ class ApiKeyManagementServiceTest {
.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();
@@ -97,12 +74,11 @@ class ApiKeyManagementServiceTest {
// ---- migration safety ---------------------------------------------------
@Test
@DisplayName("an existing legacy key migrates to a PERSONAL, owner-only row - never a team")
@DisplayName("an existing legacy key migrates to an owner-only row")
void legacyKeyMigratesAsPersonal() {
caller.setApiKey("legacy-raw-key");
when(apiKeyRepository.existsByKeyHash(ApiKeyHasher.hash("legacy-raw-key")))
.thenReturn(false);
when(policyAuthority.currentUserTeamId()).thenReturn(null);
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L)).thenReturn(List.of());
service.listVisibleKeys();
@@ -111,9 +87,7 @@ class ApiKeyManagementServiceTest {
ArgumentCaptor<ApiKey> saved = ArgumentCaptor.forClass(ApiKey.class);
verify(legacyMigrator).insertMigratedKey(saved.capture());
ApiKey migrated = saved.getValue();
assertThat(migrated.getScope()).isEqualTo(ApiKeyScope.PERSONAL);
assertThat(migrated.getOwnerUserId()).isEqualTo(1L);
assertThat(migrated.getTeamId()).isNull();
}
@Test
@@ -122,7 +96,6 @@ class ApiKeyManagementServiceTest {
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();
@@ -133,175 +106,66 @@ class ApiKeyManagementServiceTest {
// ---- personal isolation -------------------------------------------------
@Test
@DisplayName("listing scopes personal keys to the caller by owner id")
@DisplayName("listing scopes 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"));
assertThat(res.keys()).singleElement().satisfies(k -> assertThat(k.id()).isEqualTo("10"));
// Isolation: the query is keyed by the caller's id, never a broad scan.
verify(apiKeyRepository).findByOwnerUserIdOrderByCreatedAtDesc(1L);
}
@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")
@DisplayName("a user creates a personal key and gets a one-time secret")
void createPersonalKey() {
CreatedApiKeyDto created =
service.createKey(new CreateApiKeyRequest("My key", "personal", null));
CreatedApiKeyDto created = service.createKey(new CreateApiKeyRequest("My key"));
assertThat(created.secret()).startsWith("sk_");
ArgumentCaptor<ApiKey> saved = ArgumentCaptor.forClass(ApiKey.class);
verify(apiKeyRepository).save(saved.capture());
assertThat(saved.getValue().getScope()).isEqualTo(ApiKeyScope.PERSONAL);
assertThat(saved.getValue().getTeamId()).isNull();
assertThat(saved.getValue().getOwnerUserId()).isEqualTo(1L);
assertThat(saved.getValue().getName()).isEqualTo("My key");
}
@Test
@DisplayName("rejects an over-long key name")
void rejectsLongName() {
String longName = "a".repeat(101);
assertThatThrownBy(
() ->
service.createKey(
new CreateApiKeyRequest(longName, "personal", null)))
assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest(longName)))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("characters or fewer");
verify(apiKeyRepository, never()).save(any());
}
@Test
@DisplayName("rejects a blank key name")
void rejectsBlankName() {
assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest(" ")))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("required");
verify(apiKeyRepository, never()).save(any());
}
@Test
@DisplayName("rejects creating a key past the per-user active-key cap")
void rejectsPastActiveKeyCap() {
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L))
.thenReturn(java.util.Collections.nCopies(50, personalKey(100, 1L)));
assertThatThrownBy(
() ->
service.createKey(
new CreateApiKeyRequest("One too many", "personal", null)))
assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest("One too many")))
.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", null)))
.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", null));
ArgumentCaptor<ApiKey> saved = ArgumentCaptor.forClass(ApiKey.class);
verify(apiKeyRepository).save(saved.capture());
assertThat(saved.getValue().getScope()).isEqualTo(ApiKeyScope.TEAM_MEMBERS);
assertThat(saved.getValue().getTeamId()).isEqualTo(5L);
// A shared key is always processing-only, so it can never carry account powers.
assertThat(saved.getValue().getAccess()).isEqualTo(ApiKeyAccess.PROCESSING);
}
@Test
@DisplayName("a team key can't request full access (full access can't be shared)")
void teamKeyCannotRequestFullAccess() {
// Rejected at access-parse time, before any team-manager/team-id lookup.
assertThatThrownBy(
() ->
service.createKey(
new CreateApiKeyRequest(
"Team full", "team-members", "full")))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("processing-only");
verify(apiKeyRepository, never()).save(any());
}
@Test
@DisplayName("a personal key can be limited to processing-only")
void personalKeyCanBeProcessingOnly() {
service.createKey(new CreateApiKeyRequest("Limited", "personal", "processing"));
ArgumentCaptor<ApiKey> saved = ArgumentCaptor.forClass(ApiKey.class);
verify(apiKeyRepository).save(saved.capture());
assertThat(saved.getValue().getScope()).isEqualTo(ApiKeyScope.PERSONAL);
assertThat(saved.getValue().getAccess()).isEqualTo(ApiKeyAccess.PROCESSING);
}
@Test
@DisplayName("a personal key defaults to full access (acts as the owner)")
void personalKeyDefaultsToFullAccess() {
service.createKey(new CreateApiKeyRequest("Default", "personal", null));
ArgumentCaptor<ApiKey> saved = ArgumentCaptor.forClass(ApiKey.class);
verify(apiKeyRepository).save(saved.capture());
assertThat(saved.getValue().getAccess()).isEqualTo(ApiKeyAccess.FULL);
}
@Test
@DisplayName("an admin who is not a team leader can still create a team key")
void adminCanCreateTeamKey() {
when(policyAuthority.canEditPolicies()).thenReturn(false);
when(userService.isCurrentUserAdmin()).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("Admin team key", "team-members", null));
ArgumentCaptor<ApiKey> 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")
@DisplayName("owner revokes their key and the legacy column is cleared")
void revokePersonalClearsLegacy() {
caller.setApiKey("legacy-raw-key");
ApiKey legacyRow = personalKey(30, 1L);
@@ -318,42 +182,17 @@ class ApiKeyManagementServiceTest {
}
@Test
@DisplayName("a non-owner cannot revoke someone else's personal key")
void revokeForeignPersonalKeyForbidden() {
@DisplayName(
"a non-owner cannot revoke someone else's key (404, not 403, so ids can't be probed)")
void revokeForeignKeyForbidden() {
when(apiKeyRepository.findById(31L)).thenReturn(Optional.of(personalKey(31, 999L)));
assertThatThrownBy(() -> service.revokeKey(31L))
.isInstanceOf(ResponseStatusException.class);
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);
}
@Test
@DisplayName("a leader of another team cannot revoke this team's key, and gets 404 not 403")
void revokeCrossTeamKeyNotFound() {
// Caller is a leader (canEditPolicies) but of a DIFFERENT team than the target key.
when(apiKeyRepository.findById(40L))
.thenReturn(Optional.of(teamKey(40, 5L, ApiKeyScope.TEAM_MEMBERS)));
when(policyAuthority.canEditPolicies()).thenReturn(true);
when(policyAuthority.currentUserTeamId()).thenReturn(6L);
assertThatThrownBy(() -> service.revokeKey(40L))
.isInstanceOf(ResponseStatusException.class)
// 404 (not 403) so a caller can't probe other teams' key ids.
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.NOT_FOUND));
assertThat(((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(404));
verify(apiKeyRepository, never()).save(any());
}
}
@@ -37,7 +37,6 @@ import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKeyAccess;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
@@ -103,10 +102,7 @@ class UserServiceMoreTest {
User u = user("api");
u.addAuthority(new Authority("ROLE_USER", u));
when(apiKeyAuthenticationService.authenticate("k"))
.thenReturn(
Optional.of(
new ApiKeyAuthentication(
u, null, u.getAuthorities(), ApiKeyAccess.FULL)));
.thenReturn(Optional.of(new ApiKeyAuthentication(u, null, u.getAuthorities())));
assertThat(userService.getAuthentication("k")).isNotNull();
}
@@ -426,8 +426,7 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
userService.trackApiKeyFirstUse(user);
ApiKeyAuthenticationToken authToken =
new ApiKeyAuthenticationToken(
user, apiKey, resolved.get().authorities(), resolved.get().access());
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());
@@ -11,7 +11,6 @@ import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.proprietary.security.service.UserService;
@@ -35,9 +34,6 @@ public class TeamSecurityExpressions {
/** Whether the current authenticated user is a {@code LEADER} of the given team. */
public boolean isTeamLeader(Long teamId) {
if (isProcessingApiKey()) {
return false;
}
User currentUser = getCurrentUser();
if (currentUser == null) {
return false;
@@ -50,9 +46,6 @@ public class TeamSecurityExpressions {
/** Whether the current authenticated user is a {@code LEADER} of their own team. */
public boolean isCurrentUserTeamLeader() {
if (isProcessingApiKey()) {
return false;
}
User currentUser = getCurrentUser();
if (currentUser == null || currentUser.getTeam() == null) {
return false;
@@ -63,19 +56,6 @@ public class TeamSecurityExpressions {
.orElse(false);
}
/**
* A processing-only API key (which every shared team key is) must never authenticate with
* team-leader powers. Team-leadership isn't a {@code GrantedAuthority} on SaaS (it's a
* membership-role lookup on the acting owner), so the owner-strips in {@code
* ApiKeyAuthenticationService} can't cap it - the token's access flag does. Defence in depth
* behind {@code ApiKeyProcessingScopeInterceptor}, which already blocks such a key from the
* team endpoints entirely.
*/
private boolean isProcessingApiKey() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth instanceof ApiKeyAuthenticationToken token && token.isProcessingOnly();
}
/** The current authenticated user's team id, or {@code null} if unauthenticated / teamless. */
public Long currentUserTeamId() {
User currentUser = getCurrentUser();
@@ -1,4 +1,4 @@
-- Named, multi-key API keys with optional team scoping, plus per-key daily usage.
-- Named, multi-key personal API keys, plus per-key daily usage.
-- Idempotent: Hibernate ddl-auto=update may already have created these on some deployments.
CREATE TABLE IF NOT EXISTS api_keys (
@@ -7,9 +7,6 @@ CREATE TABLE IF NOT EXISTS api_keys (
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,
access VARCHAR(20) NOT NULL DEFAULT 'FULL',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL,
last_used_at TIMESTAMPTZ,
@@ -18,7 +15,6 @@ CREATE TABLE IF NOT EXISTS api_keys (
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,
@@ -248,11 +248,7 @@ class SupabaseAuthenticationFilterMoreTest {
Optional.of(
new stirling.software.proprietary.security.service
.ApiKeyAuthenticationService.ApiKeyAuthentication(
user,
null,
user.getAuthorities(),
stirling.software.proprietary.security.model
.ApiKeyAccess.FULL)));
user, null, user.getAuthorities())));
request.setRequestURI("/api/v1/something");
request.setMethod("POST");
@@ -30,7 +30,6 @@ import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import stirling.software.proprietary.security.model.ApiKeyAccess;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
@@ -100,7 +99,7 @@ class SupabaseAuthenticationFilterTest {
Optional.of(
new stirling.software.proprietary.security.service
.ApiKeyAuthenticationService.ApiKeyAuthentication(
user, null, user.getAuthorities(), ApiKeyAccess.FULL)));
user, null, user.getAuthorities())));
request.setRequestURI("/api/v1/something");
request.setMethod("POST");
@@ -21,7 +21,6 @@ import org.springframework.security.core.context.SecurityContextHolder;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.model.TeamMembership;
import stirling.software.proprietary.security.model.ApiKeyAccess;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
@@ -119,26 +118,11 @@ class TeamSecurityExpressionsTest {
}
@Test
void sharedTeamApiKeyNeverLeadsEvenIfOwnerIsLeader() {
// Owner genuinely leads the team, but the request came in on a processing-only (shared)
// key.
void apiKeyOfALeaderStillLeads() {
// A key acts as the owner; if they lead the team, the key leads.
SecurityContextHolder.getContext()
.setAuthentication(
new ApiKeyAuthenticationToken(
leaderUser(), "sk_shared", List.of(), ApiKeyAccess.PROCESSING));
// Denied without consulting membership - a shared key must not confer team-leader powers.
assertFalse(expressions().isCurrentUserTeamLeader());
assertFalse(expressions().isTeamLeader(TEAM_ID));
}
@Test
void personalApiKeyOfALeaderStillLeads() {
// A full-access (non-shared) key acts as the owner; if they lead, the key leads.
SecurityContextHolder.getContext()
.setAuthentication(
new ApiKeyAuthenticationToken(
leaderUser(), "sk_personal", List.of(), ApiKeyAccess.FULL));
new ApiKeyAuthenticationToken(leaderUser(), "sk_personal", List.of()));
when(membershipRepository.findByTeamIdAndUserId(TEAM_ID, USER_ID))
.thenReturn(Optional.of(membershipWithRole(TeamRole.LEADER)));
@@ -6898,7 +6898,6 @@ heading = "API keys"
subheading = "Personal and team-scoped credentials, each with its own usage tracking."
[portal.infrastructure.apiKeys.card]
access = "Access"
created = "Created"
lastUsed = "Last used"
revoke = "Revoke key"
@@ -6906,7 +6905,7 @@ usageMonth = "Usage this month"
usageToday = "Usage today"
[portal.infrastructure.apiKeys.empty]
description = "Create a scoped key to start calling the Stirling API."
description = "Create a key to start calling the Stirling API."
title = "No API keys yet"
[portal.infrastructure.apiKeys.error]
@@ -7000,32 +6999,17 @@ inProgress = "In progress"
notStarted = "Not started"
[portal.infrastructure.createKey]
accessFull = "Full access"
accessFullHelp = "Acts as you, including account and admin endpoints. Personal keys only — full access can't be shared."
accessLabel = "Access"
accessProcessing = "Processing only"
accessProcessingHelp = "Limited to the file/PDF processing endpoints. Can't touch account, team, or admin settings."
accessTeamNote = "Shared keys are always processing-only, so they can't carry account powers."
cancel = "Cancel"
createKey = "Create key"
done = "Done"
keyNameLabel = "Key name"
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, 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."
subtitle = "Give the key a name so you can recognise it later. You can revoke it at any time."
subtitleCreated = "Copy this secret now — it won't be shown again."
teamScopeLeaderOnly = "Team-scoped keys need team-leader or admin rights and a team to scope them to."
title = "Create API key"
titleCreated = "Key created"
yourTeam = "your team"
[portal.infrastructure.deployLabel]
live = "Live"
@@ -7068,19 +7052,10 @@ subheading = "Live health for every deployed Stirling region — latency, load,
description = "Deployed regions appear here once your workspace is provisioned."
title = "No regions deployed"
[portal.infrastructure.keyAccessLabel]
full = "Full access"
processing = "Processing only"
[portal.infrastructure.keyLabel]
active = "Active"
revoked = "Revoked"
[portal.infrastructure.keyScopeLabel]
personal = "Personal"
teamLead = "Team leaders"
teamMembers = "Team members"
[portal.infrastructure.modelLabel]
active = "Active"
degraded = "Degraded"
@@ -7996,11 +7971,9 @@ confirm = "Delete"
title = "Delete source?"
[portal.sources.detail]
apiKeyAccess = "Access"
apiKeyLastUsed = "Last used"
apiKeyPrefix = "Key prefix"
apiKeyReadOnly = "Managed in Infrastructure → API keys. Shown here for visibility and usage only."
apiKeyScope = "Scope"
closeAriaLabel = "Close detail"
delete = "Delete source"
docs24h = "Last 24h"
@@ -45,29 +45,11 @@ export interface RecentDeployment {
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";
/**
* How much power a key carries. {@code full} acts as its owner (account + admin);
* {@code processing} is confined to the file/PDF endpoints. Shared (team) keys are
* always {@code processing}. Mirrors the backend {@code ApiKeyAccess}.
*/
export type ApiKeyAccess = "full" | "processing";
export interface ApiKey {
id: string;
name: string;
/** Non-secret leading fragment, e.g. "sk_a3f81b2c". */
prefix: string;
scope: ApiKeyScope;
access: ApiKeyAccess;
/** Team name for a team-scoped key, else null. */
teamName: string | null;
created: string;
/** Formatted last-use time, or "Never". */
lastUsed: string;
@@ -78,16 +60,10 @@ export interface ApiKey {
usageMonth: number;
/** Lifetime request count. */
usageTotal: 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. */
@@ -314,7 +290,7 @@ export async function fetchDeployments(
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. */
/** GET the caller's personal API keys; SaaS or local, scoped server-side per user. */
export async function fetchApiKeys(): Promise<ApiKeysResponse> {
return apiClient.saas.isConfigured()
? apiClient.saas.json<ApiKeysResponse>(API_KEYS_PATH)
@@ -324,8 +300,6 @@ export async function fetchApiKeys(): Promise<ApiKeysResponse> {
/** POST a new key; the response carries the one-time secret. */
export async function createApiKey(body: {
name: string;
scope: ApiKeyScope;
access: ApiKeyAccess;
}): Promise<CreatedApiKey> {
const opts = { method: "POST" as const, body };
return apiClient.saas.isConfigured()
@@ -333,7 +307,7 @@ export async function createApiKey(body: {
: apiClient.local.json<CreatedApiKey>(API_KEYS_PATH, opts);
}
/** DELETE (revoke) a key the caller manages. */
/** DELETE (revoke) a key the caller owns. */
export async function revokeApiKey(id: string): Promise<void> {
const path = `${API_KEYS_PATH}/${encodeURIComponent(id)}`;
const opts = { method: "DELETE" as const };
@@ -7,16 +7,12 @@ const BASE: ApiKey = {
id: "key-1",
name: "Production · ingest",
prefix: "sk_a3f81b2c",
scope: "personal",
access: "full",
teamName: null,
created: "2026-03-02",
lastUsed: "2026-07-10 09:14",
status: "active",
usageToday: 84210,
usageMonth: 2410933,
usageTotal: 9820145,
canManage: true,
};
const meta: Meta<typeof ApiKeyCard> = {
@@ -37,31 +33,6 @@ type Story = StoryObj<typeof ApiKeyCard>;
export const Personal: Story = { args: { apiKey: BASE } };
export const TeamMembers: Story = {
args: {
apiKey: {
...BASE,
name: "Team · shared ingest",
scope: "team-members",
access: "processing",
teamName: "Acme Corp",
},
},
};
export const TeamLeadOnly: Story = {
args: {
apiKey: {
...BASE,
name: "Ops · leaders only",
scope: "team-lead",
access: "processing",
teamName: "Acme Corp",
usageToday: 0,
},
},
};
export const Revoked: Story = {
args: {
apiKey: {
@@ -1,12 +1,9 @@
import { useState } from "react";
import { Button, Card, Chip, StatusBadge } from "@app/ui";
import { Button, Card, StatusBadge } from "@app/ui";
import { useTranslation } from "react-i18next";
import type { ApiKey } from "@portal/api/infrastructure";
import {
KEY_ACCESS_LABEL,
KEY_LABEL,
KEY_SCOPE_LABEL,
KEY_SCOPE_TONE,
KEY_TONE,
} from "@portal/components/infrastructure/infraFormat";
@@ -21,7 +18,7 @@ export function ApiKeyCard({
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const revocable = apiKey.canManage && apiKey.status === "active";
const revocable = apiKey.status === "active";
return (
<Card padding="default" className="portal-infra__key">
<Button
@@ -33,11 +30,6 @@ export function ApiKeyCard({
aria-expanded={open}
rightSection={
<span className="portal-infra__key-head-right">
<Chip size="sm" accent={KEY_SCOPE_TONE[apiKey.scope]}>
{apiKey.scope === "personal" || !apiKey.teamName
? t(KEY_SCOPE_LABEL[apiKey.scope])
: `${t(KEY_SCOPE_LABEL[apiKey.scope])} · ${apiKey.teamName}`}
</Chip>
<StatusBadge tone={KEY_TONE[apiKey.status]} size="sm">
{t(KEY_LABEL[apiKey.status])}
</StatusBadge>
@@ -59,10 +51,6 @@ export function ApiKeyCard({
{open && (
<div className="portal-infra__key-body">
<dl className="portal-infra__kv">
<div>
<dt>{t("portal.infrastructure.apiKeys.card.access")}</dt>
<dd>{t(KEY_ACCESS_LABEL[apiKey.access])}</dd>
</div>
<div>
<dt>{t("portal.infrastructure.apiKeys.card.created")}</dt>
<dd>{apiKey.created}</dd>
@@ -20,7 +20,7 @@ type Story = StoryObj<typeof ApiKeysTab>;
export const Default: Story = {};
const EMPTY = { keys: [], canCreateTeamKeys: false, teamName: null };
const EMPTY = { keys: [] };
export const Loading: Story = {
parameters: {
@@ -95,8 +95,6 @@ export function ApiKeysTab() {
<CreateKeyModal
open={modalOpen}
onClose={() => setModalOpen(false)}
canCreateTeamKeys={data?.canCreateTeamKeys ?? false}
teamName={data?.teamName ?? null}
onCreated={reload}
/>
@@ -10,8 +10,6 @@ const meta: Meta<typeof CreateKeyModal> = {
open: true,
onClose: () => console.log("close"),
onCreated: () => console.log("created"),
canCreateTeamKeys: true,
teamName: "Acme Corp",
},
};
export default meta;
@@ -19,8 +17,4 @@ type Story = StoryObj<typeof CreateKeyModal>;
export const Form: Story = {};
export const PersonalOnly: Story = {
args: { canCreateTeamKeys: false, teamName: null },
};
export const Closed: Story = { args: { open: false } };
@@ -23,14 +23,7 @@ const K = "portal.infrastructure.createKey";
function renderModal(props: Partial<ComponentProps<typeof CreateKeyModal>>) {
return render(
<MantineProvider>
<CreateKeyModal
open
onClose={() => {}}
onCreated={() => {}}
canCreateTeamKeys
teamName="Acme Corp"
{...props}
/>
<CreateKeyModal open onClose={() => {}} onCreated={() => {}} {...props} />
</MantineProvider>,
);
}
@@ -65,17 +58,6 @@ describe("CreateKeyModal", () => {
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,
// and a personal key defaults to full access.
expect(createApiKey).toHaveBeenCalledWith({
name: "Production ingest",
scope: "personal",
access: "full",
});
});
it("disables team scopes when the caller cannot create team keys", () => {
renderModal({ canCreateTeamKeys: false, teamName: null });
expect(screen.getByText(`${K}.teamScopeLeaderOnly`)).toBeInTheDocument();
expect(createApiKey).toHaveBeenCalledWith({ name: "Production ingest" });
});
});
@@ -1,62 +1,27 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Banner,
Button,
CodeBlock,
FormField,
Input,
Modal,
RadioGroup,
type RadioOption,
} from "@app/ui";
import {
createApiKey,
type ApiKeyAccess,
type ApiKeyScope,
type CreatedApiKey,
} from "@portal/api/infrastructure";
import { Banner, Button, CodeBlock, FormField, Input, Modal } from "@app/ui";
import { createApiKey, type CreatedApiKey } from "@portal/api/infrastructure";
import { errorMessage } from "@portal/api/http";
export function CreateKeyModal({
open,
onClose,
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 [scope, setScope] = useState<ApiKeyScope>("personal");
const [access, setAccess] = useState<ApiKeyAccess>("full");
const [created, setCreated] = useState<CreatedApiKey | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// A shared (team) key can only ever be processing-only; full access is never sharable.
const isTeamScope = scope !== "personal";
const effectiveAccess: ApiKeyAccess = isTeamScope ? "processing" : access;
function changeScope(next: ApiKeyScope) {
setScope(next);
if (next !== "personal") {
setAccess("processing");
}
}
function reset() {
setName("");
setScope("personal");
setAccess("full");
setCreated(null);
setSubmitting(false);
setError(null);
@@ -72,11 +37,7 @@ export function CreateKeyModal({
setSubmitting(true);
setError(null);
try {
const result = await createApiKey({
name: name.trim(),
scope,
access: effectiveAccess,
});
const result = await createApiKey({ name: name.trim() });
setCreated(result);
onCreated();
} catch (e) {
@@ -86,44 +47,6 @@ export function CreateKeyModal({
}
}
const teamLabel = teamName ?? t("portal.infrastructure.createKey.yourTeam");
const accessOptions: RadioOption<ApiKeyAccess>[] = [
{
value: "full",
label: t("portal.infrastructure.createKey.accessFull"),
description: t("portal.infrastructure.createKey.accessFullHelp"),
disabled: isTeamScope,
},
{
value: "processing",
label: t("portal.infrastructure.createKey.accessProcessing"),
description: t("portal.infrastructure.createKey.accessProcessingHelp"),
},
];
const scopeOptions: RadioOption<ApiKeyScope>[] = [
{
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 (
<Modal
open={open}
@@ -188,38 +111,6 @@ export function CreateKeyModal({
)}
/>
</FormField>
<FormField label={t("portal.infrastructure.createKey.scopeLabel")}>
<div className="portal-infra__stack">
<RadioGroup<ApiKeyScope>
name="apiKeyScope"
value={scope}
onChange={changeScope}
options={scopeOptions}
/>
{!canCreateTeamKeys && (
<p className="portal-infra__muted">
{t("portal.infrastructure.createKey.teamScopeLeaderOnly")}
</p>
)}
</div>
</FormField>
<FormField label={t("portal.infrastructure.createKey.accessLabel")}>
<div className="portal-infra__stack">
<RadioGroup<ApiKeyAccess>
name="apiKeyAccess"
value={effectiveAccess}
onChange={setAccess}
options={accessOptions}
/>
{isTeamScope && (
<p className="portal-infra__muted">
{t("portal.infrastructure.createKey.accessTeamNote")}
</p>
)}
</div>
</FormField>
</div>
)}
</Modal>
@@ -1,8 +1,6 @@
import type { TFunction } from "i18next";
import type { ChipAccent, StatusTone } from "@app/ui";
import type {
ApiKeyAccess,
ApiKeyScope,
ApiKeyStatus,
AttestationStatus,
AuditCategory,
@@ -64,25 +62,6 @@ export const KEY_LABEL: Record<ApiKeyStatus, string> = {
revoked: "portal.infrastructure.keyLabel.revoked",
};
// Scope chip labels (i18n keys), resolved via t(KEY_SCOPE_LABEL[value]).
export const KEY_SCOPE_LABEL: Record<ApiKeyScope, string> = {
personal: "portal.infrastructure.keyScopeLabel.personal",
"team-lead": "portal.infrastructure.keyScopeLabel.teamLead",
"team-members": "portal.infrastructure.keyScopeLabel.teamMembers",
};
export const KEY_SCOPE_TONE: Record<ApiKeyScope, ChipAccent> = {
personal: "default",
"team-lead": "premium",
"team-members": "brand",
};
// Access labels (i18n keys), resolved via t(KEY_ACCESS_LABEL[value]).
export const KEY_ACCESS_LABEL: Record<ApiKeyAccess, string> = {
full: "portal.infrastructure.keyAccessLabel.full",
processing: "portal.infrastructure.keyAccessLabel.processing",
};
export const CERT_TONE: Record<CertStatus, StatusTone> = {
certified: "success",
"in-progress": "warning",
@@ -42,25 +42,18 @@ export const infrastructureHandlers = [
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,
usageTotal: 0,
},
secret: "sk_live_demo_key_rotate_in_prod",
});
@@ -168,61 +168,45 @@ const API_KEYS_ALL: ApiKey[] = [
id: "key-1",
name: "Production · ingest",
prefix: "sk_a3f81b2c",
scope: "personal",
access: "full",
teamName: null,
created: "2026-03-02",
lastUsed: "2026-07-10 09:14",
status: "active",
usageToday: 84210,
usageMonth: 2410933,
usageTotal: 2410933,
canManage: true,
},
{
id: "key-2",
name: "Team · shared ingest",
name: "Nightly batch",
prefix: "sk_77be0f42",
scope: "team-members",
access: "processing",
teamName: "Acme Corp",
created: "2026-01-18",
lastUsed: "2026-07-10 08:33",
status: "active",
usageToday: 6120,
usageMonth: 188400,
usageTotal: 188400,
canManage: true,
},
{
id: "key-3",
name: "Ops · leaders only",
name: "CI pipeline",
prefix: "sk_d9013ab7",
scope: "team-lead",
access: "processing",
teamName: "Acme Corp",
created: "2025-08-09",
lastUsed: "2026-07-04 17:02",
status: "active",
usageToday: 0,
usageMonth: 14200,
usageTotal: 14200,
canManage: true,
},
{
id: "key-4",
name: "Sandbox · webhook tester",
prefix: "sk_2c4a91de",
scope: "personal",
access: "full",
teamName: null,
created: "2026-05-30",
lastUsed: "Never",
status: "revoked",
usageToday: 0,
usageMonth: 0,
usageTotal: 0,
canManage: true,
},
];
@@ -233,7 +217,7 @@ export function apiKeysFor(tier: Tier): ApiKeysResponse {
: tier === "pro"
? API_KEYS_ALL.slice(0, 3)
: API_KEYS_ALL;
return { keys, canCreateTeamKeys: tier !== "free", teamName: "Acme Corp" };
return { keys };
}
/* ──────────────────────────────────────────────────────────────────────── */
@@ -97,11 +97,7 @@ describe("Sources view", () => {
createSource.mockReset();
deleteSource.mockReset();
fetchApiKeys.mockReset();
fetchApiKeys.mockResolvedValue({
keys: [],
canCreateTeamKeys: false,
teamName: null,
});
fetchApiKeys.mockResolvedValue({ keys: [] });
});
it("surfaces the inline 409 message when deleting a referenced source", async () => {
@@ -206,20 +202,14 @@ describe("Sources view", () => {
id: "7",
name: "Production ingest",
prefix: "sk_a3f81b2c",
scope: "personal",
access: "full",
teamName: null,
created: "2026-03-02",
lastUsed: "2026-07-10 09:14",
status: "active",
usageToday: 12,
usageMonth: 340,
usageTotal: 5000,
canManage: true,
},
],
canCreateTeamKeys: false,
teamName: null,
});
renderView();
@@ -21,10 +21,6 @@ import {
type ApiKey,
type ApiKeysResponse,
} from "@portal/api/infrastructure";
import {
KEY_ACCESS_LABEL,
KEY_SCOPE_LABEL,
} from "@portal/components/infrastructure/infraFormat";
import { AgentBuilderAction } from "@portal/components/sources/AgentBuilderAction";
import { KpiStrip } from "@portal/components/sources/KpiStrip";
import { SourcesTable } from "@portal/components/sources/SourcesTable";
@@ -38,10 +34,6 @@ import "@portal/views/Sources.css";
* usage stats but is never a policy input (type "apikey" has no edit/create path).
*/
function apiKeyToSourceRow(k: ApiKey, t: TFunction): SourceView {
const scope =
k.scope === "personal" || !k.teamName
? t(KEY_SCOPE_LABEL[k.scope])
: `${t(KEY_SCOPE_LABEL[k.scope])} · ${k.teamName}`;
return {
id: `apikey:${k.id}`,
name: k.name,
@@ -50,11 +42,6 @@ function apiKeyToSourceRow(k: ApiKey, t: TFunction): SourceView {
referenceCount: 0,
referencingPolicies: [],
config: [
{ label: t("portal.sources.detail.apiKeyScope"), value: scope },
{
label: t("portal.sources.detail.apiKeyAccess"),
value: t(KEY_ACCESS_LABEL[k.access]),
},
{ label: t("portal.sources.detail.apiKeyPrefix"), value: k.prefix },
{ label: t("portal.sources.detail.apiKeyLastUsed"), value: k.lastUsed },
],
@@ -277,8 +264,6 @@ export function Sources() {
<CreateKeyModal
open={apiKeyModalOpen}
onClose={() => setApiKeyModalOpen(false)}
canCreateTeamKeys={apiKeyState.data?.canCreateTeamKeys ?? false}
teamName={apiKeyState.data?.teamName ?? null}
onCreated={refetch}
/>
@@ -2,7 +2,7 @@
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.
revoke named personal API keys.
Endpoints (all @EnterpriseEndpoint, JWT/ROLE required):
- GET /api/v1/proprietary/ui-data/infrastructure/api-keys (list)
@@ -14,7 +14,7 @@ Feature: API Keys management API
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.
key created before multi-key support is ever lost.
Admin credentials: username=admin, password=stirling
Global API key: 123456789
@@ -41,13 +41,13 @@ Feature: API Keys management API
@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"}'
When I send a JSON POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication and body '{"name": "bdd_personal_key"}'
Then the response status code should be one of "200, 403"
@negative
Scenario: Creating a key without a name is rejected
Given I am logged in as admin
When I send a JSON POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication and body '{"name": "", "scope": "personal"}'
When I send a JSON POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication and body '{"name": ""}'
Then the response status code should be one of "400, 403"
@negative