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); } }