fix(profile): member delete must not destroy the shared account (closes #50)

Members joining via workspace invite share the owner's account_id.
ProfileController::destroy was unconditionally calling $account->delete()
in every profile-deletion path, so any member could wipe the whole
organization (cascade: workspaces, posts, social accounts, signatures,
labels) just by clicking Delete on their own profile.

Gate the account/subscription teardown behind isAccountOwner(). For
members the path now only detaches them from workspaces and deletes
the user row — owner's data is untouched.

Tested in both SELF_HOSTED=true and false.
This commit is contained in:
Paulo Castellano 2026-05-19 12:20:49 -03:00
parent 48bf94e137
commit 1660187067
2 changed files with 70 additions and 10 deletions

View file

@ -94,15 +94,7 @@ public function destroy(ProfileDeleteRequest $request): RedirectResponse
$user->update(['current_workspace_id' => null]);
$account = $user->account;
// Cancel account subscription if exists
if ($account && $account->subscribed(Account::SUBSCRIPTION_NAME)) {
$account->subscription(Account::SUBSCRIPTION_NAME)->cancelNow();
}
if ($account) {
$account->subscriptions()->delete();
}
$isOwner = $user->isAccountOwner();
$ownedWorkspaces = Workspace::where('user_id', $user->id)->get();
@ -126,7 +118,12 @@ public function destroy(ProfileDeleteRequest $request): RedirectResponse
$user->workspaces()->detach();
if ($account) {
if ($account && $isOwner) {
if ($account->subscribed(Account::SUBSCRIPTION_NAME)) {
$account->subscription(Account::SUBSCRIPTION_NAME)->cancelNow();
}
$account->subscriptions()->delete();
$account->delete();
}
});

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Http\UploadedFile;
@ -250,3 +251,65 @@
$response->assertRedirect(route('login'));
});
test('member deleting profile does NOT destroy the shared account', function (bool $selfHosted) {
config()->set('trypost.self_hosted', $selfHosted);
$owner = User::factory()->create();
$member = User::factory()->create(['account_id' => $owner->account_id]);
$workspace = Workspace::factory()->create([
'account_id' => $owner->account_id,
'user_id' => $owner->id,
]);
$owner->workspaces()->attach($workspace->id, ['role' => Role::Member->value]);
$member->workspaces()->attach($workspace->id, ['role' => Role::Member->value]);
$this->actingAs($member)->delete(route('app.profile.destroy'), [
'password' => 'password',
]);
expect($member->fresh())->toBeNull();
expect(Account::find($owner->account_id))->not->toBeNull();
expect(Workspace::find($workspace->id))->not->toBeNull();
expect($owner->fresh())->not->toBeNull();
})->with([true, false]);
test('member deleting profile detaches them from workspaces', function (bool $selfHosted) {
config()->set('trypost.self_hosted', $selfHosted);
$owner = User::factory()->create();
$member = User::factory()->create(['account_id' => $owner->account_id]);
$workspace = Workspace::factory()->create([
'account_id' => $owner->account_id,
'user_id' => $owner->id,
]);
$member->workspaces()->attach($workspace->id, ['role' => Role::Member->value]);
$this->actingAs($member)->delete(route('app.profile.destroy'), [
'password' => 'password',
]);
expect($workspace->fresh()->members()->where('users.id', $member->id)->exists())->toBeFalse();
})->with([true, false]);
test('owner deleting profile destroys the account and cascades', function (bool $selfHosted) {
config()->set('trypost.self_hosted', $selfHosted);
$owner = User::factory()->create();
$accountId = $owner->account_id;
$workspace = Workspace::factory()->create([
'account_id' => $accountId,
'user_id' => $owner->id,
]);
$owner->workspaces()->attach($workspace->id, ['role' => Role::Member->value]);
$this->actingAs($owner)->delete(route('app.profile.destroy'), [
'password' => 'password',
]);
expect(Account::find($accountId))->toBeNull();
expect(Workspace::find($workspace->id))->toBeNull();
})->with([true, false]);