From 7c8a46255bc1021ccf95859abbee0422bfce14cd Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:26:36 +0100 Subject: [PATCH] Add API key access levels and processing-only key boundary --- .../model/api/apikey/CreateApiKeyRequest.java | 5 +- .../model/api/apikey/PortalApiKeyDto.java | 2 + .../ProprietaryWebMvcConfig.java | 4 + .../configuration/SecurityConfiguration.java | 7 +- .../ApiKeyProcessingScopeInterceptor.java | 85 +++++++++++++ .../filter/JwtAuthenticationFilter.java | 18 ++- .../filter/UserAuthenticationFilter.java | 2 +- .../proprietary/security/model/ApiKey.java | 9 ++ .../security/model/ApiKeyAccess.java | 22 ++++ .../model/ApiKeyAuthenticationToken.java | 33 +++-- .../service/ApiKeyAuthenticationService.java | 32 ++--- .../service/ApiKeyManagementService.java | 38 ++++++ .../security/service/ApiKeyUsageRecorder.java | 35 ++---- .../security/service/ApiKeyUsageWriter.java | 59 +++++++++ .../SecurityConfigurationTest.java | 5 +- .../ApiKeyProcessingScopeInterceptorTest.java | 115 ++++++++++++++++++ .../filter/UserAuthenticationFilterTest.java | 3 +- .../ApiKeyAuthenticationServiceTest.java | 42 ++++++- .../service/ApiKeyManagementServiceTest.java | 54 +++++++- .../SupabaseAuthenticationFilter.java | 2 +- .../security/TeamSecurityExpressions.java | 18 +-- .../db/migration/saas/V33__api_keys.sql | 1 + .../SupabaseAuthenticationFilterMoreTest.java | 6 +- .../SupabaseAuthenticationFilterTest.java | 3 +- .../security/TeamSecurityExpressionsTest.java | 12 +- .../public/locales/en-US/translation.toml | 12 ++ .../editor/src/portal/api/infrastructure.ts | 9 ++ .../infrastructure/ApiKeyCard.stories.tsx | 3 + .../components/infrastructure/ApiKeyCard.tsx | 5 + .../infrastructure/CreateKeyModal.test.tsx | 4 +- .../infrastructure/CreateKeyModal.tsx | 51 +++++++- .../components/infrastructure/infraFormat.ts | 7 ++ .../editor/src/portal/mocks/infrastructure.ts | 4 + .../editor/src/portal/views/Sources.test.tsx | 1 + frontend/editor/src/portal/views/Sources.tsx | 9 +- 35 files changed, 625 insertions(+), 92 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ApiKeyProcessingScopeInterceptor.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAccess.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageWriter.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/filter/ApiKeyProcessingScopeInterceptorTest.java 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 bfd0ada738..e9e8523cf0 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 @@ -2,6 +2,7 @@ package stirling.software.proprietary.model.api.apikey; /** * Create-key request body from the portal. {@code scope} is "personal", "team-lead", or - * "team-members"; team scoping resolves to the caller's own team server-side. + * "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) {} +public record CreateApiKeyRequest(String name, String scope, String access) {} 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 4088741ff7..a04577ba65 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 @@ -13,6 +13,8 @@ public record PortalApiKeyDto( 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, 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 34e0116cf6..3b914af960 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,6 +6,7 @@ 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 @@ -13,10 +14,13 @@ 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/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 20a9cb2628..eab9fc6fd0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -57,6 +57,7 @@ import stirling.software.proprietary.security.oauth2.TauriAuthorizationRequestRe import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationSuccessHandler; import stirling.software.proprietary.security.saml2.CustomSaml2ResponseAuthenticationConverter; +import stirling.software.proprietary.security.service.ApiKeyAuthenticationService; import stirling.software.proprietary.security.service.CustomOAuth2UserService; import stirling.software.proprietary.security.service.CustomUserDetailsService; import stirling.software.proprietary.security.service.JwtServiceInterface; @@ -484,12 +485,14 @@ public class SecurityConfiguration { } @Bean - public JwtAuthenticationFilter jwtAuthenticationFilter() { + public JwtAuthenticationFilter jwtAuthenticationFilter( + ApiKeyAuthenticationService apiKeyAuthenticationService) { return new JwtAuthenticationFilter( jwtService, userService, userDetailsService, jwtAuthenticationEntryPoint, - securityProperties); + securityProperties, + apiKeyAuthenticationService); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ApiKeyProcessingScopeInterceptor.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ApiKeyProcessingScopeInterceptor.java new file mode 100644 index 0000000000..a73b4f942c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ApiKeyProcessingScopeInterceptor.java @@ -0,0 +1,85 @@ +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 92bbcab89c..6cca43e72c 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 @@ -33,8 +33,9 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; import stirling.software.proprietary.security.model.AuthenticationType; -import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.exception.AuthenticationFailureException; +import stirling.software.proprietary.security.service.ApiKeyAuthenticationService; +import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication; import stirling.software.proprietary.security.service.CustomUserDetailsService; import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.UserService; @@ -48,6 +49,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final CustomUserDetailsService userDetailsService; private final AuthenticationEntryPoint authenticationEntryPoint; private final ApplicationProperties.Security securityProperties; + private final ApiKeyAuthenticationService apiKeyAuthenticationService; @Override protected void doFilterInternal( @@ -131,9 +133,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { if (apiKey != null && !apiKey.isBlank()) { try { - Optional user = userService.getUserByApiKey(apiKey); + // Resolve through the shared service so a team/processing key gets its capped + // authorities and access flag - resolving via the owner here would hand a + // shared + // key the owner's full (admin) rights and bypass the processing-scope boundary. + Optional resolved = + apiKeyAuthenticationService.authenticate(apiKey); - if (user.isEmpty()) { + if (resolved.isEmpty()) { handleAuthenticationFailure( request, response, @@ -143,7 +150,10 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { authentication = new ApiKeyAuthenticationToken( - user.get(), apiKey, user.get().getAuthorities()); + resolved.get().user(), + apiKey, + resolved.get().authorities(), + resolved.get().access()); 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 399e018051..fef731a54d 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 @@ -116,7 +116,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { user, apiKey, resolved.get().authorities(), - resolved.get().teamScoped()); + resolved.get().access()); 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 619354b4c1..367b8c5c8c 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 @@ -62,6 +62,15 @@ public class ApiKey implements Serializable { @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 new file mode 100644 index 0000000000..a86f7cc9fb --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAccess.java @@ -0,0 +1,22 @@ +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 8bed099da0..017a80bea4 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 @@ -9,38 +9,51 @@ public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken { private final Object principal; private Object credentials; - // True when the resolving key is team-scoped (shared). Such a key must never confer team-leader - // powers - it acts at the level of the least-privileged member who can use it. - private final boolean teamScoped; + // 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.teamScoped = false; + this.access = ApiKeyAccess.FULL; setAuthenticated(false); } public ApiKeyAuthenticationToken( Object principal, String apiKey, Collection authorities) { - this(principal, apiKey, authorities, false); + this(principal, apiKey, authorities, ApiKeyAccess.FULL); } public ApiKeyAuthenticationToken( Object principal, String apiKey, Collection authorities, - boolean teamScoped) { + ApiKeyAccess access) { super(authorities); this.principal = principal; // principal can be a UserDetails object this.credentials = apiKey; - this.teamScoped = teamScoped; + this.access = access == null ? ApiKeyAccess.FULL : access; super.setAuthenticated(true); // this authentication is trusted } - /** Whether this token came from a shared team key (never grants team-leader authority). */ - public boolean isTeamScoped() { - return teamScoped; + /** + * 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 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 6e9f658fe8..f78b7a4465 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 @@ -16,7 +16,7 @@ import lombok.RequiredArgsConstructor; import stirling.software.common.model.enumeration.Role; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.ApiKey; -import stirling.software.proprietary.security.model.ApiKeyScope; +import stirling.software.proprietary.security.model.ApiKeyAccess; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.ApiKeyRepository; @@ -72,29 +72,28 @@ public class ApiKeyAuthenticationService { usageRecorder.record(key.getId()); return Optional.of( new ApiKeyAuthentication( - owner, - auditLabel(key), - authoritiesFor(owner, key), - key.getScope().isTeamScoped())); + owner, auditLabel(key), authoritiesFor(owner, key), key.getAccess())); } - // Legacy single per-user key: keep working, always personal to its user. + // Legacy single per-user key: keep working, always a full-access personal key for its user. return userRepository .findByApiKey(rawKey) .filter(User::isEnabled) - .map(user -> new ApiKeyAuthentication(user, null, user.getAuthorities(), false)); + .map( + user -> + new ApiKeyAuthentication( + user, null, user.getAuthorities(), ApiKeyAccess.FULL)); } /** - * Authorities the resolved key authenticates with. A PERSONAL key is the owner using their own - * credential, so it carries the owner's authorities unchanged. A TEAM key is a SHARED - * credential (visible to leaders or all members), so it must never confer admin: the owner's - * authorities are capped below {@code ROLE_ADMIN}, falling back to {@code ROLE_USER}. This - * blocks the escalation where an admin-owned team key would hand admin rights to everyone who - * can see it. + * 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.getScope() == ApiKeyScope.PERSONAL) { + if (key.getAccess() != ApiKeyAccess.PROCESSING) { return owner.getAuthorities(); } String adminRole = Role.ADMIN.getRoleId(); @@ -135,11 +134,12 @@ public class ApiKeyAuthenticationService { /** * A resolved key: the user, an optional processor-feed label, the authorities to run as, and - * whether it is a shared team key (which must not confer team-leader powers). + * how much power the key carries (a PROCESSING key is confined to file/PDF endpoints and never + * confers team-leader powers). */ public record ApiKeyAuthentication( User user, String auditLabel, Collection authorities, - boolean teamScoped) {} + ApiKeyAccess access) {} } 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 bf698b0f3a..5f916794e0 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 @@ -26,6 +26,7 @@ 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; @@ -154,6 +155,7 @@ public class ApiKeyManagementService { + " 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; @@ -181,6 +183,7 @@ public class ApiKeyManagementService { .ownerUserId(caller.getId()) .teamId(teamId) .scope(scope) + .access(access) .enabled(true) .createdAt(Instant.now()) .build()); @@ -247,6 +250,7 @@ public class ApiKeyManagementService { .ownerUserId(user.getId()) .teamId(null) .scope(ApiKeyScope.PERSONAL) + .access(ApiKeyAccess.FULL) .enabled(true) .createdAt(Instant.now()) .build()); @@ -285,6 +289,7 @@ public class ApiKeyManagementService { .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())) @@ -308,6 +313,39 @@ public class ApiKeyManagementService { }; } + 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; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorder.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorder.java index de4eb75f16..de31ba4c9e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorder.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorder.java @@ -3,56 +3,41 @@ package stirling.software.proprietary.security.service; import java.time.Instant; import java.time.ZoneOffset; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import stirling.software.proprietary.security.model.ApiKeyDailyUsage; -import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository; -import stirling.software.proprietary.security.repository.ApiKeyRepository; - /** * Records per-key usage off the request thread. Kept a separate bean so the {@code @Async} proxy is * honoured (a self-invocation from the resolver would run inline). Best-effort: never fails a - * request. + * request. The actual writes go through {@link ApiKeyUsageWriter} so each step commits in its own + * transaction and a first-write race can't drop a count. */ @Slf4j @Service @RequiredArgsConstructor public class ApiKeyUsageRecorder { - private final ApiKeyRepository apiKeyRepository; - private final ApiKeyDailyUsageRepository usageRepository; + private final ApiKeyUsageWriter writer; /** Bump today's tally for the key and stamp last-used. */ @Async("auditExecutor") - @Transactional public void record(Long apiKeyId) { if (apiKeyId == null) { return; } try { long epochDay = Instant.now().atZone(ZoneOffset.UTC).toLocalDate().toEpochDay(); - if (usageRepository.incrementIfPresent(apiKeyId, epochDay) == 0) { - try { - usageRepository.save(new ApiKeyDailyUsage(apiKeyId, epochDay, 1)); - } catch (DataIntegrityViolationException raced) { - // A concurrent first-write already inserted today's row; increment it instead - // of dropping this request's count. - usageRepository.incrementIfPresent(apiKeyId, epochDay); - } + // First writer of the day inserts the row; everyone else (and the loser of an insert + // race) increments. Separate transactions mean a unique-key clash never rolls back an + // already-counted request. + if (writer.increment(apiKeyId, epochDay) == 0 + && !writer.tryInsertFirstUse(apiKeyId, epochDay)) { + writer.increment(apiKeyId, epochDay); } - apiKeyRepository - .findById(apiKeyId) - .ifPresent( - key -> { - key.setLastUsedAt(Instant.now()); - apiKeyRepository.save(key); - }); + writer.stampLastUsed(apiKeyId); } catch (Exception e) { log.debug("Failed to record API key usage for id={}", apiKeyId, e); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageWriter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageWriter.java new file mode 100644 index 0000000000..94bec676d7 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageWriter.java @@ -0,0 +1,59 @@ +package stirling.software.proprietary.security.service; + +import java.time.Instant; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.security.model.ApiKeyDailyUsage; +import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository; +import stirling.software.proprietary.security.repository.ApiKeyRepository; + +/** + * Per-step transactional writes for {@link ApiKeyUsageRecorder}. Each method runs in its own + * ({@code REQUIRES_NEW}) transaction so a unique-key clash when two requests race to insert the + * day's first row rolls back only that failed insert - never an already-counted request or the + * last-used stamp. + */ +@Component +@RequiredArgsConstructor +class ApiKeyUsageWriter { + + private final ApiKeyRepository apiKeyRepository; + private final ApiKeyDailyUsageRepository usageRepository; + + /** Bump today's tally if the row already exists; returns rows updated (0 if none yet). */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public int increment(Long apiKeyId, long epochDay) { + return usageRepository.incrementIfPresent(apiKeyId, epochDay); + } + + /** + * Insert today's row with a count of 1. Flushes so a concurrent first-write's unique-key clash + * surfaces here (returning false) instead of at commit; the caller then increments instead. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean tryInsertFirstUse(Long apiKeyId, long epochDay) { + try { + usageRepository.saveAndFlush(new ApiKeyDailyUsage(apiKeyId, epochDay, 1)); + return true; + } catch (DataIntegrityViolationException raced) { + return false; + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void stampLastUsed(Long apiKeyId) { + apiKeyRepository + .findById(apiKeyId) + .ifPresent( + key -> { + key.setLastUsedAt(Instant.now()); + apiKeyRepository.save(key); + }); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/SecurityConfigurationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/SecurityConfigurationTest.java index 50d522c056..fe2bf8e948 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/SecurityConfigurationTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/SecurityConfigurationTest.java @@ -29,6 +29,7 @@ import stirling.software.proprietary.security.database.repository.PersistentLogi import stirling.software.proprietary.security.filter.IPRateLimitingFilter; import stirling.software.proprietary.security.filter.JwtAuthenticationFilter; import stirling.software.proprietary.security.filter.UserAuthenticationFilter; +import stirling.software.proprietary.security.service.ApiKeyAuthenticationService; import stirling.software.proprietary.security.service.CustomUserDetailsService; import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; @@ -160,7 +161,9 @@ class SecurityConfigurationTest { @Test @DisplayName("jwtAuthenticationFilter is created") void jwtAuthenticationFilter() { - JwtAuthenticationFilter filter = newConfig(true).jwtAuthenticationFilter(); + JwtAuthenticationFilter filter = + newConfig(true) + .jwtAuthenticationFilter(mock(ApiKeyAuthenticationService.class)); assertThat(filter).isNotNull(); } 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 new file mode 100644 index 0000000000..9f19779efb --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/ApiKeyProcessingScopeInterceptorTest.java @@ -0,0 +1,115 @@ +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 b60f115acf..a033a0cb4f 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,6 +27,7 @@ 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; @@ -113,7 +114,7 @@ class UserAuthenticationFilterTest { user, "Prod (sk_demo0000)", user.getAuthorities(), - false))); + ApiKeyAccess.FULL))); 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 d677408442..f6d8a0f60c 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,6 +20,7 @@ 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; @@ -131,6 +132,7 @@ class ApiKeyAuthenticationServiceTest { .ownerUserId(7L) .teamId(3L) .scope(ApiKeyScope.TEAM_MEMBERS) + .access(ApiKeyAccess.PROCESSING) .enabled(true) .createdAt(Instant.now()) .build(); @@ -144,9 +146,9 @@ class ApiKeyAuthenticationServiceTest { List auths = result.get().authorities().stream().map(GrantedAuthority::getAuthority).toList(); assertThat(auths).doesNotContain(Role.ADMIN.getRoleId()).contains(Role.USER.getRoleId()); - // Marked team-scoped so SaaS team-leader checks (a membership lookup, not an authority) - // won't treat this shared key as a leader. - assertThat(result.get().teamScoped()).isTrue(); + // 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 @@ -164,8 +166,38 @@ class ApiKeyAuthenticationServiceTest { List auths = result.get().authorities().stream().map(GrantedAuthority::getAuthority).toList(); assertThat(auths).contains(Role.ADMIN.getRoleId()); - // A personal key is not shared, so it is never team-scoped (keeps the owner's full role). - assertThat(result.get().teamScoped()).isFalse(); + // 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 32e1ee1ec2..c03dd3ed5f 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 @@ -30,6 +30,7 @@ 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; @@ -170,7 +171,8 @@ class ApiKeyManagementServiceTest { @Test @DisplayName("any user can create a personal key and gets a one-time secret") void createPersonalKey() { - CreatedApiKeyDto created = service.createKey(new CreateApiKeyRequest("My key", "personal")); + CreatedApiKeyDto created = + service.createKey(new CreateApiKeyRequest("My key", "personal", null)); assertThat(created.secret()).startsWith("sk_"); ArgumentCaptor saved = ArgumentCaptor.forClass(ApiKey.class); @@ -184,7 +186,10 @@ class ApiKeyManagementServiceTest { @DisplayName("rejects an over-long key name") void rejectsLongName() { String longName = "a".repeat(101); - assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest(longName, "personal"))) + assertThatThrownBy( + () -> + service.createKey( + new CreateApiKeyRequest(longName, "personal", null))) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("characters or fewer"); verify(apiKeyRepository, never()).save(any()); @@ -199,7 +204,7 @@ class ApiKeyManagementServiceTest { assertThatThrownBy( () -> service.createKey( - new CreateApiKeyRequest("One too many", "personal"))) + new CreateApiKeyRequest("One too many", "personal", null))) .isInstanceOf(ResponseStatusException.class); verify(apiKeyRepository, never()).save(any()); } @@ -212,7 +217,7 @@ class ApiKeyManagementServiceTest { assertThatThrownBy( () -> service.createKey( - new CreateApiKeyRequest("Team key", "team-members"))) + new CreateApiKeyRequest("Team key", "team-members", null))) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("team leader"); verify(apiKeyRepository, never()).save(any()); @@ -227,12 +232,49 @@ class ApiKeyManagementServiceTest { team.setName("Acme"); when(teamRepository.findById(5L)).thenReturn(Optional.of(team)); - service.createKey(new CreateApiKeyRequest("Team key", "team-members")); + 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 @@ -245,7 +287,7 @@ class ApiKeyManagementServiceTest { team.setName("Acme"); when(teamRepository.findById(5L)).thenReturn(Optional.of(team)); - service.createKey(new CreateApiKeyRequest("Admin team key", "team-members")); + service.createKey(new CreateApiKeyRequest("Admin team key", "team-members", null)); ArgumentCaptor saved = ArgumentCaptor.forClass(ApiKey.class); verify(apiKeyRepository).save(saved.capture()); 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 d13f1ed4dd..05e1c79453 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 @@ -427,7 +427,7 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { ApiKeyAuthenticationToken authToken = new ApiKeyAuthenticationToken( - user, apiKey, resolved.get().authorities(), resolved.get().teamScoped()); + user, apiKey, resolved.get().authorities(), resolved.get().access()); 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 fab0c3a895..a940cb229a 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 @@ -35,7 +35,7 @@ public class TeamSecurityExpressions { /** Whether the current authenticated user is a {@code LEADER} of the given team. */ public boolean isTeamLeader(Long teamId) { - if (isSharedTeamApiKey()) { + if (isProcessingApiKey()) { return false; } User currentUser = getCurrentUser(); @@ -50,7 +50,7 @@ public class TeamSecurityExpressions { /** Whether the current authenticated user is a {@code LEADER} of their own team. */ public boolean isCurrentUserTeamLeader() { - if (isSharedTeamApiKey()) { + if (isProcessingApiKey()) { return false; } User currentUser = getCurrentUser(); @@ -64,14 +64,16 @@ public class TeamSecurityExpressions { } /** - * A shared team key must never authenticate with team-leader powers: it acts at the level of the - * least-privileged member who can use it. 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 team-scope flag does. + * 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 isSharedTeamApiKey() { + private boolean isProcessingApiKey() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - return auth instanceof ApiKeyAuthenticationToken token && token.isTeamScoped(); + return auth instanceof ApiKeyAuthenticationToken token && token.isProcessingOnly(); } /** The current authenticated user's team id, or {@code null} if unauthenticated / teamless. */ 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 580c8beb33..c748207425 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 @@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS api_keys ( 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, 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 5e456b6675..5f34938804 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,7 +248,11 @@ class SupabaseAuthenticationFilterMoreTest { Optional.of( new stirling.software.proprietary.security.service .ApiKeyAuthenticationService.ApiKeyAuthentication( - user, null, user.getAuthorities(), false))); + user, + null, + user.getAuthorities(), + stirling.software.proprietary.security.model + .ApiKeyAccess.FULL))); 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 2488cf30fb..66055d61fe 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,6 +30,7 @@ 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; @@ -99,7 +100,7 @@ class SupabaseAuthenticationFilterTest { Optional.of( new stirling.software.proprietary.security.service .ApiKeyAuthenticationService.ApiKeyAuthentication( - user, null, user.getAuthorities(), false))); + user, null, user.getAuthorities(), ApiKeyAccess.FULL))); 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 0c95af6e4f..bc6b18d2cc 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,6 +21,7 @@ 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,10 +120,12 @@ class TeamSecurityExpressionsTest { @Test void sharedTeamApiKeyNeverLeadsEvenIfOwnerIsLeader() { - // Owner genuinely leads the team, but the request came in on a team-scoped (shared) key. + // Owner genuinely leads the team, but the request came in on a processing-only (shared) + // key. SecurityContextHolder.getContext() .setAuthentication( - new ApiKeyAuthenticationToken(leaderUser(), "sk_shared", List.of(), true)); + 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()); @@ -131,10 +134,11 @@ class TeamSecurityExpressionsTest { @Test void personalApiKeyOfALeaderStillLeads() { - // A personal (non-shared) key acts as the owner; if they lead, the key leads. + // 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(), false)); + new ApiKeyAuthenticationToken( + leaderUser(), "sk_personal", List.of(), ApiKeyAccess.FULL)); 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 166e985022..3f385d082a 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -6898,6 +6898,7 @@ 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" @@ -6990,6 +6991,12 @@ 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" @@ -7052,6 +7059,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" @@ -7976,6 +7987,7 @@ 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." diff --git a/frontend/editor/src/portal/api/infrastructure.ts b/frontend/editor/src/portal/api/infrastructure.ts index 20f0268220..ca6967fbfc 100644 --- a/frontend/editor/src/portal/api/infrastructure.ts +++ b/frontend/editor/src/portal/api/infrastructure.ts @@ -52,12 +52,20 @@ export type ApiKeyStatus = "active" | "revoked"; */ 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; @@ -317,6 +325,7 @@ export async function fetchApiKeys(): Promise { export async function createApiKey(body: { name: string; scope: ApiKeyScope; + access: ApiKeyAccess; }): Promise { const opts = { method: "POST" as const, body }; return apiClient.saas.isConfigured() diff --git a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx index c3312a4906..98e3f8f2c8 100644 --- a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx +++ b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx @@ -8,6 +8,7 @@ const BASE: ApiKey = { name: "Production · ingest", prefix: "sk_a3f81b2c", scope: "personal", + access: "full", teamName: null, created: "2026-03-02", lastUsed: "2026-07-10 09:14", @@ -42,6 +43,7 @@ export const TeamMembers: Story = { ...BASE, name: "Team · shared ingest", scope: "team-members", + access: "processing", teamName: "Acme Corp", }, }, @@ -53,6 +55,7 @@ export const TeamLeadOnly: Story = { ...BASE, name: "Ops · leaders only", scope: "team-lead", + access: "processing", teamName: "Acme Corp", usageToday: 0, }, diff --git a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx index 8b99973ac8..65f23dddf1 100644 --- a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx +++ b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx @@ -3,6 +3,7 @@ import { Button, Card, Chip, 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, @@ -57,6 +58,10 @@ export function ApiKeyCard({ {open && (
+
+
{t("portal.infrastructure.apiKeys.card.access")}
+
{t(KEY_ACCESS_LABEL[apiKey.access])}
+
{t("portal.infrastructure.apiKeys.card.created")}
{apiKey.created}
diff --git a/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.test.tsx b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.test.tsx index 50b1e1d9a8..6d2b7563b2 100644 --- a/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.test.tsx +++ b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.test.tsx @@ -65,10 +65,12 @@ describe("CreateKeyModal", () => { screen.getByText("sk_live_demo_key_rotate_in_prod"), ).toBeInTheDocument(); await waitFor(() => expect(onCreated).toHaveBeenCalled()); - // Personal is the default scope when the leader doesn't pick a team scope. + // Personal is the default scope when the leader doesn't pick a team scope, + // and a personal key defaults to full access. expect(createApiKey).toHaveBeenCalledWith({ name: "Production ingest", scope: "personal", + access: "full", }); }); diff --git a/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.tsx b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.tsx index 407fb6bb11..82f04c8a0d 100644 --- a/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.tsx +++ b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.tsx @@ -12,6 +12,7 @@ import { } from "@app/ui"; import { createApiKey, + type ApiKeyAccess, type ApiKeyScope, type CreatedApiKey, } from "@portal/api/infrastructure"; @@ -36,13 +37,26 @@ export function CreateKeyModal({ const { t } = useTranslation(); const [name, setName] = useState(""); const [scope, setScope] = useState("personal"); + const [access, setAccess] = useState("full"); const [created, setCreated] = useState(null); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + // A shared (team) key can only ever be processing-only; full access is never sharable. + const isTeamScope = scope !== "personal"; + const effectiveAccess: ApiKeyAccess = isTeamScope ? "processing" : access; + + function changeScope(next: ApiKeyScope) { + setScope(next); + if (next !== "personal") { + setAccess("processing"); + } + } + function reset() { setName(""); setScope("personal"); + setAccess("full"); setCreated(null); setSubmitting(false); setError(null); @@ -58,7 +72,11 @@ export function CreateKeyModal({ setSubmitting(true); setError(null); try { - const result = await createApiKey({ name: name.trim(), scope }); + const result = await createApiKey({ + name: name.trim(), + scope, + access: effectiveAccess, + }); setCreated(result); onCreated(); } catch (e) { @@ -69,6 +87,19 @@ export function CreateKeyModal({ } const teamLabel = teamName ?? t("portal.infrastructure.createKey.yourTeam"); + const accessOptions: RadioOption[] = [ + { + value: "full", + label: t("portal.infrastructure.createKey.accessFull"), + description: t("portal.infrastructure.createKey.accessFullHelp"), + disabled: isTeamScope, + }, + { + value: "processing", + label: t("portal.infrastructure.createKey.accessProcessing"), + description: t("portal.infrastructure.createKey.accessProcessingHelp"), + }, + ]; const scopeOptions: RadioOption[] = [ { value: "personal", @@ -163,7 +194,7 @@ export function CreateKeyModal({ name="apiKeyScope" value={scope} - onChange={setScope} + onChange={changeScope} options={scopeOptions} /> {!canCreateTeamKeys && ( @@ -173,6 +204,22 @@ export function CreateKeyModal({ )}
+ + +
+ + name="apiKeyAccess" + value={effectiveAccess} + onChange={setAccess} + options={accessOptions} + /> + {isTeamScope && ( +

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

+ )} +
+
)} diff --git a/frontend/editor/src/portal/components/infrastructure/infraFormat.ts b/frontend/editor/src/portal/components/infrastructure/infraFormat.ts index 233d76ac2e..46b599edb0 100644 --- a/frontend/editor/src/portal/components/infrastructure/infraFormat.ts +++ b/frontend/editor/src/portal/components/infrastructure/infraFormat.ts @@ -1,6 +1,7 @@ import type { TFunction } from "i18next"; import type { ChipAccent, StatusTone } from "@app/ui"; import type { + ApiKeyAccess, ApiKeyScope, ApiKeyStatus, AttestationStatus, @@ -76,6 +77,12 @@ export const KEY_SCOPE_TONE: Record = { "team-members": "brand", }; +// Access labels (i18n keys), resolved via t(KEY_ACCESS_LABEL[value]). +export const KEY_ACCESS_LABEL: Record = { + full: "portal.infrastructure.keyAccessLabel.full", + processing: "portal.infrastructure.keyAccessLabel.processing", +}; + export const CERT_TONE: Record = { certified: "success", "in-progress": "warning", diff --git a/frontend/editor/src/portal/mocks/infrastructure.ts b/frontend/editor/src/portal/mocks/infrastructure.ts index 44186bfdf9..0de31e27ea 100644 --- a/frontend/editor/src/portal/mocks/infrastructure.ts +++ b/frontend/editor/src/portal/mocks/infrastructure.ts @@ -169,6 +169,7 @@ const API_KEYS_ALL: ApiKey[] = [ name: "Production · ingest", prefix: "sk_a3f81b2c", scope: "personal", + access: "full", teamName: null, created: "2026-03-02", lastUsed: "2026-07-10 09:14", @@ -183,6 +184,7 @@ const API_KEYS_ALL: ApiKey[] = [ name: "Team · shared ingest", prefix: "sk_77be0f42", scope: "team-members", + access: "processing", teamName: "Acme Corp", created: "2026-01-18", lastUsed: "2026-07-10 08:33", @@ -197,6 +199,7 @@ const API_KEYS_ALL: ApiKey[] = [ name: "Ops · leaders only", prefix: "sk_d9013ab7", scope: "team-lead", + access: "processing", teamName: "Acme Corp", created: "2025-08-09", lastUsed: "2026-07-04 17:02", @@ -211,6 +214,7 @@ const API_KEYS_ALL: ApiKey[] = [ name: "Sandbox · webhook tester", prefix: "sk_2c4a91de", scope: "personal", + access: "full", teamName: null, created: "2026-05-30", lastUsed: "Never", diff --git a/frontend/editor/src/portal/views/Sources.test.tsx b/frontend/editor/src/portal/views/Sources.test.tsx index 4fc9d5d850..277aceabfe 100644 --- a/frontend/editor/src/portal/views/Sources.test.tsx +++ b/frontend/editor/src/portal/views/Sources.test.tsx @@ -207,6 +207,7 @@ describe("Sources view", () => { name: "Production ingest", prefix: "sk_a3f81b2c", scope: "personal", + access: "full", teamName: null, created: "2026-03-02", lastUsed: "2026-07-10 09:14", diff --git a/frontend/editor/src/portal/views/Sources.tsx b/frontend/editor/src/portal/views/Sources.tsx index 258a22ec64..1e55852343 100644 --- a/frontend/editor/src/portal/views/Sources.tsx +++ b/frontend/editor/src/portal/views/Sources.tsx @@ -21,7 +21,10 @@ import { type ApiKey, type ApiKeysResponse, } from "@portal/api/infrastructure"; -import { KEY_SCOPE_LABEL } from "@portal/components/infrastructure/infraFormat"; +import { + KEY_ACCESS_LABEL, + KEY_SCOPE_LABEL, +} from "@portal/components/infrastructure/infraFormat"; import { AgentBuilderAction } from "@portal/components/sources/AgentBuilderAction"; import { KpiStrip } from "@portal/components/sources/KpiStrip"; import { SourcesTable } from "@portal/components/sources/SourcesTable"; @@ -48,6 +51,10 @@ function apiKeyToSourceRow(k: ApiKey, t: TFunction): SourceView { referencingPolicies: [], config: [ { label: t("portal.sources.detail.apiKeyScope"), value: scope }, + { + label: t("portal.sources.detail.apiKeyAccess"), + value: t(KEY_ACCESS_LABEL[k.access]), + }, { label: t("portal.sources.detail.apiKeyPrefix"), value: k.prefix }, { label: t("portal.sources.detail.apiKeyLastUsed"), value: k.lastUsed }, ],