mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 22:29:24 +00:00
test new func with api credits (NOT WORKING)
This commit is contained in:
parent
30937d204e
commit
7ebe3133d2
@ -7,7 +7,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestMethod;
|
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.
|
||||||
*
|
*
|
||||||
* <p>Behaviour notes:
|
* <p>Behaviour notes:
|
||||||
*
|
*
|
||||||
@ -16,7 +16,7 @@ import org.springframework.web.bind.annotation.RequestMethod;
|
|||||||
* multipart/form-data} unless you override {@link #consumes()}.
|
* multipart/form-data} unless you override {@link #consumes()}.
|
||||||
* <li>When the client supplies {@code ?async=true} the call is handed to {@link
|
* <li>When the client supplies {@code ?async=true} the call is handed to {@link
|
||||||
* stirling.software.common.service.JobExecutorService JobExecutorService} where it may be
|
* 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.
|
* these advanced options are ignored.
|
||||||
* <li>Progress information (see {@link #trackProgress()}) is stored in {@link
|
* <li>Progress information (see {@link #trackProgress()}) is stored in {@link
|
||||||
* stirling.software.common.service.TaskManager TaskManager} and can be polled via <code>
|
* stirling.software.common.service.TaskManager TaskManager} and can be polled via <code>
|
||||||
@ -48,8 +48,8 @@ public @interface AutoJobPostMapping {
|
|||||||
long timeout() default -1;
|
long timeout() default -1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Total number of attempts (initial + retries). Must be at least 1. Retries are executed
|
* Total number of attempts (initial + retries). Must be at least 1. Retries are executed
|
||||||
* with exponential back‑off.
|
* with exponential back-off.
|
||||||
*
|
*
|
||||||
* <p>Only honoured when {@code async=true}.
|
* <p>Only honoured when {@code async=true}.
|
||||||
*/
|
*/
|
||||||
@ -71,8 +71,9 @@ public @interface AutoJobPostMapping {
|
|||||||
boolean queueable() default false;
|
boolean queueable() default false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Relative resource weight (1–100) used by the scheduler to prioritise / throttle jobs. Values
|
* Credit cost for this endpoint in the API credit system. Also used as relative resource weight
|
||||||
* below 1 are clamped to 1, values above 100 to 100.
|
* (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;
|
||||||
}
|
}
|
||||||
|
@ -45,13 +45,11 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
|
|||||||
|
|
||||||
String queryString = request.getQueryString();
|
String queryString = request.getQueryString();
|
||||||
if (queryString != null && !queryString.isEmpty()) {
|
if (queryString != null && !queryString.isEmpty()) {
|
||||||
String requestURI = request.getRequestURI();
|
// Reuse the requestURI variable from above
|
||||||
|
|
||||||
if (requestURI.contains("/api/")) {
|
if (requestURI.contains("/api/")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Map<String, String> allowedParameters = new HashMap<>();
|
Map<String, String> allowedParameters = new HashMap<>();
|
||||||
|
|
||||||
// Keep only the allowed parameters
|
// Keep only the allowed parameters
|
||||||
|
@ -37,7 +37,6 @@ public class ConvertHtmlToPDF {
|
|||||||
private final CustomHtmlSanitizer customHtmlSanitizer;
|
private final CustomHtmlSanitizer customHtmlSanitizer;
|
||||||
|
|
||||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/html/pdf")
|
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/html/pdf")
|
||||||
|
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF",
|
summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF",
|
||||||
description =
|
description =
|
||||||
|
@ -46,7 +46,6 @@ public class ConvertMarkdownToPdf {
|
|||||||
private final CustomHtmlSanitizer customHtmlSanitizer;
|
private final CustomHtmlSanitizer customHtmlSanitizer;
|
||||||
|
|
||||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/markdown/pdf")
|
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/markdown/pdf")
|
||||||
|
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "Convert a Markdown file to PDF",
|
summary = "Convert a Markdown file to PDF",
|
||||||
description =
|
description =
|
||||||
|
@ -59,10 +59,11 @@ spring.main.allow-bean-definition-overriding=true
|
|||||||
# Set up a consistent temporary directory location
|
# Set up a consistent temporary directory location
|
||||||
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
|
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
|
||||||
|
|
||||||
# API Rate Limiting Configuration
|
# API Credit System Configuration
|
||||||
api.rate-limit.enabled=true
|
api.credit-system.enabled=true
|
||||||
api.rate-limit.anonymous.enabled=true
|
api.credit-system.anonymous.enabled=true
|
||||||
api.rate-limit.anonymous.monthly-limit=10
|
api.credit-system.anonymous.monthly-credits=10
|
||||||
api.rate-limit.anonymous.abuse-threshold=3
|
api.credit-system.anonymous.abuse-threshold=3
|
||||||
api.rate-limit.exclude-settings=true
|
api.credit-system.exclude-settings=true
|
||||||
api.rate-limit.exclude-actuator=true
|
api.credit-system.exclude-actuator=true
|
||||||
|
api.credit-system.default-credit-cost=1
|
@ -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<String, Integer> 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<String, Integer> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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<String, Integer> 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<String, Integer> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
@ -18,7 +18,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import stirling.software.proprietary.model.security.PersistentAuditEvent;
|
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;
|
import stirling.software.proprietary.util.SecretMasker;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@ -30,13 +30,13 @@ public class CustomAuditEventRepository implements AuditEventRepository {
|
|||||||
private final PersistentAuditEventRepository repo;
|
private final PersistentAuditEventRepository repo;
|
||||||
private final ObjectMapper mapper;
|
private final ObjectMapper mapper;
|
||||||
|
|
||||||
/* ── READ side intentionally inert (endpoint disabled) ── */
|
/* READ side intentionally inert (endpoint disabled) */
|
||||||
@Override
|
@Override
|
||||||
public List<AuditEvent> find(String p, Instant after, String type) {
|
public List<AuditEvent> find(String p, Instant after, String type) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── WRITE side (async) ───────────────────────────────── */
|
/* WRITE side (async) */
|
||||||
@Async("auditExecutor")
|
@Async("auditExecutor")
|
||||||
@Override
|
@Override
|
||||||
public void add(AuditEvent ev) {
|
public void add(AuditEvent ev) {
|
||||||
|
@ -40,7 +40,7 @@ import stirling.software.proprietary.audit.AuditEventType;
|
|||||||
import stirling.software.proprietary.audit.AuditLevel;
|
import stirling.software.proprietary.audit.AuditLevel;
|
||||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||||
import stirling.software.proprietary.model.security.PersistentAuditEvent;
|
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;
|
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||||
|
|
||||||
/** Controller for the audit dashboard. Admin-only access. */
|
/** Controller for the audit dashboard. Admin-only access. */
|
||||||
|
@ -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<CreditMetricsResponse> 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<CreditMetricsResponse> 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.");
|
||||||
|
}
|
||||||
|
}
|
@ -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<UsageMetricsResponse> 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<UsageMetricsResponse> 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<ApiRateLimitConfig> 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<ApiRateLimitConfig> 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<ApiRateLimitConfig> 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();
|
|
||||||
}
|
|
||||||
}
|
|
@ -6,26 +6,29 @@ import java.time.YearMonth;
|
|||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
|
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
|
|
||||||
import stirling.software.proprietary.model.converter.YearMonthStringConverter;
|
import stirling.software.proprietary.model.converter.YearMonthStringConverter;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "anonymous_api_usage",
|
@Table(
|
||||||
uniqueConstraints = {
|
name = "anonymous_api_credit_usage",
|
||||||
@UniqueConstraint(name = "uq_anon_fingerprint_month", columnNames = {"fingerprint", "month"})
|
uniqueConstraints = {
|
||||||
},
|
@UniqueConstraint(
|
||||||
indexes = {
|
name = "uq_anon_credit_fingerprint_month",
|
||||||
@Index(name = "idx_anon_fingerprint", columnList = "fingerprint"),
|
columnNames = {"fingerprint", "month"})
|
||||||
@Index(name = "idx_anon_month", columnList = "month"),
|
},
|
||||||
@Index(name = "idx_anon_ip", columnList = "ip_address")
|
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
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@Builder
|
@Builder
|
||||||
@ -33,7 +36,7 @@ import stirling.software.proprietary.model.converter.YearMonthStringConverter;
|
|||||||
@Setter
|
@Setter
|
||||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
|
||||||
@ToString(onlyExplicitlyIncluded = true)
|
@ToString(onlyExplicitlyIncluded = true)
|
||||||
public class AnonymousApiUsage implements Serializable {
|
public class AnonymousCreditUsage implements Serializable {
|
||||||
|
|
||||||
private static final long serialVersionUID = 1L;
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
@ -55,9 +58,14 @@ public class AnonymousApiUsage implements Serializable {
|
|||||||
private YearMonth month;
|
private YearMonth month;
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
@Column(name = "usage_count", nullable = false)
|
@Column(name = "credits_consumed", nullable = false)
|
||||||
@Builder.Default
|
@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)
|
@Column(name = "ip_address", length = 45)
|
||||||
private String ipAddress;
|
private String ipAddress;
|
||||||
@ -67,9 +75,8 @@ public class AnonymousApiUsage implements Serializable {
|
|||||||
|
|
||||||
@ElementCollection(fetch = FetchType.EAGER)
|
@ElementCollection(fetch = FetchType.EAGER)
|
||||||
@CollectionTable(
|
@CollectionTable(
|
||||||
name = "anonymous_api_related_fingerprints",
|
name = "anonymous_api_related_fingerprints",
|
||||||
joinColumns = @JoinColumn(name = "usage_id")
|
joinColumns = @JoinColumn(name = "usage_id"))
|
||||||
)
|
|
||||||
@Column(name = "related_fingerprint")
|
@Column(name = "related_fingerprint")
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<String> relatedFingerprints = new HashSet<>();
|
private Set<String> relatedFingerprints = new HashSet<>();
|
||||||
@ -101,4 +108,12 @@ public class AnonymousApiUsage implements Serializable {
|
|||||||
public void preUpdate() {
|
public void preUpdate() {
|
||||||
this.lastAccess = Instant.now();
|
this.lastAccess = Instant.now();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
public int getRemainingCredits() {
|
||||||
|
return Math.max(0, creditsAllocated - creditsConsumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasCreditsRemaining(int creditCost) {
|
||||||
|
return getRemainingCredits() >= creditCost;
|
||||||
|
}
|
||||||
|
}
|
@ -3,30 +3,37 @@ package stirling.software.proprietary.model;
|
|||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import jakarta.validation.constraints.Min;
|
import jakarta.validation.constraints.Min;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
|
||||||
|
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
|
|
||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "api_rate_limit_configs",
|
@Table(
|
||||||
uniqueConstraints = {
|
name = "api_credit_configs",
|
||||||
@UniqueConstraint(name = "uq_user_cfg", columnNames = {"scope_type", "user_id"}),
|
uniqueConstraints = {
|
||||||
@UniqueConstraint(name = "uq_org_cfg", columnNames = {"scope_type", "org_id"}),
|
@UniqueConstraint(
|
||||||
@UniqueConstraint(name = "uq_role_cfg", columnNames = {"scope_type", "role_name"})
|
name = "uq_user_credit_cfg",
|
||||||
},
|
columnNames = {"scope_type", "user_id"}),
|
||||||
indexes = {
|
@UniqueConstraint(
|
||||||
@Index(name = "idx_cfg_user", columnList = "user_id"),
|
name = "uq_org_credit_cfg",
|
||||||
@Index(name = "idx_cfg_org", columnList = "org_id"),
|
columnNames = {"scope_type", "org_id"}),
|
||||||
@Index(name = "idx_cfg_role", columnList = "role_name"),
|
@UniqueConstraint(
|
||||||
@Index(name = "idx_cfg_scope", columnList = "scope_type")
|
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
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@Builder
|
@Builder
|
||||||
@ -34,7 +41,7 @@ import stirling.software.proprietary.security.model.User;
|
|||||||
@Setter
|
@Setter
|
||||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
|
||||||
@ToString(onlyExplicitlyIncluded = true)
|
@ToString(onlyExplicitlyIncluded = true)
|
||||||
public class ApiRateLimitConfig implements Serializable {
|
public class ApiCreditConfig implements Serializable {
|
||||||
|
|
||||||
private static final long serialVersionUID = 1L;
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
@ -69,8 +76,8 @@ public class ApiRateLimitConfig implements Serializable {
|
|||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
@Min(0)
|
@Min(0)
|
||||||
@Column(name = "monthly_limit", nullable = false)
|
@Column(name = "monthly_credits", nullable = false)
|
||||||
private Integer monthlyLimit;
|
private Integer monthlyCredits;
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
@ -101,24 +108,25 @@ public class ApiRateLimitConfig implements Serializable {
|
|||||||
if (user != null) nonNullCount++;
|
if (user != null) nonNullCount++;
|
||||||
if (organization != null) nonNullCount++;
|
if (organization != null) nonNullCount++;
|
||||||
if (roleName != null) nonNullCount++;
|
if (roleName != null) nonNullCount++;
|
||||||
|
|
||||||
if (nonNullCount != 1) {
|
if (nonNullCount != 1) {
|
||||||
throw new IllegalStateException(
|
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) {
|
if (user != null && scopeType != ScopeType.USER) {
|
||||||
throw new IllegalStateException("ScopeType must be USER when user is set");
|
throw new IllegalStateException("ScopeType must be USER when user is set");
|
||||||
}
|
}
|
||||||
if (organization != null && scopeType != ScopeType.ORGANIZATION) {
|
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) {
|
if (roleName != null && scopeType != ScopeType.ROLE_DEFAULT) {
|
||||||
throw new IllegalStateException("ScopeType must be ROLE_DEFAULT when roleName is set");
|
throw new IllegalStateException("ScopeType must be ROLE_DEFAULT when roleName is set");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Boolean.TRUE.equals(isPooled) && scopeType != ScopeType.ORGANIZATION) {
|
if (Boolean.TRUE.equals(isPooled) && scopeType != ScopeType.ORGANIZATION) {
|
||||||
throw new IllegalStateException("isPooled can only be true for ORGANIZATION scope");
|
throw new IllegalStateException("isPooled can only be true for ORGANIZATION scope");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -5,28 +5,33 @@ import java.time.Instant;
|
|||||||
import java.time.YearMonth;
|
import java.time.YearMonth;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
|
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import jakarta.validation.constraints.Min;
|
import jakarta.validation.constraints.Min;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
|
||||||
|
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
|
|
||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "api_rate_limit_usage",
|
@Table(
|
||||||
uniqueConstraints = {
|
name = "api_credit_usage",
|
||||||
@UniqueConstraint(name = "uq_user_month", columnNames = {"user_id", "month_key"}),
|
uniqueConstraints = {
|
||||||
@UniqueConstraint(name = "uq_org_month", columnNames = {"org_id", "month_key"})
|
@UniqueConstraint(
|
||||||
},
|
name = "uq_credit_user_month",
|
||||||
indexes = {
|
columnNames = {"user_id", "month_key"}),
|
||||||
@Index(name = "idx_usage_user_month", columnList = "user_id, month_key"),
|
@UniqueConstraint(
|
||||||
@Index(name = "idx_usage_org_month", columnList = "org_id, month_key"),
|
name = "uq_credit_org_month",
|
||||||
@Index(name = "idx_usage_month", columnList = "month_key")
|
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
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@Builder
|
@Builder
|
||||||
@ -34,7 +39,7 @@ import stirling.software.proprietary.security.model.User;
|
|||||||
@Setter
|
@Setter
|
||||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
|
||||||
@ToString(onlyExplicitlyIncluded = true)
|
@ToString(onlyExplicitlyIncluded = true)
|
||||||
public class ApiRateLimitUsage implements Serializable {
|
public class ApiCreditUsage implements Serializable {
|
||||||
|
|
||||||
private static final long serialVersionUID = 1L;
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
@ -61,8 +66,14 @@ public class ApiRateLimitUsage implements Serializable {
|
|||||||
@NotNull
|
@NotNull
|
||||||
@Min(0)
|
@Min(0)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
@Column(name = "usage_count", nullable = false)
|
@Column(name = "credits_consumed", nullable = false)
|
||||||
private Integer usageCount = 0;
|
private Integer creditsConsumed = 0;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Min(0)
|
||||||
|
@Builder.Default
|
||||||
|
@Column(name = "credits_allocated", nullable = false)
|
||||||
|
private Integer creditsAllocated = 0;
|
||||||
|
|
||||||
@CreationTimestamp
|
@CreationTimestamp
|
||||||
@Column(name = "created_at", updatable = false, nullable = false)
|
@Column(name = "created_at", updatable = false, nullable = false)
|
||||||
@ -88,19 +99,29 @@ public class ApiRateLimitUsage implements Serializable {
|
|||||||
return YearMonth.now(ZoneOffset.UTC);
|
return YearMonth.now(ZoneOffset.UTC);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ApiRateLimitUsage forUser(User user) {
|
public static ApiCreditUsage forUser(User user, int creditsAllocated) {
|
||||||
return ApiRateLimitUsage.builder()
|
return ApiCreditUsage.builder()
|
||||||
.user(user)
|
.user(user)
|
||||||
.monthKey(getCurrentMonth())
|
.monthKey(getCurrentMonth())
|
||||||
.usageCount(0)
|
.creditsConsumed(0)
|
||||||
.build();
|
.creditsAllocated(creditsAllocated)
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ApiRateLimitUsage forOrganization(Organization org) {
|
public static ApiCreditUsage forOrganization(Organization org, int creditsAllocated) {
|
||||||
return ApiRateLimitUsage.builder()
|
return ApiCreditUsage.builder()
|
||||||
.organization(org)
|
.organization(org)
|
||||||
.monthKey(getCurrentMonth())
|
.monthKey(getCurrentMonth())
|
||||||
.usageCount(0)
|
.creditsConsumed(0)
|
||||||
.build();
|
.creditsAllocated(creditsAllocated)
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
public int getRemainingCredits() {
|
||||||
|
return Math.max(0, creditsAllocated - creditsConsumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasCreditsRemaining(int creditCost) {
|
||||||
|
return getRemainingCredits() >= creditCost;
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
@ -8,7 +8,7 @@ import jakarta.persistence.Converter;
|
|||||||
|
|
||||||
@Converter(autoApply = true)
|
@Converter(autoApply = true)
|
||||||
public class YearMonthStringConverter implements AttributeConverter<YearMonth, String> {
|
public class YearMonthStringConverter implements AttributeConverter<YearMonth, String> {
|
||||||
|
|
||||||
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
|
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -20,4 +20,4 @@ public class YearMonthStringConverter implements AttributeConverter<YearMonth, S
|
|||||||
public YearMonth convertToEntityAttribute(String dbData) {
|
public YearMonth convertToEntityAttribute(String dbData) {
|
||||||
return dbData != null ? YearMonth.parse(dbData, FORMATTER) : null;
|
return dbData != null ? YearMonth.parse(dbData, FORMATTER) : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,78 +0,0 @@
|
|||||||
package stirling.software.proprietary.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.Modifying;
|
|
||||||
import org.springframework.data.jpa.repository.Query;
|
|
||||||
import org.springframework.data.repository.query.Param;
|
|
||||||
import org.springframework.stereotype.Repository;
|
|
||||||
|
|
||||||
import stirling.software.proprietary.model.AnonymousApiUsage;
|
|
||||||
|
|
||||||
@Repository
|
|
||||||
public interface AnonymousApiUsageRepository extends JpaRepository<AnonymousApiUsage, Long> {
|
|
||||||
|
|
||||||
Optional<AnonymousApiUsage> findByFingerprintAndMonth(String fingerprint, YearMonth month);
|
|
||||||
|
|
||||||
@Query("SELECT a FROM AnonymousApiUsage a WHERE a.fingerprint = :fingerprint AND a.month = :month AND a.isBlocked = false")
|
|
||||||
Optional<AnonymousApiUsage> findActiveByFingerprintAndMonth(@Param("fingerprint") String fingerprint, @Param("month") YearMonth month);
|
|
||||||
|
|
||||||
@Query("SELECT a FROM AnonymousApiUsage a WHERE a.ipAddress = :ipAddress AND a.month = :month")
|
|
||||||
List<AnonymousApiUsage> 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<AnonymousApiUsage> 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);
|
|
||||||
}
|
|
@ -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<ApiRateLimitConfig, Long> {
|
|
||||||
|
|
||||||
Optional<ApiRateLimitConfig> findByUserAndIsActiveTrue(User user);
|
|
||||||
|
|
||||||
Optional<ApiRateLimitConfig> findByOrganizationAndIsActiveTrue(Organization organization);
|
|
||||||
|
|
||||||
Optional<ApiRateLimitConfig> 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<ApiRateLimitConfig> findDefaultForRole(@Param("roleName") String roleName);
|
|
||||||
|
|
||||||
}
|
|
@ -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<ApiRateLimitUsage, Long> {
|
|
||||||
|
|
||||||
Optional<ApiRateLimitUsage> findByUserAndMonthKey(User user, YearMonth monthKey);
|
|
||||||
|
|
||||||
Optional<ApiRateLimitUsage> 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);
|
|
||||||
}
|
|
@ -201,7 +201,6 @@ public class AccountWebController {
|
|||||||
return "login";
|
return "login";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// @PreAuthorize("hasRole('ROLE_ADMIN')")
|
// @PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||||
// @GetMapping("/usage")
|
// @GetMapping("/usage")
|
||||||
|
|
||||||
|
@ -38,7 +38,8 @@ import stirling.software.proprietary.security.CustomAuthenticationSuccessHandler
|
|||||||
import stirling.software.proprietary.security.CustomLogoutSuccessHandler;
|
import stirling.software.proprietary.security.CustomLogoutSuccessHandler;
|
||||||
import stirling.software.proprietary.security.database.repository.JPATokenRepositoryImpl;
|
import stirling.software.proprietary.security.database.repository.JPATokenRepositoryImpl;
|
||||||
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
|
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.FirstLoginFilter;
|
||||||
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
|
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
|
||||||
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
|
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
|
||||||
@ -71,7 +72,8 @@ public class SecurityConfiguration {
|
|||||||
private final UserAuthenticationFilter userAuthenticationFilter;
|
private final UserAuthenticationFilter userAuthenticationFilter;
|
||||||
private final LoginAttemptService loginAttemptService;
|
private final LoginAttemptService loginAttemptService;
|
||||||
private final FirstLoginFilter firstLoginFilter;
|
private final FirstLoginFilter firstLoginFilter;
|
||||||
private final ApiRateLimitFilter apiRateLimitFilter;
|
private final ApiCreditFilter apiCreditFilter;
|
||||||
|
private final CreditOutcomeFilter creditOutcomeFilter;
|
||||||
private final SessionPersistentRegistry sessionRegistry;
|
private final SessionPersistentRegistry sessionRegistry;
|
||||||
private final PersistentLoginRepository persistentLoginRepository;
|
private final PersistentLoginRepository persistentLoginRepository;
|
||||||
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
|
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
|
||||||
@ -90,7 +92,8 @@ public class SecurityConfiguration {
|
|||||||
UserAuthenticationFilter userAuthenticationFilter,
|
UserAuthenticationFilter userAuthenticationFilter,
|
||||||
LoginAttemptService loginAttemptService,
|
LoginAttemptService loginAttemptService,
|
||||||
FirstLoginFilter firstLoginFilter,
|
FirstLoginFilter firstLoginFilter,
|
||||||
ApiRateLimitFilter apiRateLimitFilter,
|
ApiCreditFilter apiCreditFilter,
|
||||||
|
CreditOutcomeFilter creditOutcomeFilter,
|
||||||
SessionPersistentRegistry sessionRegistry,
|
SessionPersistentRegistry sessionRegistry,
|
||||||
@Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper,
|
@Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper,
|
||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
@ -107,7 +110,8 @@ public class SecurityConfiguration {
|
|||||||
this.userAuthenticationFilter = userAuthenticationFilter;
|
this.userAuthenticationFilter = userAuthenticationFilter;
|
||||||
this.loginAttemptService = loginAttemptService;
|
this.loginAttemptService = loginAttemptService;
|
||||||
this.firstLoginFilter = firstLoginFilter;
|
this.firstLoginFilter = firstLoginFilter;
|
||||||
this.apiRateLimitFilter = apiRateLimitFilter;
|
this.apiCreditFilter = apiCreditFilter;
|
||||||
|
this.creditOutcomeFilter = creditOutcomeFilter;
|
||||||
this.sessionRegistry = sessionRegistry;
|
this.sessionRegistry = sessionRegistry;
|
||||||
this.persistentLoginRepository = persistentLoginRepository;
|
this.persistentLoginRepository = persistentLoginRepository;
|
||||||
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
|
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
|
||||||
@ -181,7 +185,8 @@ public class SecurityConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.addFilterBefore(rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class);
|
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.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
http.sessionManagement(
|
http.sessionManagement(
|
||||||
sessionManagement ->
|
sessionManagement ->
|
||||||
|
@ -22,7 +22,6 @@ public class DatabaseWebController {
|
|||||||
|
|
||||||
private final DatabaseService databaseService;
|
private final DatabaseService databaseService;
|
||||||
|
|
||||||
|
|
||||||
@Deprecated
|
@Deprecated
|
||||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||||
// @GetMapping("/database")
|
// @GetMapping("/database")
|
||||||
|
@ -36,7 +36,6 @@ public class TeamWebController {
|
|||||||
@Deprecated
|
@Deprecated
|
||||||
// @GetMapping
|
// @GetMapping
|
||||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||||
|
|
||||||
public String listTeams(HttpServletRequest request, Model model) {
|
public String listTeams(HttpServletRequest request, Model model) {
|
||||||
// Get teams with user counts using a DTO projection
|
// Get teams with user counts using a DTO projection
|
||||||
List<TeamWithUserCountDTO> allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount();
|
List<TeamWithUserCountDTO> allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount();
|
||||||
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
@ -28,13 +28,12 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import stirling.software.common.model.ApplicationProperties;
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
|
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
|
||||||
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
|
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.ApiKeyAuthenticationToken;
|
||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||||
import stirling.software.proprietary.security.service.UserService;
|
import stirling.software.proprietary.security.service.UserService;
|
||||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||||
import stirling.software.proprietary.security.matcher.ApiJobEndpointMatcher;
|
|
||||||
import stirling.software.proprietary.service.ApiRateLimitService;
|
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@ -44,24 +43,24 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final SessionPersistentRegistry sessionPersistentRegistry;
|
private final SessionPersistentRegistry sessionPersistentRegistry;
|
||||||
private final boolean loginEnabledValue;
|
private final boolean loginEnabledValue;
|
||||||
private final ApiRateLimitService rateLimitService;
|
|
||||||
private final ApiJobEndpointMatcher apiJobEndpointMatcher;
|
private final ApiJobEndpointMatcher apiJobEndpointMatcher;
|
||||||
|
|
||||||
@Value("${api.rate-limit.anonymous.enabled:true}")
|
@Value("${api.credit-system.anonymous.enabled:true}")
|
||||||
private boolean anonymousApiEnabled;
|
private boolean anonymousApiEnabled;
|
||||||
|
|
||||||
|
@Value("${api.credit-system.anonymous.monthly-credits:10}")
|
||||||
|
private int anonymousMonthlyCredits;
|
||||||
|
|
||||||
public UserAuthenticationFilter(
|
public UserAuthenticationFilter(
|
||||||
@Lazy ApplicationProperties.Security securityProp,
|
@Lazy ApplicationProperties.Security securityProp,
|
||||||
@Lazy UserService userService,
|
@Lazy UserService userService,
|
||||||
SessionPersistentRegistry sessionPersistentRegistry,
|
SessionPersistentRegistry sessionPersistentRegistry,
|
||||||
@Qualifier("loginEnabled") boolean loginEnabledValue,
|
@Qualifier("loginEnabled") boolean loginEnabledValue,
|
||||||
@Lazy ApiRateLimitService rateLimitService,
|
|
||||||
ApiJobEndpointMatcher apiJobEndpointMatcher) {
|
ApiJobEndpointMatcher apiJobEndpointMatcher) {
|
||||||
this.securityProp = securityProp;
|
this.securityProp = securityProp;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
||||||
this.loginEnabledValue = loginEnabledValue;
|
this.loginEnabledValue = loginEnabledValue;
|
||||||
this.rateLimitService = rateLimitService;
|
|
||||||
this.apiJobEndpointMatcher = apiJobEndpointMatcher;
|
this.apiJobEndpointMatcher = apiJobEndpointMatcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,21 +125,17 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
if (authentication == null || !authentication.isAuthenticated()) {
|
if (authentication == null || !authentication.isAuthenticated()) {
|
||||||
String method = request.getMethod();
|
String method = request.getMethod();
|
||||||
String contextPath = request.getContextPath();
|
String contextPath = request.getContextPath();
|
||||||
|
|
||||||
// Check if this is an API job endpoint and anonymous access is enabled
|
// Check if this is an API job endpoint and anonymous access is enabled
|
||||||
if (anonymousApiEnabled && apiJobEndpointMatcher.matches(request)) {
|
if (anonymousApiEnabled && apiJobEndpointMatcher.matches(request)) {
|
||||||
// Check anonymous rate limit
|
// Check anonymous rate limit
|
||||||
String ipAddress = getClientIpAddress(request);
|
String ipAddress = getClientIpAddress(request);
|
||||||
String userAgent = request.getHeader("User-Agent");
|
String userAgent = request.getHeader("User-Agent");
|
||||||
|
|
||||||
ApiRateLimitService.UsageMetrics metrics = rateLimitService
|
// Anonymous users will be handled by ApiCreditFilter
|
||||||
.getAnonymousUsageMetrics(ipAddress, userAgent);
|
// Just allow them through for now
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
if (metrics.remaining() > 0) {
|
return;
|
||||||
// Allow anonymous API access - rate limiting will be enforced by ApiRateLimitFilter
|
|
||||||
filterChain.doFilter(request, response);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("GET".equalsIgnoreCase(method) && !(contextPath + "/login").equals(requestURI)) {
|
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"
|
"Authentication required. Please provide a X-API-KEY in request"
|
||||||
+ " header.\n"
|
+ " header.\n"
|
||||||
+ "This is found in Settings -> Account Settings -> API Key\n"
|
+ "This is found in Settings -> Account Settings -> API Key\n"
|
||||||
+ "Anonymous users have limited API access ("
|
+ "Anonymous users have limited API access ("
|
||||||
+ rateLimitService.getAnonymousMonthlyLimit() + " requests/month)\n"
|
+ anonymousMonthlyCredits
|
||||||
|
+ " credits/month)\n"
|
||||||
+ "Alternatively you can disable authentication if this is"
|
+ "Alternatively you can disable authentication if this is"
|
||||||
+ " unexpected");
|
+ " unexpected");
|
||||||
return;
|
return;
|
||||||
@ -285,16 +281,13 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getClientIpAddress(HttpServletRequest request) {
|
private String getClientIpAddress(HttpServletRequest request) {
|
||||||
// Check for proxy headers
|
// Check for proxy headers
|
||||||
String[] headers = {
|
String[] headers = {
|
||||||
"X-Forwarded-For",
|
"X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP"
|
||||||
"X-Real-IP",
|
|
||||||
"Proxy-Client-IP",
|
|
||||||
"WL-Proxy-Client-IP"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for (String header : headers) {
|
for (String header : headers) {
|
||||||
String ip = request.getHeader(header);
|
String ip = request.getHeader(header);
|
||||||
if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
|
if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
|
||||||
@ -306,7 +299,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
return ip;
|
return ip;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return request.getRemoteAddr();
|
return request.getRemoteAddr();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,27 +16,27 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared matcher component to determine if a request should be subject to
|
* Shared matcher component to determine if a request should be subject to anonymous API access and
|
||||||
* anonymous API access and rate limiting. This ensures consistent behavior
|
* credit limiting. This ensures consistent behavior between UserAuthenticationFilter and
|
||||||
* between UserAuthenticationFilter and ApiRateLimitFilter.
|
* ApiCreditFilter.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class ApiJobEndpointMatcher {
|
public class ApiJobEndpointMatcher {
|
||||||
|
|
||||||
private final RequestMappingHandlerMapping handlerMapping;
|
private final RequestMappingHandlerMapping handlerMapping;
|
||||||
|
|
||||||
@Value("${api.rate-limit.exclude-settings:true}")
|
@Value("${api.credit-system.exclude-settings:true}")
|
||||||
private boolean excludeSettings;
|
private boolean excludeSettings;
|
||||||
|
|
||||||
@Value("${api.rate-limit.exclude-actuator:true}")
|
@Value("${api.credit-system.exclude-actuator:true}")
|
||||||
private boolean excludeActuator;
|
private boolean excludeActuator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if a request matches the criteria for API job endpoints
|
* Determines if a request matches the criteria for API job endpoints that should be
|
||||||
* that should be rate-limited and allowed for anonymous access.
|
* credit-limited and allowed for anonymous access.
|
||||||
*
|
*
|
||||||
* @param request the HTTP request to check
|
* @param request the HTTP request to check
|
||||||
* @return true if the request is a POST to an @AutoJobPostMapping endpoint
|
* @return true if the request is a POST to an @AutoJobPostMapping endpoint
|
||||||
*/
|
*/
|
||||||
@ -45,49 +45,51 @@ public class ApiJobEndpointMatcher {
|
|||||||
if (!"POST".equalsIgnoreCase(request.getMethod())) {
|
if (!"POST".equalsIgnoreCase(request.getMethod())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
String path = request.getRequestURI();
|
String path = request.getRequestURI();
|
||||||
|
|
||||||
// Apply exclusion rules
|
// Apply exclusion rules
|
||||||
if (excludeActuator && path != null && path.startsWith("/actuator")) {
|
if (excludeActuator && path != null && path.startsWith("/actuator")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (excludeSettings && isSettingsEndpoint(path)) {
|
if (excludeSettings && isSettingsEndpoint(path)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the handler method has @AutoJobPostMapping annotation
|
// Check if the handler method has @AutoJobPostMapping annotation
|
||||||
return hasAutoJobPostMapping(request);
|
return hasAutoJobPostMapping(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean hasAutoJobPostMapping(HttpServletRequest request) {
|
private boolean hasAutoJobPostMapping(HttpServletRequest request) {
|
||||||
try {
|
try {
|
||||||
HandlerExecutionChain chain = handlerMapping.getHandler(request);
|
HandlerExecutionChain chain = handlerMapping.getHandler(request);
|
||||||
if (chain == null) {
|
if (chain == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Object handler = chain.getHandler();
|
Object handler = chain.getHandler();
|
||||||
if (!(handler instanceof HandlerMethod handlerMethod)) {
|
if (!(handler instanceof HandlerMethod handlerMethod)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Method method = handlerMethod.getMethod();
|
Method method = handlerMethod.getMethod();
|
||||||
return method.isAnnotationPresent(AutoJobPostMapping.class);
|
return method.isAnnotationPresent(AutoJobPostMapping.class);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} 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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isSettingsEndpoint(String path) {
|
private boolean isSettingsEndpoint(String path) {
|
||||||
return path != null && (
|
return path != null
|
||||||
path.contains("/settings") ||
|
&& (path.contains("/settings")
|
||||||
path.contains("/update-enable-analytics") ||
|
|| path.contains("/update-enable-analytics")
|
||||||
path.contains("/config") ||
|
|| path.contains("/config")
|
||||||
path.contains("/preferences")
|
|| path.contains("/preferences"));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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<AnonymousCreditUsage, Long> {
|
||||||
|
|
||||||
|
Optional<AnonymousCreditUsage> findByFingerprintAndMonth(String fingerprint, YearMonth month);
|
||||||
|
|
||||||
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
Optional<AnonymousCreditUsage> findByFingerprintAndMonthForUpdate(String fingerprint, YearMonth month);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT u FROM AnonymousCreditUsage u WHERE u.fingerprint = :fingerprint ORDER BY u.month DESC")
|
||||||
|
List<AnonymousCreditUsage> findByFingerprintOrderByMonthDesc(
|
||||||
|
@Param("fingerprint") String fingerprint);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT u FROM AnonymousCreditUsage u WHERE u.ipAddress = :ipAddress AND u.month = :month")
|
||||||
|
List<AnonymousCreditUsage> 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<AnonymousCreditUsage> findAllBlockedUsers();
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT u FROM AnonymousCreditUsage u WHERE u.abuseScore >= :threshold ORDER BY u.abuseScore DESC, u.updatedAt DESC")
|
||||||
|
List<AnonymousCreditUsage> findHighAbuseScoreUsers(@Param("threshold") int threshold);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT u FROM AnonymousCreditUsage u WHERE u.month = :month ORDER BY u.creditsConsumed DESC")
|
||||||
|
List<AnonymousCreditUsage> 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<AnonymousCreditUsage> findRelatedFingerprints(
|
||||||
|
@Param("fingerprints") List<String> 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<AnonymousCreditUsage> 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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<ApiCreditConfig, Long> {
|
||||||
|
|
||||||
|
Optional<ApiCreditConfig> findByUserAndIsActiveTrue(User user);
|
||||||
|
|
||||||
|
Optional<ApiCreditConfig> findByOrganizationAndIsActiveTrue(Organization organization);
|
||||||
|
|
||||||
|
Optional<ApiCreditConfig> 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<ApiCreditConfig> findDefaultForRole(@Param("roleName") String roleName);
|
||||||
|
|
||||||
|
List<ApiCreditConfig> findAllByIsActiveTrueOrderByCreatedAtDesc();
|
||||||
|
}
|
@ -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<ApiCreditUsage, Long> {
|
||||||
|
|
||||||
|
Optional<ApiCreditUsage> findByUserAndMonthKey(User user, YearMonth monthKey);
|
||||||
|
|
||||||
|
Optional<ApiCreditUsage> findByOrganizationAndMonthKey(
|
||||||
|
Organization organization, YearMonth monthKey);
|
||||||
|
|
||||||
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
Optional<ApiCreditUsage> findByUserAndMonthKeyForUpdate(User user, YearMonth monthKey);
|
||||||
|
|
||||||
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
Optional<ApiCreditUsage> 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<ApiCreditUsage> 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<ApiCreditUsage> 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<ApiCreditUsage> findByUserOrderByMonthKeyDesc(User user);
|
||||||
|
|
||||||
|
List<ApiCreditUsage> findByOrganizationOrderByMonthKeyDesc(Organization organization);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT u FROM ApiCreditUsage u WHERE u.monthKey = :month ORDER BY u.creditsConsumed DESC")
|
||||||
|
List<ApiCreditUsage> findTopConsumersByMonth(@Param("month") YearMonth month);
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package stirling.software.proprietary.repository;
|
package stirling.software.proprietary.security.repository;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
@ -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<String, Integer> 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<ApiCreditConfig> 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<AnonymousCreditUsage> 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<ApiCreditConfig> resolveEffectiveConfig(
|
||||||
|
User user, Organization org, String roleName) {
|
||||||
|
// 1. User-specific config (highest priority)
|
||||||
|
Optional<ApiCreditConfig> userConfig = configRepository.findByUserAndIsActiveTrue(user);
|
||||||
|
if (userConfig.isPresent()) {
|
||||||
|
return userConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Organization config (if user belongs to org)
|
||||||
|
if (org != null) {
|
||||||
|
Optional<ApiCreditConfig> 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<ApiCreditConfig> 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<ApiCreditConfig> 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<ApiCreditConfig> 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<ApiCreditConfig> 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<ApiCreditConfig> 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<AnonymousCreditUsage> 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");
|
||||||
|
}
|
||||||
|
}
|
@ -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<ApiRateLimitConfig> 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<ApiRateLimitConfig> 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<AnonymousApiUsage> 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<AnonymousApiUsage> 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<AnonymousApiUsage> 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<ApiRateLimitConfig> resolveEffectiveConfig(User user, Organization org, String roleName) {
|
|
||||||
// Priority: User > Organization > Role
|
|
||||||
Optional<ApiRateLimitConfig> userConfig = configRepository.findByUserAndIsActiveTrue(user);
|
|
||||||
if (userConfig.isPresent()) {
|
|
||||||
return userConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (org != null) {
|
|
||||||
Optional<ApiRateLimitConfig> orgConfig = configRepository.findByOrganizationAndIsActiveTrue(org);
|
|
||||||
if (orgConfig.isPresent()) {
|
|
||||||
return orgConfig;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return configRepository.findByScopeTypeAndRoleNameAndIsActiveTrue(
|
|
||||||
ApiRateLimitConfig.ScopeType.ROLE_DEFAULT, roleName);
|
|
||||||
}
|
|
||||||
}
|
|
@ -15,7 +15,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
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. */
|
/** Service to periodically clean up old audit events based on retention policy. */
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
@ -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<CreditRequestContext> 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;
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user