Compare 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 Stirling 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 Stirling 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 Stirling 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 Stirling 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
98 changed files with 3800 additions and 238 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": []
}
@@ -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,13 +45,11 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
String queryString = request.getQueryString();
if (queryString != null && !queryString.isEmpty()) {
String requestURI = request.getRequestURI();
// Reuse the requestURI variable from above
if (requestURI.contains("/api/")) {
return true;
}
Map<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,6 +1,7 @@
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
@@ -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
@@ -55,4 +57,13 @@ posthog.host=https://eu.i.posthog.com
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}
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
# 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);">
@@ -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,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");
@@ -8,6 +8,7 @@ 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;
@@ -37,6 +38,8 @@ import stirling.software.proprietary.security.CustomAuthenticationSuccessHandler
import stirling.software.proprietary.security.CustomLogoutSuccessHandler;
import stirling.software.proprietary.security.database.repository.JPATokenRepositoryImpl;
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
import stirling.software.proprietary.security.filter.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;
@@ -69,11 +72,14 @@ public class SecurityConfiguration {
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,
@@ -86,12 +92,15 @@ public class SecurityConfiguration {
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) {
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver,
Environment environment) {
this.userDetailsService = userDetailsService;
this.userService = userService;
this.loginEnabledValue = loginEnabledValue;
@@ -101,11 +110,14 @@ public class SecurityConfiguration {
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
@@ -129,34 +141,52 @@ public class SecurityConfiguration {
new CsrfTokenRequestAttributeHandler();
requestHandler.setCsrfRequestAttributeName(null);
http.csrf(
csrf ->
csrf.ignoringRequestMatchers(
request -> {
String apiKey = request.getHeader("X-API-KEY");
// If there's no API key, don't ignore 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)
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;
}
})
.csrfTokenRepository(cookieRepo)
.csrfTokenRequestHandler(requestHandler));
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 ->
@@ -215,6 +245,9 @@ public class SecurityConfiguration {
|| trimmedUri.startsWith("/images/")
|| trimmedUri.startsWith("/public/")
|| trimmedUri.startsWith("/css/")
|| (isH2ConsoleEnabled()
&& trimmedUri.startsWith(
"/h2-console/"))
|| trimmedUri.startsWith("/fonts/")
|| trimmedUri.startsWith("/js/")
|| trimmedUri.startsWith(
@@ -323,4 +356,8 @@ public class SecurityConfiguration {
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('ROLE_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());
}
}
@@ -207,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,
@@ -279,7 +279,7 @@ public class UserController {
true);
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
@PostMapping("/admin/changeRole")
@Transactional
public RedirectView changeRole(
@@ -342,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,
@@ -392,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);
});
}
}
@@ -328,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());
}
@@ -620,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
@@ -0,0 +1,35 @@
package stirling.software.proprietary.service;
import org.springframework.stereotype.Component;
import stirling.software.proprietary.model.CreditRequestContext;
/**
* Thread-local storage for credit request context Allows tracking credit information throughout the
* request lifecycle
*/
@Component
public class CreditContextManager {
private static final ThreadLocal<CreditRequestContext> contextHolder = new ThreadLocal<>();
/** Store credit context for the current request thread */
public void setContext(CreditRequestContext context) {
contextHolder.set(context);
}
/** Get credit context for the current request thread */
public CreditRequestContext getContext() {
return contextHolder.get();
}
/** Clear the credit context (should be called at end of request) */
public void clearContext() {
contextHolder.remove();
}
/** Check if there's an active credit context */
public boolean hasContext() {
return contextHolder.get() != null;
}
}
@@ -12,6 +12,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.repository.TeamRepository;
@@ -20,14 +21,23 @@ class TeamServiceTest {
@Mock private TeamRepository teamRepository;
@Mock private OrganizationService organizationService;
@InjectMocks private TeamService teamService;
@Test
void getDefaultTeam() {
var organization = new Organization();
organization.setId(1L);
organization.setName("Default Organization");
var team = new Team();
team.setName("Marleyans");
team.setOrganization(organization);
when(teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME))
when(organizationService.getOrCreateDefaultOrganization()).thenReturn(organization);
when(teamRepository.findByNameAndOrganizationId(
TeamService.DEFAULT_TEAM_NAME, organization.getId()))
.thenReturn(Optional.of(team));
Team result = teamService.getOrCreateDefaultTeam();
@@ -37,12 +47,19 @@ class TeamServiceTest {
@Test
void createDefaultTeam_whenRepositoryIsEmpty() {
var organization = new Organization();
organization.setId(1L);
organization.setName("Default Organization");
String teamName = "Default";
var defaultTeam = new Team();
defaultTeam.setId(1L);
defaultTeam.setName(teamName);
defaultTeam.setOrganization(organization);
when(teamRepository.findByName(teamName)).thenReturn(Optional.empty());
when(organizationService.getOrCreateDefaultOrganization()).thenReturn(organization);
when(teamRepository.findByNameAndOrganizationId(teamName, organization.getId()))
.thenReturn(Optional.empty());
when(teamRepository.save(any(Team.class))).thenReturn(defaultTeam);
Team result = teamService.getOrCreateDefaultTeam();
@@ -52,10 +69,17 @@ class TeamServiceTest {
@Test
void getInternalTeam() {
var organization = new Organization();
organization.setId(2L);
organization.setName("Internal Organization");
var team = new Team();
team.setName("Eldians");
team.setOrganization(organization);
when(teamRepository.findByName(TeamService.INTERNAL_TEAM_NAME))
when(organizationService.getOrCreateInternalOrganization()).thenReturn(organization);
when(teamRepository.findByNameAndOrganizationId(
TeamService.INTERNAL_TEAM_NAME, organization.getId()))
.thenReturn(Optional.of(team));
Team result = teamService.getOrCreateInternalTeam();
@@ -65,15 +89,21 @@ class TeamServiceTest {
@Test
void createInternalTeam_whenRepositoryIsEmpty() {
var organization = new Organization();
organization.setId(2L);
organization.setName("Internal Organization");
String teamName = "Internal";
Team internalTeam = new Team();
internalTeam.setId(2L);
internalTeam.setName(teamName);
internalTeam.setOrganization(organization);
when(teamRepository.findByName(teamName)).thenReturn(Optional.empty());
when(teamRepository.save(any(Team.class))).thenReturn(internalTeam);
when(teamRepository.findByName(TeamService.INTERNAL_TEAM_NAME))
when(organizationService.getOrCreateInternalOrganization()).thenReturn(organization);
when(teamRepository.findByNameAndOrganizationId(
TeamService.INTERNAL_TEAM_NAME, organization.getId()))
.thenReturn(Optional.empty());
when(teamRepository.save(any(Team.class))).thenReturn(internalTeam);
Team result = teamService.getOrCreateInternalTeam();
@@ -286,7 +286,7 @@ class UserServiceTest {
String username = "testuser";
String password = "password123";
Long teamId = 1L;
String customRole = Role.LIMITED_API_USER.getRoleId();
String customRole = Role.USER.getRoleId();
String encodedPassword = "encodedPassword123";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);