Skip to content

fix(admin): son aktif admin korumasi es zamanli isteklerle asilabiliyordu - #6

Closed
ersinkoc wants to merge 1 commit into
CodeByPinar:mainfrom
ersinkoc:fix/last-admin-race-guard
Closed

fix(admin): son aktif admin korumasi es zamanli isteklerle asilabiliyordu #6
ersinkoc wants to merge 1 commit into
CodeByPinar:mainfrom
ersinkoc:fix/last-admin-race-guard

Conversation

@ersinkoc

@ersinkoc ersinkoc commented Sep 11, 2026

Copy link
Copy Markdown

Hata

"Son aktif admin" koruması bir check-then-act yarışıydı: koruma, veritabanı sunucusunda kilitlenmemiş bir SELECT COUNT(*) (other_active_admin_exists()) ve her uç noktada ondan sonra çalışan ayrı bir UPDATE şeklindeydi.

Tam iki aktif admin varken, birbirini eş zamanlı pasifleştiren/düşüren iki istek her ikisi de "başka bir admin var" görür, her ikisi de korumadan geçer ve her ikisi de commit eder → sistemde sıfır aktif admin kalır (tam kilitleme; kurtarma doğrudan DB müdahalesi gerektirir). Korumayı yazan yorum ("sistemde admin kalmayabilir") tam olarak bunu önlemek istiyordu.

Etkilenen yollar:

  • admin/users/toggle_status.php (hızlı aç/kapat)
  • admin/users/update.php (user_collect_input() doğrulaması üzerinden rol düşürme / hesap kapatma)

Kanıt (yerel MariaDB üzerinde deterministik yarış)

Proof harness'i gerçek iki işçi süreç çalıştırır — her biri kendi PDO bağlantısıyla — ve marker dosyalarıyla kesintiyi zorlar: her iki koruma da her iki UPDATE'ten önce çalışacak şekilde:

Worker A (hedef B): {"completed":true,"guard_rejected":false,"updated":true}
Worker B (hedef A): {"completed":true,"guard_rejected":false,"updated":true}
Race sonrası aktif admin sayısı: 0 (invariant: >= 1)          → DÜZELTME ÖNCESİ: FAIL

Düzeltmeden sonra aynı zorlanmış kesinti: bir işçi güncelledi, diğeri guard tarafından reddedildi, 1 aktif admin kaldı → PASS.

Çözüm

  • admin/users/_validate.php — yeni last_admin_atomic_guard(PDO, int, bool): çağıranın transaction'ı içinde tüm aktif admin satırları SELECT ... FOR UPDATE ile kilitlenir. Eş zamanlı iki işlem aynı satır kümesini aynı sırada kilitlediği için serileşirler: ilki commit edene kadar ikincisi bloklanır, sonra commit edilmiş güncel durumu okur ve reddedilir (kilitli okuma, REPEATABLE READ altında bile current-read'dir).
  • admin/users/toggle_status.php ve admin/users/update.php — koruma + UPDATE tek transaction içinde; her iki hata yolunda rollback. Eski yalnız-SELECT kontrolu UX ön-doğrulaması olarak kalır (yarış penceresini kapatmaz, atomik olan otoriterdir).

Doğrulama

  • Dayanıklı regresyon testi tools/last_admin_race_test.php (yerel MariaDB ister; users tablosunu yedekler/geri yükler, işçi scriptini repo dışında geçici dizinde üretir): tek bağlantılı karar şeması (2 admin → izinli; tek kalan admin → reddedilir; admin olmayan hedef → etkilenmez) + iki süreçli zorlanmış kesisme yarışı — 7 OK / 0 FAIL.
  • php -l temiz (4 dosya).
  • php tools/smoke_test.php: 58 OK / 1 FAIL (yalnızca /var/www/riskops yerleşim yolu onaylaması); php tools/fresh_bootstrap_test.php: 13 OK / 0 FAIL.

Kapsam çiti

Bu PR yalnızca admin/users/_validate.php, admin/users/toggle_status.php, admin/users/update.php ve tools/last_admin_race_test.php dosyalarını değiştirir. Dal doğrudan main üzerindendir; açık diğer PR'lere bağımlılığı yoktur.

Not: yarış testi bir MariaDB sunucusu ister (README kurulumundaki gibi; geliştirme ortamında docker run -d --name riskops-mariadb ... mariadb:10.11 + config/database.php). Sunucu yoksa test açıkça başarısız olur.


Summary by cubic

Fixes the last-active-admin guard race so two concurrent requests can no longer both pass the "another admin exists" check and commit, leaving the system with zero active admins.

Fix

  • last_admin_atomic_guard() locks all active admin rows with SELECT ... FOR UPDATE inside the caller's transaction.
  • toggle_status.php and update.php now run the guard and the UPDATE in one transaction and roll back on both error paths.
  • The old SELECT-only check stays as UX pre-validation; the locked guard is authoritative.

Verification

  • Adds tools/last_admin_race_test.php, a deterministic two-process race test with 7 checks passing.
  • The test requires a local MariaDB and backs up and restores the users table.

Written for commit 2021962. Summary will update on new commits.

Review in cubic

…ordu

Kok neden: "son aktif admin" korumasi check-then-act'ti -
other_active_admin_exists() kilitsiz bir SELECT COUNT(...) yapiyor, her
uc nokta (toggle_status.php ve user_collect_input() uzerinden
update.php) sonra AYRI bir UPDATE calistiryordu. Tam iki aktif admin
varken birbirini es zamanli pasiflestiren/dusuren iki istek de "baska
admin var" gorup gecebilir ve ikisi de commit eder: sistem SIFIR aktif
adminle kalir (tam kilitleme; kurtarma dogrudan DB mudahalesi ister).

Kanit (round-6 proof'u, yerel MariaDB): gercek iki isci sureci - her
biri kendi PDO baglantisiyla - marker dosyalariyla kesisme zorlandi;
her iki guard da UPDATE'lerden once calisti, sonucta 0 aktif admin
(FAIL). DuzeItSonrasi: bir isci guncelledi, digeri guard tarafindan
reddedildi, 1 aktif admin kaldi (PASS).

Cozum: admin/users/_validate.php'ye last_admin_atomic_guard() eklendi -
cagiranin transaction'i icinde TUM aktif admin satirlari FOR UPDATE ile
kilitlenir; ayni satir kumesi ayni sirada kilitlendigi icin es zamanli
islemler serilesir, ikincisi bloklanir ve commit edilmis guncel durumu
okuyarak reddedilir. toggle_status.php ve update.php kontrol+UPDATE'i
tek transaction'a tasiyor; eski yalniz-SELECT kontrolu UX on-kontrolu
olarak kaldi.

Dayanikli regresyon testi tools/last_admin_race_test.php (yerel MariaDB
ister; users tablosunu yedekleyip geri yukler): tek baglanti karar
semasi + iki surecli zorlanmis kesisme yarisi - 7 kontrol, 0 FAIL.
php tools/smoke_test.php: 58 OK / 1 FAIL (yalnizca /var/www/riskops
yerlesim yolu onaylamasi); php tools/fresh_bootstrap_test.php: 13 OK /
0 FAIL.

Not: dal dogrudan main uzerindendir; acik diger PR'lere bagimliligi
yoktur.
@ersinkoc ersinkoc changed the title fix(admin): son aktif admin korumasi es zamanli isteklerle asilabiliyordu fix(admin): son aktif admin korumasi es zamanli isteklerle asilabiliyordu Sep 11, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

4 issues found across 4 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="tools/last_admin_race_test.php">

<violation number="1" location="tools/last_admin_race_test.php:120">
P2: An untrusted local user can pre-create or race this predictable `/tmp` directory and its symlinks, allowing the test to overwrite files or execute a replaced worker when run with higher privileges. Use a cryptographically random temporary directory with mode `0700` and exclusive file creation.</violation>

<violation number="2" location="tools/last_admin_race_test.php:129">
P2: This test ignores the application's database configuration and hardcodes a local password. With the documented setup it can fail to connect, or the parent and workers can use different database connections, so the regression test does not test the configured application; build the parent PDO from `config/database.php` too.</violation>

<violation number="3" location="tools/last_admin_race_test.php:169">
P1: If any later database or worker operation throws, the script exits before `$restoreSnapshot()` and leaves every existing account inactive, potentially locking out all administrators. Wrap the mutating test in `try/finally` and restore the snapshot from the finally block.</violation>
</file>

<file name="admin/users/_validate.php">

<violation number="1" location="admin/users/_validate.php:69">
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`.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

section('1) Tek baglanti: guard karar semasi');
/* ------------------------------------------------------------------ */

$pdo->exec('UPDATE users SET status = 0');

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: If any later database or worker operation throws, the script exits before $restoreSnapshot() and leaves every existing account inactive, potentially locking out all administrators. Wrap the mutating test in try/finally and restore the snapshot from the finally block.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tools/last_admin_race_test.php, line 169:

<comment>If any later database or worker operation throws, the script exits before `$restoreSnapshot()` and leaves every existing account inactive, potentially locking out all administrators. Wrap the mutating test in `try/finally` and restore the snapshot from the finally block.</comment>

<file context>
@@ -0,0 +1,267 @@
+section('1) Tek baglanti: guard karar semasi');
+/* ------------------------------------------------------------------ */
+
+$pdo->exec('UPDATE users SET status = 0');
+$ins = $pdo->prepare('INSERT INTO users (name, email, password, role, status) VALUES (:n, :e, :p, \'admin\', 1)');
+$ins->execute([':n' => 'Race A', ':e' => 'race-test.a@riskops.local', ':p' => password_hash('x', PASSWORD_DEFAULT)]);
</file context>

Comment thread admin/users/_validate.php
Comment on lines +69 to +80
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;

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;


$tmp = sys_get_temp_dir() . '/riskops-race-' . getmypid();
if (!is_dir($tmp)) {
mkdir($tmp, 0777, true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: An untrusted local user can pre-create or race this predictable /tmp directory and its symlinks, allowing the test to overwrite files or execute a replaced worker when run with higher privileges. Use a cryptographically random temporary directory with mode 0700 and exclusive file creation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tools/last_admin_race_test.php, line 120:

<comment>An untrusted local user can pre-create or race this predictable `/tmp` directory and its symlinks, allowing the test to overwrite files or execute a replaced worker when run with higher privileges. Use a cryptographically random temporary directory with mode `0700` and exclusive file creation.</comment>

<file context>
@@ -0,0 +1,267 @@
+
+$tmp = sys_get_temp_dir() . '/riskops-race-' . getmypid();
+if (!is_dir($tmp)) {
+    mkdir($tmp, 0777, true);
+}
+$workerFile = $tmp . '/worker.php';
</file context>


try {
$pdo = new PDO(
'mysql:host=127.0.0.1;port=3306;dbname=riskops;charset=utf8mb4',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: This test ignores the application's database configuration and hardcodes a local password. With the documented setup it can fail to connect, or the parent and workers can use different database connections, so the regression test does not test the configured application; build the parent PDO from config/database.php too.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tools/last_admin_race_test.php, line 129:

<comment>This test ignores the application's database configuration and hardcodes a local password. With the documented setup it can fail to connect, or the parent and workers can use different database connections, so the regression test does not test the configured application; build the parent PDO from `config/database.php` too.</comment>

<file context>
@@ -0,0 +1,267 @@
+
+try {
+    $pdo = new PDO(
+        'mysql:host=127.0.0.1;port=3306;dbname=riskops;charset=utf8mb4',
+        'riskops_user',
+        'riskops_local_dev',
</file context>

@CodeByPinar

Copy link
Copy Markdown
Owner

Teşekkürler. Dört bulguyu da doğruladım, dördü de gerçekti.

Özellikle schema.sql'deki eksik users tablosu ciddiydi: belgelenmiş
kurulumu tamamen kırıyordu ve yeni eklediğim Docker kurulumunu da
bozuyordu (docker-compose.yml şemayı initdb'ye bağlıyor). Yakaladığınız
için teşekkürler.

Düzeltmeleri main'e bağımsız olarak uyguladım (2530ae1). PR'ları merge
etmek yerine yeniden yazmamın iki sebebi var:

  1. PR'lar birbirinin üstüne istiflenmişti ve diff'leri çakışıyordu
    (fix(dashboard): trend grafigi ay sonunda 7-11 aya dusuyordu #1 ile fix(dashboard): trend grafigi ay sonunda 7-11 aya dusuyordu  #4 aynı düzeltmeyi içeriyor, fix(reports): yonetici ozeti Son 6 Ay tablosu ay sonunda 3-5 aya dusuyordu  #5 fix(dashboard): trend grafigi ay sonunda 7-11 aya dusuyordu  #4'ün üstünde duruyor).
  2. cubic'in test dosyalarına yaptığı eleştiriler haklıydı: sabit
    kodlanmış veritabanı parolası, sabit isimli veritabanını DROP etme,
    ilgisiz audit_logs satırlarını silen temizlik.

cubic'in son admin korumasına yaptığı P1 eleştirisini de hesaba kattım:
hedefin admin olup olmadığı artık transaction öncesi anlık görüntüden
değil, kilitli okumadan geliyor. Aksi hâlde hedef bu arada admin'e
yükseltilirse koruma tamamen atlanabilirdi.

Eklenen regresyon testleri:

  • tools/trend_months_test.php 35 OK (veritabanı gerektirmez)
  • tools/last_admin_race_test.php 7 OK (iki süreçli gerçek yarış;
    users tablosunu yedekler, finally bloğunda geri yükler)

#7 açık kalıyor - orada karşılığı olmayan bir test var, kapsamı
kaybetmek istemiyorum. İki P1 kapanınca memnuniyetle merge ederim.

Tekrar teşekkürler, başka bulgunuz olursa memnuniyetle bakarım.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants