feat(doctor): complete IRIMC import feature — claim flow, least-privilege importer, unique import key

- Extract import logic from AdminApiController into DoctorImportService
  (thin DoctorImportController keeps the same route/contract)
- Surrogate users get marker role ROLE_UNCLAIMED_DOCTOR (+ backfill command
  app:doctors:backfill-surrogate-role) enabling safe deletion after claim
- DB-level UNIQUE (source, medical_system_code) + concurrent-import retry
- Doctor profile claim flow (climed.md): shahkar + PersonInfo identity checks
  via existing ApiIrService, Persian name normalization (PersianText),
  pessimistic-lock race protection, DoctorClaimRequest audit table
  (national code hashed, mobile masked), doctor_claim rate limiter,
  public claim-info endpoint, welcome SMS
- Admin support tools: manual transfer endpoint + paginated doctor-claims
  audit list + owner_status filter/fields in admin doctors list
- Least privilege: system owner now gets ROLE_IMPORTER (ROLE_ADMIN stripped),
  import endpoint accepts ADMIN|IMPORTER, isStaff includes IMPORTER
- Headless crawler login: X-Service-Token header bypasses captcha only
  (rate limit + password checks intact; empty env = no bypass)
- docs: doctor-claim.md (new), doctor-import.md, admin.md, doctor.md
- tests: DoctorImportTest (6), DoctorClaimTest (11), PersianTextTest (5)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-11 11:39:15 +03:30
co-authored by Claude Opus 4.8
parent 83c872bb78
commit af125572c9
29 changed files with 1944 additions and 303 deletions
+9 -3
View File
@@ -70,11 +70,17 @@ class SystemOwnerCommand extends Command
}
}
// least privilege: کاربر سیستمی فقط ROLE_IMPORTER می‌گیرد (دسترسی فقط به اندپوینت
// ایمپورت پزشک). اگر از نسخه‌های قبلی ROLE_ADMIN دارد، حذف می‌شود.
$roles = $user->getRoles();
if (!in_array('ROLE_ADMIN', $roles, true)) {
$roles[] = 'ROLE_ADMIN';
$user->setRoles(array_values(array_unique($roles)));
if (in_array('ROLE_ADMIN', $roles, true)) {
$roles = array_values(array_diff($roles, ['ROLE_ADMIN']));
$io->note('ROLE_ADMIN از کاربر سیستمی حذف شد (least privilege).');
}
if (!in_array('ROLE_IMPORTER', $roles, true)) {
$roles[] = 'ROLE_IMPORTER';
}
$user->setRoles(array_values(array_unique($roles)));
if ($password !== null) {
$user->setPasswordHash($this->hasher->hashPassword($user, (string) $password));
+2 -1
View File
@@ -121,6 +121,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this->hasRole('ROLE_DOCTOR')
|| $this->hasRole('ROLE_CLINIC')
|| $this->hasRole('ROLE_SECRETARY')
|| $this->hasRole('ROLE_ADMIN');
|| $this->hasRole('ROLE_ADMIN')
|| $this->hasRole('ROLE_IMPORTER'); // کاربر سیستمی کرالر — لاگین با رمز؛ دسترسی فقط اندپوینت ایمپورت
}
}
+15 -1
View File
@@ -30,8 +30,20 @@ class PasswordAuthenticator extends AbstractAuthenticator
private readonly RateLimiterFactory $loginLimiter,
private readonly CaptchaGuard $captcha,
private readonly int $refreshTokenTtl = 2592000,
private readonly ?string $crawlerServiceToken = null,
) {}
/**
* لاگین سرویسی کرالر: هدر X-Service-Token با مقدار env CRAWLER_SERVICE_TOKEN
* فقط کپچا را دور می‌زند — rate limit و اعتبارسنجی رمز دست‌نخورده می‌مانند.
* env خالی = هیچ bypass (secure by default).
*/
private function isTrustedServiceLogin(Request $request): bool
{
return ($this->crawlerServiceToken ?? '') !== ''
&& hash_equals($this->crawlerServiceToken, (string) $request->headers->get('X-Service-Token', ''));
}
public function supports(Request $request): ?bool
{
return $request->getPathInfo() === '/api/v1/user/login'
@@ -46,7 +58,9 @@ class PasswordAuthenticator extends AbstractAuthenticator
}
// AppException را ExceptionSubscriber به پاسخ 422 با ERR_CAPTCHA_001 تبدیل می‌کند.
$this->captcha->assertValid($request);
if (!$this->isTrustedServiceLogin($request)) {
$this->captcha->assertValid($request);
}
$data = json_decode($request->getContent(), true) ?? [];
$mobile = trim($data['mobile_number'] ?? '');