diff --git a/core/lang/az/global.php b/core/lang/az/global.php
index cf7686457e..e3290b7475 100644
--- a/core/lang/az/global.php
+++ b/core/lang/az/global.php
@@ -408,6 +408,7 @@
$_lang["user_country"] = 'Ölkə';
$_lang["user_dob"] = 'Doğum tarixi';
$_lang["user_doesnt_exist"] = 'İstifadəçi mövcud deyil';
+$_lang["user_role_invalid"] = 'Seçilmiş rol mövcud deyil. Başqa rol seçin və yenidən yadda saxlayın.';
$_lang["user_edit_self_msg"] = 'Məlumatlarınızı tam yeniləmək üçün yadda saxladıqdan sonra çıxıb yenidən daxil olmalı ola bilərsiniz. Yeni şifrələr e-poçt ünvanınıza göndəriləcək və ya ekranda göstəriləcək.';
$_lang["user_email"] = 'E-poçt ünvanı';
$_lang["user_failedlogincount"] = 'Uğursuz giriş cəhdləri';
diff --git a/core/lang/be/global.php b/core/lang/be/global.php
index a93af798de..ad6302da83 100644
--- a/core/lang/be/global.php
+++ b/core/lang/be/global.php
@@ -396,6 +396,7 @@
$_lang["user_country"] = 'Краіна';
$_lang["user_dob"] = 'Дата нараджэння';
$_lang["user_doesnt_exist"] = 'Карыстальнік не існуе';
+$_lang["user_role_invalid"] = 'Выбраная роля не існуе. Абярыце іншую ролю і захавайце зноў.';
$_lang["user_edit_self_msg"] = 'Магчыма, вам спатрэбіцца выйсці і ўвайсці зноў пасля захавання, каб цалкам абнавіць інфармацыю. Новыя паролі будуць адпраўлены на ваш адрас электроннай пошты або паказаны на экране.';
$_lang["user_email"] = 'Адрас электроннай пошты';
$_lang["user_failedlogincount"] = 'Няўдалыя ўваходы';
diff --git a/core/lang/bg/global.php b/core/lang/bg/global.php
index bdd4b40695..a270d6a065 100644
--- a/core/lang/bg/global.php
+++ b/core/lang/bg/global.php
@@ -1097,6 +1097,7 @@
$_lang["user_country"] = 'Държава';
$_lang["user_dob"] = 'Дата на раждане';
$_lang["user_doesnt_exist"] = 'Потребителят не съществува';
+$_lang["user_role_invalid"] = 'Избраната роля не съществува. Изберете друга роля и запазете отново.';
$_lang["user_edit_self_msg"] = 'След съхраняване трябва да излезете и влезете в системата отново, за да се обнови информацията напълно.
Също така, ако изберете сами да си генерирате парола, тя ще бъде изпратена на пощата ви.';
$_lang["user_email"] = 'Емейл адрес';
$_lang["user_failedlogincount"] = 'Грешни влизания';
diff --git a/core/lang/cs/global.php b/core/lang/cs/global.php
index cc6092173b..749c951236 100644
--- a/core/lang/cs/global.php
+++ b/core/lang/cs/global.php
@@ -1101,6 +1101,7 @@
$_lang["user_country"] = 'Země';
$_lang["user_dob"] = 'Datum narození';
$_lang["user_doesnt_exist"] = 'Uživatel neexistuje';
+$_lang["user_role_invalid"] = 'Vybraná role neexistuje. Zvolte jinou roli a uložte znovu.';
$_lang["user_edit_self_msg"] = 'Pravděpodobně se budete muset odhlásit a přihlásit znovu kvůli kompletní aktualizaci vaších informací.
Také byste si měli nechat vygenerovat nové heslo a nechat si ho poslat e-mailem.';
$_lang["user_email"] = 'Emailová adresa';
$_lang["user_failedlogincount"] = 'Chybná přihlášení';
diff --git a/core/lang/da/global.php b/core/lang/da/global.php
index 0d48c0d206..02fa7d0968 100644
--- a/core/lang/da/global.php
+++ b/core/lang/da/global.php
@@ -1100,6 +1100,7 @@
$_lang["user_country"] = 'Land';
$_lang["user_dob"] = 'Fødselsdato';
$_lang["user_doesnt_exist"] = 'Bruger eksisterer ikke';
+$_lang["user_role_invalid"] = 'Den valgte rolle findes ikke. Vælg en anden rolle, og gem igen.';
$_lang["user_edit_self_msg"] = 'Du skal logge ud og ind igen for at opdatere alle dine informationer. Det nye kodeord vil enten blive sent til den registrerede e-mail adresse eller blive vist på skærmen.';
$_lang["user_email"] = 'E-mail adresse';
$_lang["user_failedlogincount"] = 'Fejlede log ind forsøg';
diff --git a/core/lang/de/global.php b/core/lang/de/global.php
index d7e3f265ed..a98dd1e3b1 100644
--- a/core/lang/de/global.php
+++ b/core/lang/de/global.php
@@ -1182,6 +1182,7 @@
$_lang["user_country"] = 'Land';
$_lang["user_dob"] = 'Geburtsdatum';
$_lang["user_doesnt_exist"] = 'Benutzer existiert nicht.';
+$_lang["user_role_invalid"] = 'Die ausgewählte Rolle existiert nicht. Wählen Sie eine andere Rolle und speichern Sie erneut.';
$_lang["user_edit_self_msg"] = 'Eventuell müssen Sie sich ab- und wieder anmelden, nachdem Sie Ihre Daten geädert haben.';
$_lang["user_email"] = 'E-Mail-Adresse';
$_lang["user_failedlogincount"] = 'Fehlgeschlagene Anmeldungen';
diff --git a/core/lang/en/global.php b/core/lang/en/global.php
index 8d44a5c5ef..c807924ade 100644
--- a/core/lang/en/global.php
+++ b/core/lang/en/global.php
@@ -1229,6 +1229,7 @@
$_lang["user_country"] = 'Country';
$_lang["user_dob"] = 'Date of birth';
$_lang["user_doesnt_exist"] = 'User does not exist';
+$_lang["user_role_invalid"] = 'The selected role does not exist. Choose another role and save again.';
$_lang["user_edit_self_msg"] = 'You may need to log out and log in again after saving to fully update your information. New passwords will be sent to your e-mail address or shown onscreen.';
$_lang["user_email"] = 'E-mail address';
$_lang["user_failedlogincount"] = 'Failed logins';
diff --git a/core/lang/es/global.php b/core/lang/es/global.php
index d9829fe836..e97e9c4f7c 100644
--- a/core/lang/es/global.php
+++ b/core/lang/es/global.php
@@ -1184,6 +1184,7 @@
$_lang["user_country"] = 'País';
$_lang["user_dob"] = 'Fecha de nacimiento';
$_lang["user_doesnt_exist"] = 'El usuario no existe';
+$_lang["user_role_invalid"] = 'El rol seleccionado no existe. Elija otro rol y guarde de nuevo.';
$_lang["user_edit_self_msg"] = 'Necesitas cerrar la sesión e iniciarla nuevamente después de actualizar completamente tu información.
También, si eliges generar una contraseña nueva para tí, te será enviada en un email.';
$_lang["user_email"] = 'Dirección de correo electrónico';
$_lang["user_failedlogincount"] = 'Ingresos fallidos';
diff --git a/core/lang/et/global.php b/core/lang/et/global.php
index 0d6ca929bc..a8c1536300 100644
--- a/core/lang/et/global.php
+++ b/core/lang/et/global.php
@@ -1229,6 +1229,7 @@
$_lang["user_country"] = 'Riik';
$_lang["user_dob"] = 'Sünnikuupäev';
$_lang["user_doesnt_exist"] = 'Kasutajat ei ole olemas';
+$_lang["user_role_invalid"] = 'Valitud rolli ei ole olemas. Valige teine roll ja salvestage uuesti.';
$_lang["user_edit_self_msg"] = 'Teie andmete täielikuks uuendamiseks võib olla vaja pärast salvestamist välja ja uuesti sisse logida. Uued paroolid saadetakse teie e-posti aadressile või kuvatakse ekraanil.';
$_lang["user_email"] = 'E-posti aadress';
$_lang["user_failedlogincount"] = 'Nurjunud sisselogimised';
diff --git a/core/lang/fa/global.php b/core/lang/fa/global.php
index f8e2d1191d..cdab0e4c24 100644
--- a/core/lang/fa/global.php
+++ b/core/lang/fa/global.php
@@ -1098,6 +1098,7 @@
$_lang["user_country"] = 'کشور ';
$_lang["user_dob"] = 'تاریخ تولد ';
$_lang["user_doesnt_exist"] = 'کاربر وجود ندارد';
+$_lang["user_role_invalid"] = 'نقش انتخابشده وجود ندارد. نقش دیگری انتخاب کنید و دوباره ذخیره کنید.';
$_lang["user_edit_self_msg"] = 'برای بروز رسانی کامل اطلاعات شما بایستی یک بار از سیستم خارج و مجددا به سیستم بازگردید
همچنین, میتوانید انتخاب کنید که یک کلمه ی عبور جدید برایتان ارسال شود, کلمه ی عبور جدید شما از طریق پست الکترونیک برای شما ارسال خواهد شد';
$_lang["user_email"] = 'آدرس پست الکترونیک ';
$_lang["user_failedlogincount"] = 'ورود ناموفق ';
diff --git a/core/lang/fi/global.php b/core/lang/fi/global.php
index c1dbb81c0c..deab221145 100644
--- a/core/lang/fi/global.php
+++ b/core/lang/fi/global.php
@@ -1096,6 +1096,7 @@
$_lang["user_country"] = 'Maa';
$_lang["user_dob"] = 'Syntymäaika';
$_lang["user_doesnt_exist"] = 'Käyttäjää ei ole olemassa';
+$_lang["user_role_invalid"] = 'Valittua roolia ei ole olemassa. Valitse toinen rooli ja tallenna uudelleen.';
$_lang["user_edit_self_msg"] = 'Omien tietojen muuttamisen jälkeen saatat joutua kirjautumaan uudestaan ylläpitoon.';
$_lang["user_email"] = 'Sähköpostiosoite';
$_lang["user_failedlogincount"] = 'Epäonnistuneet kirjautumiset';
diff --git a/core/lang/fr/global.php b/core/lang/fr/global.php
index 00697700ed..bffb7cb52d 100644
--- a/core/lang/fr/global.php
+++ b/core/lang/fr/global.php
@@ -1067,6 +1067,7 @@
$_lang["user_country"] = 'Pays';
$_lang["user_dob"] = 'Date de naissance';
$_lang["user_doesnt_exist"] = 'L\'Utilisateur n\'existe pas';
+$_lang["user_role_invalid"] = 'Le rôle sélectionné n\'existe pas. Choisissez un autre rôle et enregistrez à nouveau.';
$_lang["user_edit_self_msg"] = 'Pour que les informations soient effectivement mises à jour, vous devrez vous déconnecter puis vous reconnecter. Les nouveaux mots de passe seront envoyés par email ou affichés sur l\'écran.';
$_lang["user_email"] = 'Adresse email ';
$_lang["user_failedlogincount"] = 'Connexions échouées';
diff --git a/core/lang/he/global.php b/core/lang/he/global.php
index 3ea4b5052b..98f7caa05b 100644
--- a/core/lang/he/global.php
+++ b/core/lang/he/global.php
@@ -1098,6 +1098,7 @@
$_lang["user_country"] = 'מדינה';
$_lang["user_dob"] = 'תאריך לידה';
$_lang["user_doesnt_exist"] = 'המשתמש לא קיים במערכת';
+$_lang["user_role_invalid"] = 'התפקיד שנבחר אינו קיים. בחרו תפקיד אחר ושמרו שוב.';
$_lang["user_edit_self_msg"] = 'You may need to log out and log in again after saving to fully update your information.
Also, should you choose to generate a new password for yourself, it will be sent to you through e-mail.';
$_lang["user_email"] = 'כתובת מייל';
$_lang["user_failedlogincount"] = 'נסיונות התחברות שנכשלו';
diff --git a/core/lang/hy/global.php b/core/lang/hy/global.php
index 7d9f6c5aed..d0ce0fdf08 100644
--- a/core/lang/hy/global.php
+++ b/core/lang/hy/global.php
@@ -1229,6 +1229,7 @@
$_lang["user_country"] = 'Երկիր';
$_lang["user_dob"] = 'Ծննդյան ամսաթիվ';
$_lang["user_doesnt_exist"] = 'Օգտատերը գոյություն չունի';
+$_lang["user_role_invalid"] = 'Ընտրված դերը գոյություն չունի։ Ընտրեք այլ դեր և նորից պահպանեք։';
$_lang["user_edit_self_msg"] = 'Ձեր տեղեկություններն ամբողջությամբ թարմացնելու համար պահպանումից հետո գուցե անհրաժեշտ լինի դուրս գալ և կրկին մուտք գործել։ Նոր գաղտնաբառերը կուղարկվեն ձեր էլ. փոստի հասցեին կամ կցուցադրվեն էկրանին։';
$_lang["user_email"] = 'Էլ. փոստի հասցե';
$_lang["user_failedlogincount"] = 'Մուտքի անհաջող փորձեր';
diff --git a/core/lang/it/global.php b/core/lang/it/global.php
index e96039aa9b..3b70611bb4 100644
--- a/core/lang/it/global.php
+++ b/core/lang/it/global.php
@@ -1193,6 +1193,7 @@
$_lang["user_country"] = 'Nazione';
$_lang["user_dob"] = 'Data di nascita';
$_lang["user_doesnt_exist"] = 'L\'utente non esiste';
+$_lang["user_role_invalid"] = 'Il ruolo selezionato non esiste. Scegli un altro ruolo e salva di nuovo.';
$_lang["user_edit_self_msg"] = 'Se cambiate lo vostre informazioni utente dopo il salvataggio potreste aver bisogno di scollegarvi e di ricollegarvi nuovamente.
Se avete scelto di generare automaticamente una nuova password vi verrà inviata per e-mail, verificate l\'indirizzo e-mail che avete inserito.';
$_lang["user_email"] = 'Indirizzo e-mail';
$_lang["user_failedlogincount"] = 'Login falliti';
diff --git a/core/lang/ja/global.php b/core/lang/ja/global.php
index c4b0282215..0f1bdcc82e 100644
--- a/core/lang/ja/global.php
+++ b/core/lang/ja/global.php
@@ -1216,6 +1216,7 @@
$_lang["user_country"] = '国';
$_lang["user_dob"] = '誕生日';
$_lang["user_doesnt_exist"] = 'ユーザー登録がありません';
+$_lang["user_role_invalid"] = '選択したロールは存在しません。別のロールを選択して、もう一度保存してください。';
$_lang["user_edit_self_msg"] = '自分自身のユーザー情報を変更した場合は、変更を管理画面に反映させるためにいったんログアウトしてください。';
$_lang["user_email"] = 'メールアドレス';
$_lang["user_failedlogincount"] = 'ログイン失敗';
diff --git a/core/lang/ka/global.php b/core/lang/ka/global.php
index 579b5f6f5b..4c03f638f7 100644
--- a/core/lang/ka/global.php
+++ b/core/lang/ka/global.php
@@ -1229,6 +1229,7 @@
$_lang["user_country"] = 'ქვეყანა';
$_lang["user_dob"] = 'დაბადების თარიღი';
$_lang["user_doesnt_exist"] = 'მომხმარებელი არ არსებობს';
+$_lang["user_role_invalid"] = 'არჩეული როლი არ არსებობს. აირჩიეთ სხვა როლი და შეინახეთ ხელახლა.';
$_lang["user_edit_self_msg"] = 'ინფორმაციის სრულად განახლებისთვის შენახვის შემდეგ შესაძლოა სისტემიდან გასვლა და ხელახლა შესვლა დაგჭირდეთ. ახალი პაროლები გამოიგზავნება თქვენს ელფოსტაზე ან გამოჩნდება ეკრანზე.';
$_lang["user_email"] = 'ელფოსტის მისამართი';
$_lang["user_failedlogincount"] = 'შესვლის წარუმატებელი მცდელობები';
diff --git a/core/lang/kk/global.php b/core/lang/kk/global.php
index adfab66b9a..ac253bab60 100644
--- a/core/lang/kk/global.php
+++ b/core/lang/kk/global.php
@@ -1252,6 +1252,7 @@
$_lang["user_country"] = 'Ел';
$_lang["user_dob"] = 'Туған күні';
$_lang["user_doesnt_exist"] = 'Пайдаланушы жоқ';
+$_lang["user_role_invalid"] = 'Таңдалған рөл жоқ. Басқа рөлді таңдап, қайта сақтаңыз.';
$_lang["user_edit_self_msg"] = 'Ақпарат толық жаңаруы үшін сақтағаннан кейін шығып, қайта кіру қажет болуы мүмкін. Жаңа құпиясөз электрондық поштаға жіберіледі немесе экранда көрсетіледі.';
$_lang["user_email"] = 'Электрондық пошта мекенжайы';
$_lang["user_failedlogincount"] = 'Сәтсіз кірулер';
diff --git a/core/lang/ky/global.php b/core/lang/ky/global.php
index 5323d6808e..154ddf7922 100644
--- a/core/lang/ky/global.php
+++ b/core/lang/ky/global.php
@@ -1229,6 +1229,7 @@
$_lang["user_country"] = 'Өлкө';
$_lang["user_dob"] = 'Туулган күн';
$_lang["user_doesnt_exist"] = 'Колдонуучу жок';
+$_lang["user_role_invalid"] = 'Тандалган роль жок. Башка ролду тандап, кайра сактаңыз.';
$_lang["user_edit_self_msg"] = 'Маалымат толук жаңыланышы үчүн сактагандан кийин чыгып, кайра кирүү керек болушу мүмкүн. Жаңы сырсөз электрондук почтага жөнөтүлөт же экранда көрсөтүлөт.';
$_lang["user_email"] = 'Электрондук почта дареги';
$_lang["user_failedlogincount"] = 'Ийгиликсиз кирүүлөр';
diff --git a/core/lang/lt/global.php b/core/lang/lt/global.php
index e464853eee..a774759186 100644
--- a/core/lang/lt/global.php
+++ b/core/lang/lt/global.php
@@ -1229,6 +1229,7 @@
$_lang["user_country"] = 'Šalis';
$_lang["user_dob"] = 'Gimimo data';
$_lang["user_doesnt_exist"] = 'Naudotojo nėra';
+$_lang["user_role_invalid"] = 'Pasirinkto vaidmens nėra. Pasirinkite kitą vaidmenį ir išsaugokite dar kartą.';
$_lang["user_edit_self_msg"] = 'Išsaugojus gali reikėti atsijungti ir vėl prisijungti, kad jūsų informacija būtų visiškai atnaujinta. Nauji slaptažodžiai bus išsiųsti jūsų el. pašto adresu arba parodyti ekrane.';
$_lang["user_email"] = 'El. pašto adresas';
$_lang["user_failedlogincount"] = 'Nesėkmingi prisijungimai';
diff --git a/core/lang/lv/global.php b/core/lang/lv/global.php
index a7d7a10853..a620d3057a 100644
--- a/core/lang/lv/global.php
+++ b/core/lang/lv/global.php
@@ -1229,6 +1229,7 @@
$_lang["user_country"] = 'Valsts';
$_lang["user_dob"] = 'Dzimšanas datums';
$_lang["user_doesnt_exist"] = 'Lietotājs neeksistē';
+$_lang["user_role_invalid"] = 'Izvēlētā loma neeksistē. Izvēlieties citu lomu un saglabājiet vēlreiz.';
$_lang["user_edit_self_msg"] = 'Pēc saglabāšanas var būt jāizrakstās un jāpiesakās vēlreiz, lai pilnībā atjauninātu jūsu informāciju. Jaunās paroles tiks nosūtītas uz jūsu e-pasta adresi vai parādītas ekrānā.';
$_lang["user_email"] = 'E-pasta adrese';
$_lang["user_failedlogincount"] = 'Neveiksmīgas pieteikšanās';
diff --git a/core/lang/nl/global.php b/core/lang/nl/global.php
index 434aba1107..48f75d89d0 100644
--- a/core/lang/nl/global.php
+++ b/core/lang/nl/global.php
@@ -1172,6 +1172,7 @@
$_lang["user_country"] = 'Land';
$_lang["user_dob"] = 'Geboortedatum';
$_lang["user_doesnt_exist"] = 'Gebruiker bestaat niet';
+$_lang["user_role_invalid"] = 'De geselecteerde rol bestaat niet. Kies een andere rol en sla opnieuw op.';
$_lang["user_edit_self_msg"] = 'Wellicht dient u na het opslaan eerst af te melden en dan weer aan te melden om uw gegevens volledig te actualiseren.
Nieuwe wachtwoorden worden via e-mail toegezonden of op het scherm getoond.';
$_lang["user_email"] = 'E-mail adres';
$_lang["user_failedlogincount"] = 'Aantal mislukte aanmeldpogingen';
diff --git a/core/lang/nn/global.php b/core/lang/nn/global.php
index 0a4b25074f..abce7882dc 100644
--- a/core/lang/nn/global.php
+++ b/core/lang/nn/global.php
@@ -1064,6 +1064,7 @@
$_lang["user_country"] = 'Land';
$_lang["user_dob"] = 'Fødselsdato';
$_lang["user_doesnt_exist"] = 'Bruker finnes ikke';
+$_lang["user_role_invalid"] = 'Den valgte rollen finnes ikke. Velg en annen rolle og lagre på nytt.';
$_lang["user_edit_self_msg"] = 'Etter du har lagret kan det hende du må logge ut og inn igjen for at dine instillinger skal oppdateres fullstendig
Om du velger å generere et nytt passord til deg selv, kommer det til å sendes til din epostadresse.';
$_lang["user_email"] = 'Epostadresse';
$_lang["user_failedlogincount"] = 'Mislykkede inlogginger';
diff --git a/core/lang/pl/global.php b/core/lang/pl/global.php
index 48f852b6f6..1209743e41 100644
--- a/core/lang/pl/global.php
+++ b/core/lang/pl/global.php
@@ -1186,6 +1186,7 @@
$_lang["user_country"] = 'Kraj';
$_lang["user_dob"] = 'Data urodzenia';
$_lang["user_doesnt_exist"] = 'Uzytkownik nie istnieje';
+$_lang["user_role_invalid"] = 'Wybrana rola nie istnieje. Wybierz inną rolę i zapisz ponownie.';
$_lang["user_edit_self_msg"] = 'Aby w pełni zaktualizować swoje dane wymagane jest przelogowanie się.
Możesz również wygenerować dla siebie nowe hasło, zostanie ono przesłane do Ciebie poprzez email.';
$_lang["user_email"] = 'Adres email';
$_lang["user_failedlogincount"] = 'Nieudane próby logowania';
diff --git a/core/lang/pt/global.php b/core/lang/pt/global.php
index e36fcedbcc..43f37053f3 100644
--- a/core/lang/pt/global.php
+++ b/core/lang/pt/global.php
@@ -1097,6 +1097,7 @@
$_lang["user_country"] = 'País';
$_lang["user_dob"] = 'Data de nascimento';
$_lang["user_doesnt_exist"] = 'O utilizador não existe';
+$_lang["user_role_invalid"] = 'O perfil selecionado não existe. Escolha outro perfil e guarde novamente.';
$_lang["user_edit_self_msg"] = 'Deve sair do sistema e aceder novamente para implementar todas as alterações.
Caso decida gerar uma nova senha para si, esta ser-lhe-à enviada por e-mail.';
$_lang["user_email"] = 'Endereço de E-mail';
$_lang["user_failedlogincount"] = 'Tentativas de acesso falhadas';
diff --git a/core/lang/ro/global.php b/core/lang/ro/global.php
index 5200408632..f7921e53bf 100644
--- a/core/lang/ro/global.php
+++ b/core/lang/ro/global.php
@@ -1229,6 +1229,7 @@
$_lang["user_country"] = 'Țară';
$_lang["user_dob"] = 'Data nașterii';
$_lang["user_doesnt_exist"] = 'Utilizatorul nu există';
+$_lang["user_role_invalid"] = 'Rolul selectat nu există. Alegeți alt rol și salvați din nou.';
$_lang["user_edit_self_msg"] = 'După salvare, poate fi necesar să vă deconectați și să vă autentificați din nou pentru actualizarea completă a informațiilor. Parolele noi vor fi trimise la adresa dumneavoastră de e-mail sau afișate pe ecran.';
$_lang["user_email"] = 'Adresă de e-mail';
$_lang["user_failedlogincount"] = 'Autentificări eșuate';
diff --git a/core/lang/sk/global.php b/core/lang/sk/global.php
index f972efe848..643727c98a 100644
--- a/core/lang/sk/global.php
+++ b/core/lang/sk/global.php
@@ -1217,6 +1217,7 @@
$_lang["user_country"] = 'Krajina';
$_lang["user_dob"] = 'Dátum narodenia';
$_lang["user_doesnt_exist"] = 'Používateľ neexistuje';
+$_lang["user_role_invalid"] = 'Vybraná rola neexistuje. Zvoľte inú rolu a uložte znova.';
$_lang["user_edit_self_msg"] = 'Odporúča sa odhlásiť a znova prihlásiť, aby sa všetky zmeny prejavili.
Tiež ak ste sa rozhodli vygenerovať si nové heslo, bude vám odoslané e-mailom.';
$_lang["user_email"] = 'E-mailová adresa';
$_lang["user_failedlogincount"] = 'Neúspešné prihlásenia';
diff --git a/core/lang/sv/global.php b/core/lang/sv/global.php
index 19b2e81027..d8ace01bdf 100644
--- a/core/lang/sv/global.php
+++ b/core/lang/sv/global.php
@@ -1098,6 +1098,7 @@
$_lang["user_country"] = 'Land';
$_lang["user_dob"] = 'Födelsedatum';
$_lang["user_doesnt_exist"] = 'Användaren finns inte';
+$_lang["user_role_invalid"] = 'Den valda rollen finns inte. Välj en annan roll och spara igen.';
$_lang["user_edit_self_msg"] = 'Efter att ha sparat kan du behöva logga ut och sedan logga in igen för att dina inställningar ska uppdateras fullständigt. Nya lösenord kommer att skickas till din e-postadress eller visas på skärmen.';
$_lang["user_email"] = 'E-postadress';
$_lang["user_failedlogincount"] = 'Misslyckade inloggningar';
diff --git a/core/lang/tg/global.php b/core/lang/tg/global.php
index 23908cabcd..de26751804 100644
--- a/core/lang/tg/global.php
+++ b/core/lang/tg/global.php
@@ -1224,6 +1224,7 @@
$_lang["user_country"] = 'Кишвар';
$_lang["user_dob"] = 'Санаи таваллуд';
$_lang["user_doesnt_exist"] = 'Корбар мавҷуд нест';
+$_lang["user_role_invalid"] = 'Нақши интихобшуда мавҷуд нест. Нақши дигарро интихоб кунед ва аз нав захира кунед.';
$_lang["user_edit_self_msg"] = 'Барои навсозии пурраи маълумот баъд аз захира шояд баромада, дубора ворид шавед. Гузарвожаи нав ба почтаи электронии шумо фиристода ё дар экран нишон дода мешавад.';
$_lang["user_email"] = 'Нишонии почтаи электронӣ';
$_lang["user_failedlogincount"] = 'Воридшавиҳои номуваффақ';
diff --git a/core/lang/tk/global.php b/core/lang/tk/global.php
index 522d54e691..a83fa895a8 100644
--- a/core/lang/tk/global.php
+++ b/core/lang/tk/global.php
@@ -1229,6 +1229,7 @@
$_lang["user_country"] = 'Ýurt';
$_lang["user_dob"] = 'Doglan senesi';
$_lang["user_doesnt_exist"] = 'Ulanyjy ýok';
+$_lang["user_role_invalid"] = 'Saýlanan rol ýok. Başga rol saýlaň we täzeden ýatda saklaň.';
$_lang["user_edit_self_msg"] = 'Maglumatyňyzy doly täzelemek üçin ýatda saklandan soň ulgamdan çykyp, täzeden girmegiňiz gerek bolup biler. Täze parollar e-poçta salgyňyza iberiler ýa-da ekranda görkeziler.';
$_lang["user_email"] = 'E-poçta salgysy';
$_lang["user_failedlogincount"] = 'Şowsuz girişler';
diff --git a/core/lang/uk/global.php b/core/lang/uk/global.php
index c820333258..cddcacbbb0 100644
--- a/core/lang/uk/global.php
+++ b/core/lang/uk/global.php
@@ -1224,6 +1224,7 @@
$_lang["user_country"] = 'Країна';
$_lang["user_dob"] = 'Дата народження';
$_lang["user_doesnt_exist"] = 'Користувач не існує';
+$_lang["user_role_invalid"] = 'Вибраної ролі не існує. Виберіть іншу роль і збережіть ще раз.';
$_lang["user_edit_self_msg"] = 'Рекомендується вийти і знову авторизуватися для того, щоб всі зміни почали діяти.
Також, якщо ви вирішили згенерувати новий пароль для себе, він буде відправлений вам по e-mail.';
$_lang["user_email"] = 'E-mail адреса';
$_lang["user_failedlogincount"] = 'Відмови авторизації';
diff --git a/core/lang/uz/global.php b/core/lang/uz/global.php
index e3b8a7b6d0..c62491423d 100644
--- a/core/lang/uz/global.php
+++ b/core/lang/uz/global.php
@@ -1229,6 +1229,7 @@
$_lang["user_country"] = 'Mamlakat';
$_lang["user_dob"] = 'Tug‘ilgan sana';
$_lang["user_doesnt_exist"] = 'Foydalanuvchi mavjud emas';
+$_lang["user_role_invalid"] = 'Tanlangan rol mavjud emas. Boshqa rolni tanlang va qayta saqlang.';
$_lang["user_edit_self_msg"] = 'Ma’lumotlaringiz to‘liq yangilanishi uchun saqlagandan so‘ng tizimdan chiqib, qayta kirishingiz kerak bo‘lishi mumkin. Yangi parollar elektron pochta manzilingizga yuboriladi yoki ekranda ko‘rsatiladi.';
$_lang["user_email"] = 'Elektron pochta manzili';
$_lang["user_failedlogincount"] = 'Muvaffaqiyatsiz kirishlar';
diff --git a/core/lang/zh/global.php b/core/lang/zh/global.php
index 31e43729a6..f9e49d6e55 100644
--- a/core/lang/zh/global.php
+++ b/core/lang/zh/global.php
@@ -1098,6 +1098,7 @@
$_lang["user_country"] = '国家';
$_lang["user_dob"] = '生日';
$_lang["user_doesnt_exist"] = 'User does not exist';
+$_lang["user_role_invalid"] = '所选角色不存在。请选择其他角色后重新保存。';
$_lang["user_edit_self_msg"] = '当你保存你的新信息后,你该退出然后再登陆,
同样,你该选择生成新的密码,新密码会通过EMAIL告诉你';
$_lang["user_email"] = 'E-mail地址';
$_lang["user_failedlogincount"] = '失败的登陆';
diff --git a/core/src/Controllers/UserRoles/UserRole.php b/core/src/Controllers/UserRoles/UserRole.php
index deeb19fc0a..7e5ff81fda 100644
--- a/core/src/Controllers/UserRoles/UserRole.php
+++ b/core/src/Controllers/UserRoles/UserRole.php
@@ -248,7 +248,7 @@ protected function guardRoleReach(int $id, ?int $actorRole = null, ?array $actor
{
$actorRole = $actorRole ?? (int) ($_SESSION['mgrRole'] ?? 0);
$actorPermissions = $actorPermissions ?? $this->actorPermissions();
- $targetPermissions = Models\RolePermissions::query()->where('role_id', $id)->pluck('permission')->all();
+ $targetPermissions = RoleGuard::reachOf($id);
if (!RoleGuard::mayManageRole($actorRole, $actorPermissions, $id, $targetPermissions)) {
$this->managerTheme->alertAndQuit('error_no_privileges');
@@ -264,10 +264,7 @@ protected function actorPermissions(): array
{
$core = $this->managerTheme->getCore();
- return array_values(array_filter(
- Models\Permissions::query()->pluck('key')->all(),
- static fn ($key) => (bool) $core->hasPermission($key)
- ));
+ return RoleGuard::heldBy(static fn ($key) => $core->hasPermission($key));
}
public static function normalizePermissionsPayload(array $permissions, array $requiredPermissions = []): array
diff --git a/core/src/Controllers/Users/EditOrNewUser.php b/core/src/Controllers/Users/EditOrNewUser.php
index 2d19c269d2..794d2e74f5 100644
--- a/core/src/Controllers/Users/EditOrNewUser.php
+++ b/core/src/Controllers/Users/EditOrNewUser.php
@@ -48,6 +48,17 @@ public function process(): bool
exit();
}
}
+ // validated once, before anything is saved: the TV lookup, the reach check and setRole()
+ // all receive this same id
+ $newRole = null;
+ if (isset($userData['role'])) {
+ $newRole = \EvolutionCMS\Support\RoleGuard::postedRoleId($userData['role']);
+ if ($newRole === null) {
+ webAlertAndQuit(\Lang::get('global.user_role_invalid'), $userData['mode'], $id);
+ exit();
+ }
+ $userData['role'] = $newRole;
+ }
$userData['username'] = $userData['newusername'];
if (isset($userData['blockeduntil']) && !is_numeric($userData['blockeduntil'])) {
$userData['blockeduntil'] = strtotime($userData['blockeduntil']);
@@ -149,19 +160,10 @@ public function process(): bool
\UserManager::clearSettings($userData);
\UserManager::saveSettings($userData);
- $newRole = (int) ($userData['role'] ?? 0);
- $actorPermissions = array_values(array_filter(
- \EvolutionCMS\Models\Permissions::query()->pluck('key')->all(),
- static fn ($key) => (bool) EvolutionCMS()->hasPermission($key)
- ));
- $newRolePermissions = \EvolutionCMS\Models\RolePermissions::query()
- ->where('role_id', $newRole)->pluck('permission')->all();
-
- if (isset($userData['role'])
- && $userData['role'] != $user->attributes->role
- && EvolutionCMS()->hasPermission('save_role')
- && \EvolutionCMS\Support\RoleGuard::mayManageRole($actorRole, $actorPermissions, $newRole, $newRolePermissions)) {
- \UserManager::setRole(['id' => $user->getKey(), 'role' => $userData['role']]);
+ if ($newRole !== null
+ && $newRole !== (int) $user->attributes->role
+ && \EvolutionCMS\Support\RoleGuard::mayAssignRole($actorRole, static fn ($key) => EvolutionCMS()->hasPermission($key), $newRole)) {
+ \UserManager::setRole(['id' => $user->getKey(), 'role' => $newRole]);
}
$postedGroups = (isset($userData['user_groups']) && is_array($userData['user_groups'])) ? $userData['user_groups'] : [];
diff --git a/core/src/Support/RoleGuard.php b/core/src/Support/RoleGuard.php
index 4ce12b4bcf..4f7e3025f2 100644
--- a/core/src/Support/RoleGuard.php
+++ b/core/src/Support/RoleGuard.php
@@ -1,5 +1,9 @@
where('role_id', $roleId)->pluck('permission')->all();
+
+ return array_values(array_unique(array_filter($grants, static fn ($key) => is_string($key) && $key !== '')));
+ }
+
+ /**
+ * The grants of a role that set its reach, for mayManageRole(): every grant that authorizes at
+ * runtime, always-on permissions included, less only the obsolete keys.
+ *
+ * @return string[]
+ */
+ public static function reachOf(int $roleId): array
+ {
+ return array_values(array_diff(self::grantsOf($roleId), self::OBSOLETE));
+ }
+
+ /**
+ * The role id a user form posted, exactly as the form writes one: "0" (no role) or the decimal
+ * id of an existing role. Anything else ("3abc", "3.9", " 3", "03", "1e1", an array, a role
+ * that does not exist) is null rather than cast, because a cast value is checked while the
+ * database may coerce the posted one into another role.
+ */
+ public static function postedRoleId(mixed $value): ?int
+ {
+ if (is_int($value)) {
+ $value = (string) $value;
+ }
+ if (!is_string($value) || preg_match('/^(0|[1-9][0-9]{0,9})$/D', $value) !== 1) {
+ return null;
+ }
+ $roleId = (int) $value;
+
+ return $roleId === 0 || UserRole::query()->whereKey($roleId)->exists() ? $roleId : null;
+ }
+
+ /**
+ * May the acting manager give a user $roleId? Assigning needs save_role and a role within the
+ * actor's reach, as EditOrNewUser asks before it changes a user's role.
+ *
+ * @param callable(string): mixed $hasPermission the actor's Core::hasPermission()
+ */
+ public static function mayAssignRole(int $actorRole, callable $hasPermission, int $roleId): bool
+ {
+ return (bool) $hasPermission('save_role')
+ && self::mayManageRole($actorRole, self::heldBy($hasPermission), $roleId, self::reachOf($roleId));
+ }
+
+ /**
+ * The keys the acting manager holds, asked of its runtime check for every key a role can carry:
+ * each defined permission and each key any role is granted, custom ones included.
+ *
+ * @param callable(string): mixed $hasPermission the actor's Core::hasPermission()
+ * @return string[]
+ */
+ public static function heldBy(callable $hasPermission): array
+ {
+ $keys = array_merge(
+ Permissions::query()->pluck('key')->all(),
+ RolePermissions::query()->distinct()->pluck('permission')->all()
+ );
+ $keys = array_unique(array_filter($keys, static fn ($key) => is_string($key) && $key !== ''));
+
+ return array_values(array_filter($keys, static fn ($key) => (bool) $hasPermission($key)));
+ }
+
+ /**
+ * The permission keys a manager may actually store on a role: the requested and the always-on
+ * keys, each only if the manager holds it. A non-administrator never stores a grant it lacks,
+ * a baseline one included; an administrator keeps everything requested plus the baseline.
*
* @param int $actorRole
* @param string[] $actorPermissions
@@ -48,10 +134,9 @@ public static function mayManageRole(int $actorRole, array $actorPermissions, in
*/
public static function grantablePermissions(int $actorRole, array $actorPermissions, array $requestedKeys, array $requiredKeys): array
{
- $allowed = $actorRole === self::ADMIN_ROLE
- ? $requestedKeys
- : array_intersect($requestedKeys, $actorPermissions);
+ $keys = array_merge($requestedKeys, $requiredKeys);
+ $allowed = $actorRole === self::ADMIN_ROLE ? $keys : array_intersect($keys, $actorPermissions);
- return array_values(array_unique(array_merge($allowed, $requiredKeys)));
+ return array_values(array_unique($allowed));
}
}
diff --git a/core/tests/Fixtures/user-role-assignment.php b/core/tests/Fixtures/user-role-assignment.php
new file mode 100644
index 0000000000..b8a10a2f8c
--- /dev/null
+++ b/core/tests/Fixtures/user-role-assignment.php
@@ -0,0 +1,215 @@
+ false, which coerces '0.5' to 1 in an INT column).
+ *
+ * argv: base64 JSON {permissions: string[], post: array, mysql?: {host, port, database, username, password}}
+ * Prints JSON: {quit: alert message or null, roles: user id => stored role, checked: role ids whose
+ * grants were read for the reach check, tvRoles: role ids whose TVs were loaded}
+ */
+
+[, $payload] = $argv;
+$options = json_decode(base64_decode($payload), true);
+
+define('IN_MANAGER_MODE', true);
+define('IN_INSTALL_MODE', false);
+define('EVO_API_MODE', true);
+
+require dirname(__DIR__, 2) . '/vendor/autoload.php';
+
+use Illuminate\Database\Capsule\Manager as Capsule;
+use Illuminate\Database\Schema\Blueprint;
+
+final class RoleAssignmentStubEvo
+{
+ public function __construct(private array $permissions)
+ {
+ }
+
+ public function hasPermission($key)
+ {
+ return in_array($key, $this->permissions, true) ? 1 : 0;
+ }
+
+ public function getManagerApi()
+ {
+ return new class {
+ public function saveFormValues(...$args): void
+ {
+ }
+ };
+ }
+
+ public function webAlertAndQuit($message, $url = '')
+ {
+ $GLOBALS['roleAssignmentQuit'] = $message;
+ exit;
+ }
+}
+
+$GLOBALS['roleAssignmentEvo'] = new RoleAssignmentStubEvo($options['permissions']);
+function evo()
+{
+ return $GLOBALS['roleAssignmentEvo'];
+}
+function evolutionCMS()
+{
+ return evo();
+}
+
+final class Lang
+{
+ public static function get($key, array $replace = [])
+ {
+ return $key;
+ }
+}
+
+final class DB
+{
+ public static function raw($value)
+ {
+ return Capsule::raw($value);
+ }
+}
+
+/** The facade as EditOrNewUser calls it; setRole() is the real service. */
+final class UserManager
+{
+ public static function create(array $data)
+ {
+ $user = EvolutionCMS\Models\User::query()->create(['username' => $data['username']]);
+ $user->attributes()->create(['email' => $data['email'] ?? '']);
+
+ return $user->refresh();
+ }
+
+ public static function edit(array $data)
+ {
+ return EvolutionCMS\Models\User::query()->findOrFail($data['id']);
+ }
+
+ public static function setRole(array $data)
+ {
+ return (new EvolutionCMS\UserManager\Services\Users\UserSetRole($data, false, false))->process();
+ }
+
+ public static function __callStatic($name, $arguments)
+ {
+ }
+}
+
+$capsule = new Capsule();
+$mysql = $options['mysql'] ?? null;
+$capsule->addConnection(is_array($mysql)
+ ? ['driver' => 'mysql', 'host' => $mysql['host'], 'port' => $mysql['port'], 'database' => $mysql['database'],
+ 'username' => $mysql['username'], 'password' => $mysql['password'], 'charset' => 'utf8mb4',
+ 'collation' => 'utf8mb4_unicode_ci', 'prefix' => '', 'strict' => false]
+ : ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']);
+$capsule->setEventDispatcher(new Illuminate\Events\Dispatcher());
+$capsule->setAsGlobal();
+$capsule->bootEloquent();
+$db = $capsule->getConnection();
+$schema = $db->getSchemaBuilder();
+$tables = [
+ 'users' => function (Blueprint $t) {
+ $t->increments('id');
+ $t->string('username')->default('');
+ $t->string('password')->default('');
+ $t->string('cachepwd')->default('');
+ },
+ 'user_attributes' => function (Blueprint $t) {
+ $t->increments('id');
+ $t->unsignedInteger('internalKey')->default(0);
+ $t->string('email')->default('');
+ $t->integer('role')->default(0);
+ $t->unsignedTinyInteger('verified')->default(0);
+ $t->unsignedInteger('createdon')->default(0);
+ $t->unsignedInteger('editedon')->default(0);
+ },
+ 'user_roles' => function (Blueprint $t) {
+ $t->increments('id');
+ $t->string('name')->default('');
+ },
+ 'role_permissions' => function (Blueprint $t) {
+ $t->increments('id');
+ $t->unsignedInteger('role_id')->default(0);
+ $t->string('permission')->default('');
+ },
+ 'permissions' => function (Blueprint $t) {
+ $t->increments('id');
+ $t->string('key')->default('');
+ $t->unsignedTinyInteger('disabled')->default(0);
+ },
+ 'site_tmplvars' => function (Blueprint $t) {
+ $t->increments('id');
+ $t->string('name')->default('');
+ $t->string('type')->default('text');
+ },
+ 'user_role_vars' => function (Blueprint $t) {
+ $t->unsignedInteger('tmplvarid')->default(0);
+ $t->unsignedInteger('roleid')->default(0);
+ },
+ 'user_values' => function (Blueprint $t) {
+ $t->unsignedInteger('userid')->default(0);
+ $t->unsignedInteger('tmplvarid')->default(0);
+ $t->text('value')->nullable();
+ },
+ 'member_groups' => function (Blueprint $t) {
+ $t->increments('id');
+ $t->unsignedInteger('user_group')->default(0);
+ $t->unsignedInteger('member')->default(0);
+ },
+];
+foreach (array_keys($tables) as $table) {
+ $schema->dropIfExists($table);
+}
+foreach ($tables as $table => $definition) {
+ $schema->create($table, $definition);
+}
+
+// role 2 (the actor's) holds what the actor holds; role 3 holds settings, which the actor lacks
+foreach ([1 => 'Administrator', 2 => 'Editor', 3 => 'Publisher'] as $id => $name) {
+ $db->table('user_roles')->insert(['id' => $id, 'name' => $name]);
+}
+foreach ($options['permissions'] as $key) {
+ $db->table('permissions')->insert(['key' => $key]);
+ $db->table('role_permissions')->insert(['role_id' => 2, 'permission' => $key]);
+}
+$db->table('role_permissions')->insert([['role_id' => 3, 'permission' => 'edit_document'], ['role_id' => 3, 'permission' => 'settings']]);
+// user 10 is the actor, user 11 a user of role 2 it edits
+foreach ([10 => 'actor', 11 => 'target'] as $id => $name) {
+ $db->table('users')->insert(['id' => $id, 'username' => $name]);
+ $db->table('user_attributes')->insert(['internalKey' => $id, 'role' => 2]);
+}
+
+$checked = $tvRoles = [];
+$db->listen(function ($query) use (&$checked, &$tvRoles) {
+ if (str_contains($query->sql, 'role_permissions') && str_contains($query->sql, 'role_id')) {
+ $checked[] = $query->bindings[0];
+ }
+ if (str_contains($query->sql, 'user_role_vars')) {
+ $tvRoles[] = end($query->bindings);
+ }
+});
+
+register_shutdown_function(function () use ($db, &$checked, &$tvRoles) {
+ echo json_encode([
+ 'quit' => $GLOBALS['roleAssignmentQuit'] ?? null,
+ 'roles' => $db->table('user_attributes')->orderBy('internalKey')->pluck('role', 'internalKey')->map(fn ($role) => (int) $role)->all(),
+ 'checked' => $checked,
+ 'tvRoles' => $tvRoles,
+ ]);
+ foreach (['users', 'user_attributes', 'user_roles', 'role_permissions', 'permissions', 'site_tmplvars', 'user_role_vars', 'user_values', 'member_groups'] as $table) {
+ $db->getSchemaBuilder()->dropIfExists($table);
+ }
+});
+
+$_SESSION = ['mgrRole' => 2, 'mgrInternalKey' => 10];
+$_POST = $options['post'] + ['newpassword' => '0', 'stay' => '', 'passwordnotifymethod' => '', 'email' => 'x@example.test'];
+
+$controller = (new ReflectionClass(EvolutionCMS\Controllers\Users\EditOrNewUser::class))->newInstanceWithoutConstructor();
+// header() after the save only warns in the CLI
+@$controller->process();
diff --git a/core/tests/Unit/Security/AdminAccountGuardTest.php b/core/tests/Unit/Security/AdminAccountGuardTest.php
index 930e35b33f..4d817f0633 100644
--- a/core/tests/Unit/Security/AdminAccountGuardTest.php
+++ b/core/tests/Unit/Security/AdminAccountGuardTest.php
@@ -27,6 +27,6 @@
expect($edit)->toContain('AdminAccountGuard::mayManageAccount(')
// role assignment now uses the stronger RoleGuard subset check, not the role-1-only guard
- ->and($edit)->toContain('RoleGuard::mayManageRole(')
+ ->and($edit)->toContain('RoleGuard::mayAssignRole(')
->and($delete)->toContain('AdminAccountGuard::mayManageAccount(');
});
diff --git a/core/tests/Unit/Security/ManagerAuthorizationGapsTest.php b/core/tests/Unit/Security/ManagerAuthorizationGapsTest.php
index da0130cd70..12af82ff2e 100644
--- a/core/tests/Unit/Security/ManagerAuthorizationGapsTest.php
+++ b/core/tests/Unit/Security/ManagerAuthorizationGapsTest.php
@@ -82,7 +82,7 @@ function controllerSource(string $class): string
it('assigns a user role only within the actor reach, not just below admin', function () {
$source = controllerSource('Users/EditOrNewUser');
- expect($source)->toContain('RoleGuard::mayManageRole($actorRole, $actorPermissions, $newRole, $newRolePermissions)')
+ expect($source)->toContain('RoleGuard::mayAssignRole($actorRole, static fn ($key) => EvolutionCMS()->hasPermission($key), $newRole)')
->and($source)->not->toContain('AdminAccountGuard::mayAssignRole(');
});
diff --git a/core/tests/Unit/Security/RoleGuardTest.php b/core/tests/Unit/Security/RoleGuardTest.php
index 634e1676bb..96ac672727 100644
--- a/core/tests/Unit/Security/RoleGuardTest.php
+++ b/core/tests/Unit/Security/RoleGuardTest.php
@@ -32,17 +32,19 @@
expect(RoleGuard::mayManageRole(3, $actor, 0, []))->toBeTrue();
});
-it('grants only the permissions the actor holds, plus the baseline', function () use ($actor) {
+it('grants only the permissions the actor holds, baseline ones included', function () use ($actor) {
$result = RoleGuard::grantablePermissions(
3,
- $actor,
+ [...$actor, 'frames'],
['edit_document', 'save_plugin', 'settings'],
['frames', 'home']
);
- expect($result)->toEqualCanonicalizing(['edit_document', 'frames', 'home'])
+ // home is always-on, but this actor does not hold it
+ expect($result)->toEqualCanonicalizing(['edit_document', 'frames'])
->and($result)->not->toContain('save_plugin')
- ->and($result)->not->toContain('settings');
+ ->and($result)->not->toContain('settings')
+ ->and($result)->not->toContain('home');
});
it('lets the administrator grant everything requested', function () {
diff --git a/core/tests/Unit/Security/RoleReachLegacyGrantsTest.php b/core/tests/Unit/Security/RoleReachLegacyGrantsTest.php
new file mode 100644
index 0000000000..40284d4333
--- /dev/null
+++ b/core/tests/Unit/Security/RoleReachLegacyGrantsTest.php
@@ -0,0 +1,276 @@
+permissions, true) ? 1 : 0;
+ }
+
+ public function getManagerApi(): object
+ {
+ return new class {
+ public function saveFormValues(): void
+ {
+ }
+
+ public function clearSavedFormValues(): void
+ {
+ }
+ };
+ }
+
+ public function webAlertAndQuit($message, $url = ''): void
+ {
+ throw new RuntimeException('webAlertAndQuit:' . $message);
+ }
+}
+
+class RoleReachThemeStub
+{
+ public function __construct(public RoleReachCoreStub $core)
+ {
+ }
+
+ public function getCore(): RoleReachCoreStub
+ {
+ return $this->core;
+ }
+
+ public function alertAndQuit($message, $lexicon = true, $url = ''): void
+ {
+ throw new RuntimeException('alertAndQuit:' . $message);
+ }
+}
+
+/** The acting manager (role 2): its role holds $grants, which is what its session answers. */
+function roleReachActor(array $grants): RoleReachCoreStub
+{
+ $_SESSION['mgrRole'] = 2;
+ Capsule::table('role_permissions')->where('role_id', 2)->delete();
+ foreach ($grants as $key) {
+ Capsule::table('role_permissions')->insert(['role_id' => 2, 'permission' => $key]);
+ }
+
+ return new RoleReachCoreStub($grants);
+}
+
+function roleReachController(RoleReachCoreStub $actor): UserRoleController
+{
+ $controller = (new ReflectionClass(UserRoleController::class))->newInstanceWithoutConstructor();
+ $property = new ReflectionProperty(UserRoleController::class, 'managerTheme');
+ $property->setAccessible(true);
+ $property->setValue($controller, new RoleReachThemeStub($actor));
+
+ return $controller;
+}
+
+function roleReachRun(callable $request): bool
+{
+ try {
+ // header() after a save or delete only warns in the CLI
+ @$request();
+
+ return true;
+ } catch (RuntimeException $e) {
+ return false;
+ }
+}
+
+/** The role form posted for role 3, asking for $keys; true when the save went through. */
+function roleReachSave(RoleReachCoreStub $actor, array $keys): bool
+{
+ $_GET = [];
+ $_REQUEST = ['id' => 3];
+ $_POST = ['a' => '35', 'id' => 3, 'name' => 'Editor (saved)', 'description' => '', 'tvsDirty' => '0',
+ 'permissions' => array_fill_keys($keys, 1)];
+ $saved = roleReachRun(fn () => roleReachController($actor)->process());
+ $_POST = [];
+
+ return $saved && Capsule::table('user_roles')->where('id', 3)->value('name') === 'Editor (saved)';
+}
+
+function roleReachDelete(RoleReachCoreStub $actor): bool
+{
+ $_GET = ['action' => 'delete', 'id' => 3];
+ $_REQUEST = $_GET;
+ roleReachRun(fn () => roleReachController($actor)->process());
+ $_GET = $_REQUEST = [];
+
+ return !Capsule::table('user_roles')->where('id', 3)->exists();
+}
+
+/**
+ * Assign, save (asking for the role's own grants), then delete role 3.
+ *
+ * @return array{assign: bool, save: bool, delete: bool}
+ */
+function roleReachOutcome(array $actorGrants): array
+{
+ $actor = roleReachActor($actorGrants);
+ $roleKeys = Capsule::table('role_permissions')->where('role_id', 3)->pluck('permission')->all();
+
+ return [
+ 'assign' => RoleGuard::mayAssignRole(2, fn ($key) => $actor->hasPermission($key), 3),
+ 'save' => roleReachSave($actor, $roleKeys),
+ 'delete' => roleReachDelete($actor),
+ ];
+}
+
+/** every real grant of the default Editor role, always-on ones included */
+const ROLE_REACH_EDITOR = ['edit_document', 'save_document', 'frames', 'home', 'error_dialog'];
+const ROLE_REACH_ALL = ['assign' => true, 'save' => true, 'delete' => true];
+const ROLE_REACH_NONE = ['assign' => false, 'save' => false, 'delete' => false];
+
+beforeEach(function () {
+ $this->capsule = CacheRefreshHarness::boot(':memory:');
+ $schema = $this->capsule->getConnection()->getSchemaBuilder();
+ $schema->create('user_roles', function (Blueprint $t) {
+ $t->increments('id');
+ $t->string('name')->default('');
+ $t->text('description')->nullable();
+ $t->timestamps();
+ });
+ $schema->create('role_permissions', function (Blueprint $t) {
+ $t->increments('id');
+ $t->unsignedInteger('role_id')->default(0);
+ $t->string('permission')->default('');
+ $t->timestamps();
+ });
+ $schema->create('permissions', function (Blueprint $t) {
+ $t->increments('id');
+ $t->string('key')->default('');
+ $t->unsignedTinyInteger('disabled')->default(0);
+ });
+ foreach (['user_attributes' => 'role', 'site_module_roles' => 'role', 'user_role_vars' => 'roleid'] as $table => $column) {
+ $schema->create($table, function (Blueprint $t) use ($column) {
+ $t->increments('id');
+ $t->unsignedInteger($column)->default(0);
+ });
+ }
+
+ // a fresh install: core permissions, frames/home/error_dialog always-on, a plugin's defined key
+ foreach (['save_role' => 0, 'delete_role' => 0, 'edit_document' => 0, 'save_document' => 0, 'settings' => 0,
+ 'shop_orders' => 0, 'frames' => 1, 'home' => 1, 'error_dialog' => 1] as $key => $disabled) {
+ $this->capsule->table('permissions')->insert(['key' => $key, 'disabled' => $disabled]);
+ }
+ $this->capsule->table('user_roles')->insert(['id' => 3, 'name' => 'Editor', 'description' => '']);
+ $this->grant = function (array $keys) {
+ foreach ($keys as $key) {
+ $this->capsule->table('role_permissions')->insert(['role_id' => 3, 'permission' => $key]);
+ }
+ };
+ ($this->grant)(ROLE_REACH_EDITOR);
+ // what an install with the earlier default seeders still has
+ $this->upgrade = function () {
+ $this->capsule->table('permissions')->insert(['key' => 'action_ok', 'disabled' => 1]);
+ ($this->grant)(['role_actionok', 'action_ok']);
+ };
+});
+
+afterEach(function () {
+ Capsule::connection()->disconnect();
+ $_GET = $_POST = $_REQUEST = [];
+ unset($_SESSION['mgrRole']);
+});
+
+it('lets each operation through with its own permission, on a fresh and an upgraded install', function (bool $upgraded, array $rights, array $expected) {
+ if ($upgraded) {
+ ($this->upgrade)();
+ }
+
+ expect(roleReachOutcome([...$rights, ...ROLE_REACH_EDITOR]))->toBe($expected);
+})->with([false, true])->with([
+ 'save_role and delete_role' => [['save_role', 'delete_role'], ROLE_REACH_ALL],
+ 'save_role only' => [['save_role'], ['assign' => true, 'save' => true, 'delete' => false]],
+ 'delete_role only' => [['delete_role'], ['assign' => false, 'save' => false, 'delete' => true]],
+ 'neither' => [[], ROLE_REACH_NONE],
+]);
+
+it('refuses every operation on a role whose runtime grants exceed the actor', function (array $rights, array $extra, array $actorExtra) {
+ ($this->upgrade)();
+ ($this->grant)($extra);
+
+ expect(roleReachOutcome([...$rights, ...array_diff(ROLE_REACH_EDITOR, ['home']), ...$actorExtra]))->toBe(ROLE_REACH_NONE)
+ ->and($this->capsule->table('user_roles')->where('id', 3)->value('name'))->toBe('Editor');
+})->with([
+ 'save_role and delete_role' => [['save_role', 'delete_role']],
+ 'save_role only' => [['save_role']],
+ 'delete_role only' => [['delete_role']],
+])->with([
+ // the actor's role was saved before home became always-on
+ 'an always-on grant' => [[], []],
+ 'a stronger core permission' => [['settings'], ['home']],
+ 'a defined custom permission' => [['shop_orders'], ['home']],
+ 'a custom grant with no row' => [['shop_refunds'], ['home']],
+]);
+
+it('allows a role carrying custom grants the actor holds', function (string $custom) {
+ ($this->upgrade)();
+ ($this->grant)([$custom]);
+
+ expect(roleReachOutcome(['save_role', 'delete_role', ...ROLE_REACH_EDITOR, $custom]))->toBe(ROLE_REACH_ALL);
+})->with(['shop_orders', 'shop_refunds']);
+
+it('stores on a saved role only grants the actor holds, and drops the obsolete ones', function () {
+ ($this->upgrade)();
+ $actor = roleReachActor(['save_role', 'edit_document', 'save_document', 'frames', 'home', 'error_dialog']);
+
+ expect(roleReachSave($actor, ['edit_document', 'settings', 'shop_refunds']))->toBeTrue()
+ ->and($this->capsule->table('role_permissions')->where('role_id', 3)->pluck('permission')->all())
+ ->toEqualCanonicalizing(['edit_document', 'frames', 'home', 'error_dialog']);
+});
+
+it('keeps the administrator role out of reach', function () {
+ $actor = roleReachActor(['save_role', 'delete_role', 'settings', ...ROLE_REACH_EDITOR]);
+
+ expect(RoleGuard::mayAssignRole(2, fn ($key) => $actor->hasPermission($key), RoleGuard::ADMIN_ROLE))->toBeFalse();
+});
+
+it('leaves out of reachOf() only the obsolete keys', function () {
+ ($this->upgrade)();
+ ($this->grant)(['shop_refunds']);
+
+ expect(RoleGuard::reachOf(3))->toEqualCanonicalizing([...ROLE_REACH_EDITOR, 'shop_refunds'])
+ ->and(RoleGuard::reachOf(99))->toBe([]);
+});
+
+test('role save, delete and assignment ask the same helpers', function () {
+ $src = dirname(__DIR__, 3) . '/src/Controllers/';
+ $role = (string) file_get_contents($src . 'UserRoles/UserRole.php');
+ $user = (string) file_get_contents($src . 'Users/EditOrNewUser.php');
+
+ expect($role)->toContain('$targetPermissions = RoleGuard::reachOf($id);')
+ ->and($role)->toContain('return RoleGuard::heldBy(static fn ($key) => $core->hasPermission($key));')
+ ->and($user)->toContain('\EvolutionCMS\Support\RoleGuard::mayAssignRole($actorRole, static fn ($key) => EvolutionCMS()->hasPermission($key), $newRole)');
+});
diff --git a/core/tests/Unit/Security/UserRoleAssignmentTest.php b/core/tests/Unit/Security/UserRoleAssignmentTest.php
new file mode 100644
index 0000000000..8b1aa92705
--- /dev/null
+++ b/core/tests/Unit/Security/UserRoleAssignmentTest.php
@@ -0,0 +1,135 @@
+ false) coerce it on the way in:
+| '0.5' became 1, so a role manager passed the check for "no role" and stored the administrator
+| role, on its own account as well; '2.5' became 3, '2abc' 2. The role id is now validated once,
+| before anything is saved, and that same int is checked and stored.
+|
+| The cases run the real EditOrNewUser::process() and UserSetRole in their own process, on SQLite
+| and, when EVO_TEST_MYSQL_HOST is set, on MySQL/MariaDB non-strict (EVO_TEST_MYSQL_PORT,
+| _DATABASE - an existing database, default evo_test - _USER, _PASSWORD).
+|
+*/
+
+/** @return array */
+function roleAssignmentBackends(): array
+{
+ $backends = ['sqlite' => [null]];
+ $host = getenv('EVO_TEST_MYSQL_HOST');
+ if ($host !== false && $host !== '') {
+ $backends['mysql non-strict'] = [[
+ 'host' => $host,
+ 'port' => (int) (getenv('EVO_TEST_MYSQL_PORT') ?: 3306),
+ 'database' => getenv('EVO_TEST_MYSQL_DATABASE') ?: 'evo_test',
+ 'username' => getenv('EVO_TEST_MYSQL_USER') ?: 'root',
+ 'password' => (string) getenv('EVO_TEST_MYSQL_PASSWORD'),
+ ]];
+ }
+
+ return $backends;
+}
+
+/**
+ * The manager of role 2 (user 10) saves the user form; user 11 also has role 2, role 3 holds
+ * settings besides edit_document.
+ *
+ * @return array{quit: ?string, roles: array, checked: list, tvRoles: list}
+ */
+function roleAssignmentRun(array $post, ?array $mysql, array $permissions = ['save_user', 'save_role', 'edit_document']): array
+{
+ $output = evoRunPhp(dirname(__DIR__, 2) . '/Fixtures/user-role-assignment.php', [
+ base64_encode(json_encode(['permissions' => $permissions, 'post' => $post, 'mysql' => $mysql])),
+ ]);
+ $decoded = json_decode($output, true);
+ expect($decoded)->toBeArray($output);
+
+ return $decoded;
+}
+
+function roleAssignmentEdit(int $userId, mixed $role): array
+{
+ return ['mode' => '12', 'id' => (string) $userId, 'newusername' => 'user' . $userId, 'role' => $role];
+}
+
+it('stores exactly the role it checked', function (?array $mysql, int $userId, string $role, array $permissions, int $stored) {
+ $result = roleAssignmentRun(roleAssignmentEdit($userId, $role), $mysql, ['save_user', 'save_role', 'edit_document', ...$permissions]);
+
+ expect($result['quit'])->toBeNull()
+ ->and($result['roles'][$userId])->toBe($stored)
+ ->and($result['checked'])->toBe([(int) $role])
+ ->and($result['tvRoles'])->toBe([(int) $role]);
+})->with(roleAssignmentBackends())->with([
+ 'no role for another user' => [11, '0', [], 0],
+ 'no role for itself' => [10, '0', [], 0],
+ 'a role within reach' => [11, '3', ['settings'], 3],
+ 'a role out of reach stays unchanged' => [11, '3', [], 2],
+ 'the administrator role, refused' => [10, '1', ['settings'], 2],
+]);
+
+it('refuses a role id that is not exactly how the form writes one, before saving anything', function (?array $mysql, mixed $role) {
+ foreach ([roleAssignmentEdit(11, $role), roleAssignmentEdit(10, $role), ['mode' => '87', 'newusername' => 'new', 'role' => $role]] as $post) {
+ $result = roleAssignmentRun($post, $mysql, ['save_user', 'save_role', 'edit_document', 'settings']);
+
+ expect($result['quit'])->toBe('global.user_role_invalid')
+ ->and($result['roles'])->toBe([10 => 2, 11 => 2])
+ ->and($result['checked'])->toBe([])
+ ->and($result['tvRoles'])->toBe([]);
+ }
+})->with(roleAssignmentBackends())->with([
+ // what MySQL/MariaDB non-strict stores in brackets
+ 'a fraction under one (1)' => ['0.5'],
+ 'a fraction (3)' => ['2.5'],
+ 'a trailing suffix (2)' => ['3abc'],
+ 'a leading space (3)' => [' 3'],
+ 'a trailing space (3)' => ['3 '],
+ 'a leading zero (3)' => ['03'],
+ 'an exponent (1)' => ['1e0'],
+ 'a hex string (0)' => ['0x1'],
+ 'a negative id' => ['-1'],
+ 'an empty value' => [''],
+ 'a role that does not exist' => ['4'],
+ 'an array' => [['3']],
+]);
+
+it('gives a new user the checked role', function (?array $mysql) {
+ $result = roleAssignmentRun(['mode' => '87', 'newusername' => 'new', 'role' => '2'], $mysql);
+
+ expect($result['quit'])->toBeNull()
+ ->and($result['roles'])->toHaveCount(3)
+ ->and(end($result['roles']))->toBe(2)
+ ->and($result['checked'])->toBe([2]);
+})->with(roleAssignmentBackends());
+
+describe('RoleGuard::postedRoleId()', function () {
+ beforeEach(function () {
+ $this->capsule = CacheRefreshHarness::boot(':memory:');
+ $this->capsule->getConnection()->getSchemaBuilder()->create('user_roles', function (Blueprint $t) {
+ $t->increments('id');
+ $t->string('name')->default('');
+ });
+ $this->capsule->table('user_roles')->insert([['id' => 1, 'name' => 'Administrator'], ['id' => 3, 'name' => 'Editor']]);
+ });
+
+ afterEach(function () {
+ Capsule::connection()->disconnect();
+ });
+
+ it('accepts no role and existing role ids, as strings or ints', function (mixed $value, int $id) {
+ expect(RoleGuard::postedRoleId($value))->toBe($id);
+ })->with([['0', 0], [0, 0], ['1', 1], ['3', 3], [3, 3]]);
+
+ it('refuses anything else', function (mixed $value) {
+ expect(RoleGuard::postedRoleId($value))->toBeNull();
+ })->with(['2', '0.5', '3abc', " 3", "3\n", '03', '1e0', '+3', '-3', -3, '', null, true, 3.0, [[3]], '99999999999']);
+});