Compare commits

..
23 Commits
Author SHA1 Message Date
Anthony Stirling 7ebe3133d2 test new func with api credits (NOT WORKING) 2025-08-14 14:56:47 +01:00
Anthony Stirling 30937d204e init limits for users and non Authed Users 2025-08-12 09:40:59 +01:00
Anthony Stirling 1a51a43962 conflicts 2025-08-11 22:04:26 +01:00
Anthony StirlingandGitHub 877caee396 Merge branch 'main' into org 2025-08-09 15:41:28 +01:00
Anthony Stirling 0dd0e0c71e formatting and fixes 2025-08-06 16:35:09 +01:00
Anthony Stirling d1c8802890 cleanup and fixes 2025-08-06 16:21:39 +01:00
a 31c397d016 Merge branch 'org' of git@github.com:Stirling-Tools/Stirling-PDF.git into org 2025-08-06 15:53:55 +01:00
Anthony Stirling e418e06ace preauth 2025-08-06 15:53:39 +01:00
Anthony Stirling 9e78057a3e disable console 2025-08-06 15:06:19 +01:00
Anthony Stirling 4437e1cf30 fix removal for V1 2025-08-06 15:05:15 +01:00
Anthony StirlingandGitHub 3f81adaf56 Merge branch 'main' into org 2025-08-06 14:57:14 +01:00
Anthony Stirling cac817ed56 remove other roles 2025-08-06 14:43:01 +01:00
Anthony StirlingandGitHub 3da9ae46b6 Merge branch 'main' into org 2025-08-05 15:47:25 +01:00
Anthony Stirling 771ce3acfe formating and lang 2025-08-01 21:14:12 +01:00
Anthony Stirling a9def611f6 admin permission switch 2025-08-01 20:34:11 +01:00
Anthony Stirling b049638f49 debug TODO: TO REMOVE before merge 2025-08-01 15:19:22 +01:00
Anthony Stirling 5761bfbffa formatting 2025-08-01 14:48:37 +01:00
Anthony Stirling 3c6786f719 logging 2025-08-01 14:48:25 +01:00
a 7af18cb10c Merge branch 'org' of git@github.com:Stirling-Tools/Stirling-PDF.git into org 2025-08-01 14:46:46 +01:00
Anthony Stirling 42f5e68f48 h2 console stuff 2025-08-01 14:46:22 +01:00
Anthony StirlingandGitHub 92e7538182 Merge branch 'main' into org 2025-07-31 12:50:55 +01:00
Anthony Stirling 5e5f2dda83 org init 2025-07-31 12:16:01 +01:00
Anthony Stirling 0966b919cb remove file locks plus formatting 2025-07-29 19:00:48 +01:00
259 changed files with 4864 additions and 7204 deletions
+3 -1
View File
@@ -10,7 +10,9 @@
"Bash(npm test)",
"Bash(npm test:*)",
"Bash(ls:*)",
"Bash(npx tsc:*)"
"Bash(npx tsc:*)",
"Bash(echo)",
"Bash(rm:*)"
],
"deny": []
}
+1 -2
View File
@@ -318,8 +318,7 @@ jobs:
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
SWAGGER_SERVER_URL: "https://${V2_PORT}.ssl.stirlingpdf.cloud"
baseUrl: "https://${V2_PORT}.ssl.stirlingpdf.cloud"
SWAGGER_SERVER_URL: "http://${{ secrets.VPS_HOST }}:${V2_PORT}"
restart: on-failure:5
stirling-pdf-v2-frontend:
+1 -2
View File
@@ -151,8 +151,7 @@ jobs:
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
SWAGGER_SERVER_URL: "https://demo.stirlingpdf.cloud"
baseUrl: "https://demo.stirlingpdf.cloud"
SWAGGER_SERVER_URL: "http://${{ secrets.VPS_HOST }}:3000"
restart: on-failure:5
frontend:
@@ -7,7 +7,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
/**
* Shortcut for a POST endpoint that is executed through the Stirling "autojob" framework.
* Shortcut for a POST endpoint that is executed through the Stirling "auto-job" framework.
*
* <p>Behaviour notes:
*
@@ -16,7 +16,7 @@ import org.springframework.web.bind.annotation.RequestMethod;
* multipart/form-data} unless you override {@link #consumes()}.
* <li>When the client supplies {@code ?async=true} the call is handed to {@link
* stirling.software.common.service.JobExecutorService JobExecutorService} where it may be
* queued, retried, tracked and subject to timeouts. For synchronous (default) invocations
* queued, retried, tracked and subject to time-outs. For synchronous (default) invocations
* these advanced options are ignored.
* <li>Progress information (see {@link #trackProgress()}) is stored in {@link
* stirling.software.common.service.TaskManager TaskManager} and can be polled via <code>
@@ -48,8 +48,8 @@ public @interface AutoJobPostMapping {
long timeout() default -1;
/**
* Total number of attempts (initial + retries). Must be at least&nbsp;1. Retries are executed
* with exponential backoff.
* Total number of attempts (initial + retries). Must be at least 1. Retries are executed
* with exponential back-off.
*
* <p>Only honoured when {@code async=true}.
*/
@@ -71,8 +71,9 @@ public @interface AutoJobPostMapping {
boolean queueable() default false;
/**
* Relative resource weight (1100) used by the scheduler to prioritise / throttle jobs. Values
* below 1 are clamped to&nbsp;1, values above 100 to&nbsp;100.
* Credit cost for this endpoint in the API credit system. Also used as relative resource weight
* (1-100) by the scheduler to prioritise / throttle jobs. Values below 1 are clamped to 1,
* values above 100 to 100.
*/
int resourceWeight() default 50;
int resourceWeight() default 1;
}
@@ -10,21 +10,25 @@ import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public enum Role {
// Unlimited access
// System-wide administrator - can manage all organizations
SYSTEM_ADMIN(
"ROLE_SYSTEM_ADMIN",
Integer.MAX_VALUE,
Integer.MAX_VALUE,
"adminUserSettings.systemAdmin"),
// Organization administrator - can manage their organization and all its teams
ORG_ADMIN("ROLE_ORG_ADMIN", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.orgAdmin"),
// Team leader - can manage users in their specific team
TEAM_LEAD("ROLE_TEAM_LEAD", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.teamLead"),
// Legacy admin role - equivalent to SYSTEM_ADMIN for backward compatibility
ADMIN("ROLE_ADMIN", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.admin"),
// Unlimited access
// Regular user with unlimited access within their team/org
USER("ROLE_USER", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.user"),
// 40 API calls Per Day, 40 web calls
LIMITED_API_USER("ROLE_LIMITED_API_USER", 40, 40, "adminUserSettings.apiUser"),
// 20 API calls Per Day, 20 web calls
EXTRA_LIMITED_API_USER("ROLE_EXTRA_LIMITED_API_USER", 20, 20, "adminUserSettings.extraApiUser"),
// 0 API calls per day and 20 web calls
WEB_ONLY_USER("ROLE_WEB_ONLY_USER", 0, 20, "adminUserSettings.webOnlyUser"),
INTERNAL_API_USER(
"STIRLING-PDF-BACKEND-API-USER",
Integer.MAX_VALUE,
@@ -63,4 +67,35 @@ public enum Role {
}
throw new IllegalArgumentException("No Role defined for id: " + roleId);
}
/** Checks if this role can manage users across all organizations */
public boolean isSystemAdmin() {
return this == SYSTEM_ADMIN || this == ADMIN; // ADMIN for backward compatibility
}
/** Checks if this role can manage an organization and all its teams */
public boolean isOrgAdmin() {
return isSystemAdmin() || this == ORG_ADMIN;
}
/** Checks if this role can manage a specific team */
public boolean isTeamLead() {
return isOrgAdmin() || this == TEAM_LEAD;
}
/** Gets the hierarchy level of this role (higher number = more permissions) */
public int getHierarchyLevel() {
return switch (this) {
case SYSTEM_ADMIN, ADMIN -> 4;
case ORG_ADMIN -> 3;
case TEAM_LEAD -> 2;
case USER -> 1;
default -> 0; // Limited users
};
}
/** Checks if this role has higher or equal authority than another role */
public boolean hasAuthorityOver(Role otherRole) {
return this.getHierarchyLevel() >= otherRole.getHierarchyLevel();
}
}
@@ -45,6 +45,11 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
String queryString = request.getQueryString();
if (queryString != null && !queryString.isEmpty()) {
// Reuse the requestURI variable from above
if (requestURI.contains("/api/")) {
return true;
}
Map<String, String> allowedParameters = new HashMap<>();
// Keep only the allowed parameters
@@ -37,7 +37,6 @@ public class ConvertHtmlToPDF {
private final CustomHtmlSanitizer customHtmlSanitizer;
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/html/pdf")
@Operation(
summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF",
description =
@@ -46,7 +46,6 @@ public class ConvertMarkdownToPdf {
private final CustomHtmlSanitizer customHtmlSanitizer;
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/markdown/pdf")
@Operation(
summary = "Convert a Markdown file to PDF",
description =
@@ -1,11 +1,12 @@
multipart.enabled=true
logging.level.org.springframework=WARN
logging.level.org.hibernate=WARN
#logging.level.root=DEBUG
logging.level.org.eclipse.jetty=WARN
#logging.level.org.springframework.security.saml2=TRACE
logging.level.org.springframework.security=DEBUG
#logging.level.org.springframework.security=DEBUG
#logging.level.org.opensaml=DEBUG
logging.level.stirling.software: DEBUG
#logging.level.stirling.software.SPDF.config.security: DEBUG
logging.level.com.zaxxer.hikari=WARN
spring.jpa.open-in-view=false
server.forward-headers-strategy=NATIVE
@@ -34,6 +35,7 @@ spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.h2.console.enabled=false
spring.h2.console.path=/h2-console
spring.jpa.hibernate.ddl-auto=update
# Defer datasource initialization to ensure that the database is fully set up
# before Hibernate attempts to access it. This is particularly useful when
@@ -57,12 +59,11 @@ spring.main.allow-bean-definition-overriding=true
# Set up a consistent temporary directory location
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
#spring.security.oauth2.resourceserver.jwt.issuer-uri=https://nrlkjfznsavsbmweiyqu.supabase.co/auth/v1
spring.security.oauth2.resourceserver.jwk-set-uri=https://nrlkjfznsavsbmweiyqu.supabase.co/auth/v1/.well-known/jwks.json
spring.security.oauth2.resourceserver.audience=authenticated
logging:
level:
org.springframework.security: DEBUG
your.project.pkg.security: DEBUG
# API Credit System Configuration
api.credit-system.enabled=true
api.credit-system.anonymous.enabled=true
api.credit-system.anonymous.monthly-credits=10
api.credit-system.anonymous.abuse-threshold=3
api.credit-system.exclude-settings=true
api.credit-system.exclude-actuator=true
api.credit-system.default-credit-cost=1
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=هل يجب تعطيل/تمكين ال
adminUserSettings.usernameInfo=يمكن أن يحتوي اسم المستخدم فقط على أحرف وأرقام والرموز الخاصة التالية @._+- أو يجب أن يكون عنوان بريد إلكتروني صالح.
adminUserSettings.role=الدور
adminUserSettings.actions=الإجراءات
adminUserSettings.apiUser=مستخدم API محدود
adminUserSettings.extraApiUser=مستخدم API محدود إضافي
adminUserSettings.webOnlyUser=مستخدم الويب فقط
adminUserSettings.demoUser=مستخدم تجريبي (بدون إعدادات مخصصة)
adminUserSettings.internalApiUser=مستخدم API داخلي
adminUserSettings.forceChange=إجبار المستخدم على تغيير كلمة المرور عند تسجيل الدخول
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=İstifadəçi aktivləşdirilməli/dea
adminUserSettings.usernameInfo=İstifadəçi adı sadəcə hərflərdən, rəqəmlərdən və @._+- xüsusi simvollarından ibarət ola bilər və ya düzgün email ünvanı olmalıdır.
adminUserSettings.role=Rol
adminUserSettings.actions=Fəaliyyətlər
adminUserSettings.apiUser=Məhdudlaşdırılmış API İstifadəçisi
adminUserSettings.extraApiUser=Əlavə Məhdudlaşdırılmış API İstifadəçisi
adminUserSettings.webOnlyUser=Yalnız Veb İstifadəçisi
adminUserSettings.demoUser=Demo İstifadəçisi (Fərdi parametrlər yoxdur)
adminUserSettings.internalApiUser=Daxili API İstifadəçisi
adminUserSettings.forceChange=İstifadəçini giriş zamanı parolu dəyişməyə məcbur et
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Трябва ли потребител
adminUserSettings.usernameInfo=Потребителското име може да съдържа само букви, цифри и следните специални символи @._+- или трябва да е валиден имейл адрес.
adminUserSettings.role=Роля
adminUserSettings.actions=Действия
adminUserSettings.apiUser=Ограничен API потребител
adminUserSettings.extraApiUser=Допълнителен ограничен API потребител
adminUserSettings.webOnlyUser=Само за уеб-потребител
adminUserSettings.demoUser=Демо потребител (без персонализирани настройки)
adminUserSettings.internalApiUser=Вътрешен API потребител
adminUserSettings.forceChange=Принудете потребителя да промени потребителското име/парола при влизане
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=སྤྱོད་མཁན་སྤ
adminUserSettings.usernameInfo=སྤྱོད་མཁན་མིང་ནང་ཡི་གེ་དང་ཨང་ཀི། དམིགས་བསལ་མཚོན་རྟགས་ @._+- ཡང་ན་གློག་འཕྲིན་ཁ་བྱང་ཚད་ལྡན་ཞིག་དགོས།
adminUserSettings.role=འགན་འཁུར།
adminUserSettings.actions=བྱ་སྤྱོད།
adminUserSettings.apiUser=ཚད་བཀག་ཅན་གྱི་ API སྤྱོད་མཁན།
adminUserSettings.extraApiUser=ཚད་བཀག་ཅན་གྱི་ API སྤྱོད་མཁན་འཕར་མ།
adminUserSettings.webOnlyUser=དྲ་ཚིགས་ཁོ་ནའི་སྤྱོད་མཁན།
adminUserSettings.demoUser=བརྟག་དཔྱད་སྤྱོད་མཁན། (རང་སྒྲིག་མེད་པ།)
adminUserSettings.internalApiUser=ནང་ཁུལ་ API སྤྱོད་མཁན།
adminUserSettings.forceChange=ནང་འཛུལ་སྐབས་གསང་ཚིག་བསྒྱུར་དགོས་པ་བཟོ་བ།
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Vols deshabilitar/habilitar aquest usu
adminUserSettings.usernameInfo=El nom d'usuari només pot contenir lletres, números i els següents caràcters especials: @._+- o ha de ser una adreça de correu electrònic vàlida.
adminUserSettings.role=Rol
adminUserSettings.actions=Accions
adminUserSettings.apiUser=Usuari amb API limitada
adminUserSettings.extraApiUser=Usuari Addicional amb API limitada
adminUserSettings.webOnlyUser=Usuari només WEB
adminUserSettings.demoUser=Usuari de Demo (Sense configuracions personalitzades)
adminUserSettings.internalApiUser=Usuari d'API Interna
adminUserSettings.forceChange=Força l'usuari a canviar la contrasenya en iniciar sessió
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Má být uživatel deaktivován/aktivo
adminUserSettings.usernameInfo=Uživatelské jméno může obsahovat pouze písmena, číslice a následující speciální znaky @._+- nebo musí být platná e-mailová adresa.
adminUserSettings.role=Role
adminUserSettings.actions=Akce
adminUserSettings.apiUser=Omezený API uživatel
adminUserSettings.extraApiUser=Další omezený API uživatel
adminUserSettings.webOnlyUser=Pouze webový uživatel
adminUserSettings.demoUser=Demo uživatel (Bez vlastních nastavení)
adminUserSettings.internalApiUser=Interní API uživatel
adminUserSettings.forceChange=Vynutit změnu hesla při přihlášení
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Skal brugeren deaktiveres/aktiveres?
adminUserSettings.usernameInfo=Brugernavn må kun indeholde bogstaver, tal og følgende specialtegn @._+- eller skal være en gyldig e-mailadresse.
adminUserSettings.role=Rolle
adminUserSettings.actions=Handlinger
adminUserSettings.apiUser=Begrænset API-bruger
adminUserSettings.extraApiUser=Yderligere Begrænset API-bruger
adminUserSettings.webOnlyUser=Kun Web-bruger
adminUserSettings.demoUser=Demo-bruger (Ingen brugerdefinerede indstillinger)
adminUserSettings.internalApiUser=Intern API-bruger
adminUserSettings.forceChange=Tving bruger til at ændre adgangskode ved login
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Soll der Benutzer deaktiviert/aktivier
adminUserSettings.usernameInfo=Der Benutzername darf nur Buchstaben, Zahlen und die folgenden Sonderzeichen @._+- enthalten oder muss eine gültige E-Mail-Adresse sein.
adminUserSettings.role=Rolle
adminUserSettings.actions=Aktionen
adminUserSettings.apiUser=Eingeschränkter API-Benutzer
adminUserSettings.extraApiUser=Zusätzlicher eingeschränkter API-Benutzer
adminUserSettings.webOnlyUser=Nur Web-Benutzer
adminUserSettings.demoUser=Demo-Benutzer (Keine benutzerdefinierten Einstellungen)
adminUserSettings.internalApiUser=Interner API-Benutzer
adminUserSettings.forceChange=Benutzer dazu zwingen, Benutzernamen/Passwort bei der Anmeldung zu ändern
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Θέλετε να απενεργοπο
adminUserSettings.usernameInfo=Το όνομα χρήστη μπορεί να περιέχει μόνο γράμματα, αριθμούς και τους ειδικούς χαρακτήρες @._+- ή πρέπει να είναι έγκυρη διεύθυνση email.
adminUserSettings.role=Ρόλος
adminUserSettings.actions=Ενέργειες
adminUserSettings.apiUser=Περιορισμένος χρήστης API
adminUserSettings.extraApiUser=Επιπλέον περιορισμένος χρήστης API
adminUserSettings.webOnlyUser=Χρήστης μόνο web
adminUserSettings.demoUser=Δοκιμαστικός χρήστης (Χωρίς προσαρμοσμένες ρυθμίσεις)
adminUserSettings.internalApiUser=Εσωτερικός χρήστης API
adminUserSettings.forceChange=Υποχρεωτική αλλαγή κωδικού κατά τη σύνδεση
@@ -446,6 +446,9 @@ account.adminNotif=You have admin privileges. Access system settings and user ma
adminUserSettings.title=User Control Settings
adminUserSettings.header=Admin User Control Settings
adminUserSettings.systemAdmin=System Administrator
adminUserSettings.orgAdmin=Organisation Administrator
adminUserSettings.teamLead=Team Leader
adminUserSettings.admin=Admin
adminUserSettings.user=User
adminUserSettings.addUser=Add New User
@@ -455,9 +458,6 @@ adminUserSettings.confirmChangeUserStatus=Should the user be disabled/enabled?
adminUserSettings.usernameInfo=Username can only contain letters, numbers and the following special characters @._+- or must be a valid email address.
adminUserSettings.role=Role
adminUserSettings.actions=Actions
adminUserSettings.apiUser=Limited API User
adminUserSettings.extraApiUser=Additional Limited API User
adminUserSettings.webOnlyUser=Web Only User
adminUserSettings.demoUser=Demo User (No custom settings)
adminUserSettings.internalApiUser=Internal API User
adminUserSettings.forceChange=Force user to change password on login
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Should the user be disabled/enabled?
adminUserSettings.usernameInfo=Username can only contain letters, numbers and the following special characters @._+- or must be a valid email address.
adminUserSettings.role=Role
adminUserSettings.actions=Actions
adminUserSettings.apiUser=Limited API User
adminUserSettings.extraApiUser=Additional Limited API User
adminUserSettings.webOnlyUser=Web Only User
adminUserSettings.demoUser=Demo User (No custom settings)
adminUserSettings.internalApiUser=Internal API User
adminUserSettings.forceChange=Force user to change password on login
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=¿Se debe habilitar/deshabilitar el us
adminUserSettings.usernameInfo=El nombre de usuario solo puede contener letras, números y los siguientes caracteres especiales @._+- o debe ser una dirección de correo electrónico válida.
adminUserSettings.role=Rol
adminUserSettings.actions=Acciones
adminUserSettings.apiUser=Usuario limitado de API
adminUserSettings.extraApiUser=Otro usuario limitado de API
adminUserSettings.webOnlyUser=Usuario solo web
adminUserSettings.demoUser=Usuario Demo (Sin ajustes personalizados)
adminUserSettings.internalApiUser=Usuario interno de API
adminUserSettings.forceChange=Forzar usuario a cambiar usuario/contraseña en el acceso
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Should the user be disabled/enabled?
adminUserSettings.usernameInfo=Username can only contain letters, numbers and the following special characters @._+- or must be a valid email address.
adminUserSettings.role=Rol
adminUserSettings.actions=Ekintzak
adminUserSettings.apiUser=APIren erabiltzaile mugatua
adminUserSettings.extraApiUser=Additional Limited API User
adminUserSettings.webOnlyUser=Web-erabiltzailea bakarrik
adminUserSettings.demoUser=Demo User (No custom settings)
adminUserSettings.internalApiUser=Internal API User
adminUserSettings.forceChange=Force user to change password on login
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=آیا باید وضعیت کاربر
adminUserSettings.usernameInfo=نام کاربری فقط می‌تواند شامل حروف، اعداد و کاراکترهای خاص @._+- باشد یا باید یک آدرس ایمیل معتبر باشد.
adminUserSettings.role=نقش
adminUserSettings.actions=اقدامات
adminUserSettings.apiUser=کاربر محدود API
adminUserSettings.extraApiUser=کاربر محدود اضافی API
adminUserSettings.webOnlyUser=فقط کاربر وب
adminUserSettings.demoUser=کاربر دمو (بدون تنظیمات سفارشی)
adminUserSettings.internalApiUser=کاربر داخلی API
adminUserSettings.forceChange=مجبور کردن کاربر به تغییر رمز عبور هنگام ورود
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Voulez vous vraiment déactiver/réact
adminUserSettings.usernameInfo=Le nom d'utilisateur ne peut contenir que des lettres, des chiffres et les caractères spéciaux suivants @._+- ou doit être une adresse e-mail valide.
adminUserSettings.role=Rôle
adminUserSettings.actions=Actions
adminUserSettings.apiUser=Utilisateur API limité
adminUserSettings.extraApiUser=Utilisateur limité supplémentaire de l'API
adminUserSettings.webOnlyUser=Utilisateur Web uniquement
adminUserSettings.demoUser=Demo User (Paramètres par défaut)
adminUserSettings.internalApiUser=Utilisateur de l'API interne
adminUserSettings.forceChange=Forcer l'utilisateur à changer son nom d'utilisateur/mot de passe lors de la connexion
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Ar cheart an t-úsáideoir a dhíchuma
adminUserSettings.usernameInfo=Ní féidir ach litreacha, uimhreacha agus na carachtair speisialta seo a leanas @._+- a bheith san ainm úsáideora nó ní mór gur seoladh ríomhphoist bailí é.
adminUserSettings.role=Ról
adminUserSettings.actions=Gníomhartha
adminUserSettings.apiUser=Úsáideoir API Teoranta
adminUserSettings.extraApiUser=Úsáideoir API Teoranta breise
adminUserSettings.webOnlyUser=Úsáideoir Gréasáin Amháin
adminUserSettings.demoUser=Úsáideoir Taispeána (Gan socruithe saincheaptha)
adminUserSettings.internalApiUser=Úsáideoir API Inmheánach
adminUserSettings.forceChange=Cuir iallach ar an úsáideoir pasfhocal a athrú ar logáil isteach
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=क्या उपयोगकर्
adminUserSettings.usernameInfo=उपयोगकर्ता नाम में केवल अक्षर, संख्याएं और निम्नलिखित विशेष वर्ण @._+- हो सकते हैं या एक वैध ईमेल पता होना चाहिए।
adminUserSettings.role=भूमिका
adminUserSettings.actions=कार्रवाइयां
adminUserSettings.apiUser=सीमित API उपयोगकर्ता
adminUserSettings.extraApiUser=अतिरिक्त सीमित API उपयोगकर्ता
adminUserSettings.webOnlyUser=केवल वेब उपयोगकर्ता
adminUserSettings.demoUser=डेमो उपयोगकर्ता (कोई कस्टम सेटिंग्स नहीं)
adminUserSettings.internalApiUser=आंतरिक API उपयोगकर्ता
adminUserSettings.forceChange=लॉगिन पर उपयोगकर्ता को पासवर्ड बदलने के लिए मजबूर करें
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Treba li isključiti/uključiti ovog k
adminUserSettings.usernameInfo=Korisničko ime može sadržavati samo slova, brojke i sljedeće posebne znakove @._+- ili mora biti važeća adresa e-pošte.
adminUserSettings.role=Uloga
adminUserSettings.actions=Akcije
adminUserSettings.apiUser=Korisnik s ograničenim API pristupom
adminUserSettings.extraApiUser=Dodatni korisnik s ograničenim API pristupom
adminUserSettings.webOnlyUser=Web Korisnik
adminUserSettings.demoUser=Demo korisnik (Bez prilagođenih Postavki)
adminUserSettings.internalApiUser=Interni API Korisnik
adminUserSettings.forceChange=Prisiliti korisnika da promijeni lozinku prilikom prijave
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Biztosan módosítja a felhasználó
adminUserSettings.usernameInfo=A felhasználónév csak betűket, számokat és a következő speciális karaktereket tartalmazhatja: @._+- vagy érvényes e-mail címnek kell lennie.
adminUserSettings.role=Szerepkör
adminUserSettings.actions=Műveletek
adminUserSettings.apiUser=Korlátozott API felhasználó
adminUserSettings.extraApiUser=További korlátozott API felhasználó
adminUserSettings.webOnlyUser=Csak webes felhasználó
adminUserSettings.demoUser=Demo felhasználó (egyedi beállítások nélkül)
adminUserSettings.internalApiUser=Belső API felhasználó
adminUserSettings.forceChange=Jelszóváltoztatás kikényszerítése bejelentkezéskor
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Haruskah pengguna dinonaktifkan/diakti
adminUserSettings.usernameInfo=Nama pengguna hanya boleh mengandung huruf, angka, dan karakter khusus berikut @._+- atau harus berupa alamat email yang valid.
adminUserSettings.role=Peran
adminUserSettings.actions=Tindakan
adminUserSettings.apiUser=Pengguna API Terbatas
adminUserSettings.extraApiUser=Pengguna API Terbatas Tambahan
adminUserSettings.webOnlyUser=Pengguna Khusus Web
adminUserSettings.demoUser=Pengguna Demo (Tanpa pengaturan kustom)
adminUserSettings.internalApiUser=Pengguna API Internal
adminUserSettings.forceChange=Memaksa pengguna untuk mengubah nama pengguna/kata sandi saat masuk
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=L'utente dovrebbe essere disabilitato/
adminUserSettings.usernameInfo=Il nome utente può contenere solo lettere, numeri e i seguenti caratteri speciali @._+- oppure deve essere un indirizzo email valido.
adminUserSettings.role=Ruolo
adminUserSettings.actions=Azioni
adminUserSettings.apiUser=Utente API limitato
adminUserSettings.extraApiUser=API utente limitato aggiuntivo
adminUserSettings.webOnlyUser=Utente solo Web
adminUserSettings.demoUser=Utente demo (nessuna impostazione personalizzata)
adminUserSettings.internalApiUser=API utente interna
adminUserSettings.forceChange=Forza l'utente a cambiare nome utente/password all'accesso
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=ユーザーを無効/有効にする
adminUserSettings.usernameInfo=ユーザー名には、文字、数字、および次の特殊文字 @._+- のみを含めることができます。または、有効な電子メール アドレスである必要があります。
adminUserSettings.role=ロール
adminUserSettings.actions=アクション
adminUserSettings.apiUser=限定されたAPIユーザー
adminUserSettings.extraApiUser=追加の制限付きAPIユーザー
adminUserSettings.webOnlyUser=ウェブ専用ユーザー
adminUserSettings.demoUser=デモユーザー (カスタム設定なし)
adminUserSettings.internalApiUser=内部APIユーザー
adminUserSettings.forceChange=ログイン時にユーザー名/パスワードを強制的に変更する
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=사용자를 비활성화/활성화하
adminUserSettings.usernameInfo=사용자 이름은 문자, 숫자 및 @._+- 특수문자만 포함하거나 유효한 이메일 주소여야 합니다.
adminUserSettings.role=역할
adminUserSettings.actions=작업
adminUserSettings.apiUser=제한된 API 사용자
adminUserSettings.extraApiUser=추가 제한된 API 사용자
adminUserSettings.webOnlyUser=웹 전용 사용자
adminUserSettings.demoUser=데모 사용자 (사용자 지정 설정 없음)
adminUserSettings.internalApiUser=내부 API 사용자
adminUserSettings.forceChange=로그인 시 사용자 비밀번호 변경 강제
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=ഉപയോക്താവിനെ
adminUserSettings.usernameInfo=ഉപയോക്തൃനാമത്തിൽ അക്ഷരങ്ങൾ, അക്കങ്ങൾ, താഴെ പറയുന്ന പ്രത്യേക പ്രതീകങ്ങൾ @._+- എന്നിവ മാത്രമേ ഉണ്ടാകാവൂ അല്ലെങ്കിൽ സാധുവായ ഒരു ഇമെയിൽ വിലാസം ആയിരിക്കണം.
adminUserSettings.role=റോൾ
adminUserSettings.actions=പ്രവർത്തനങ്ങൾ
adminUserSettings.apiUser=പരിമിതമായ API ഉപയോക്താവ്
adminUserSettings.extraApiUser=അധിക പരിമിതമായ API ഉപയോക്താവ്
adminUserSettings.webOnlyUser=വെബ് മാത്രം ഉപയോക്താവ്
adminUserSettings.demoUser=ഡെമോ ഉപയോക്താവ് (ഇഷ്ടാനുസൃത ക്രമീകരണങ്ങളില്ല)
adminUserSettings.internalApiUser=ആന്തരിക API ഉപയോക്താവ്
adminUserSettings.forceChange=ലോഗിൻ ചെയ്യുമ്പോൾ പാസ്വേഡ് മാറ്റാൻ ഉപയോക്താവിനെ നിർബന്ധിക്കുക
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Should the user be disabled/enabled?
adminUserSettings.usernameInfo=Gebruikersnaam kan alleen letters, nummers en de volgende speciale tekens @._+- bevatten of moet een geldig emailadres zijn.
adminUserSettings.role=Rol
adminUserSettings.actions=Acties
adminUserSettings.apiUser=Beperkte API gebruiker
adminUserSettings.extraApiUser=Extra beperkte API gebruiker
adminUserSettings.webOnlyUser=Alleen web gebruiker
adminUserSettings.demoUser=Demogebruiker (geen aangepaste instellingen)
adminUserSettings.internalApiUser=Interne API gebruiker
adminUserSettings.forceChange=Forceer gebruiker om gebruikersnaam/wachtwoord te wijzigen bij inloggen
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Skal brukeren deaktiveres/aktiveres?
adminUserSettings.usernameInfo=Brukernavn kan bare inneholde bokstaver, tall og følgende spesialtegn @._+- eller må være en gyldig e-postadresse.
adminUserSettings.role=Rolle
adminUserSettings.actions=Handlinger
adminUserSettings.apiUser=Begrenset API Bruker
adminUserSettings.extraApiUser=Ekstra Begrenset API Bruker
adminUserSettings.webOnlyUser=Kun Web Bruker
adminUserSettings.demoUser=Demo Bruker (Ingen tilpassede innstillinger)
adminUserSettings.internalApiUser=Intern API Bruker
adminUserSettings.forceChange=Tving bruker til å endre passord ved innlogging
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Czy użytkownik powinien zostać wył
adminUserSettings.usernameInfo=Niewłaściwa nazwa użytkownika - musi zawierać litery, cyfry i @._+- LUB być adresem email.
adminUserSettings.role=Rola
adminUserSettings.actions=Akcje
adminUserSettings.apiUser=Ograniczony Użytkownik API
adminUserSettings.extraApiUser=Dodatkowy ograniczony Użytkownik API
adminUserSettings.webOnlyUser=Użytkownik tylko WEB
adminUserSettings.demoUser=Użytkownik DEMO
adminUserSettings.internalApiUser=Wewnętrzny użytkownik API
adminUserSettings.forceChange=Wymuś zmianę hasło po zalogowaniu
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=O usuário deve ser desabilitado/habil
adminUserSettings.usernameInfo=Nome de usuário só pode incluir letras, números e os seguintes caracteres especiais @._+- ou deve ser um e-mail válido.
adminUserSettings.role=Função
adminUserSettings.actions=Ações
adminUserSettings.apiUser=Usuário de API limitado
adminUserSettings.extraApiUser=Usuário de API limitado adicional
adminUserSettings.webOnlyUser=Usuário web apenas
adminUserSettings.demoUser=Usuário demo (Sem configurações personalizadas)
adminUserSettings.internalApiUser=Usuário de API interno
adminUserSettings.forceChange=Forçar usuário a trocar a senha ao iniciar sessão
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Deve o utilizador ser desativado/ativa
adminUserSettings.usernameInfo=O nome de utilizador só pode conter letras, números e os seguintes caracteres especiais @._+- ou deve ser um endereço de email válido.
adminUserSettings.role=Função
adminUserSettings.actions=Ações
adminUserSettings.apiUser=Utilizador API Limitado
adminUserSettings.extraApiUser=Utilizador API Limitado Adicional
adminUserSettings.webOnlyUser=Utilizador Apenas Web
adminUserSettings.demoUser=Utilizador Demo (Sem Definições Personalizadas)
adminUserSettings.internalApiUser=Utilizador API Interno
adminUserSettings.forceChange=Forçar utilizador a alterar palavra-passe no login
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Ar trebui dezactivat/activat utilizato
adminUserSettings.usernameInfo=Numele de utilizator poate conține doar litere, numere și următoarele caractere speciale @._+- sau trebuie să fie o adresă de email validă.
adminUserSettings.role=Rol
adminUserSettings.actions=Acțiuni
adminUserSettings.apiUser=Utilizator API Limitat
adminUserSettings.extraApiUser=Utilizator API Limitat Suplimentar
adminUserSettings.webOnlyUser=Utilizator Doar Web
adminUserSettings.demoUser=Utilizator Demo (Fără setări personalizate)
adminUserSettings.internalApiUser=Utilizator API Intern
adminUserSettings.forceChange=Forțează utilizatorul să schimbe parola la conectare
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Отключить/включить п
adminUserSettings.usernameInfo=Имя пользователя может содержать только буквы, цифры и следующие специальные символы @._+- или должно быть действительным адресом электронной почты.
adminUserSettings.role=Роль
adminUserSettings.actions=Действия
adminUserSettings.apiUser=Ограниченный пользователь API
adminUserSettings.extraApiUser=Дополнительный ограниченный пользователь API
adminUserSettings.webOnlyUser=Только веб-пользователь
adminUserSettings.demoUser=Демо-пользователь (без настраиваемых параметров)
adminUserSettings.internalApiUser=Внутренний пользователь API
adminUserSettings.forceChange=Требовать смену пароля при входе
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Should the user be disabled/enabled?
adminUserSettings.usernameInfo=Používateľské meno musí obsahovať iba písmená a čísla, žiadne medzery alebo špeciálne znaky.
adminUserSettings.role=Rola
adminUserSettings.actions=Akcie
adminUserSettings.apiUser=Obmedzený API používateľ
adminUserSettings.extraApiUser=Ďalší obmedzený API používateľ
adminUserSettings.webOnlyUser=Používateľ iba pre web
adminUserSettings.demoUser=Demo používateľ (Bez vlastných nastavení)
adminUserSettings.internalApiUser=Interný API používateľ
adminUserSettings.forceChange=Donútiť používateľa zmeniť heslo pri prihlásení
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Ali naj bo uporabnik onemogočen/omogo
adminUserSettings.usernameInfo=Uporabniško ime lahko vsebuje samo črke, številke in naslednje posebne znake @._+- ali mora biti veljaven e-poštni naslov.
adminUserSettings.role=Vloga
adminUserSettings.actions=Dejanja
adminUserSettings.apiUser=Omejen uporabnik API-ja
adminUserSettings.extraApiUser=Dodatni omejeni uporabnik API-ja
adminUserSettings.webOnlyUser=Samo spletni uporabnik
adminUserSettings.demoUser=Demo uporabnik (brez nastavitev po meri)
adminUserSettings.internalApiUser=Notranji uporabnik API-ja
adminUserSettings.forceChange=Prisili uporabnika, da spremeni geslo ob prijavi
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Da onemogućim/omogućim korisnika?
adminUserSettings.usernameInfo=Korisničko ime može sadržati samo slova, brojeve i specijalne karaktere @._+- ili mora biti validna email adresa.
adminUserSettings.role=Uloga
adminUserSettings.actions=Akcije
adminUserSettings.apiUser=Korisnik s ograničenim API pristupom
adminUserSettings.extraApiUser=Dodatni ograničeni API korisnik
adminUserSettings.webOnlyUser=Korisnik samo za web
adminUserSettings.demoUser=Demo korisnik (Bez prilagođenih podešavanja)
adminUserSettings.internalApiUser=Interni API korisnik
adminUserSettings.forceChange=Prisili korisnika da promeni korisničko ime/lozinku pri prijavi
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Ska användaren inaktiveras/aktiveras?
adminUserSettings.usernameInfo=Användarnamn kan endast innehålla bokstäver, siffror och följande specialtecken @._+- eller måste vara en giltig e-postadress.
adminUserSettings.role=Roll
adminUserSettings.actions=Åtgärder
adminUserSettings.apiUser=Begränsad API-användare
adminUserSettings.extraApiUser=Ytterligare begränsad API-användare
adminUserSettings.webOnlyUser=Endast webbanvändare
adminUserSettings.demoUser=Demoanvändare (Inga anpassade inställningar)
adminUserSettings.internalApiUser=Intern API-användare
adminUserSettings.forceChange=Tvinga användare att ändra lösenord vid inloggning
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=ผู้ใช้นี้ควร
adminUserSettings.usernameInfo=ชื่อผู้ใช้สามารถประกอบด้วยตัวอักษร ตัวเลข และอักขระพิเศษต่อไปนี้ @._+- หรือจะต้องเป็นที่อยู่อีเมลที่ถูกต้อง
adminUserSettings.role=บทบาท
adminUserSettings.actions=การดำเนินการ
adminUserSettings.apiUser=ผู้ใช้ API จำกัด
adminUserSettings.extraApiUser=ผู้ใช้ API เพิ่มเติม
adminUserSettings.webOnlyUser=ผู้ใช้เว็บเท่านั้น
adminUserSettings.demoUser=ผู้ใช้ทดลอง (ไม่มีการตั้งค่าปรับแต่ง)
adminUserSettings.internalApiUser=ผู้ใช้ API ภายใน
adminUserSettings.forceChange=บังคับให้ผู้ใช้เปลี่ยนรหัสผ่านในการเข้าสู่ระบบ
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Kullanıcı devre dışı bırakılmal
adminUserSettings.usernameInfo=Kullanıcı adı yalnızca harf, rakam ve aşağıdaki özel karakterleri @._+- içerebilir veya geçerli bir e-posta adresi olmalıdır.
adminUserSettings.role=Rol
adminUserSettings.actions=Eylemler
adminUserSettings.apiUser=Sınırlı API Kullanıcısı
adminUserSettings.extraApiUser=Ek Sınırlı API Kullanıcısı
adminUserSettings.webOnlyUser=Sadece Web Kullanıcısı
adminUserSettings.demoUser=Demo Kullanıcısı (Özel ayar yok)
adminUserSettings.internalApiUser=Dahili API Kullanıcısı
adminUserSettings.forceChange=Kullanıcının girişte kullanıcı adı/şifre değiştirmesini zorla
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Чи потрібно вимкнути
adminUserSettings.usernameInfo=Ім’я користувача може містити лише літери, цифри та наступні спеціальні символи @._+- або має бути дійсною електронною адресою.
adminUserSettings.role=Роль
adminUserSettings.actions=Дії
adminUserSettings.apiUser=Обмежений користувач API
adminUserSettings.extraApiUser=Додатковий обмежений користувач API
adminUserSettings.webOnlyUser=Тільки веб-користувач
adminUserSettings.demoUser=Демо-користувач (без налаштованих параметрів)
adminUserSettings.internalApiUser=Внутрішній користувач API
adminUserSettings.forceChange=Примусити користувача змінити пароль при вході в систему
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Should the user be disabled/enabled?
adminUserSettings.usernameInfo=Tên người dùng chỉ có thể chứa chữ cái, số và các ký tự đặc biệt sau @._+- hoặc phải là một địa chỉ email hợp lệ.
adminUserSettings.role=Vai trò
adminUserSettings.actions=Hành động
adminUserSettings.apiUser=Người dùng API giới hạn
adminUserSettings.extraApiUser=Người dùng API giới hạn bổ sung
adminUserSettings.webOnlyUser=Chỉ người dùng web
adminUserSettings.demoUser=Người dùng demo (Không có cài đặt tùy chỉnh)
adminUserSettings.internalApiUser=Người dùng API nội bộ
adminUserSettings.forceChange=Buộc người dùng thay đổi mật khẩu khi đăng nhập
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=是否应禁用/启用该用户?
adminUserSettings.usernameInfo=用户名只能包含字母、数字和以下特殊字符@._+-,或者必须是有效的电子邮件地址。
adminUserSettings.role=角色
adminUserSettings.actions=操作
adminUserSettings.apiUser=受限制的 API 用户
adminUserSettings.extraApiUser=额外受限制的 API 用户
adminUserSettings.webOnlyUser=仅限 Web 用户
adminUserSettings.demoUser=演示用户(无自定义设置)
adminUserSettings.internalApiUser=内部 API 用户
adminUserSettings.forceChange=强制用户在登录时更改用户名/密码
@@ -455,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=是否要停用/啟用此使用者?
adminUserSettings.usernameInfo=使用者名稱只能包含字母、數字和以下特殊字元 @._+- 或必須是有效的電子郵件地址。
adminUserSettings.role=角色
adminUserSettings.actions=操作
adminUserSettings.apiUser=受限制的 API 使用者
adminUserSettings.extraApiUser=額外受限制的 API 使用者
adminUserSettings.webOnlyUser=僅網頁版使用者
adminUserSettings.demoUser=示範使用者(無自訂設定)
adminUserSettings.internalApiUser=內部 API 使用者
adminUserSettings.forceChange=強制使用者在登入時變更密碼
@@ -32,7 +32,7 @@
</div>
<!-- Admin Settings Banner (for admins only) -->
<div th:if="${role == 'ROLE_ADMIN'}" class="data-panel data-mb-3" style="background-color: var(--md-sys-color-secondary-container);">
<div th:if="${isSystemAdmin}" class="data-panel data-mb-3" style="background-color: var(--md-sys-color-secondary-container);">
<div class="data-body" style="display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.5rem; background-color: var(--md-sys-color-secondary-container);">
<div style="display: flex; align-items: center; gap: 1rem;">
<span class="material-symbols-rounded" style="font-size: 2rem; color: var(--md-sys-color-secondary);">
-2
View File
@@ -37,8 +37,6 @@ dependencies {
api "org.springframework.security:spring-security-core:$springSecuritySamlVersion"
api "org.springframework.security:spring-security-web:$springSecuritySamlVersion"
api "org.springframework.security:spring-security-config:$springSecuritySamlVersion"
api("org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.5.4")
api "org.springframework.security:spring-security-oauth2-jose:$springSecuritySamlVersion"
api "org.springframework.security:spring-security-saml2-service-provider:$springSecuritySamlVersion"
api 'org.springframework.boot:spring-boot-starter-jetty'
api 'org.springframework.boot:spring-boot-starter-security'
@@ -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,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 stirling.software.proprietary.model.security.PersistentAuditEvent;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.util.SecretMasker;
@Component
@@ -30,13 +30,13 @@ public class CustomAuditEventRepository implements AuditEventRepository {
private final PersistentAuditEventRepository repo;
private final ObjectMapper mapper;
/* ── READ side intentionally inert (endpoint disabled) ── */
/* READ side intentionally inert (endpoint disabled) */
@Override
public List<AuditEvent> find(String p, Instant after, String type) {
return List.of();
}
/* ── WRITE side (async) ───────────────────────────────── */
/* WRITE side (async) */
@Async("auditExecutor")
@Override
public void add(AuditEvent ev) {
@@ -33,7 +33,7 @@ public class AdminJobController {
* @return Job statistics
*/
@GetMapping("/api/v1/admin/job/stats")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
public ResponseEntity<JobStats> getJobStats() {
JobStats stats = taskManager.getJobStats();
log.info(
@@ -49,7 +49,7 @@ public class AdminJobController {
* @return Queue statistics
*/
@GetMapping("/api/v1/admin/job/queue/stats")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
public ResponseEntity<?> getQueueStats() {
Map<String, Object> queueStats = jobQueue.getQueueStats();
log.info("Admin requested queue stats: {} queued jobs", queueStats.get("queuedJobs"));
@@ -62,7 +62,7 @@ public class AdminJobController {
* @return A response indicating how many jobs were cleaned up
*/
@PostMapping("/api/v1/admin/job/cleanup")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
public ResponseEntity<?> cleanupOldJobs() {
int beforeCount = taskManager.getJobStats().getTotalJobs();
taskManager.cleanupOldJobs();
@@ -40,14 +40,14 @@ import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.model.security.PersistentAuditEvent;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
/** Controller for the audit dashboard. Admin-only access. */
@Slf4j
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
@RequestMapping("/audit")
@PreAuthorize("hasRole('ADMIN')")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
@RequiredArgsConstructor
@EnterpriseEndpoint
public class AuditDashboardController {
@@ -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.");
}
}
@@ -0,0 +1,119 @@
package stirling.software.proprietary.model;
import java.io.Serializable;
import java.time.Instant;
import java.time.YearMonth;
import java.util.HashSet;
import java.util.Set;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import stirling.software.proprietary.model.converter.YearMonthStringConverter;
@Entity
@Table(
name = "anonymous_api_credit_usage",
uniqueConstraints = {
@UniqueConstraint(
name = "uq_anon_credit_fingerprint_month",
columnNames = {"fingerprint", "month"})
},
indexes = {
@Index(name = "idx_anon_credit_fingerprint", columnList = "fingerprint"),
@Index(name = "idx_anon_credit_month", columnList = "month"),
@Index(name = "idx_anon_credit_ip", columnList = "ip_address")
})
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class AnonymousCreditUsage implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
@EqualsAndHashCode.Include
@ToString.Include
private Long id;
@NotNull
@Column(name = "fingerprint", nullable = false, length = 128)
@ToString.Include
private String fingerprint;
@NotNull
@Column(name = "month", nullable = false, length = 7)
@Convert(converter = YearMonthStringConverter.class)
private YearMonth month;
@NotNull
@Column(name = "credits_consumed", nullable = false)
@Builder.Default
private Integer creditsConsumed = 0;
@NotNull
@Column(name = "credits_allocated", nullable = false)
@Builder.Default
private Integer creditsAllocated = 0;
@Column(name = "ip_address", length = 45)
private String ipAddress;
@Column(name = "user_agent", length = 512)
private String userAgent;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "anonymous_api_related_fingerprints",
joinColumns = @JoinColumn(name = "usage_id"))
@Column(name = "related_fingerprint")
@Builder.Default
private Set<String> relatedFingerprints = new HashSet<>();
@Column(name = "abuse_score")
@Builder.Default
private Integer abuseScore = 0;
@Column(name = "is_blocked")
@Builder.Default
private Boolean isBlocked = false;
@Column(name = "last_access")
private Instant lastAccess;
@CreationTimestamp
@Column(name = "created_at", updatable = false, nullable = false)
private Instant createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
@Column(name = "version")
private Long version;
@PreUpdate
public void preUpdate() {
this.lastAccess = Instant.now();
}
public int getRemainingCredits() {
return Math.max(0, creditsAllocated - creditsConsumed);
}
public boolean hasCreditsRemaining(int creditCost) {
return getRemainingCredits() >= creditCost;
}
}
@@ -0,0 +1,132 @@
package stirling.software.proprietary.model;
import java.io.Serializable;
import java.time.Instant;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.*;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import stirling.software.proprietary.security.model.User;
@Entity
@Table(
name = "api_credit_configs",
uniqueConstraints = {
@UniqueConstraint(
name = "uq_user_credit_cfg",
columnNames = {"scope_type", "user_id"}),
@UniqueConstraint(
name = "uq_org_credit_cfg",
columnNames = {"scope_type", "org_id"}),
@UniqueConstraint(
name = "uq_role_credit_cfg",
columnNames = {"scope_type", "role_name"})
},
indexes = {
@Index(name = "idx_credit_cfg_user", columnList = "user_id"),
@Index(name = "idx_credit_cfg_org", columnList = "org_id"),
@Index(name = "idx_credit_cfg_role", columnList = "role_name"),
@Index(name = "idx_credit_cfg_scope", columnList = "scope_type")
})
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class ApiCreditConfig implements Serializable {
private static final long serialVersionUID = 1L;
public enum ScopeType {
USER,
ORGANIZATION,
ROLE_DEFAULT
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
@EqualsAndHashCode.Include
@ToString.Include
private Long id;
@NotNull
@Column(name = "scope_type", nullable = false, length = 20)
@Enumerated(EnumType.STRING)
private ScopeType scopeType;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = true)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "org_id", nullable = true)
private Organization organization;
@Column(name = "role_name", nullable = true, length = 50)
private String roleName;
@NotNull
@Min(0)
@Column(name = "monthly_credits", nullable = false)
private Integer monthlyCredits;
@NotNull
@Builder.Default
@Column(name = "is_pooled", nullable = false)
private Boolean isPooled = false;
@NotNull
@Builder.Default
@Column(name = "is_active", nullable = false)
private Boolean isActive = true;
@CreationTimestamp
@Column(name = "created_at", updatable = false, nullable = false)
private Instant createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
@Column(name = "version")
private Long version;
@PrePersist
@PreUpdate
protected void validateScope() {
int nonNullCount = 0;
if (user != null) nonNullCount++;
if (organization != null) nonNullCount++;
if (roleName != null) nonNullCount++;
if (nonNullCount != 1) {
throw new IllegalStateException(
"Exactly one of user, organization, or roleName must be set");
}
if (user != null && scopeType != ScopeType.USER) {
throw new IllegalStateException("ScopeType must be USER when user is set");
}
if (organization != null && scopeType != ScopeType.ORGANIZATION) {
throw new IllegalStateException(
"ScopeType must be ORGANIZATION when organization is set");
}
if (roleName != null && scopeType != ScopeType.ROLE_DEFAULT) {
throw new IllegalStateException("ScopeType must be ROLE_DEFAULT when roleName is set");
}
if (Boolean.TRUE.equals(isPooled) && scopeType != ScopeType.ORGANIZATION) {
throw new IllegalStateException("isPooled can only be true for ORGANIZATION scope");
}
}
}
@@ -0,0 +1,127 @@
package stirling.software.proprietary.model;
import java.io.Serializable;
import java.time.Instant;
import java.time.YearMonth;
import java.time.ZoneOffset;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.*;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import stirling.software.proprietary.security.model.User;
@Entity
@Table(
name = "api_credit_usage",
uniqueConstraints = {
@UniqueConstraint(
name = "uq_credit_user_month",
columnNames = {"user_id", "month_key"}),
@UniqueConstraint(
name = "uq_credit_org_month",
columnNames = {"org_id", "month_key"})
},
indexes = {
@Index(name = "idx_credit_usage_user_month", columnList = "user_id, month_key"),
@Index(name = "idx_credit_usage_org_month", columnList = "org_id, month_key"),
@Index(name = "idx_credit_usage_month", columnList = "month_key")
})
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class ApiCreditUsage implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
@EqualsAndHashCode.Include
@ToString.Include
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = true)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "org_id", nullable = true)
private Organization organization;
@NotNull
@Column(name = "month_key", nullable = false, length = 7)
@ToString.Include
private YearMonth monthKey;
@NotNull
@Min(0)
@Builder.Default
@Column(name = "credits_consumed", nullable = false)
private Integer creditsConsumed = 0;
@NotNull
@Min(0)
@Builder.Default
@Column(name = "credits_allocated", nullable = false)
private Integer creditsAllocated = 0;
@CreationTimestamp
@Column(name = "created_at", updatable = false, nullable = false)
private Instant createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
@Column(name = "version")
private Long version;
@PrePersist
@PreUpdate
protected void validateScope() {
if ((user == null && organization == null) || (user != null && organization != null)) {
throw new IllegalStateException("Exactly one of user or organization must be set");
}
}
public static YearMonth getCurrentMonth() {
return YearMonth.now(ZoneOffset.UTC);
}
public static ApiCreditUsage forUser(User user, int creditsAllocated) {
return ApiCreditUsage.builder()
.user(user)
.monthKey(getCurrentMonth())
.creditsConsumed(0)
.creditsAllocated(creditsAllocated)
.build();
}
public static ApiCreditUsage forOrganization(Organization org, int creditsAllocated) {
return ApiCreditUsage.builder()
.organization(org)
.monthKey(getCurrentMonth())
.creditsConsumed(0)
.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
}
@@ -0,0 +1,45 @@
package stirling.software.proprietary.model;
import java.io.Serializable;
import java.util.HashSet;
import java.util.Set;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "organizations")
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class Organization implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "org_id")
private Long id;
@Column(name = "name", unique = true, nullable = false)
private String name;
@Column(name = "description")
private String description;
@OneToMany(mappedBy = "organization", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Team> teams = new HashSet<>();
public void addTeam(Team team) {
teams.add(team);
team.setOrganization(this);
}
public void removeTeam(Team team) {
teams.remove(team);
team.setOrganization(null);
}
}
@@ -11,7 +11,7 @@ import lombok.*;
import stirling.software.proprietary.security.model.User;
@Entity
@Table(name = "teams")
@Table(name = "teams", uniqueConstraints = @UniqueConstraint(columnNames = {"name", "org_id"}))
@NoArgsConstructor
@Getter
@Setter
@@ -26,9 +26,15 @@ public class Team implements Serializable {
@Column(name = "team_id")
private Long id;
@Column(name = "name", unique = true, nullable = false)
@Column(name = "name", nullable = false)
private String name;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(
name = "org_id",
nullable = true) // Nullable for backward compatibility during migration
private Organization organization;
@OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<User> users = new HashSet<>();
@@ -0,0 +1,23 @@
package stirling.software.proprietary.model.converter;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
@Converter(autoApply = true)
public class YearMonthStringConverter implements AttributeConverter<YearMonth, String> {
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
@Override
public String convertToDatabaseColumn(YearMonth yearMonth) {
return yearMonth != null ? yearMonth.format(FORMATTER) : null;
}
@Override
public YearMonth convertToEntityAttribute(String dbData) {
return dbData != null ? YearMonth.parse(dbData, FORMATTER) : null;
}
}
@@ -0,0 +1,15 @@
package stirling.software.proprietary.model.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OrganizationWithTeamCountDTO {
private Long id;
private String name;
private String description;
private Long teamCount;
}
@@ -15,9 +15,12 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
import stirling.software.proprietary.security.service.OrganizationService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
@@ -28,6 +31,8 @@ public class InitialSecuritySetup {
private final UserService userService;
private final TeamService teamService;
private final OrganizationService organizationService;
private final TeamRepository teamRepository;
private final ApplicationProperties applicationProperties;
private final DatabaseServiceInterface databaseService;
@@ -44,6 +49,9 @@ public class InitialSecuritySetup {
}
userService.migrateOauth2ToSSO();
migrateAdminRolesToSystemAdmin();
migrateDeprecatedRolesToUser();
assignTeamsToDefaultOrganizationIfMissing();
assignUsersToDefaultTeamIfMissing();
initializeInternalApiUser();
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
@@ -52,6 +60,49 @@ public class InitialSecuritySetup {
}
}
private void assignTeamsToDefaultOrganizationIfMissing() {
// Find teams without organizations (legacy teams from before org feature)
List<Team> teamsWithoutOrg = teamRepository.findTeamsWithoutOrganization();
if (teamsWithoutOrg.isEmpty()) {
log.debug("No teams without organizations found - migration not needed");
return;
}
log.info(
"Found {} teams without organizations. Starting migration...",
teamsWithoutOrg.size());
// Ensure default organizations exist
Organization defaultOrg = organizationService.getOrCreateDefaultOrganization();
Organization internalOrg = organizationService.getOrCreateInternalOrganization();
int migratedCount = 0;
for (Team team : teamsWithoutOrg) {
try {
// Assign internal team to internal org, all others to default org
if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) {
team.setOrganization(internalOrg);
log.debug("Assigned team '{}' to internal organization", team.getName());
} else {
team.setOrganization(defaultOrg);
log.debug("Assigned team '{}' to default organization", team.getName());
}
teamRepository.save(team);
migratedCount++;
} catch (Exception e) {
log.error(
"Failed to migrate team '{}' to organization: {}",
team.getName(),
e.getMessage());
}
}
if (migratedCount > 0) {
log.info("Successfully migrated {} teams to organizations", migratedCount);
}
}
private void assignUsersToDefaultTeamIfMissing() {
Team defaultTeam = teamService.getOrCreateDefaultTeam();
Team internalTeam = teamService.getOrCreateInternalTeam();
@@ -86,7 +137,7 @@ public class InitialSecuritySetup {
Team team = teamService.getOrCreateDefaultTeam();
userService.saveUser(
initialUsername, initialPassword, team, Role.ADMIN.getRoleId(), false);
initialUsername, initialPassword, team, Role.SYSTEM_ADMIN.getRoleId(), false);
log.info("Admin user created: {}", initialUsername);
} else {
createDefaultAdminUser();
@@ -100,7 +151,7 @@ public class InitialSecuritySetup {
if (userService.findByUsernameIgnoreCase(defaultUsername).isEmpty()) {
Team team = teamService.getOrCreateDefaultTeam();
userService.saveUser(
defaultUsername, defaultPassword, team, Role.ADMIN.getRoleId(), true);
defaultUsername, defaultPassword, team, Role.SYSTEM_ADMIN.getRoleId(), true);
log.info("Default admin user created: {}", defaultUsername);
}
}
@@ -134,4 +185,89 @@ public class InitialSecuritySetup {
}
userService.syncCustomApiUser(applicationProperties.getSecurity().getCustomGlobalAPIKey());
}
private void migrateAdminRolesToSystemAdmin() {
List<User> adminUsers = userService.findByRole(Role.ADMIN.getRoleId());
if (adminUsers.isEmpty()) {
log.debug("No ROLE_ADMIN users found - migration not needed");
return;
}
log.info("Found {} ROLE_ADMIN users. Converting to SYSTEM_ADMIN...", adminUsers.size());
int migratedCount = 0;
for (User user : adminUsers) {
try {
user.setUserRole(Role.SYSTEM_ADMIN);
userService.saveUser(user);
log.debug(
"Converted user '{}' from ROLE_ADMIN to SYSTEM_ADMIN", user.getUsername());
migratedCount++;
} catch (Exception e) {
log.error(
"Failed to migrate user '{}' from ROLE_ADMIN to SYSTEM_ADMIN: {}",
user.getUsername(),
e.getMessage());
}
}
if (migratedCount > 0) {
log.info(
"Successfully migrated {} users from ROLE_ADMIN to SYSTEM_ADMIN",
migratedCount);
}
}
private void migrateDeprecatedRolesToUser() {
String[] deprecatedRoles = {
"ROLE_WEB_ONLY_USER", "ROLE_EXTRA_LIMITED_API_USER", "ROLE_LIMITED_API_USER"
};
int totalMigrated = 0;
for (String deprecatedRole : deprecatedRoles) {
List<User> usersWithDeprecatedRole = userService.findByRole(deprecatedRole);
if (!usersWithDeprecatedRole.isEmpty()) {
log.info(
"Found {} users with role {}. Converting to USER...",
usersWithDeprecatedRole.size(),
deprecatedRole);
int migratedCount = 0;
for (User user : usersWithDeprecatedRole) {
try {
user.setUserRole(Role.USER);
userService.saveUser(user);
log.debug(
"Converted user '{}' from {} to USER",
user.getUsername(),
deprecatedRole);
migratedCount++;
} catch (Exception e) {
log.error(
"Failed to migrate user '{}' from {} to USER: {}",
user.getUsername(),
deprecatedRole,
e.getMessage());
}
}
if (migratedCount > 0) {
log.info(
"Successfully migrated {} users from {} to USER",
migratedCount,
deprecatedRole);
totalMigrated += migratedCount;
}
}
}
if (totalMigrated == 0) {
log.debug("No users with deprecated roles found - migration not needed");
} else {
log.info("Total users migrated from deprecated roles to USER: {}", totalMigrated);
}
}
}
@@ -7,3 +7,14 @@ import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
@Component
@RequiredArgsConstructor
public class RateLimitResetScheduler {
private final IPRateLimitingFilter rateLimitingFilter;
@Scheduled(cron = "0 0 0 * * MON") // At 00:00 every Monday TODO: configurable
public void resetRateLimit() {
rateLimitingFilter.resetRequestCounts();
}
}
@@ -7,6 +7,7 @@ import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -202,6 +203,7 @@ public class AccountWebController {
// @PreAuthorize("hasRole('ROLE_ADMIN')")
// @GetMapping("/usage")
public String showUsage() {
if (!runningEE) {
return "error";
@@ -211,11 +213,25 @@ public class AccountWebController {
// @PreAuthorize("hasRole('ROLE_ADMIN')")
// @GetMapping("/adminSettings")
public String showAddUserForm(
HttpServletRequest request, Model model, Authentication authentication) {
List<User> allUsers = userRepository.findAllWithTeam();
Iterator<User> iterator = allUsers.iterator();
Map<String, String> roleDetails = Role.getAllRoleDetails();
// Filter role details to only show SYSTEM_ADMIN, USER, and DEMO_USER in UI
Map<String, String> filteredRoleDetails = new LinkedHashMap<>();
String[] allowedRoles = {
Role.SYSTEM_ADMIN.getRoleId(), Role.USER.getRoleId(), Role.DEMO_USER.getRoleId()
};
for (String roleId : allowedRoles) {
if (roleDetails.containsKey(roleId)) {
filteredRoleDetails.put(roleId, roleDetails.get(roleId));
}
}
roleDetails = filteredRoleDetails;
// Map to store session information and user activity status
Map<String, Boolean> userSessions = new HashMap<>();
Map<String, Date> userLastRequest = new HashMap<>();
@@ -423,6 +439,7 @@ public class AccountWebController {
model.addAttribute("username", username);
model.addAttribute("messageType", messageType);
model.addAttribute("role", user.get().getRolesAsString());
model.addAttribute("isSystemAdmin", user.get().isSystemAdmin());
model.addAttribute("settings", settingsJson);
model.addAttribute("changeCredsFlag", user.get().isFirstLogin());
model.addAttribute("currentPage", "account");
@@ -1,311 +0,0 @@
package stirling.software.proprietary.security.configuration;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationListener;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.event.AbstractAuthenticationEvent;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
/**
* Security configuration for Supabase-issued JWTs only.
*
* Requires:
*
* spring:
* security:
* oauth2:
* resourceserver:
* jwt:
* issuer-uri: https://<project-id>.supabase.co/auth/v1
*
* Optional logging (application.yml):
* logging:
* level:
* org.springframework.security: DEBUG
* your.project.pkg.security: DEBUG
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class);
/** Your Supabase project ref, e.g. abcd1234efgh5678ijkl */
@Value("${app.supabase.project-ref:nrlkjfznsavsbmweiyqu}")
private String projectRef;
/** Optional audience to enforce (leave empty to skip) */
@Value("${app.jwt.expected-aud:}")
private String expectedAud;
/** Clock skew in seconds for exp validation */
@Value("${app.jwt.clock-skew-seconds:120}")
private long clockSkewSeconds;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// allow CORS preflight only
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
// public endpoints
.requestMatchers(
new AntPathRequestMatcher("/actuator/health"),
new AntPathRequestMatcher("/public/**"),
new AntPathRequestMatcher("/images/**"),
new AntPathRequestMatcher("/css/**"),
new AntPathRequestMatcher("/js/**")
).permitAll()
// everything else requires auth
.anyRequest().authenticated()
)
.addFilterBefore(new VerboseAuthLoggingFilter(), BearerTokenAuthenticationFilter.class)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
.accessDeniedHandler(new BearerTokenAccessDeniedHandler())
)
.oauth2ResourceServer(oauth -> oauth
.jwt(jwt -> jwt
.decoder(jwtDecoder)
.jwtAuthenticationConverter(this::toAuthentication)
)
);
return http.build();
}
/** CORS config so browser can send Authorization on real requests. */
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOrigins(List.of(
"http://localhost:3000", // dev
"http://localhost:5173",
"http://localhost:8080",
"https://your-frontend.example"// prod
));
cfg.setAllowedMethods(List.of("GET","POST","PUT","PATCH","DELETE","OPTIONS"));
cfg.setAllowedHeaders(List.of("Authorization","Content-Type","X-Requested-With","Accept","Origin"));
cfg.setExposedHeaders(List.of("WWW-Authenticate"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
}
/** JWKS-based decoder + custom validator (issuer/exp/aud). */
@Bean
JwtDecoder jwtDecoder() {
String issuer = "https://" + projectRef + ".supabase.co/auth/v1"; // no trailing slash
String jwks = issuer + "/.well-known/jwks.json";
log.info("Configuring JWT decoder with JWKS: {}", jwks);
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwks).build();
decoder.setJwtValidator(new CompositeValidator(issuer, expectedAud, Duration.ofSeconds(clockSkewSeconds)));
if (expectedAud == null || expectedAud.isBlank()) {
log.info("JWT validation: enforcing issuer='{}' and exp (skew {}s)", issuer, clockSkewSeconds);
} else {
log.info("JWT validation: enforcing issuer='{}', audience='{}', and exp (skew {}s)",
issuer, expectedAud, clockSkewSeconds);
}
return decoder;
}
/** Map claims -> authorities. DEBUG: hotmail.com => ROLE_ADMIN. */
private AbstractAuthenticationToken toAuthentication(Jwt jwt) {
List<GrantedAuthority> authorities = new ArrayList<>();
// Supabase default role -> ROLE_authenticated (optional but handy)
String supabaseRole = jwt.getClaimAsString("role"); // often "authenticated"
if (supabaseRole != null && !supabaseRole.isBlank()) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + supabaseRole));
}
// Your custom app_role (if you add it via Access Token Hook) -> ROLE_*
String appRole = jwt.getClaimAsString("app_role");
if (appRole != null && !appRole.isBlank()) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + appRole.toUpperCase()));
}
// DEBUG RULE: email domain admin
String email = jwt.getClaimAsString("email");
if (email != null && email.toLowerCase(Locale.ROOT).endsWith("@hotmail.com")) {
authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
} else {
// Give a basic user role for convenience while debugging (optional)
authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
}
// Optional permissions array -> PERM_*
List<String> perms = jwt.getClaimAsStringList("permissions");
if (perms != null) {
perms.stream()
.filter(p -> p != null && !p.isBlank())
.map(p -> new SimpleGrantedAuthority("PERM_" + p))
.forEach(authorities::add);
}
String principalName = (email != null && !email.isBlank()) ? email : jwt.getSubject();
if (log.isDebugEnabled()) {
log.debug("JWT accepted: sub='{}', email='{}', supabase.role='{}', app_role='{}', permissions={}",
jwt.getSubject(), email, supabaseRole, appRole, perms);
log.debug("Granted authorities: {}", authorities.stream()
.map(GrantedAuthority::getAuthority).collect(Collectors.toList()));
}
return new JwtAuthenticationToken(jwt, authorities, principalName);
}
/** Logs authentication lifecycle events (success/failure). */
@Bean
ApplicationListener<AbstractAuthenticationEvent> authenticationEventsLogger() {
return event -> {
try {
if (event.getSource() instanceof AbstractAuthenticationToken auth) {
String type = event.getClass().getSimpleName();
String name = auth.getName();
String authorities = auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).collect(Collectors.joining(","));
log.debug("[AuthEvent] {} principal='{}' authorities='{}' details={}",
type, name, authorities, auth.getDetails());
} else {
log.debug("[AuthEvent] {} source={}", event.getClass().getSimpleName(), event.getSource());
}
} catch (Exception e) {
log.warn("Failed to log authentication event", e);
}
};
}
/** Super-chatty per-request logger around bearer processing; never logs raw token. */
static class VerboseAuthLoggingFilter extends OncePerRequestFilter {
private static final Logger flog = LoggerFactory.getLogger(VerboseAuthLoggingFilter.class);
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
boolean hasBearer = authHeader != null && authHeader.startsWith("Bearer ");
if (flog.isDebugEnabled()) {
flog.debug("[REQ] {} {} AuthorizationHeaderPresent={} (token hidden)",
request.getMethod(), request.getRequestURI(), hasBearer);
}
try {
filterChain.doFilter(request, response);
} catch (InvalidBearerTokenException ibte) {
flog.warn("[AUTH] Invalid bearer token: {}", ibte.getMessage());
throw ibte;
} catch (Exception ex) {
flog.error("[AUTH] Unexpected auth error: {}", ex.toString(), ex);
throw ex;
}
var auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
if (auth instanceof JwtAuthenticationToken jwtAuth) {
String authorities = jwtAuth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).collect(Collectors.joining(","));
flog.debug("[AUTH] OK principal='{}' authorities='{}'", jwtAuth.getName(), authorities);
} else {
flog.debug("[AUTH] No authentication established");
}
}
}
/** Validator: issuer == expected, not expired (with skew), optional audience. */
static final class CompositeValidator implements OAuth2TokenValidator<Jwt> {
private final String expectedIssuer; // not null
private final String expectedAudienceOrNull; // may be null/blank
private final Duration skew;
CompositeValidator(String expectedIssuer, String expectedAudienceOrNull, Duration skew) {
this.expectedIssuer = Objects.requireNonNull(expectedIssuer);
this.expectedAudienceOrNull = (expectedAudienceOrNull != null && !expectedAudienceOrNull.isBlank())
? expectedAudienceOrNull : null;
this.skew = Objects.requireNonNull(skew);
}
@Override
public OAuth2TokenValidatorResult validate(Jwt token) {
List<OAuth2Error> errors = new ArrayList<>();
String iss = token.getIssuer() != null ? token.getIssuer().toString() : null;
if (iss == null || !iss.equals(expectedIssuer)) {
errors.add(new OAuth2Error("invalid_token", "Invalid issuer: " + iss, null));
}
Instant exp = token.getExpiresAt();
if (exp == null) {
errors.add(new OAuth2Error("invalid_token", "Missing exp claim", null));
} else if (exp.isBefore(Instant.now().minus(skew))) {
errors.add(new OAuth2Error("invalid_token", "Token expired at " + exp, null));
}
if (expectedAudienceOrNull != null) {
List<String> aud = token.getAudience();
if (aud == null || !aud.contains(expectedAudienceOrNull)) {
errors.add(new OAuth2Error("invalid_token", "Missing/invalid audience: " + expectedAudienceOrNull, null));
}
}
if (!errors.isEmpty()) {
errors.forEach(e -> log.warn("JWT validation error: {} - {}", e.getErrorCode(), e.getDescription()));
return OAuth2TokenValidatorResult.failure(errors);
}
return OAuth2TokenValidatorResult.success();
}
}
}
@@ -0,0 +1,363 @@
package stirling.software.proprietary.security.configuration;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.env.Environment;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.savedrequest.NullRequestCache;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.CustomAuthenticationFailureHandler;
import stirling.software.proprietary.security.CustomAuthenticationSuccessHandler;
import stirling.software.proprietary.security.CustomLogoutSuccessHandler;
import stirling.software.proprietary.security.database.repository.JPATokenRepositoryImpl;
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
import stirling.software.proprietary.security.filter.ApiCreditFilter;
import stirling.software.proprietary.security.filter.CreditOutcomeFilter;
import stirling.software.proprietary.security.filter.FirstLoginFilter;
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationFailureHandler;
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationSuccessHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationSuccessHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2ResponseAuthenticationConverter;
import stirling.software.proprietary.security.service.CustomOAuth2UserService;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@Slf4j
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@DependsOn("runningProOrHigher")
public class SecurityConfiguration {
private final CustomUserDetailsService userDetailsService;
private final UserService userService;
private final boolean loginEnabledValue;
private final boolean runningProOrHigher;
private final ApplicationProperties applicationProperties;
private final AppConfig appConfig;
private final UserAuthenticationFilter userAuthenticationFilter;
private final LoginAttemptService loginAttemptService;
private final FirstLoginFilter firstLoginFilter;
private final ApiCreditFilter apiCreditFilter;
private final CreditOutcomeFilter creditOutcomeFilter;
private final SessionPersistentRegistry sessionRegistry;
private final PersistentLoginRepository persistentLoginRepository;
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations;
private final OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver;
private final Environment environment;
public SecurityConfiguration(
PersistentLoginRepository persistentLoginRepository,
CustomUserDetailsService userDetailsService,
@Lazy UserService userService,
@Qualifier("loginEnabled") boolean loginEnabledValue,
@Qualifier("runningProOrHigher") boolean runningProOrHigher,
AppConfig appConfig,
ApplicationProperties applicationProperties,
UserAuthenticationFilter userAuthenticationFilter,
LoginAttemptService loginAttemptService,
FirstLoginFilter firstLoginFilter,
ApiCreditFilter apiCreditFilter,
CreditOutcomeFilter creditOutcomeFilter,
SessionPersistentRegistry sessionRegistry,
@Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper,
@Autowired(required = false)
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
@Autowired(required = false)
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver,
Environment environment) {
this.userDetailsService = userDetailsService;
this.userService = userService;
this.loginEnabledValue = loginEnabledValue;
this.runningProOrHigher = runningProOrHigher;
this.appConfig = appConfig;
this.applicationProperties = applicationProperties;
this.userAuthenticationFilter = userAuthenticationFilter;
this.loginAttemptService = loginAttemptService;
this.firstLoginFilter = firstLoginFilter;
this.apiCreditFilter = apiCreditFilter;
this.creditOutcomeFilter = creditOutcomeFilter;
this.sessionRegistry = sessionRegistry;
this.persistentLoginRepository = persistentLoginRepository;
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations;
this.saml2AuthenticationRequestResolver = saml2AuthenticationRequestResolver;
this.environment = environment;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
if (applicationProperties.getSecurity().getCsrfDisabled() || !loginEnabledValue) {
http.csrf(csrf -> csrf.disable());
}
if (loginEnabledValue) {
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
if (!applicationProperties.getSecurity().getCsrfDisabled()) {
CookieCsrfTokenRepository cookieRepo =
CookieCsrfTokenRepository.withHttpOnlyFalse();
CsrfTokenRequestAttributeHandler requestHandler =
new CsrfTokenRequestAttributeHandler();
requestHandler.setCsrfRequestAttributeName(null);
http.csrf(
csrf -> {
var csrfConfig =
csrf.ignoringRequestMatchers(
request -> {
String apiKey = request.getHeader("X-API-KEY");
// If there's no API key, don't ignore CSRF
// (return false)
if (apiKey == null || apiKey.trim().isEmpty()) {
return false;
}
// Validate API key using existing UserService
try {
Optional<User> user =
userService.getUserByApiKey(apiKey);
// If API key is valid, ignore CSRF (return
// true)
// If API key is invalid, don't ignore CSRF
// (return false)
return user.isPresent();
} catch (Exception e) {
// If there's any error validating the API
// key, don't ignore CSRF
return false;
}
});
// Only ignore CSRF for H2 console if H2 console is enabled
if (isH2ConsoleEnabled()) {
csrfConfig = csrfConfig.ignoringRequestMatchers("/h2-console/**");
}
csrfConfig
.csrfTokenRepository(cookieRepo)
.csrfTokenRequestHandler(requestHandler);
});
}
// Allow H2 console frames only if H2 console is enabled
if (isH2ConsoleEnabled()) {
http.headers(
headers -> headers.frameOptions(frameOptions -> frameOptions.sameOrigin()));
}
http.addFilterBefore(rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class);
http.addFilterAfter(apiCreditFilter, UserAuthenticationFilter.class);
http.addFilterAfter(creditOutcomeFilter, ApiCreditFilter.class);
http.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class);
http.sessionManagement(
sessionManagement ->
sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(10)
.maxSessionsPreventsLogin(false)
.sessionRegistry(sessionRegistry)
.expiredUrl("/login?logout=true"));
http.authenticationProvider(daoAuthenticationProvider());
http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache()));
http.logout(
logout ->
logout.logoutRequestMatcher(
PathPatternRequestMatcher.withDefaults()
.matcher("/logout"))
.logoutSuccessHandler(
new CustomLogoutSuccessHandler(
applicationProperties, appConfig))
.clearAuthentication(true)
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID", "remember-me"));
http.rememberMe(
rememberMeConfigurer -> // Use the configurator directly
rememberMeConfigurer
.tokenRepository(persistentTokenRepository())
.tokenValiditySeconds( // 14 days
14 * 24 * 60 * 60)
.userDetailsService( // Your existing UserDetailsService
userDetailsService)
.useSecureCookie( // Enable secure cookie
true)
.rememberMeParameter( // Form parameter name
"remember-me")
.rememberMeCookieName( // Cookie name
"remember-me")
.alwaysRemember(false));
http.authorizeHttpRequests(
authz ->
authz.requestMatchers(
req -> {
String uri = req.getRequestURI();
String contextPath = req.getContextPath();
// Remove the context path from the URI
String trimmedUri =
uri.startsWith(contextPath)
? uri.substring(
contextPath.length())
: uri;
return trimmedUri.startsWith("/login")
|| trimmedUri.startsWith("/oauth")
|| trimmedUri.startsWith("/saml2")
|| trimmedUri.endsWith(".svg")
|| trimmedUri.startsWith("/register")
|| trimmedUri.startsWith("/error")
|| trimmedUri.startsWith("/images/")
|| trimmedUri.startsWith("/public/")
|| trimmedUri.startsWith("/css/")
|| (isH2ConsoleEnabled()
&& trimmedUri.startsWith(
"/h2-console/"))
|| trimmedUri.startsWith("/fonts/")
|| trimmedUri.startsWith("/js/")
|| trimmedUri.startsWith(
"/api/v1/info/status");
})
.permitAll()
.anyRequest()
.authenticated());
// Handle User/Password Logins
if (applicationProperties.getSecurity().isUserPass()) {
http.formLogin(
formLogin ->
formLogin
.loginPage("/login")
.successHandler(
new CustomAuthenticationSuccessHandler(
loginAttemptService, userService))
.failureHandler(
new CustomAuthenticationFailureHandler(
loginAttemptService, userService))
.defaultSuccessUrl("/")
.permitAll());
}
// Handle OAUTH2 Logins
if (applicationProperties.getSecurity().isOauth2Active()) {
http.oauth2Login(
oauth2 ->
oauth2.loginPage("/oauth2")
/*
This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database.
If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser'
is set as true, else login fails with an error message advising the same.
*/
.successHandler(
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
applicationProperties,
userService))
.failureHandler(
new CustomOAuth2AuthenticationFailureHandler())
. // Add existing Authorities from the database
userInfoEndpoint(
userInfoEndpoint ->
userInfoEndpoint
.oidcUserService(
new CustomOAuth2UserService(
applicationProperties,
userService,
loginAttemptService))
.userAuthoritiesMapper(
oAuth2userAuthoritiesMapper))
.permitAll());
}
// Handle SAML
if (applicationProperties.getSecurity().isSaml2Active() && runningProOrHigher) {
// Configure the authentication provider
OpenSaml4AuthenticationProvider authenticationProvider =
new OpenSaml4AuthenticationProvider();
authenticationProvider.setResponseAuthenticationConverter(
new CustomSaml2ResponseAuthenticationConverter(userService));
http.authenticationProvider(authenticationProvider)
.saml2Login(
saml2 -> {
try {
saml2.loginPage("/saml2")
.relyingPartyRegistrationRepository(
saml2RelyingPartyRegistrations)
.authenticationManager(
new ProviderManager(authenticationProvider))
.successHandler(
new CustomSaml2AuthenticationSuccessHandler(
loginAttemptService,
applicationProperties,
userService))
.failureHandler(
new CustomSaml2AuthenticationFailureHandler())
.authenticationRequestResolver(
saml2AuthenticationRequestResolver);
} catch (Exception e) {
log.error("Error configuring SAML 2 login", e);
throw new RuntimeException(e);
}
});
}
} else {
log.debug("Login is not enabled.");
http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll());
}
return http.build();
}
public DaoAuthenticationProvider daoAuthenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public IPRateLimitingFilter rateLimitingFilter() {
// Example limit TODO add config level
int maxRequestsPerIp = 1000000;
return new IPRateLimitingFilter(maxRequestsPerIp, maxRequestsPerIp);
}
@Bean
public PersistentTokenRepository persistentTokenRepository() {
return new JPATokenRepositoryImpl(persistentLoginRepository);
}
private boolean isH2ConsoleEnabled() {
return environment.getProperty("spring.h2.console.enabled", Boolean.class, false);
}
}
@@ -43,7 +43,7 @@ import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequ
@Tag(name = "Admin Settings", description = "Admin-only Settings Management APIs")
@RequestMapping("/api/v1/admin/settings")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
@Slf4j
public class AdminSettingsController {
@@ -33,7 +33,7 @@ import stirling.software.proprietary.security.service.DatabaseService;
@Slf4j
@Controller
@RequestMapping("/api/v1/database")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
@Conditional(H2SQLCondition.class)
@Tag(name = "Database", description = "Database APIs for backup, import, and management")
@RequiredArgsConstructor
@@ -0,0 +1,216 @@
package stirling.software.proprietary.security.controller.api;
import java.util.List;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.config.PremiumEndpoint;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.RoleBasedAuthorizationService;
@RestController
@RequestMapping("/api/v1/org-admin")
@Tag(name = "Organization Admin", description = "Organization Admin Management APIs")
@Slf4j
@RequiredArgsConstructor
@PremiumEndpoint
@PreAuthorize(
"@roleBasedAuthorizationService.canManageOrgUsers() or @roleBasedAuthorizationService.canManageOrgTeams()")
public class OrgAdminController {
private final TeamRepository teamRepository;
private final UserRepository userRepository;
private final RoleBasedAuthorizationService authorizationService;
/** Get all teams in the org admin's organization */
@GetMapping("/teams")
@PreAuthorize("@roleBasedAuthorizationService.canManageOrgTeams()")
public ResponseEntity<List<Team>> getOrganizationTeams() {
User currentUser = authorizationService.getCurrentUser();
if (currentUser == null || currentUser.getOrganization() == null) {
return ResponseEntity.badRequest().build();
}
List<Team> teams =
teamRepository.findByOrganizationId(currentUser.getOrganization().getId());
return ResponseEntity.ok(teams);
}
/** Get all users in the org admin's organization */
@GetMapping("/users")
public ResponseEntity<List<User>> getOrganizationUsers() {
if (!authorizationService.canManageOrgUsers()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
User currentUser = authorizationService.getCurrentUser();
if (currentUser == null || currentUser.getOrganization() == null) {
return ResponseEntity.badRequest().build();
}
// Get all users in teams belonging to this organization
List<Team> orgTeams =
teamRepository.findByOrganizationId(currentUser.getOrganization().getId());
List<User> orgUsers =
orgTeams.stream().flatMap(team -> team.getUsers().stream()).distinct().toList();
return ResponseEntity.ok(orgUsers);
}
/** Assign a user to a team within the organization */
@PostMapping("/assign-user-to-team")
@Transactional
public ResponseEntity<?> assignUserToTeam(
@RequestParam("userId") Long userId, @RequestParam("teamId") Long teamId) {
if (!authorizationService.canManageOrgUsers()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to manage organization users");
}
Optional<User> userOpt = userRepository.findById(userId);
Optional<Team> teamOpt = teamRepository.findById(teamId);
if (userOpt.isEmpty() || teamOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
Team team = teamOpt.get();
if (!authorizationService.canAddUserToTeam(userId, team)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to add user to this team");
}
// Assign user to team
user.setTeam(team);
userRepository.save(user);
return ResponseEntity.ok().body("User assigned to team successfully");
}
/** Promote a user to team lead */
@PostMapping("/promote-to-team-lead")
@Transactional
public ResponseEntity<?> promoteToTeamLead(@RequestParam("userId") Long userId) {
if (!authorizationService.canManageUser(userId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to manage this user");
}
if (!authorizationService.canAssignRole(Role.TEAM_LEAD)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to assign team lead role");
}
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
// User must be in a team to become a team lead
if (user.getTeam() == null) {
return ResponseEntity.badRequest()
.body("User must be assigned to a team before becoming a team lead");
}
user.setUserRole(Role.TEAM_LEAD);
userRepository.save(user);
return ResponseEntity.ok().body("User promoted to team lead successfully");
}
/** Demote a team lead to regular user */
@PostMapping("/demote-from-team-lead")
@Transactional
public ResponseEntity<?> demoteFromTeamLead(@RequestParam("userId") Long userId) {
if (!authorizationService.canManageUser(userId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to manage this user");
}
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
user.setUserRole(Role.USER);
userRepository.save(user);
return ResponseEntity.ok().body("User demoted from team lead successfully");
}
/** Create a new team in the organization */
@PostMapping("/create-team")
@Transactional
public ResponseEntity<?> createTeam(@RequestParam("teamName") String teamName) {
if (!authorizationService.canManageOrgTeams()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to create teams");
}
User currentUser = authorizationService.getCurrentUser();
if (currentUser == null || currentUser.getOrganization() == null) {
return ResponseEntity.badRequest()
.body("Org admin must be assigned to an organization");
}
Organization organization = currentUser.getOrganization();
// Check if team name already exists in the organization
if (teamRepository.existsByNameIgnoreCaseAndOrganizationId(
teamName, organization.getId())) {
return ResponseEntity.badRequest()
.body("Team with name '" + teamName + "' already exists in this organization");
}
Team newTeam = new Team();
newTeam.setName(teamName);
newTeam.setOrganization(organization);
Team savedTeam = teamRepository.save(newTeam);
return ResponseEntity.ok(savedTeam);
}
/** Remove a user from the organization (removes from their team) */
@PostMapping("/remove-user")
@Transactional
public ResponseEntity<?> removeUserFromOrganization(@RequestParam("userId") Long userId) {
if (!authorizationService.canRemoveUserFromTeam(userId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to remove this user");
}
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
user.setTeam(null);
user.setUserRole(Role.USER); // Reset to basic user role
userRepository.save(user);
return ResponseEntity.ok().body("User removed from organization successfully");
}
}
@@ -0,0 +1,99 @@
package stirling.software.proprietary.security.controller.api;
import java.util.List;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.model.dto.OrganizationWithTeamCountDTO;
import stirling.software.proprietary.security.repository.OrganizationRepository;
import stirling.software.proprietary.security.service.OrganizationService;
import stirling.software.proprietary.security.service.RoleBasedAuthorizationService;
@RestController
@RequestMapping("/api/v1/organizations")
@RequiredArgsConstructor
@PreAuthorize("@roleBasedAuthorizationService.canManageOrganizations()")
public class OrganizationController {
private final OrganizationRepository organizationRepository;
private final OrganizationService organizationService;
private final RoleBasedAuthorizationService authorizationService;
@GetMapping
public ResponseEntity<List<OrganizationWithTeamCountDTO>> getAllOrganizations() {
List<OrganizationWithTeamCountDTO> organizations =
organizationRepository.findAllOrganizationsWithTeamCount();
return ResponseEntity.ok(organizations);
}
@GetMapping("/{id}")
@PreAuthorize(
"@roleBasedAuthorizationService.canViewOrganization(@organizationRepository.findById(#id).orElse(null))")
public ResponseEntity<Organization> getOrganization(@PathVariable Long id) {
Optional<Organization> organizationOpt = organizationRepository.findById(id);
if (organizationOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
Organization organization = organizationOpt.get();
return ResponseEntity.ok(organization);
}
@PostMapping
public ResponseEntity<?> createOrganization(@RequestBody Organization organization) {
if (organizationRepository.existsByNameIgnoreCase(organization.getName())) {
return ResponseEntity.badRequest()
.body("Organization with name '" + organization.getName() + "' already exists");
}
Organization savedOrganization = organizationRepository.save(organization);
return ResponseEntity.status(HttpStatus.CREATED).body(savedOrganization);
}
@PutMapping("/{id}")
public ResponseEntity<?> updateOrganization(
@PathVariable Long id, @RequestBody Organization organization) {
Optional<Organization> existingOrganization = organizationRepository.findById(id);
if (existingOrganization.isEmpty()) {
return ResponseEntity.notFound().build();
}
if (organizationRepository.existsByNameIgnoreCase(organization.getName())
&& !existingOrganization.get().getName().equalsIgnoreCase(organization.getName())) {
return ResponseEntity.badRequest()
.body("Organization with name '" + organization.getName() + "' already exists");
}
organization.setId(id);
Organization savedOrganization = organizationRepository.save(organization);
return ResponseEntity.ok(savedOrganization);
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteOrganization(@PathVariable Long id) {
Optional<Organization> organization = organizationRepository.findById(id);
if (organization.isEmpty()) {
return ResponseEntity.notFound().build();
}
// Prevent deletion of default organizations
if (OrganizationService.DEFAULT_ORG_NAME.equals(organization.get().getName())
|| OrganizationService.INTERNAL_ORG_NAME.equals(organization.get().getName())) {
return ResponseEntity.badRequest().body("Cannot delete system organizations");
}
if (!organization.get().getTeams().isEmpty()) {
return ResponseEntity.badRequest()
.body("Cannot delete organization with existing teams");
}
organizationRepository.deleteById(id);
return ResponseEntity.ok().build();
}
}
@@ -14,11 +14,16 @@ import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.config.PremiumEndpoint;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.OrganizationRepository;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.OrganizationService;
import stirling.software.proprietary.security.service.OrganizationValidationService;
import stirling.software.proprietary.security.service.RoleBasedAuthorizationService;
import stirling.software.proprietary.security.service.TeamService;
@Controller
@@ -27,35 +32,50 @@ import stirling.software.proprietary.security.service.TeamService;
@Slf4j
@RequiredArgsConstructor
@PremiumEndpoint
@PreAuthorize("@roleBasedAuthorizationService.canManageOrgTeams()")
public class TeamController {
private final TeamRepository teamRepository;
private final UserRepository userRepository;
private final OrganizationRepository organizationRepository;
private final OrganizationService organizationService;
private final OrganizationValidationService organizationValidationService;
private final RoleBasedAuthorizationService authorizationService;
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/create")
public RedirectView createTeam(@RequestParam("name") String name) {
if (teamRepository.existsByNameIgnoreCase(name)) {
public RedirectView createTeam(
@RequestParam("name") String name,
@RequestParam("organizationId") Long organizationId) {
Organization organization = organizationService.getOrCreateDefaultOrganization();
if (organizationId != null) {
organization = organizationRepository.findById(organizationId).orElse(organization);
}
if (teamRepository.existsByNameIgnoreCaseAndOrganizationId(name, organization.getId())) {
return new RedirectView("/teams?messageType=teamExists");
}
Team team = new Team();
team.setName(name);
team.setOrganization(organization);
teamRepository.save(team);
return new RedirectView("/teams?messageType=teamCreated");
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/rename")
@PreAuthorize(
"@roleBasedAuthorizationService.canManageTeam(@teamRepository.findById(#teamId).orElse(null))")
public RedirectView renameTeam(
@RequestParam("teamId") Long teamId, @RequestParam("newName") String newName) {
Optional<Team> existing = teamRepository.findById(teamId);
if (existing.isEmpty()) {
return new RedirectView("/teams?messageType=teamNotFound");
}
if (teamRepository.existsByNameIgnoreCase(newName)) {
Team team = existing.get();
if (teamRepository.existsByNameIgnoreCaseAndOrganizationId(
newName, team.getOrganization().getId())) {
return new RedirectView("/teams?messageType=teamNameExists");
}
Team team = existing.get();
// Prevent renaming the Internal team
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
@@ -67,9 +87,10 @@ public class TeamController {
return new RedirectView("/teams?messageType=teamRenamed");
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/delete")
@Transactional
@PreAuthorize(
"@roleBasedAuthorizationService.canManageTeam(@teamRepository.findById(#teamId).orElse(null))")
public RedirectView deleteTeam(@RequestParam("teamId") Long teamId) {
Optional<Team> teamOpt = teamRepository.findById(teamId);
if (teamOpt.isEmpty()) {
@@ -92,9 +113,10 @@ public class TeamController {
return new RedirectView("/teams?messageType=teamDeleted");
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/addUser")
@Transactional
@PreAuthorize(
"@roleBasedAuthorizationService.canAddUserToTeam(#userId, @teamRepository.findById(#teamId).orElse(null))")
public RedirectView addUserToTeam(
@RequestParam("teamId") Long teamId, @RequestParam("userId") Long userId) {
@@ -121,6 +143,13 @@ public class TeamController {
return new RedirectView("/teams/" + teamId + "?error=cannotMoveInternalUsers");
}
// Ensure user and team are in the same organization (or user has no org yet)
if (user.getOrganization() != null
&& !organizationValidationService.isTeamInOrganization(
team, user.getOrganization())) {
return new RedirectView("/teams/" + teamId + "?error=userNotInSameOrganization");
}
// Assign user to team
user.setTeam(team);
userRepository.save(user);
@@ -0,0 +1,169 @@
package stirling.software.proprietary.security.controller.api;
import java.util.List;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.config.PremiumEndpoint;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.RoleBasedAuthorizationService;
@RestController
@RequestMapping("/api/v1/team-lead")
@Tag(name = "Team Lead", description = "Team Lead Management APIs")
@Slf4j
@RequiredArgsConstructor
@PremiumEndpoint
@PreAuthorize("@roleBasedAuthorizationService.canManageTeamUsers()")
public class TeamLeadController {
private final TeamRepository teamRepository;
private final UserRepository userRepository;
private final RoleBasedAuthorizationService authorizationService;
/** Get team members that the current team lead can manage */
@GetMapping("/my-team-members")
public ResponseEntity<List<User>> getMyTeamMembers() {
if (!authorizationService.canManageTeamUsers()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
User currentUser = authorizationService.getCurrentUser();
if (currentUser == null || currentUser.getTeam() == null) {
return ResponseEntity.badRequest().build();
}
List<User> teamMembers = userRepository.findByTeam(currentUser.getTeam());
return ResponseEntity.ok(teamMembers);
}
/** Add a user to the team lead's team */
@PostMapping("/add-member")
@Transactional
public ResponseEntity<?> addMemberToMyTeam(@RequestParam("userId") Long userId) {
User currentUser = authorizationService.getCurrentUser();
if (currentUser == null || currentUser.getTeam() == null) {
return ResponseEntity.badRequest().body("Team lead must be assigned to a team");
}
if (!authorizationService.canAddUserToTeam(userId, currentUser.getTeam())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to add users to this team");
}
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
// Check if user is already in a team
if (user.getTeam() != null) {
return ResponseEntity.badRequest()
.body("User is already assigned to team: " + user.getTeam().getName());
}
// Assign user to team
user.setTeam(currentUser.getTeam());
userRepository.save(user);
return ResponseEntity.ok().body("User added to team successfully");
}
/** Remove a user from the team lead's team */
@PostMapping("/remove-member")
@Transactional
public ResponseEntity<?> removeMemberFromMyTeam(@RequestParam("userId") Long userId) {
if (!authorizationService.canRemoveUserFromTeam(userId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to remove this user");
}
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
User currentUser = authorizationService.getCurrentUser();
// Prevent team leads from removing themselves
if (currentUser != null && currentUser.getId().equals(userId)) {
return ResponseEntity.badRequest()
.body("Team leads cannot remove themselves from the team");
}
// Remove user from team
user.setTeam(null);
userRepository.save(user);
return ResponseEntity.ok().body("User removed from team successfully");
}
/** Update a team member's role (team leads can only assign USER role) */
@PostMapping("/update-member-role")
@Transactional
public ResponseEntity<?> updateMemberRole(
@RequestParam("userId") Long userId, @RequestParam("role") String roleString) {
if (!authorizationService.canManageUser(userId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to manage this user");
}
try {
Role newRole = Role.fromString(roleString);
// Team leads can only assign USER role
if (!authorizationService.canAssignRole(newRole)) {
return ResponseEntity.badRequest()
.body("Not authorized to assign role: " + newRole.getRoleName());
}
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
user.setUserRole(newRole);
userRepository.save(user);
return ResponseEntity.ok().body("User role updated successfully");
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body("Invalid role: " + roleString);
}
}
/** Get team information for the current team lead */
@GetMapping("/my-team")
public ResponseEntity<Team> getMyTeam() {
User currentUser = authorizationService.getCurrentUser();
if (currentUser == null || currentUser.getTeam() == null) {
return ResponseEntity.badRequest().build();
}
if (!authorizationService.canManageTeam(currentUser.getTeam())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
return ResponseEntity.ok(currentUser.getTeam());
}
}
@@ -105,7 +105,9 @@ public class UserController {
if (user.getUsername().equals(newUsername)) {
return new RedirectView("/account?messageType=usernameExists", true);
}
if (!userService.isPasswordCorrect(user, currentPassword)) {
return new RedirectView("/account?messageType=incorrectPassword", true);
}
if (!user.getUsername().equals(newUsername) && userService.usernameExists(newUsername)) {
return new RedirectView("/account?messageType=usernameExists", true);
}
@@ -139,7 +141,9 @@ public class UserController {
return new RedirectView("/change-creds?messageType=userNotFound", true);
}
User user = userOpt.get();
if (!userService.isPasswordCorrect(user, currentPassword)) {
return new RedirectView("/change-creds?messageType=incorrectPassword", true);
}
userService.changePassword(user, newPassword);
userService.changeFirstUse(user, false);
// Logout using Spring's utility
@@ -165,7 +169,9 @@ public class UserController {
return new RedirectView("/account?messageType=userNotFound", true);
}
User user = userOpt.get();
if (!userService.isPasswordCorrect(user, currentPassword)) {
return new RedirectView("/account?messageType=incorrectPassword", true);
}
userService.changePassword(user, newPassword);
// Logout using Spring's utility
new SecurityContextLogoutHandler().logout(request, response, null);
@@ -201,7 +207,7 @@ public class UserController {
return "redirect:/account";
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
@PostMapping("/admin/saveUser")
public RedirectView saveUser(
@RequestParam(name = "username", required = true) String username,
@@ -273,7 +279,7 @@ public class UserController {
true);
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
@PostMapping("/admin/changeRole")
@Transactional
public RedirectView changeRole(
@@ -336,7 +342,7 @@ public class UserController {
true);
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
@PostMapping("/admin/changeUserEnabled/{username}")
public RedirectView changeUserEnabled(
@PathVariable("username") String username,
@@ -386,7 +392,7 @@ public class UserController {
true);
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
@PostMapping("/admin/deleteUser/{username}")
public RedirectView deleteUser(
@PathVariable("username") String username, Authentication authentication) {
@@ -25,6 +25,7 @@ public class DatabaseWebController {
@Deprecated
@PreAuthorize("hasRole('ROLE_ADMIN')")
// @GetMapping("/database")
public String database(HttpServletRequest request, Model model, Authentication authentication) {
String error = request.getParameter("error");
String confirmed = request.getParameter("infoMessage");
@@ -24,9 +24,6 @@ public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByAuthenticationTypeIgnoreCase(String authenticationType);
@Query("SELECT u FROM User u WHERE u.team IS NULL")
List<User> findAllWithoutTeam();
@Query(value = "SELECT u FROM User u LEFT JOIN FETCH u.team")
List<User> findAllWithTeam();
@@ -36,5 +33,11 @@ public interface UserRepository extends JpaRepository<User, Long> {
long countByTeam(Team team);
List<User> findAllByTeam(Team team);
List<User> findByTeam(Team team);
@Query("SELECT u FROM User u WHERE u.team IS NULL")
List<User> findUsersWithoutTeam();
@Query("SELECT u FROM User u JOIN u.authorities a WHERE a.authority = :role")
List<User> findByRole(@Param("role") String role);
}
@@ -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));
}
}
@@ -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));
}
}
@@ -5,6 +5,7 @@ import java.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
@@ -27,6 +28,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
import stirling.software.proprietary.security.matcher.ApiJobEndpointMatcher;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
@@ -41,16 +43,25 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
private final UserService userService;
private final SessionPersistentRegistry sessionPersistentRegistry;
private final boolean loginEnabledValue;
private final ApiJobEndpointMatcher apiJobEndpointMatcher;
@Value("${api.credit-system.anonymous.enabled:true}")
private boolean anonymousApiEnabled;
@Value("${api.credit-system.anonymous.monthly-credits:10}")
private int anonymousMonthlyCredits;
public UserAuthenticationFilter(
@Lazy ApplicationProperties.Security securityProp,
@Lazy UserService userService,
SessionPersistentRegistry sessionPersistentRegistry,
@Qualifier("loginEnabled") boolean loginEnabledValue) {
@Qualifier("loginEnabled") boolean loginEnabledValue,
ApiJobEndpointMatcher apiJobEndpointMatcher) {
this.securityProp = securityProp;
this.userService = userService;
this.sessionPersistentRegistry = sessionPersistentRegistry;
this.loginEnabledValue = loginEnabledValue;
this.apiJobEndpointMatcher = apiJobEndpointMatcher;
}
@Override
@@ -110,11 +121,23 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
}
}
// If we still don't have any authentication, deny the request
// If we still don't have any authentication, check if anonymous API access is allowed
if (authentication == null || !authentication.isAuthenticated()) {
String method = request.getMethod();
String contextPath = request.getContextPath();
// Check if this is an API job endpoint and anonymous access is enabled
if (anonymousApiEnabled && apiJobEndpointMatcher.matches(request)) {
// Check anonymous rate limit
String ipAddress = getClientIpAddress(request);
String userAgent = request.getHeader("User-Agent");
// Anonymous users will be handled by ApiCreditFilter
// Just allow them through for now
filterChain.doFilter(request, response);
return;
}
if ("GET".equalsIgnoreCase(method) && !(contextPath + "/login").equals(requestURI)) {
response.sendRedirect(contextPath + "/login"); // redirect to the login page
return;
@@ -125,6 +148,9 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
"Authentication required. Please provide a X-API-KEY in request"
+ " header.\n"
+ "This is found in Settings -> Account Settings -> API Key\n"
+ "Anonymous users have limited API access ("
+ anonymousMonthlyCredits
+ " credits/month)\n"
+ "Alternatively you can disable authentication if this is"
+ " unexpected");
return;
@@ -255,4 +281,25 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
return false;
}
private String getClientIpAddress(HttpServletRequest request) {
// Check for proxy headers
String[] headers = {
"X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP"
};
for (String header : headers) {
String ip = request.getHeader(header);
if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
// Handle comma-separated IPs
int commaIndex = ip.indexOf(',');
if (commaIndex > 0) {
ip = ip.substring(0, commaIndex).trim();
}
return ip;
}
}
return request.getRemoteAddr();
}
}
@@ -0,0 +1,95 @@
package stirling.software.proprietary.security.matcher;
import java.lang.reflect.Method;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerExecutionChain;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
/**
* Shared matcher component to determine if a request should be subject to anonymous API access and
* credit limiting. This ensures consistent behavior between UserAuthenticationFilter and
* ApiCreditFilter.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class ApiJobEndpointMatcher {
private final RequestMappingHandlerMapping handlerMapping;
@Value("${api.credit-system.exclude-settings:true}")
private boolean excludeSettings;
@Value("${api.credit-system.exclude-actuator:true}")
private boolean excludeActuator;
/**
* Determines if a request matches the criteria for API job endpoints that should be
* credit-limited and allowed for anonymous access.
*
* @param request the HTTP request to check
* @return true if the request is a POST to an @AutoJobPostMapping endpoint
*/
public boolean matches(HttpServletRequest request) {
// Only POST requests are considered
if (!"POST".equalsIgnoreCase(request.getMethod())) {
return false;
}
String path = request.getRequestURI();
// Apply exclusion rules
if (excludeActuator && path != null && path.startsWith("/actuator")) {
return false;
}
if (excludeSettings && isSettingsEndpoint(path)) {
return false;
}
// Check if the handler method has @AutoJobPostMapping annotation
return hasAutoJobPostMapping(request);
}
private boolean hasAutoJobPostMapping(HttpServletRequest request) {
try {
HandlerExecutionChain chain = handlerMapping.getHandler(request);
if (chain == null) {
return false;
}
Object handler = chain.getHandler();
if (!(handler instanceof HandlerMethod handlerMethod)) {
return false;
}
Method method = handlerMethod.getMethod();
return method.isAnnotationPresent(AutoJobPostMapping.class);
} catch (Exception e) {
log.trace(
"Could not resolve handler for {}: {}",
request.getRequestURI(),
e.getMessage());
return false;
}
}
private boolean isSettingsEndpoint(String path) {
return path != null
&& (path.contains("/settings")
|| path.contains("/update-enable-analytics")
|| path.contains("/config")
|| path.contains("/preferences"));
}
}
@@ -55,7 +55,11 @@ public class User implements Serializable {
@Column(name = "authenticationtype")
private String authenticationType;
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
@OneToMany(
fetch = FetchType.EAGER,
cascade = CascadeType.ALL,
mappedBy = "user",
orphanRemoval = true)
private Set<Authority> authorities = new HashSet<>();
@ManyToOne(fetch = FetchType.EAGER)
@@ -102,4 +106,88 @@ public class User implements Serializable {
public boolean hasPassword() {
return this.password != null && !this.password.isEmpty();
}
public stirling.software.proprietary.model.Organization getOrganization() {
return this.team != null ? this.team.getOrganization() : null;
}
// Role-based permission methods
public Role getUserRole() {
String roleString = getRolesAsString();
if (roleString == null || roleString.isEmpty()) return Role.USER;
try {
return Role.fromString(roleString);
} catch (IllegalArgumentException e) {
return Role.USER; // Default fallback
}
}
public boolean isSystemAdmin() {
Role role = getUserRole();
return role.isSystemAdmin();
}
public boolean isOrgAdmin() {
Role role = getUserRole();
return role.isOrgAdmin();
}
public boolean isTeamLead() {
Role role = getUserRole();
return role.isTeamLead();
}
public boolean canManageUser(User otherUser) {
// System admins can manage anyone
if (isSystemAdmin()) return true;
// Org admins can manage users in their organization
if (isOrgAdmin()) {
stirling.software.proprietary.model.Organization thisOrg = getOrganization();
stirling.software.proprietary.model.Organization otherOrg = otherUser.getOrganization();
return thisOrg != null && otherOrg != null && thisOrg.getId().equals(otherOrg.getId());
}
// Team leads can manage users in their team
if (isTeamLead()) {
return this.team != null
&& otherUser.team != null
&& this.team.getId().equals(otherUser.team.getId());
}
return false;
}
public boolean canManageTeam(stirling.software.proprietary.model.Team targetTeam) {
if (targetTeam == null) return false;
// System admins can manage any team
if (isSystemAdmin()) return true;
// Org admins can manage teams in their organization
if (isOrgAdmin()) {
stirling.software.proprietary.model.Organization thisOrg = getOrganization();
stirling.software.proprietary.model.Organization teamOrg = targetTeam.getOrganization();
return thisOrg != null && teamOrg != null && thisOrg.getId().equals(teamOrg.getId());
}
// Team leads can only manage their own team
if (isTeamLead()) {
return this.team != null && this.team.getId().equals(targetTeam.getId());
}
return false;
}
public void setUserRole(Role role) {
// Clear existing authorities
this.authorities.clear();
// Add new authority
Authority authority = new Authority();
authority.setAuthority(role.getRoleId());
authority.setUser(this);
this.authorities.add(authority);
}
}
@@ -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);
}
@@ -0,0 +1,23 @@
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.stereotype.Repository;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.model.dto.OrganizationWithTeamCountDTO;
@Repository
public interface OrganizationRepository extends JpaRepository<Organization, Long> {
Optional<Organization> findByName(String name);
@Query(
"SELECT new stirling.software.proprietary.model.dto.OrganizationWithTeamCountDTO(o.id, o.name, o.description, COUNT(t)) "
+ "FROM Organization o LEFT JOIN o.teams t GROUP BY o.id, o.name, o.description")
List<OrganizationWithTeamCountDTO> findAllOrganizationsWithTeamCount();
boolean existsByNameIgnoreCase(String name);
}
@@ -1,4 +1,4 @@
package stirling.software.proprietary.repository;
package stirling.software.proprietary.security.repository;
import java.time.Instant;
import java.util.List;
@@ -14,10 +14,24 @@ import stirling.software.proprietary.model.dto.TeamWithUserCountDTO;
public interface TeamRepository extends JpaRepository<Team, Long> {
Optional<Team> findByName(String name);
Optional<Team> findByNameAndOrganizationId(String name, Long organizationId);
List<Team> findByOrganizationId(Long organizationId);
@Query(
"SELECT new stirling.software.proprietary.model.dto.TeamWithUserCountDTO(t.id, t.name, COUNT(u)) "
+ "FROM Team t LEFT JOIN t.users u WHERE t.organization.id = :organizationId GROUP BY t.id, t.name")
List<TeamWithUserCountDTO> findAllTeamsWithUserCountByOrganizationId(Long organizationId);
@Query(
"SELECT new stirling.software.proprietary.model.dto.TeamWithUserCountDTO(t.id, t.name, COUNT(u)) "
+ "FROM Team t LEFT JOIN t.users u GROUP BY t.id, t.name")
List<TeamWithUserCountDTO> findAllTeamsWithUserCount();
boolean existsByNameIgnoreCase(String name);
boolean existsByNameIgnoreCaseAndOrganizationId(String name, Long organizationId);
@Query("SELECT t FROM Team t WHERE t.organization IS NULL")
List<Team> findTeamsWithoutOrganization();
}
@@ -37,7 +37,7 @@ class AppUpdateAuthService implements ShowAdminInterface {
}
Optional<User> user = userRepository.findByUsername(authentication.getName());
if (user.isPresent() && showUpdateOnlyAdmin) {
return "ROLE_ADMIN".equals(user.get().getRolesAsString());
return user.get().isSystemAdmin();
}
return showUpdate;
}
@@ -0,0 +1,43 @@
package stirling.software.proprietary.security.service;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.security.repository.OrganizationRepository;
@Service
@RequiredArgsConstructor
public class OrganizationService {
private final OrganizationRepository organizationRepository;
public static final String DEFAULT_ORG_NAME = "Default Organization";
public static final String INTERNAL_ORG_NAME = "Internal Organization";
public Organization getOrCreateDefaultOrganization() {
return organizationRepository
.findByName(DEFAULT_ORG_NAME)
.orElseGet(
() -> {
Organization defaultOrg = new Organization();
defaultOrg.setName(DEFAULT_ORG_NAME);
defaultOrg.setDescription("Default organization for initial setup");
return organizationRepository.save(defaultOrg);
});
}
public Organization getOrCreateInternalOrganization() {
return organizationRepository
.findByName(INTERNAL_ORG_NAME)
.orElseGet(
() -> {
Organization internalOrg = new Organization();
internalOrg.setName(INTERNAL_ORG_NAME);
internalOrg.setDescription(
"Internal organization for system operations");
return organizationRepository.save(internalOrg);
});
}
}
@@ -0,0 +1,79 @@
package stirling.software.proprietary.security.service;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
@Service
@RequiredArgsConstructor
public class OrganizationValidationService {
private final TeamRepository teamRepository;
/**
* Validates that a user has access to a specific team. Users can only access teams within their
* own organization.
*/
public boolean canUserAccessTeam(User user, Team team) {
if (user == null || team == null) {
return false;
}
Organization userOrg = user.getOrganization();
Organization teamOrg = team.getOrganization();
if (userOrg == null || teamOrg == null) {
return false;
}
return userOrg.getId().equals(teamOrg.getId());
}
/** Validates that a user has access to a specific team by ID. */
public boolean canUserAccessTeam(User user, Long teamId) {
if (user == null || teamId == null) {
return false;
}
Organization userOrg = user.getOrganization();
if (userOrg == null) {
return false;
}
return teamRepository
.findById(teamId)
.map(team -> userOrg.getId().equals(team.getOrganization().getId()))
.orElse(false);
}
/** Validates that two users belong to the same organization. */
public boolean areUsersInSameOrganization(User user1, User user2) {
if (user1 == null || user2 == null) {
return false;
}
Organization org1 = user1.getOrganization();
Organization org2 = user2.getOrganization();
if (org1 == null || org2 == null) {
return false;
}
return org1.getId().equals(org2.getId());
}
/** Validates that a team belongs to a specific organization. */
public boolean isTeamInOrganization(Team team, Organization organization) {
if (team == null || organization == null) {
return false;
}
Organization teamOrg = team.getOrganization();
return teamOrg != null && teamOrg.getId().equals(organization.getId());
}
}
@@ -0,0 +1,128 @@
package stirling.software.proprietary.security.service;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
@Service
@RequiredArgsConstructor
public class RoleBasedAuthorizationService {
private final UserRepository userRepository;
/** Gets the current authenticated user */
public User getCurrentUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return null;
}
String username = authentication.getName();
return userRepository.findByUsername(username).orElse(null);
}
/** Checks if current user can manage users across all organizations (System Admin) */
public boolean canManageAllUsers() {
User currentUser = getCurrentUser();
return currentUser != null && currentUser.isSystemAdmin();
}
/** Checks if current user can manage users within their organization (Org Admin or above) */
public boolean canManageOrgUsers() {
User currentUser = getCurrentUser();
return currentUser != null && currentUser.isOrgAdmin();
}
/** Checks if current user can manage team members (Team Lead or above) */
public boolean canManageTeamUsers() {
User currentUser = getCurrentUser();
return currentUser != null && currentUser.isTeamLead();
}
/** Checks if current user can manage a specific user */
public boolean canManageUser(Long userId) {
User currentUser = getCurrentUser();
if (currentUser == null) return false;
User targetUser = userRepository.findById(userId).orElse(null);
if (targetUser == null) return false;
return currentUser.canManageUser(targetUser);
}
/** Checks if current user can manage a specific team */
public boolean canManageTeam(Team team) {
User currentUser = getCurrentUser();
if (currentUser == null || team == null) return false;
return currentUser.canManageTeam(team);
}
/** Checks if current user can manage teams within their organization */
public boolean canManageOrgTeams() {
User currentUser = getCurrentUser();
return currentUser != null && currentUser.isOrgAdmin();
}
/** Checks if current user can create/manage organizations (System Admin only) */
public boolean canManageOrganizations() {
return canManageAllUsers();
}
/** Checks if current user can assign roles */
public boolean canAssignRole(Role targetRole) {
User currentUser = getCurrentUser();
if (currentUser == null) return false;
// Users can only assign roles that are lower than or equal to their own
return currentUser.getUserRole().hasAuthorityOver(targetRole);
}
/** Checks if current user can remove a user from their team/organization */
public boolean canRemoveUserFromTeam(Long userId) {
return canManageUser(userId);
}
/** Checks if current user can add a user to a specific team */
public boolean canAddUserToTeam(Long userId, Team team) {
User currentUser = getCurrentUser();
if (currentUser == null || team == null) return false;
// Must be able to manage both the user and the target team
return canManageUser(userId) && currentUser.canManageTeam(team);
}
/** Gets the highest role the current user can assign to others */
public Role getMaxAssignableRole() {
User currentUser = getCurrentUser();
if (currentUser == null) return Role.USER;
return switch (currentUser.getUserRole()) {
case SYSTEM_ADMIN, ADMIN -> Role.ORG_ADMIN; // System admins can create org admins
case ORG_ADMIN -> Role.TEAM_LEAD; // Org admins can create team leads
case TEAM_LEAD -> Role.USER; // Team leads can only create regular users
default -> Role.USER;
};
}
/** Checks if current user can view organization details */
public boolean canViewOrganization(Organization organization) {
User currentUser = getCurrentUser();
if (currentUser == null || organization == null) return false;
// System admins can view any org
if (currentUser.isSystemAdmin()) return true;
// Org admins and team leads can view their own organization
Organization userOrg = currentUser.getOrganization();
return userOrg != null && userOrg.getId().equals(organization.getId());
}
}
@@ -4,6 +4,7 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.repository.TeamRepository;
@@ -12,29 +13,46 @@ import stirling.software.proprietary.security.repository.TeamRepository;
public class TeamService {
private final TeamRepository teamRepository;
private final OrganizationService organizationService;
public static final String DEFAULT_TEAM_NAME = "Default";
public static final String INTERNAL_TEAM_NAME = "Internal";
public Team getOrCreateDefaultTeam() {
Organization defaultOrg = organizationService.getOrCreateDefaultOrganization();
return teamRepository
.findByName(DEFAULT_TEAM_NAME)
.findByNameAndOrganizationId(DEFAULT_TEAM_NAME, defaultOrg.getId())
.orElseGet(
() -> {
Team defaultTeam = new Team();
defaultTeam.setName(DEFAULT_TEAM_NAME);
defaultTeam.setOrganization(defaultOrg);
return teamRepository.save(defaultTeam);
});
}
public Team getOrCreateInternalTeam() {
Organization internalOrg = organizationService.getOrCreateInternalOrganization();
return teamRepository
.findByName(INTERNAL_TEAM_NAME)
.findByNameAndOrganizationId(INTERNAL_TEAM_NAME, internalOrg.getId())
.orElseGet(
() -> {
Team internalTeam = new Team();
internalTeam.setName(INTERNAL_TEAM_NAME);
internalTeam.setOrganization(internalOrg);
return teamRepository.save(internalTeam);
});
}
public Team getOrCreateTeamForOrganization(String teamName, Organization organization) {
return teamRepository
.findByNameAndOrganizationId(teamName, organization.getId())
.orElseGet(
() -> {
Team team = new Team();
team.setName(teamName);
team.setOrganization(organization);
return teamRepository.save(team);
});
}
}
@@ -51,6 +51,7 @@ public class UserService implements UserServiceInterface {
private final TeamRepository teamRepository;
private final AuthorityRepository authorityRepository;
private final PasswordEncoder passwordEncoder;
private final MessageSource messageSource;
@@ -327,6 +328,10 @@ public class UserService implements UserServiceInterface {
return userRepository.findByUsernameIgnoreCaseWithSettings(username);
}
public List<User> findByRole(String role) {
return userRepository.findByRole(role);
}
public Authority findRole(User user) {
return authorityRepository.findByUserId(user.getId());
}
@@ -343,7 +348,7 @@ public class UserService implements UserServiceInterface {
public void changePassword(User user, String newPassword)
throws SQLException, UnsupportedProviderException {
//user.setPassword(passwordEncoder.encode(newPassword));
user.setPassword(passwordEncoder.encode(newPassword));
userRepository.save(user);
databaseService.exportDatabase();
}
@@ -380,7 +385,10 @@ public class UserService implements UserServiceInterface {
databaseService.exportDatabase();
}
public boolean isPasswordCorrect(User user, String currentPassword) {
return passwordEncoder.matches(currentPassword, user.getPassword());
}
/**
* Resolves a team based on the provided information, with consistent error handling.
*
@@ -452,7 +460,7 @@ public class UserService implements UserServiceInterface {
// Set password if provided
if (password != null && !password.isEmpty()) {
// user.setPassword(passwordEncoder.encode(password));
user.setPassword(passwordEncoder.encode(password));
}
// Set authentication type
@@ -616,10 +624,14 @@ public class UserService implements UserServiceInterface {
}
public List<User> getUsersWithoutTeam() {
return userRepository.findAllWithoutTeam();
return userRepository.findUsersWithoutTeam();
}
public void saveAll(List<User> users) {
userRepository.saveAll(users);
}
public User saveUser(User user) {
return userRepository.save(user);
}
}
@@ -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");
}
}
@@ -15,7 +15,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.repository.PersistentAuditEventRepository;
/** Service to periodically clean up old audit events based on retention policy. */
@Slf4j

Some files were not shown because too many files have changed in this diff Show More