| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980 |
- <?php
- declare(strict_types=1);
- use App\Infrastructure\Db\Migrations\BaseMigration;
- /**
- * Defense-in-depth for SEC_REVIEW F12: enforce at the DB layer that a
- * row with `is_local = 1` always has `subject IS NULL`. SPEC §6 / §8
- * documents this invariant — local-admin rows have no OIDC subject —
- * and `UserRepository::upsertLocal` honours it in code, but a
- * compromised IdP that pushed an OIDC user with the local admin's
- * `display_name` plus a later "data fix" flipping `is_local=1` would
- * otherwise let `findLocal()` bind the local-admin password to that
- * hijacked OIDC identity.
- *
- * Implementation:
- * - MySQL 8: a CHECK constraint added via ALTER TABLE.
- * - SQLite: a pair of BEFORE INSERT and BEFORE UPDATE triggers that
- * RAISE(ABORT) when the predicate is violated. SQLite cannot ALTER
- * TABLE ADD CHECK without a full table rebuild, and triggers give
- * equivalent runtime enforcement.
- *
- * If a deployment already has a row violating the invariant
- * (is_local=1 AND subject IS NOT NULL), this migration will fail. That
- * is the desired signal: the operator must inspect and consolidate
- * before applying the constraint.
- */
- final class AddUsersLocalSubjectInvariant extends BaseMigration
- {
- private const CONSTRAINT_NAME = 'chk_users_local_subject_null';
- private const TRIGGER_INSERT = 'trg_users_local_subject_null_insert';
- private const TRIGGER_UPDATE = 'trg_users_local_subject_null_update';
- public function up(): void
- {
- if ($this->isMysql()) {
- $this->execute(
- 'ALTER TABLE users ADD CONSTRAINT ' . self::CONSTRAINT_NAME . ' '
- . 'CHECK (NOT (is_local = 1 AND subject IS NOT NULL))'
- );
- return;
- }
- $this->execute(<<<'SQL'
- CREATE TRIGGER trg_users_local_subject_null_insert
- BEFORE INSERT ON users
- FOR EACH ROW
- WHEN NEW.is_local = 1 AND NEW.subject IS NOT NULL
- BEGIN
- SELECT RAISE(ABORT, 'local user must have null subject');
- END
- SQL);
- $this->execute(<<<'SQL'
- CREATE TRIGGER trg_users_local_subject_null_update
- BEFORE UPDATE ON users
- FOR EACH ROW
- WHEN NEW.is_local = 1 AND NEW.subject IS NOT NULL
- BEGIN
- SELECT RAISE(ABORT, 'local user must have null subject');
- END
- SQL);
- }
- public function down(): void
- {
- if ($this->isMysql()) {
- $this->execute(
- 'ALTER TABLE users DROP CONSTRAINT ' . self::CONSTRAINT_NAME
- );
- return;
- }
- $this->execute('DROP TRIGGER IF EXISTS ' . self::TRIGGER_INSERT);
- $this->execute('DROP TRIGGER IF EXISTS ' . self::TRIGGER_UPDATE);
- }
- }
|