20260505100000_add_users_local_subject_invariant.php 2.8 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
  1. <?php
  2. declare(strict_types=1);
  3. use App\Infrastructure\Db\Migrations\BaseMigration;
  4. /**
  5. * Defense-in-depth for SEC_REVIEW F12: enforce at the DB layer that a
  6. * row with `is_local = 1` always has `subject IS NULL`. SPEC §6 / §8
  7. * documents this invariant — local-admin rows have no OIDC subject —
  8. * and `UserRepository::upsertLocal` honours it in code, but a
  9. * compromised IdP that pushed an OIDC user with the local admin's
  10. * `display_name` plus a later "data fix" flipping `is_local=1` would
  11. * otherwise let `findLocal()` bind the local-admin password to that
  12. * hijacked OIDC identity.
  13. *
  14. * Implementation:
  15. * - MySQL 8: a CHECK constraint added via ALTER TABLE.
  16. * - SQLite: a pair of BEFORE INSERT and BEFORE UPDATE triggers that
  17. * RAISE(ABORT) when the predicate is violated. SQLite cannot ALTER
  18. * TABLE ADD CHECK without a full table rebuild, and triggers give
  19. * equivalent runtime enforcement.
  20. *
  21. * If a deployment already has a row violating the invariant
  22. * (is_local=1 AND subject IS NOT NULL), this migration will fail. That
  23. * is the desired signal: the operator must inspect and consolidate
  24. * before applying the constraint.
  25. */
  26. final class AddUsersLocalSubjectInvariant extends BaseMigration
  27. {
  28. private const CONSTRAINT_NAME = 'chk_users_local_subject_null';
  29. private const TRIGGER_INSERT = 'trg_users_local_subject_null_insert';
  30. private const TRIGGER_UPDATE = 'trg_users_local_subject_null_update';
  31. public function up(): void
  32. {
  33. if ($this->isMysql()) {
  34. $this->execute(
  35. 'ALTER TABLE users ADD CONSTRAINT ' . self::CONSTRAINT_NAME . ' '
  36. . 'CHECK (NOT (is_local = 1 AND subject IS NOT NULL))'
  37. );
  38. return;
  39. }
  40. $this->execute(<<<'SQL'
  41. CREATE TRIGGER trg_users_local_subject_null_insert
  42. BEFORE INSERT ON users
  43. FOR EACH ROW
  44. WHEN NEW.is_local = 1 AND NEW.subject IS NOT NULL
  45. BEGIN
  46. SELECT RAISE(ABORT, 'local user must have null subject');
  47. END
  48. SQL);
  49. $this->execute(<<<'SQL'
  50. CREATE TRIGGER trg_users_local_subject_null_update
  51. BEFORE UPDATE ON users
  52. FOR EACH ROW
  53. WHEN NEW.is_local = 1 AND NEW.subject IS NOT NULL
  54. BEGIN
  55. SELECT RAISE(ABORT, 'local user must have null subject');
  56. END
  57. SQL);
  58. }
  59. public function down(): void
  60. {
  61. if ($this->isMysql()) {
  62. $this->execute(
  63. 'ALTER TABLE users DROP CONSTRAINT ' . self::CONSTRAINT_NAME
  64. );
  65. return;
  66. }
  67. $this->execute('DROP TRIGGER IF EXISTS ' . self::TRIGGER_INSERT);
  68. $this->execute('DROP TRIGGER IF EXISTS ' . self::TRIGGER_UPDATE);
  69. }
  70. }