Files
breadcrumb-the-shire/tests/Service/Access/UserAuthorizationPolicyTest.php

577 lines
24 KiB
PHP

<?php
namespace MintyPHP\Tests\Service\Access;
use MintyPHP\Service\Access\PermissionGateway;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\UserAuthorizationPolicy;
use MintyPHP\Service\Directory\DirectoryScopeGateway;
use PHPUnit\Framework\TestCase;
class UserAuthorizationPolicyTest extends TestCase
{
public function testAdminViewRequiresUsersViewPermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->expects($this->once())
->method('userHas')
->with(13, PermissionService::USERS_VIEW)
->willReturn(false);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW, [
'actor_user_id' => 13,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('forbidden', $decision->error());
}
public function testAdminCreateRequiresUsersCreatePermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->expects($this->once())
->method('userHas')
->with(13, PermissionService::USERS_CREATE)
->willReturn(false);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_CREATE, [
'actor_user_id' => 13,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
}
public function testAdminActivateRequiresUsersUpdatePermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->expects($this->once())
->method('userHas')
->with(11, PermissionService::USERS_UPDATE)
->willReturn(false);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->never())->method('canAccess');
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_ACTIVATE, [
'actor_user_id' => 11,
'target_user_id' => 22,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('forbidden', $decision->error());
}
public function testAdminActivateReturnsPermissionDeniedWhenTenantScopeFails(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 11 && $permission === PermissionService::USERS_UPDATE);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
->method('canAccess')
->with('users', 22, 11)
->willReturn(false);
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_ACTIVATE, [
'actor_user_id' => 11,
'target_user_id' => 22,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('permission_denied', $decision->error());
}
public function testAdminDeactivateAllowsSelfWithoutScopeLookup(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 9 && $permission === PermissionService::USERS_UPDATE);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->never())->method('canAccess');
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_DEACTIVATE, [
'actor_user_id' => 9,
'target_user_id' => 9,
]);
$this->assertTrue($decision->isAllowed());
}
public function testAdminBulkDeleteRequiresUsersDeletePermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->expects($this->once())
->method('userHas')
->with(7, PermissionService::USERS_DELETE)
->willReturn(false);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_BULK, [
'actor_user_id' => 7,
'bulk_action' => 'delete',
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
}
public function testAdminDeleteReturnsPermissionDeniedWhenTenantScopeFails(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 7 && $permission === PermissionService::USERS_DELETE);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
->method('canAccess')
->with('users', 17, 7)
->willReturn(false);
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_DELETE, [
'actor_user_id' => 7,
'target_user_id' => 17,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('permission_denied', $decision->error());
}
public function testAdminAccessPdfBulkRequiresPermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->expects($this->once())
->method('userHas')
->with(21, PermissionService::USERS_ACCESS_PDF)
->willReturn(false);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->never())->method('canAccess');
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_ACCESS_PDF_BULK, [
'actor_user_id' => 21,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
}
public function testAdminApiTokensManageRequiresScopeForOtherUsers(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 8 && $permission === PermissionService::API_TOKENS_MANAGE);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
->method('canAccess')
->with('users', 18, 8)
->willReturn(false);
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_API_TOKENS_MANAGE, [
'actor_user_id' => 8,
'target_user_id' => 18,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame('permission_denied', $decision->error());
}
public function testAdminSendAccessAllowsSelfWithSelfUpdatePermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
if ($userId !== 30) {
return false;
}
if ($permission === PermissionService::USERS_SELF_UPDATE) {
return true;
}
return false;
});
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->never())->method('canAccess');
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_SEND_ACCESS, [
'actor_user_id' => 30,
'target_user_id' => 30,
]);
$this->assertTrue($decision->isAllowed());
}
public function testEditContextAllowsOwnAccountWithUsersSelfUpdate(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
if ($userId !== 30) {
return false;
}
return $permission === PermissionService::USERS_SELF_UPDATE;
});
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->never())->method('canAccess');
$scopeGateway->expects($this->once())
->method('getUserTenantIds')
->with(30)
->willReturn([2, 3]);
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_CONTEXT, [
'actor_user_id' => 30,
'target_user_id' => 30,
]);
$capabilities = $decision->attribute('capabilities', []);
$this->assertTrue($decision->isAllowed());
$this->assertTrue($capabilities['is_own_account'] ?? false);
$this->assertTrue($capabilities['can_edit_user'] ?? false);
$this->assertTrue($capabilities['can_view_page'] ?? false);
$this->assertTrue($capabilities['can_view_security_artifacts'] ?? false);
$this->assertSame([2, 3], $capabilities['allowed_tenant_ids'] ?? []);
}
public function testEditContextReturnsPermissionDeniedWhenTargetIsOutOfScope(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
->method('canAccess')
->with('users', 66, 33)
->willReturn(false);
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_CONTEXT, [
'actor_user_id' => 33,
'target_user_id' => 66,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('permission_denied', $decision->error());
}
public function testEditContextHidesSecurityArtifactsWithoutAuditPermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
if ($userId !== 9) {
return false;
}
return $permission === PermissionService::USERS_VIEW;
});
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
->method('canAccess')
->with('users', 55, 9)
->willReturn(true);
$scopeGateway->expects($this->once())
->method('getUserTenantIds')
->with(9)
->willReturn([1]);
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_CONTEXT, [
'actor_user_id' => 9,
'target_user_id' => 55,
]);
$this->assertTrue($decision->isAllowed());
$this->assertFalse((bool) ($decision->attribute('capabilities', [])['can_view_security_artifacts'] ?? true));
}
public function testEditContextShowsSecurityArtifactsWithAuditPermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
if ($userId !== 9) {
return false;
}
return in_array($permission, [PermissionService::USERS_VIEW, PermissionService::USERS_VIEW_AUDIT], true);
});
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
->method('canAccess')
->with('users', 55, 9)
->willReturn(true);
$scopeGateway->expects($this->once())
->method('getUserTenantIds')
->with(9)
->willReturn([1]);
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_CONTEXT, [
'actor_user_id' => 9,
'target_user_id' => 55,
]);
$this->assertTrue($decision->isAllowed());
$this->assertTrue((bool) ($decision->attribute('capabilities', [])['can_view_security_artifacts'] ?? false));
}
public function testEditSubmitRejectsUserWithoutEditCapability(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static function (int $userId, string $permission): bool {
if ($userId !== 9) {
return false;
}
return $permission === PermissionService::USERS_VIEW;
});
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
->method('canAccess')
->with('users', 55, 9)
->willReturn(true);
$scopeGateway->expects($this->once())
->method('getUserTenantIds')
->with(9)
->willReturn([1]);
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_SUBMIT, [
'actor_user_id' => 9,
'target_user_id' => 55,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('forbidden', $decision->error());
}
public function testAvatarUploadAllowsOwnAccountWithSelfUpdate(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 19 && $permission === PermissionService::USERS_SELF_UPDATE);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->never())->method('canAccess');
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_AVATAR_UPLOAD, [
'actor_user_id' => 19,
'target_user_id' => 19,
]);
$this->assertTrue($decision->isAllowed());
}
public function testAvatarDeleteReturnsPermissionDeniedOutOfScope(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
->method('canAccess')
->with('users', 42, 7)
->willReturn(false);
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_AVATAR_DELETE, [
'actor_user_id' => 7,
'target_user_id' => 42,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame('permission_denied', $decision->error());
}
public function testAvatarViewAllowsInScopeAddressBookViewer(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 5 && $permission === PermissionService::ADDRESS_BOOK_VIEW);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
->method('canAccess')
->with('users', 70, 5)
->willReturn(true);
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_AVATAR_VIEW, [
'actor_user_id' => 5,
'target_user_id' => 70,
]);
$this->assertTrue($decision->isAllowed());
}
public function testAvatarViewRejectsInScopeUserWithoutAnyRelevantPermission(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')->willReturn(false);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
->method('canAccess')
->with('users', 70, 5)
->willReturn(true);
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_AVATAR_VIEW, [
'actor_user_id' => 5,
'target_user_id' => 70,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame('forbidden', $decision->error());
}
public function testApiShowReturnsNotFoundWhenTenantScopeFails(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 4 && $permission === PermissionService::USERS_VIEW);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
->method('canAccess')
->with('users', 44, 4)
->willReturn(false);
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_GET, [
'actor_user_id' => 4,
'target_user_id' => 44,
'scoped_tenant_id' => null,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(404, $decision->status());
$this->assertSame('not_found', $decision->error());
}
public function testApiShowReturnsNotFoundForScopedTokenCrossTenantResource(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 4 && $permission === PermissionService::USERS_VIEW);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
->method('canAccess')
->with('users', 44, 4)
->willReturn(true);
$scopeGateway->expects($this->once())
->method('resourceBelongsToTenant')
->with('users', 44, 2)
->willReturn(false);
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_GET, [
'actor_user_id' => 4,
'target_user_id' => 44,
'scoped_tenant_id' => 2,
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(404, $decision->status());
$this->assertSame('not_found', $decision->error());
}
public function testApiCreateForScopedTokenRejectsOutOfScopeTenantIds(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 5 && $permission === PermissionService::USERS_CREATE);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_API_USERS_INDEX_POST, [
'actor_user_id' => 5,
'scoped_tenant_id' => 9,
'input' => ['tenant_ids' => [9, 10]],
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('tenant_scoped_token_forbidden', $decision->error());
}
public function testApiCreateForScopedTokenEnforcesScopedTenantInInput(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 5 && $permission === PermissionService::USERS_CREATE);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_API_USERS_INDEX_POST, [
'actor_user_id' => 5,
'scoped_tenant_id' => 9,
'input' => ['first_name' => 'Ada', 'tenant_ids' => [9]],
]);
$this->assertTrue($decision->isAllowed());
$this->assertSame([9], $decision->attribute('input')['tenant_ids'] ?? []);
$this->assertSame(9, $decision->attribute('input')['primary_tenant_id'] ?? null);
}
public function testApiUpdateForScopedTokenRejectsOutOfScopePrimaryTenant(): void
{
$permissionGateway = $this->createMock(PermissionGateway::class);
$permissionGateway->method('userHas')
->willReturnCallback(static fn (int $userId, string $permission): bool => $userId === 6 && $permission === PermissionService::USERS_UPDATE);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
->method('canAccess')
->with('users', 42, 6)
->willReturn(true);
$scopeGateway->expects($this->once())
->method('resourceBelongsToTenant')
->with('users', 42, 2)
->willReturn(true);
$policy = new UserAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_UPDATE, [
'actor_user_id' => 6,
'target_user_id' => 42,
'scoped_tenant_id' => 2,
'input' => ['primary_tenant_id' => 7],
]);
$this->assertFalse($decision->isAllowed());
$this->assertSame(403, $decision->status());
$this->assertSame('tenant_scoped_token_forbidden', $decision->error());
}
}