## 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
289 lines
11 KiB
PHP
289 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
|
use App\Models\AuditLog;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantPermission;
|
|
use App\Models\User;
|
|
use App\Models\WorkspaceMembership;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\Providers\ProviderReasonCodes;
|
|
use App\Support\Verification\VerificationReportWriter;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use Livewire\Livewire;
|
|
|
|
function assistConfiguredPermissionKeys(): array
|
|
{
|
|
$configured = array_merge(
|
|
config('intune_permissions.permissions', []),
|
|
config('entra_permissions.permissions', []),
|
|
);
|
|
|
|
return array_values(array_filter(array_map(static function (mixed $permission): ?string {
|
|
if (! is_array($permission)) {
|
|
return null;
|
|
}
|
|
|
|
$key = $permission['key'] ?? null;
|
|
|
|
return is_string($key) && trim($key) !== '' ? trim($key) : null;
|
|
}, $configured)));
|
|
}
|
|
|
|
function seedAssistPermissionInventory(
|
|
Tenant $tenant,
|
|
?string $missingKey = null,
|
|
?int $staleDays = null,
|
|
array $errorKeys = [],
|
|
): void {
|
|
foreach (assistConfiguredPermissionKeys() as $key) {
|
|
if ($missingKey !== null && $key === $missingKey) {
|
|
continue;
|
|
}
|
|
|
|
TenantPermission::query()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'permission_key' => $key,
|
|
'status' => in_array($key, $errorKeys, true) ? 'error' : 'granted',
|
|
'details' => in_array($key, $errorKeys, true) ? ['reason_code' => 'permission_denied'] : ['source' => 'db'],
|
|
'last_checked_at' => $staleDays === null ? now() : now()->subDays($staleDays),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array{0:User,1:Tenant,2:\App\Models\TenantOnboardingSession,3:ProviderConnection,4:OperationRun,5:?string}
|
|
*/
|
|
function createVerificationAssistDraft(
|
|
string $state = 'blocked',
|
|
string $workspaceRole = 'owner',
|
|
string $tenantRole = 'owner',
|
|
bool $staleVerificationRun = false,
|
|
): array {
|
|
[$user, $tenant] = createUserWithTenant(
|
|
role: $tenantRole,
|
|
workspaceRole: $workspaceRole,
|
|
ensureDefaultMicrosoftProviderConnection: false,
|
|
);
|
|
|
|
$workspace = $tenant->workspace()->firstOrFail();
|
|
|
|
$verifiedConnection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'provider' => 'microsoft',
|
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
|
'display_name' => 'Verified connection',
|
|
'is_default' => true,
|
|
'status' => 'connected',
|
|
]);
|
|
|
|
$selectedConnection = $verifiedConnection;
|
|
|
|
if ($staleVerificationRun) {
|
|
$selectedConnection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'provider' => 'dummy',
|
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
|
'display_name' => 'Current selected connection',
|
|
'is_default' => false,
|
|
'status' => 'connected',
|
|
]);
|
|
}
|
|
|
|
$allPermissionKeys = assistConfiguredPermissionKeys();
|
|
$missingKey = $allPermissionKeys[0] ?? null;
|
|
$errorKeys = [];
|
|
$staleDays = null;
|
|
$check = [];
|
|
$outcome = OperationRunOutcome::Succeeded->value;
|
|
|
|
if ($state === 'blocked') {
|
|
seedAssistPermissionInventory($tenant, missingKey: $missingKey);
|
|
|
|
$check = [
|
|
'key' => 'permissions.admin_consent',
|
|
'title' => 'Required application permissions',
|
|
'status' => 'fail',
|
|
'severity' => 'critical',
|
|
'blocking' => true,
|
|
'reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
|
|
'message' => 'Missing required Graph permissions.',
|
|
'evidence' => [],
|
|
'next_steps' => [],
|
|
];
|
|
|
|
$outcome = OperationRunOutcome::Blocked->value;
|
|
} elseif ($state === 'needs_attention') {
|
|
seedAssistPermissionInventory($tenant, staleDays: 45);
|
|
|
|
$check = [
|
|
'key' => 'permissions.admin_consent',
|
|
'title' => 'Required application permissions',
|
|
'status' => 'warn',
|
|
'severity' => 'medium',
|
|
'blocking' => false,
|
|
'reason_code' => ProviderReasonCodes::ProviderPermissionRefreshFailed,
|
|
'message' => 'Stored permission data needs review.',
|
|
'evidence' => [],
|
|
'next_steps' => [],
|
|
];
|
|
} elseif ($state === 'degraded') {
|
|
$errorKeys = array_slice($allPermissionKeys, 0, 1);
|
|
seedAssistPermissionInventory($tenant, staleDays: 45, errorKeys: $errorKeys);
|
|
|
|
$check = [
|
|
'key' => 'permissions.admin_consent',
|
|
'title' => 'Required application permissions',
|
|
'status' => 'warn',
|
|
'severity' => 'medium',
|
|
'blocking' => false,
|
|
'reason_code' => ProviderReasonCodes::ProviderPermissionRefreshFailed,
|
|
'message' => 'Stored permission data needs review.',
|
|
'evidence' => [],
|
|
'next_steps' => [],
|
|
];
|
|
} else {
|
|
seedAssistPermissionInventory($tenant);
|
|
|
|
$check = [
|
|
'key' => 'provider.connection.check',
|
|
'title' => 'Provider connection check',
|
|
'status' => 'pass',
|
|
'severity' => 'info',
|
|
'blocking' => false,
|
|
'reason_code' => 'ok',
|
|
'message' => 'Connection is healthy.',
|
|
'evidence' => [],
|
|
'next_steps' => [],
|
|
];
|
|
}
|
|
|
|
$run = OperationRun::factory()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'type' => 'provider.connection.check',
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => $outcome,
|
|
'context' => [
|
|
'provider_connection_id' => (int) $verifiedConnection->getKey(),
|
|
'target_scope' => [
|
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
|
'entra_tenant_name' => (string) $tenant->name,
|
|
],
|
|
'verification_report' => VerificationReportWriter::build('provider.connection.check', [$check]),
|
|
],
|
|
]);
|
|
|
|
$draft = createOnboardingDraft([
|
|
'workspace' => $workspace,
|
|
'tenant' => $tenant,
|
|
'started_by' => $user,
|
|
'updated_by' => $user,
|
|
'current_step' => 'verify',
|
|
'state' => [
|
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
|
'tenant_name' => (string) $tenant->name,
|
|
'provider_connection_id' => (int) $selectedConnection->getKey(),
|
|
'verification_operation_run_id' => (int) $run->getKey(),
|
|
],
|
|
]);
|
|
|
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
|
|
|
return [$user, $tenant, $draft, $selectedConnection, $run, $missingKey];
|
|
}
|
|
|
|
it('shows the assist trigger for blocked and needs-attention states and hides it when verification is ready', function (string $state, bool $shouldSeeTrigger): void {
|
|
[$user, , $draft] = createVerificationAssistDraft($state);
|
|
|
|
$component = Livewire::actingAs($user)
|
|
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $draft->getKey()]);
|
|
|
|
if ($shouldSeeTrigger) {
|
|
$component->assertActionVisible('wizardVerificationRequiredPermissionsAssist');
|
|
|
|
return;
|
|
}
|
|
|
|
$component->assertActionHidden('wizardVerificationRequiredPermissionsAssist');
|
|
})->with([
|
|
'blocked' => ['blocked', true],
|
|
'needs attention' => ['needs_attention', true],
|
|
'ready' => ['ready', false],
|
|
]);
|
|
|
|
it('opens and closes the assist slideover without changing the verify step', function (): void {
|
|
[$user, , $draft] = createVerificationAssistDraft('blocked');
|
|
|
|
$existingRunCount = OperationRun::query()->count();
|
|
$existingAuditCount = AuditLog::query()->count();
|
|
|
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $user->last_workspace_id);
|
|
|
|
Livewire::actingAs($user)
|
|
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $draft->getKey()])
|
|
->mountAction('wizardVerificationRequiredPermissionsAssist')
|
|
->assertMountedActionModalSee('Required permissions assist')
|
|
->assertMountedActionModalSee('Open full page')
|
|
->unmountAction();
|
|
|
|
expect($draft->fresh()?->current_step)->toBe('verify')
|
|
->and(OperationRun::query()->count())->toBe($existingRunCount)
|
|
->and(AuditLog::query()->count())->toBe($existingAuditCount);
|
|
});
|
|
|
|
it('renders summary metadata and missing application permissions in the assist slideover', function (): void {
|
|
[$user, , $draft, , , $missingKey] = createVerificationAssistDraft('blocked');
|
|
|
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $user->last_workspace_id);
|
|
|
|
Livewire::actingAs($user)
|
|
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $draft->getKey()])
|
|
->mountAction('wizardVerificationRequiredPermissionsAssist')
|
|
->assertMountedActionModalSee('Missing application permissions')
|
|
->assertMountedActionModalSee('Copy missing application permissions')
|
|
->assertMountedActionModalSee((string) $missingKey)
|
|
->assertMountedActionModalDontSee('Copy missing delegated permissions');
|
|
});
|
|
|
|
it('renders degraded fallback and hides unavailable copy actions when stored detail is incomplete', function (): void {
|
|
[$user, , $draft] = createVerificationAssistDraft('degraded', staleVerificationRun: true);
|
|
|
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $user->last_workspace_id);
|
|
|
|
Livewire::actingAs($user)
|
|
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $draft->getKey()])
|
|
->mountAction('wizardVerificationRequiredPermissionsAssist')
|
|
->assertMountedActionModalSee('Verification result is stale')
|
|
->assertMountedActionModalSee('Stored permission data needs refresh')
|
|
->assertMountedActionModalSee('Compact detail is incomplete')
|
|
->assertMountedActionModalDontSee('Copy missing application permissions')
|
|
->assertMountedActionModalDontSee('Copy missing delegated permissions');
|
|
});
|
|
|
|
it('returns 404 for workspace members who are out of scope for the tenant assist surface', function (): void {
|
|
[$authorizedUser, $tenant, $draft] = createVerificationAssistDraft('blocked');
|
|
|
|
$workspace = $tenant->workspace()->firstOrFail();
|
|
$outOfScopeUser = User::factory()->create();
|
|
|
|
WorkspaceMembership::query()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'user_id' => (int) $outOfScopeUser->getKey(),
|
|
'role' => 'owner',
|
|
]);
|
|
|
|
$this->actingAs($outOfScopeUser)
|
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
->get(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]))
|
|
->assertNotFound();
|
|
});
|