From 044ecd356c0183e937e8afcf7cdc21d6912d79a6 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 9 Nov 2026 19:06:01 +0100 Subject: [PATCH 1/3] fix(acl): compare role reach by the grants that authorize at runtime At login the session holds every stored grant of the role, defined by a permissions row or not, so a plugin's own key authorizes as much as a core one. adb4e31 compared only grants of defined permissions on both sides: a role carrying such a key could be saved, assigned or deleted by a role manager who does not hold it. Both sides now follow the runtime grants. RoleGuard::heldBy() asks the actor's hasPermission() for every defined key and every key any role is granted; RoleGuard::reachOf() takes the target's stored grants without the obsolete keys the earlier default seeders left (RoleGuard::OBSOLETE: role_actionok, action_ok, which nothing checks) and without the always-on permissions, which a role save stores on every role whoever saves it. Role save, delete and assignment use the same two helpers. Co-Authored-By: Claude Opus 5.5 --- core/src/Controllers/UserRoles/UserRole.php | 5 +- core/src/Controllers/Users/EditOrNewUser.php | 5 +- core/src/Support/RoleGuard.php | 54 +++++- .../Security/RoleReachLegacyGrantsTest.php | 159 ++++++++++++------ 4 files changed, 154 insertions(+), 69 deletions(-) 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..dcfc9d747f 100644 --- a/core/src/Controllers/Users/EditOrNewUser.php +++ b/core/src/Controllers/Users/EditOrNewUser.php @@ -150,10 +150,7 @@ public function process(): bool \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) - )); + $actorPermissions = \EvolutionCMS\Support\RoleGuard::heldBy(static fn ($key) => EvolutionCMS()->hasPermission($key)); $newRolePermissions = \EvolutionCMS\Support\RoleGuard::reachOf($newRole); if (isset($userData['role']) diff --git a/core/src/Support/RoleGuard.php b/core/src/Support/RoleGuard.php index 2c759ae548..88aab6443f 100644 --- a/core/src/Support/RoleGuard.php +++ b/core/src/Support/RoleGuard.php @@ -40,20 +40,56 @@ 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(): grantsOf() without the obsolete + * keys and the always-on permissions, which grantablePermissions() stores on every role any + * role manager saves, so holding one is nothing an actor could not confer. * * @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(); + $alwaysOn = Permissions::query()->where('disabled', 1)->pluck('key')->all(); + + return array_values(array_diff(self::grantsOf($roleId), self::OBSOLETE, $alwaysOn)); + } + + /** + * 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))); } /** diff --git a/core/tests/Unit/Security/RoleReachLegacyGrantsTest.php b/core/tests/Unit/Security/RoleReachLegacyGrantsTest.php index 963fdbf6a6..77427d8e90 100644 --- a/core/tests/Unit/Security/RoleReachLegacyGrantsTest.php +++ b/core/tests/Unit/Security/RoleReachLegacyGrantsTest.php @@ -8,16 +8,17 @@ /* |-------------------------------------------------------------------------- -| 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. At +| login the session receives every stored grant of the role, defined by a permissions row or not +| (a plugin's own key), so both sides are compared by those runtime grants: the actor's through +| its own hasPermission(), the target's as stored. Two kinds of grant set no reach: +| - the obsolete keys the earlier default seeders left on every role (role_actionok, action_ok), +| which nothing checks; counting them locked default roles away from role managers holding +| every real permission of them; +| - always-on permissions, which a role save stores on every role whoever saves it. | */ @@ -36,8 +37,6 @@ public function hasPermission($key): int class RoleReachThemeStub { - public ?string $quitMessage = null; - public function __construct(public RoleReachCoreStub $core) { } @@ -49,28 +48,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 role 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 roleReachMaySave(RoleReachCoreStub $actor, int $role): bool { try { - (new ReflectionMethod(UserRoleController::class, 'guardRoleReach'))->invoke($controller, $role); + (new ReflectionMethod(UserRoleController::class, 'guardRoleReach'))->invoke(roleReachController($actor), $role); return true; } catch (RuntimeException $e) { @@ -78,25 +86,42 @@ function roleReachMaySave(UserRoleController $controller, int $role): bool } } -function roleReachDelete(UserRoleController $controller, int $role): bool +/** The check EditOrNewUser makes before it assigns $role to a user. */ +function roleReachMayAssign(RoleReachCoreStub $actor, int $role): bool +{ + return RoleGuard::mayManageRole(2, RoleGuard::heldBy(fn ($key) => $actor->hasPermission($key)), $role, RoleGuard::reachOf($role)); +} + +/** The real delete branch; true when the role is gone. */ +function roleReachDelete(RoleReachCoreStub $actor, int $role): bool { $_GET['action'] = $_REQUEST['action'] = 'delete'; $_GET['id'] = $_REQUEST['id'] = $role; try { // header() after the delete only warns in the CLI - @$controller->process(); + @roleReachController($actor)->process(); } catch (RuntimeException $e) { } return !Capsule::table('user_roles')->where('id', $role)->exists(); } -/** The check EditOrNewUser makes before it assigns $role to a user. */ -function roleReachMayAssign(array $permissions, int $role): bool +/** @return array{save: bool, assign: bool, delete: bool} delete last: it removes the role */ +function roleReachOutcome(array $actorGrants, int $role = 3): array { - return RoleGuard::mayManageRole(2, $permissions, $role, RoleGuard::reachOf($role)); + $actor = roleReachActor($actorGrants); + + return [ + 'save' => roleReachMaySave($actor, $role), + 'assign' => roleReachMayAssign($actor, $role), + 'delete' => roleReachDelete($actor, $role), + ]; } +const ROLE_REACH_ALL = ['save' => true, 'assign' => true, 'delete' => true]; +const ROLE_REACH_NONE = ['save' => false, 'assign' => false, 'delete' => false]; +const ROLE_REACH_MANAGER = ['save_role', 'delete_role', 'frames', 'home']; + beforeEach(function () { $this->capsule = CacheRefreshHarness::boot(':memory:'); $schema = $this->capsule->getConnection()->getSchemaBuilder(); @@ -123,15 +148,23 @@ 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, int $role = 3) { + foreach ($keys as $key) { + $this->capsule->table('role_permissions')->insert(['role_id' => $role, 'permission' => $key]); + } + }; + ($this->grant)(['edit_document', 'save_document', 'frames', 'home', 'error_dialog']); + // 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 () { @@ -142,44 +175,66 @@ function roleReachMayAssign(array $permissions, int $role): bool 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 a role manager holding every real permission of the default role manage it', function (bool $upgraded) { + 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([...ROLE_REACH_MANAGER, 'edit_document', 'save_document']))->toBe(ROLE_REACH_ALL); +})->with(['fresh install' => [false], 'upgraded install' => [true]]); - expect(roleReachMaySave(roleReachController($permissions), 3))->toBeTrue() - ->and(roleReachMayAssign($permissions, 3))->toBeTrue() - ->and(roleReachDelete(roleReachController($permissions), 3))->toBeTrue(); -}); - -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]); +it('refuses the default role when the actor lacks one of its real permissions', function (bool $upgraded) { + if ($upgraded) { + ($this->upgrade)(); } - expect(roleReachMaySave(roleReachController($permissions), 3))->toBeFalse() - ->and(roleReachMayAssign($permissions, 3))->toBeFalse() - ->and(roleReachDelete(roleReachController($permissions), 3))->toBeFalse() + expect(roleReachOutcome([...ROLE_REACH_MANAGER, 'edit_document']))->toBe(ROLE_REACH_NONE) ->and($this->capsule->table('role_permissions')->where('role_id', 3)->count())->toBeGreaterThan(0); +})->with(['fresh install' => [false], 'upgraded install' => [true]]); + +it('counts custom permissions, defined or only granted, as runtime authorizes them', function (string $custom, bool $actorHoldsIt, array $expected) { + // shop_orders has a permissions row; shop_refunds is a plugin's key that only a grant carries + ($this->upgrade)(); + ($this->grant)([$custom]); + $actor = [...ROLE_REACH_MANAGER, 'edit_document', 'save_document', ...($actorHoldsIt ? [$custom] : [])]; + + expect(roleReachOutcome($actor))->toBe($expected); })->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'], + 'defined, held by the actor' => ['shop_orders', true, ROLE_REACH_ALL], + 'defined, not held by the actor' => ['shop_orders', false, ROLE_REACH_NONE], + 'undefined, held by the actor' => ['shop_refunds', true, ROLE_REACH_ALL], + 'undefined, not held by the actor' => ['shop_refunds', false, ROLE_REACH_NONE], ]); +it('refuses a role holding a stronger core permission', function () { + ($this->grant)(['settings']); + + expect(roleReachOutcome([...ROLE_REACH_MANAGER, 'edit_document', 'save_document']))->toBe(ROLE_REACH_NONE); +}); + +it('ignores only the obsolete keys and the always-on permissions', function () { + ($this->upgrade)(); + ($this->grant)(['shop_refunds']); + + expect(RoleGuard::grantsOf(3))->toContain('role_actionok', 'action_ok', 'frames', 'shop_refunds') + ->and(RoleGuard::reachOf(3))->toEqualCanonicalizing(['edit_document', 'save_document', 'shop_refunds']) + ->and(RoleGuard::reachOf(99))->toBe([]); +}); + it('keeps the administrator role out of reach', function () { - $permissions = ['save_role', 'delete_role', 'edit_document', 'save_document', 'settings']; + $actor = roleReachActor([...ROLE_REACH_MANAGER, 'edit_document', 'save_document', 'settings']); - expect(roleReachMayAssign($permissions, RoleGuard::ADMIN_ROLE))->toBeFalse() - ->and(roleReachMaySave(roleReachController($permissions), RoleGuard::ADMIN_ROLE))->toBeFalse(); + expect(roleReachMaySave($actor, RoleGuard::ADMIN_ROLE))->toBeFalse() + ->and(roleReachMayAssign($actor, RoleGuard::ADMIN_ROLE))->toBeFalse(); }); -test('both reach checks read the target grants through reachOf()', function () { +test('role save, delete and assignment ask the same two 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('$newRolePermissions = \EvolutionCMS\Support\RoleGuard::reachOf($newRole);') + ->and($user)->toContain('$actorPermissions = \EvolutionCMS\Support\RoleGuard::heldBy('); }); From fec047f71daca1f11a5faba1cae37ea25f07c53b Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 9 Nov 2026 19:41:54 +0100 Subject: [PATCH 2/3] fix(acl): count always-on grants in role reach and assign through one rule 044ecd3 left always-on permissions out of a target role's reach, because a role save stores them on every role. Only a manager holding save_role can save, though: one allowed to delete a role, or holding a narrower set, could act on a role whose always-on grants it lacks, and those grants authorize at runtime like any other. reachOf() now leaves out only the obsolete keys. A non-administrator stores on a saved role only grants it holds, always-on ones included. The decision EditOrNewUser makes before it changes a user's role, save_role plus the reach check, moves to RoleGuard::mayAssignRole(). The tests run the real role save and delete and mayAssignRole() for save_role / delete_role combinations, fresh and upgraded installs, custom permissions with and without a permissions row, and an actor whose role lacks an always-on grant the target holds. Co-Authored-By: Claude Opus 5.5 --- core/src/Controllers/Users/EditOrNewUser.php | 5 +- core/src/Support/RoleGuard.php | 31 +-- .../Unit/Security/AdminAccountGuardTest.php | 2 +- .../Security/ManagerAuthorizationGapsTest.php | 2 +- core/tests/Unit/Security/RoleGuardTest.php | 10 +- .../Security/RoleReachLegacyGrantsTest.php | 186 +++++++++++------- 6 files changed, 140 insertions(+), 96 deletions(-) diff --git a/core/src/Controllers/Users/EditOrNewUser.php b/core/src/Controllers/Users/EditOrNewUser.php index dcfc9d747f..950c397d42 100644 --- a/core/src/Controllers/Users/EditOrNewUser.php +++ b/core/src/Controllers/Users/EditOrNewUser.php @@ -150,13 +150,10 @@ public function process(): bool \UserManager::saveSettings($userData); $newRole = (int) ($userData['role'] ?? 0); - $actorPermissions = \EvolutionCMS\Support\RoleGuard::heldBy(static fn ($key) => 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)) { + && \EvolutionCMS\Support\RoleGuard::mayAssignRole($actorRole, static fn ($key) => EvolutionCMS()->hasPermission($key), $newRole)) { \UserManager::setRole(['id' => $user->getKey(), 'role' => $userData['role']]); } diff --git a/core/src/Support/RoleGuard.php b/core/src/Support/RoleGuard.php index 88aab6443f..88f6a5f955 100644 --- a/core/src/Support/RoleGuard.php +++ b/core/src/Support/RoleGuard.php @@ -61,17 +61,26 @@ public static function grantsOf(int $roleId): array } /** - * The grants of a role that set its reach, for mayManageRole(): grantsOf() without the obsolete - * keys and the always-on permissions, which grantablePermissions() stores on every role any - * role manager saves, so holding one is nothing an actor could not confer. + * 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 { - $alwaysOn = Permissions::query()->where('disabled', 1)->pluck('key')->all(); + return array_values(array_diff(self::grantsOf($roleId), self::OBSOLETE)); + } - return array_values(array_diff(self::grantsOf($roleId), self::OBSOLETE, $alwaysOn)); + /** + * 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)); } /** @@ -93,8 +102,9 @@ public static function heldBy(callable $hasPermission): array } /** - * 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 @@ -104,10 +114,9 @@ public static function heldBy(callable $hasPermission): 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/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 77427d8e90..40284d4333 100644 --- a/core/tests/Unit/Security/RoleReachLegacyGrantsTest.php +++ b/core/tests/Unit/Security/RoleReachLegacyGrantsTest.php @@ -11,14 +11,17 @@ | Role reach follows the grants that authorize at runtime |-------------------------------------------------------------------------- | -| A non-administrator may save, delete or assign only a role whose grants it holds itself. At -| login the session receives every stored grant of the role, defined by a permissions row or not -| (a plugin's own key), so both sides are compared by those runtime grants: the actor's through -| its own hasPermission(), the target's as stored. Two kinds of grant set no reach: -| - the obsolete keys the earlier default seeders left on every role (role_actionok, action_ok), -| which nothing checks; counting them locked default roles away from role managers holding -| every real permission of them; -| - always-on permissions, which a role save stores on every role whoever saves it. +| 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. | */ @@ -33,6 +36,24 @@ 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 @@ -52,7 +73,7 @@ public function alertAndQuit($message, $lexicon = true, $url = ''): void } } -/** The role manager (role 2): its role holds $grants, which is what its session answers. */ +/** The acting manager (role 2): its role holds $grants, which is what its session answers. */ function roleReachActor(array $grants): RoleReachCoreStub { $_SESSION['mgrRole'] = 2; @@ -74,11 +95,11 @@ function roleReachController(RoleReachCoreStub $actor): UserRoleController return $controller; } -/** guardRoleReach(), which role save (a=35/36) runs before anything else */ -function roleReachMaySave(RoleReachCoreStub $actor, int $role): bool +function roleReachRun(callable $request): bool { try { - (new ReflectionMethod(UserRoleController::class, 'guardRoleReach'))->invoke(roleReachController($actor), $role); + // header() after a save or delete only warns in the CLI + @$request(); return true; } catch (RuntimeException $e) { @@ -86,41 +107,50 @@ function roleReachMaySave(RoleReachCoreStub $actor, int $role): bool } } -/** The check EditOrNewUser makes before it assigns $role to a user. */ -function roleReachMayAssign(RoleReachCoreStub $actor, 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 { - return RoleGuard::mayManageRole(2, RoleGuard::heldBy(fn ($key) => $actor->hasPermission($key)), $role, RoleGuard::reachOf($role)); + $_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)'; } -/** The real delete branch; true when the role is gone. */ -function roleReachDelete(RoleReachCoreStub $actor, int $role): bool +function roleReachDelete(RoleReachCoreStub $actor): bool { - $_GET['action'] = $_REQUEST['action'] = 'delete'; - $_GET['id'] = $_REQUEST['id'] = $role; - try { - // header() after the delete only warns in the CLI - @roleReachController($actor)->process(); - } catch (RuntimeException $e) { - } + $_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(); } -/** @return array{save: bool, assign: bool, delete: bool} delete last: it removes the role */ -function roleReachOutcome(array $actorGrants, int $role = 3): array +/** + * 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 [ - 'save' => roleReachMaySave($actor, $role), - 'assign' => roleReachMayAssign($actor, $role), - 'delete' => roleReachDelete($actor, $role), + 'assign' => RoleGuard::mayAssignRole(2, fn ($key) => $actor->hasPermission($key), 3), + 'save' => roleReachSave($actor, $roleKeys), + 'delete' => roleReachDelete($actor), ]; } -const ROLE_REACH_ALL = ['save' => true, 'assign' => true, 'delete' => true]; -const ROLE_REACH_NONE = ['save' => false, 'assign' => false, 'delete' => false]; -const ROLE_REACH_MANAGER = ['save_role', 'delete_role', 'frames', 'home']; +/** 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:'); @@ -129,6 +159,7 @@ function roleReachOutcome(array $actorGrants, int $role = 3): array $t->increments('id'); $t->string('name')->default(''); $t->text('description')->nullable(); + $t->timestamps(); }); $schema->create('role_permissions', function (Blueprint $t) { $t->increments('id'); @@ -154,12 +185,12 @@ function roleReachOutcome(array $actorGrants, int $role = 3): array $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, int $role = 3) { + $this->grant = function (array $keys) { foreach ($keys as $key) { - $this->capsule->table('role_permissions')->insert(['role_id' => $role, 'permission' => $key]); + $this->capsule->table('role_permissions')->insert(['role_id' => 3, 'permission' => $key]); } }; - ($this->grant)(['edit_document', 'save_document', 'frames', 'home', 'error_dialog']); + ($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]); @@ -169,72 +200,77 @@ function roleReachOutcome(array $actorGrants, int $role = 3): array afterEach(function () { Capsule::connection()->disconnect(); - foreach (['action', 'id'] as $k) { - unset($_GET[$k], $_REQUEST[$k]); - } + $_GET = $_POST = $_REQUEST = []; unset($_SESSION['mgrRole']); }); -it('lets a role manager holding every real permission of the default role manage it', function (bool $upgraded) { - if ($upgraded) { - ($this->upgrade)(); - } - - expect(roleReachOutcome([...ROLE_REACH_MANAGER, 'edit_document', 'save_document']))->toBe(ROLE_REACH_ALL); -})->with(['fresh install' => [false], 'upgraded install' => [true]]); - -it('refuses the default role when the actor lacks one of its real permissions', function (bool $upgraded) { +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([...ROLE_REACH_MANAGER, 'edit_document']))->toBe(ROLE_REACH_NONE) - ->and($this->capsule->table('role_permissions')->where('role_id', 3)->count())->toBeGreaterThan(0); -})->with(['fresh install' => [false], 'upgraded install' => [true]]); + 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('counts custom permissions, defined or only granted, as runtime authorizes them', function (string $custom, bool $actorHoldsIt, array $expected) { - // shop_orders has a permissions row; shop_refunds is a plugin's key that only a grant carries +it('refuses every operation on a role whose runtime grants exceed the actor', function (array $rights, array $extra, array $actorExtra) { ($this->upgrade)(); - ($this->grant)([$custom]); - $actor = [...ROLE_REACH_MANAGER, 'edit_document', 'save_document', ...($actorHoldsIt ? [$custom] : [])]; + ($this->grant)($extra); - expect(roleReachOutcome($actor))->toBe($expected); + 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([ - 'defined, held by the actor' => ['shop_orders', true, ROLE_REACH_ALL], - 'defined, not held by the actor' => ['shop_orders', false, ROLE_REACH_NONE], - 'undefined, held by the actor' => ['shop_refunds', true, ROLE_REACH_ALL], - 'undefined, not held by the actor' => ['shop_refunds', false, ROLE_REACH_NONE], + '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('refuses a role holding a stronger core permission', function () { - ($this->grant)(['settings']); +it('allows a role carrying custom grants the actor holds', function (string $custom) { + ($this->upgrade)(); + ($this->grant)([$custom]); - expect(roleReachOutcome([...ROLE_REACH_MANAGER, 'edit_document', 'save_document']))->toBe(ROLE_REACH_NONE); -}); + expect(roleReachOutcome(['save_role', 'delete_role', ...ROLE_REACH_EDITOR, $custom]))->toBe(ROLE_REACH_ALL); +})->with(['shop_orders', 'shop_refunds']); -it('ignores only the obsolete keys and the always-on permissions', function () { +it('stores on a saved role only grants the actor holds, and drops the obsolete ones', function () { ($this->upgrade)(); - ($this->grant)(['shop_refunds']); + $actor = roleReachActor(['save_role', 'edit_document', 'save_document', 'frames', 'home', 'error_dialog']); - expect(RoleGuard::grantsOf(3))->toContain('role_actionok', 'action_ok', 'frames', 'shop_refunds') - ->and(RoleGuard::reachOf(3))->toEqualCanonicalizing(['edit_document', 'save_document', 'shop_refunds']) - ->and(RoleGuard::reachOf(99))->toBe([]); + 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([...ROLE_REACH_MANAGER, '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(roleReachMaySave($actor, RoleGuard::ADMIN_ROLE))->toBeFalse() - ->and(roleReachMayAssign($actor, 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 two helpers', 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($role)->toContain('$targetPermissions = RoleGuard::reachOf($id);') ->and($role)->toContain('return RoleGuard::heldBy(static fn ($key) => $core->hasPermission($key));') - ->and($user)->toContain('$newRolePermissions = \EvolutionCMS\Support\RoleGuard::reachOf($newRole);') - ->and($user)->toContain('$actorPermissions = \EvolutionCMS\Support\RoleGuard::heldBy('); + ->and($user)->toContain('\EvolutionCMS\Support\RoleGuard::mayAssignRole($actorRole, static fn ($key) => EvolutionCMS()->hasPermission($key), $newRole)'); }); From 75dc25b7402f00e0e1bc8f343440ab4ff3087a1e Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 9 Nov 2026 23:55:49 +0100 Subject: [PATCH 3/3] fix(acl): store exactly the role id the assignment check approved EditOrNewUser checked the reach of (int) $_POST['role'] but passed the posted string to setRole(). MySQL and MariaDB in the sql_mode the installer configures coerce it on write ('0.5' becomes 1), so the stored role could be one the check never saw. RoleGuard::postedRoleId() now accepts only "0" or the decimal id of an existing role, validated once before anything is saved, and that same int goes to the TV lookup, the reach check and setRole(). Co-Authored-By: Claude Opus 5.5 --- core/lang/az/global.php | 1 + core/lang/be/global.php | 1 + core/lang/bg/global.php | 1 + core/lang/cs/global.php | 1 + core/lang/da/global.php | 1 + core/lang/de/global.php | 1 + core/lang/en/global.php | 1 + core/lang/es/global.php | 1 + core/lang/et/global.php | 1 + core/lang/fa/global.php | 1 + core/lang/fi/global.php | 1 + core/lang/fr/global.php | 1 + core/lang/he/global.php | 1 + core/lang/hy/global.php | 1 + core/lang/it/global.php | 1 + core/lang/ja/global.php | 1 + core/lang/ka/global.php | 1 + core/lang/kk/global.php | 1 + core/lang/ky/global.php | 1 + core/lang/lt/global.php | 1 + core/lang/lv/global.php | 1 + core/lang/nl/global.php | 1 + core/lang/nn/global.php | 1 + core/lang/pl/global.php | 1 + core/lang/pt/global.php | 1 + core/lang/ro/global.php | 1 + core/lang/sk/global.php | 1 + core/lang/sv/global.php | 1 + core/lang/tg/global.php | 1 + core/lang/tk/global.php | 1 + core/lang/uk/global.php | 1 + core/lang/uz/global.php | 1 + core/lang/zh/global.php | 1 + core/src/Controllers/Users/EditOrNewUser.php | 19 +- core/src/Support/RoleGuard.php | 20 ++ core/tests/Fixtures/user-role-assignment.php | 215 ++++++++++++++++++ .../Unit/Security/UserRoleAssignmentTest.php | 135 +++++++++++ 37 files changed, 417 insertions(+), 5 deletions(-) create mode 100644 core/tests/Fixtures/user-role-assignment.php create mode 100644 core/tests/Unit/Security/UserRoleAssignmentTest.php 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/Users/EditOrNewUser.php b/core/src/Controllers/Users/EditOrNewUser.php index 950c397d42..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,12 +160,10 @@ public function process(): bool \UserManager::clearSettings($userData); \UserManager::saveSettings($userData); - $newRole = (int) ($userData['role'] ?? 0); - - if (isset($userData['role']) - && $userData['role'] != $user->attributes->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' => $userData['role']]); + \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 88f6a5f955..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. @@ -71,6 +72,25 @@ 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. 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/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']); +});