assertValidRole($role); $this->assertActorCanManage($actor, $workspace); return DB::transaction(function () use ($workspace, $actor, $member, $role, $source): WorkspaceMembership { $existing = WorkspaceMembership::query() ->where('workspace_id', (int) $workspace->getKey()) ->where('user_id', (int) $member->getKey()) ->first(); if ($existing) { if ($existing->role !== $role) { $fromRole = (string) $existing->role; $existing->forceFill([ 'role' => $role, ])->save(); $this->auditLogger->log( workspace: $workspace, action: AuditActionId::WorkspaceMembershipRoleChange->value, context: [ 'metadata' => [ 'member_user_id' => (int) $member->getKey(), 'from_role' => $fromRole, 'to_role' => $role, 'source' => $source, ], ], actor: $actor, status: 'success', resourceType: 'workspace', resourceId: (string) $workspace->getKey(), ); } return $existing->refresh(); } $membership = WorkspaceMembership::query()->create([ 'workspace_id' => (int) $workspace->getKey(), 'user_id' => (int) $member->getKey(), 'role' => $role, ]); $this->auditLogger->log( workspace: $workspace, action: AuditActionId::WorkspaceMembershipAdd->value, context: [ 'metadata' => [ 'member_user_id' => (int) $member->getKey(), 'role' => $role, 'source' => $source, ], ], actor: $actor, status: 'success', resourceType: 'workspace', resourceId: (string) $workspace->getKey(), ); return $membership; }); } public function changeRole(Workspace $workspace, User $actor, WorkspaceMembership $membership, string $newRole): WorkspaceMembership { $this->assertValidRole($newRole); $this->assertActorCanManage($actor, $workspace); try { return DB::transaction(function () use ($workspace, $actor, $membership, $newRole): WorkspaceMembership { $membership->refresh(); if ($membership->workspace_id !== (int) $workspace->getKey()) { throw new DomainException('Membership belongs to a different workspace.'); } $oldRole = (string) $membership->role; if ($oldRole === $newRole) { return $membership; } $this->guardLastOwnerDemotion($workspace, $membership, $newRole); $membership->forceFill([ 'role' => $newRole, ])->save(); $this->auditLogger->log( workspace: $workspace, action: AuditActionId::WorkspaceMembershipRoleChange->value, context: [ 'metadata' => [ 'member_user_id' => (int) $membership->user_id, 'from_role' => $oldRole, 'to_role' => $newRole, ], ], actor: $actor, status: 'success', resourceType: 'workspace', resourceId: (string) $workspace->getKey(), ); return $membership->refresh(); }); } catch (DomainException $exception) { if ($exception->getMessage() === 'You cannot demote the last remaining owner.') { $this->auditLogger->log( workspace: $workspace, action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value, context: [ 'metadata' => [ 'member_user_id' => (int) $membership->user_id, 'from_role' => (string) $membership->role, 'attempted_to_role' => $newRole, ], ], actor: $actor, status: 'blocked', resourceType: 'workspace', resourceId: (string) $workspace->getKey(), ); } throw $exception; } } public function removeMember(Workspace $workspace, User $actor, WorkspaceMembership $membership): void { $this->assertActorCanManage($actor, $workspace); try { DB::transaction(function () use ($workspace, $actor, $membership): void { $membership->refresh(); if ($membership->workspace_id !== (int) $workspace->getKey()) { throw new DomainException('Membership belongs to a different workspace.'); } $this->guardLastOwnerRemoval($workspace, $membership); $memberUserId = (int) $membership->user_id; $oldRole = (string) $membership->role; $membership->delete(); $this->auditLogger->log( workspace: $workspace, action: AuditActionId::WorkspaceMembershipRemove->value, context: [ 'metadata' => [ 'member_user_id' => $memberUserId, 'role' => $oldRole, ], ], actor: $actor, status: 'success', resourceType: 'workspace', resourceId: (string) $workspace->getKey(), ); }); } catch (DomainException $exception) { if ($exception->getMessage() === 'You cannot remove the last remaining owner.') { $this->auditLogger->log( workspace: $workspace, action: AuditActionId::WorkspaceMembershipLastOwnerBlocked->value, context: [ 'metadata' => [ 'member_user_id' => (int) $membership->user_id, 'role' => (string) $membership->role, 'attempted_action' => 'remove', ], ], actor: $actor, status: 'blocked', resourceType: 'workspace', resourceId: (string) $workspace->getKey(), ); } throw $exception; } } private function assertActorCanManage(User $actor, Workspace $workspace): void { /** @var WorkspaceCapabilityResolver $resolver */ $resolver = app(WorkspaceCapabilityResolver::class); if (! $resolver->can($actor, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)) { throw new DomainException('Forbidden.'); } } private function assertValidRole(string $role): void { $valid = array_map( static fn (WorkspaceRole $workspaceRole): string => $workspaceRole->value, WorkspaceRole::cases(), ); if (! in_array($role, $valid, true)) { throw new DomainException('Invalid role.'); } } private function guardLastOwnerDemotion(Workspace $workspace, WorkspaceMembership $membership, string $newRole): void { if ($membership->role !== WorkspaceRole::Owner->value) { return; } if ($newRole === WorkspaceRole::Owner->value) { return; } $owners = WorkspaceMembership::query() ->where('workspace_id', (int) $workspace->getKey()) ->where('role', WorkspaceRole::Owner->value) ->count(); if ($owners <= 1) { throw new DomainException('You cannot demote the last remaining owner.'); } } private function guardLastOwnerRemoval(Workspace $workspace, WorkspaceMembership $membership): void { if ($membership->role !== WorkspaceRole::Owner->value) { return; } $owners = WorkspaceMembership::query() ->where('workspace_id', (int) $workspace->getKey()) ->where('role', WorkspaceRole::Owner->value) ->count(); if ($owners <= 1) { throw new DomainException('You cannot remove the last remaining owner.'); } } }