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
374 lines
14 KiB
PHP
374 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Intune;
|
|
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantPermission;
|
|
use App\Services\Graph\GraphClientInterface;
|
|
|
|
class TenantPermissionService
|
|
{
|
|
public function __construct(private readonly GraphClientInterface $graphClient) {}
|
|
|
|
/**
|
|
* @return array<int, array{key:string,type:string,description:?string,features:array<int,string>}>
|
|
*/
|
|
public function getRequiredPermissions(): array
|
|
{
|
|
return config('intune_permissions.permissions', []);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, array{status:string,details:array<string,mixed>|null,last_checked_at:?\Illuminate\Support\Carbon}>
|
|
*/
|
|
public function getGrantedPermissions(Tenant $tenant): array
|
|
{
|
|
return TenantPermission::query()
|
|
->where('tenant_id', $tenant->id)
|
|
->get()
|
|
->keyBy('permission_key')
|
|
->map(fn (TenantPermission $permission) => [
|
|
'status' => $permission->status,
|
|
'details' => $permission->details,
|
|
'last_checked_at' => $permission->last_checked_at,
|
|
])
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, array{status:string,details?:array<string,mixed>|null}|string>|null $grantedStatuses
|
|
* @param bool $persist Persist comparison results to tenant_permissions
|
|
* @param bool $liveCheck If true, fetch actual permissions from Graph API
|
|
* @param bool $useConfiguredStub Include configured stub permissions when no live check is used
|
|
* @param array{tenant?:string|null,client_id?:string|null,client_secret?:string|null,client_request_id?:string|null}|null $graphOptions
|
|
* @return array{
|
|
* overall_status:string,
|
|
* permissions:array<int,array{key:string,type:string,description:?string,features:array<int,string>,status:string,details:array<string,mixed>|null}>,
|
|
* live_check?: array{attempted:bool,succeeded:bool,http_status:?int,reason_code:?string}
|
|
* }
|
|
*/
|
|
public function compare(
|
|
Tenant $tenant,
|
|
?array $grantedStatuses = null,
|
|
bool $persist = true,
|
|
bool $liveCheck = false,
|
|
bool $useConfiguredStub = true,
|
|
?array $graphOptions = null,
|
|
): array {
|
|
$required = $this->getRequiredPermissions();
|
|
$liveCheckMeta = [
|
|
'attempted' => false,
|
|
'succeeded' => false,
|
|
'http_status' => null,
|
|
'reason_code' => null,
|
|
];
|
|
|
|
$liveCheckFailed = false;
|
|
$liveCheckDetails = null;
|
|
|
|
// If liveCheck is requested, fetch actual permissions from Graph
|
|
if ($liveCheck && $grantedStatuses === null) {
|
|
$liveCheckMeta['attempted'] = true;
|
|
|
|
$appId = null;
|
|
if (is_array($graphOptions) && is_string($graphOptions['client_id'] ?? null) && $graphOptions['client_id'] !== '') {
|
|
$appId = (string) $graphOptions['client_id'];
|
|
} elseif (is_string($tenant->graphOptions()['client_id'] ?? null) && $tenant->graphOptions()['client_id'] !== '') {
|
|
$appId = (string) $tenant->graphOptions()['client_id'];
|
|
}
|
|
|
|
if ($appId !== null) {
|
|
$liveCheckMeta['app_id'] = $appId;
|
|
}
|
|
|
|
$grantedStatuses = $this->fetchLivePermissions($tenant, $graphOptions);
|
|
|
|
if (isset($grantedStatuses['__error'])) {
|
|
$liveCheckFailed = true;
|
|
$liveCheckError = is_array($grantedStatuses['__error'] ?? null) ? $grantedStatuses['__error'] : null;
|
|
$liveCheckDetails = is_array($liveCheckError['details'] ?? null)
|
|
? $liveCheckError['details']
|
|
: (is_array($liveCheckError) ? $liveCheckError : null);
|
|
|
|
$httpStatus = $liveCheckDetails['status'] ?? null;
|
|
$liveCheckMeta['http_status'] = is_int($httpStatus) ? $httpStatus : null;
|
|
$liveCheckMeta['reason_code'] = $this->deriveLiveCheckReasonCode(
|
|
$liveCheckMeta['http_status'],
|
|
is_array($liveCheckDetails) ? $liveCheckDetails : null,
|
|
);
|
|
|
|
unset($grantedStatuses['__error']);
|
|
$grantedStatuses = null;
|
|
} else {
|
|
$observedCount = is_array($grantedStatuses) ? count($grantedStatuses) : 0;
|
|
$liveCheckMeta['observed_permissions_count'] = $observedCount;
|
|
|
|
if ($observedCount === 0) {
|
|
// Enterprise-safe: if the live refresh produced an empty inventory, treat it as non-fresh.
|
|
// This prevents false "missing" findings due to partial/misconfigured verification context.
|
|
$liveCheckMeta['succeeded'] = false;
|
|
$liveCheckMeta['reason_code'] = 'permissions_inventory_empty';
|
|
$grantedStatuses = null;
|
|
} else {
|
|
$liveCheckMeta['succeeded'] = true;
|
|
$liveCheckMeta['reason_code'] = 'ok';
|
|
}
|
|
}
|
|
}
|
|
|
|
$storedStatuses = $this->getGrantedPermissions($tenant);
|
|
|
|
if (! $useConfiguredStub) {
|
|
$storedStatuses = $this->dropConfiguredStatuses($storedStatuses);
|
|
}
|
|
|
|
$granted = $this->normalizeGrantedStatuses(
|
|
$grantedStatuses ?? array_replace_recursive(
|
|
$useConfiguredStub && ! $liveCheck ? $this->configuredGrantedStatuses() : [],
|
|
$storedStatuses
|
|
)
|
|
);
|
|
$results = [];
|
|
$hasMissing = false;
|
|
$hasErrors = false;
|
|
$checkedAt = now();
|
|
|
|
$canPersist = $persist;
|
|
|
|
if ($liveCheckMeta['attempted'] === true && $liveCheckMeta['succeeded'] === false) {
|
|
// Enterprise-safe: never overwrite stored inventory when we could not refresh it.
|
|
$canPersist = false;
|
|
}
|
|
|
|
foreach ($required as $permission) {
|
|
$key = $permission['key'];
|
|
$status = $liveCheckFailed
|
|
? 'error'
|
|
: ($granted[$key]['status'] ?? 'missing');
|
|
|
|
$details = $liveCheckFailed
|
|
? array_filter([
|
|
'source' => 'graph_api',
|
|
'status' => $liveCheckMeta['http_status'],
|
|
'reason_code' => $liveCheckMeta['reason_code'],
|
|
'message' => is_array($liveCheckDetails) ? ($liveCheckDetails['message'] ?? null) : null,
|
|
], fn (mixed $value): bool => $value !== null)
|
|
: ($granted[$key]['details'] ?? null);
|
|
|
|
if ($canPersist) {
|
|
TenantPermission::updateOrCreate(
|
|
[
|
|
'tenant_id' => $tenant->id,
|
|
'permission_key' => $key,
|
|
],
|
|
[
|
|
'status' => $status,
|
|
'details' => $details,
|
|
'last_checked_at' => $checkedAt,
|
|
]
|
|
);
|
|
}
|
|
|
|
$results[] = [
|
|
'key' => $key,
|
|
'type' => $permission['type'] ?? 'application',
|
|
'description' => $permission['description'] ?? null,
|
|
'features' => $permission['features'] ?? [],
|
|
'status' => $status,
|
|
'details' => $details,
|
|
];
|
|
|
|
$hasMissing = $hasMissing || $status === 'missing';
|
|
$hasErrors = $hasErrors || $status === 'error';
|
|
}
|
|
|
|
$overall = match (true) {
|
|
$hasErrors => 'error',
|
|
$hasMissing => 'missing',
|
|
default => 'granted',
|
|
};
|
|
|
|
$payload = [
|
|
'overall_status' => $overall,
|
|
'permissions' => $results,
|
|
];
|
|
|
|
if ($liveCheckMeta['attempted'] === true) {
|
|
$payload['live_check'] = $liveCheckMeta;
|
|
}
|
|
|
|
return $payload;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $details
|
|
*/
|
|
private function deriveLiveCheckReasonCode(?int $httpStatus, ?array $details = null): string
|
|
{
|
|
if (is_array($details) && is_string($details['reason_code'] ?? null)) {
|
|
return (string) $details['reason_code'];
|
|
}
|
|
|
|
return match (true) {
|
|
$httpStatus === 401 => 'authentication_failed',
|
|
$httpStatus === 403 => 'permission_denied',
|
|
$httpStatus === 408 => 'dependency_unreachable',
|
|
$httpStatus === 429 => 'throttled',
|
|
is_int($httpStatus) && $httpStatus >= 500 => 'dependency_unreachable',
|
|
is_int($httpStatus) && $httpStatus >= 400 => 'unknown_error',
|
|
default => is_array($details) && is_string($details['message'] ?? null) ? 'dependency_unreachable' : 'unknown_error',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, array{status:string,details?:array<string,mixed>|null}|string> $granted
|
|
* @return array<string, array{status:string,details:array<string,mixed>|null}>
|
|
*/
|
|
private function normalizeGrantedStatuses(array $granted): array
|
|
{
|
|
$normalized = [];
|
|
|
|
foreach ($granted as $key => $value) {
|
|
if (is_string($value)) {
|
|
$normalized[$key] = ['status' => $value, 'details' => null];
|
|
|
|
continue;
|
|
}
|
|
|
|
$normalized[$key] = [
|
|
'status' => $value['status'] ?? 'missing',
|
|
'details' => $value['details'] ?? null,
|
|
];
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, array{status:string,details:array<string,mixed>|null,last_checked_at:?\Illuminate\Support\Carbon}> $granted
|
|
* @return array<string, array{status:string,details:array<string,mixed>|null,last_checked_at:?\Illuminate\Support\Carbon}>
|
|
*/
|
|
private function dropConfiguredStatuses(array $granted): array
|
|
{
|
|
foreach ($granted as $key => $value) {
|
|
$source = $value['details']['source'] ?? null;
|
|
|
|
if ($source === 'configured') {
|
|
unset($granted[$key]);
|
|
}
|
|
}
|
|
|
|
return $granted;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, array{status:string,details:array<string,mixed>|null}>
|
|
*/
|
|
public function configuredGrantedStatuses(): array
|
|
{
|
|
$configured = $this->configuredGrantedKeys();
|
|
$normalized = [];
|
|
|
|
foreach ($configured as $key) {
|
|
$normalized[$key] = [
|
|
'status' => 'granted',
|
|
'details' => ['source' => 'configured'],
|
|
];
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function configuredGrantedKeys(): array
|
|
{
|
|
$env = env('INTUNE_GRANTED_PERMISSIONS');
|
|
|
|
if (is_string($env) && filled($env)) {
|
|
return collect(explode(',', $env))
|
|
->map(fn (string $key) => trim($key))
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
return config('intune_permissions.granted_stub', []);
|
|
}
|
|
|
|
/**
|
|
* Fetch actual granted permissions from Graph API.
|
|
*
|
|
* @return array<string, array{status:string,details:array<string,mixed>|null}>
|
|
*/
|
|
private function fetchLivePermissions(Tenant $tenant, ?array $graphOptions = null): array
|
|
{
|
|
try {
|
|
$response = $this->graphClient->getServicePrincipalPermissions(
|
|
$graphOptions ?? $tenant->graphOptions()
|
|
);
|
|
|
|
if (! $response->success) {
|
|
return [
|
|
'__error' => [
|
|
'status' => 'error',
|
|
'details' => [
|
|
'source' => 'graph_api',
|
|
'status' => $response->status,
|
|
'errors' => $response->errors,
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
$grantedPermissions = $response->data['permissions'] ?? [];
|
|
$diagnostics = is_array($response->data['diagnostics'] ?? null) ? $response->data['diagnostics'] : null;
|
|
$assignmentsTotal = is_array($diagnostics) ? (int) ($diagnostics['assignments_total'] ?? 0) : 0;
|
|
$mappedTotal = is_array($diagnostics) ? (int) ($diagnostics['mapped_total'] ?? 0) : null;
|
|
|
|
if ($assignmentsTotal > 0 && $mappedTotal === 0) {
|
|
return [
|
|
'__error' => [
|
|
'status' => 'error',
|
|
'details' => [
|
|
'source' => 'graph_api',
|
|
'status' => $response->status,
|
|
'reason_code' => 'permission_mapping_failed',
|
|
'message' => 'Graph returned app role assignments, but the system could not map them to permission values.',
|
|
'diagnostics' => $diagnostics,
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
$normalized = [];
|
|
|
|
foreach ($grantedPermissions as $permission) {
|
|
$normalized[$permission] = [
|
|
'status' => 'granted',
|
|
'details' => ['source' => 'graph_api', 'checked_at' => now()->toIso8601String()],
|
|
];
|
|
}
|
|
|
|
return $normalized;
|
|
} catch (\Throwable $e) {
|
|
// Log error but don't fail - fall back to config
|
|
\Log::warning('Failed to fetch live permissions from Graph', [
|
|
'tenant_id' => $tenant->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return [
|
|
'__error' => [
|
|
'status' => 'error',
|
|
'details' => [
|
|
'source' => 'graph_api',
|
|
'message' => $e->getMessage(),
|
|
],
|
|
],
|
|
];
|
|
}
|
|
}
|
|
}
|