Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions admin/users/_validate.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,47 @@ function other_active_admin_exists(int $excludeUserId): bool
return (int)$stmt->fetchColumn() > 0;
}

/**
* Son aktif admin korumasini ISLEM (transaction) ICINDE atomik uygular.
*
* NEDEN GEREKLI: other_active_admin_exists() + ayri bir UPDATE klasik
* check-then-act yarisir. Iki aktif admin ayni anda birbirini
* pasiflestirirse/dusurse her iki istek de "baska admin var" gorup
* gecebilir ve sistem sifir aktif adminle kalir (olculdu: round-6
* proof'u, zorlanmis kesisme ile 0 admin).
*
* COZUM: cagiranin actigi transaction icinde TUM aktif admin satirlari
* FOR UPDATE ile kilitlenir. Es zamanli iki islem ayni satir kumesini
* ayni sirada kilitledigi icin serilesirler: ilki commit edene kadar
* ikincisi bloklanir; sonra guncel (commit edilmis) durumu okur ve
* artik tek admin kaldiysa REDDEDILIR. Kilitli okuma (FOR UPDATE)
* REPEATABLE READ altinda bile guncel surumu gorur.
*
* KULLANIM: cagiran beginTransaction() yapmis olmali; bu fonksiyon
* kilitleri tutar ve KARAR verir; UPDATE ve commit cagirana aittir.
*
* @param PDO $pdo Cagiranin transaction baglantisi.
* @param int $targetUserId Degistirilmek istenen kullanici.
* @param bool $targetWasActiveAdmin Hedef su an aktif bir admin mi?
* @return bool true = isleme devam edilebilir; false = hedef son aktif
* admin, islem geri cevrilmeli.
*/
function last_admin_atomic_guard(PDO $pdo, int $targetUserId, bool $targetWasActiveAdmin): bool
{
if (!$targetWasActiveAdmin) {
return true;
}
$ids = $pdo->query(
"SELECT id FROM users WHERE role = 'admin' AND status = 1 FOR UPDATE"
)->fetchAll(PDO::FETCH_COLUMN);
foreach ($ids as $id) {
if ((int)$id !== $targetUserId) {
return true; // kilide alinmis kumede baska bir aktif admin var
}
}
return false;
Comment on lines +69 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a target is promoted to admin after the caller’s snapshot, this early return skips the atomic check and can leave zero active admins. Lock and inspect the current target row inside the transaction instead of trusting targetWasActiveAdmin.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At admin/users/_validate.php, line 69:

<comment>When a target is promoted to admin after the caller’s snapshot, this early return skips the atomic check and can leave zero active admins. Lock and inspect the current target row inside the transaction instead of trusting `targetWasActiveAdmin`.</comment>

<file context>
@@ -39,6 +39,47 @@ function other_active_admin_exists(int $excludeUserId): bool
+ */
+function last_admin_atomic_guard(PDO $pdo, int $targetUserId, bool $targetWasActiveAdmin): bool
+{
+    if (!$targetWasActiveAdmin) {
+        return true;
+    }
</file context>
Suggested change
if (!$targetWasActiveAdmin) {
return true;
}
$ids = $pdo->query(
"SELECT id FROM users WHERE role = 'admin' AND status = 1 FOR UPDATE"
)->fetchAll(PDO::FETCH_COLUMN);
foreach ($ids as $id) {
if ((int)$id !== $targetUserId) {
return true; // kilide alinmis kumede baska bir aktif admin var
}
}
return false;
$stmt = $pdo->prepare(
"SELECT id, role, status FROM users
WHERE id = :target OR (role = 'admin' AND status = 1)
ORDER BY id FOR UPDATE"
);
$stmt->execute([':target' => $targetUserId]);
$rows = $stmt->fetchAll();
$targetIsActiveAdmin = false;
$otherActiveAdminExists = false;
foreach ($rows as $row) {
$isActiveAdmin = $row['role'] === 'admin' && (int)$row['status'] === 1;
if ((int)$row['id'] === $targetUserId) {
$targetIsActiveAdmin = $isActiveAdmin;
} elseif ($isActiveAdmin) {
$otherActiveAdminExists = true;
}
}
return !$targetIsActiveAdmin || $otherActiveAdminExists;

}

/**
* POST verisini okur ve doğrular.
*
Expand Down
38 changes: 29 additions & 9 deletions admin/users/toggle_status.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,39 @@

$newStatus = (int)$user['status'] === 1 ? 0 : 1;

if ($newStatus === 0) {
if ($id === auth_id()) {
flash('error', 'Kendi hesabınızı devre dışı bırakamazsınız.');
redirect('/admin/users/');
}
if ($user['role'] === ROLE_ADMIN && !other_active_admin_exists($id)) {
if ($newStatus === 0 && $id === auth_id()) {
flash('error', 'Kendi hesabınızı devre dışı bırakamazsınız.');
redirect('/admin/users/');
}

/* --- Son aktif admin koruması: ATOMİK -------------------------------
Kontrol ve UPDATE aynı transaction içinde; tüm aktif admin satırları
FOR UPDATE ile kilitlenir (bkz. last_admin_atomic_guard). Ayrı bir
SELECT COUNT(...) + ayrı UPDATE klasik check-then-act yarışıydı: iki
admin birbirini aynı anda pasifleştirdiğinde her iki istek de
"başka admin var" görüyor ve sistem yönetimsiz kalıyordu. */
$pdo = db();
$pdo->beginTransaction();
try {
$wasActiveAdmin = $user['role'] === ROLE_ADMIN && (int)$user['status'] === 1;

if ($newStatus === 0 && !last_admin_atomic_guard($pdo, $id, $wasActiveAdmin)) {
$pdo->rollBack();
flash('error', 'Bu, sistemdeki tek aktif admin hesabı. Önce başka bir admin tanımlayın.');
redirect('/admin/users/');
}
}

db()->prepare('UPDATE users SET status = :s WHERE id = :id')
->execute([':s' => $newStatus, ':id' => $id]);
$pdo->prepare('UPDATE users SET status = :s WHERE id = :id')
->execute([':s' => $newStatus, ':id' => $id]);
$pdo->commit();
} catch (Throwable $ex) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
app_log('error', 'User status toggle failed: ' . $ex->getMessage(), ['user' => $id]);
flash('error', 'Durum değiştirilemedi.');
redirect('/admin/users/');
}

audit('user_status_changed', 'user', $id,
['status' => (int)$user['status']],
Expand Down
26 changes: 25 additions & 1 deletion admin/users/update.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,26 @@
}

try {
db()->prepare(
$pdo = db();
$pdo->beginTransaction();

/* --- Son aktif admin koruması: ATOMİK ---------------------------
Doğrulamadaki kontrol (user_collect_input) yalnızca UX; yarış
penceresini kapatamaz. Yetkiyi düşüren veya hesabı kapatan
UPDATE'ten önce aynı transaction içinde kilitli kontrol. */
$wasActiveAdmin = $before['role'] === ROLE_ADMIN && (int)$before['status'] === 1;
$staysActiveAdmin = $data['role'] === ROLE_ADMIN && (int)$data['status'] === 1;

if ($wasActiveAdmin && !$staysActiveAdmin
&& !last_admin_atomic_guard($pdo, $id, true)) {
$pdo->rollBack();
user_fail_back(
['role' => 'Bu, sistemdeki tek aktif admin hesabı. Önce başka bir admin tanımlayın.'],
'/admin/users/edit.php?id=' . $id
);
}

$pdo->prepare(
'UPDATE users SET
name = :n, email = :e, role = :r, department_id = :d,
title = :t, phone = :ph, status = :s
Expand All @@ -49,7 +68,12 @@
':s' => $data['status'],
':id' => $id,
]);

$pdo->commit();
} catch (Throwable $ex) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
app_log('error', 'User update failed: ' . $ex->getMessage(), ['user' => $id]);
old_set($_POST);
flash('error', 'Değişiklikler kaydedilemedi.');
Expand Down
Loading