TenantAtlas/app/Services/Intune/RbacHealthService.php

167 lines
6.2 KiB
PHP

<?php
namespace App\Services\Intune;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Support\RbacReason;
use Carbon\CarbonImmutable;
class RbacHealthService
{
public function __construct(private readonly GraphClientInterface $graph) {}
/**
* @return array{status:string,reason:?string,used_artifacts:bool}
*/
public function check(Tenant $tenant): array
{
if (! $tenant->isActive()) {
return $this->record($tenant, 'error', RbacReason::MissingArtifacts->value, false);
}
$artifactsPresent = filled($tenant->rbac_group_id) || filled($tenant->rbac_role_assignment_id);
if (! $artifactsPresent) {
return $this->record($tenant, 'missing', RbacReason::MissingArtifacts->value, false);
}
$context = $tenant->graphOptions();
$spId = $this->resolveServicePrincipalId($tenant, $context);
if (! $spId) {
return $this->record($tenant, 'error', RbacReason::ServicePrincipalMissing->value, true);
}
if ($tenant->rbac_role_assignment_id) {
$response = $this->graph->request('GET', "deviceManagement/roleAssignments/{$tenant->rbac_role_assignment_id}", $context);
if ($response->successful()) {
$assignment = $response->data;
if (! $this->matchesScope($assignment, $tenant)) {
return $this->record($tenant, 'partial', RbacReason::ScopeMismatch->value, true);
}
if (! $this->matchesRole($assignment, $tenant, $context)) {
return $this->record($tenant, 'partial', RbacReason::RoleMismatch->value, true);
}
if (! $this->assignmentIncludesGroup($assignment, $tenant)) {
return $this->record($tenant, 'partial', RbacReason::ServicePrincipalNotMember->value, true);
}
return $this->record($tenant, 'ok', null, true);
}
}
if ($tenant->rbac_group_id) {
$response = $this->graph->request('GET', "groups/{$tenant->rbac_group_id}", $context);
if ($response->failed()) {
return $this->record($tenant, 'error', RbacReason::GroupMissing->value, true);
}
if (! $this->groupHasServicePrincipal($tenant->rbac_group_id, $spId, $context)) {
return $this->record($tenant, 'partial', RbacReason::ServicePrincipalNotMember->value, true);
}
// If group exists and SP is a member, but no role assignment found via API,
// check if this tenant requires manual assignment (unsupported API)
$hasManualAssignmentWarning = is_array($tenant->rbac_last_warnings)
&& in_array('manual_role_assignment_required', $tenant->rbac_last_warnings, true);
if ($tenant->rbac_status_reason === RbacReason::ManualAssignmentRequired->value || $hasManualAssignmentWarning) {
// Keep the manual_assignment_required status - group and membership are OK
// This account type doesn't support the Intune RBAC API, but permissions work via app registration
return $this->record($tenant, 'manual_assignment_required', RbacReason::ManualAssignmentRequired->value, true);
}
}
return $this->record($tenant, 'missing', RbacReason::AssignmentMissing->value, true);
}
/**
* @return array{status:string,reason:?string,used_artifacts:bool}
*/
private function record(Tenant $tenant, string $status, ?string $reason, bool $usedArtifacts): array
{
$tenant->update([
'rbac_status' => $status,
'rbac_status_reason' => $reason,
'rbac_last_checked_at' => CarbonImmutable::now(),
]);
return [
'status' => $status,
'reason' => $reason,
'used_artifacts' => $usedArtifacts,
];
}
private function resolveServicePrincipalId(Tenant $tenant, array $context): ?string
{
$response = $this->graph->request('GET', 'servicePrincipals', [
'query' => [
'$filter' => "appId eq '{$tenant->app_client_id}'",
],
] + $context);
return $response->successful()
? ($response->data['value'][0]['id'] ?? null)
: null;
}
private function groupHasServicePrincipal(string $groupId, string $spId, array $context): bool
{
$response = $this->graph->request('GET', "groups/{$groupId}/members", $context);
if (! $response->successful()) {
return false;
}
$members = $response->data['value'] ?? [];
return collect($members)->contains(fn ($member) => ($member['id'] ?? null) === $spId);
}
private function matchesScope(array $assignment, Tenant $tenant): bool
{
$scopes = $assignment['resourceScopes'] ?? [];
$expected = ($tenant->rbac_scope_mode === 'scope_group' && filled($tenant->rbac_scope_id))
? [$tenant->rbac_scope_id]
: ['/'];
return $scopes === $expected;
}
private function matchesRole(array $assignment, Tenant $tenant, array $context): bool
{
$roleDefinitionId = data_get($assignment, 'roleDefinition.id') ?? data_get($assignment, 'roleDefinitionId');
$expectedId = $tenant->rbac_role_definition_id;
if (! $expectedId || ! $roleDefinitionId) {
return true;
}
return strcasecmp($expectedId, $roleDefinitionId) === 0;
}
private function assignmentIncludesGroup(array $assignment, Tenant $tenant): bool
{
$bindingBase = sprintf('https://graph.microsoft.com/%s', trim(config('graph.version', 'beta'), '/'));
$expected = "{$bindingBase}/groups/{$tenant->rbac_group_id}";
$members = $assignment['members@odata.bind'] ?? $assignment['members'] ?? [];
if (empty($members) && isset($assignment['members@odata.bind'])) {
$members = $assignment['members@odata.bind'];
}
if (isset($assignment['members']) && is_array($assignment['members'])) {
return collect($assignment['members'])->contains(fn ($id) => $id === $tenant->rbac_group_id);
}
return collect($members)->contains($expected);
}
}