296 lines
10 KiB
PHP
296 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Auth;
|
|
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantMembership;
|
|
use App\Models\User;
|
|
use App\Services\Intune\AuditLogger;
|
|
use App\Support\Audit\AuditActionId;
|
|
use DomainException;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class TenantMembershipManager
|
|
{
|
|
public function __construct(public AuditLogger $auditLogger) {}
|
|
|
|
public function addMember(
|
|
Tenant $tenant,
|
|
User $actor,
|
|
User $member,
|
|
string $role,
|
|
string $source = 'manual',
|
|
?string $sourceRef = null,
|
|
): TenantMembership {
|
|
$this->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.');
|
|
}
|
|
}
|
|
}
|