diff --git a/app/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java b/app/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java index 8fb729560..c75e19b00 100644 --- a/app/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java +++ b/app/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java @@ -7,7 +7,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; /** - * Shortcut for a POST endpoint that is executed through the Stirling "auto‑job" framework. + * Shortcut for a POST endpoint that is executed through the Stirling "auto-job" framework. * *
Behaviour notes: * @@ -16,7 +16,7 @@ import org.springframework.web.bind.annotation.RequestMethod; * multipart/form-data} unless you override {@link #consumes()}. *
@@ -48,8 +48,8 @@ public @interface AutoJobPostMapping {
long timeout() default -1;
/**
- * Total number of attempts (initial + retries). Must be at least 1. Retries are executed
- * with exponential back‑off.
+ * Total number of attempts (initial + retries). Must be at least 1. Retries are executed
+ * with exponential back-off.
*
* Only honoured when {@code async=true}.
*/
@@ -71,8 +71,9 @@ public @interface AutoJobPostMapping {
boolean queueable() default false;
/**
- * Relative resource weight (1–100) used by the scheduler to prioritise / throttle jobs. Values
- * below 1 are clamped to 1, values above 100 to 100.
+ * Credit cost for this endpoint in the API credit system. Also used as relative resource weight
+ * (1-100) by the scheduler to prioritise / throttle jobs. Values below 1 are clamped to 1,
+ * values above 100 to 100.
*/
- int resourceWeight() default 50;
+ int resourceWeight() default 1;
}
diff --git a/app/core/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java b/app/core/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java
index bdcd183a2..16de58503 100644
--- a/app/core/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java
+++ b/app/core/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java
@@ -45,13 +45,11 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
String queryString = request.getQueryString();
if (queryString != null && !queryString.isEmpty()) {
- String requestURI = request.getRequestURI();
-
+ // Reuse the requestURI variable from above
if (requestURI.contains("/api/")) {
return true;
}
-
Map allowedParameters = new HashMap<>();
// Keep only the allowed parameters
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertHtmlToPDF.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertHtmlToPDF.java
index 32d1347d6..97cf2e620 100644
--- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertHtmlToPDF.java
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertHtmlToPDF.java
@@ -37,7 +37,6 @@ public class ConvertHtmlToPDF {
private final CustomHtmlSanitizer customHtmlSanitizer;
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/html/pdf")
-
@Operation(
summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF",
description =
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdf.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdf.java
index 2e0f53a0f..3c82c2844 100644
--- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdf.java
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdf.java
@@ -46,7 +46,6 @@ public class ConvertMarkdownToPdf {
private final CustomHtmlSanitizer customHtmlSanitizer;
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/markdown/pdf")
-
@Operation(
summary = "Convert a Markdown file to PDF",
description =
diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties
index 2c566aa97..d322869bd 100644
--- a/app/core/src/main/resources/application.properties
+++ b/app/core/src/main/resources/application.properties
@@ -59,10 +59,11 @@ spring.main.allow-bean-definition-overriding=true
# Set up a consistent temporary directory location
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
-# API Rate Limiting Configuration
-api.rate-limit.enabled=true
-api.rate-limit.anonymous.enabled=true
-api.rate-limit.anonymous.monthly-limit=10
-api.rate-limit.anonymous.abuse-threshold=3
-api.rate-limit.exclude-settings=true
-api.rate-limit.exclude-actuator=true
\ No newline at end of file
+# API Credit System Configuration
+api.credit-system.enabled=true
+api.credit-system.anonymous.enabled=true
+api.credit-system.anonymous.monthly-credits=10
+api.credit-system.anonymous.abuse-threshold=3
+api.credit-system.exclude-settings=true
+api.credit-system.exclude-actuator=true
+api.credit-system.default-credit-cost=1
\ No newline at end of file
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/ApiCreditConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/ApiCreditConfiguration.java
new file mode 100644
index 000000000..101214e9f
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/ApiCreditConfiguration.java
@@ -0,0 +1,71 @@
+package stirling.software.proprietary.config;
+
+import java.util.Map;
+
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.common.model.enumeration.Role;
+import stirling.software.proprietary.service.ApiCreditService;
+
+@Configuration
+@ConfigurationProperties(prefix = "api.credit-system")
+@Data
+@Slf4j
+public class ApiCreditConfiguration {
+
+ private boolean enabled = true;
+ private boolean excludeSettings = true;
+ private boolean excludeActuator = true;
+ private int defaultCreditCost = 1;
+
+ /**
+ * Default monthly credit limits by role. Override in application.yml/properties. Note:
+ * Integer.MAX_VALUE is treated as "unlimited".
+ */
+ private Map defaultCreditLimits =
+ Map.of(
+ "ROLE_SYSTEM_ADMIN", Integer.MAX_VALUE,
+ "ROLE_ORG_ADMIN", 10000,
+ "ROLE_TEAM_LEAD", 5000,
+ "ROLE_ADMIN", Integer.MAX_VALUE,
+ "ROLE_USER", 50,
+ "ROLE_DEMO_USER", 20,
+ "STIRLING-PDF-BACKEND-API-USER", Integer.MAX_VALUE);
+
+ @Bean
+ public CommandLineRunner initializeDefaultCreditLimits(ApiCreditService creditService) {
+ return args -> {
+ if (!enabled) {
+ log.info("API credit system is disabled");
+ return;
+ }
+ log.info("Initializing default API credit limits...");
+ initializeDefaults(creditService);
+ log.info("Default API credit limits initialized successfully");
+ };
+ }
+
+ private void initializeDefaults(ApiCreditService creditService) {
+ for (Map.Entry entry : defaultCreditLimits.entrySet()) {
+ String roleName = entry.getKey();
+ Integer creditLimit = entry.getValue();
+
+ try {
+ Role.fromString(roleName);
+ creditService.createOrUpdateRoleDefault(roleName, creditLimit);
+ log.debug(
+ "Set default credit limit for role {} to {}/month",
+ roleName,
+ creditLimit == Integer.MAX_VALUE ? "unlimited" : creditLimit);
+ } catch (IllegalArgumentException e) {
+ log.warn("Skipping unknown role in credit system config: {}", roleName);
+ }
+ }
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/ApiRateLimitConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/ApiRateLimitConfiguration.java
deleted file mode 100644
index 3a13cbcbc..000000000
--- a/app/proprietary/src/main/java/stirling/software/proprietary/config/ApiRateLimitConfiguration.java
+++ /dev/null
@@ -1,68 +0,0 @@
-package stirling.software.proprietary.config;
-
-import java.util.Map;
-
-import org.springframework.boot.CommandLineRunner;
-import org.springframework.boot.context.properties.ConfigurationProperties;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-
-import lombok.Data;
-import lombok.extern.slf4j.Slf4j;
-
-import stirling.software.common.model.enumeration.Role;
-import stirling.software.proprietary.service.ApiRateLimitService;
-
-@Configuration
-@ConfigurationProperties(prefix = "api.rate-limit")
-@Data
-@Slf4j
-public class ApiRateLimitConfiguration {
-
- private boolean enabled = true;
- private boolean excludeSettings = true;
- private boolean excludeActuator = true;
-
- /**
- * Default monthly limits by role. Override in application.yml/properties.
- * Note: Integer.MAX_VALUE is treated as "unlimited".
- */
- private Map defaultLimits = Map.of(
- "ROLE_SYSTEM_ADMIN", Integer.MAX_VALUE,
- "ROLE_ORG_ADMIN", 10000,
- "ROLE_TEAM_LEAD", 5000,
- "ROLE_ADMIN", Integer.MAX_VALUE,
- "ROLE_USER", 1000,
- "ROLE_DEMO_USER", 100,
- "STIRLING-PDF-BACKEND-API-USER", Integer.MAX_VALUE
- );
-
- @Bean
- public CommandLineRunner initializeDefaultRateLimits(ApiRateLimitService rateLimitService) {
- return args -> {
- if (!enabled) {
- log.info("API rate limiting is disabled");
- return;
- }
- log.info("Initializing default API rate limits...");
- initializeDefaults(rateLimitService);
- log.info("Default API rate limits initialized successfully");
- };
- }
-
- private void initializeDefaults(ApiRateLimitService rateLimitService) {
- for (Map.Entry entry : defaultLimits.entrySet()) {
- String roleName = entry.getKey();
- Integer limit = entry.getValue();
-
- try {
- Role.fromString(roleName);
- rateLimitService.createOrUpdateRoleDefault(roleName, limit);
- log.debug("Set default rate limit for role {} to {}/month",
- roleName, limit == Integer.MAX_VALUE ? "unlimited" : limit);
- } catch (IllegalArgumentException e) {
- log.warn("Skipping unknown role in rate limit config: {}", roleName);
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java
deleted file mode 100644
index a43f6b69d..000000000
--- a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java
+++ /dev/null
@@ -1,17 +0,0 @@
-package stirling.software.proprietary.config;
-
-import org.springframework.context.annotation.Configuration;
-import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
-import org.springframework.scheduling.annotation.EnableScheduling;
-import org.springframework.transaction.annotation.EnableTransactionManagement;
-
-/** Configuration to explicitly enable JPA repositories and scheduling for the audit system. */
-@Configuration
-@EnableTransactionManagement
-@EnableJpaRepositories(basePackages = "stirling.software.proprietary.repository")
-@EnableScheduling
-public class AuditJpaConfig {
- // This configuration enables JPA repositories in the specified package
- // and enables scheduling for audit cleanup tasks
- // No additional beans or methods needed
-}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java
index c5ed53bf6..efd10a133 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java
@@ -18,7 +18,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.security.PersistentAuditEvent;
-import stirling.software.proprietary.repository.PersistentAuditEventRepository;
+import stirling.software.proprietary.security.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.util.SecretMasker;
@Component
@@ -30,13 +30,13 @@ public class CustomAuditEventRepository implements AuditEventRepository {
private final PersistentAuditEventRepository repo;
private final ObjectMapper mapper;
- /* ── READ side intentionally inert (endpoint disabled) ── */
+ /* READ side intentionally inert (endpoint disabled) */
@Override
public List find(String p, Instant after, String type) {
return List.of();
}
- /* ── WRITE side (async) ───────────────────────────────── */
+ /* WRITE side (async) */
@Async("auditExecutor")
@Override
public void add(AuditEvent ev) {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/AuditDashboardController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/AuditDashboardController.java
index 6030558cd..7c70e4360 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/AuditDashboardController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/AuditDashboardController.java
@@ -40,7 +40,7 @@ import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.model.security.PersistentAuditEvent;
-import stirling.software.proprietary.repository.PersistentAuditEventRepository;
+import stirling.software.proprietary.security.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
/** Controller for the audit dashboard. Admin-only access. */
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ApiCreditController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ApiCreditController.java
new file mode 100644
index 000000000..bfefcb4f8
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ApiCreditController.java
@@ -0,0 +1,173 @@
+package stirling.software.proprietary.controller.api;
+
+import java.time.YearMonth;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.*;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.proprietary.security.model.User;
+import stirling.software.proprietary.security.service.UserService;
+import stirling.software.proprietary.service.ApiCreditService;
+
+@RestController
+@RequestMapping("/api/v1/credits")
+@RequiredArgsConstructor
+@Slf4j
+@Tag(
+ name = "API Credits",
+ description = "Endpoints for managing and viewing API credit limits and usage")
+public class ApiCreditController {
+
+ private final ApiCreditService creditService;
+ private final UserService userService;
+
+ public record CreditMetricsResponse(
+ int creditsConsumed,
+ int monthlyCredits,
+ int remaining,
+ String scope,
+ String month,
+ boolean isPooled,
+ long resetEpochMillis) {}
+
+ public record UpdateCreditLimitRequest(int monthlyCredits, Boolean isActive) {}
+
+ public record CreateUserCreditConfigRequest(
+ String username, int monthlyCredits, boolean isActive) {}
+
+ public record CreateOrgCreditConfigRequest(
+ String organizationName, int monthlyCredits, boolean isPooled, boolean isActive) {}
+
+ @Operation(
+ summary = "Get current user's credit metrics",
+ description =
+ "Returns the current user's credit consumption, limits, and remaining credits for the current month")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "Credit metrics retrieved successfully"),
+ @ApiResponse(responseCode = "401", description = "User not authenticated")
+ })
+ @GetMapping("/my-usage")
+ public ResponseEntity getMyCredits(Authentication authentication) {
+ if (authentication == null || !authentication.isAuthenticated()) {
+ return ResponseEntity.status(401).build();
+ }
+
+ User user =
+ userService
+ .findByUsername(authentication.getName())
+ .orElseThrow(() -> new RuntimeException("User not found"));
+
+ ApiCreditService.CreditMetrics metrics = creditService.getUserCreditMetrics(user);
+
+ YearMonth nextMonth = metrics.month().plusMonths(1);
+ ZonedDateTime resetTime = nextMonth.atDay(1).atStartOfDay(ZoneOffset.UTC);
+ long resetEpochMillis = resetTime.toInstant().toEpochMilli();
+
+ CreditMetricsResponse response =
+ new CreditMetricsResponse(
+ metrics.creditsConsumed(),
+ metrics.monthlyCredits(),
+ metrics.remaining(),
+ metrics.scope(),
+ metrics.month().toString(),
+ metrics.isPooled(),
+ resetEpochMillis);
+
+ return ResponseEntity.ok(response);
+ }
+
+ @Operation(
+ summary = "Get user credit metrics",
+ description = "Returns credit metrics for a specific user (admin only)")
+ @PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
+ @GetMapping("/user/{username}")
+ public ResponseEntity getUserCredits(
+ @Parameter(description = "Username to get credit metrics for") @PathVariable
+ String username) {
+ User user = userService.findByUsername(username).orElse(null);
+
+ if (user == null) {
+ return ResponseEntity.notFound().build();
+ }
+
+ ApiCreditService.CreditMetrics metrics = creditService.getUserCreditMetrics(user);
+
+ YearMonth nextMonth = metrics.month().plusMonths(1);
+ ZonedDateTime resetTime = nextMonth.atDay(1).atStartOfDay(ZoneOffset.UTC);
+ long resetEpochMillis = resetTime.toInstant().toEpochMilli();
+
+ CreditMetricsResponse response =
+ new CreditMetricsResponse(
+ metrics.creditsConsumed(),
+ metrics.monthlyCredits(),
+ metrics.remaining(),
+ metrics.scope(),
+ metrics.month().toString(),
+ metrics.isPooled(),
+ resetEpochMillis);
+
+ return ResponseEntity.ok(response);
+ }
+
+ @Operation(
+ summary = "Create user-specific credit limit",
+ description = "Create a credit limit configuration for a specific user (admin only)")
+ @PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
+ @PostMapping("/config/user")
+ public ResponseEntity> createUserCreditConfig(
+ @RequestBody CreateUserCreditConfigRequest request) {
+ try {
+ User user = userService.findByUsername(request.username()).orElse(null);
+
+ if (user == null) {
+ return ResponseEntity.badRequest().body("User not found: " + request.username());
+ }
+
+ creditService.createUserCreditConfig(user, request.monthlyCredits(), request.isActive());
+ return ResponseEntity.ok().body("User credit configuration created successfully");
+ } catch (Exception e) {
+ log.error("Error creating user credit config", e);
+ return ResponseEntity.badRequest().body("Error: " + e.getMessage());
+ }
+ }
+
+ @Operation(
+ summary = "Update role default credit limits",
+ description = "Update the default credit limit for a specific role (admin only)")
+ @PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
+ @PutMapping("/config/role/{roleName}")
+ public ResponseEntity> updateRoleDefault(
+ @Parameter(description = "Role name to update") @PathVariable String roleName,
+ @RequestBody UpdateCreditLimitRequest request) {
+ try {
+ creditService.createOrUpdateRoleDefault(roleName, request.monthlyCredits());
+ return ResponseEntity.ok().body("Role default updated successfully");
+ } catch (Exception e) {
+ log.error("Error updating role default", e);
+ return ResponseEntity.badRequest().body("Error: " + e.getMessage());
+ }
+ }
+
+ @Operation(
+ summary = "Get credit system status",
+ description = "Returns basic information about the credit system configuration")
+ @GetMapping("/status")
+ public ResponseEntity> getCreditSystemStatus() {
+ return ResponseEntity.ok()
+ .body(
+ "API Credit System is active. Use /api/v1/credits/my-usage to check your credit balance.");
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ApiRateLimitController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ApiRateLimitController.java
deleted file mode 100644
index 26f1de895..000000000
--- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ApiRateLimitController.java
+++ /dev/null
@@ -1,213 +0,0 @@
-package stirling.software.proprietary.controller.api;
-
-import java.time.YearMonth;
-import java.time.ZoneOffset;
-import java.time.ZonedDateTime;
-
-import org.springframework.http.ResponseEntity;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.security.core.Authentication;
-import org.springframework.web.bind.annotation.*;
-
-import io.swagger.v3.oas.annotations.Operation;
-import io.swagger.v3.oas.annotations.Parameter;
-import io.swagger.v3.oas.annotations.responses.ApiResponse;
-import io.swagger.v3.oas.annotations.responses.ApiResponses;
-import io.swagger.v3.oas.annotations.tags.Tag;
-
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-
-import stirling.software.proprietary.model.ApiRateLimitConfig;
-import stirling.software.proprietary.model.Organization;
-import stirling.software.proprietary.security.model.User;
-import stirling.software.proprietary.security.repository.OrganizationRepository;
-import stirling.software.proprietary.security.service.UserService;
-import stirling.software.proprietary.service.ApiRateLimitService;
-
-@RestController
-@RequestMapping("/api/v1/rate-limits")
-@RequiredArgsConstructor
-@Slf4j
-@Tag(name = "API Rate Limits", description = "Endpoints for managing and viewing API rate limits")
-public class ApiRateLimitController {
-
- private final ApiRateLimitService rateLimitService;
- private final UserService userService;
- private final OrganizationRepository organizationRepository;
-
- public record UsageMetricsResponse(
- int currentUsage,
- int monthlyLimit,
- int remaining,
- String scope,
- String month,
- boolean isPooled,
- long resetEpochMillis
- ) {}
-
- public record UpdateLimitRequest(
- int monthlyLimit,
- boolean isPooled
- ) {}
-
- @GetMapping("/usage")
- @Operation(summary = "Get current usage metrics",
- description = "Returns the current API usage metrics for the authenticated user")
- @ApiResponses({
- @ApiResponse(responseCode = "200", description = "Usage metrics retrieved successfully"),
- @ApiResponse(responseCode = "401", description = "Not authenticated")
- })
- public ResponseEntity getCurrentUsage(Authentication auth) {
- User user = getUserFromAuth(auth);
- if (user == null) {
- return ResponseEntity.status(401).build();
- }
-
- ApiRateLimitService.UsageMetrics metrics = rateLimitService.getUsageMetrics(user);
-
- return ResponseEntity.ok(new UsageMetricsResponse(
- metrics.currentUsage(),
- metrics.monthlyLimit(),
- metrics.remaining(),
- metrics.scope(),
- metrics.month().toString(),
- metrics.isPooled(),
- getNextMonthResetEpochMillis()
- ));
- }
-
- @GetMapping("/usage/{username}")
- @PreAuthorize("hasAnyRole('SYSTEM_ADMIN', 'ORG_ADMIN', 'TEAM_LEAD')")
- @Operation(summary = "Get usage metrics for a specific user",
- description = "Returns API usage metrics for the specified user (requires admin privileges)")
- @ApiResponses({
- @ApiResponse(responseCode = "200", description = "Usage metrics retrieved successfully"),
- @ApiResponse(responseCode = "403", description = "Insufficient privileges"),
- @ApiResponse(responseCode = "404", description = "User not found")
- })
- public ResponseEntity getUserUsage(
- @PathVariable @Parameter(description = "Username to get metrics for") String username,
- Authentication auth) {
-
- User requestingUser = getUserFromAuth(auth);
- User targetUser = userService.findByUsername(username).orElse(null);
-
- if (targetUser == null) {
- return ResponseEntity.notFound().build();
- }
-
- if (!requestingUser.canManageUser(targetUser)) {
- return ResponseEntity.status(403).build();
- }
-
- ApiRateLimitService.UsageMetrics metrics = rateLimitService.getUsageMetrics(targetUser);
-
- return ResponseEntity.ok(new UsageMetricsResponse(
- metrics.currentUsage(),
- metrics.monthlyLimit(),
- metrics.remaining(),
- metrics.scope(),
- metrics.month().toString(),
- metrics.isPooled(),
- getNextMonthResetEpochMillis()
- ));
- }
-
- @PutMapping("/user/{username}")
- @PreAuthorize("hasAnyRole('SYSTEM_ADMIN', 'ORG_ADMIN')")
- @Operation(summary = "Update rate limit for a user",
- description = "Sets a custom rate limit for a specific user")
- @ApiResponses({
- @ApiResponse(responseCode = "200", description = "Rate limit updated successfully"),
- @ApiResponse(responseCode = "403", description = "Insufficient privileges"),
- @ApiResponse(responseCode = "404", description = "User not found")
- })
- public ResponseEntity updateUserLimit(
- @PathVariable @Parameter(description = "Username to update") String username,
- @RequestBody UpdateLimitRequest request,
- Authentication auth) {
-
- User requestingUser = getUserFromAuth(auth);
- User targetUser = userService.findByUsername(username).orElse(null);
-
- if (targetUser == null) {
- return ResponseEntity.notFound().build();
- }
-
- if (!requestingUser.canManageUser(targetUser)) {
- return ResponseEntity.status(403).build();
- }
-
- ApiRateLimitConfig config = rateLimitService.createOrUpdateUserLimit(
- targetUser, request.monthlyLimit());
-
- return ResponseEntity.ok(config);
- }
-
- @PutMapping("/organization/{orgId}")
- @PreAuthorize("hasAnyRole('SYSTEM_ADMIN', 'ORG_ADMIN')")
- @Operation(summary = "Update rate limit for an organization",
- description = "Sets a rate limit for an entire organization")
- @ApiResponses({
- @ApiResponse(responseCode = "200", description = "Rate limit updated successfully"),
- @ApiResponse(responseCode = "403", description = "Insufficient privileges"),
- @ApiResponse(responseCode = "404", description = "Organization not found")
- })
- public ResponseEntity updateOrgLimit(
- @PathVariable @Parameter(description = "Organization ID") Long orgId,
- @RequestBody UpdateLimitRequest request,
- Authentication auth) {
-
- User requestingUser = getUserFromAuth(auth);
- Organization org = organizationRepository.findById(orgId).orElse(null);
-
- if (org == null) {
- return ResponseEntity.notFound().build();
- }
-
- if (!requestingUser.isSystemAdmin()) {
- Organization userOrg = requestingUser.getOrganization();
- if (userOrg == null || !userOrg.getId().equals(orgId) || !requestingUser.isOrgAdmin()) {
- return ResponseEntity.status(403).build();
- }
- }
-
- ApiRateLimitConfig config = rateLimitService.createOrUpdateOrgLimit(
- org, request.monthlyLimit(), request.isPooled());
-
- return ResponseEntity.ok(config);
- }
-
- @PutMapping("/role/{roleName}")
- @PreAuthorize("hasRole('SYSTEM_ADMIN')")
- @Operation(summary = "Update default rate limit for a role",
- description = "Sets the default rate limit for all users with a specific role")
- @ApiResponses({
- @ApiResponse(responseCode = "200", description = "Rate limit updated successfully"),
- @ApiResponse(responseCode = "403", description = "Insufficient privileges")
- })
- public ResponseEntity updateRoleDefault(
- @PathVariable @Parameter(description = "Role name") String roleName,
- @RequestBody UpdateLimitRequest request) {
-
- ApiRateLimitConfig config = rateLimitService.createOrUpdateRoleDefault(
- roleName, request.monthlyLimit());
-
- return ResponseEntity.ok(config);
- }
-
- private User getUserFromAuth(Authentication auth) {
- if (auth == null || !auth.isAuthenticated()) {
- return null;
- }
- return userService.findByUsername(auth.getName()).orElse(null);
- }
-
- private long getNextMonthResetEpochMillis() {
- YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
- YearMonth nextMonth = currentMonth.plusMonths(1);
- ZonedDateTime resetTime = nextMonth.atDay(1).atStartOfDay(ZoneOffset.UTC);
- return resetTime.toInstant().toEpochMilli();
- }
-}
\ No newline at end of file
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/AnonymousApiUsage.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/AnonymousCreditUsage.java
similarity index 65%
rename from app/proprietary/src/main/java/stirling/software/proprietary/model/AnonymousApiUsage.java
rename to app/proprietary/src/main/java/stirling/software/proprietary/model/AnonymousCreditUsage.java
index 2547c2a3e..64dae9270 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/model/AnonymousApiUsage.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/AnonymousCreditUsage.java
@@ -6,26 +6,29 @@ import java.time.YearMonth;
import java.util.HashSet;
import java.util.Set;
-import jakarta.persistence.*;
-import jakarta.validation.constraints.NotNull;
-
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
+import jakarta.persistence.*;
+import jakarta.validation.constraints.NotNull;
+
import lombok.*;
import stirling.software.proprietary.model.converter.YearMonthStringConverter;
@Entity
-@Table(name = "anonymous_api_usage",
- uniqueConstraints = {
- @UniqueConstraint(name = "uq_anon_fingerprint_month", columnNames = {"fingerprint", "month"})
- },
- indexes = {
- @Index(name = "idx_anon_fingerprint", columnList = "fingerprint"),
- @Index(name = "idx_anon_month", columnList = "month"),
- @Index(name = "idx_anon_ip", columnList = "ip_address")
- })
+@Table(
+ name = "anonymous_api_credit_usage",
+ uniqueConstraints = {
+ @UniqueConstraint(
+ name = "uq_anon_credit_fingerprint_month",
+ columnNames = {"fingerprint", "month"})
+ },
+ indexes = {
+ @Index(name = "idx_anon_credit_fingerprint", columnList = "fingerprint"),
+ @Index(name = "idx_anon_credit_month", columnList = "month"),
+ @Index(name = "idx_anon_credit_ip", columnList = "ip_address")
+ })
@NoArgsConstructor
@AllArgsConstructor
@Builder
@@ -33,7 +36,7 @@ import stirling.software.proprietary.model.converter.YearMonthStringConverter;
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
-public class AnonymousApiUsage implements Serializable {
+public class AnonymousCreditUsage implements Serializable {
private static final long serialVersionUID = 1L;
@@ -55,9 +58,14 @@ public class AnonymousApiUsage implements Serializable {
private YearMonth month;
@NotNull
- @Column(name = "usage_count", nullable = false)
+ @Column(name = "credits_consumed", nullable = false)
@Builder.Default
- private Integer usageCount = 0;
+ private Integer creditsConsumed = 0;
+
+ @NotNull
+ @Column(name = "credits_allocated", nullable = false)
+ @Builder.Default
+ private Integer creditsAllocated = 0;
@Column(name = "ip_address", length = 45)
private String ipAddress;
@@ -67,9 +75,8 @@ public class AnonymousApiUsage implements Serializable {
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
- name = "anonymous_api_related_fingerprints",
- joinColumns = @JoinColumn(name = "usage_id")
- )
+ name = "anonymous_api_related_fingerprints",
+ joinColumns = @JoinColumn(name = "usage_id"))
@Column(name = "related_fingerprint")
@Builder.Default
private Set relatedFingerprints = new HashSet<>();
@@ -101,4 +108,12 @@ public class AnonymousApiUsage implements Serializable {
public void preUpdate() {
this.lastAccess = Instant.now();
}
-}
\ No newline at end of file
+
+ public int getRemainingCredits() {
+ return Math.max(0, creditsAllocated - creditsConsumed);
+ }
+
+ public boolean hasCreditsRemaining(int creditCost) {
+ return getRemainingCredits() >= creditCost;
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/ApiRateLimitConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/ApiCreditConfig.java
similarity index 70%
rename from app/proprietary/src/main/java/stirling/software/proprietary/model/ApiRateLimitConfig.java
rename to app/proprietary/src/main/java/stirling/software/proprietary/model/ApiCreditConfig.java
index c93dd5aeb..eca828d43 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/model/ApiRateLimitConfig.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/ApiCreditConfig.java
@@ -3,30 +3,37 @@ package stirling.software.proprietary.model;
import java.io.Serializable;
import java.time.Instant;
+import org.hibernate.annotations.CreationTimestamp;
+import org.hibernate.annotations.UpdateTimestamp;
+
import jakarta.persistence.*;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
-import org.hibernate.annotations.CreationTimestamp;
-import org.hibernate.annotations.UpdateTimestamp;
-
import lombok.*;
import stirling.software.proprietary.security.model.User;
@Entity
-@Table(name = "api_rate_limit_configs",
- uniqueConstraints = {
- @UniqueConstraint(name = "uq_user_cfg", columnNames = {"scope_type", "user_id"}),
- @UniqueConstraint(name = "uq_org_cfg", columnNames = {"scope_type", "org_id"}),
- @UniqueConstraint(name = "uq_role_cfg", columnNames = {"scope_type", "role_name"})
- },
- indexes = {
- @Index(name = "idx_cfg_user", columnList = "user_id"),
- @Index(name = "idx_cfg_org", columnList = "org_id"),
- @Index(name = "idx_cfg_role", columnList = "role_name"),
- @Index(name = "idx_cfg_scope", columnList = "scope_type")
- })
+@Table(
+ name = "api_credit_configs",
+ uniqueConstraints = {
+ @UniqueConstraint(
+ name = "uq_user_credit_cfg",
+ columnNames = {"scope_type", "user_id"}),
+ @UniqueConstraint(
+ name = "uq_org_credit_cfg",
+ columnNames = {"scope_type", "org_id"}),
+ @UniqueConstraint(
+ name = "uq_role_credit_cfg",
+ columnNames = {"scope_type", "role_name"})
+ },
+ indexes = {
+ @Index(name = "idx_credit_cfg_user", columnList = "user_id"),
+ @Index(name = "idx_credit_cfg_org", columnList = "org_id"),
+ @Index(name = "idx_credit_cfg_role", columnList = "role_name"),
+ @Index(name = "idx_credit_cfg_scope", columnList = "scope_type")
+ })
@NoArgsConstructor
@AllArgsConstructor
@Builder
@@ -34,7 +41,7 @@ import stirling.software.proprietary.security.model.User;
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
-public class ApiRateLimitConfig implements Serializable {
+public class ApiCreditConfig implements Serializable {
private static final long serialVersionUID = 1L;
@@ -69,8 +76,8 @@ public class ApiRateLimitConfig implements Serializable {
@NotNull
@Min(0)
- @Column(name = "monthly_limit", nullable = false)
- private Integer monthlyLimit;
+ @Column(name = "monthly_credits", nullable = false)
+ private Integer monthlyCredits;
@NotNull
@Builder.Default
@@ -101,24 +108,25 @@ public class ApiRateLimitConfig implements Serializable {
if (user != null) nonNullCount++;
if (organization != null) nonNullCount++;
if (roleName != null) nonNullCount++;
-
+
if (nonNullCount != 1) {
throw new IllegalStateException(
- "Exactly one of user, organization, or roleName must be set");
+ "Exactly one of user, organization, or roleName must be set");
}
if (user != null && scopeType != ScopeType.USER) {
throw new IllegalStateException("ScopeType must be USER when user is set");
}
if (organization != null && scopeType != ScopeType.ORGANIZATION) {
- throw new IllegalStateException("ScopeType must be ORGANIZATION when organization is set");
+ throw new IllegalStateException(
+ "ScopeType must be ORGANIZATION when organization is set");
}
if (roleName != null && scopeType != ScopeType.ROLE_DEFAULT) {
throw new IllegalStateException("ScopeType must be ROLE_DEFAULT when roleName is set");
}
-
+
if (Boolean.TRUE.equals(isPooled) && scopeType != ScopeType.ORGANIZATION) {
throw new IllegalStateException("isPooled can only be true for ORGANIZATION scope");
}
}
-}
\ No newline at end of file
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/ApiRateLimitUsage.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/ApiCreditUsage.java
similarity index 51%
rename from app/proprietary/src/main/java/stirling/software/proprietary/model/ApiRateLimitUsage.java
rename to app/proprietary/src/main/java/stirling/software/proprietary/model/ApiCreditUsage.java
index 2d47afff4..c988e9c53 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/model/ApiRateLimitUsage.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/ApiCreditUsage.java
@@ -5,28 +5,33 @@ import java.time.Instant;
import java.time.YearMonth;
import java.time.ZoneOffset;
+import org.hibernate.annotations.CreationTimestamp;
+import org.hibernate.annotations.UpdateTimestamp;
+
import jakarta.persistence.*;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
-import org.hibernate.annotations.CreationTimestamp;
-import org.hibernate.annotations.UpdateTimestamp;
-
import lombok.*;
import stirling.software.proprietary.security.model.User;
@Entity
-@Table(name = "api_rate_limit_usage",
- uniqueConstraints = {
- @UniqueConstraint(name = "uq_user_month", columnNames = {"user_id", "month_key"}),
- @UniqueConstraint(name = "uq_org_month", columnNames = {"org_id", "month_key"})
- },
- indexes = {
- @Index(name = "idx_usage_user_month", columnList = "user_id, month_key"),
- @Index(name = "idx_usage_org_month", columnList = "org_id, month_key"),
- @Index(name = "idx_usage_month", columnList = "month_key")
- })
+@Table(
+ name = "api_credit_usage",
+ uniqueConstraints = {
+ @UniqueConstraint(
+ name = "uq_credit_user_month",
+ columnNames = {"user_id", "month_key"}),
+ @UniqueConstraint(
+ name = "uq_credit_org_month",
+ columnNames = {"org_id", "month_key"})
+ },
+ indexes = {
+ @Index(name = "idx_credit_usage_user_month", columnList = "user_id, month_key"),
+ @Index(name = "idx_credit_usage_org_month", columnList = "org_id, month_key"),
+ @Index(name = "idx_credit_usage_month", columnList = "month_key")
+ })
@NoArgsConstructor
@AllArgsConstructor
@Builder
@@ -34,7 +39,7 @@ import stirling.software.proprietary.security.model.User;
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
-public class ApiRateLimitUsage implements Serializable {
+public class ApiCreditUsage implements Serializable {
private static final long serialVersionUID = 1L;
@@ -61,8 +66,14 @@ public class ApiRateLimitUsage implements Serializable {
@NotNull
@Min(0)
@Builder.Default
- @Column(name = "usage_count", nullable = false)
- private Integer usageCount = 0;
+ @Column(name = "credits_consumed", nullable = false)
+ private Integer creditsConsumed = 0;
+
+ @NotNull
+ @Min(0)
+ @Builder.Default
+ @Column(name = "credits_allocated", nullable = false)
+ private Integer creditsAllocated = 0;
@CreationTimestamp
@Column(name = "created_at", updatable = false, nullable = false)
@@ -88,19 +99,29 @@ public class ApiRateLimitUsage implements Serializable {
return YearMonth.now(ZoneOffset.UTC);
}
- public static ApiRateLimitUsage forUser(User user) {
- return ApiRateLimitUsage.builder()
- .user(user)
- .monthKey(getCurrentMonth())
- .usageCount(0)
- .build();
+ public static ApiCreditUsage forUser(User user, int creditsAllocated) {
+ return ApiCreditUsage.builder()
+ .user(user)
+ .monthKey(getCurrentMonth())
+ .creditsConsumed(0)
+ .creditsAllocated(creditsAllocated)
+ .build();
}
- public static ApiRateLimitUsage forOrganization(Organization org) {
- return ApiRateLimitUsage.builder()
- .organization(org)
- .monthKey(getCurrentMonth())
- .usageCount(0)
- .build();
+ public static ApiCreditUsage forOrganization(Organization org, int creditsAllocated) {
+ return ApiCreditUsage.builder()
+ .organization(org)
+ .monthKey(getCurrentMonth())
+ .creditsConsumed(0)
+ .creditsAllocated(creditsAllocated)
+ .build();
}
-}
\ No newline at end of file
+
+ public int getRemainingCredits() {
+ return Math.max(0, creditsAllocated - creditsConsumed);
+ }
+
+ public boolean hasCreditsRemaining(int creditCost) {
+ return getRemainingCredits() >= creditCost;
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/CreditRequestContext.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/CreditRequestContext.java
new file mode 100644
index 000000000..188e31f89
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/CreditRequestContext.java
@@ -0,0 +1,83 @@
+package stirling.software.proprietary.model;
+
+import java.util.UUID;
+
+import jakarta.servlet.http.HttpServletResponse;
+import stirling.software.proprietary.security.model.User;
+
+/** Holds context information for credit-based API requests */
+public class CreditRequestContext {
+
+ private final String requestId;
+ private final User user;
+ private final String ipAddress;
+ private final String userAgent;
+ private final int creditCost;
+ private final String endpoint;
+ private boolean creditsPreChecked = false;
+ private HttpServletResponse httpResponse;
+
+ public CreditRequestContext(
+ String requestId,
+ User user,
+ String ipAddress,
+ String userAgent,
+ int creditCost,
+ String endpoint) {
+ this.requestId = requestId;
+ this.user = user;
+ this.ipAddress = ipAddress;
+ this.userAgent = userAgent;
+ this.creditCost = creditCost;
+ this.endpoint = endpoint;
+ }
+
+ public String getRequestId() {
+ return requestId;
+ }
+
+ public User getUser() {
+ return user;
+ }
+
+ public String getIpAddress() {
+ return ipAddress;
+ }
+
+ public String getUserAgent() {
+ return userAgent;
+ }
+
+ public int getCreditCost() {
+ return creditCost;
+ }
+
+ public String getEndpoint() {
+ return endpoint;
+ }
+
+ public boolean isCreditsPreChecked() {
+ return creditsPreChecked;
+ }
+
+ public void setCreditsPreChecked(boolean preChecked) {
+ this.creditsPreChecked = preChecked;
+ }
+
+ public boolean isAnonymous() {
+ return user == null;
+ }
+
+ public HttpServletResponse getHttpResponse() {
+ return httpResponse;
+ }
+
+ public void setHttpResponse(HttpServletResponse httpResponse) {
+ this.httpResponse = httpResponse;
+ }
+
+ /** Generate a unique identifier for this request */
+ public static String generateRequestId() {
+ return UUID.randomUUID().toString();
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/FailureType.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/FailureType.java
new file mode 100644
index 000000000..390737465
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/FailureType.java
@@ -0,0 +1,20 @@
+package stirling.software.proprietary.model;
+
+/** Classification of API call failures for credit charging purposes */
+public enum FailureType {
+ /**
+ * Client-side errors that don't consume credits and don't count toward failure limit Examples:
+ * 400 Bad Request, 401 Unauthorized, 403 Forbidden, 422 Unprocessable Entity
+ */
+ CLIENT_ERROR,
+
+ /**
+ * Processing errors that occur after validation passes - these count toward consecutive
+ * failures Examples: 500 Internal Server Error, processing exceptions, timeout during PDF
+ * manipulation
+ */
+ PROCESSING_ERROR,
+
+ /** Successful processing - resets failure counter and consumes credits */
+ SUCCESS
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/converter/YearMonthStringConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/converter/YearMonthStringConverter.java
index f731eb309..f8476d863 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/model/converter/YearMonthStringConverter.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/converter/YearMonthStringConverter.java
@@ -8,7 +8,7 @@ import jakarta.persistence.Converter;
@Converter(autoApply = true)
public class YearMonthStringConverter implements AttributeConverter {
-
+
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
@Override
@@ -20,4 +20,4 @@ public class YearMonthStringConverter implements AttributeConverter {
-
- Optional findByFingerprintAndMonth(String fingerprint, YearMonth month);
-
- @Query("SELECT a FROM AnonymousApiUsage a WHERE a.fingerprint = :fingerprint AND a.month = :month AND a.isBlocked = false")
- Optional findActiveByFingerprintAndMonth(@Param("fingerprint") String fingerprint, @Param("month") YearMonth month);
-
- @Query("SELECT a FROM AnonymousApiUsage a WHERE a.ipAddress = :ipAddress AND a.month = :month")
- List findByIpAddressAndMonth(@Param("ipAddress") String ipAddress, @Param("month") YearMonth month);
-
- @Query("SELECT a FROM AnonymousApiUsage a WHERE :fingerprint IN (SELECT rf FROM a.relatedFingerprints rf) AND a.month = :month")
- List findRelatedUsages(@Param("fingerprint") String fingerprint, @Param("month") YearMonth month);
-
- @Query("SELECT COALESCE(SUM(a.usageCount), 0) FROM AnonymousApiUsage a WHERE a.ipAddress = :ipAddress AND a.month = :month")
- Integer getTotalUsageByIpAndMonth(@Param("ipAddress") String ipAddress, @Param("month") YearMonth month);
-
- @Modifying
- @Query("UPDATE AnonymousApiUsage a SET a.usageCount = a.usageCount + 1, a.lastAccess = CURRENT_TIMESTAMP WHERE a.id = :id AND a.usageCount < :limit")
- int incrementUsage(@Param("id") Long id, @Param("limit") int limit);
-
- @Modifying
- @Query("UPDATE AnonymousApiUsage a SET a.isBlocked = true WHERE a.fingerprint = :fingerprint")
- void blockFingerprint(@Param("fingerprint") String fingerprint);
-
- @Modifying
- @Query("UPDATE AnonymousApiUsage a SET a.abuseScore = a.abuseScore + :increment WHERE a.id = :id")
- void incrementAbuseScore(@Param("id") Long id, @Param("increment") int increment);
-
- @Query("SELECT COUNT(DISTINCT a.fingerprint) FROM AnonymousApiUsage a WHERE a.ipAddress = :ipAddress AND a.month = :month")
- Long countDistinctFingerprintsForIp(@Param("ipAddress") String ipAddress, @Param("month") YearMonth month);
-
- @Modifying
- @Query(value = "INSERT INTO anonymous_api_usage (fingerprint, month, usage_count, ip_address, user_agent, abuse_score, is_blocked, last_access, created_at, updated_at, version) " +
- "VALUES (:fingerprint, :month, 1, :ipAddress, :userAgent, 0, false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) " +
- "ON CONFLICT (fingerprint, month) DO UPDATE SET " +
- "usage_count = anonymous_api_usage.usage_count + 1, " +
- "last_access = CURRENT_TIMESTAMP, " +
- "updated_at = CURRENT_TIMESTAMP " +
- "WHERE anonymous_api_usage.usage_count < :limit",
- nativeQuery = true)
- int upsertAndIncrement(@Param("fingerprint") String fingerprint,
- @Param("month") String month,
- @Param("ipAddress") String ipAddress,
- @Param("userAgent") String userAgent,
- @Param("limit") int limit);
-
- @Query(value = "INSERT INTO anonymous_api_usage (fingerprint, month, usage_count, ip_address, user_agent, " +
- "abuse_score, is_blocked, last_access, created_at, updated_at, version) " +
- "VALUES (:fingerprint, :month, 1, :ipAddress, :userAgent, 0, false, CURRENT_TIMESTAMP, " +
- "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) " +
- "ON CONFLICT (fingerprint, month) DO UPDATE SET " +
- "usage_count = anonymous_api_usage.usage_count + 1, " +
- "last_access = CURRENT_TIMESTAMP, " +
- "updated_at = CURRENT_TIMESTAMP " +
- "WHERE anonymous_api_usage.usage_count < :limit " +
- "RETURNING usage_count",
- nativeQuery = true)
- Integer upsertAndIncrementReturningCount(@Param("fingerprint") String fingerprint,
- @Param("month") String month,
- @Param("ipAddress") String ipAddress,
- @Param("userAgent") String userAgent,
- @Param("limit") int limit);
-}
\ No newline at end of file
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/repository/ApiRateLimitConfigRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/repository/ApiRateLimitConfigRepository.java
deleted file mode 100644
index 9bb066870..000000000
--- a/app/proprietary/src/main/java/stirling/software/proprietary/repository/ApiRateLimitConfigRepository.java
+++ /dev/null
@@ -1,34 +0,0 @@
-package stirling.software.proprietary.repository;
-
-import java.util.List;
-import java.util.Optional;
-
-import org.springframework.data.jpa.repository.JpaRepository;
-import org.springframework.data.jpa.repository.Query;
-import org.springframework.data.repository.query.Param;
-import org.springframework.stereotype.Repository;
-
-import stirling.software.proprietary.model.ApiRateLimitConfig;
-import stirling.software.proprietary.model.ApiRateLimitConfig.ScopeType;
-import stirling.software.proprietary.model.Organization;
-import stirling.software.proprietary.security.model.User;
-
-@Repository
-public interface ApiRateLimitConfigRepository extends JpaRepository {
-
- Optional findByUserAndIsActiveTrue(User user);
-
- Optional findByOrganizationAndIsActiveTrue(Organization organization);
-
- Optional findByScopeTypeAndRoleNameAndIsActiveTrue(ScopeType scopeType, String roleName);
-
- @Query("""
- SELECT c
- FROM ApiRateLimitConfig c
- WHERE c.isActive = true
- AND c.scopeType = stirling.software.proprietary.model.ApiRateLimitConfig$ScopeType.ROLE_DEFAULT
- AND c.roleName = :roleName
- """)
- Optional findDefaultForRole(@Param("roleName") String roleName);
-
-}
\ No newline at end of file
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/repository/ApiRateLimitUsageRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/repository/ApiRateLimitUsageRepository.java
deleted file mode 100644
index 9644cf6e2..000000000
--- a/app/proprietary/src/main/java/stirling/software/proprietary/repository/ApiRateLimitUsageRepository.java
+++ /dev/null
@@ -1,73 +0,0 @@
-package stirling.software.proprietary.repository;
-
-import java.time.YearMonth;
-import java.util.Optional;
-
-import org.springframework.data.jpa.repository.JpaRepository;
-import org.springframework.data.jpa.repository.Modifying;
-import org.springframework.data.jpa.repository.Query;
-import org.springframework.data.repository.query.Param;
-import org.springframework.stereotype.Repository;
-import org.springframework.transaction.annotation.Transactional;
-
-import stirling.software.proprietary.model.ApiRateLimitUsage;
-import stirling.software.proprietary.model.Organization;
-import stirling.software.proprietary.security.model.User;
-
-@Repository
-public interface ApiRateLimitUsageRepository extends JpaRepository {
-
- Optional findByUserAndMonthKey(User user, YearMonth monthKey);
-
- Optional findByOrganizationAndMonthKey(Organization organization, YearMonth monthKey);
-
- default int getUserUsageOrZero(User user, YearMonth monthKey) {
- return findByUserAndMonthKey(user, monthKey)
- .map(ApiRateLimitUsage::getUsageCount)
- .orElse(0);
- }
-
- default int getOrgUsageOrZero(Organization org, YearMonth monthKey) {
- return findByOrganizationAndMonthKey(org, monthKey)
- .map(ApiRateLimitUsage::getUsageCount)
- .orElse(0);
- }
-
- @Modifying(flushAutomatically = true, clearAutomatically = false)
- @Transactional
- @Query(
- value = """
- INSERT INTO api_rate_limit_usage (user_id, month_key, usage_count, created_at, updated_at, version)
- VALUES (:#{#user.id}, :monthKey, :inc, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)
- ON CONFLICT (user_id, month_key) DO UPDATE
- SET usage_count = api_rate_limit_usage.usage_count + :inc,
- updated_at = CURRENT_TIMESTAMP,
- version = api_rate_limit_usage.version + 1
- WHERE api_rate_limit_usage.usage_count + :inc <= :maxLimit
- """,
- nativeQuery = true
- )
- int upsertAndIncrementUserUsage(@Param("user") User user,
- @Param("monthKey") String monthKey,
- @Param("inc") int increment,
- @Param("maxLimit") int maxLimit);
-
- @Modifying(flushAutomatically = true, clearAutomatically = false)
- @Transactional
- @Query(
- value = """
- INSERT INTO api_rate_limit_usage (org_id, month_key, usage_count, created_at, updated_at, version)
- VALUES (:#{#org.id}, :monthKey, :inc, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)
- ON CONFLICT (org_id, month_key) DO UPDATE
- SET usage_count = api_rate_limit_usage.usage_count + :inc,
- updated_at = CURRENT_TIMESTAMP,
- version = api_rate_limit_usage.version + 1
- WHERE api_rate_limit_usage.usage_count + :inc <= :maxLimit
- """,
- nativeQuery = true
- )
- int upsertAndIncrementOrgUsage(@Param("org") Organization org,
- @Param("monthKey") String monthKey,
- @Param("inc") int increment,
- @Param("maxLimit") int maxLimit);
-}
\ No newline at end of file
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java
index a522fe5b2..1280bd7c8 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java
@@ -201,7 +201,6 @@ public class AccountWebController {
return "login";
}
-
// @PreAuthorize("hasRole('ROLE_ADMIN')")
// @GetMapping("/usage")
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 f052651bb..c74c778bd 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
@@ -38,7 +38,8 @@ import stirling.software.proprietary.security.CustomAuthenticationSuccessHandler
import stirling.software.proprietary.security.CustomLogoutSuccessHandler;
import stirling.software.proprietary.security.database.repository.JPATokenRepositoryImpl;
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
-import stirling.software.proprietary.security.filter.ApiRateLimitFilter;
+import stirling.software.proprietary.security.filter.ApiCreditFilter;
+import stirling.software.proprietary.security.filter.CreditOutcomeFilter;
import stirling.software.proprietary.security.filter.FirstLoginFilter;
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
@@ -71,7 +72,8 @@ public class SecurityConfiguration {
private final UserAuthenticationFilter userAuthenticationFilter;
private final LoginAttemptService loginAttemptService;
private final FirstLoginFilter firstLoginFilter;
- private final ApiRateLimitFilter apiRateLimitFilter;
+ private final ApiCreditFilter apiCreditFilter;
+ private final CreditOutcomeFilter creditOutcomeFilter;
private final SessionPersistentRegistry sessionRegistry;
private final PersistentLoginRepository persistentLoginRepository;
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
@@ -90,7 +92,8 @@ public class SecurityConfiguration {
UserAuthenticationFilter userAuthenticationFilter,
LoginAttemptService loginAttemptService,
FirstLoginFilter firstLoginFilter,
- ApiRateLimitFilter apiRateLimitFilter,
+ ApiCreditFilter apiCreditFilter,
+ CreditOutcomeFilter creditOutcomeFilter,
SessionPersistentRegistry sessionRegistry,
@Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper,
@Autowired(required = false)
@@ -107,7 +110,8 @@ public class SecurityConfiguration {
this.userAuthenticationFilter = userAuthenticationFilter;
this.loginAttemptService = loginAttemptService;
this.firstLoginFilter = firstLoginFilter;
- this.apiRateLimitFilter = apiRateLimitFilter;
+ this.apiCreditFilter = apiCreditFilter;
+ this.creditOutcomeFilter = creditOutcomeFilter;
this.sessionRegistry = sessionRegistry;
this.persistentLoginRepository = persistentLoginRepository;
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
@@ -181,7 +185,8 @@ public class SecurityConfiguration {
}
http.addFilterBefore(rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class);
- http.addFilterAfter(apiRateLimitFilter, UserAuthenticationFilter.class);
+ http.addFilterAfter(apiCreditFilter, UserAuthenticationFilter.class);
+ http.addFilterAfter(creditOutcomeFilter, ApiCreditFilter.class);
http.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class);
http.sessionManagement(
sessionManagement ->
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/DatabaseWebController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/DatabaseWebController.java
index 57f52ac35..89cd56f32 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/DatabaseWebController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/DatabaseWebController.java
@@ -22,7 +22,6 @@ public class DatabaseWebController {
private final DatabaseService databaseService;
-
@Deprecated
@PreAuthorize("hasRole('ROLE_ADMIN')")
// @GetMapping("/database")
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/TeamWebController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/TeamWebController.java
index 6e3f30705..71ea99d2f 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/TeamWebController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/TeamWebController.java
@@ -36,7 +36,6 @@ public class TeamWebController {
@Deprecated
// @GetMapping
@PreAuthorize("hasRole('ROLE_ADMIN')")
-
public String listTeams(HttpServletRequest request, Model model) {
// Get teams with user counts using a DTO projection
List allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount();
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ApiCreditFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ApiCreditFilter.java
new file mode 100644
index 000000000..1101e0a24
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ApiCreditFilter.java
@@ -0,0 +1,311 @@
+package stirling.software.proprietary.security.filter;
+
+import java.io.IOException;
+import java.lang.reflect.Method;
+import java.time.YearMonth;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.annotation.Order;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+import org.springframework.web.method.HandlerMethod;
+import org.springframework.web.servlet.HandlerExecutionChain;
+import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.common.annotations.AutoJobPostMapping;
+import stirling.software.proprietary.model.CreditRequestContext;
+import stirling.software.proprietary.security.matcher.ApiJobEndpointMatcher;
+import stirling.software.proprietary.security.model.User;
+import stirling.software.proprietary.security.service.UserService;
+import stirling.software.proprietary.service.ApiCreditService;
+import stirling.software.proprietary.service.CreditContextManager;
+
+@Component
+@Order(1)
+@RequiredArgsConstructor
+@Slf4j
+public class ApiCreditFilter extends OncePerRequestFilter {
+
+ private final ApiCreditService creditService;
+ private final UserService userService;
+ private final ApiJobEndpointMatcher apiJobEndpointMatcher;
+ private final RequestMappingHandlerMapping handlerMapping;
+ private final ObjectMapper objectMapper;
+ private final CreditContextManager contextManager;
+
+ @Value("${api.credit-system.enabled:true}")
+ private boolean creditSystemEnabled;
+
+ @Value("${api.credit-system.anonymous.enabled:true}")
+ private boolean anonymousCreditSystemEnabled;
+
+ @Value("${api.credit-system.default-credit-cost:1}")
+ private int defaultCreditCost;
+
+ @Override
+ protected void doFilterInternal(
+ HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+
+ if (!shouldApplyCreditSystem(request)) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+
+ try {
+ // Determine credit cost from annotation
+ int creditCost = getCreditCostForEndpoint(request);
+
+ User user = getCurrentUser();
+ String ipAddress = getClientIpAddress(request);
+ String userAgent = request.getHeader("User-Agent");
+
+ // Create request context for tracking
+ String requestId = CreditRequestContext.generateRequestId();
+ CreditRequestContext context =
+ new CreditRequestContext(
+ requestId,
+ user,
+ ipAddress,
+ userAgent,
+ creditCost,
+ request.getRequestURI());
+ contextManager.setContext(context);
+
+ ApiCreditService.CreditStatus status;
+
+ if (user == null) {
+ // Handle anonymous users with same pre-check approach as authenticated users
+ if (!anonymousCreditSystemEnabled) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+
+ status = creditService.preCheckAnonymousCredits(ipAddress, userAgent, creditCost);
+ context.setCreditsPreChecked(true);
+
+ addCreditHeaders(response, status);
+
+ if (!status.allowed()) {
+ handleAnonymousCreditExceeded(response, status);
+ return;
+ }
+ } else {
+ // Handle authenticated users with pre-check approach
+ status = creditService.preCheckCredits(user, creditCost);
+ context.setCreditsPreChecked(true);
+
+ addCreditHeaders(response, status);
+
+ if (!status.allowed()) {
+ handleCreditExceeded(response, user, status);
+ return;
+ }
+ }
+
+ filterChain.doFilter(request, response);
+
+ } finally {
+ // Always clear context at end of request
+ contextManager.clearContext();
+ }
+ }
+
+ private boolean shouldApplyCreditSystem(HttpServletRequest request) {
+ if (!creditSystemEnabled) {
+ return false;
+ }
+
+ // Use the shared matcher to determine if this endpoint should be credit-limited
+ return apiJobEndpointMatcher.matches(request);
+ }
+
+ private int getCreditCostForEndpoint(HttpServletRequest request) {
+ try {
+ HandlerExecutionChain chain = handlerMapping.getHandler(request);
+ if (chain == null) {
+ return defaultCreditCost;
+ }
+
+ Object handler = chain.getHandler();
+ if (!(handler instanceof HandlerMethod handlerMethod)) {
+ return defaultCreditCost;
+ }
+
+ Method method = handlerMethod.getMethod();
+ AutoJobPostMapping annotation = method.getAnnotation(AutoJobPostMapping.class);
+ if (annotation == null) {
+ return defaultCreditCost;
+ }
+
+ // Use resourceWeight as credit cost, with minimum of 1 and maximum of 100
+ return Math.max(1, Math.min(100, annotation.resourceWeight()));
+
+ } catch (Exception e) {
+ log.debug(
+ "Could not determine credit cost for {}: {}",
+ request.getRequestURI(),
+ e.getMessage());
+ return defaultCreditCost;
+ }
+ }
+
+ // TODO: improve with Redis and async in future V2.1
+ private User getCurrentUser() {
+ try {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ if (authentication == null || !authentication.isAuthenticated()) {
+ return null;
+ }
+
+ String username = authentication.getName();
+ if ("anonymousUser".equals(username)) {
+ return null;
+ }
+
+ return userService.findByUsername(username).orElse(null);
+ } catch (Exception e) {
+ log.error("Error getting user for rate limiting: {}", e.getMessage());
+ return null;
+ }
+ }
+
+ private void addCreditHeaders(
+ HttpServletResponse response, ApiCreditService.CreditStatus status) {
+ // Calculate window length (seconds in current month)
+ YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
+ YearMonth nextMonth = currentMonth.plusMonths(1);
+ ZonedDateTime startOfMonth = currentMonth.atDay(1).atStartOfDay(ZoneOffset.UTC);
+ ZonedDateTime startOfNextMonth = nextMonth.atDay(1).atStartOfDay(ZoneOffset.UTC);
+ long windowSeconds = java.time.Duration.between(startOfMonth, startOfNextMonth).getSeconds();
+
+ // Use standard RateLimit headers (IETF draft-ietf-httpapi-ratelimit-headers) adapted for
+ // credits
+ response.setHeader("RateLimit-Limit", String.valueOf(status.monthlyCredits()));
+ response.setHeader("RateLimit-Remaining", String.valueOf(status.remaining()));
+ response.setHeader(
+ "RateLimit-Reset",
+ String.valueOf(getSecondsUntilReset())); // Delta seconds to reset
+ response.setHeader(
+ "RateLimit-Policy",
+ String.format(
+ "%d;w=%d;comment=\"%s\"",
+ status.monthlyCredits(), windowSeconds, status.scope()));
+ response.setHeader("X-Credits-Used-This-Month", String.valueOf(status.creditsConsumed()));
+
+ // Add Retry-After for 429 responses
+ if (!status.allowed()) {
+ response.setHeader("Retry-After", String.valueOf(getSecondsUntilReset()));
+ }
+ }
+
+ private long getNextMonthResetEpochMillis() {
+ YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
+ YearMonth nextMonth = currentMonth.plusMonths(1);
+ ZonedDateTime resetTime = nextMonth.atDay(1).atStartOfDay(ZoneOffset.UTC);
+ return resetTime.toInstant().toEpochMilli();
+ }
+
+ private long getSecondsUntilReset() {
+ return (getNextMonthResetEpochMillis() - System.currentTimeMillis()) / 1000;
+ }
+
+ private void handleCreditExceeded(
+ HttpServletResponse response, User user, ApiCreditService.CreditStatus status)
+ throws IOException {
+ log.warn("Credit limit exceeded for user: {} - {}", user.getUsername(), status.reason());
+
+ response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
+ response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+
+ var errorResponse =
+ new CreditErrorResponse(
+ "Credit limit exceeded",
+ status.reason(),
+ status.creditsConsumed(),
+ status.monthlyCredits(),
+ status.remaining(),
+ status.scope(),
+ getNextMonthResetEpochMillis());
+
+ response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
+ }
+
+ private record CreditErrorResponse(
+ String error,
+ String message,
+ int creditsConsumed,
+ int monthlyCredits,
+ int creditsRemaining,
+ String scope,
+ long resetEpochMillis) {}
+
+ private String getClientIpAddress(HttpServletRequest request) {
+ // Check for proxy headers
+ String[] headers = {
+ "X-Forwarded-For",
+ "X-Real-IP",
+ "Proxy-Client-IP",
+ "WL-Proxy-Client-IP",
+ "HTTP_X_FORWARDED_FOR",
+ "HTTP_X_FORWARDED",
+ "HTTP_X_CLUSTER_CLIENT_IP",
+ "HTTP_CLIENT_IP",
+ "HTTP_FORWARDED_FOR",
+ "HTTP_FORWARDED",
+ "HTTP_VIA",
+ "REMOTE_ADDR"
+ };
+
+ for (String header : headers) {
+ String ip = request.getHeader(header);
+ if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
+ // Handle comma-separated IPs (in case of multiple proxies)
+ int commaIndex = ip.indexOf(',');
+ if (commaIndex > 0) {
+ ip = ip.substring(0, commaIndex).trim();
+ }
+ return ip;
+ }
+ }
+
+ // Fallback to remote address
+ return request.getRemoteAddr();
+ }
+
+ private void handleAnonymousCreditExceeded(
+ HttpServletResponse response, ApiCreditService.CreditStatus status) throws IOException {
+ log.warn("Anonymous credit limit exceeded - {}", status.reason());
+
+ response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
+ response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+
+ var errorResponse =
+ new CreditErrorResponse(
+ "Credit limit exceeded",
+ status.reason() + " - Please login for higher limits",
+ status.creditsConsumed(),
+ status.monthlyCredits(),
+ status.remaining(),
+ status.scope(),
+ getNextMonthResetEpochMillis());
+
+ response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ApiRateLimitFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ApiRateLimitFilter.java
deleted file mode 100644
index 930c37e23..000000000
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ApiRateLimitFilter.java
+++ /dev/null
@@ -1,222 +0,0 @@
-package stirling.software.proprietary.security.filter;
-
-import java.io.IOException;
-import java.time.YearMonth;
-import java.time.ZoneOffset;
-import java.time.ZonedDateTime;
-
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.core.annotation.Order;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.context.SecurityContextHolder;
-import org.springframework.stereotype.Component;
-import org.springframework.web.filter.OncePerRequestFilter;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-
-import jakarta.servlet.FilterChain;
-import jakarta.servlet.ServletException;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-
-import stirling.software.proprietary.security.matcher.ApiJobEndpointMatcher;
-import stirling.software.proprietary.security.model.User;
-import stirling.software.proprietary.security.service.UserService;
-import stirling.software.proprietary.service.ApiRateLimitService;
-
-@Component
-@Order(1)
-@RequiredArgsConstructor
-@Slf4j
-public class ApiRateLimitFilter extends OncePerRequestFilter {
-
- private final ApiRateLimitService rateLimitService;
- private final UserService userService;
- private final ApiJobEndpointMatcher apiJobEndpointMatcher;
- private final ObjectMapper objectMapper;
-
- @Value("${api.rate-limit.enabled:true}")
- private boolean rateLimitEnabled;
-
- @Value("${api.rate-limit.anonymous.enabled:true}")
- private boolean anonymousRateLimitEnabled;
-
- @Override
- protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
- FilterChain filterChain) throws ServletException, IOException {
-
- if (!shouldApplyRateLimit(request)) {
- filterChain.doFilter(request, response);
- return;
- }
-
- User user = getCurrentUser();
- ApiRateLimitService.RateLimitStatus status;
-
- if (user == null) {
- // Handle anonymous users
- if (!anonymousRateLimitEnabled) {
- filterChain.doFilter(request, response);
- return;
- }
-
- String ipAddress = getClientIpAddress(request);
- String userAgent = request.getHeader("User-Agent");
-
- status = rateLimitService.checkAndIncrementAnonymousUsage(ipAddress, userAgent);
-
- addRateLimitHeaders(response, status);
-
- if (!status.allowed()) {
- handleAnonymousRateLimitExceeded(response, status);
- return;
- }
- } else {
- // Handle authenticated users
- status = rateLimitService.checkAndIncrementUsage(user);
-
- addRateLimitHeaders(response, status);
-
- if (!status.allowed()) {
- handleRateLimitExceeded(response, user, status);
- return;
- }
- }
-
- filterChain.doFilter(request, response);
- }
-
- private boolean shouldApplyRateLimit(HttpServletRequest request) {
- if (!rateLimitEnabled) {
- return false;
- }
-
- // Use the shared matcher to determine if this endpoint should be rate-limited
- return apiJobEndpointMatcher.matches(request);
- }
-
- // TODO: improve with Redis and async in future V2.1
- private User getCurrentUser() {
- try {
- Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
- if (authentication == null || !authentication.isAuthenticated()) {
- return null;
- }
-
- String username = authentication.getName();
- if ("anonymousUser".equals(username)) {
- return null;
- }
-
- return userService.findByUsername(username).orElse(null);
- } catch (Exception e) {
- log.error("Error getting user for rate limiting: {}", e.getMessage());
- return null;
- }
- }
-
- private void addRateLimitHeaders(HttpServletResponse response, ApiRateLimitService.RateLimitStatus status) {
- // Use standard RateLimit headers (IETF draft-ietf-httpapi-ratelimit-headers)
- response.setHeader("RateLimit-Limit", String.valueOf(status.monthlyLimit()));
- response.setHeader("RateLimit-Remaining", String.valueOf(status.remaining()));
- response.setHeader("RateLimit-Reset", String.valueOf(getNextMonthResetEpochMillis() / 1000)); // Unix timestamp in seconds
- response.setHeader("RateLimit-Policy", String.format("%d;w=%d;comment=\"%s\"",
- status.monthlyLimit(), getSecondsUntilReset(), status.scope()));
- }
-
- private long getNextMonthResetEpochMillis() {
- YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
- YearMonth nextMonth = currentMonth.plusMonths(1);
- ZonedDateTime resetTime = nextMonth.atDay(1).atStartOfDay(ZoneOffset.UTC);
- return resetTime.toInstant().toEpochMilli();
- }
-
- private long getSecondsUntilReset() {
- return (getNextMonthResetEpochMillis() - System.currentTimeMillis()) / 1000;
- }
-
- private void handleRateLimitExceeded(HttpServletResponse response, User user,
- ApiRateLimitService.RateLimitStatus status) throws IOException {
- log.warn("Rate limit exceeded for user: {} - {}", user.getUsername(), status.reason());
-
- response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
- response.setContentType(MediaType.APPLICATION_JSON_VALUE);
-
- var errorResponse = new RateLimitErrorResponse(
- "Rate limit exceeded",
- status.reason(),
- status.currentUsage(),
- status.monthlyLimit(),
- status.scope(),
- getNextMonthResetEpochMillis()
- );
-
- response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
- }
-
- private record RateLimitErrorResponse(
- String error,
- String message,
- int currentUsage,
- int monthlyLimit,
- String scope,
- long resetEpochMillis
- ) {}
-
- private String getClientIpAddress(HttpServletRequest request) {
- // Check for proxy headers
- String[] headers = {
- "X-Forwarded-For",
- "X-Real-IP",
- "Proxy-Client-IP",
- "WL-Proxy-Client-IP",
- "HTTP_X_FORWARDED_FOR",
- "HTTP_X_FORWARDED",
- "HTTP_X_CLUSTER_CLIENT_IP",
- "HTTP_CLIENT_IP",
- "HTTP_FORWARDED_FOR",
- "HTTP_FORWARDED",
- "HTTP_VIA",
- "REMOTE_ADDR"
- };
-
- for (String header : headers) {
- String ip = request.getHeader(header);
- if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
- // Handle comma-separated IPs (in case of multiple proxies)
- int commaIndex = ip.indexOf(',');
- if (commaIndex > 0) {
- ip = ip.substring(0, commaIndex).trim();
- }
- return ip;
- }
- }
-
- // Fallback to remote address
- return request.getRemoteAddr();
- }
-
- private void handleAnonymousRateLimitExceeded(HttpServletResponse response,
- ApiRateLimitService.RateLimitStatus status) throws IOException {
- log.warn("Anonymous rate limit exceeded - {}", status.reason());
-
- response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
- response.setContentType(MediaType.APPLICATION_JSON_VALUE);
-
- var errorResponse = new RateLimitErrorResponse(
- "Rate limit exceeded",
- status.reason() + " - Please login for higher limits",
- status.currentUsage(),
- status.monthlyLimit(),
- status.scope(),
- getNextMonthResetEpochMillis()
- );
-
- response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
- }
-}
\ No newline at end of file
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/CreditOutcomeFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/CreditOutcomeFilter.java
new file mode 100644
index 000000000..e32feb733
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/CreditOutcomeFilter.java
@@ -0,0 +1,164 @@
+package stirling.software.proprietary.security.filter;
+
+import java.io.IOException;
+import java.time.YearMonth;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpServletResponseWrapper;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.proprietary.model.CreditRequestContext;
+import stirling.software.proprietary.model.FailureType;
+import stirling.software.proprietary.service.ApiCreditService;
+import stirling.software.proprietary.service.CreditContextManager;
+
+/**
+ * Filter that runs after API processing to record credit outcomes based on response status and any
+ * exceptions that occurred
+ */
+@Component
+@Order(100) // Run after ApiCreditFilter (Order=1) and other processing
+@RequiredArgsConstructor
+@Slf4j
+public class CreditOutcomeFilter extends OncePerRequestFilter {
+
+ private final CreditContextManager contextManager;
+ private final ApiCreditService creditService;
+
+ @Override
+ protected void doFilterInternal(
+ HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+
+ // Only process if we have credit context (meaning this was a credit-tracked request)
+ CreditRequestContext context = contextManager.getContext();
+ if (context == null || !context.isCreditsPreChecked()) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+
+ // Set response in context for header updates later
+ context.setHttpResponse(response);
+
+ // Wrap response to capture status without buffering body
+ StatusCaptureResponseWrapper responseWrapper = new StatusCaptureResponseWrapper(response);
+
+ Exception processingException = null;
+ try {
+ filterChain.doFilter(request, responseWrapper);
+ } catch (Exception e) {
+ processingException = e;
+ throw e; // Re-throw to maintain normal exception handling
+ } finally {
+ // Record the outcome based on response status and any exception
+ recordCreditOutcome(context, responseWrapper.getStatusCode(), processingException);
+ }
+ }
+
+ private void recordCreditOutcome(
+ CreditRequestContext context, int httpStatus, Exception exception) {
+ try {
+ FailureType outcome = ApiCreditService.determineFailureType(httpStatus, exception);
+
+ if (context.isAnonymous()) {
+ // For anonymous users, just log the outcome (credits already consumed)
+ creditService.recordAnonymousRequestOutcome(
+ context.getIpAddress(),
+ context.getUserAgent(),
+ context.getCreditCost(),
+ outcome);
+ } else {
+ // For authenticated users, this determines if/how credits are charged
+ ApiCreditService.CreditStatus status = creditService.recordRequestOutcome(
+ context.getUser(), context.getCreditCost(), outcome);
+
+ // Update response headers to reflect post-charge state
+ if (status != null) {
+ updateCreditHeaders(context.getHttpResponse(), status, context.getCreditCost());
+ }
+ }
+
+ } catch (Exception e) {
+ log.error(
+ "Error recording credit outcome for request {}: {}",
+ context.getRequestId(),
+ e.getMessage(),
+ e);
+
+ // On error recording outcome, default to charging credits to be safe
+ if (!context.isAnonymous()) {
+ try {
+ creditService.recordRequestOutcome(
+ context.getUser(),
+ context.getCreditCost(),
+ FailureType.PROCESSING_ERROR);
+ } catch (Exception e2) {
+ log.error("Failed to record fallback credit charge: {}", e2.getMessage());
+ }
+ }
+ }
+ }
+
+ /**
+ * Lightweight response wrapper that captures HTTP status without buffering the response body.
+ * This avoids memory issues with large PDF responses while still allowing us to track outcomes.
+ */
+ private static class StatusCaptureResponseWrapper extends HttpServletResponseWrapper {
+ private int httpStatus = HttpServletResponse.SC_OK;
+
+ public StatusCaptureResponseWrapper(HttpServletResponse response) {
+ super(response);
+ }
+
+ @Override
+ public void setStatus(int sc) {
+ this.httpStatus = sc;
+ super.setStatus(sc);
+ }
+
+ @Override
+ public void sendError(int sc) throws IOException {
+ this.httpStatus = sc;
+ super.sendError(sc);
+ }
+
+ @Override
+ public void sendError(int sc, String msg) throws IOException {
+ this.httpStatus = sc;
+ super.sendError(sc, msg);
+ }
+
+ public int getStatusCode() {
+ return httpStatus;
+ }
+ }
+
+ private void updateCreditHeaders(HttpServletResponse response, ApiCreditService.CreditStatus status, int creditCost) {
+ // Calculate window length (seconds in current month)
+ YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
+ YearMonth nextMonth = currentMonth.plusMonths(1);
+ ZonedDateTime startOfMonth = currentMonth.atDay(1).atStartOfDay(ZoneOffset.UTC);
+ ZonedDateTime startOfNextMonth = nextMonth.atDay(1).atStartOfDay(ZoneOffset.UTC);
+ long windowSeconds = java.time.Duration.between(startOfMonth, startOfNextMonth).getSeconds();
+ long resetSeconds = java.time.Duration.between(ZonedDateTime.now(ZoneOffset.UTC), startOfNextMonth).getSeconds();
+
+ // Update headers to reflect post-charge state
+ response.setHeader("RateLimit-Limit", String.valueOf(status.monthlyCredits()));
+ response.setHeader("RateLimit-Remaining", String.valueOf(status.remaining()));
+ response.setHeader("RateLimit-Reset", String.valueOf(resetSeconds));
+ response.setHeader("RateLimit-Policy", String.format("%d;w=%d;comment=\"%s\"", status.monthlyCredits(), windowSeconds, status.scope()));
+ response.setHeader("X-Credits-Used-This-Month", String.valueOf(status.creditsConsumed()));
+ response.setHeader("X-Credit-Cost", String.valueOf(creditCost));
+ }
+}
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 e4216e11b..1094300e4 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
@@ -28,13 +28,12 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
+import stirling.software.proprietary.security.matcher.ApiJobEndpointMatcher;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
-import stirling.software.proprietary.security.matcher.ApiJobEndpointMatcher;
-import stirling.software.proprietary.service.ApiRateLimitService;
@Slf4j
@Component
@@ -44,24 +43,24 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
private final UserService userService;
private final SessionPersistentRegistry sessionPersistentRegistry;
private final boolean loginEnabledValue;
- private final ApiRateLimitService rateLimitService;
private final ApiJobEndpointMatcher apiJobEndpointMatcher;
-
- @Value("${api.rate-limit.anonymous.enabled:true}")
+
+ @Value("${api.credit-system.anonymous.enabled:true}")
private boolean anonymousApiEnabled;
+ @Value("${api.credit-system.anonymous.monthly-credits:10}")
+ private int anonymousMonthlyCredits;
+
public UserAuthenticationFilter(
@Lazy ApplicationProperties.Security securityProp,
@Lazy UserService userService,
SessionPersistentRegistry sessionPersistentRegistry,
@Qualifier("loginEnabled") boolean loginEnabledValue,
- @Lazy ApiRateLimitService rateLimitService,
ApiJobEndpointMatcher apiJobEndpointMatcher) {
this.securityProp = securityProp;
this.userService = userService;
this.sessionPersistentRegistry = sessionPersistentRegistry;
this.loginEnabledValue = loginEnabledValue;
- this.rateLimitService = rateLimitService;
this.apiJobEndpointMatcher = apiJobEndpointMatcher;
}
@@ -126,21 +125,17 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
if (authentication == null || !authentication.isAuthenticated()) {
String method = request.getMethod();
String contextPath = request.getContextPath();
-
+
// Check if this is an API job endpoint and anonymous access is enabled
if (anonymousApiEnabled && apiJobEndpointMatcher.matches(request)) {
// Check anonymous rate limit
String ipAddress = getClientIpAddress(request);
String userAgent = request.getHeader("User-Agent");
-
- ApiRateLimitService.UsageMetrics metrics = rateLimitService
- .getAnonymousUsageMetrics(ipAddress, userAgent);
-
- if (metrics.remaining() > 0) {
- // Allow anonymous API access - rate limiting will be enforced by ApiRateLimitFilter
- filterChain.doFilter(request, response);
- return;
- }
+
+ // Anonymous users will be handled by ApiCreditFilter
+ // Just allow them through for now
+ filterChain.doFilter(request, response);
+ return;
}
if ("GET".equalsIgnoreCase(method) && !(contextPath + "/login").equals(requestURI)) {
@@ -153,8 +148,9 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
"Authentication required. Please provide a X-API-KEY in request"
+ " header.\n"
+ "This is found in Settings -> Account Settings -> API Key\n"
- + "Anonymous users have limited API access ("
- + rateLimitService.getAnonymousMonthlyLimit() + " requests/month)\n"
+ + "Anonymous users have limited API access ("
+ + anonymousMonthlyCredits
+ + " credits/month)\n"
+ "Alternatively you can disable authentication if this is"
+ " unexpected");
return;
@@ -285,16 +281,13 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
return false;
}
-
+
private String getClientIpAddress(HttpServletRequest request) {
// Check for proxy headers
String[] headers = {
- "X-Forwarded-For",
- "X-Real-IP",
- "Proxy-Client-IP",
- "WL-Proxy-Client-IP"
+ "X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP"
};
-
+
for (String header : headers) {
String ip = request.getHeader(header);
if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
@@ -306,7 +299,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
return ip;
}
}
-
+
return request.getRemoteAddr();
}
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/matcher/ApiJobEndpointMatcher.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/matcher/ApiJobEndpointMatcher.java
index bf276dedc..dfb33d5cb 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/matcher/ApiJobEndpointMatcher.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/matcher/ApiJobEndpointMatcher.java
@@ -16,27 +16,27 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
/**
- * Shared matcher component to determine if a request should be subject to
- * anonymous API access and rate limiting. This ensures consistent behavior
- * between UserAuthenticationFilter and ApiRateLimitFilter.
+ * Shared matcher component to determine if a request should be subject to anonymous API access and
+ * credit limiting. This ensures consistent behavior between UserAuthenticationFilter and
+ * ApiCreditFilter.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class ApiJobEndpointMatcher {
-
+
private final RequestMappingHandlerMapping handlerMapping;
-
- @Value("${api.rate-limit.exclude-settings:true}")
+
+ @Value("${api.credit-system.exclude-settings:true}")
private boolean excludeSettings;
-
- @Value("${api.rate-limit.exclude-actuator:true}")
+
+ @Value("${api.credit-system.exclude-actuator:true}")
private boolean excludeActuator;
-
+
/**
- * Determines if a request matches the criteria for API job endpoints
- * that should be rate-limited and allowed for anonymous access.
- *
+ * Determines if a request matches the criteria for API job endpoints that should be
+ * credit-limited and allowed for anonymous access.
+ *
* @param request the HTTP request to check
* @return true if the request is a POST to an @AutoJobPostMapping endpoint
*/
@@ -45,49 +45,51 @@ public class ApiJobEndpointMatcher {
if (!"POST".equalsIgnoreCase(request.getMethod())) {
return false;
}
-
+
String path = request.getRequestURI();
-
+
// Apply exclusion rules
if (excludeActuator && path != null && path.startsWith("/actuator")) {
return false;
}
-
+
if (excludeSettings && isSettingsEndpoint(path)) {
return false;
}
-
+
// Check if the handler method has @AutoJobPostMapping annotation
return hasAutoJobPostMapping(request);
}
-
+
private boolean hasAutoJobPostMapping(HttpServletRequest request) {
try {
HandlerExecutionChain chain = handlerMapping.getHandler(request);
if (chain == null) {
return false;
}
-
+
Object handler = chain.getHandler();
if (!(handler instanceof HandlerMethod handlerMethod)) {
return false;
}
-
+
Method method = handlerMethod.getMethod();
return method.isAnnotationPresent(AutoJobPostMapping.class);
-
+
} catch (Exception e) {
- log.trace("Could not resolve handler for {}: {}", request.getRequestURI(), e.getMessage());
+ log.trace(
+ "Could not resolve handler for {}: {}",
+ request.getRequestURI(),
+ e.getMessage());
return false;
}
}
-
+
private boolean isSettingsEndpoint(String path) {
- return path != null && (
- path.contains("/settings") ||
- path.contains("/update-enable-analytics") ||
- path.contains("/config") ||
- path.contains("/preferences")
- );
+ return path != null
+ && (path.contains("/settings")
+ || path.contains("/update-enable-analytics")
+ || path.contains("/config")
+ || path.contains("/preferences"));
}
-}
\ No newline at end of file
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/AnonymousCreditUsageRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/AnonymousCreditUsageRepository.java
new file mode 100644
index 000000000..da56a49a1
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/AnonymousCreditUsageRepository.java
@@ -0,0 +1,123 @@
+package stirling.software.proprietary.security.repository;
+
+import java.time.Instant;
+import java.time.YearMonth;
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Lock;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import jakarta.persistence.LockModeType;
+
+import stirling.software.proprietary.model.AnonymousCreditUsage;
+
+@Repository
+public interface AnonymousCreditUsageRepository extends JpaRepository {
+
+ Optional findByFingerprintAndMonth(String fingerprint, YearMonth month);
+
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ Optional findByFingerprintAndMonthForUpdate(String fingerprint, YearMonth month);
+
+ @Query(
+ "SELECT u FROM AnonymousCreditUsage u WHERE u.fingerprint = :fingerprint ORDER BY u.month DESC")
+ List findByFingerprintOrderByMonthDesc(
+ @Param("fingerprint") String fingerprint);
+
+ @Query(
+ "SELECT u FROM AnonymousCreditUsage u WHERE u.ipAddress = :ipAddress AND u.month = :month")
+ List findByIpAddressAndMonth(
+ @Param("ipAddress") String ipAddress, @Param("month") YearMonth month);
+
+ @Query(
+ "SELECT u FROM AnonymousCreditUsage u WHERE u.isBlocked = true ORDER BY u.updatedAt DESC")
+ List findAllBlockedUsers();
+
+ @Query(
+ "SELECT u FROM AnonymousCreditUsage u WHERE u.abuseScore >= :threshold ORDER BY u.abuseScore DESC, u.updatedAt DESC")
+ List findHighAbuseScoreUsers(@Param("threshold") int threshold);
+
+ @Query(
+ "SELECT u FROM AnonymousCreditUsage u WHERE u.month = :month ORDER BY u.creditsConsumed DESC")
+ List findTopAnonymousConsumersByMonth(@Param("month") YearMonth month);
+
+ @Modifying
+ @Query(
+ "UPDATE AnonymousCreditUsage u SET u.isBlocked = :blocked WHERE u.fingerprint = :fingerprint")
+ int updateBlockedStatus(
+ @Param("fingerprint") String fingerprint, @Param("blocked") boolean blocked);
+
+ @Modifying
+ @Query("DELETE FROM AnonymousCreditUsage u WHERE u.month < :cutoffMonth")
+ int deleteOldRecords(@Param("cutoffMonth") YearMonth cutoffMonth);
+
+ @Query(
+ "SELECT COUNT(u) FROM AnonymousCreditUsage u WHERE u.month = :month AND u.lastAccess >= :since")
+ long countActiveAnonymousUsers(@Param("month") YearMonth month, @Param("since") Instant since);
+
+ @Query(
+ """
+ SELECT u FROM AnonymousCreditUsage u
+ WHERE u.fingerprint IN :fingerprints
+ AND u.month = :month
+ """)
+ List findRelatedFingerprints(
+ @Param("fingerprints") List fingerprints, @Param("month") YearMonth month);
+
+ default boolean consumeAnonymousCredits(
+ String fingerprint, YearMonth month, int creditCost, int monthlyCredits,
+ String ipAddress, String userAgent) {
+ for (int attempt = 0; attempt < 2; attempt++) {
+ try {
+ // Use pessimistic locking to prevent concurrent overspending
+ Optional existingUsage = findByFingerprintAndMonthForUpdate(fingerprint, month);
+
+ AnonymousCreditUsage usage =
+ existingUsage.orElseGet(
+ () -> {
+ // Create new usage record if it doesn't exist
+ AnonymousCreditUsage newUsage = AnonymousCreditUsage.builder()
+ .fingerprint(fingerprint)
+ .month(month)
+ .creditsConsumed(0)
+ .creditsAllocated(monthlyCredits)
+ .ipAddress(ipAddress)
+ .userAgent(userAgent)
+ .abuseScore(0)
+ .isBlocked(false)
+ .build();
+ return saveAndFlush(newUsage);
+ });
+
+ if (Boolean.TRUE.equals(usage.getIsBlocked())) {
+ return false;
+ }
+
+ // Check if credits are available
+ if (!usage.hasCreditsRemaining(creditCost)) {
+ return false;
+ }
+
+ // Consume credits atomically
+ usage.setCreditsConsumed(usage.getCreditsConsumed() + creditCost);
+ usage.setCreditsAllocated(monthlyCredits);
+ usage.setLastAccess(java.time.Instant.now());
+ saveAndFlush(usage);
+
+ return true;
+ } catch (org.springframework.dao.DataIntegrityViolationException e) {
+ // Another thread created/updated concurrently; retry once
+ if (attempt == 1) {
+ throw e; // Re-throw if second attempt fails
+ }
+ // Continue to retry
+ }
+ }
+ return false;
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiCreditConfigRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiCreditConfigRepository.java
new file mode 100644
index 000000000..b6532856d
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiCreditConfigRepository.java
@@ -0,0 +1,37 @@
+package stirling.software.proprietary.security.repository;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import stirling.software.proprietary.model.ApiCreditConfig;
+import stirling.software.proprietary.model.ApiCreditConfig.ScopeType;
+import stirling.software.proprietary.model.Organization;
+import stirling.software.proprietary.security.model.User;
+
+@Repository
+public interface ApiCreditConfigRepository extends JpaRepository {
+
+ Optional findByUserAndIsActiveTrue(User user);
+
+ Optional findByOrganizationAndIsActiveTrue(Organization organization);
+
+ Optional findByScopeTypeAndRoleNameAndIsActiveTrue(
+ ScopeType scopeType, String roleName);
+
+ @Query(
+ """
+ SELECT c
+ FROM ApiCreditConfig c
+ WHERE c.isActive = true
+ AND c.scopeType = stirling.software.proprietary.model.ApiCreditConfig$ScopeType.ROLE_DEFAULT
+ AND c.roleName = :roleName
+ """)
+ Optional findDefaultForRole(@Param("roleName") String roleName);
+
+ List findAllByIsActiveTrueOrderByCreatedAtDesc();
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiCreditUsageRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiCreditUsageRepository.java
new file mode 100644
index 000000000..888047e8b
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiCreditUsageRepository.java
@@ -0,0 +1,127 @@
+package stirling.software.proprietary.security.repository;
+
+import java.time.YearMonth;
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Lock;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import jakarta.persistence.LockModeType;
+
+import stirling.software.proprietary.model.ApiCreditUsage;
+import stirling.software.proprietary.model.Organization;
+import stirling.software.proprietary.security.model.User;
+
+@Repository
+public interface ApiCreditUsageRepository extends JpaRepository {
+
+ Optional findByUserAndMonthKey(User user, YearMonth monthKey);
+
+ Optional findByOrganizationAndMonthKey(
+ Organization organization, YearMonth monthKey);
+
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ Optional findByUserAndMonthKeyForUpdate(User user, YearMonth monthKey);
+
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ Optional findByOrganizationAndMonthKeyForUpdate(
+ Organization organization, YearMonth monthKey);
+
+ @Query(
+ "SELECT COALESCE(u.creditsConsumed, 0) FROM ApiCreditUsage u WHERE u.user = :user AND u.monthKey = :month")
+ int getUserCreditsConsumed(@Param("user") User user, @Param("month") YearMonth month);
+
+ @Query(
+ "SELECT COALESCE(u.creditsConsumed, 0) FROM ApiCreditUsage u WHERE u.organization = :org AND u.monthKey = :month")
+ int getOrgCreditsConsumed(@Param("org") Organization org, @Param("month") YearMonth month);
+
+ // Note: Native MySQL INSERT ON DUPLICATE KEY UPDATE method removed for database portability
+ // Use consumeUserCredits() default method instead which handles all database engines
+
+ default boolean consumeUserCredits(
+ User user, YearMonth month, int creditCost, int monthlyCredits) {
+ for (int attempt = 0; attempt < 2; attempt++) {
+ try {
+ // Use pessimistic locking to prevent concurrent overspending
+ Optional existingUsage = findByUserAndMonthKeyForUpdate(user, month);
+
+ ApiCreditUsage usage =
+ existingUsage.orElseGet(
+ () -> {
+ // Create new usage record if it doesn't exist
+ ApiCreditUsage newUsage = ApiCreditUsage.forUser(user, monthlyCredits);
+ return saveAndFlush(newUsage);
+ });
+
+ // Check if credits are available
+ if (!usage.hasCreditsRemaining(creditCost)) {
+ return false;
+ }
+
+ // Consume credits atomically
+ usage.setCreditsConsumed(usage.getCreditsConsumed() + creditCost);
+ usage.setCreditsAllocated(monthlyCredits);
+ saveAndFlush(usage);
+
+ return true;
+ } catch (org.springframework.dao.DataIntegrityViolationException e) {
+ // Another thread created/updated concurrently; retry once
+ if (attempt == 1) {
+ throw e; // Re-throw if second attempt fails
+ }
+ // Continue to retry
+ }
+ }
+ return false;
+ }
+
+ default boolean consumeOrgCredits(
+ Organization org, YearMonth month, int creditCost, int monthlyCredits) {
+ for (int attempt = 0; attempt < 2; attempt++) {
+ try {
+ // Use pessimistic locking to prevent concurrent overspending
+ Optional existingUsage = findByOrganizationAndMonthKeyForUpdate(org, month);
+
+ ApiCreditUsage usage =
+ existingUsage.orElseGet(
+ () -> {
+ // Create new usage record if it doesn't exist
+ ApiCreditUsage newUsage =
+ ApiCreditUsage.forOrganization(org, monthlyCredits);
+ return saveAndFlush(newUsage);
+ });
+
+ // Check if credits are available
+ if (!usage.hasCreditsRemaining(creditCost)) {
+ return false;
+ }
+
+ // Consume credits atomically
+ usage.setCreditsConsumed(usage.getCreditsConsumed() + creditCost);
+ usage.setCreditsAllocated(monthlyCredits);
+ saveAndFlush(usage);
+
+ return true;
+ } catch (org.springframework.dao.DataIntegrityViolationException e) {
+ // Another thread created/updated concurrently; retry once
+ if (attempt == 1) {
+ throw e; // Re-throw if second attempt fails
+ }
+ // Continue to retry
+ }
+ }
+ return false;
+ }
+
+ List findByUserOrderByMonthKeyDesc(User user);
+
+ List findByOrganizationOrderByMonthKeyDesc(Organization organization);
+
+ @Query(
+ "SELECT u FROM ApiCreditUsage u WHERE u.monthKey = :month ORDER BY u.creditsConsumed DESC")
+ List findTopConsumersByMonth(@Param("month") YearMonth month);
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/PersistentAuditEventRepository.java
similarity index 98%
rename from app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java
rename to app/proprietary/src/main/java/stirling/software/proprietary/security/repository/PersistentAuditEventRepository.java
index af6d7d554..636f23a3b 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/PersistentAuditEventRepository.java
@@ -1,4 +1,4 @@
-package stirling.software.proprietary.repository;
+package stirling.software.proprietary.security.repository;
import java.time.Instant;
import java.util.List;
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ApiCreditService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ApiCreditService.java
new file mode 100644
index 000000000..bf45cd32a
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/ApiCreditService.java
@@ -0,0 +1,763 @@
+package stirling.software.proprietary.service;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Instant;
+import java.time.YearMonth;
+import java.time.ZoneOffset;
+import java.util.Base64;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.proprietary.model.AnonymousCreditUsage;
+import stirling.software.proprietary.model.ApiCreditConfig;
+import stirling.software.proprietary.model.FailureType;
+import stirling.software.proprietary.model.Organization;
+import stirling.software.proprietary.security.repository.AnonymousCreditUsageRepository;
+import stirling.software.proprietary.security.repository.ApiCreditConfigRepository;
+import stirling.software.proprietary.security.repository.ApiCreditUsageRepository;
+import stirling.software.proprietary.security.model.User;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class ApiCreditService {
+
+ private final ApiCreditConfigRepository configRepository;
+ private final ApiCreditUsageRepository usageRepository;
+ private final AnonymousCreditUsageRepository anonymousUsageRepository;
+
+ // In-memory tracking of consecutive failures per user and anonymous users (for simple
+ // implementation)
+ // TODO: Move to Redis or database for production clustering
+ private final ConcurrentHashMap consecutiveFailures =
+ new ConcurrentHashMap<>();
+
+ @Value("${api.credit-system.anonymous.enabled:true}")
+ private boolean anonymousCreditSystemEnabled;
+
+ @Value("${api.credit-system.anonymous.monthly-credits:10}")
+ private int anonymousMonthlyCredits;
+
+ @Value("${api.credit-system.anonymous.abuse-threshold:3}")
+ private int abuseThreshold;
+
+ public record CreditStatus(
+ boolean allowed,
+ int creditsConsumed,
+ int monthlyCredits,
+ int remaining,
+ String scope,
+ String reason) {}
+
+ public record CreditMetrics(
+ int creditsConsumed,
+ int monthlyCredits,
+ int remaining,
+ String scope,
+ YearMonth month,
+ boolean isPooled) {}
+
+ // TODO: improve with Redis and async in future V2.1
+ @Transactional
+ public CreditStatus checkAndConsumeCredits(User user, int creditCost) {
+ if (user == null) {
+ return new CreditStatus(false, 0, 0, 0, "NONE", "No user provided");
+ }
+
+ Organization org = user.getOrganization();
+ String roleName = user.getRoleName();
+ YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
+
+ Optional configOpt = resolveEffectiveConfig(user, org, roleName);
+
+ if (configOpt.isEmpty()) {
+ log.warn(
+ "No credit config found for user: {}, org: {}, role: {}",
+ user.getUsername(),
+ org != null ? org.getName() : "null",
+ roleName);
+ return new CreditStatus(
+ true,
+ 0,
+ Integer.MAX_VALUE,
+ Integer.MAX_VALUE,
+ "UNLIMITED",
+ "No credit limit configured");
+ }
+
+ ApiCreditConfig config = configOpt.get();
+
+ if (!config.getIsActive()) {
+ return new CreditStatus(
+ true,
+ 0,
+ Integer.MAX_VALUE,
+ Integer.MAX_VALUE,
+ "DISABLED",
+ "Credit system disabled");
+ }
+
+ String scope = determineScope(config);
+ int monthlyCredits = config.getMonthlyCredits();
+
+ boolean success;
+ int currentCreditsConsumed;
+
+ switch (config.getScopeType()) {
+ case USER -> {
+ success =
+ usageRepository.consumeUserCredits(
+ user, currentMonth, creditCost, monthlyCredits);
+ currentCreditsConsumed = usageRepository.getUserCreditsConsumed(user, currentMonth);
+ }
+ case ORGANIZATION -> {
+ if (org == null) {
+ return new CreditStatus(
+ false,
+ 0,
+ 0,
+ 0,
+ scope,
+ "User has no organization but org-level limit is configured");
+ }
+ boolean pooled = Boolean.TRUE.equals(config.getIsPooled());
+ if (pooled) {
+ success =
+ usageRepository.consumeOrgCredits(
+ org, currentMonth, creditCost, monthlyCredits);
+ currentCreditsConsumed =
+ usageRepository.getOrgCreditsConsumed(org, currentMonth);
+ } else {
+ success =
+ usageRepository.consumeUserCredits(
+ user, currentMonth, creditCost, monthlyCredits);
+ currentCreditsConsumed =
+ usageRepository.getUserCreditsConsumed(user, currentMonth);
+ }
+ }
+ case ROLE_DEFAULT -> {
+ // Role defaults are per-user (not pooled)
+ success =
+ usageRepository.consumeUserCredits(
+ user, currentMonth, creditCost, monthlyCredits);
+ currentCreditsConsumed = usageRepository.getUserCreditsConsumed(user, currentMonth);
+ }
+ default -> {
+ log.error("Unexpected scope type: {}", config.getScopeType());
+ return new CreditStatus(false, 0, 0, 0, scope, "Invalid configuration");
+ }
+ }
+
+ int remaining = Math.max(0, monthlyCredits - currentCreditsConsumed);
+
+ if (!success) {
+ return new CreditStatus(
+ false,
+ currentCreditsConsumed,
+ monthlyCredits,
+ remaining,
+ scope,
+ String.format(
+ "Monthly credit limit of %d would be exceeded. "
+ + "Current consumption: %d, requested: %d",
+ monthlyCredits, currentCreditsConsumed, creditCost));
+ }
+
+ return new CreditStatus(
+ true, currentCreditsConsumed, monthlyCredits, remaining, scope, "Success");
+ }
+
+ @Transactional
+ public CreditStatus checkAndConsumeAnonymousCredits(
+ String ipAddress, String userAgent, int creditCost) {
+ if (!anonymousCreditSystemEnabled) {
+ return new CreditStatus(
+ true,
+ 0,
+ Integer.MAX_VALUE,
+ Integer.MAX_VALUE,
+ "DISABLED",
+ "Anonymous credit system disabled");
+ }
+
+ String fingerprint = generateFingerprint(ipAddress, userAgent);
+ YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
+
+ // Try to consume credits atomically
+ boolean success = anonymousUsageRepository.consumeAnonymousCredits(
+ fingerprint, currentMonth, creditCost, anonymousMonthlyCredits, ipAddress, userAgent);
+
+ // Get current usage state for response
+ Optional usageOpt =
+ anonymousUsageRepository.findByFingerprintAndMonth(fingerprint, currentMonth);
+
+ if (usageOpt.isEmpty()) {
+ // This shouldn't happen but handle gracefully
+ return new CreditStatus(false, 0, anonymousMonthlyCredits, anonymousMonthlyCredits, "ANONYMOUS", "Unknown error");
+ }
+
+ AnonymousCreditUsage usage = usageOpt.get();
+
+ if (Boolean.TRUE.equals(usage.getIsBlocked())) {
+ return new CreditStatus(
+ false,
+ usage.getCreditsConsumed(),
+ usage.getCreditsAllocated(),
+ usage.getRemainingCredits(),
+ "ANONYMOUS",
+ "IP address is blocked due to abuse");
+ }
+
+ if (!success) {
+ // Credit consumption failed - handle abuse scoring
+ usage.setAbuseScore(usage.getAbuseScore() + 1);
+ if (usage.getAbuseScore() >= abuseThreshold) {
+ usage.setIsBlocked(true);
+ log.warn(
+ "Blocking anonymous user {} due to abuse score: {}",
+ fingerprint,
+ usage.getAbuseScore());
+ }
+ anonymousUsageRepository.save(usage);
+
+ return new CreditStatus(
+ false,
+ usage.getCreditsConsumed(),
+ usage.getCreditsAllocated(),
+ usage.getRemainingCredits(),
+ "ANONYMOUS",
+ String.format(
+ "Monthly credit limit of %d exceeded. Current consumption: %d",
+ usage.getCreditsAllocated(), usage.getCreditsConsumed()));
+ }
+
+ // Success - return updated metrics
+ return new CreditStatus(
+ true,
+ usage.getCreditsConsumed(),
+ usage.getCreditsAllocated(),
+ usage.getRemainingCredits(),
+ "ANONYMOUS",
+ "Success");
+ }
+
+ public Optional resolveEffectiveConfig(
+ User user, Organization org, String roleName) {
+ // 1. User-specific config (highest priority)
+ Optional userConfig = configRepository.findByUserAndIsActiveTrue(user);
+ if (userConfig.isPresent()) {
+ return userConfig;
+ }
+
+ // 2. Organization config (if user belongs to org)
+ if (org != null) {
+ Optional orgConfig =
+ configRepository.findByOrganizationAndIsActiveTrue(org);
+ if (orgConfig.isPresent()) {
+ return orgConfig;
+ }
+ }
+
+ // 3. Role default config (lowest priority)
+ return configRepository.findDefaultForRole(roleName);
+ }
+
+ public void createOrUpdateRoleDefault(String roleName, int monthlyCredits) {
+ Optional existing = configRepository.findDefaultForRole(roleName);
+
+ if (existing.isPresent()) {
+ ApiCreditConfig config = existing.get();
+ config.setMonthlyCredits(monthlyCredits);
+ configRepository.save(config);
+ } else {
+ ApiCreditConfig newConfig =
+ ApiCreditConfig.builder()
+ .scopeType(ApiCreditConfig.ScopeType.ROLE_DEFAULT)
+ .roleName(roleName)
+ .monthlyCredits(monthlyCredits)
+ .isPooled(false)
+ .isActive(true)
+ .build();
+ configRepository.save(newConfig);
+ }
+ }
+
+ public void createUserCreditConfig(User user, int monthlyCredits, boolean isActive) {
+ // Check if user already has a config
+ Optional existing = configRepository.findByUserAndIsActiveTrue(user);
+ if (existing.isPresent()) {
+ throw new RuntimeException("User already has a credit configuration");
+ }
+
+ ApiCreditConfig newConfig =
+ ApiCreditConfig.builder()
+ .scopeType(ApiCreditConfig.ScopeType.USER)
+ .user(user)
+ .monthlyCredits(monthlyCredits)
+ .isPooled(false)
+ .isActive(isActive)
+ .build();
+ configRepository.save(newConfig);
+ }
+
+ public void createOrganizationCreditConfig(Organization org, int monthlyCredits, boolean isPooled, boolean isActive) {
+ // Check if organization already has a config
+ Optional existing = configRepository.findByOrganizationAndIsActiveTrue(org);
+ if (existing.isPresent()) {
+ throw new RuntimeException("Organization already has a credit configuration");
+ }
+
+ ApiCreditConfig newConfig =
+ ApiCreditConfig.builder()
+ .scopeType(ApiCreditConfig.ScopeType.ORGANIZATION)
+ .organization(org)
+ .monthlyCredits(monthlyCredits)
+ .isPooled(isPooled)
+ .isActive(isActive)
+ .build();
+ configRepository.save(newConfig);
+ }
+
+ private String determineScope(ApiCreditConfig config) {
+ return switch (config.getScopeType()) {
+ case USER ->
+ "USER:"
+ + (config.getUser() != null
+ ? config.getUser().getUsername()
+ : "unknown");
+ case ORGANIZATION ->
+ "ORG:"
+ + (config.getOrganization() != null
+ ? config.getOrganization().getName()
+ : "unknown")
+ + (Boolean.TRUE.equals(config.getIsPooled())
+ ? ":POOLED"
+ : ":INDIVIDUAL");
+ case ROLE_DEFAULT -> "ROLE:" + config.getRoleName();
+ };
+ }
+
+ private String generateFingerprint(String ipAddress, String userAgent) {
+ try {
+ String input = ipAddress + ":" + (userAgent != null ? userAgent : "");
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] hash = md.digest(input.getBytes());
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
+ } catch (NoSuchAlgorithmException e) {
+ log.error("SHA-256 not available", e);
+ return ipAddress; // Fallback to IP address
+ }
+ }
+
+ public CreditMetrics getUserCreditMetrics(User user) {
+ YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
+ Organization org = user.getOrganization();
+ String roleName = user.getRoleName();
+
+ Optional configOpt = resolveEffectiveConfig(user, org, roleName);
+ if (configOpt.isEmpty()) {
+ return new CreditMetrics(
+ 0, Integer.MAX_VALUE, Integer.MAX_VALUE, "UNLIMITED", currentMonth, false);
+ }
+
+ ApiCreditConfig config = configOpt.get();
+ if (!config.getIsActive()) {
+ return new CreditMetrics(
+ 0, Integer.MAX_VALUE, Integer.MAX_VALUE, "DISABLED", currentMonth, false);
+ }
+
+ String scope = determineScope(config);
+ int monthlyCredits = config.getMonthlyCredits();
+ int creditsConsumed;
+ boolean isPooled = false;
+
+ switch (config.getScopeType()) {
+ case USER ->
+ creditsConsumed = usageRepository.getUserCreditsConsumed(user, currentMonth);
+ case ORGANIZATION -> {
+ isPooled = Boolean.TRUE.equals(config.getIsPooled());
+ if (isPooled && org != null) {
+ creditsConsumed = usageRepository.getOrgCreditsConsumed(org, currentMonth);
+ } else {
+ creditsConsumed = usageRepository.getUserCreditsConsumed(user, currentMonth);
+ }
+ }
+ case ROLE_DEFAULT -> {
+ // Role defaults are per-user (not pooled)
+ creditsConsumed = usageRepository.getUserCreditsConsumed(user, currentMonth);
+ }
+ default -> creditsConsumed = 0;
+ }
+
+ int remaining = Math.max(0, monthlyCredits - creditsConsumed);
+
+ return new CreditMetrics(
+ creditsConsumed, monthlyCredits, remaining, scope, currentMonth, isPooled);
+ }
+
+ // Methods for handling consecutive failure tracking
+ private int incrementConsecutiveFailures(User user) {
+ String userKey = getUserKey(user);
+ return consecutiveFailures.compute(
+ userKey, (key, value) -> (value == null) ? 1 : value + 1);
+ }
+
+ private void resetConsecutiveFailures(User user) {
+ String userKey = getUserKey(user);
+ consecutiveFailures.remove(userKey);
+ }
+
+ private String getUserKey(User user) {
+ return "user:" + user.getId();
+ }
+
+ private String getAnonymousKey(String ipAddress, String userAgent) {
+ return "anon:" + generateFingerprint(ipAddress, userAgent);
+ }
+
+ private int incrementConsecutiveFailures(String ipAddress, String userAgent) {
+ String anonymousKey = getAnonymousKey(ipAddress, userAgent);
+ return consecutiveFailures.compute(
+ anonymousKey, (key, value) -> (value == null) ? 1 : value + 1);
+ }
+
+ private void resetConsecutiveFailures(String ipAddress, String userAgent) {
+ String anonymousKey = getAnonymousKey(ipAddress, userAgent);
+ consecutiveFailures.remove(anonymousKey);
+ }
+
+ // Enhanced methods for handling failure-based charging
+ @Transactional
+ public CreditStatus preCheckCredits(User user, int creditCost) {
+ // Only check if credits are available, don't consume yet
+ return checkCreditsAvailability(user, creditCost);
+ }
+
+ @Transactional
+ public CreditStatus preCheckAnonymousCredits(
+ String ipAddress, String userAgent, int creditCost) {
+ if (!anonymousCreditSystemEnabled) {
+ return new CreditStatus(
+ true,
+ 0,
+ Integer.MAX_VALUE,
+ Integer.MAX_VALUE,
+ "DISABLED",
+ "Anonymous credit system disabled");
+ }
+
+ return checkAnonymousCreditsAvailability(ipAddress, userAgent, creditCost);
+ }
+
+ @Transactional
+ public CreditStatus recordRequestOutcome(User user, int creditCost, FailureType outcome) {
+ switch (outcome) {
+ case SUCCESS -> {
+ // API succeeded, consume credits and reset failure counter
+ CreditStatus chargeResult = checkAndConsumeCredits(user, creditCost);
+ resetConsecutiveFailures(user);
+ if (chargeResult.allowed()) {
+ log.debug(
+ "User {} charged {} credits for successful API call",
+ user.getUsername(),
+ creditCost);
+ } else {
+ log.warn(
+ "Failed to charge user {} {} credits on successful API call: {}",
+ user.getUsername(),
+ creditCost,
+ chargeResult.reason());
+ }
+ return chargeResult;
+ }
+ case CLIENT_ERROR -> {
+ // Client error: no credit charge, no failure count increment
+ log.debug("User {} not charged for client error (4xx)", user.getUsername());
+ return checkCreditsAvailability(user, creditCost);
+ }
+ case PROCESSING_ERROR -> {
+ // Processing error: no immediate charge, but count toward consecutive failures
+ int consecutiveCount = incrementConsecutiveFailures(user);
+ if (consecutiveCount >= 3) {
+ // Charge full credit cost after 3 consecutive processing failures
+ CreditStatus chargeResult = checkAndConsumeCredits(user, creditCost);
+ resetConsecutiveFailures(user);
+ if (chargeResult.allowed()) {
+ log.warn(
+ "User {} charged {} credits after {} consecutive processing failures",
+ user.getUsername(),
+ creditCost,
+ consecutiveCount);
+ } else {
+ log.error(
+ "Failed to charge user {} {} credits after {} consecutive failures: {}",
+ user.getUsername(),
+ creditCost,
+ consecutiveCount,
+ chargeResult.reason());
+ }
+ return chargeResult;
+ } else {
+ log.debug(
+ "User {} not charged for processing failure #{}",
+ user.getUsername(),
+ consecutiveCount);
+ return checkCreditsAvailability(user, creditCost);
+ }
+ }
+ }
+ return null; // Should never reach here
+ }
+
+ @Transactional
+ public void recordAnonymousRequestOutcome(
+ String ipAddress, String userAgent, int creditCost, FailureType outcome) {
+ switch (outcome) {
+ case SUCCESS -> {
+ // API succeeded, consume credits and reset failure counter
+ CreditStatus chargeResult =
+ checkAndConsumeAnonymousCredits(ipAddress, userAgent, creditCost);
+ resetConsecutiveFailures(ipAddress, userAgent);
+ if (chargeResult.allowed()) {
+ log.debug(
+ "Anonymous user {} charged {} credits for successful API call",
+ ipAddress,
+ creditCost);
+ } else {
+ log.warn(
+ "Failed to charge anonymous user {} {} credits on successful API call: {}",
+ ipAddress,
+ creditCost,
+ chargeResult.reason());
+ }
+ }
+ case CLIENT_ERROR -> {
+ // Client error: no credit charge, no failure count increment
+ log.debug("Anonymous user {} not charged for client error (4xx)", ipAddress);
+ }
+ case PROCESSING_ERROR -> {
+ // Processing error: no immediate charge, but count toward consecutive failures
+ int consecutiveCount = incrementConsecutiveFailures(ipAddress, userAgent);
+ if (consecutiveCount >= 3) {
+ // Charge full credit cost after 3 consecutive processing failures
+ CreditStatus chargeResult =
+ checkAndConsumeAnonymousCredits(ipAddress, userAgent, creditCost);
+ resetConsecutiveFailures(ipAddress, userAgent);
+ if (chargeResult.allowed()) {
+ log.warn(
+ "Anonymous user {} charged {} credits after {} consecutive processing failures",
+ ipAddress,
+ creditCost,
+ consecutiveCount);
+ } else {
+ log.error(
+ "Failed to charge anonymous user {} {} credits after {} consecutive failures: {}",
+ ipAddress,
+ creditCost,
+ consecutiveCount,
+ chargeResult.reason());
+ }
+ } else {
+ log.debug(
+ "Anonymous user {} not charged for processing failure #{}",
+ ipAddress,
+ consecutiveCount);
+ }
+ }
+ }
+ }
+
+ /** Determine failure type based on HTTP status code and exception type */
+ public static FailureType determineFailureType(int httpStatusCode, Throwable exception) {
+ // Check exception first - any exception means processing error regardless of status code
+ if (exception != null) {
+ String exceptionName = exception.getClass().getSimpleName().toLowerCase();
+
+ // Client error indicators in exceptions
+ if (exceptionName.contains("validation")
+ || exceptionName.contains("badrequest")
+ || exceptionName.contains("illegalargument")
+ || exceptionName.contains("missingparam")
+ || exceptionName.contains("unauthorized")
+ || exceptionName.contains("forbidden")) {
+ return FailureType.CLIENT_ERROR;
+ }
+
+ // All other exceptions are processing errors
+ return FailureType.PROCESSING_ERROR;
+ }
+
+ // No exception - check HTTP status code
+ if (httpStatusCode >= 200 && httpStatusCode < 300) {
+ return FailureType.SUCCESS;
+ }
+
+ // Client error cases (4xx) - don't count toward failures
+ if (httpStatusCode >= 400 && httpStatusCode < 500) {
+ return FailureType.CLIENT_ERROR;
+ }
+
+ // Server errors (5xx) - count toward consecutive failures
+ if (httpStatusCode >= 500) {
+ return FailureType.PROCESSING_ERROR;
+ }
+
+ // Default to processing error for unknown cases to be safe
+ return FailureType.PROCESSING_ERROR;
+ }
+
+ private CreditStatus checkCreditsAvailability(User user, int creditCost) {
+ // This is similar to checkAndConsumeCredits but doesn't actually consume
+ if (user == null) {
+ return new CreditStatus(false, 0, 0, 0, "NONE", "No user provided");
+ }
+
+ Organization org = user.getOrganization();
+ String roleName = user.getRoleName();
+ YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
+
+ Optional configOpt = resolveEffectiveConfig(user, org, roleName);
+
+ if (configOpt.isEmpty()) {
+ return new CreditStatus(
+ true,
+ 0,
+ Integer.MAX_VALUE,
+ Integer.MAX_VALUE,
+ "UNLIMITED",
+ "No credit limit configured");
+ }
+
+ ApiCreditConfig config = configOpt.get();
+
+ if (!config.getIsActive()) {
+ return new CreditStatus(
+ true,
+ 0,
+ Integer.MAX_VALUE,
+ Integer.MAX_VALUE,
+ "DISABLED",
+ "Credit system disabled");
+ }
+
+ String scope = determineScope(config);
+ int monthlyCredits = config.getMonthlyCredits();
+ int currentCreditsConsumed;
+
+ switch (config.getScopeType()) {
+ case USER -> {
+ currentCreditsConsumed = usageRepository.getUserCreditsConsumed(user, currentMonth);
+ }
+ case ORGANIZATION -> {
+ if (org == null) {
+ return new CreditStatus(
+ false,
+ 0,
+ 0,
+ 0,
+ scope,
+ "User has no organization but org-level limit is configured");
+ }
+ boolean pooled = Boolean.TRUE.equals(config.getIsPooled());
+ if (pooled) {
+ currentCreditsConsumed =
+ usageRepository.getOrgCreditsConsumed(org, currentMonth);
+ } else {
+ currentCreditsConsumed =
+ usageRepository.getUserCreditsConsumed(user, currentMonth);
+ }
+ }
+ case ROLE_DEFAULT -> {
+ // Role defaults are per-user (not pooled)
+ currentCreditsConsumed = usageRepository.getUserCreditsConsumed(user, currentMonth);
+ }
+ default -> {
+ return new CreditStatus(false, 0, 0, 0, scope, "Invalid configuration");
+ }
+ }
+
+ int remaining = Math.max(0, monthlyCredits - currentCreditsConsumed);
+ boolean hasEnoughCredits = remaining >= creditCost;
+
+ if (!hasEnoughCredits) {
+ return new CreditStatus(
+ false,
+ currentCreditsConsumed,
+ monthlyCredits,
+ remaining,
+ scope,
+ String.format(
+ "Insufficient credits. Required: %d, Available: %d",
+ creditCost, remaining));
+ }
+
+ return new CreditStatus(
+ true,
+ currentCreditsConsumed,
+ monthlyCredits,
+ remaining,
+ scope,
+ "Credits available");
+ }
+
+ private CreditStatus checkAnonymousCreditsAvailability(
+ String ipAddress, String userAgent, int creditCost) {
+ String fingerprint = generateFingerprint(ipAddress, userAgent);
+ YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
+
+ // Get existing usage record
+ Optional existingUsage =
+ anonymousUsageRepository.findByFingerprintAndMonth(fingerprint, currentMonth);
+
+ AnonymousCreditUsage usage =
+ existingUsage.orElse(
+ AnonymousCreditUsage.builder()
+ .fingerprint(fingerprint)
+ .month(currentMonth)
+ .creditsConsumed(0)
+ .creditsAllocated(anonymousMonthlyCredits)
+ .ipAddress(ipAddress)
+ .userAgent(userAgent)
+ .abuseScore(0)
+ .isBlocked(false)
+ .build());
+
+ if (Boolean.TRUE.equals(usage.getIsBlocked())) {
+ return new CreditStatus(
+ false,
+ usage.getCreditsConsumed(),
+ usage.getCreditsAllocated(),
+ usage.getRemainingCredits(),
+ "ANONYMOUS",
+ "IP address is blocked due to abuse");
+ }
+
+ if (!usage.hasCreditsRemaining(creditCost)) {
+ return new CreditStatus(
+ false,
+ usage.getCreditsConsumed(),
+ usage.getCreditsAllocated(),
+ usage.getRemainingCredits(),
+ "ANONYMOUS",
+ String.format(
+ "Insufficient credits. Required: %d, Available: %d",
+ creditCost, usage.getRemainingCredits()));
+ }
+
+ return new CreditStatus(
+ true,
+ usage.getCreditsConsumed(),
+ usage.getCreditsAllocated(),
+ usage.getRemainingCredits(),
+ "ANONYMOUS",
+ "Credits available");
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ApiRateLimitService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ApiRateLimitService.java
deleted file mode 100644
index b0069f8cd..000000000
--- a/app/proprietary/src/main/java/stirling/software/proprietary/service/ApiRateLimitService.java
+++ /dev/null
@@ -1,366 +0,0 @@
-package stirling.software.proprietary.service;
-
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.time.Instant;
-import java.time.YearMonth;
-import java.time.ZoneOffset;
-import java.util.Base64;
-import java.util.List;
-import java.util.Optional;
-
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-
-import stirling.software.common.model.enumeration.Role;
-import stirling.software.proprietary.model.AnonymousApiUsage;
-import stirling.software.proprietary.model.ApiRateLimitConfig;
-import stirling.software.proprietary.model.ApiRateLimitUsage;
-import stirling.software.proprietary.model.Organization;
-import stirling.software.proprietary.repository.AnonymousApiUsageRepository;
-import stirling.software.proprietary.repository.ApiRateLimitConfigRepository;
-import stirling.software.proprietary.repository.ApiRateLimitUsageRepository;
-import stirling.software.proprietary.security.model.User;
-
-@Service
-@RequiredArgsConstructor
-@Slf4j
-public class ApiRateLimitService {
-
- private final ApiRateLimitConfigRepository configRepository;
- private final ApiRateLimitUsageRepository usageRepository;
- private final AnonymousApiUsageRepository anonymousUsageRepository;
-
- @Value("${api.rate-limit.anonymous.enabled:true}")
- private boolean anonymousRateLimitEnabled;
-
- @Value("${api.rate-limit.anonymous.monthly-limit:10}")
- private int anonymousMonthlyLimit;
-
- @Value("${api.rate-limit.anonymous.abuse-threshold:3}")
- private int abuseThreshold;
-
- public record RateLimitStatus(
- boolean allowed,
- int currentUsage,
- int monthlyLimit,
- int remaining,
- String scope,
- String reason
- ) {}
-
- public record UsageMetrics(
- int currentUsage,
- int monthlyLimit,
- int remaining,
- String scope,
- YearMonth month,
- boolean isPooled
- ) {}
-
- // TODO: improve with Redis and async in future V2.1
- @Transactional
- public RateLimitStatus checkAndIncrementUsage(User user) {
- if (user == null) {
- return new RateLimitStatus(false, 0, 0, 0, "NONE", "No user provided");
- }
-
- Organization org = user.getOrganization();
- String roleName = user.getUserRole().getRoleId();
- YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
- String monthKey = currentMonth.toString();
-
- Optional configOpt = resolveEffectiveConfig(user, org, roleName);
-
- if (configOpt.isEmpty()) {
- log.warn("No rate limit config found for user: {}, org: {}, role: {}",
- user.getUsername(), org != null ? org.getName() : "null", roleName);
- return new RateLimitStatus(true, 0, Integer.MAX_VALUE, Integer.MAX_VALUE,
- "UNLIMITED", "No rate limit configured");
- }
-
- ApiRateLimitConfig config = configOpt.get();
-
- if (!config.getIsActive()) {
- return new RateLimitStatus(true, 0, Integer.MAX_VALUE, Integer.MAX_VALUE,
- "DISABLED", "Rate limiting disabled");
- }
-
- String scope = determineScope(config);
- int monthlyLimit = config.getMonthlyLimit();
-
- boolean success;
- int currentUsage;
-
- switch (config.getScopeType()) {
- case USER -> {
- success = usageRepository.upsertAndIncrementUserUsage(
- user, monthKey, 1, monthlyLimit) > 0;
- currentUsage = usageRepository.getUserUsageOrZero(user, currentMonth);
- }
- case ORGANIZATION -> {
- if (org == null) {
- return new RateLimitStatus(false, 0, 0, 0, scope,
- "User has no organization but org-level limit is configured");
- }
- boolean pooled = Boolean.TRUE.equals(config.getIsPooled());
- if (pooled) {
- success = usageRepository.upsertAndIncrementOrgUsage(
- org, monthKey, 1, monthlyLimit) > 0;
- currentUsage = usageRepository.getOrgUsageOrZero(org, currentMonth);
- scope = "ORG:" + org.getName() + " (pooled)";
- } else {
- // per-user limit defined by org policy
- success = usageRepository.upsertAndIncrementUserUsage(
- user, monthKey, 1, monthlyLimit) > 0;
- currentUsage = usageRepository.getUserUsageOrZero(user, currentMonth);
- scope = "ORG:" + org.getName() + " (per-user)";
- }
- }
- case ROLE_DEFAULT -> {
- success = usageRepository.upsertAndIncrementUserUsage(
- user, monthKey, 1, monthlyLimit) > 0;
- currentUsage = usageRepository.getUserUsageOrZero(user, currentMonth);
- }
- default -> {
- log.error("Unknown scope type: {}", config.getScopeType());
- return new RateLimitStatus(false, 0, 0, 0, scope, "Invalid configuration");
- }
- }
-
- int remaining = Math.max(0, monthlyLimit - currentUsage);
-
- if (!success) {
- return new RateLimitStatus(false, currentUsage, monthlyLimit, 0, scope,
- "Monthly limit exceeded");
- }
-
- return new RateLimitStatus(true, currentUsage, monthlyLimit, remaining, scope, "OK");
- }
-
- @Transactional(readOnly = true)
- public UsageMetrics getUsageMetrics(User user) {
- if (user == null) {
- return new UsageMetrics(0, Integer.MAX_VALUE, Integer.MAX_VALUE,
- "NONE", YearMonth.now(ZoneOffset.UTC), false);
- }
-
- Organization org = user.getOrganization();
- String roleName = user.getUserRole().getRoleId();
- YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
-
- Optional configOpt = resolveEffectiveConfig(user, org, roleName);
-
- if (configOpt.isEmpty() || !configOpt.get().getIsActive()) {
- return new UsageMetrics(0, Integer.MAX_VALUE, Integer.MAX_VALUE,
- "UNLIMITED", currentMonth, false);
- }
-
- ApiRateLimitConfig config = configOpt.get();
- String scope = determineScope(config);
- int monthlyLimit = config.getMonthlyLimit();
- boolean isPooled = Boolean.TRUE.equals(config.getIsPooled());
-
- int currentUsage = switch (config.getScopeType()) {
- case USER, ROLE_DEFAULT -> usageRepository.getUserUsageOrZero(user, currentMonth);
- case ORGANIZATION -> {
- if (org != null && isPooled) {
- yield usageRepository.getOrgUsageOrZero(org, currentMonth);
- } else if (org != null) {
- yield usageRepository.getUserUsageOrZero(user, currentMonth);
- } else {
- yield 0;
- }
- }
- };
-
- int remaining = Math.max(0, monthlyLimit - currentUsage);
-
- return new UsageMetrics(currentUsage, monthlyLimit, remaining, scope, currentMonth, isPooled);
- }
-
- @Transactional
- public ApiRateLimitConfig createOrUpdateUserLimit(User user, int monthlyLimit) {
- ApiRateLimitConfig config = configRepository.findByUserAndIsActiveTrue(user)
- .orElse(ApiRateLimitConfig.builder()
- .scopeType(ApiRateLimitConfig.ScopeType.USER)
- .user(user)
- .build());
-
- config.setMonthlyLimit(monthlyLimit);
- config.setIsActive(true);
- config.setIsPooled(false);
-
- return configRepository.save(config);
- }
-
- @Transactional
- public ApiRateLimitConfig createOrUpdateOrgLimit(Organization org, int monthlyLimit, boolean isPooled) {
- ApiRateLimitConfig config = configRepository.findByOrganizationAndIsActiveTrue(org)
- .orElse(ApiRateLimitConfig.builder()
- .scopeType(ApiRateLimitConfig.ScopeType.ORGANIZATION)
- .organization(org)
- .build());
-
- config.setMonthlyLimit(monthlyLimit);
- config.setIsActive(true);
- config.setIsPooled(isPooled);
-
- return configRepository.save(config);
- }
-
- @Transactional
- public ApiRateLimitConfig createOrUpdateRoleDefault(String roleName, int monthlyLimit) {
- ApiRateLimitConfig config = configRepository.findDefaultForRole(roleName)
- .orElse(ApiRateLimitConfig.builder()
- .scopeType(ApiRateLimitConfig.ScopeType.ROLE_DEFAULT)
- .roleName(roleName)
- .build());
-
- config.setMonthlyLimit(monthlyLimit);
- config.setIsActive(true);
- config.setIsPooled(false);
-
- return configRepository.save(config);
- }
-
- private String determineScope(ApiRateLimitConfig config) {
- return switch (config.getScopeType()) {
- case USER -> "USER:" + config.getUser().getUsername();
- case ORGANIZATION -> "ORG:" + config.getOrganization().getName() +
- (Boolean.TRUE.equals(config.getIsPooled()) ? " (pooled)" : "");
- case ROLE_DEFAULT -> "ROLE:" + config.getRoleName();
- };
- }
-
- @Transactional
- public RateLimitStatus checkAndIncrementAnonymousUsage(String ipAddress, String userAgent) {
- if (!anonymousRateLimitEnabled) {
- return new RateLimitStatus(true, 0, Integer.MAX_VALUE, Integer.MAX_VALUE,
- "ANONYMOUS_DISABLED", "Anonymous rate limiting disabled");
- }
-
- YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
- String fingerprint = generateFingerprint(ipAddress, userAgent);
-
- // Check for abuse patterns
- if (detectAbuse(fingerprint, ipAddress, currentMonth)) {
- log.warn("Abuse detected for anonymous user - IP: {}, Fingerprint: {}", ipAddress, fingerprint);
- return new RateLimitStatus(false, 0, 0, 0, "ANONYMOUS_BLOCKED",
- "Access blocked due to suspicious activity");
- }
-
- // Atomic upsert and increment operation that returns the new count
- Integer newCount = anonymousUsageRepository.upsertAndIncrementReturningCount(
- fingerprint, currentMonth.toString(), ipAddress, userAgent, anonymousMonthlyLimit);
-
- if (newCount == null) {
- // Limit exceeded (WHERE clause prevented update)
- return new RateLimitStatus(false, anonymousMonthlyLimit, anonymousMonthlyLimit, 0,
- "ANONYMOUS", "Monthly limit exceeded for anonymous access");
- }
-
- int remaining = Math.max(0, anonymousMonthlyLimit - newCount);
- return new RateLimitStatus(true, newCount, anonymousMonthlyLimit, remaining,
- "ANONYMOUS", "OK");
- }
-
- private boolean detectAbuse(String fingerprint, String ipAddress, YearMonth month) {
- // Check if fingerprint is already blocked
- Optional blockedUsage = anonymousUsageRepository
- .findByFingerprintAndMonth(fingerprint, month);
- if (blockedUsage.isPresent() && Boolean.TRUE.equals(blockedUsage.get().getIsBlocked())) {
- return true;
- }
-
- // Check for multiple fingerprints from same IP (credential changing)
- Long distinctFingerprints = anonymousUsageRepository
- .countDistinctFingerprintsForIp(ipAddress, month);
-
- if (distinctFingerprints > abuseThreshold) {
- // Block all fingerprints associated with this IP
- List ipUsages = anonymousUsageRepository
- .findByIpAddressAndMonth(ipAddress, month);
- for (AnonymousApiUsage ipUsage : ipUsages) {
- ipUsage.setIsBlocked(true);
- ipUsage.setAbuseScore(ipUsage.getAbuseScore() + 10);
- // Link fingerprints as related
- ipUsage.getRelatedFingerprints().add(fingerprint);
- }
- anonymousUsageRepository.saveAll(ipUsages);
- return true;
- }
-
- // Check total usage across all fingerprints for this IP
- Integer totalIpUsage = anonymousUsageRepository
- .getTotalUsageByIpAndMonth(ipAddress, month);
- if (totalIpUsage > anonymousMonthlyLimit * 2) {
- // Excessive usage from single IP
- return true;
- }
-
- return false;
- }
-
- private String generateFingerprint(String ipAddress, String userAgent) {
- try {
- MessageDigest md = MessageDigest.getInstance("SHA-256");
- String data = ipAddress + "|" + (userAgent != null ? userAgent : "unknown");
- byte[] hash = md.digest(data.getBytes());
- return Base64.getEncoder().encodeToString(hash);
- } catch (NoSuchAlgorithmException e) {
- log.error("Failed to generate fingerprint", e);
- return ipAddress; // Fallback to IP
- }
- }
-
- public int getAnonymousMonthlyLimit() {
- return anonymousMonthlyLimit;
- }
-
- public UsageMetrics getAnonymousUsageMetrics(String ipAddress, String userAgent) {
- if (!anonymousRateLimitEnabled) {
- return new UsageMetrics(0, Integer.MAX_VALUE, Integer.MAX_VALUE,
- "ANONYMOUS_DISABLED", YearMonth.now(ZoneOffset.UTC), false);
- }
-
- YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
- String fingerprint = generateFingerprint(ipAddress, userAgent);
-
- Optional usageOpt = anonymousUsageRepository
- .findByFingerprintAndMonth(fingerprint, currentMonth);
-
- if (usageOpt.isEmpty()) {
- return new UsageMetrics(0, anonymousMonthlyLimit, anonymousMonthlyLimit,
- "ANONYMOUS", currentMonth, false);
- }
-
- AnonymousApiUsage usage = usageOpt.get();
- int remaining = Math.max(0, anonymousMonthlyLimit - usage.getUsageCount());
-
- return new UsageMetrics(usage.getUsageCount(), anonymousMonthlyLimit, remaining,
- "ANONYMOUS", currentMonth, false);
- }
-
- private Optional resolveEffectiveConfig(User user, Organization org, String roleName) {
- // Priority: User > Organization > Role
- Optional userConfig = configRepository.findByUserAndIsActiveTrue(user);
- if (userConfig.isPresent()) {
- return userConfig;
- }
-
- if (org != null) {
- Optional orgConfig = configRepository.findByOrganizationAndIsActiveTrue(org);
- if (orgConfig.isPresent()) {
- return orgConfig;
- }
- }
-
- return configRepository.findByScopeTypeAndRoleNameAndIsActiveTrue(
- ApiRateLimitConfig.ScopeType.ROLE_DEFAULT, roleName);
- }
-}
\ No newline at end of file
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java
index 8a70a1b7a..e96f90b0a 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java
@@ -15,7 +15,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.config.AuditConfigurationProperties;
-import stirling.software.proprietary.repository.PersistentAuditEventRepository;
+import stirling.software.proprietary.security.repository.PersistentAuditEventRepository;
/** Service to periodically clean up old audit events based on retention policy. */
@Slf4j
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/CreditContextManager.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/CreditContextManager.java
new file mode 100644
index 000000000..4bda2936d
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/CreditContextManager.java
@@ -0,0 +1,35 @@
+package stirling.software.proprietary.service;
+
+import org.springframework.stereotype.Component;
+
+import stirling.software.proprietary.model.CreditRequestContext;
+
+/**
+ * Thread-local storage for credit request context Allows tracking credit information throughout the
+ * request lifecycle
+ */
+@Component
+public class CreditContextManager {
+
+ private static final ThreadLocal contextHolder = new ThreadLocal<>();
+
+ /** Store credit context for the current request thread */
+ public void setContext(CreditRequestContext context) {
+ contextHolder.set(context);
+ }
+
+ /** Get credit context for the current request thread */
+ public CreditRequestContext getContext() {
+ return contextHolder.get();
+ }
+
+ /** Clear the credit context (should be called at end of request) */
+ public void clearContext() {
+ contextHolder.remove();
+ }
+
+ /** Check if there's an active credit context */
+ public boolean hasContext() {
+ return contextHolder.get() != null;
+ }
+}