where('tenant_id', $tenant->getKey()) ->where('user_id', $member->getKey()) ->first(); if ($existing) { if ($existing->role !== $role->value) { $existing->forceFill([ 'role' => $role->value, 'source' => $source, 'source_ref' => $sourceRef, 'created_by_user_id' => (int) $actor->getKey(), ])->save(); $this->auditLogger->log( tenant: $tenant, action: 'tenant_membership.role_change', context: [ 'metadata' => [ 'member_user_id' => (int) $member->getKey(), 'from_role' => $existing->getOriginal('role'), 'to_role' => $role->value, 'source' => $source, ], ], actorId: (int) $actor->getKey(), actorEmail: $actor->email, actorName: $actor->name, status: 'success', resourceType: 'tenant', resourceId: (string) $tenant->getKey(), ); } return $existing->refresh(); } $membership = TenantMembership::query()->create([ 'tenant_id' => (int) $tenant->getKey(), 'user_id' => (int) $member->getKey(), 'role' => $role->value, 'source' => $source, 'source_ref' => $sourceRef, 'created_by_user_id' => (int) $actor->getKey(), ]); $this->auditLogger->log( tenant: $tenant, action: 'tenant_membership.add', context: [ 'metadata' => [ 'member_user_id' => (int) $member->getKey(), 'role' => $role->value, 'source' => $source, ], ], actorId: (int) $actor->getKey(), actorEmail: $actor->email, actorName: $actor->name, status: 'success', resourceType: 'tenant', resourceId: (string) $tenant->getKey(), ); return $membership; }); } public function changeRole(Tenant $tenant, User $actor, TenantMembership $membership, TenantRole $newRole): TenantMembership { return DB::transaction(function () use ($tenant, $actor, $membership, $newRole): TenantMembership { $membership->refresh(); if ($membership->tenant_id !== (int) $tenant->getKey()) { throw new DomainException('Membership belongs to a different tenant.'); } $oldRole = $membership->role; if ($oldRole === $newRole->value) { return $membership; } $this->guardLastOwnerDemotion($tenant, $membership, $newRole); $membership->forceFill([ 'role' => $newRole->value, ])->save(); $this->auditLogger->log( tenant: $tenant, action: 'tenant_membership.role_change', context: [ 'metadata' => [ 'member_user_id' => (int) $membership->user_id, 'from_role' => $oldRole, 'to_role' => $newRole->value, ], ], actorId: (int) $actor->getKey(), actorEmail: $actor->email, actorName: $actor->name, status: 'success', resourceType: 'tenant', resourceId: (string) $tenant->getKey(), ); return $membership->refresh(); }); } public function removeMember(Tenant $tenant, User $actor, TenantMembership $membership): void { DB::transaction(function () use ($tenant, $actor, $membership): void { $membership->refresh(); if ($membership->tenant_id !== (int) $tenant->getKey()) { throw new DomainException('Membership belongs to a different tenant.'); } $this->guardLastOwnerRemoval($tenant, $membership); $memberUserId = (int) $membership->user_id; $oldRole = (string) $membership->role; $membership->delete(); $this->auditLogger->log( tenant: $tenant, action: 'tenant_membership.remove', context: [ 'metadata' => [ 'member_user_id' => $memberUserId, 'role' => $oldRole, ], ], actorId: (int) $actor->getKey(), actorEmail: $actor->email, actorName: $actor->name, status: 'success', resourceType: 'tenant', resourceId: (string) $tenant->getKey(), ); }); } public function bootstrapRecover(Tenant $tenant, User $actor, User $member): TenantMembership { $membership = $this->addMember( tenant: $tenant, actor: $actor, member: $member, role: TenantRole::Owner, source: 'break_glass', ); $this->auditLogger->log( tenant: $tenant, action: 'tenant_membership.bootstrap_recover', context: [ 'metadata' => [ 'member_user_id' => (int) $member->getKey(), ], ], actorId: (int) $actor->getKey(), actorEmail: $actor->email, actorName: $actor->name, status: 'success', resourceType: 'tenant', resourceId: (string) $tenant->getKey(), ); return $membership; } private function guardLastOwnerRemoval(Tenant $tenant, TenantMembership $membership): void { if ($membership->role !== TenantRole::Owner->value) { return; } $owners = TenantMembership::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('role', TenantRole::Owner->value) ->count(); if ($owners <= 1) { throw new DomainException('You cannot remove the last remaining owner.'); } } private function guardLastOwnerDemotion(Tenant $tenant, TenantMembership $membership, TenantRole $newRole): void { if ($membership->role !== TenantRole::Owner->value) { return; } if ($newRole === TenantRole::Owner) { return; } $owners = TenantMembership::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('role', TenantRole::Owner->value) ->count(); if ($owners <= 1) { throw new DomainException('You cannot demote the last remaining owner.'); } } }