diff --git a/core/lang/az/global.php b/core/lang/az/global.php
index bb5b542443..73272c743e 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 687d2d58f3..735c43c419 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 c354c6ff4d..1d329161c1 100644
--- a/core/lang/bg/global.php
+++ b/core/lang/bg/global.php
@@ -1105,6 +1105,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 605ea28281..86d74e32d0 100644
--- a/core/lang/cs/global.php
+++ b/core/lang/cs/global.php
@@ -1109,6 +1109,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 b53a2c4194..7ad3e1e74f 100644
--- a/core/lang/da/global.php
+++ b/core/lang/da/global.php
@@ -1108,6 +1108,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 c679424ce6..c048e6d107 100644
--- a/core/lang/de/global.php
+++ b/core/lang/de/global.php
@@ -1190,6 +1190,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 11c5547446..4984461f0d 100644
--- a/core/lang/en/global.php
+++ b/core/lang/en/global.php
@@ -1244,6 +1244,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 4e04536113..e1b2197b48 100644
--- a/core/lang/es/global.php
+++ b/core/lang/es/global.php
@@ -1192,6 +1192,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 c4212e0f9c..90b6e17e53 100644
--- a/core/lang/et/global.php
+++ b/core/lang/et/global.php
@@ -1244,6 +1244,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 7ad85007d2..0ad10083fb 100644
--- a/core/lang/fa/global.php
+++ b/core/lang/fa/global.php
@@ -1106,6 +1106,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 381677af23..6addb41a96 100644
--- a/core/lang/fi/global.php
+++ b/core/lang/fi/global.php
@@ -1104,6 +1104,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 6b7b4947bd..e2542ae039 100644
--- a/core/lang/fr/global.php
+++ b/core/lang/fr/global.php
@@ -1075,6 +1075,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 ac8ce7581a..9914925ed8 100644
--- a/core/lang/he/global.php
+++ b/core/lang/he/global.php
@@ -1106,6 +1106,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 0a6fd3feb0..590ce4e3fc 100644
--- a/core/lang/hy/global.php
+++ b/core/lang/hy/global.php
@@ -1244,6 +1244,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 d4e83ffe21..22e80625a1 100644
--- a/core/lang/it/global.php
+++ b/core/lang/it/global.php
@@ -1201,6 +1201,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 2698b614fc..9a3133d2f4 100644
--- a/core/lang/ja/global.php
+++ b/core/lang/ja/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/ka/global.php b/core/lang/ka/global.php
index 18df10dc61..9284cac839 100644
--- a/core/lang/ka/global.php
+++ b/core/lang/ka/global.php
@@ -1244,6 +1244,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 fde51e2d62..236e9b0f13 100644
--- a/core/lang/kk/global.php
+++ b/core/lang/kk/global.php
@@ -1267,6 +1267,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 37e214183c..4a7d3f956e 100644
--- a/core/lang/ky/global.php
+++ b/core/lang/ky/global.php
@@ -1244,6 +1244,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 5763e44d06..43db2a79db 100644
--- a/core/lang/lt/global.php
+++ b/core/lang/lt/global.php
@@ -1244,6 +1244,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 a0d5257327..6ae738a4eb 100644
--- a/core/lang/lv/global.php
+++ b/core/lang/lv/global.php
@@ -1244,6 +1244,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 42d4932e03..34bf2bd5dd 100644
--- a/core/lang/nl/global.php
+++ b/core/lang/nl/global.php
@@ -1180,6 +1180,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 92b0ab3499..375490a781 100644
--- a/core/lang/nn/global.php
+++ b/core/lang/nn/global.php
@@ -1072,6 +1072,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 bb24dae257..a9684a9886 100644
--- a/core/lang/pl/global.php
+++ b/core/lang/pl/global.php
@@ -1194,6 +1194,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 3eddc822ad..117ea22533 100644
--- a/core/lang/pt/global.php
+++ b/core/lang/pt/global.php
@@ -1105,6 +1105,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 92e190f4f1..3c7c966824 100644
--- a/core/lang/ro/global.php
+++ b/core/lang/ro/global.php
@@ -1244,6 +1244,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 a6705b8a29..24597f38cb 100644
--- a/core/lang/sk/global.php
+++ b/core/lang/sk/global.php
@@ -1225,6 +1225,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 73742990f5..fd3c67ed70 100644
--- a/core/lang/sv/global.php
+++ b/core/lang/sv/global.php
@@ -1106,6 +1106,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 9f97630c81..5aaafbf789 100644
--- a/core/lang/tg/global.php
+++ b/core/lang/tg/global.php
@@ -1239,6 +1239,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 da97da4d98..46d12dbed1 100644
--- a/core/lang/tk/global.php
+++ b/core/lang/tk/global.php
@@ -1244,6 +1244,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 b0c2311678..55e0ad2e27 100644
--- a/core/lang/uk/global.php
+++ b/core/lang/uk/global.php
@@ -1239,6 +1239,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 634ec5513b..4c4f0c3bc3 100644
--- a/core/lang/uz/global.php
+++ b/core/lang/uz/global.php
@@ -1244,6 +1244,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 a919b0415b..7622d014f4 100644
--- a/core/lang/zh/global.php
+++ b/core/lang/zh/global.php
@@ -1106,6 +1106,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 07e6f0eb8d..7e5ff81fda 100644
--- a/core/src/Controllers/UserRoles/UserRole.php
+++ b/core/src/Controllers/UserRoles/UserRole.php
@@ -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 5e35bd0087..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,18 +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\Support\RoleGuard::reachOf($newRole);
-
- 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 2c759ae548..4f7e3025f2 100644
--- a/core/src/Support/RoleGuard.php
+++ b/core/src/Support/RoleGuard.php
@@ -2,6 +2,7 @@
use EvolutionCMS\Models\Permissions;
use EvolutionCMS\Models\RolePermissions;
+use EvolutionCMS\Models\UserRole;
/**
* Keeps a manager from granting, through a role, more than it holds itself.
@@ -40,25 +41,90 @@ public static function mayManageRole(int $actorRole, array $actorPermissions, in
}
/**
- * The grants of a role that give it reach, for mayManageRole(): those of a defined permission
- * a role may hold or not. A grant of a permission that no longer exists (sites installed with
- * the earlier default seeders still carry role_actionok) or of an always-on permission, which
- * a role save stores for every role, is nothing an actor could lack.
+ * Grants that nothing authorizes any more: role_actionok, the lang key the earlier default
+ * seeders granted every role in place of action_ok, and action_ok itself, which nothing checks.
+ * Sites installed with those seeders still carry them; they count on neither side.
+ */
+ public const OBSOLETE = ['role_actionok', 'action_ok'];
+
+ /**
+ * What a role authorizes at runtime: the session of its managers holds every stored grant
+ * (StoreContextService::buildManagerPermissions()), whether or not a permissions row defines
+ * it, so a plugin's own key counts as much as a core one.
+ *
+ * @return string[]
+ */
+ public static function grantsOf(int $roleId): array
+ {
+ $grants = RolePermissions::query()->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 RolePermissions::query()
- ->where('role_id', $roleId)
- ->whereIn('permission', Permissions::query()->where('disabled', 0)->select('key'))
- ->pluck('permission')
- ->all();
+ 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 keys it also holds,
- * plus the always-on baseline keys. An administrator keeps everything requested.
+ * 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
@@ -68,10 +134,9 @@ public static function reachOf(int $roleId): array
*/
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
index 963fdbf6a6..40284d4333 100644
--- a/core/tests/Unit/Security/RoleReachLegacyGrantsTest.php
+++ b/core/tests/Unit/Security/RoleReachLegacyGrantsTest.php
@@ -8,16 +8,20 @@
/*
|--------------------------------------------------------------------------
-| Role reach on a site installed with the earlier default seeders
+| Role reach follows the grants that authorize at runtime
|--------------------------------------------------------------------------
|
-| A non-administrator may edit, delete or assign only a role whose grants it holds itself. The
-| actor's side lists defined permissions, so a grant no permission defines (role_actionok, which
-| the earlier default seeders gave every role and no update removes) could never be matched, and
-| a role manager holding every real permission of a default role was refused it. The same held
-| for an always-on permission that a later save stored on the target but not on the actor's role.
-| The comparison now takes the target's grants of defined, optional permissions only
-| (RoleGuard::reachOf()); every real permission still counts.
+| A non-administrator may save, delete or assign only a role whose grants it holds itself, and
+| stores on a role only grants it holds. At login the session receives every stored grant of the
+| role, defined by a permissions row or not (a plugin's own key), always-on ones included, so both
+| sides are compared by those runtime grants: the actor's through its own hasPermission(), the
+| target's as stored. Only the obsolete keys the earlier default seeders left on every role
+| (role_actionok, action_ok), which nothing checks, set no reach; counting them locked default
+| roles away from role managers holding every real permission of them.
+|
+| Each operation also needs its own permission: save_role to save or assign, delete_role to delete.
+| The cases run the real role save (updateOrCreate) and delete, and RoleGuard::mayAssignRole(),
+| which EditOrNewUser asks before it changes a user's role.
|
*/
@@ -32,12 +36,28 @@ public function hasPermission($key): int
{
return in_array($key, $this->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 ?string $quitMessage = null;
-
public function __construct(public RoleReachCoreStub $core)
{
}
@@ -49,28 +69,37 @@ public function getCore(): RoleReachCoreStub
public function alertAndQuit($message, $lexicon = true, $url = ''): void
{
- $this->quitMessage = (string) $message;
throw new RuntimeException('alertAndQuit:' . $message);
}
}
-/** A role manager (role 2) holding every real permission of the default Editor role 3, and no more. */
-function roleReachController(array $permissions = ['save_role', 'delete_role', 'edit_document', 'save_document']): UserRoleController
+/** 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(new RoleReachCoreStub($permissions)));
+ $property->setValue($controller, new RoleReachThemeStub($actor));
return $controller;
}
-/** guardRoleReach(), which role save (a=35/36) runs before anything else */
-function roleReachMaySave(UserRoleController $controller, int $role): bool
+function roleReachRun(callable $request): bool
{
try {
- (new ReflectionMethod(UserRoleController::class, 'guardRoleReach'))->invoke($controller, $role);
+ // header() after a save or delete only warns in the CLI
+ @$request();
return true;
} catch (RuntimeException $e) {
@@ -78,25 +107,51 @@ function roleReachMaySave(UserRoleController $controller, int $role): bool
}
}
-function roleReachDelete(UserRoleController $controller, int $role): bool
+/** The role form posted for role 3, asking for $keys; true when the save went through. */
+function roleReachSave(RoleReachCoreStub $actor, array $keys): bool
{
- $_GET['action'] = $_REQUEST['action'] = 'delete';
- $_GET['id'] = $_REQUEST['id'] = $role;
- try {
- // header() after the delete only warns in the CLI
- @$controller->process();
- } catch (RuntimeException $e) {
- }
+ $_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', $role)->exists();
+ return !Capsule::table('user_roles')->where('id', 3)->exists();
}
-/** The check EditOrNewUser makes before it assigns $role to a user. */
-function roleReachMayAssign(array $permissions, int $role): bool
+/**
+ * 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
{
- return RoleGuard::mayManageRole(2, $permissions, $role, RoleGuard::reachOf($role));
+ $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();
@@ -104,6 +159,7 @@ function roleReachMayAssign(array $permissions, int $role): bool
$t->increments('id');
$t->string('name')->default('');
$t->text('description')->nullable();
+ $t->timestamps();
});
$schema->create('role_permissions', function (Blueprint $t) {
$t->increments('id');
@@ -123,63 +179,98 @@ function roleReachMayAssign(array $permissions, int $role): bool
});
}
- // as an upgraded site has them: action_ok is still defined (always-on), role_actionok never was
+ // 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,
- 'action_ok' => 1, 'error_dialog' => 1] as $key => $disabled) {
+ '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' => '']);
- foreach (['edit_document', 'save_document', 'role_actionok', 'action_ok', 'error_dialog'] as $key) {
- $this->capsule->table('role_permissions')->insert(['role_id' => 3, 'permission' => $key]);
- }
+ $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();
- foreach (['action', 'id'] as $k) {
- unset($_GET[$k], $_REQUEST[$k]);
- }
+ $_GET = $_POST = $_REQUEST = [];
unset($_SESSION['mgrRole']);
});
-it('counts only the grants of defined, optional permissions', function () {
- expect(RoleGuard::reachOf(3))->toEqualCanonicalizing(['edit_document', 'save_document'])
- ->and(RoleGuard::reachOf(99))->toBe([]);
-});
+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)();
+ }
-it('lets a role manager with every real permission of a legacy default role save, assign and delete it', function () {
- $permissions = ['save_role', 'delete_role', 'edit_document', 'save_document'];
+ 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],
+]);
- expect(roleReachMaySave(roleReachController($permissions), 3))->toBeTrue()
- ->and(roleReachMayAssign($permissions, 3))->toBeTrue()
- ->and(roleReachDelete(roleReachController($permissions), 3))->toBeTrue();
-});
+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);
-it('still refuses the legacy role when the actor lacks one of its real permissions', function (array $permissions, ?string $extraGrant) {
- if ($extraGrant !== null) {
- $this->capsule->table('role_permissions')->insert(['role_id' => 3, 'permission' => $extraGrant]);
- }
-
- expect(roleReachMaySave(roleReachController($permissions), 3))->toBeFalse()
- ->and(roleReachMayAssign($permissions, 3))->toBeFalse()
- ->and(roleReachDelete(roleReachController($permissions), 3))->toBeFalse()
- ->and($this->capsule->table('role_permissions')->where('role_id', 3)->count())->toBeGreaterThan(0);
+ 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([
- 'a permission of the role' => [['save_role', 'delete_role', 'edit_document'], null],
- 'a stronger permission on the role' => [['save_role', 'delete_role', 'edit_document', 'save_document'], 'settings'],
+ '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 () {
- $permissions = ['save_role', 'delete_role', 'edit_document', 'save_document', 'settings'];
+ $actor = roleReachActor(['save_role', 'delete_role', 'settings', ...ROLE_REACH_EDITOR]);
+
+ expect(RoleGuard::mayAssignRole(2, fn ($key) => $actor->hasPermission($key), RoleGuard::ADMIN_ROLE))->toBeFalse();
+});
- expect(roleReachMayAssign($permissions, RoleGuard::ADMIN_ROLE))->toBeFalse()
- ->and(roleReachMaySave(roleReachController($permissions), 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('both reach checks read the target grants through reachOf()', function () {
+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((string) file_get_contents($src . 'UserRoles/UserRole.php'))->toContain('$targetPermissions = RoleGuard::reachOf($id);')
- ->and((string) file_get_contents($src . 'Users/EditOrNewUser.php'))
- ->toContain('$newRolePermissions = \EvolutionCMS\Support\RoleGuard::reachOf($newRole);');
+ 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']);
+});