assertValidRole($role); return DB::transaction(function () use ($tenant, $actor, $member, $role, $source, $sourceRef): TenantMembership { $existing = TenantMembership::query() ->where('tenant_id', $tenant->getKey()) ->where('user_id', $member->getKey()) ->first(); if ($existing) { if ($existing->role !== $role) { $existing->forceFill([ 'role' => $role, 'source' => $source, 'source_ref' => $sourceRef, 'created_by_user_id' => (int) $actor->getKey(), ])->save(); $this->auditLogger->log( tenant: $tenant, action: AuditActionId::TenantMembershipRoleChange->value, context: [ 'metadata' => [ 'member_user_id' => (int) $member->getKey(), 'from_role' => $existing->getOriginal('role'), 'to_role' => $role, '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, 'source' => $source, 'source_ref' => $sourceRef, 'created_by_user_id' => (int) $actor->getKey(), ]); $this->auditLogger->log( tenant: $tenant, action: AuditActionId::TenantMembershipAdd->value, context: [ 'metadata' => [ 'member_user_id' => (int) $member->getKey(), 'role' => $role, '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, string $newRole): TenantMembership { $this->assertValidRole($newRole); try { 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) { return $membership; } $this->guardLastOwnerDemotion($tenant, $membership, $newRole); $membership->forceFill([ 'role' => $newRole, ])->save(); $this->auditLogger->log( tenant: $tenant, action: AuditActionId::TenantMembershipRoleChange->value, context: [ 'metadata' => [ 'member_user_id' => (int) $membership->user_id, 'from_role' => $oldRole, 'to_role' => $newRole, ], ], actorId: (int) $actor->getKey(), actorEmail: $actor->email, actorName: $actor->name, status: 'success', resourceType: 'tenant', resourceId: (string) $tenant->getKey(), ); return $membership->refresh(); }); } catch (DomainException $exception) { if ($exception->getMessage() === 'You cannot demote the last remaining owner.') { $this->auditLogger->log( tenant: $tenant, action: AuditActionId::TenantMembershipLastOwnerBlocked->value, context: [ 'metadata' => [ 'member_user_id' => (int) $membership->user_id, 'from_role' => (string) $membership->role, 'attempted_to_role' => $newRole, ], ], actorId: (int) $actor->getKey(), actorEmail: $actor->email, actorName: $actor->name, status: 'blocked', resourceType: 'tenant', resourceId: (string) $tenant->getKey(), ); } throw $exception; } } public function removeMember(Tenant $tenant, User $actor, TenantMembership $membership): void { try { 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: AuditActionId::TenantMembershipRemove->value, 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(), ); }); } catch (DomainException $exception) { if ($exception->getMessage() === 'You cannot remove the last remaining owner.') { $this->auditLogger->log( tenant: $tenant, action: AuditActionId::TenantMembershipLastOwnerBlocked->value, context: [ 'metadata' => [ 'member_user_id' => (int) $membership->user_id, 'role' => (string) $membership->role, 'attempted_action' => 'remove', ], ], actorId: (int) $actor->getKey(), actorEmail: $actor->email, actorName: $actor->name, status: 'blocked', resourceType: 'tenant', resourceId: (string) $tenant->getKey(), ); } throw $exception; } } public function bootstrapRecover(Tenant $tenant, User $actor, User $member): TenantMembership { $membership = $this->addMember( tenant: $tenant, actor: $actor, member: $member, role: 'owner', source: 'break_glass', ); $this->auditLogger->log( tenant: $tenant, action: AuditActionId::TenantMembershipBootstrapRecover->value, 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 !== 'owner') { return; } $owners = TenantMembership::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('role', 'owner') ->count(); if ($owners <= 1) { throw new DomainException('You cannot remove the last remaining owner.'); } } private function guardLastOwnerDemotion(Tenant $tenant, TenantMembership $membership, string $newRole): void { if ($membership->role !== 'owner') { return; } if ($newRole === 'owner') { return; } $owners = TenantMembership::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('role', 'owner') ->count(); if ($owners <= 1) { throw new DomainException('You cannot demote the last remaining owner.'); } } private function assertValidRole(string $role): void { if (! in_array($role, ['owner', 'manager', 'operator', 'readonly'], true)) { throw new DomainException('Invalid role value.'); } } }