TenantAtlas/app/Support/Verification/TenantPermissionCheckClusters.php
ahmido 05a604cfb6 Spec 076: Tenant Required Permissions (enterprise remediation UX) (#92)
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
2026-02-05 22:08:51 +00:00

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,
];
}
}