20260504100000_add_unique_local_user_index.php 1.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
  1. <?php
  2. declare(strict_types=1);
  3. use App\Infrastructure\Db\Migrations\BaseMigration;
  4. /**
  5. * Defense-in-depth for SEC_REVIEW F3: enforce at the DB layer that there
  6. * is at most one row in `users` with `is_local = 1`. SPEC §6/§8 says the
  7. * local admin is a single record whose password lives in the UI's env;
  8. * `UserRepository::upsertLocal` already enforces this in code, but the
  9. * DB-level index makes regressions fail loud.
  10. *
  11. * Implementation:
  12. * - SQLite: native partial unique index `WHERE is_local = 1`.
  13. * - MySQL 8: functional unique index over a CASE expression that yields
  14. * `1` for local rows and `NULL` for non-local rows. Multi-NULL is
  15. * permitted in unique indexes on both engines.
  16. *
  17. * If a deployment already has multiple `is_local = 1` rows (either from
  18. * the F3 exploit window or from operators legitimately renaming the
  19. * local admin via the pre-fix code path), this migration will fail. That
  20. * is the desired signal: the operator must inspect those rows manually
  21. * and consolidate before applying the index.
  22. */
  23. final class AddUniqueLocalUserIndex extends BaseMigration
  24. {
  25. private const INDEX_NAME = 'uniq_users_one_local';
  26. public function up(): void
  27. {
  28. if ($this->isMysql()) {
  29. $this->execute(
  30. 'CREATE UNIQUE INDEX ' . self::INDEX_NAME . ' '
  31. . 'ON users ((CASE WHEN is_local = 1 THEN 1 ELSE NULL END))'
  32. );
  33. return;
  34. }
  35. $this->execute(
  36. 'CREATE UNIQUE INDEX ' . self::INDEX_NAME . ' '
  37. . 'ON users (is_local) WHERE is_local = 1'
  38. );
  39. }
  40. public function down(): void
  41. {
  42. if ($this->isMysql()) {
  43. $this->execute('DROP INDEX ' . self::INDEX_NAME . ' ON users');
  44. return;
  45. }
  46. $this->execute('DROP INDEX IF EXISTS ' . self::INDEX_NAME);
  47. }
  48. }