diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java
index ecc163de21..9c76978ccb 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java
@@ -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.
- *
- *
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 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 = 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");
}
}
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java
index 5ba6fe13d8..6ad49c1e1d 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java
@@ -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) {
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();
}
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
index e9e8523cf0..14aa43093c 100644
--- 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
@@ -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) {}
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
index a04577ba65..062c1e0a9f 100644
--- 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
@@ -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) {}
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
index 005bcddc01..dd21af7226 100644
--- 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
@@ -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 keys, boolean canCreateTeamKeys, String teamName) {}
+public record PortalApiKeysResponse(List keys) {}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ProprietaryWebMvcConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ProprietaryWebMvcConfig.java
index 3b914af960..34e0116cf6 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ProprietaryWebMvcConfig.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ProprietaryWebMvcConfig.java
@@ -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/**");
}
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ApiKeyProcessingScopeInterceptor.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ApiKeyProcessingScopeInterceptor.java
deleted file mode 100644
index a73b4f942c..0000000000
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ApiKeyProcessingScopeInterceptor.java
+++ /dev/null
@@ -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.
- *
- *
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 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;
- }
-}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java
index 6cca43e72c..3ceb850fda 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java
@@ -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) {
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 fef731a54d..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
@@ -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());
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
index 367b8c5c8c..fea66144d2 100644
--- 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
@@ -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;
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAccess.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAccess.java
deleted file mode 100644
index a86f7cc9fb..0000000000
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAccess.java
+++ /dev/null
@@ -1,22 +0,0 @@
-package stirling.software.proprietary.security.model;
-
-/**
- * How much power an {@link ApiKey} carries when it authenticates - chosen by the creator.
- *
- *
- *
{@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).
- *
{@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.
- *
- */
-public enum ApiKeyAccess {
- FULL,
- PROCESSING;
-
- public boolean isProcessingOnly() {
- return this == PROCESSING;
- }
-}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationToken.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationToken.java
index 017a80bea4..b09ba20aab 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationToken.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationToken.java
@@ -5,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;
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
deleted file mode 100644
index c58c980bad..0000000000
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyScope.java
+++ /dev/null
@@ -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.
- *
- *
- *
{@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.
- *
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 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 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) {}
}
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
index 9b79c0ffa7..e30bce7845 100644
--- 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
@@ -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.
*
- *
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.
+ *
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 visible =
+ apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(caller.getId());
- 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);
- }
-
- // 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 ids = visible.stream().map(ApiKey::getId).toList();
Map 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)) {
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiKeyIntegrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiKeyIntegrationTest.java
index e26df82706..b8c1f953e9 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiKeyIntegrationTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiKeyIntegrationTest.java
@@ -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 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;
}
}
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpOAuthIntegrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpOAuthIntegrationTest.java
index e607f6e424..1e814ea580 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpOAuthIntegrationTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpOAuthIntegrationTest.java
@@ -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);
- }
}
}
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpScopeMetadataDisabledTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpScopeMetadataDisabledTest.java
index ccdd36daae..17d15d1f11 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpScopeMetadataDisabledTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpScopeMetadataDisabledTest.java
@@ -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);
- }
}
}
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/ApiKeyProcessingScopeInterceptorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/ApiKeyProcessingScopeInterceptorTest.java
deleted file mode 100644
index 9f19779efb..0000000000
--- a/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/ApiKeyProcessingScopeInterceptorTest.java
+++ /dev/null
@@ -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();
- }
-}
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 a033a0cb4f..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
@@ -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()))
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
index f6d8a0f60c..5a7a5d910b 100644
--- 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
@@ -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 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 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 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
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
index f77a4f3e94..e74452b114 100644
--- 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
@@ -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 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 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 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 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 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 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());
}
}
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 481b7224dd..6e2b942b80 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
@@ -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();
}
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 05e1c79453..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
@@ -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());
diff --git a/app/saas/src/main/java/stirling/software/saas/security/TeamSecurityExpressions.java b/app/saas/src/main/java/stirling/software/saas/security/TeamSecurityExpressions.java
index a940cb229a..5263705986 100644
--- a/app/saas/src/main/java/stirling/software/saas/security/TeamSecurityExpressions.java
+++ b/app/saas/src/main/java/stirling/software/saas/security/TeamSecurityExpressions.java
@@ -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();
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
index c748207425..abced067e6 100644
--- 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
@@ -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,
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 5f34938804..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
@@ -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");
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 66055d61fe..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
@@ -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");
diff --git a/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java b/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java
index bc6b18d2cc..ee74f66a29 100644
--- a/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java
+++ b/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java
@@ -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)));
diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml
index 556cd6cc7d..4fcab851c2 100644
--- a/frontend/editor/public/locales/en-US/translation.toml
+++ b/frontend/editor/public/locales/en-US/translation.toml
@@ -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"
diff --git a/frontend/editor/src/portal/api/infrastructure.ts b/frontend/editor/src/portal/api/infrastructure.ts
index ca6967fbfc..3eebb0d46a 100644
--- a/frontend/editor/src/portal/api/infrastructure.ts
+++ b/frontend/editor/src/portal/api/infrastructure.ts
@@ -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 {
return apiClient.saas.isConfigured()
? apiClient.saas.json(API_KEYS_PATH)
@@ -324,8 +300,6 @@ export async function fetchApiKeys(): Promise {
/** POST a new key; the response carries the one-time secret. */
export async function createApiKey(body: {
name: string;
- scope: ApiKeyScope;
- access: ApiKeyAccess;
}): Promise {
const opts = { method: "POST" as const, body };
return apiClient.saas.isConfigured()
@@ -333,7 +307,7 @@ export async function createApiKey(body: {
: apiClient.local.json(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 {
const path = `${API_KEYS_PATH}/${encodeURIComponent(id)}`;
const opts = { method: "DELETE" as const };
diff --git a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx
index 8949827c8e..e95194e0d2 100644
--- a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx
+++ b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx
@@ -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 = {
@@ -37,31 +33,6 @@ type Story = StoryObj;
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: {
diff --git a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx
index dc1236819a..fb5fd5d104 100644
--- a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx
+++ b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx
@@ -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 (