## Summary - implement the provider capability registry and derived capability evaluation flow - update provider connections, onboarding, required-permissions diagnostics, and provider blocker translation to use capability-first summaries - add bounded unit, feature, and browser test coverage plus the prepared Spec 283 artifacts ## Notes - branch: `283-provider-capability-registry` - commit: `74e75c3e` - no additional validation commands were run in this git/PR flow step Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #342
556 lines
20 KiB
PHP
556 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Intune;
|
|
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Support\Providers\Capabilities\ProviderCapabilityDefinition;
|
|
use App\Support\Providers\Capabilities\ProviderCapabilityRegistry;
|
|
use App\Support\Providers\Capabilities\ProviderCapabilityStatus;
|
|
use App\Support\Verification\TenantPermissionCheckClusters;
|
|
use App\Support\Verification\VerificationReportOverall;
|
|
use Carbon\CarbonInterface;
|
|
use Illuminate\Support\Carbon;
|
|
|
|
class TenantRequiredPermissionsViewModelBuilder
|
|
{
|
|
/**
|
|
* @phpstan-type TenantPermissionRow array{key:string,type:'application'|'delegated',description:?string,features:array<int,string>,status:'granted'|'missing'|'error',details:array<string,mixed>|null}
|
|
* @phpstan-type FeatureImpact array{feature:string,missing:int,required_application:int,required_delegated:int,blocked:bool}
|
|
* @phpstan-type CapabilityGroup array{provider_capability_key:string,label:string,status:string,provider_requirement_keys:array<int,string>,missing_requirement_keys:array<int,string>,evidence_counts:array{requirements:int,missing:int,errors:int},message:string}
|
|
* @phpstan-type FilterState array{status:'missing'|'present'|'all',type:'application'|'delegated'|'all',features:array<int,string>,search:string}
|
|
* @phpstan-type ViewModel array{
|
|
* tenant: array{id:int,external_id:string,name:string},
|
|
* overview: array{
|
|
* overall: string,
|
|
* counts: array{missing_application:int,missing_delegated:int,present:int,error:int},
|
|
* feature_impacts: array<int, FeatureImpact>,
|
|
* capability_groups: array<int, CapabilityGroup>,
|
|
* primary_capability_group: CapabilityGroup|null,
|
|
* freshness: array{last_refreshed_at:?string,is_stale:bool}
|
|
* },
|
|
* permissions: array<int, TenantPermissionRow>,
|
|
* filters: FilterState,
|
|
* copy: array{application:string,delegated:string}
|
|
* }
|
|
*/
|
|
public function __construct(private readonly TenantPermissionService $permissionService) {}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return ViewModel
|
|
*/
|
|
public function build(ManagedEnvironment $tenant, array $filters = []): array
|
|
{
|
|
$comparison = $this->permissionService->compare(
|
|
$tenant,
|
|
persist: false,
|
|
liveCheck: false,
|
|
useConfiguredStub: false,
|
|
);
|
|
|
|
/** @var array<int, TenantPermissionRow> $allPermissions */
|
|
$allPermissions = collect($comparison['permissions'] ?? [])
|
|
->filter(fn (mixed $row): bool => is_array($row))
|
|
->map(fn (array $row): array => self::normalizePermissionRow($row))
|
|
->values()
|
|
->all();
|
|
|
|
$state = self::normalizeFilterState($filters);
|
|
|
|
$filteredPermissions = self::applyFilterState($allPermissions, $state);
|
|
$freshness = self::deriveFreshness(self::parseLastRefreshedAt($comparison['last_refreshed_at'] ?? null));
|
|
|
|
$summaryPermissions = $filteredPermissions;
|
|
$capabilityGroups = self::deriveCapabilityGroups($allPermissions, $freshness);
|
|
|
|
return [
|
|
'tenant' => [
|
|
'id' => (int) $tenant->getKey(),
|
|
'external_id' => (string) $tenant->external_id,
|
|
'name' => (string) $tenant->name,
|
|
],
|
|
'overview' => [
|
|
'overall' => self::deriveOverallStatus($summaryPermissions, (bool) ($freshness['is_stale'] ?? true)),
|
|
'counts' => self::deriveCounts($summaryPermissions),
|
|
'feature_impacts' => self::deriveFeatureImpacts($summaryPermissions),
|
|
'capability_groups' => $capabilityGroups,
|
|
'primary_capability_group' => self::primaryCapabilityGroup($capabilityGroups),
|
|
'freshness' => $freshness,
|
|
],
|
|
'permissions' => $filteredPermissions,
|
|
'filters' => $state,
|
|
'copy' => [
|
|
'application' => self::deriveCopyPayload($allPermissions, 'application', $state['features']),
|
|
'delegated' => self::deriveCopyPayload($allPermissions, 'delegated', $state['features']),
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, TenantPermissionRow> $permissions
|
|
* @param array{last_refreshed_at:?string,is_stale:bool} $freshness
|
|
* @return array<int, CapabilityGroup>
|
|
*/
|
|
public static function deriveCapabilityGroups(array $permissions, array $freshness): array
|
|
{
|
|
/** @var ProviderCapabilityRegistry $registry */
|
|
$registry = app(ProviderCapabilityRegistry::class);
|
|
|
|
return array_map(
|
|
static fn (ProviderCapabilityDefinition $definition): array => self::deriveCapabilityGroup(
|
|
definition: $definition,
|
|
permissions: $permissions,
|
|
isStale: (bool) ($freshness['is_stale'] ?? true),
|
|
),
|
|
array_values($registry->all()),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, CapabilityGroup> $groups
|
|
* @return CapabilityGroup|null
|
|
*/
|
|
public static function primaryCapabilityGroup(array $groups): ?array
|
|
{
|
|
if ($groups === []) {
|
|
return null;
|
|
}
|
|
|
|
usort($groups, static function (array $a, array $b): int {
|
|
$aStatus = ProviderCapabilityStatus::tryFrom((string) ($a['status'] ?? 'unknown')) ?? ProviderCapabilityStatus::Unknown;
|
|
$bStatus = ProviderCapabilityStatus::tryFrom((string) ($b['status'] ?? 'unknown')) ?? ProviderCapabilityStatus::Unknown;
|
|
|
|
return $aStatus->priority() <=> $bStatus->priority();
|
|
});
|
|
|
|
return $groups[0];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, TenantPermissionRow> $permissions
|
|
* @return CapabilityGroup
|
|
*/
|
|
private static function deriveCapabilityGroup(
|
|
ProviderCapabilityDefinition $definition,
|
|
array $permissions,
|
|
bool $isStale,
|
|
): array {
|
|
$rowsByRequirement = [];
|
|
|
|
foreach ($definition->providerRequirementKeys as $requirementKey) {
|
|
$rowsByRequirement[$requirementKey] = TenantPermissionCheckClusters::rowsForRequirementKey($permissions, $requirementKey);
|
|
}
|
|
|
|
$rows = array_values(array_merge(...array_values($rowsByRequirement ?: [[]])));
|
|
$missingRows = array_values(array_filter(
|
|
$rows,
|
|
static fn (array $row): bool => ($row['status'] ?? null) === 'missing',
|
|
));
|
|
$errorRows = array_values(array_filter(
|
|
$rows,
|
|
static fn (array $row): bool => ($row['status'] ?? null) === 'error',
|
|
));
|
|
$missingRequirementKeys = [];
|
|
|
|
foreach ($rowsByRequirement as $requirementKey => $requirementRows) {
|
|
foreach ($requirementRows as $row) {
|
|
if (in_array(($row['status'] ?? null), ['missing', 'error'], true)) {
|
|
$missingRequirementKeys[] = (string) $requirementKey;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
$status = match (true) {
|
|
$rows === [] => ProviderCapabilityStatus::NotApplicable,
|
|
$errorRows !== [] => ProviderCapabilityStatus::Unknown,
|
|
$missingRows !== [] => ProviderCapabilityStatus::Missing,
|
|
$isStale => ProviderCapabilityStatus::Unknown,
|
|
default => ProviderCapabilityStatus::Supported,
|
|
};
|
|
|
|
$message = match ($status) {
|
|
ProviderCapabilityStatus::Supported => "{$definition->label} capability is supported by stored permission evidence.",
|
|
ProviderCapabilityStatus::Missing => "{$definition->label} capability is missing required provider permissions.",
|
|
ProviderCapabilityStatus::Unknown => "{$definition->label} capability needs refreshed permission evidence.",
|
|
ProviderCapabilityStatus::Blocked => "{$definition->label} capability is blocked.",
|
|
ProviderCapabilityStatus::NotApplicable => "{$definition->label} capability has no mapped permission rows for this tenant.",
|
|
};
|
|
|
|
return [
|
|
'provider_capability_key' => $definition->key,
|
|
'label' => $definition->label,
|
|
'status' => $status->value,
|
|
'provider_requirement_keys' => $definition->providerRequirementKeys,
|
|
'missing_requirement_keys' => array_values(array_unique($missingRequirementKeys)),
|
|
'evidence_counts' => [
|
|
'requirements' => count($rows),
|
|
'missing' => count($missingRows),
|
|
'errors' => count($errorRows),
|
|
],
|
|
'message' => $message,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, TenantPermissionRow> $permissions
|
|
*/
|
|
public static function deriveOverallStatus(array $permissions, bool $hasStaleFreshness = false): string
|
|
{
|
|
$hasMissingApplication = collect($permissions)->contains(
|
|
fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'application',
|
|
);
|
|
|
|
if ($hasMissingApplication) {
|
|
return VerificationReportOverall::Blocked->value;
|
|
}
|
|
|
|
$hasErrors = collect($permissions)->contains(
|
|
fn (array $row): bool => $row['status'] === 'error',
|
|
);
|
|
|
|
$hasMissingDelegated = collect($permissions)->contains(
|
|
fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'delegated',
|
|
);
|
|
|
|
if ($hasErrors || $hasMissingDelegated || $hasStaleFreshness) {
|
|
return VerificationReportOverall::NeedsAttention->value;
|
|
}
|
|
|
|
return VerificationReportOverall::Ready->value;
|
|
}
|
|
|
|
/**
|
|
* @return array{last_refreshed_at:?string,is_stale:bool}
|
|
*/
|
|
public static function deriveFreshness(?CarbonInterface $lastRefreshedAt, ?CarbonInterface $referenceTime = null): array
|
|
{
|
|
$reference = $referenceTime instanceof Carbon
|
|
? $referenceTime->copy()
|
|
: ($referenceTime !== null ? Carbon::instance($referenceTime) : now());
|
|
|
|
$lastRefreshed = $lastRefreshedAt instanceof Carbon
|
|
? $lastRefreshedAt
|
|
: ($lastRefreshedAt !== null ? Carbon::instance($lastRefreshedAt) : null);
|
|
|
|
$isStale = $lastRefreshed === null
|
|
|| $lastRefreshed->lt($reference->copy()->subDays(30));
|
|
|
|
return [
|
|
'last_refreshed_at' => $lastRefreshed?->toIso8601String(),
|
|
'is_stale' => $isStale,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, TenantPermissionRow> $permissions
|
|
* @return array{missing_application:int,missing_delegated:int,present:int,error:int}
|
|
*/
|
|
public static function deriveCounts(array $permissions): array
|
|
{
|
|
$counts = [
|
|
'missing_application' => 0,
|
|
'missing_delegated' => 0,
|
|
'present' => 0,
|
|
'error' => 0,
|
|
];
|
|
|
|
foreach ($permissions as $row) {
|
|
if (($row['status'] ?? null) === 'missing') {
|
|
if (($row['type'] ?? null) === 'delegated') {
|
|
$counts['missing_delegated'] += 1;
|
|
} else {
|
|
$counts['missing_application'] += 1;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (($row['status'] ?? null) === 'granted') {
|
|
$counts['present'] += 1;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (($row['status'] ?? null) === 'error') {
|
|
$counts['error'] += 1;
|
|
}
|
|
}
|
|
|
|
return $counts;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, TenantPermissionRow> $permissions
|
|
* @return array<int, FeatureImpact>
|
|
*/
|
|
public static function deriveFeatureImpacts(array $permissions): array
|
|
{
|
|
/** @var array<string, FeatureImpact> $impacts */
|
|
$impacts = [];
|
|
|
|
foreach ($permissions as $row) {
|
|
$features = array_values(array_unique($row['features'] ?? []));
|
|
|
|
foreach ($features as $feature) {
|
|
if (! isset($impacts[$feature])) {
|
|
$impacts[$feature] = [
|
|
'feature' => $feature,
|
|
'missing' => 0,
|
|
'required_application' => 0,
|
|
'required_delegated' => 0,
|
|
'blocked' => false,
|
|
];
|
|
}
|
|
|
|
if (($row['type'] ?? null) === 'delegated') {
|
|
$impacts[$feature]['required_delegated'] += 1;
|
|
} else {
|
|
$impacts[$feature]['required_application'] += 1;
|
|
}
|
|
|
|
if (($row['status'] ?? null) === 'missing') {
|
|
$impacts[$feature]['missing'] += 1;
|
|
|
|
if (($row['type'] ?? null) === 'application') {
|
|
$impacts[$feature]['blocked'] = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$values = array_values($impacts);
|
|
|
|
usort($values, static function (array $a, array $b): int {
|
|
$blocked = (int) ($b['blocked'] <=> $a['blocked']);
|
|
if ($blocked !== 0) {
|
|
return $blocked;
|
|
}
|
|
|
|
$missing = (int) (($b['missing'] ?? 0) <=> ($a['missing'] ?? 0));
|
|
if ($missing !== 0) {
|
|
return $missing;
|
|
}
|
|
|
|
return strcmp((string) ($a['feature'] ?? ''), (string) ($b['feature'] ?? ''));
|
|
});
|
|
|
|
return $values;
|
|
}
|
|
|
|
/**
|
|
* Copy payload semantics:
|
|
* - Always Missing-only
|
|
* - Always Type fixed by button (application vs delegated)
|
|
* - Respects Feature filter only
|
|
* - Ignores Search
|
|
*
|
|
* @param array<int, TenantPermissionRow> $permissions
|
|
* @param 'application'|'delegated' $type
|
|
* @param array<int, string> $featureFilter
|
|
*/
|
|
public static function deriveCopyPayload(array $permissions, string $type, array $featureFilter = []): string
|
|
{
|
|
$featureFilter = array_values(array_unique(array_filter(array_map('strval', $featureFilter))));
|
|
|
|
$payload = collect($permissions)
|
|
->filter(function (array $row) use ($type, $featureFilter): bool {
|
|
if (($row['status'] ?? null) !== 'missing') {
|
|
return false;
|
|
}
|
|
|
|
if (($row['type'] ?? null) !== $type) {
|
|
return false;
|
|
}
|
|
|
|
if ($featureFilter === []) {
|
|
return true;
|
|
}
|
|
|
|
$rowFeatures = $row['features'] ?? [];
|
|
|
|
return count(array_intersect($featureFilter, $rowFeatures)) > 0;
|
|
})
|
|
->pluck('key')
|
|
->map(fn (mixed $key): string => (string) $key)
|
|
->filter()
|
|
->unique()
|
|
->sort()
|
|
->values()
|
|
->all();
|
|
|
|
return implode("\n", $payload);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, TenantPermissionRow> $permissions
|
|
* @return array<int, TenantPermissionRow>
|
|
*/
|
|
public static function applyFilterState(array $permissions, array $state): array
|
|
{
|
|
$status = $state['status'] ?? 'missing';
|
|
$type = $state['type'] ?? 'all';
|
|
$features = $state['features'] ?? [];
|
|
$search = $state['search'] ?? '';
|
|
|
|
$search = is_string($search) ? trim($search) : '';
|
|
$searchLower = strtolower($search);
|
|
|
|
$features = array_values(array_unique(array_filter(array_map('strval', $features))));
|
|
|
|
$filtered = collect($permissions)
|
|
->filter(function (array $row) use ($status, $type, $features): bool {
|
|
$rowStatus = $row['status'] ?? null;
|
|
$rowType = $row['type'] ?? null;
|
|
|
|
if ($status === 'missing' && ! in_array($rowStatus, ['missing', 'error'], true)) {
|
|
return false;
|
|
}
|
|
|
|
if ($status === 'present' && $rowStatus !== 'granted') {
|
|
return false;
|
|
}
|
|
|
|
if ($type !== 'all' && $rowType !== $type) {
|
|
return false;
|
|
}
|
|
|
|
if ($features === []) {
|
|
return true;
|
|
}
|
|
|
|
$rowFeatures = $row['features'] ?? [];
|
|
|
|
return count(array_intersect($features, $rowFeatures)) > 0;
|
|
})
|
|
->when($searchLower !== '', function ($collection) use ($searchLower) {
|
|
return $collection->filter(function (array $row) use ($searchLower): bool {
|
|
$key = strtolower((string) ($row['key'] ?? ''));
|
|
$description = strtolower((string) ($row['description'] ?? ''));
|
|
|
|
return str_contains($key, $searchLower) || ($description !== '' && str_contains($description, $searchLower));
|
|
});
|
|
})
|
|
->values()
|
|
->all();
|
|
|
|
usort($filtered, static function (array $a, array $b): int {
|
|
$weight = static function (array $row): int {
|
|
return match ($row['status'] ?? null) {
|
|
'missing' => 0,
|
|
'error' => 1,
|
|
default => 2,
|
|
};
|
|
};
|
|
|
|
$cmp = $weight($a) <=> $weight($b);
|
|
if ($cmp !== 0) {
|
|
return $cmp;
|
|
}
|
|
|
|
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
|
|
});
|
|
|
|
return $filtered;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return FilterState
|
|
*/
|
|
public static function normalizeFilterState(array $filters): array
|
|
{
|
|
$status = (string) ($filters['status'] ?? 'missing');
|
|
$type = (string) ($filters['type'] ?? 'all');
|
|
$features = $filters['features'] ?? [];
|
|
$search = (string) ($filters['search'] ?? '');
|
|
|
|
if (! in_array($status, ['missing', 'present', 'all'], true)) {
|
|
$status = 'missing';
|
|
}
|
|
|
|
if (! in_array($type, ['application', 'delegated', 'all'], true)) {
|
|
$type = 'all';
|
|
}
|
|
|
|
if (! is_array($features)) {
|
|
$features = [];
|
|
}
|
|
|
|
$features = array_values(array_unique(array_filter(array_map('strval', $features))));
|
|
|
|
return [
|
|
'status' => $status,
|
|
'type' => $type,
|
|
'features' => $features,
|
|
'search' => $search,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
* @return TenantPermissionRow
|
|
*/
|
|
private static function normalizePermissionRow(array $row): array
|
|
{
|
|
$key = (string) ($row['key'] ?? '');
|
|
$type = (string) ($row['type'] ?? 'application');
|
|
$description = $row['description'] ?? null;
|
|
$features = $row['features'] ?? [];
|
|
$status = (string) ($row['status'] ?? 'missing');
|
|
$details = $row['details'] ?? null;
|
|
|
|
if (! in_array($type, ['application', 'delegated'], true)) {
|
|
$type = 'application';
|
|
}
|
|
|
|
if (! is_string($description) || $description === '') {
|
|
$description = null;
|
|
}
|
|
|
|
if (! is_array($features)) {
|
|
$features = [];
|
|
}
|
|
|
|
$features = array_values(array_unique(array_filter(array_map('strval', $features))));
|
|
|
|
if (! in_array($status, ['granted', 'missing', 'error'], true)) {
|
|
$status = 'missing';
|
|
}
|
|
|
|
if (! is_array($details)) {
|
|
$details = null;
|
|
}
|
|
|
|
return [
|
|
'key' => $key,
|
|
'type' => $type,
|
|
'description' => $description,
|
|
'features' => $features,
|
|
'status' => $status,
|
|
'details' => $details,
|
|
];
|
|
}
|
|
|
|
private static function parseLastRefreshedAt(mixed $value): ?Carbon
|
|
{
|
|
if ($value instanceof Carbon) {
|
|
return $value;
|
|
}
|
|
|
|
if ($value instanceof CarbonInterface) {
|
|
return Carbon::instance($value);
|
|
}
|
|
|
|
if (is_string($value) && $value !== '') {
|
|
try {
|
|
return Carbon::parse($value);
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|