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
+ {t("portal.infrastructure.createKey.accessTeamNote")}
+
+ *
+ */
+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 extends GrantedAuthority>) null);
this.principal = null;
this.credentials = apiKey;
- this.teamScoped = false;
+ this.access = ApiKeyAccess.FULL;
setAuthenticated(false);
}
public ApiKeyAuthenticationToken(
Object principal, String apiKey, Collection extends GrantedAuthority> authorities) {
- this(principal, apiKey, authorities, false);
+ this(principal, apiKey, authorities, ApiKeyAccess.FULL);
}
public ApiKeyAuthenticationToken(
Object principal,
String apiKey,
Collection extends GrantedAuthority> 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 extends GrantedAuthority> 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 extends GrantedAuthority> 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
+