167 lines
6.2 KiB
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);
|
|
}
|
|
}
|