diff --git a/src/app/Console/Commands/UserForceDelete.php b/src/app/Console/Commands/UserForceDelete.php new file mode 100644 index 00000000..1336ad26 --- /dev/null +++ b/src/app/Console/Commands/UserForceDelete.php @@ -0,0 +1,46 @@ +where('email', $this->argument('user'))->first(); + + if (!$user) { + return 1; + } + + if (!$user->trashed()) { + $this->error('The user is not yet deleted'); + return 1; + } + + DB::beginTransaction(); + $user->forceDelete(); + DB::commit(); + } +} diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php index 3ab2834b..187cc058 100644 --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -1,151 +1,153 @@ 'integer', ]; /** * Return the costs per day for this entitlement. * * @return float */ public function costsPerDay() { if ($this->cost == 0) { return (float) 0; } $discount = $this->wallet->getDiscountRate(); $daysInLastMonth = \App\Utils::daysInLastMonth(); $costsPerDay = (float) ($this->cost * $discount) / $daysInLastMonth; return $costsPerDay; } /** * Create a transaction record for this entitlement. * * @param string $type The type of transaction ('created', 'billed', 'deleted'), but use the * \App\Transaction constants. * @param int $amount The amount involved in cents * * @return string The transaction ID */ public function createTransaction($type, $amount = null) { $transaction = \App\Transaction::create( [ 'object_id' => $this->id, 'object_type' => \App\Entitlement::class, 'type' => $type, 'amount' => $amount ] ); return $transaction->id; } /** * Principally entitleable objects such as 'Domain' or 'User'. * * @return mixed */ public function entitleable() { return $this->morphTo(); } /** * Returns entitleable object title (e.g. email or domain name). * * @return string|null An object title/name */ public function entitleableTitle(): ?string { if ($this->entitleable instanceof \App\User) { return $this->entitleable->email; } if ($this->entitleable instanceof \App\Domain) { return $this->entitleable->namespace; } + + return null; } /** * The SKU concerned. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function sku() { return $this->belongsTo('App\Sku'); } /** * The wallet this entitlement is being billed to * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function wallet() { return $this->belongsTo('App\Wallet'); } /** * Cost mutator. Make sure cost is integer. */ public function setCostAttribute($cost): void { $this->attributes['cost'] = round($cost); } } diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php index f9db6619..61a9ac12 100644 --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -1,154 +1,165 @@ {$entitlement->getKeyName()} = $allegedly_unique; break; } } // can't dispatch job here because it'll fail serialization // Make sure the owner is at least a controller on the wallet $wallet = \App\Wallet::find($entitlement->wallet_id); if (!$wallet || !$wallet->owner) { return false; } $sku = \App\Sku::find($entitlement->sku_id); if (!$sku) { return false; } $result = $sku->handler_class::preReq($entitlement, $wallet->owner); if (!$result) { return false; } return true; } /** * Handle the entitlement "created" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function created(Entitlement $entitlement) { $entitlement->entitleable->updated_at = Carbon::now(); $entitlement->entitleable->save(); $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_CREATED); } /** * Handle the entitlement "deleted" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function deleted(Entitlement $entitlement) { // Remove all configured 2FA methods from Roundcube database if ($entitlement->sku->title == '2fa') { // FIXME: Should that be an async job? $sf = new \App\Auth\SecondFactor($entitlement->entitleable); $sf->removeFactors(); } $entitlement->entitleable->updated_at = Carbon::now(); $entitlement->entitleable->save(); $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_DELETED); } + /** + * Handle the entitlement "deleting" event. + * + * @param \App\Entitlement $entitlement The entitlement. + * + * @return void + */ public function deleting(Entitlement $entitlement) { + if ($entitlement->trashed()) { + return; + } + // Start calculating the costs for the consumption of this entitlement if the // existing consumption spans >= 14 days. // // Effect is that anything's free for the first 14 days if ($entitlement->created_at >= Carbon::now()->subDays(14)) { return; } $owner = $entitlement->wallet->owner; // Determine if we're still within the free first month $freeMonthEnds = $owner->created_at->copy()->addMonthsWithoutOverflow(1); if ($freeMonthEnds >= Carbon::now()) { return; } $cost = 0; $now = Carbon::now(); // get the discount rate applied to the wallet. $discount = $entitlement->wallet->getDiscountRate(); // just in case this had not been billed yet, ever $diffInMonths = $entitlement->updated_at->diffInMonths($now); $cost += (int) ($entitlement->cost * $discount * $diffInMonths); // this moves the hypothetical updated at forward to however many months past the original $updatedAt = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diffInMonths); // now we have the diff in days since the last "billed" period end. // This may be an entitlement paid up until February 28th, 2020, with today being March // 12th 2020. Calculating the costs for the entitlement is based on the daily price // the price per day is based on the number of days in the last month // or the current month if the period does not overlap with the previous month // FIXME: This really should be simplified to $daysInMonth=30 $diffInDays = $updatedAt->diffInDays($now); if ($now->day >= $diffInDays) { $daysInMonth = $now->daysInMonth; } else { $daysInMonth = \App\Utils::daysInLastMonth(); } $pricePerDay = $entitlement->cost / $daysInMonth; $cost += (int) (round($pricePerDay * $discount * $diffInDays, 0)); if ($cost == 0) { return; } $entitlement->wallet->debit($cost); } } diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php index e5079547..1391daee 100644 --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -1,190 +1,259 @@ id) { while (true) { $allegedly_unique = \App\Utils::uuidInt(); if (!User::find($allegedly_unique)) { $user->{$user->getKeyName()} = $allegedly_unique; break; } } } // only users that are not imported get the benefit of the doubt. $user->status |= User::STATUS_NEW | User::STATUS_ACTIVE; // can't dispatch job here because it'll fail serialization } /** * Handle the "created" event. * * Ensures the user has at least one wallet. * * Should ensure some basic settings are available as well. * * @param \App\User $user The user created. * * @return void */ public function created(User $user) { $settings = [ 'country' => 'CH', 'currency' => 'CHF', /* 'first_name' => '', 'last_name' => '', 'billing_address' => '', 'organization' => '', 'phone' => '', 'external_email' => '', */ ]; foreach ($settings as $key => $value) { $settings[$key] = [ 'key' => $key, 'value' => $value, 'user_id' => $user->id, ]; } // Note: Don't use setSettings() here to bypass UserSetting observers // Note: This is a single multi-insert query $user->settings()->insert(array_values($settings)); $user->wallets()->create(); // Create user record in LDAP, then check if the account is created in IMAP $chain = [ new \App\Jobs\UserVerify($user), ]; \App\Jobs\UserCreate::withChain($chain)->dispatch($user); } /** * Handle the "deleted" event. * * @param \App\User $user The user deleted. * * @return void */ public function deleted(User $user) { // } /** * Handle the "deleting" event. * * @param User $user The user that is being deleted. * * @return void */ public function deleting(User $user) { + if ($user->isForceDeleting()) { + $this->forceDeleting($user); + return; + } + // TODO: Especially in tests we're doing delete() on a already deleted user. // Should we escape here - for performance reasons? // TODO: I think all of this should use database transactions // Entitlements do not have referential integrity on the entitled object, so this is our // way of doing an onDelete('cascade') without the foreign key. $entitlements = Entitlement::where('entitleable_id', $user->id) ->where('entitleable_type', User::class)->get(); foreach ($entitlements as $entitlement) { $entitlement->delete(); } // Remove owned users/domains $wallets = $user->wallets()->pluck('id')->all(); $assignments = Entitlement::whereIn('wallet_id', $wallets)->get(); $users = []; $domains = []; $entitlements = []; foreach ($assignments as $entitlement) { if ($entitlement->entitleable_type == Domain::class) { $domains[] = $entitlement->entitleable_id; } elseif ($entitlement->entitleable_type == User::class && $entitlement->entitleable_id != $user->id) { $users[] = $entitlement->entitleable_id; } else { $entitlements[] = $entitlement->id; } } $users = array_unique($users); $domains = array_unique($domains); - // Note: Domains/users need to be deleted one by one to make sure - // events are fired and observers can do the proper cleanup. - // Entitlements have no delete event handlers as for now. + // Domains/users/entitlements need to be deleted one by one to make sure + // events are fired and observers can do the proper cleanup. if (!empty($users)) { foreach (User::whereIn('id', $users)->get() as $_user) { $_user->delete(); } } if (!empty($domains)) { foreach (Domain::whereIn('id', $domains)->get() as $_domain) { $_domain->delete(); } } if (!empty($entitlements)) { Entitlement::whereIn('id', $entitlements)->delete(); } // FIXME: What do we do with user wallets? \App\Jobs\UserDelete::dispatch($user->id); } + /** + * Handle the "deleting" event on forceDelete() call. + * + * @param User $user The user that is being deleted. + * + * @return void + */ + public function forceDeleting(User $user) + { + // TODO: We assume that at this moment all belongings are already soft-deleted. + + // Remove owned users/domains + $wallets = $user->wallets()->pluck('id')->all(); + $assignments = Entitlement::withTrashed()->whereIn('wallet_id', $wallets)->get(); + $entitlements = []; + $domains = []; + $users = []; + + foreach ($assignments as $entitlement) { + $entitlements[] = $entitlement->id; + + if ($entitlement->entitleable_type == Domain::class) { + $domains[] = $entitlement->entitleable_id; + } elseif ( + $entitlement->entitleable_type == User::class + && $entitlement->entitleable_id != $user->id + ) { + $users[] = $entitlement->entitleable_id; + } + } + + $users = array_unique($users); + $domains = array_unique($domains); + + // Remove the user "direct" entitlements explicitely, if they belong to another + // user's wallet they will not be removed by the wallets foreign key cascade + Entitlement::withTrashed() + ->where('entitleable_id', $user->id) + ->where('entitleable_type', User::class) + ->forceDelete(); + + // Users need to be deleted one by one to make sure observers can do the proper cleanup. + if (!empty($users)) { + foreach (User::withTrashed()->whereIn('id', $users)->get() as $_user) { + $_user->forceDelete(); + } + } + + // Domains can be just removed + if (!empty($domains)) { + Domain::withTrashed()->whereIn('id', $domains)->forceDelete(); + } + + // Remove transactions, they also have no foreign key constraint + Transaction::where('object_type', Entitlement::class) + ->whereIn('object_id', $entitlements) + ->delete(); + + Transaction::where('object_type', Wallet::class) + ->whereIn('object_id', $wallets) + ->delete(); + } + /** * Handle the "retrieving" event. * * @param User $user The user that is being retrieved. * * @todo This is useful for audit. * * @return void */ public function retrieving(User $user) { // TODO \App\Jobs\UserRead::dispatch($user); } /** * Handle the "updating" event. * * @param User $user The user that is being updated. * * @return void */ public function updating(User $user) { \App\Jobs\UserUpdate::dispatch($user); } } diff --git a/src/tests/Feature/Console/UserForceDeleteTest.php b/src/tests/Feature/Console/UserForceDeleteTest.php new file mode 100644 index 00000000..5e50718b --- /dev/null +++ b/src/tests/Feature/Console/UserForceDeleteTest.php @@ -0,0 +1,98 @@ +deleteTestUser('user@force-delete.com'); + $this->deleteTestDomain('force-delete.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('user@force-delete.com'); + $this->deleteTestDomain('force-delete.com'); + + parent::tearDown(); + } + + /** + * Test the command + */ + public function testHandle(): void + { + // Non-existing user + $this->artisan('user:force-delete unknown@unknown.org') + ->assertExitCode(1); + + Queue::fake(); + $user = $this->getTestUser('user@force-delete.com'); + $domain = $this->getTestDomain('force-delete.com', [ + 'status' => \App\Domain::STATUS_NEW, + 'type' => \App\Domain::TYPE_HOSTED, + ]); + $package_kolab = \App\Package::where('title', 'kolab')->first(); + $package_domain = \App\Package::where('title', 'domain-hosting')->first(); + $user->assignPackage($package_kolab); + $domain->assignPackage($package_domain, $user); + $wallet = $user->wallets()->first(); + $entitlements = $wallet->entitlements->pluck('id')->all(); + + $this->assertCount(5, $entitlements); + + // Non-deleted user + $this->artisan('user:force-delete user@force-delete.com') + ->assertExitCode(1); + + $user->delete(); + + $this->assertTrue($user->trashed()); + $this->assertTrue($domain->fresh()->trashed()); + + // Deleted user + $this->artisan('user:force-delete user@force-delete.com') + ->assertExitCode(0); + + $this->assertCount( + 0, + \App\User::withTrashed()->where('email', 'user@force-delete.com')->get() + ); + $this->assertCount( + 0, + \App\Domain::withTrashed()->where('namespace', 'force-delete.com')->get() + ); + $this->assertCount( + 0, + \App\Wallet::where('id', $wallet->id)->get() + ); + $this->assertCount( + 0, + \App\Entitlement::withTrashed()->where('wallet_id', $wallet->id)->get() + ); + $this->assertCount( + 0, + \App\Entitlement::withTrashed()->where('entitleable_id', $user->id)->get() + ); + $this->assertCount( + 0, + \App\Transaction::whereIn('object_id', $entitlements) + ->where('object_type', \App\Entitlement::class) + ->get() + ); + + // TODO: Test that it also deletes users in a group account + } +}