Implements Spec 076 enterprise remediation UX for tenant required permissions. Highlights - Above-the-fold overview (impact + counts) with missing-first experience - Feature-based grouping, filters/search, copy-to-clipboard for missing app/delegated permissions - Tenant-scoped deny-as-not-found semantics; DB-only viewing - Centralized badge semantics (no ad-hoc status mapping) Testing - Feature tests for default filters, grouping, copy output, and non-member 404 behavior. Integration - Adds deep links from verification checks to the Required permissions page. Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box> Reviewed-on: #92
416 lines
14 KiB
PHP
416 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Verification;
|
|
|
|
use App\Models\Tenant;
|
|
use App\Support\Links\RequiredPermissionsLinks;
|
|
|
|
final class TenantPermissionCheckClusters
|
|
{
|
|
/**
|
|
* @phpstan-type TenantPermissionRow array{
|
|
* key:string,
|
|
* type:'application'|'delegated',
|
|
* description:?string,
|
|
* features:array<int,string>,
|
|
* status:'granted'|'missing'|'error',
|
|
* details:array<string,mixed>|null
|
|
* }
|
|
*
|
|
* @param array<int, array<string, mixed>> $permissions
|
|
* @param array{fresh?:bool,reason_code?:string,message?:string}|null $inventory
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public static function buildChecks(Tenant $tenant, array $permissions, ?array $inventory = null): array
|
|
{
|
|
$inventory = is_array($inventory) ? $inventory : [];
|
|
|
|
$inventoryFresh = $inventory['fresh'] ?? true;
|
|
$inventoryFresh = is_bool($inventoryFresh) ? $inventoryFresh : true;
|
|
|
|
$inventoryReasonCode = $inventory['reason_code'] ?? null;
|
|
$inventoryReasonCode = is_string($inventoryReasonCode) && $inventoryReasonCode !== ''
|
|
? $inventoryReasonCode
|
|
: 'dependency_unreachable';
|
|
|
|
$inventoryMessage = $inventory['message'] ?? null;
|
|
$inventoryMessage = is_string($inventoryMessage) && trim($inventoryMessage) !== ''
|
|
? trim($inventoryMessage)
|
|
: 'Unable to refresh observed permissions inventory during this run. Retry verification.';
|
|
|
|
$inventoryEvidence = self::inventoryEvidence($inventory);
|
|
|
|
/** @var array<int, TenantPermissionRow> $rows */
|
|
$rows = collect($permissions)
|
|
->filter(fn (mixed $row): bool => is_array($row))
|
|
->map(fn (array $row): array => self::normalizePermissionRow($row))
|
|
->values()
|
|
->all();
|
|
|
|
$checks = [];
|
|
|
|
foreach (self::definitions() as $definition) {
|
|
$key = (string) ($definition['key'] ?? 'unknown');
|
|
$title = (string) ($definition['title'] ?? 'Check');
|
|
|
|
$clusterRows = array_values(array_filter($rows, fn (array $row): bool => self::matches($definition, $row)));
|
|
|
|
$checks[] = self::buildCheck(
|
|
tenant: $tenant,
|
|
key: $key,
|
|
title: $title,
|
|
clusterRows: $clusterRows,
|
|
inventoryFresh: $inventoryFresh,
|
|
inventoryReasonCode: $inventoryReasonCode,
|
|
inventoryMessage: $inventoryMessage,
|
|
inventoryEvidence: $inventoryEvidence,
|
|
);
|
|
}
|
|
|
|
return $checks;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{key:string,title:string,mode:string,prefixes?:array<int,string>,keys?:array<int,string>}>
|
|
*/
|
|
private static function definitions(): array
|
|
{
|
|
return [
|
|
[
|
|
'key' => 'permissions.admin_consent',
|
|
'title' => 'Admin consent granted',
|
|
'mode' => 'type',
|
|
'type' => 'application',
|
|
],
|
|
[
|
|
'key' => 'permissions.directory_groups',
|
|
'title' => 'Directory & group read access',
|
|
'mode' => 'keys',
|
|
'keys' => [
|
|
'Directory.Read.All',
|
|
'Group.Read.All',
|
|
],
|
|
],
|
|
[
|
|
'key' => 'permissions.intune_configuration',
|
|
'title' => 'Intune configuration access',
|
|
'mode' => 'prefixes',
|
|
'prefixes' => [
|
|
'DeviceManagementConfiguration.',
|
|
'DeviceManagementServiceConfig.',
|
|
],
|
|
],
|
|
[
|
|
'key' => 'permissions.intune_apps',
|
|
'title' => 'Intune apps access',
|
|
'mode' => 'prefixes',
|
|
'prefixes' => [
|
|
'DeviceManagementApps.',
|
|
],
|
|
],
|
|
[
|
|
'key' => 'permissions.intune_rbac_assignments',
|
|
'title' => 'Intune RBAC & assignments prerequisites',
|
|
'mode' => 'prefixes',
|
|
'prefixes' => [
|
|
'DeviceManagementRBAC.',
|
|
],
|
|
],
|
|
[
|
|
'key' => 'permissions.scripts_remediations',
|
|
'title' => 'Scripts/remediations access',
|
|
'mode' => 'prefixes',
|
|
'prefixes' => [
|
|
'DeviceManagementScripts.',
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{mode:string,prefixes?:array<int,string>,keys?:array<int,string>,type?:string} $definition
|
|
* @param TenantPermissionRow $row
|
|
*/
|
|
private static function matches(array $definition, array $row): bool
|
|
{
|
|
$mode = (string) ($definition['mode'] ?? '');
|
|
$key = (string) ($row['key'] ?? '');
|
|
|
|
if ($mode === 'type') {
|
|
return ($row['type'] ?? null) === ($definition['type'] ?? null);
|
|
}
|
|
|
|
if ($mode === 'keys') {
|
|
$keys = $definition['keys'] ?? [];
|
|
|
|
return is_array($keys) && in_array($key, $keys, true);
|
|
}
|
|
|
|
if ($mode === 'prefixes') {
|
|
$prefixes = $definition['prefixes'] ?? [];
|
|
|
|
if (! is_array($prefixes)) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($prefixes as $prefix) {
|
|
if (is_string($prefix) && $prefix !== '' && str_starts_with($key, $prefix)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, TenantPermissionRow> $clusterRows
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function buildCheck(
|
|
Tenant $tenant,
|
|
string $key,
|
|
string $title,
|
|
array $clusterRows,
|
|
bool $inventoryFresh,
|
|
string $inventoryReasonCode,
|
|
string $inventoryMessage,
|
|
array $inventoryEvidence,
|
|
): array
|
|
{
|
|
if (! $inventoryFresh) {
|
|
return [
|
|
'key' => $key,
|
|
'title' => $title,
|
|
'status' => VerificationCheckStatus::Warn->value,
|
|
'severity' => VerificationCheckSeverity::Medium->value,
|
|
'blocking' => false,
|
|
'reason_code' => $inventoryReasonCode,
|
|
'message' => $inventoryMessage,
|
|
'evidence' => $inventoryEvidence,
|
|
'next_steps' => [
|
|
[
|
|
'label' => 'Open required permissions',
|
|
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
if ($clusterRows === []) {
|
|
return [
|
|
'key' => $key,
|
|
'title' => $title,
|
|
'status' => VerificationCheckStatus::Skip->value,
|
|
'severity' => VerificationCheckSeverity::Info->value,
|
|
'blocking' => false,
|
|
'reason_code' => 'not_applicable',
|
|
'message' => 'Not applicable for this tenant.',
|
|
'evidence' => [],
|
|
'next_steps' => [],
|
|
];
|
|
}
|
|
|
|
$missingApplication = array_values(array_filter(
|
|
$clusterRows,
|
|
static fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'application',
|
|
));
|
|
|
|
$missingDelegated = array_values(array_filter(
|
|
$clusterRows,
|
|
static fn (array $row): bool => $row['status'] === 'missing' && $row['type'] === 'delegated',
|
|
));
|
|
|
|
$errored = array_values(array_filter(
|
|
$clusterRows,
|
|
static fn (array $row): bool => $row['status'] === 'error',
|
|
));
|
|
|
|
$evidence = array_values(array_unique(array_merge(
|
|
self::evidence($missingApplication, $missingDelegated, $errored),
|
|
$inventoryEvidence,
|
|
), SORT_REGULAR));
|
|
|
|
if ($missingApplication !== [] || $errored !== []) {
|
|
$missingKeys = array_values(array_unique(array_merge(
|
|
array_map(static fn (array $row): string => $row['key'], $missingApplication),
|
|
array_map(static fn (array $row): string => $row['key'], $errored),
|
|
)));
|
|
|
|
$message = $missingKeys !== []
|
|
? sprintf('Missing required application permission(s): %s', implode(', ', array_slice($missingKeys, 0, 6)))
|
|
: 'Missing required permissions.';
|
|
|
|
return [
|
|
'key' => $key,
|
|
'title' => $title,
|
|
'status' => VerificationCheckStatus::Fail->value,
|
|
'severity' => VerificationCheckSeverity::Critical->value,
|
|
'blocking' => true,
|
|
'reason_code' => 'ext.missing_permission',
|
|
'message' => $message,
|
|
'evidence' => $evidence,
|
|
'next_steps' => [
|
|
[
|
|
'label' => 'Open required permissions',
|
|
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
if ($missingDelegated !== []) {
|
|
$missingKeys = array_values(array_unique(array_map(static fn (array $row): string => $row['key'], $missingDelegated)));
|
|
$message = sprintf('Missing delegated permission(s): %s', implode(', ', array_slice($missingKeys, 0, 6)));
|
|
|
|
return [
|
|
'key' => $key,
|
|
'title' => $title,
|
|
'status' => VerificationCheckStatus::Warn->value,
|
|
'severity' => VerificationCheckSeverity::Medium->value,
|
|
'blocking' => false,
|
|
'reason_code' => 'ext.missing_delegated_permission',
|
|
'message' => $message,
|
|
'evidence' => $evidence,
|
|
'next_steps' => [
|
|
[
|
|
'label' => 'Open required permissions',
|
|
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
return [
|
|
'key' => $key,
|
|
'title' => $title,
|
|
'status' => VerificationCheckStatus::Pass->value,
|
|
'severity' => VerificationCheckSeverity::Info->value,
|
|
'blocking' => false,
|
|
'reason_code' => 'ok',
|
|
'message' => 'All required permissions are granted.',
|
|
'evidence' => [],
|
|
'next_steps' => [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, TenantPermissionRow> $missingApplication
|
|
* @param array<int, TenantPermissionRow> $missingDelegated
|
|
* @param array<int, TenantPermissionRow> $errored
|
|
* @return array<int, array{kind:string,value:int|string}>
|
|
*/
|
|
private static function evidence(array $missingApplication, array $missingDelegated, array $errored): array
|
|
{
|
|
$pointers = [];
|
|
|
|
foreach (array_merge($missingApplication, $missingDelegated, $errored) as $row) {
|
|
$pointers[] = [
|
|
'kind' => 'missing_permission',
|
|
'value' => (string) ($row['key'] ?? ''),
|
|
];
|
|
|
|
$pointers[] = [
|
|
'kind' => 'permission_type',
|
|
'value' => (string) ($row['type'] ?? 'application'),
|
|
];
|
|
|
|
foreach (($row['features'] ?? []) as $feature) {
|
|
if (! is_string($feature) || $feature === '') {
|
|
continue;
|
|
}
|
|
|
|
$pointers[] = [
|
|
'kind' => 'feature',
|
|
'value' => $feature,
|
|
];
|
|
}
|
|
}
|
|
|
|
$unique = [];
|
|
|
|
foreach ($pointers as $pointer) {
|
|
$key = $pointer['kind'].':'.(string) $pointer['value'];
|
|
$unique[$key] = $pointer;
|
|
}
|
|
|
|
return array_values($unique);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $inventory
|
|
* @return array<int, array{kind:string,value:int|string}>
|
|
*/
|
|
private static function inventoryEvidence(array $inventory): array
|
|
{
|
|
$pointers = [];
|
|
|
|
$appId = $inventory['app_id'] ?? null;
|
|
if (is_string($appId) && $appId !== '') {
|
|
$pointers[] = [
|
|
'kind' => 'app_id',
|
|
'value' => $appId,
|
|
];
|
|
}
|
|
|
|
$observedCount = $inventory['observed_permissions_count'] ?? null;
|
|
if (is_int($observedCount) || (is_numeric($observedCount) && (string) (int) $observedCount === (string) $observedCount)) {
|
|
$pointers[] = [
|
|
'kind' => 'observed_permissions_count',
|
|
'value' => (int) $observedCount,
|
|
];
|
|
}
|
|
|
|
return $pointers;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
* @return TenantPermissionRow
|
|
*/
|
|
private static function normalizePermissionRow(array $row): array
|
|
{
|
|
$key = (string) ($row['key'] ?? '');
|
|
$type = (string) ($row['type'] ?? 'application');
|
|
$status = (string) ($row['status'] ?? 'missing');
|
|
$description = $row['description'] ?? null;
|
|
$features = $row['features'] ?? [];
|
|
$details = $row['details'] ?? null;
|
|
|
|
if (! in_array($type, ['application', 'delegated'], true)) {
|
|
$type = 'application';
|
|
}
|
|
|
|
if (! in_array($status, ['granted', 'missing', 'error'], true)) {
|
|
$status = 'missing';
|
|
}
|
|
|
|
if (! is_string($description) || $description === '') {
|
|
$description = null;
|
|
}
|
|
|
|
if (! is_array($features)) {
|
|
$features = [];
|
|
}
|
|
|
|
$features = array_values(array_unique(array_filter(array_map('strval', $features))));
|
|
|
|
if (! is_array($details)) {
|
|
$details = null;
|
|
}
|
|
|
|
return [
|
|
'key' => $key,
|
|
'type' => $type,
|
|
'description' => $description,
|
|
'features' => $features,
|
|
'status' => $status,
|
|
'details' => $details,
|
|
];
|
|
}
|
|
}
|