fix(security): make commission_percent & active admin-only on PATCH representation

A representation editing its own record could raise its own commission or
self-activate (privilege escalation). Restrict both fields to ROLE_ADMIN and
range-check commission (0–100). Owner can still edit name/city/bank.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-28 17:36:48 +03:30
co-authored by Claude Opus 4.8
parent e456809b55
commit 6bd49c2d3e
3 changed files with 112 additions and 6 deletions
+4
View File
@@ -121,6 +121,8 @@ Update representation.
All fields optional.
**Privileged fields:** `commission_percent` and `active` are **admin-only** — a representation editing its own record may change `full_name`, `city_id`, `bank_account` but **not** these two. `commission_percent` must be within `0100`.
### Response `200`
Updated representation object.
@@ -129,6 +131,8 @@ Updated representation object.
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not owner or admin |
| `ERR_AUTH_006` | 403 | Non-admin tried to change `commission_percent` or `active` |
| `ERR_VALIDATION_001` | 422 | `commission_percent` خارج از بازه ۰ تا ۱۰۰ (`field: commission_percent`) |
| `ERR_NOT_FOUND_001` | 404 | Representation not found |
---
@@ -107,6 +107,13 @@ class RepresentationController extends BaseController
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این کاربر قبلاً نماینده است', 409);
}
if (!empty($data['commission_percent'])) {
$commission = (float) $data['commission_percent'];
if ($commission < 0 || $commission > 100) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درصد کمیسیون باید بین ۰ تا ۱۰۰ باشد', 422, 'commission_percent');
}
}
$rep = new Representation($user, $fullName);
if (isset($data['city_id'])) $rep->setCityId($data['city_id'] ? (int)$data['city_id'] : null);
if (!empty($data['commission_percent'])) $rep->setCommissionPercent((string)$data['commission_percent']);
@@ -202,12 +209,31 @@ class RepresentationController extends BaseController
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
if (array_key_exists('full_name', $data)) $rep->setFullName($data['full_name']);
if (array_key_exists('city_id', $data)) $rep->setCityId($data['city_id'] ? (int)$data['city_id'] : null);
if (array_key_exists('bank_account', $data)) $rep->setBankAccount($data['bank_account']);
if (array_key_exists('commission_percent', $data)) $rep->setCommissionPercent((string)$data['commission_percent']);
if (array_key_exists('active', $data)) $rep->setActive((bool)$data['active']);
$data = json_decode($request->getContent(), true) ?? [];
$isAdmin = $user->hasRole('ROLE_ADMIN');
if (array_key_exists('full_name', $data)) $rep->setFullName($data['full_name']);
if (array_key_exists('city_id', $data)) $rep->setCityId($data['city_id'] ? (int)$data['city_id'] : null);
if (array_key_exists('bank_account', $data)) $rep->setBankAccount($data['bank_account']);
// commission_percent and active are privileged: a representative must not
// be able to raise their own commission or activate themselves.
if (array_key_exists('commission_percent', $data)) {
if (!$isAdmin) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'تغییر درصد کمیسیون فقط توسط مدیر مجاز است', 403);
}
$commission = (float) $data['commission_percent'];
if ($commission < 0 || $commission > 100) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درصد کمیسیون باید بین ۰ تا ۱۰۰ باشد', 422, 'commission_percent');
}
$rep->setCommissionPercent((string) $commission);
}
if (array_key_exists('active', $data)) {
if (!$isAdmin) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'تغییر وضعیت فعال‌بودن فقط توسط مدیر مجاز است', 403);
}
$rep->setActive((bool) $data['active']);
}
$this->representationRepo->save($rep);
@@ -0,0 +1,76 @@
<?php
namespace App\Tests\Representation;
use App\Representation\Entity\Representation;
use App\Tests\ApiTestCase;
/**
* commission_percent / active are admin-only on PATCH representation, and the
* commission value is range-checked. Guards against a representative raising
* their own commission or self-activating.
*/
class RepresentationCommissionTest extends ApiTestCase
{
private function makeRep(): array
{
$owner = $this->createUser(['ROLE_USER', 'ROLE_REPRESENTATION']);
$rep = new Representation($owner, 'نماینده تست');
$rep->setCommissionPercent('10');
$this->em->persist($rep);
$this->em->flush();
return [$owner, $rep];
}
public function testOwnerCannotChangeOwnCommission(): void
{
[$owner, $rep] = $this->makeRep();
$this->authJson('PATCH', '/api/v1/representation/' . $rep->getUuid(), $owner, [
'commission_percent' => 90,
]);
$this->assertSame(403, $this->responseCode());
$this->em->clear();
$fresh = $this->em->getRepository(Representation::class)->find($rep->getId());
$this->assertSame(10.0, (float) $fresh->getCommissionPercent());
}
public function testOwnerCannotSelfActivate(): void
{
[$owner, $rep] = $this->makeRep();
$this->authJson('PATCH', '/api/v1/representation/' . $rep->getUuid(), $owner, [
'active' => true,
]);
$this->assertSame(403, $this->responseCode());
}
public function testAdminCanChangeCommission(): void
{
[, $rep] = $this->makeRep();
$admin = $this->createUser(['ROLE_ADMIN']);
$this->authJson('PATCH', '/api/v1/representation/' . $rep->getUuid(), $admin, [
'commission_percent' => 25,
]);
$this->assertSame(200, $this->responseCode());
}
public function testAdminRejectedOnOutOfRangeCommission(): void
{
[, $rep] = $this->makeRep();
$admin = $this->createUser(['ROLE_ADMIN']);
$this->authJson('PATCH', '/api/v1/representation/' . $rep->getUuid(), $admin, [
'commission_percent' => 150,
]);
$this->assertSame(422, $this->responseCode());
}
public function testOwnerCanStillEditOwnName(): void
{
[$owner, $rep] = $this->makeRep();
$this->authJson('PATCH', '/api/v1/representation/' . $rep->getUuid(), $owner, [
'full_name' => 'نام جدید',
]);
$this->assertSame(200, $this->responseCode());
}
}