TenantAtlas/app/Services/Graph/GroupResolver.php
ahmido bda1d90fc4 Spec 094: Assignment ops observability hardening (#113)
Implements spec 094 (assignment fetch/restore observability hardening):

- Adds OperationRun tracking for assignment fetch (during backup) and assignment restore (during restore execution)
- Normalizes failure codes/reason_code and sanitizes failure messages
- Ensures exactly one audit log entry per assignment restore execution
- Enforces correct guard/membership vs capability semantics on affected admin surfaces
- Switches assignment Graph services to depend on GraphClientInterface

Also includes Postgres-only FK defense-in-depth check and a discoverable `composer test:pgsql` runner (scoped to the FK constraint test).

Tests:
- `vendor/bin/sail artisan test --compact` (passed)
- `vendor/bin/sail composer test:pgsql` (passed)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #113
2026-02-15 14:08:14 +00:00

124 lines
3.7 KiB
PHP

<?php
namespace App\Services\Graph;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class GroupResolver
{
public function __construct(
private readonly GraphClientInterface $graphClient,
) {}
/**
* Resolve group IDs to group objects with display names.
*
* Uses POST /directoryObjects/getByIds endpoint.
* Missing IDs are marked as orphaned.
*
* @param array $groupIds Array of group IDs to resolve
* @param string $tenantId Target tenant ID
* @return array Keyed array: ['group-id' => ['id' => ..., 'displayName' => ..., 'orphaned' => bool]]
*/
public function resolveGroupIds(array $groupIds, string $tenantId, array $options = []): array
{
if (empty($groupIds)) {
return [];
}
// Create cache key
$cacheKey = $this->getCacheKey($groupIds, $tenantId);
return Cache::remember($cacheKey, 300, function () use ($groupIds, $tenantId, $options) {
return $this->fetchAndResolveGroups($groupIds, $tenantId, $options);
});
}
/**
* Fetch groups from Graph API and resolve orphaned IDs.
*/
private function fetchAndResolveGroups(array $groupIds, string $tenantId, array $options = []): array
{
try {
$response = $this->graphClient->request(
'POST',
'/directoryObjects/getByIds',
array_merge($options, [
'tenant' => $tenantId,
'json' => [
'ids' => array_values($groupIds),
'types' => ['group'],
],
])
);
$resolvedGroups = $response->data['value'] ?? [];
// Create result map
$result = [];
$resolvedIds = [];
// Add resolved groups
foreach ($resolvedGroups as $group) {
$groupId = $group['id'];
$resolvedIds[] = $groupId;
$result[$groupId] = [
'id' => $groupId,
'displayName' => $group['displayName'] ?? null,
'orphaned' => false,
];
}
// Add orphaned groups (not in response)
foreach ($groupIds as $groupId) {
if (! in_array($groupId, $resolvedIds)) {
$result[$groupId] = [
'id' => $groupId,
'displayName' => null,
'orphaned' => true,
];
}
}
Log::debug('Resolved group IDs', [
'tenant_id' => $tenantId,
'requested' => count($groupIds),
'resolved' => count($resolvedIds),
'orphaned' => count($groupIds) - count($resolvedIds),
]);
return $result;
} catch (GraphException $e) {
Log::warning('Failed to resolve group IDs', [
'tenant_id' => $tenantId,
'group_ids' => $groupIds,
'error' => $e->getMessage(),
'context' => $e->context,
]);
// Return all as orphaned on failure
$result = [];
foreach ($groupIds as $groupId) {
$result[$groupId] = [
'id' => $groupId,
'displayName' => null,
'orphaned' => true,
];
}
return $result;
}
}
/**
* Generate cache key for group resolution.
*/
private function getCacheKey(array $groupIds, string $tenantId): string
{
sort($groupIds);
return "groups:{$tenantId}:".md5(implode(',', $groupIds));
}
}