Merge pull request 'feat: pemissions' (#30) from feat/admin-permissions into main
Reviewed-on: #30
This commit was merged in pull request #30.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user