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()}. *

  • When the client supplies {@code ?async=true} the call is handed to {@link * stirling.software.common.service.JobExecutorService JobExecutorService} where it may be - * queued, retried, tracked and subject to time‑outs. For synchronous (default) invocations + * queued, retried, tracked and subject to time-outs. For synchronous (default) invocations * these advanced options are ignored. *
  • Progress information (see {@link #trackProgress()}) is stored in {@link * stirling.software.common.service.TaskManager TaskManager} and can be polled via @@ -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; + } +}