From e09965f918f70784a2ff3aad65e3ed5ccab40b85 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 16:38:44 +0200 Subject: [PATCH] fix(admin): in-flight claim (claimed_at) for exactly-once maintenance send; scope permission rollback Co-Authored-By: Claude Opus 4.8 --- app/Models/MaintenanceNotification.php | 4 +-- app/Providers/AppServiceProvider.php | 22 +++++++++++---- ...7_25_133900_seed_roles_and_permissions.php | 7 ++++- ...laimed_at_to_maintenance_notifications.php | 28 +++++++++++++++++++ 4 files changed, 52 insertions(+), 9 deletions(-) create mode 100644 database/migrations/2026_07_25_140002_add_claimed_at_to_maintenance_notifications.php diff --git a/app/Models/MaintenanceNotification.php b/app/Models/MaintenanceNotification.php index 14d4d2c..f19cd98 100644 --- a/app/Models/MaintenanceNotification.php +++ b/app/Models/MaintenanceNotification.php @@ -7,11 +7,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class MaintenanceNotification extends Model { - protected $fillable = ['maintenance_window_id', 'customer_id', 'event', 'email', 'sent_at']; + protected $fillable = ['maintenance_window_id', 'customer_id', 'event', 'email', 'sent_at', 'claimed_at']; protected function casts(): array { - return ['sent_at' => 'datetime']; + return ['sent_at' => 'datetime', 'claimed_at' => 'datetime']; } public function window(): BelongsTo diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 98f0e44..984df77 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -49,16 +49,20 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { // Send-time guard for maintenance mail (X-CP-Notification carries the - // ledger id). Read-only — it never marks the row sent (that happens on - // real delivery in MessageSent, so a transport failure stays retryable). - // It aborts a send when the window was cancelled meanwhile, or when a - // sibling copy already delivered (sent_at set) — the common backlog dedup. + // ledger id). Uses a distinct in-flight claim (claimed_at) so exactly one + // copy ships without ever pre-marking delivery: + // • already delivered (sent_at set) or window cancelled → suppress; + // • otherwise atomically claim claimed_at (null or older than the TTL) — + // the winner sends; a concurrent duplicate loses the claim and aborts. + // sent_at is set only on real delivery (MessageSent), so a transport + // failure leaves the claim to expire and the mail stays retryable. Event::listen(MessageSending::class, function (MessageSending $event) { $header = $event->message->getHeaders()->get('X-CP-Notification'); if ($header === null) { return null; } - $notification = MaintenanceNotification::query()->with('window')->find((int) $header->getBodyAsString()); + $id = (int) $header->getBodyAsString(); + $notification = MaintenanceNotification::query()->with('window')->find($id); if ($notification === null) { return null; } @@ -69,7 +73,13 @@ class AppServiceProvider extends ServiceProvider return false; // window cancelled meanwhile — do not deliver } - return null; + $claimed = MaintenanceNotification::query() + ->whereKey($id) + ->whereNull('sent_at') + ->where(fn ($q) => $q->whereNull('claimed_at')->orWhere('claimed_at', '<=', now()->subMinutes(15))) + ->update(['claimed_at' => now()]); + + return $claimed === 0 ? false : null; // lost the claim → a sibling is delivering }); // Stamp a maintenance-notification ledger row as delivered only once the diff --git a/database/migrations/2026_07_25_133900_seed_roles_and_permissions.php b/database/migrations/2026_07_25_133900_seed_roles_and_permissions.php index e4ec7e3..35f63a6 100644 --- a/database/migrations/2026_07_25_133900_seed_roles_and_permissions.php +++ b/database/migrations/2026_07_25_133900_seed_roles_and_permissions.php @@ -60,6 +60,11 @@ return new class extends Migration { app(PermissionRegistrar::class)->forgetCachedPermissions(); Role::query()->whereIn('name', ['Owner', 'Admin', 'Support', 'Billing', 'Read-only'])->delete(); - Permission::query()->delete(); + // Only the capabilities this migration introduced — never the whole catalogue. + Permission::query()->whereIn('name', [ + 'console.view', 'hosts.manage', 'datacenters.manage', 'customers.manage', + 'customers.impersonate', 'provisioning.retry', 'provisioning.cancel', + 'billing.manage', 'maintenance.manage', 'staff.manage', + ])->delete(); } }; diff --git a/database/migrations/2026_07_25_140002_add_claimed_at_to_maintenance_notifications.php b/database/migrations/2026_07_25_140002_add_claimed_at_to_maintenance_notifications.php new file mode 100644 index 0000000..da42eb5 --- /dev/null +++ b/database/migrations/2026_07_25_140002_add_claimed_at_to_maintenance_notifications.php @@ -0,0 +1,28 @@ +timestamp('claimed_at')->nullable()->after('sent_at'); + }); + } + + public function down(): void + { + Schema::table('maintenance_notifications', function (Blueprint $table) { + $table->dropColumn('claimed_at'); + }); + } +};