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. - * - *

- */ -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) null); this.principal = null; this.credentials = apiKey; - this.access = ApiKeyAccess.FULL; setAuthenticated(false); } public ApiKeyAuthenticationToken( Object principal, String apiKey, Collection authorities) { - this(principal, apiKey, authorities, ApiKeyAccess.FULL); - } - - public ApiKeyAuthenticationToken( - Object principal, - String apiKey, - Collection 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. - * - * - */ -public enum ApiKeyScope { - PERSONAL, - TEAM_LEAD, - TEAM_MEMBERS; - - public boolean isTeamScoped() { - return this == TEAM_LEAD || this == TEAM_MEMBERS; - } -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyRepository.java index 5d219e4988..62e2cc1408 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyRepository.java @@ -16,6 +16,4 @@ public interface ApiKeyRepository extends JpaRepository { boolean existsByKeyHash(String keyHash); List findByOwnerUserIdOrderByCreatedAtDesc(Long ownerUserId); - - List findByTeamIdOrderByCreatedAtDesc(Long teamId); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationService.java index f78b7a4465..ab223f929b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationService.java @@ -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. * *

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 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 authorities, - ApiKeyAccess access) {} + User user, String auditLabel, Collection 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 (