## Summary
- add the shared trusted-state model and resolver helpers for first-slice Livewire and Filament surfaces
- harden managed tenant onboarding, tenant required permissions, and system runbooks against forged or stale public state
- add focused Pest guard and regression coverage plus the complete spec 152 artifact set
## Validation
- `vendor/bin/sail artisan test --compact`
- manual smoke validated on `/admin/onboarding/{onboardingDraft}`
- manual smoke validated on `/admin/tenants/{tenant}/required-permissions`
- manual smoke validated on `/system/ops/runbooks`
## Notes
- Livewire v4.0+ / Filament v5 stack unchanged
- no new panels, routes, assets, or global-search changes
- provider registration remains in `bootstrap/providers.php`
Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #182
184 lines
6.3 KiB
PHP
184 lines
6.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Onboarding;
|
|
|
|
use App\Exceptions\Onboarding\OnboardingDraftConflictException;
|
|
use App\Exceptions\Onboarding\OnboardingDraftImmutableException;
|
|
use App\Models\TenantOnboardingSession;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
class OnboardingDraftMutationService
|
|
{
|
|
public function __construct(
|
|
private readonly OnboardingLifecycleService $lifecycleService,
|
|
) {}
|
|
|
|
/**
|
|
* @param callable(TenantOnboardingSession):void $mutator
|
|
*/
|
|
public function createOrResume(
|
|
Workspace $workspace,
|
|
User $actor,
|
|
string $entraTenantId,
|
|
callable $mutator,
|
|
?TenantOnboardingSession $preferredDraft = null,
|
|
?int $expectedVersion = null,
|
|
bool $incrementVersion = true,
|
|
?bool &$wasCreated = null,
|
|
): TenantOnboardingSession {
|
|
return DB::transaction(function () use ($workspace, $actor, $entraTenantId, $mutator, $preferredDraft, $expectedVersion, $incrementVersion, &$wasCreated): TenantOnboardingSession {
|
|
$draft = $this->resolveDraftForIdentity($workspace, $entraTenantId, $preferredDraft);
|
|
$isNew = ! $draft instanceof TenantOnboardingSession;
|
|
$wasCreated = $isNew;
|
|
|
|
if ($isNew) {
|
|
$draft = new TenantOnboardingSession;
|
|
$draft->workspace_id = (int) $workspace->getKey();
|
|
$draft->entra_tenant_id = $entraTenantId;
|
|
$draft->started_by_user_id = (int) $actor->getKey();
|
|
$draft->version = 0;
|
|
} elseif ($expectedVersion !== null) {
|
|
$this->assertExpectedVersion($draft, $expectedVersion);
|
|
}
|
|
|
|
$draft->entra_tenant_id = $entraTenantId;
|
|
$draft->updated_by_user_id = (int) $actor->getKey();
|
|
|
|
$mutator($draft);
|
|
|
|
$this->persistDraft(
|
|
draft: $draft,
|
|
incrementVersion: $incrementVersion || $isNew,
|
|
);
|
|
|
|
return $draft->refresh();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param callable(TenantOnboardingSession):void $mutator
|
|
*/
|
|
public function mutate(
|
|
TenantOnboardingSession $draft,
|
|
User $actor,
|
|
callable $mutator,
|
|
?int $expectedVersion = null,
|
|
bool $incrementVersion = true,
|
|
bool $allowTerminal = false,
|
|
): TenantOnboardingSession {
|
|
return DB::transaction(function () use ($draft, $actor, $mutator, $expectedVersion, $incrementVersion, $allowTerminal): TenantOnboardingSession {
|
|
$lockedDraft = TenantOnboardingSession::query()
|
|
->whereKey($draft->getKey())
|
|
->lockForUpdate()
|
|
->firstOrFail();
|
|
|
|
if (! $allowTerminal && $lockedDraft->lifecycleState()->isTerminal()) {
|
|
throw new OnboardingDraftImmutableException(
|
|
draftId: (int) $lockedDraft->getKey(),
|
|
lifecycleState: $lockedDraft->lifecycleState(),
|
|
);
|
|
}
|
|
|
|
if ($expectedVersion !== null) {
|
|
$this->assertExpectedVersion($lockedDraft, $expectedVersion);
|
|
}
|
|
|
|
$lockedDraft->updated_by_user_id = (int) $actor->getKey();
|
|
|
|
$mutator($lockedDraft);
|
|
|
|
$this->persistDraft(
|
|
draft: $lockedDraft,
|
|
incrementVersion: $incrementVersion,
|
|
);
|
|
|
|
return $lockedDraft->refresh();
|
|
});
|
|
}
|
|
|
|
public function lockForTrustedMutation(TenantOnboardingSession|int|string $draft, Workspace $workspace): TenantOnboardingSession
|
|
{
|
|
$draftId = $draft instanceof TenantOnboardingSession
|
|
? (int) $draft->getKey()
|
|
: (int) $draft;
|
|
|
|
$lockedDraft = TenantOnboardingSession::query()
|
|
->whereKey($draftId)
|
|
->where('workspace_id', (int) $workspace->getKey())
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
if (! $lockedDraft instanceof TenantOnboardingSession) {
|
|
throw new NotFoundHttpException;
|
|
}
|
|
|
|
return $lockedDraft;
|
|
}
|
|
|
|
private function resolveDraftForIdentity(
|
|
Workspace $workspace,
|
|
string $entraTenantId,
|
|
?TenantOnboardingSession $preferredDraft = null,
|
|
): ?TenantOnboardingSession {
|
|
if ($preferredDraft instanceof TenantOnboardingSession) {
|
|
$lockedPreferredDraft = TenantOnboardingSession::query()
|
|
->whereKey($preferredDraft->getKey())
|
|
->where('workspace_id', (int) $workspace->getKey())
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
if ($lockedPreferredDraft instanceof TenantOnboardingSession && $lockedPreferredDraft->entra_tenant_id === $entraTenantId) {
|
|
if ($lockedPreferredDraft->lifecycleState()->isTerminal()) {
|
|
throw new OnboardingDraftImmutableException(
|
|
draftId: (int) $lockedPreferredDraft->getKey(),
|
|
lifecycleState: $lockedPreferredDraft->lifecycleState(),
|
|
);
|
|
}
|
|
|
|
return $lockedPreferredDraft;
|
|
}
|
|
}
|
|
|
|
return TenantOnboardingSession::query()
|
|
->where('workspace_id', (int) $workspace->getKey())
|
|
->where('entra_tenant_id', $entraTenantId)
|
|
->resumable()
|
|
->orderByDesc('updated_at')
|
|
->lockForUpdate()
|
|
->first();
|
|
}
|
|
|
|
private function assertExpectedVersion(TenantOnboardingSession $draft, int $expectedVersion): void
|
|
{
|
|
$actualVersion = max(1, (int) ($draft->version ?? 1));
|
|
|
|
if ($expectedVersion === $actualVersion) {
|
|
return;
|
|
}
|
|
|
|
throw new OnboardingDraftConflictException(
|
|
draftId: (int) $draft->getKey(),
|
|
expectedVersion: $expectedVersion,
|
|
actualVersion: $actualVersion,
|
|
);
|
|
}
|
|
|
|
private function persistDraft(TenantOnboardingSession $draft, bool $incrementVersion): void
|
|
{
|
|
$currentVersion = max(0, (int) ($draft->version ?? 0));
|
|
|
|
$draft->version = $incrementVersion
|
|
? $currentVersion + 1
|
|
: max(1, $currentVersion);
|
|
|
|
$this->lifecycleService->applySnapshot($draft, false);
|
|
|
|
$draft->save();
|
|
}
|
|
}
|