TenantAtlas/app/Console/Commands/GraphContractCheck.php
ahmido 32c3a64147 feat(112): LIST $expand parity + Entra principal names (#136)
Implements LIST `$expand` parity with GET by forwarding caller-provided, contract-allowlisted expands.

Key changes:
- Entra Admin Roles scan now requests `expand=principal` for role assignments so `principal.displayName` can render.
- `$expand` normalization/sanitization: top-level comma split (commas inside balanced parentheses preserved), trim, dedupe, allowlist exact match, caps (max 10 tokens, max 200 chars/token).
- Diagnostics when expands are removed/truncated (non-prod warning, production low-noise).

Tests:
- Adds/extends unit coverage for Graph contract sanitization, list request shaping, and the EntraAdminRolesReportService.

Spec artifacts included under `specs/112-list-expand-parity/`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #136
2026-02-25 23:54:20 +00:00

72 lines
2.3 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use Illuminate\Console\Command;
class GraphContractCheck extends Command
{
protected $signature = 'graph:contract:check {--tenant=}';
protected $description = 'Validate Graph contract registry against live endpoints (lightweight probes)';
public function handle(GraphClientInterface $graph, GraphContractRegistry $registry): int
{
$contracts = config('graph_contracts.types', []);
if (empty($contracts)) {
$this->warn('No graph contracts configured.');
return self::SUCCESS;
}
$tenant = $this->option('tenant');
$failures = 0;
foreach ($contracts as $type => $contract) {
$resource = $contract['resource'] ?? null;
$select = $contract['allowed_select'] ?? [];
$expand = $contract['allowed_expand'] ?? [];
if (! $resource) {
$this->error("[$type] missing resource path");
$failures++;
continue;
}
$queryInput = array_filter([
'$top' => 1,
'$select' => $select,
'$expand' => $expand,
], static fn ($value): bool => $value !== null && $value !== '' && $value !== []);
$query = $registry->sanitizeQuery($type, $queryInput)['query'];
$response = $graph->request('GET', $resource, [
'query' => $query,
'tenant' => $tenant,
]);
if ($response->failed()) {
$code = $response->meta['error_code'] ?? $response->status;
$message = $response->meta['error_message'] ?? ($response->errors[0]['message'] ?? $response->errors[0] ?? 'unknown');
$this->error("[$type] drift or capability issue ({$code}): {$message}");
$failures++;
continue;
}
if (! empty($response->warnings)) {
$this->warn("[$type] completed with warnings: ".implode('; ', $response->warnings));
} else {
$this->info("[$type] OK");
}
}
return $failures > 0 ? self::FAILURE : self::SUCCESS;
}
}