TenantAtlas/app/Services/Intune/TenantPermissionService.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

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