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()); } }