TenantAtlas/app/Services/Intune/SettingsCatalogDefinitionResolver.php
ahmido 412dd7ad66 feat/017-policy-types-mam-endpoint-security-baselines (#23)
Hydrate configurationPolicies/{id}/settings for endpoint security/baseline policies so snapshots include real rule data.
Treat those types like Settings Catalog policies in the normalizer so they show the searchable settings table, recognizable categories, and readable choice values (firewall-specific formatting + interface badge parsing).
Improve “General” tab cards: badge lists for platforms/technologies, template reference summary (name/family/version/ID), and ISO timestamps rendered as YYYY‑MM‑DD HH:MM:SS; added regression test for the view.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #23
2026-01-03 02:06:35 +00:00

329 lines
11 KiB
PHP

<?php
namespace App\Services\Intune;
use App\Models\SettingsCatalogDefinition;
use App\Services\Graph\GraphClientInterface;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class SettingsCatalogDefinitionResolver
{
private const CACHE_TTL = 60 * 60 * 24 * 30; // 30 days
private const MEMORY_CACHE_PREFIX = 'settings_catalog_def_';
public function __construct(
private GraphClientInterface $graphClient
) {}
/**
* Resolve multiple definition IDs to their metadata.
*
* @param array<string> $definitionIds
* @return array<string, array> Map of definitionId => metadata
*/
public function resolve(array $definitionIds): array
{
if (empty($definitionIds)) {
return [];
}
$definitions = [];
$missingIds = [];
// Step 1: Check memory cache
foreach ($definitionIds as $id) {
$cached = Cache::get(self::MEMORY_CACHE_PREFIX.$id);
if ($cached) {
$definitions[$id] = $cached;
} else {
$missingIds[] = $id;
}
}
if (empty($missingIds)) {
return $definitions;
}
// Step 2: Check database cache
$dbDefinitions = SettingsCatalogDefinition::findByDefinitionIds($missingIds);
foreach ($dbDefinitions as $definitionId => $dbDef) {
$metadata = $this->transformToMetadata($dbDef);
$definitions[$definitionId] = $metadata;
// Cache in memory
Cache::put(
self::MEMORY_CACHE_PREFIX.$definitionId,
$metadata,
now()->addSeconds(self::CACHE_TTL)
);
$missingIds = array_diff($missingIds, [$definitionId]);
}
if (empty($missingIds)) {
return $definitions;
}
// Step 3: Fetch from Graph API
try {
$graphDefinitions = $this->fetchFromGraph($missingIds);
foreach ($graphDefinitions as $definitionId => $metadata) {
// Store in database
$this->storeInDatabase($definitionId, $metadata);
// Cache in memory
Cache::put(
self::MEMORY_CACHE_PREFIX.$definitionId,
$metadata,
now()->addSeconds(self::CACHE_TTL)
);
$definitions[$definitionId] = $metadata;
}
} catch (\Exception $e) {
Log::error('Failed to fetch setting definitions from Graph API', [
'definition_ids' => $missingIds,
'error' => $e->getMessage(),
]);
}
// Step 4: Fallback for still missing definitions
foreach ($missingIds as $id) {
if (! isset($definitions[$id])) {
$fallback = $this->getFallbackMetadata($id);
$definitions[$id] = $fallback;
// Cache fallback in memory too (short TTL since it's not real data)
Cache::put(
self::MEMORY_CACHE_PREFIX.$id,
$fallback,
now()->addMinutes(5) // Shorter TTL for fallbacks
);
}
}
return $definitions;
}
/**
* Resolve a single definition ID.
*/
public function resolveOne(string $definitionId): ?array
{
$result = $this->resolve([$definitionId]);
return $result[$definitionId] ?? null;
}
/**
* Warm cache for definition IDs without returning data.
* Non-blocking: catches and logs errors.
*/
public function warmCache(array $definitionIds): void
{
try {
$this->resolve($definitionIds);
} catch (\Exception $e) {
Log::warning('Failed to warm cache for setting definitions', [
'definition_ids' => $definitionIds,
'error' => $e->getMessage(),
]);
}
}
/**
* Clear cache for a specific definition or all definitions.
*/
public function clearCache(?string $definitionId = null): void
{
if ($definitionId) {
Cache::forget(self::MEMORY_CACHE_PREFIX.$definitionId);
SettingsCatalogDefinition::where('definition_id', $definitionId)->delete();
} else {
// Clear all memory cache (prefix-based)
Cache::flush();
SettingsCatalogDefinition::truncate();
}
}
/**
* Fetch definitions from Graph API.
*
* @param array<string> $definitionIds
* @return array<string, array>
*/
private function fetchFromGraph(array $definitionIds): array
{
$definitions = [];
// Note: Microsoft Graph API does not support "in" operator for $filter.
// We fetch each definition individually.
// Endpoint: /deviceManagement/configurationSettings/{definitionId}
foreach ($definitionIds as $definitionId) {
// Skip template IDs with placeholders - these are not real definition IDs
if (str_contains($definitionId, '{') || str_contains($definitionId, '}')) {
Log::info('Skipping template definition ID', ['definitionId' => $definitionId]);
continue;
}
try {
$response = $this->graphClient->request(
'GET',
"/deviceManagement/configurationSettings/{$definitionId}"
);
if ($response->successful() && isset($response->data)) {
$item = $response->data;
$definitions[$definitionId] = [
'displayName' => $item['displayName'] ?? $this->prettifyDefinitionId($definitionId),
'description' => $item['description'] ?? null,
'helpText' => $item['helpText'] ?? null,
'categoryId' => $item['categoryId'] ?? null,
'uxBehavior' => $item['uxBehavior'] ?? null,
'raw' => $item,
];
}
} catch (\Exception $e) {
Log::warning('Failed to fetch definition from Graph API', [
'definitionId' => $definitionId,
'error' => $e->getMessage(),
]);
// Continue with other definitions
}
}
return $definitions;
}
/**
* Store definition in database.
*/
private function storeInDatabase(string $definitionId, array $metadata): void
{
try {
SettingsCatalogDefinition::updateOrCreate(
['definition_id' => $definitionId],
[
'display_name' => $metadata['displayName'],
'description' => $metadata['description'],
'help_text' => $metadata['helpText'],
'category_id' => $metadata['categoryId'],
'ux_behavior' => $metadata['uxBehavior'],
'raw' => $metadata['raw'] ?? [],
]
);
Log::info('Stored definition in database', ['definition_id' => $definitionId]);
} catch (\Exception $e) {
Log::error('Failed to store definition in database', [
'definition_id' => $definitionId,
'error' => $e->getMessage(),
'metadata' => $metadata,
]);
throw $e;
}
}
/**
* Transform database model to metadata array.
*/
private function transformToMetadata(SettingsCatalogDefinition $definition): array
{
return [
'displayName' => $definition->display_name,
'description' => $definition->description,
'helpText' => $definition->help_text,
'categoryId' => $definition->category_id,
'uxBehavior' => $definition->ux_behavior,
'raw' => $definition->raw,
];
}
/**
* Get fallback metadata for unknown definition.
*/
private function getFallbackMetadata(string $definitionId): array
{
return [
'displayName' => $this->prettifyDefinitionId($definitionId),
'description' => null,
'helpText' => null,
'categoryId' => null,
'uxBehavior' => null,
'raw' => null,
'isFallback' => true,
];
}
/**
* Prettify definition ID for fallback display.
* Example: "device_vendor_msft_policy_name" → "Device Vendor Msft Policy Name"
* Special handling for {tenantid} placeholders (Microsoft template definitions).
*/
public function prettifyDefinitionId(string $definitionId): string
{
// Remove {tenantid} placeholder - it's a Microsoft template variable, not part of the name
$cleaned = str_replace(['{tenantid}', '_tenantid_', '_{tenantid}_'], ['', '_', '_'], $definitionId);
// Remove other template placeholders, e.g. "{FirewallRuleId}"
$cleaned = preg_replace('/\{[^}]+\}/', '', $cleaned);
// Clean up consecutive underscores
$cleaned = preg_replace('/_+/', '_', $cleaned);
$cleaned = trim($cleaned, '_');
$lowered = Str::lower($cleaned);
if (str_starts_with($lowered, 'vendor_msft_firewall_mdmstore_firewallrules')) {
$suffix = ltrim(substr($lowered, strlen('vendor_msft_firewall_mdmstore_firewallrules')), '_');
if ($suffix === '') {
return 'Firewall rule';
}
$known = [
'displayname' => 'Name',
'name' => 'Name',
'description' => 'Description',
'direction' => 'Direction',
'action' => 'Action',
'actiontype' => 'Action type',
'profiles' => 'Profiles',
'profile' => 'Profile',
'protocol' => 'Protocol',
'localport' => 'Local port',
'remoteport' => 'Remote port',
'localaddress' => 'Local address',
'remoteaddress' => 'Remote address',
'interfacetype' => 'Interface type',
'interfacetypes' => 'Interface types',
'edgetraversal' => 'Edge traversal',
'enabled' => 'Enabled',
];
if (isset($known[$suffix])) {
return $known[$suffix];
}
return Str::headline($suffix);
}
// Convert to title case
$prettified = Str::title(str_replace('_', ' ', $cleaned));
// Remove redundant prefixes to shorten labels
$prettified = preg_replace('/^Device Vendor Msft Passportforwork Policies\s+/', '', $prettified);
$prettified = preg_replace('/^Device Vendor Msft Passportforwork\s+/', 'Windows Hello - ', $prettified);
// Shorten common terms
$prettified = str_replace('Pincomplexity', 'PIN', $prettified);
$prettified = str_replace('Usepassportforwork', 'Enable Windows Hello', $prettified);
return $prettified;
}
}