From 3e921fc07e11434eeb46813a4f06537f63e0b695 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Thu, 23 Jul 2026 18:01:26 -0400 Subject: [PATCH] feat: pemissions Signed-off-by: Sebastian Krupinski --- lib/Controllers/PasswordController.php | 12 ++--- lib/Module.php | 10 ++++ src/components/AdminSecurityPanel.vue | 21 ++++++-- src/components/CredentialSetupPanel.vue | 2 +- tests/php/Integration/IntegrationTestCase.php | 44 +++++++++++++++-- .../Integration/PasswordControllerTest.php | 49 ++++++++++++++----- 6 files changed, 109 insertions(+), 29 deletions(-) diff --git a/lib/Controllers/PasswordController.php b/lib/Controllers/PasswordController.php index b382814..f63618a 100644 --- a/lib/Controllers/PasswordController.php +++ b/lib/Controllers/PasswordController.php @@ -53,7 +53,7 @@ class PasswordController extends ControllerAbstract /** * Admin endpoint: Get credential status for a user */ - #[AuthenticatedRoute('/admin/status/{uid}', name: 'password.admin.status', methods: ['GET'])] + #[AuthenticatedRoute('/status', name: 'password.admin.status', methods: ['POST'], permissions: ['authentication_provider_password.admin.view', 'authentication_provider_password.admin.manage'])] public function getStatus(string $uid): JsonResponse { $tenantId = $this->sessionTenant->identifier(); @@ -62,8 +62,6 @@ class PasswordController extends ControllerAbstract return new JsonResponse(['error' => 'Invalid session state'], 400); } - // TODO: Add permission check for admin operations - $user = $this->userAccountsService->fetchByIdentifier($uid); if (!$user) { return new JsonResponse(['error' => 'User not found'], 404); @@ -81,7 +79,7 @@ class PasswordController extends ControllerAbstract /** * Admin endpoint: Set/reset user password */ - #[AuthenticatedRoute('/admin/reset', name: 'password.admin.reset', methods: ['POST'])] + #[AuthenticatedRoute('/reset', name: 'password.admin.reset', methods: ['POST'], permissions: ['authentication_provider_password.admin.manage'])] public function adminReset(string $uid, string $password): JsonResponse { $tenantId = $this->sessionTenant->identifier(); @@ -90,8 +88,6 @@ class PasswordController extends ControllerAbstract return new JsonResponse(['error' => 'Invalid session state'], 400); } - // TODO: Add permission check for admin operations - if (strlen($password) < 8) { return new JsonResponse(['error' => 'Password must be at least 8 characters'], 400); } @@ -114,7 +110,7 @@ class PasswordController extends ControllerAbstract /** * Admin endpoint: Remove user password */ - #[AuthenticatedRoute('/admin/remove/{uid}', name: 'password.admin.remove', methods: ['DELETE'])] + #[AuthenticatedRoute('/remove', name: 'password.admin.remove', methods: ['POST'], permissions: ['authentication_provider_password.admin.manage'])] public function adminRemove(string $uid): JsonResponse { $tenantId = $this->sessionTenant->identifier(); @@ -123,8 +119,6 @@ class PasswordController extends ControllerAbstract return new JsonResponse(['error' => 'Invalid session state'], 400); } - // TODO: Add permission check for admin operations - $user = $this->userAccountsService->fetchByIdentifier($uid); if (!$user) { return new JsonResponse(['error' => 'User not found'], 404); diff --git a/lib/Module.php b/lib/Module.php index 2d148b1..e82122f 100644 --- a/lib/Module.php +++ b/lib/Module.php @@ -53,6 +53,16 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M 'description' => 'View and access the password authentication provider module', 'group' => 'Authentication Providers' ], + 'authentication_provider_password.admin.view' => [ + 'label' => 'View Password Status', + 'description' => 'View whether another user has a password credential configured', + 'group' => 'Authentication Providers' + ], + 'authentication_provider_password.admin.manage' => [ + 'label' => 'Manage User Passwords', + 'description' => 'Set, reset, or remove password credentials for other users', + 'group' => 'Authentication Providers' + ], ]; } diff --git a/src/components/AdminSecurityPanel.vue b/src/components/AdminSecurityPanel.vue index 4c0b077..0b4dfb3 100644 --- a/src/components/AdminSecurityPanel.vue +++ b/src/components/AdminSecurityPanel.vue @@ -30,8 +30,15 @@ const confirmPassword = ref(''); const loadStatus = async () => { statusLoading.value = true; try { - const response = await fetch(`/m/authentication_provider_password/admin/status/${props.user.uid}`, { + const response = await fetch('/m/authentication_provider_password/status', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, credentials: 'include', + body: JSON.stringify({ + uid: props.user.uid, + }), }); if (response.ok) { @@ -60,7 +67,7 @@ const resetPassword = async () => { error.value = null; try { - const response = await fetch('/m/authentication_provider_password/admin/reset', { + const response = await fetch('/m/authentication_provider_password/reset', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -95,9 +102,15 @@ const removePassword = async () => { error.value = null; try { - const response = await fetch(`/m/authentication_provider_password/admin/remove/${props.user.uid}`, { - method: 'DELETE', + const response = await fetch('/m/authentication_provider_password/remove', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, credentials: 'include', + body: JSON.stringify({ + uid: props.user.uid, + }), }); if (response.ok) { diff --git a/src/components/CredentialSetupPanel.vue b/src/components/CredentialSetupPanel.vue index 2a3a738..9e9609b 100644 --- a/src/components/CredentialSetupPanel.vue +++ b/src/components/CredentialSetupPanel.vue @@ -34,7 +34,7 @@ const setPassword = async () => { error.value = null; try { - const response = await fetch('/m/authentication_provider_password/admin/reset', { + const response = await fetch('/m/authentication_provider_password/reset', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/tests/php/Integration/IntegrationTestCase.php b/tests/php/Integration/IntegrationTestCase.php index 193315b..2c6e825 100644 --- a/tests/php/Integration/IntegrationTestCase.php +++ b/tests/php/Integration/IntegrationTestCase.php @@ -7,6 +7,7 @@ namespace KTXT\AuthenticationProviderPassword\Tests\Integration; use KTXC\Http\Request\Request; use KTXC\Http\Response\Response; use KTXC\Server; +use KTXC\Stores\UserRolesStore; use PHPUnit\Framework\TestCase; /** @@ -67,14 +68,21 @@ abstract class IntegrationTestCase extends TestCase // ========================================================================= /** - * Provision a user (optionally with a password credential) and return - * its uid, so tests can exercise admin endpoints that operate by uid. + * Provision a user (optionally with a password credential and roles) and + * return its uid, so tests can exercise admin endpoints that operate by uid. + * + * @param string[] $roles Role IDs to assign (see createRoleWithPermissions()) */ - protected static function createUser(string $identity, ?string $password = null): string + protected static function createUser(string $identity, ?string $password = null, array $roles = []): string { $uid = bin2hex(random_bytes(8)); - static::runConsole(['user:create', static::$tenantIdentifier, $identity, '--uid', $uid]); + $args = ['user:create', static::$tenantIdentifier, $identity, '--uid', $uid]; + foreach ($roles as $role) { + $args[] = '--role'; + $args[] = $role; + } + static::runConsole($args); if ($password !== null) { static::runConsole(['user:password', static::$tenantIdentifier, $identity, $password]); @@ -83,6 +91,34 @@ abstract class IntegrationTestCase extends TestCase return $uid; } + /** + * Create a role granting the given permissions and return its role id. + * + * There's no `bin/console` command for role management, so this goes + * straight through the DI container to UserRolesStore — the same store + * the real UserRolesController uses — rather than calling controllers + * directly. + * + * @param string[] $permissions + */ + protected static function createRoleWithPermissions(array $permissions, string $label = 'Test Role'): string + { + $server = static::buildServer(); + try { + $server->kernel()->boot(); + $store = $server->container()->get(UserRolesStore::class); + $role = $store->createRole(static::$tenantIdentifier, [ + 'label' => $label, + 'permissions' => $permissions, + ]); + } finally { + restore_error_handler(); + restore_exception_handler(); + } + + return $role['rid']; + } + /** * Run a bin/console command as a real subprocess and fail the test on * a non-zero exit code. diff --git a/tests/php/Integration/PasswordControllerTest.php b/tests/php/Integration/PasswordControllerTest.php index 9489b02..685ac2d 100644 --- a/tests/php/Integration/PasswordControllerTest.php +++ b/tests/php/Integration/PasswordControllerTest.php @@ -63,12 +63,13 @@ class PasswordControllerTest extends IntegrationTestCase public function testAdminStatusReflectsEnrollment(): void { - $enrolledUid = static::createUser('grace@example.test', 'Grace-Pass-1!'); + $roleId = static::createRoleWithPermissions(['authentication_provider_password.admin.view']); + $enrolledUid = static::createUser('grace@example.test', 'Grace-Pass-1!', [$roleId]); $unenrolledUid = static::createUser('heidi@example.test'); $accessToken = static::loginWithPassword('grace@example.test', 'Grace-Pass-1!')['accessToken']; - $enrolledStatus = static::authorizedRequest($accessToken, 'GET', self::BASE . "/admin/status/{$enrolledUid}"); - $unenrolledStatus = static::authorizedRequest($accessToken, 'GET', self::BASE . "/admin/status/{$unenrolledUid}"); + $enrolledStatus = static::authorizedRequest($accessToken, 'POST', self::BASE . '/status', ['uid' => $enrolledUid]); + $unenrolledStatus = static::authorizedRequest($accessToken, 'POST', self::BASE . '/status', ['uid' => $unenrolledUid]); $this->assertTrue($enrolledStatus['body']['enrolled'] ?? null); $this->assertFalse($unenrolledStatus['body']['enrolled'] ?? null); @@ -77,11 +78,12 @@ class PasswordControllerTest extends IntegrationTestCase public function testAdminResetSetsAPasswordThatCanLogIn(): void { $uid = static::createUser('ivan@example.test'); - // Bootstrap an authenticated actor to call the admin endpoint - static::createUser('actor1@example.test', 'Actor-Pass-1!'); + // Bootstrap an authenticated actor with permission to call the admin endpoint + $roleId = static::createRoleWithPermissions(['authentication_provider_password.admin.manage']); + static::createUser('actor1@example.test', 'Actor-Pass-1!', [$roleId]); $accessToken = static::loginWithPassword('actor1@example.test', 'Actor-Pass-1!')['accessToken']; - $reset = static::authorizedRequest($accessToken, 'POST', self::BASE . '/admin/reset', [ + $reset = static::authorizedRequest($accessToken, 'POST', self::BASE . '/reset', [ 'uid' => $uid, 'password' => 'Reset-By-Admin-1!', ]); @@ -96,10 +98,11 @@ class PasswordControllerTest extends IntegrationTestCase public function testAdminResetRejectsShortPasswords(): void { $uid = static::createUser('judy@example.test'); - static::createUser('actor2@example.test', 'Actor-Pass-2!'); + $roleId = static::createRoleWithPermissions(['authentication_provider_password.admin.manage']); + static::createUser('actor2@example.test', 'Actor-Pass-2!', [$roleId]); $accessToken = static::loginWithPassword('actor2@example.test', 'Actor-Pass-2!')['accessToken']; - $reset = static::authorizedRequest($accessToken, 'POST', self::BASE . '/admin/reset', [ + $reset = static::authorizedRequest($accessToken, 'POST', self::BASE . '/reset', [ 'uid' => $uid, 'password' => 'short', ]); @@ -110,17 +113,41 @@ class PasswordControllerTest extends IntegrationTestCase public function testAdminRemoveRevokesLogin(): void { $uid = static::createUser('kevin@example.test', 'Kevin-Pass-1!'); - static::createUser('actor3@example.test', 'Actor-Pass-3!'); + $roleId = static::createRoleWithPermissions(['authentication_provider_password.admin.manage']); + static::createUser('actor3@example.test', 'Actor-Pass-3!', [$roleId]); $accessToken = static::loginWithPassword('actor3@example.test', 'Actor-Pass-3!')['accessToken']; - $remove = static::authorizedRequest($accessToken, 'DELETE', self::BASE . "/admin/remove/{$uid}"); + $remove = static::authorizedRequest($accessToken, 'POST', self::BASE . '/remove', ['uid' => $uid]); $this->assertSame(200, $remove['status']); $this->assertTrue($remove['body']['success'] ?? false); - $status = static::authorizedRequest($accessToken, 'GET', self::BASE . "/admin/status/{$uid}"); + $status = static::authorizedRequest($accessToken, 'POST', self::BASE . '/status', ['uid' => $uid]); $this->assertFalse($status['body']['enrolled'] ?? null); $login = static::loginWithPassword('kevin@example.test', 'Kevin-Pass-1!'); $this->assertSame('failed', $login['body']['status'] ?? null); } + + public function testAdminEndpointsRejectActorsWithoutPermission(): void + { + $targetUid = static::createUser('mallory-target@example.test', 'Mallory-Target-1!'); + static::createUser('mallory@example.test', 'Mallory-Pass-1!'); + $accessToken = static::loginWithPassword('mallory@example.test', 'Mallory-Pass-1!')['accessToken']; + + $status = static::authorizedRequest($accessToken, 'POST', self::BASE . '/status', ['uid' => $targetUid]); + $this->assertSame(403, $status['status']); + + $reset = static::authorizedRequest($accessToken, 'POST', self::BASE . '/reset', [ + 'uid' => $targetUid, + 'password' => 'Should-Not-Apply-1!', + ]); + $this->assertSame(403, $reset['status']); + + $remove = static::authorizedRequest($accessToken, 'POST', self::BASE . '/remove', ['uid' => $targetUid]); + $this->assertSame(403, $remove['status']); + + // None of the rejected calls should have taken effect + $login = static::loginWithPassword('mallory-target@example.test', 'Mallory-Target-1!'); + $this->assertSame('success', $login['body']['status'] ?? null); + } }