472 lines
19 KiB
PHP
472 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Verification;
|
|
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\Tenant;
|
|
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
|
use App\Support\Links\RequiredPermissionsLinks;
|
|
use App\Support\Providers\ProviderNextStepsRegistry;
|
|
use App\Support\Providers\ProviderReasonCodes;
|
|
|
|
final class VerificationAssistViewModelBuilder
|
|
{
|
|
public function __construct(
|
|
private readonly TenantRequiredPermissionsViewModelBuilder $requiredPermissionsViewModelBuilder,
|
|
private readonly ProviderNextStepsRegistry $providerNextStepsRegistry,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $verificationReport
|
|
* @return array{is_visible:bool,reason:'permission_blocked'|'permission_attention'|'hidden_ready'|'hidden_irrelevant'}
|
|
*/
|
|
public function visibility(Tenant $tenant, ?array $verificationReport): array
|
|
{
|
|
return $this->deriveVisibility(
|
|
$verificationReport,
|
|
$this->requiredPermissionsViewModel($tenant),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $verificationReport
|
|
* @return array{
|
|
* tenant: array{id:int,external_id:string,name:string},
|
|
* verification: array{overall:?string,status:?string,is_stale:bool,stale_reason:?string},
|
|
* overview: array{
|
|
* overall:string,
|
|
* counts: array{missing_application:int,missing_delegated:int,present:int,error:int},
|
|
* freshness: array{last_refreshed_at:?string,is_stale:bool}
|
|
* },
|
|
* missing_permissions: array{
|
|
* application: array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}>,
|
|
* delegated: array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}>
|
|
* },
|
|
* copy: array{application:string,delegated:string},
|
|
* actions: array{
|
|
* full_page: array{label:string,url:string,opens_in_new_tab:bool,available:bool,is_secondary:bool},
|
|
* copy_application: array{label:string,payload:string,available:bool},
|
|
* copy_delegated: array{label:string,payload:string,available:bool},
|
|
* grant_admin_consent: array{label:string,url:?string,opens_in_new_tab:bool,available:bool},
|
|
* manage_provider_connection: array{label:string,url:?string,opens_in_new_tab:bool,available:bool},
|
|
* rerun_verification: array{label:string,handled_by_existing_wizard:true}
|
|
* },
|
|
* fallback: array{has_incomplete_detail:bool,message:?string}
|
|
* }
|
|
*/
|
|
public function build(
|
|
Tenant $tenant,
|
|
?array $verificationReport,
|
|
?ProviderConnection $providerConnection = null,
|
|
?string $verificationStatus = null,
|
|
bool $isVerificationStale = false,
|
|
?string $staleReason = null,
|
|
bool $canAccessProviderConnectionDiagnostics = false,
|
|
): array {
|
|
$requiredPermissionsViewModel = $this->requiredPermissionsViewModel($tenant);
|
|
$tenantViewModel = is_array($requiredPermissionsViewModel['tenant'] ?? null)
|
|
? $requiredPermissionsViewModel['tenant']
|
|
: [];
|
|
$overview = is_array($requiredPermissionsViewModel['overview'] ?? null)
|
|
? $requiredPermissionsViewModel['overview']
|
|
: [];
|
|
$counts = $this->normalizeCounts(is_array($overview['counts'] ?? null) ? $overview['counts'] : []);
|
|
$freshness = $this->normalizeFreshness(is_array($overview['freshness'] ?? null) ? $overview['freshness'] : []);
|
|
$rows = $this->attentionRows(is_array($requiredPermissionsViewModel['permissions'] ?? null)
|
|
? $requiredPermissionsViewModel['permissions']
|
|
: []);
|
|
$partitionedRows = $this->partitionRows($rows);
|
|
$copy = is_array($requiredPermissionsViewModel['copy'] ?? null)
|
|
? $requiredPermissionsViewModel['copy']
|
|
: [];
|
|
|
|
$reasonCode = $this->primaryRelevantReasonCode($verificationReport);
|
|
$registrySteps = $reasonCode === null
|
|
? []
|
|
: $this->providerNextStepsRegistry->forReason($tenant, $reasonCode, $providerConnection);
|
|
|
|
$fallbackMessage = $this->fallbackMessage(
|
|
verificationReport: $verificationReport,
|
|
overview: $overview,
|
|
counts: $counts,
|
|
rows: $rows,
|
|
);
|
|
|
|
return [
|
|
'tenant' => [
|
|
'id' => (int) ($tenantViewModel['id'] ?? $tenant->getKey()),
|
|
'external_id' => (string) ($tenantViewModel['external_id'] ?? $tenant->external_id),
|
|
'name' => (string) (($tenantViewModel['name'] ?? $tenant->name) ?: $tenant->external_id),
|
|
],
|
|
'verification' => [
|
|
'overall' => $this->verificationOverall($verificationReport),
|
|
'status' => $this->normalizeOptionalString($verificationStatus),
|
|
'is_stale' => $isVerificationStale,
|
|
'stale_reason' => $this->normalizeOptionalString($staleReason),
|
|
],
|
|
'overview' => [
|
|
'overall' => $this->normalizeOverviewOverall($overview['overall'] ?? null),
|
|
'counts' => $counts,
|
|
'freshness' => $freshness,
|
|
],
|
|
'missing_permissions' => $partitionedRows,
|
|
'copy' => [
|
|
'application' => (string) ($copy['application'] ?? ''),
|
|
'delegated' => (string) ($copy['delegated'] ?? ''),
|
|
],
|
|
'actions' => [
|
|
'full_page' => [
|
|
'label' => 'Open full page',
|
|
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
|
'opens_in_new_tab' => true,
|
|
'available' => true,
|
|
'is_secondary' => true,
|
|
],
|
|
'copy_application' => [
|
|
'label' => 'Copy missing application permissions',
|
|
'payload' => (string) ($copy['application'] ?? ''),
|
|
'available' => trim((string) ($copy['application'] ?? '')) !== '',
|
|
],
|
|
'copy_delegated' => [
|
|
'label' => 'Copy missing delegated permissions',
|
|
'payload' => (string) ($copy['delegated'] ?? ''),
|
|
'available' => trim((string) ($copy['delegated'] ?? '')) !== '',
|
|
],
|
|
'grant_admin_consent' => $this->grantAdminConsentAction($tenant, $counts, $registrySteps),
|
|
'manage_provider_connection' => $this->manageProviderConnectionAction($registrySteps, $canAccessProviderConnectionDiagnostics),
|
|
'rerun_verification' => [
|
|
'label' => 'Use the existing Start verification action in this step after reviewing changes.',
|
|
'handled_by_existing_wizard' => true,
|
|
],
|
|
],
|
|
'fallback' => [
|
|
'has_incomplete_detail' => $fallbackMessage !== null,
|
|
'message' => $fallbackMessage,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function requiredPermissionsViewModel(Tenant $tenant): array
|
|
{
|
|
return $this->requiredPermissionsViewModelBuilder->build($tenant, [
|
|
'status' => 'all',
|
|
'type' => 'all',
|
|
'features' => [],
|
|
'search' => '',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $verificationReport
|
|
* @param array<string, mixed> $requiredPermissionsViewModel
|
|
* @return array{is_visible:bool,reason:'permission_blocked'|'permission_attention'|'hidden_ready'|'hidden_irrelevant'}
|
|
*/
|
|
private function deriveVisibility(?array $verificationReport, array $requiredPermissionsViewModel): array
|
|
{
|
|
$overview = is_array($requiredPermissionsViewModel['overview'] ?? null)
|
|
? $requiredPermissionsViewModel['overview']
|
|
: [];
|
|
$overviewOverall = $this->normalizeOverviewOverall($overview['overall'] ?? null);
|
|
$hasRelevantPermissionIssue = $this->reportHasRelevantPermissionIssue($verificationReport);
|
|
|
|
if ($overviewOverall === VerificationReportOverall::Blocked->value) {
|
|
return [
|
|
'is_visible' => true,
|
|
'reason' => 'permission_blocked',
|
|
];
|
|
}
|
|
|
|
if ($overviewOverall === VerificationReportOverall::NeedsAttention->value || $hasRelevantPermissionIssue) {
|
|
return [
|
|
'is_visible' => true,
|
|
'reason' => 'permission_attention',
|
|
];
|
|
}
|
|
|
|
if ($overviewOverall === VerificationReportOverall::Ready->value || $this->verificationOverall($verificationReport) === VerificationReportOverall::Ready->value) {
|
|
return [
|
|
'is_visible' => false,
|
|
'reason' => 'hidden_ready',
|
|
];
|
|
}
|
|
|
|
return [
|
|
'is_visible' => false,
|
|
'reason' => 'hidden_irrelevant',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $verificationReport
|
|
* @param array<string, mixed> $overview
|
|
* @param array{missing_application:int,missing_delegated:int,present:int,error:int} $counts
|
|
* @param array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}> $rows
|
|
*/
|
|
private function fallbackMessage(?array $verificationReport, array $overview, array $counts, array $rows): ?string
|
|
{
|
|
if ($counts['error'] > 0) {
|
|
return 'Some stored permission details are incomplete. Open the full page or rerun verification for a complete diagnostic view.';
|
|
}
|
|
|
|
$freshness = is_array($overview['freshness'] ?? null) ? $overview['freshness'] : [];
|
|
if ((bool) ($freshness['is_stale'] ?? false) && $rows === []) {
|
|
return 'Stored permission data is stale. Open the full page or rerun verification to refresh diagnostics.';
|
|
}
|
|
|
|
if ($rows === [] && $this->reportHasRelevantPermissionIssue($verificationReport)) {
|
|
return 'Stored verification found a permissions issue, but the compact detail is incomplete. Open the full page or rerun verification.';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $verificationReport
|
|
*/
|
|
private function verificationOverall(?array $verificationReport): ?string
|
|
{
|
|
$summary = is_array($verificationReport['summary'] ?? null)
|
|
? $verificationReport['summary']
|
|
: [];
|
|
$overall = $summary['overall'] ?? null;
|
|
|
|
return is_string($overall) && in_array($overall, VerificationReportOverall::values(), true)
|
|
? $overall
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $verificationReport
|
|
*/
|
|
private function primaryRelevantReasonCode(?array $verificationReport): ?string
|
|
{
|
|
foreach ($this->relevantChecks($verificationReport) as $check) {
|
|
$reasonCode = $check['reason_code'] ?? null;
|
|
|
|
if (is_string($reasonCode) && $reasonCode !== '') {
|
|
return $reasonCode;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $verificationReport
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function relevantChecks(?array $verificationReport): array
|
|
{
|
|
$checks = is_array($verificationReport['checks'] ?? null)
|
|
? $verificationReport['checks']
|
|
: [];
|
|
|
|
return array_values(array_filter($checks, function (mixed $check): bool {
|
|
if (! is_array($check)) {
|
|
return false;
|
|
}
|
|
|
|
$status = $check['status'] ?? null;
|
|
$key = $check['key'] ?? null;
|
|
$reasonCode = $check['reason_code'] ?? null;
|
|
|
|
if (! is_string($status) || in_array($status, ['pass', 'skip', 'running'], true)) {
|
|
return false;
|
|
}
|
|
|
|
if (is_string($key) && str_starts_with($key, 'permissions.')) {
|
|
return true;
|
|
}
|
|
|
|
return in_array($reasonCode, [
|
|
ProviderReasonCodes::ProviderConsentMissing,
|
|
ProviderReasonCodes::ProviderConsentFailed,
|
|
ProviderReasonCodes::ProviderConsentRevoked,
|
|
ProviderReasonCodes::ProviderPermissionMissing,
|
|
ProviderReasonCodes::ProviderPermissionDenied,
|
|
ProviderReasonCodes::ProviderPermissionRefreshFailed,
|
|
ProviderReasonCodes::IntuneRbacPermissionMissing,
|
|
], true);
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $verificationReport
|
|
*/
|
|
private function reportHasRelevantPermissionIssue(?array $verificationReport): bool
|
|
{
|
|
return $this->relevantChecks($verificationReport) !== [];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $counts
|
|
* @param array<int, array{label:string,url:string}> $registrySteps
|
|
* @return array{label:string,url:?string,opens_in_new_tab:bool,available:bool}
|
|
*/
|
|
private function grantAdminConsentAction(Tenant $tenant, array $counts, array $registrySteps): array
|
|
{
|
|
$available = ($counts['missing_application'] + $counts['missing_delegated']) > 0;
|
|
|
|
if (! $available) {
|
|
return [
|
|
'label' => 'Admin consent guide',
|
|
'url' => null,
|
|
'opens_in_new_tab' => true,
|
|
'available' => false,
|
|
];
|
|
}
|
|
|
|
foreach ($registrySteps as $step) {
|
|
$label = is_string($step['label'] ?? null) ? (string) $step['label'] : '';
|
|
$url = is_string($step['url'] ?? null) ? (string) $step['url'] : '';
|
|
|
|
if ($label !== '' && str_contains(strtolower($label), 'consent') && $url !== '') {
|
|
return [
|
|
'label' => $label,
|
|
'url' => $url,
|
|
'opens_in_new_tab' => true,
|
|
'available' => true,
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'label' => 'Admin consent guide',
|
|
'url' => RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant),
|
|
'opens_in_new_tab' => true,
|
|
'available' => true,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array{label:string,url:string}> $registrySteps
|
|
* @return array{label:string,url:?string,opens_in_new_tab:bool,available:bool}
|
|
*/
|
|
private function manageProviderConnectionAction(array $registrySteps, bool $canAccessProviderConnectionDiagnostics): array
|
|
{
|
|
if (! $canAccessProviderConnectionDiagnostics) {
|
|
return [
|
|
'label' => 'Manage provider connection',
|
|
'url' => null,
|
|
'opens_in_new_tab' => true,
|
|
'available' => false,
|
|
];
|
|
}
|
|
|
|
foreach ($registrySteps as $step) {
|
|
$label = is_string($step['label'] ?? null) ? (string) $step['label'] : '';
|
|
$url = is_string($step['url'] ?? null) ? (string) $step['url'] : '';
|
|
$path = parse_url($url, PHP_URL_PATH);
|
|
|
|
if (is_string($path) && str_contains($path, '/provider-connections')) {
|
|
return [
|
|
'label' => $label !== '' ? $label : 'Manage provider connection',
|
|
'url' => $url,
|
|
'opens_in_new_tab' => true,
|
|
'available' => true,
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'label' => 'Manage provider connection',
|
|
'url' => null,
|
|
'opens_in_new_tab' => true,
|
|
'available' => false,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overview
|
|
*/
|
|
private function normalizeOverviewOverall(mixed $overview): string
|
|
{
|
|
return is_string($overview) && in_array($overview, [
|
|
VerificationReportOverall::Ready->value,
|
|
VerificationReportOverall::NeedsAttention->value,
|
|
VerificationReportOverall::Blocked->value,
|
|
], true)
|
|
? $overview
|
|
: VerificationReportOverall::NeedsAttention->value;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $counts
|
|
* @return array{missing_application:int,missing_delegated:int,present:int,error:int}
|
|
*/
|
|
private function normalizeCounts(array $counts): array
|
|
{
|
|
return [
|
|
'missing_application' => $this->normalizeNonNegativeInteger($counts['missing_application'] ?? 0),
|
|
'missing_delegated' => $this->normalizeNonNegativeInteger($counts['missing_delegated'] ?? 0),
|
|
'present' => $this->normalizeNonNegativeInteger($counts['present'] ?? 0),
|
|
'error' => $this->normalizeNonNegativeInteger($counts['error'] ?? 0),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $freshness
|
|
* @return array{last_refreshed_at:?string,is_stale:bool}
|
|
*/
|
|
private function normalizeFreshness(array $freshness): array
|
|
{
|
|
return [
|
|
'last_refreshed_at' => $this->normalizeOptionalString($freshness['last_refreshed_at'] ?? null),
|
|
'is_stale' => (bool) ($freshness['is_stale'] ?? true),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $permissions
|
|
* @return array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}>
|
|
*/
|
|
private function attentionRows(array $permissions): array
|
|
{
|
|
return array_values(array_filter($permissions, function (mixed $row): bool {
|
|
return is_array($row) && (($row['status'] ?? null) === 'missing' || ($row['status'] ?? null) === 'error');
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}> $rows
|
|
* @return array{
|
|
* application: array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}>,
|
|
* delegated: array<int, array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}>
|
|
* }
|
|
*/
|
|
private function partitionRows(array $rows): array
|
|
{
|
|
return [
|
|
'application' => array_values(array_filter($rows, static fn (array $row): bool => ($row['type'] ?? null) === 'application')),
|
|
'delegated' => array_values(array_filter($rows, static fn (array $row): bool => ($row['type'] ?? null) === 'delegated')),
|
|
];
|
|
}
|
|
|
|
private function normalizeNonNegativeInteger(mixed $value): int
|
|
{
|
|
if (is_int($value)) {
|
|
return max(0, $value);
|
|
}
|
|
|
|
if (is_numeric($value)) {
|
|
return max(0, (int) $value);
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
private function normalizeOptionalString(mixed $value): ?string
|
|
{
|
|
if (! is_string($value) || trim($value) === '') {
|
|
return null;
|
|
}
|
|
|
|
return trim($value);
|
|
}
|
|
}
|