|
@@ -235,6 +235,80 @@ final class AuthEndpointsTest extends AppTestCase
|
|
|
]);
|
|
]);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * SEC_REVIEW F12 regression. Even if direct DB tampering or a
|
|
|
|
|
+ * compromised "data-fix" script tries to insert a row with
|
|
|
|
|
+ * `is_local=1` AND a non-null `subject` (an OIDC identity flagged
|
|
|
|
|
+ * as local), the migration
|
|
|
|
|
+ * 20260505100000_add_users_local_subject_invariant rejects the
|
|
|
|
|
+ * write — MySQL via CHECK constraint, SQLite via BEFORE INSERT
|
|
|
|
|
+ * trigger.
|
|
|
|
|
+ */
|
|
|
|
|
+ public function testDbLayerRejectsInsertingLocalRowWithNonNullSubject(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $this->expectException(\Doctrine\DBAL\Exception::class);
|
|
|
|
|
+
|
|
|
|
|
+ $this->db->insert('users', [
|
|
|
|
|
+ 'subject' => 'hijacked-oidc-sub',
|
|
|
|
|
+ 'email' => 'hijacked@example.com',
|
|
|
|
|
+ 'display_name' => 'admin',
|
|
|
|
|
+ 'role' => Role::Admin->value,
|
|
|
|
|
+ 'is_local' => 1,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * SEC_REVIEW F12 regression. The classic threat model is "OIDC row
|
|
|
|
|
+ * already exists, attacker flips its is_local to 1 via a data-fix
|
|
|
|
|
+ * script". The BEFORE UPDATE trigger / CHECK constraint must catch
|
|
|
|
|
+ * this transition too — not only fresh inserts.
|
|
|
|
|
+ */
|
|
|
|
|
+ public function testDbLayerRejectsFlippingIsLocalOnOidcRow(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $this->db->insert('users', [
|
|
|
|
|
+ 'subject' => 'oidc-sub',
|
|
|
|
|
+ 'email' => 'oidc@example.com',
|
|
|
|
|
+ 'display_name' => 'admin',
|
|
|
|
|
+ 'role' => Role::Viewer->value,
|
|
|
|
|
+ 'is_local' => 0,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ $oidcId = (int) $this->db->lastInsertId();
|
|
|
|
|
+
|
|
|
|
|
+ $this->expectException(\Doctrine\DBAL\Exception::class);
|
|
|
|
|
+
|
|
|
|
|
+ $this->db->update('users', ['is_local' => 1], ['id' => $oidcId]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * SEC_REVIEW F12 regression. Belt-and-suspenders to the DB-level
|
|
|
|
|
+ * constraint: `findLocal()` must additionally filter by
|
|
|
|
|
+ * `subject IS NULL`, so that even if some future code path bypasses
|
|
|
|
|
+ * the constraint (or it is dropped during ops), a hijacked OIDC row
|
|
|
|
|
+ * cannot bind to local-admin login. We cannot exercise this via a
|
|
|
|
|
+ * direct insert (the DB constraint blocks it), so we drop the
|
|
|
|
|
+ * constraint for the duration of the test, inject the malformed
|
|
|
|
|
+ * row, and assert findLocal does not return it.
|
|
|
|
|
+ */
|
|
|
|
|
+ public function testFindLocalIgnoresHijackedRowEvenIfDbConstraintIsBypassed(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ // Bypass the SQLite triggers so we can simulate a tampered DB.
|
|
|
|
|
+ $this->db->executeStatement('DROP TRIGGER IF EXISTS trg_users_local_subject_null_insert');
|
|
|
|
|
+ $this->db->executeStatement('DROP TRIGGER IF EXISTS trg_users_local_subject_null_update');
|
|
|
|
|
+
|
|
|
|
|
+ $this->db->insert('users', [
|
|
|
|
|
+ 'subject' => 'oidc-sub-hijacked',
|
|
|
|
|
+ 'email' => 'hijack@example.com',
|
|
|
|
|
+ 'display_name' => 'admin',
|
|
|
|
|
+ 'role' => Role::Admin->value,
|
|
|
|
|
+ 'is_local' => 1,
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ /** @var \App\Infrastructure\Auth\UserRepository $users */
|
|
|
|
|
+ $users = $this->container->get(\App\Infrastructure\Auth\UserRepository::class);
|
|
|
|
|
+
|
|
|
|
|
+ self::assertNull($users->findLocal(), 'findLocal must skip rows with non-null subject');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
public function testUpsertLocalIsIdempotent(): void
|
|
public function testUpsertLocalIsIdempotent(): void
|
|
|
{
|
|
{
|
|
|
$token = $this->createToken(TokenKind::Service);
|
|
$token = $this->createToken(TokenKind::Service);
|