Prepare Data History Provider 2.7 integration update

- modularize Citrix and agent integrations and add remote desktop endpoints
- extend ticket overview, ticket links, token validation, and staged relation handling
- update configuration, licensing, project, signing, and publish artifacts
This commit is contained in:
Meik
2026-07-20 09:31:52 +02:00
parent a3357e2a36
commit ccc48c521a
62 changed files with 5267 additions and 2521 deletions

View File

@@ -2,7 +2,11 @@
using System.Collections.Generic;
using System.Data.Common;
using System.Diagnostics;
using System.DirectoryServices.AccountManagement;
using System.DirectoryServices.ActiveDirectory;
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
@@ -13,28 +17,26 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Xml.Linq;
using System.IdentityModel.Tokens.Jwt;
using System.DirectoryServices.AccountManagement;
using System.DirectoryServices.ActiveDirectory;
using System.IO;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using C4IT.Configuration;
using C4IT.DataHistoryProvider.Base.Modules.Citrix;
using C4IT.FASD.Base;
using C4IT.FASD.Licensing;
using C4IT.Logging;
using C4IT.Matrix42.WebClient;
using C4IT.Security;
using C4IT.XML;
using C4IT.Matrix42.WebClient;
using C4IT_DataHistoryProvider_Base;
using C4IT_DataHistoryProvider_Base.DataSources;
using C4IT_DataHistoryProvider_Base.Services;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using static C4IT.FASD.Base.cF4sdQuickActionRevision;
using static C4IT.Logging.cLogManager;
@@ -105,7 +107,18 @@ namespace C4IT.DataHistoryProvider
public abstract Task<bool> DoScanAsync(bool Always, bool Rescan, cF4sdWebRequestInfo requestInfo, int LogDeep, CancellationToken Token, bool EnhancedDebug = false);
public abstract Task<List<object>> GetIds(cF4sdConnectorIds IdEntry, enumFasdInformationClass InfoClass, cF4sdWebRequestInfo requestInfo, int LogDeep);
public virtual async Task<List<object>> GetIds(cF4sdConnectorIds IdEntry, enumFasdInformationClass InfoClass, cF4sdWebRequestInfo requestInfo, int LogDeep)
{
await Task.CompletedTask;
if (IdEntry.sid == null)
return null;
return new List<object>() { IdEntry.Id };
}
public virtual bool UseForeignUserIds()
{
return false;
}
public virtual List<object> GetFilterValues(List<object> IDs, enumFasdInformationClass InfoClass)
{
@@ -222,6 +235,15 @@ namespace C4IT.DataHistoryProvider
await Task.CompletedTask;
return null;
}
public virtual async Task<int> ValidateTokenInfo(cDataHistoryCollectorTokenCache.cTokenInfo tokenInfo)
// 0: Token Info is correct
// -1: Token Info is incorrect
// 1: Token Info as to be updated due to newer infos
{
await Task.CompletedTask;
return 0;
}
}
public class cDataHistoryCollector : cDataHistoryCollectorModule, IConfigNodeValidation, ISearchResultRelationProvider
@@ -257,6 +279,7 @@ namespace C4IT.DataHistoryProvider
public cDataHistoryCollectorM42Wpm M42WpmCollector { get; private set; } = null;
public cDataHistoryCollectorNxql NxqlCollector { get; private set; } = null;
public cDataHistoryCollectorCitrix CitrixCollector { get; private set; } = null;
//public cDataHistoryCollectorEmpirum EmpirumCollector { get; private set; } = null;
public bool F4SDAnalyticsValid = false;
public cF4SDAnalyticsConfigDatabase F4SDAnalyticsDb { get; private set; } = null;
public event DataHistoryUiMessageFunc ProcessUiMessage;
@@ -279,6 +302,8 @@ namespace C4IT.DataHistoryProvider
private readonly List<ISearchResultRelationProvider> _searchResultRelationProviders = new List<ISearchResultRelationProvider>();
private Dictionary<cF4SDTokenRegistration.enumTokenType, cDataHistoryCollectorModule> _tokenValidators = new Dictionary<cF4SDTokenRegistration.enumTokenType, cDataHistoryCollectorModule>();
public bool RunningInIISExpress = false;
#region Services
@@ -288,7 +313,7 @@ namespace C4IT.DataHistoryProvider
public IStagedRelationService GetStagedRelationService()
{
if (_stagedRelationService is null)
_stagedRelationService = new StagedRelationService(_searchResultRelationProviders);
_stagedRelationService = new StagedRelationService(this, _searchResultRelationProviders);
return _stagedRelationService;
}
@@ -329,7 +354,7 @@ namespace C4IT.DataHistoryProvider
if (cF4SDLicense.Instance.Modules.ContainsKey(_ActiveDirectory.LicenseId))
{
ActiveDirectory = _ActiveDirectory;
Connectors.Add(enumDataHistoryOrigin.ActiveDirectory, ActiveDirectory);
Connectors.Add(enumDataHistoryOrigin.ActiveDirectory, ActiveDirectory);
}
var _F4sdAgent = new cDataHistoryCollectorClientAgent(this);
@@ -450,6 +475,7 @@ namespace C4IT.DataHistoryProvider
var HasIntuneConfig = (_cfgInfrastructure.AzureTenants?.Values.Any(t => t.ScanIntuneDevices) == true);
var HasMobileDeviceConfig = (_cfgInfrastructure.AzureTenants?.Values.Any(t => t.WithMobileDevices) == true);
var HasCitrixConfig = (InfrastructureConfig.Citrix != null);
var HasEmpirumConfig = true;
DoProcessUiMessage(1, "resolving the ad membership groups");
ActiveDirectory.ResolveWinAuthenticationGroups();
@@ -462,7 +488,7 @@ namespace C4IT.DataHistoryProvider
DoProcessUiMessage(0, "");
DoProcessUiMessage(1, "loading the data cluster definitions...");
var _cfgDataClusters = new cDataHistoryConfigClusters();
_cfgDataClusters.LoadFromFile(out PMs, withM42Config: HasM42Config, withIntuneConfig: HasIntuneConfig,withMobileDeviceConfig:HasMobileDeviceConfig,withCitrixConfig:HasCitrixConfig);
_cfgDataClusters.LoadFromFile(out PMs, withM42Config: HasM42Config, withIntuneConfig: HasIntuneConfig, withMobileDeviceConfig: HasMobileDeviceConfig, withCitrixConfig: HasCitrixConfig);
if (PMs != null && PMs.Count > 0)
DoProcessUiMessage(2, PMs);
@@ -509,24 +535,32 @@ namespace C4IT.DataHistoryProvider
{
var _CitrixCollector = new cDataHistoryCollectorCitrix(this);
if (cF4SDLicense.Instance?.Modules != null)
//if (cF4SDLicense.Instance.Modules.ContainsKey(_CitrixCollector.LicenseId))
//{
CitrixCollector = _CitrixCollector;
Connectors.Add(enumDataHistoryOrigin.Citrix, _CitrixCollector);
RegisterSearchRelationProvider(_CitrixCollector);
//}
if (cF4SDLicense.Instance.Modules.ContainsKey(_CitrixCollector.LicenseId))
{
CitrixCollector = _CitrixCollector;
Connectors.Add(enumDataHistoryOrigin.Citrix, _CitrixCollector);
RegisterSearchRelationProvider(_CitrixCollector);
}
}
if(HasIntuneConfig)
if (HasIntuneConfig)
{
Connectors.Add(enumDataHistoryOrigin.Intune, ActiveDirectory);
}
//if (HasIntuneConfig && HasMobileDeviceConfig)
//{
// RegisterSearchRelationProvider(ActiveDirectory);
//}
//if (HasEmpirumConfig)
//{
// var _EmpirumCollector = new cDataHistoryCollectorEmpirum(this);
// EmpirumCollector = _EmpirumCollector;
// Connectors.Add(enumDataHistoryOrigin.Empirum, EmpirumCollector);
//}
// do the post configuration tasks afer the infrastructure was loaded
DoProcessUiMessage(1, "processing post configuration tasks");
foreach (var Table in _cfgDataClusters.Tables.Values)
@@ -673,7 +707,7 @@ namespace C4IT.DataHistoryProvider
var HasMobileDeviceConfig = (InfrastructureConfig.AzureTenants?.Values.Any(t => t.WithMobileDevices) == true);
var HasCitrixConfig = (InfrastructureConfig.Citrix != null);
var _cfg = cFasdBaseConfig.GetConfigByType(_item, null, _msg, withM42Config: HasM42Config, withIntuneConfig: HasIntuneConfig, withMobileDeviceConfig: HasMobileDeviceConfig, withCitrixConfig:HasCitrixConfig);
var _cfg = cFasdBaseConfig.GetConfigByType(_item, null, _msg, withM42Config: HasM42Config, withIntuneConfig: HasIntuneConfig, withMobileDeviceConfig: HasMobileDeviceConfig, withCitrixConfig: HasCitrixConfig);
if (_cfg.IsValid)
{
_Configs[_item] = _cfg;
@@ -1201,7 +1235,6 @@ namespace C4IT.DataHistoryProvider
return false;
}
private async Task<bool> UpdateF4SDAnalyticsViewsAsync(cDbConnection DbConn, cF4sdWebRequestInfo requestInfo, int LogDeep, CancellationToken Token, bool ForceCreate)
{
MethodBase CM = null; if (cLogManager.DefaultLogger.IsDebug) { CM = MethodBase.GetCurrentMethod(); LogMethodBegin(CM); }
@@ -2490,7 +2523,7 @@ namespace C4IT.DataHistoryProvider
var anonymous = InfrastructureConfig.F4SDAnalyticsDB?.AnonymousUser;
Guid.TryParse(requestInfo.userInfo?.Id.ToString(), out Guid userGuid);
if (anonymous != null && (bool)anonymous)
Param.User = GetUserHashGuid(userGuid).ToString();
Param.User = GetUserHashGuid(userGuid).ToString();
else
Param.User = userGuid.ToString();
@@ -2666,17 +2699,27 @@ namespace C4IT.DataHistoryProvider
}
public async Task<cF4sdUserInfo> GetCurrentUserInfoAsync(CancellationToken Token, cF4sdWebRequestInfo requestInfo, int LogDeep)
public async Task<cF4sdUserInfo> GetCurrentUserInfoAsync(CancellationToken Token, cF4sdWebRequestInfo requestInfo, int logDeep)
{
MethodBase CM = null; if (cLogManager.DefaultLogger.IsDebug) { CM = MethodBase.GetCurrentMethod(); LogMethodBegin(CM); }
if (cPerformanceLogger.IsActive && requestInfo != null) { if (CM == null) CM = MethodBase.GetCurrentMethod(); cPerformanceLogger.LogPerformanceStart(LogDeep, CM, requestInfo.id, requestInfo.created); }
var _startTime = DateTime.UtcNow;
if (cPerformanceLogger.IsActive && requestInfo != null) { if (CM == null) CM = MethodBase.GetCurrentMethod(); cPerformanceLogger.LogPerformanceStart(logDeep, CM, requestInfo.id, requestInfo.created); }
var startTime = DateTime.UtcNow;
try
{
var _user = WindowsIdentity.GetCurrent();
var _res = await GetWinUserInfoAsync(_user, CultureInfo.CurrentCulture.DisplayName, true, Token, requestInfo, LogDeep + 1);
return _res;
if (!ActiveDirectory.WinAuthenticationGropsResolved)
{
ActiveDirectory.ResolveWinAuthenticationGroups();
if (!ActiveDirectory.WinAuthenticationGropsResolved)
{
LogEntry("Could not resolve authentication groups", LogLevels.Warning);
return null;
}
}
WindowsIdentity user = WindowsIdentity.GetCurrent();
return await GetWinUserInfoAsync(user, CultureInfo.CurrentCulture.DisplayName, true, Token, requestInfo, logDeep + 1);
}
catch (Exception E)
{
@@ -2684,7 +2727,7 @@ namespace C4IT.DataHistoryProvider
}
finally
{
if (cPerformanceLogger.IsActive && requestInfo != null) { cPerformanceLogger.LogPerformanceEnd(LogDeep, CM, requestInfo.id, requestInfo.created, _startTime); }
if (cPerformanceLogger.IsActive && requestInfo != null) { cPerformanceLogger.LogPerformanceEnd(logDeep, CM, requestInfo.id, requestInfo.created, startTime); }
if (CM != null) LogMethodEnd(CM);
}
@@ -2938,8 +2981,8 @@ namespace C4IT.DataHistoryProvider
}
if (strPhone != null)
{
Entry.Result.Name = strName + " (" + strPhone + ")";
Entry.Result.DisplayName = strName + " (" + strPhone + ")";
Entry.Result.Name = strName;
Entry.Result.DisplayName = $"{strName} ({strPhone})";
}
else
{
@@ -3853,7 +3896,7 @@ namespace C4IT.DataHistoryProvider
return null;
}
public async Task<List<cF4sdApiSearchResultRelation>> GetUsageFromOnlyAgentData(enumFasdInformationClass infoClass, List<Guid> Ids, int Age, CancellationToken Token, cF4sdWebRequestInfo requestInfo, int LogDeep)
public async Task<List<cF4sdApiSearchResultRelation>> GetUsageFromOnlyAgentData(IEnumerable<cF4sdConnectorIds> ids, enumFasdInformationClass infoClass, int Age, CancellationToken Token, cF4sdWebRequestInfo requestInfo, int LogDeep)
{
MethodBase CM = null;
if (cLogManager.DefaultLogger.IsDebug) { CM = MethodBase.GetCurrentMethod(); LogMethodBegin(CM); }
@@ -3862,20 +3905,15 @@ namespace C4IT.DataHistoryProvider
try
{
var _count = ids.Count();
var Age2 = Age;
if (Ids.Count == 1)
if (_count == 1)
Age2 = 365;
var AgentLimit = DateTime.UtcNow + TimeSpan.FromDays(-Age2);
var idInput = Ids.Select(v => new cF4sdIdentityEntry() { Id = v, Class = infoClass }).ToList();
var listIds = await getConnectorIdList(idInput, Token, requestInfo, LogDeep + 1);
var lstAgentIds = new Dictionary<int, cDataHistoryUsageIdInfo>(_count);
if (listIds == null || listIds.Count == 0)
return null;
var lstAgentIds = new Dictionary<int, cDataHistoryUsageIdInfo>(Ids.Count);
foreach (var Entry in listIds)
foreach (var Entry in ids)
{
if (Entry.lastSeenByAgent != null && Entry.lastSeenByAgent >= AgentLimit)
{
@@ -5237,7 +5275,7 @@ namespace C4IT.DataHistoryProvider
if (Connectors.TryGetValue(tableConfig.ParentCluster.Origin, out var C))
{
var IdValues = await C.GetIds(Identity, tableConfig.ParentCluster.InformationClass, requestInfo, LogDeep + 1);
if (IdValues != null && tableConfig.KeyColumns.Count == IdValues.Count)
if (IdValues != null && tableConfig.KeyColumns.Count == IdValues.Count)
{
var Filters = new Dictionary<string, object>();
for (int i = 0; i < IdValues.Count; i++)
@@ -5357,22 +5395,16 @@ namespace C4IT.DataHistoryProvider
return null;
}
private async Task<cF4sdConnectorIds> getConnectorIds(enumFasdInformationClass infoClass, cF4sdIdentityEntry Identity, CancellationToken Token, cF4sdWebRequestInfo requestInfo, int LogDeep, bool WithLocalAccounts = false)
private async Task<cF4sdConnectorIds> getConnectorIdsFromDb(enumFasdInformationClass infoClass, cF4sdIdentityEntry Identity, CancellationToken Token, cF4sdWebRequestInfo requestInfo, int LogDeep, bool WithLocalAccounts = false)
{
MethodBase CM = null; if (cLogManager.DefaultLogger.IsDebug) { CM = MethodBase.GetCurrentMethod(); LogMethodBegin(CM); }
if (cPerformanceLogger.IsActive && requestInfo != null) { if (CM == null) CM = MethodBase.GetCurrentMethod(); cPerformanceLogger.LogPerformanceStart(LogDeep, CM, requestInfo.id, requestInfo.created); }
var _startTime = DateTime.UtcNow;
int apiError = 0;
try
{
if (infoClass == enumFasdInformationClass.Ticket)
return new cF4sdConnectorIds() { Id = Identity.Id, infoClass = enumFasdInformationClass.Ticket };
if (infoClass == enumFasdInformationClass.VirtualSession)
return new cF4sdConnectorIds() { Id = Identity.Id, infoClass = enumFasdInformationClass.VirtualSession };
if (infoClass == enumFasdInformationClass.MobileDevice)
return new cF4sdConnectorIds() { Id = Identity.Id, infoClass = enumFasdInformationClass.MobileDevice };
int apiError = 0;
if (!DataHistorySqlHelper.GetWellKnownSqlStatement($"GetConnectorIdsFrom{infoClass}Id", out var Query))
return null;
@@ -5516,6 +5548,66 @@ namespace C4IT.DataHistoryProvider
return RetVal;
}
}
catch (Exception E)
{
LogException(E);
}
finally
{
if (cPerformanceLogger.IsActive && requestInfo != null) { cPerformanceLogger.LogPerformanceEnd(LogDeep, CM, requestInfo.id, requestInfo.created, _startTime); }
if (CM != null) LogMethodEnd(CM);
}
return null;
}
private async Task<cF4sdConnectorIds> getConnectorIds(enumFasdInformationClass infoClass, cF4sdIdentityEntry Identity, CancellationToken Token, cF4sdWebRequestInfo requestInfo, int LogDeep, bool WithLocalAccounts = false)
{
MethodBase CM = null; if (cLogManager.DefaultLogger.IsDebug) { CM = MethodBase.GetCurrentMethod(); LogMethodBegin(CM); }
if (cPerformanceLogger.IsActive && requestInfo != null) { if (CM == null) CM = MethodBase.GetCurrentMethod(); cPerformanceLogger.LogPerformanceStart(LogDeep, CM, requestInfo.id, requestInfo.created); }
var _startTime = DateTime.UtcNow;
try
{
if (infoClass == enumFasdInformationClass.Ticket)
return new cF4sdConnectorIds() { Id = Identity.Id, infoClass = enumFasdInformationClass.Ticket };
if (infoClass == enumFasdInformationClass.VirtualSession)
return new cF4sdConnectorIds() { Id = Identity.Id, infoClass = enumFasdInformationClass.VirtualSession };
if (infoClass == enumFasdInformationClass.MobileDevice)
return new cF4sdConnectorIds() { Id = Identity.Id, infoClass = enumFasdInformationClass.MobileDevice };
var lstTasks = new List<Task>();
var _mainTask = getConnectorIdsFromDb(infoClass, Identity, Token, requestInfo, LogDeep + 1, WithLocalAccounts);
lstTasks.Add(_mainTask);
var _needForeignUserInfo = false;
if (infoClass == enumFasdInformationClass.User)
foreach (var module in this.Connectors.Values)
_needForeignUserInfo |= module.UseForeignUserIds();
Task<cForeignUserInfo> _userInfoTask = null;
if (_needForeignUserInfo)
{
_userInfoTask = GetForeignUserInfoAsync(Identity.Id, Token);
lstTasks.Add(_userInfoTask);
}
await Task.WhenAll(lstTasks);
var _result = _mainTask.Result;
if (_result == null)
return null;
if (_needForeignUserInfo)
_result.ForeignUserInfo = _userInfoTask?.Result;
return _result;
}
catch (Exception E)
{
@@ -6431,12 +6523,6 @@ namespace C4IT.DataHistoryProvider
return true;
}
public override async Task<List<object>> GetIds(cF4sdConnectorIds IdEntry, enumFasdInformationClass InfoClass, cF4sdWebRequestInfo requestInfo, int LogDeep)
{
await Task.CompletedTask;
return new List<object>() { IdEntry.Id };
}
public static string getStingList(List<string> L)
{
if (L == null || L.Count == 0)
@@ -6495,7 +6581,7 @@ namespace C4IT.DataHistoryProvider
return null;
}
public cCollectorStatusInfo GetCollectorStatus(cF4sdWebRequestInfo requestInfo)
public cCollectorStatusInfo CheckCollectorStatus(cF4sdWebRequestInfo requestInfo)
{
MethodBase CM = null; if (cLogManager.DefaultLogger.IsDebug) { CM = MethodBase.GetCurrentMethod(); LogMethodBegin(CM); }
try
@@ -6598,11 +6684,100 @@ namespace C4IT.DataHistoryProvider
public HashSet<enumFasdInformationClass> GetSupportedInformationClasses() => new HashSet<enumFasdInformationClass>() { enumFasdInformationClass.User, enumFasdInformationClass.Computer };
public async Task<cF4sdStagedSearchResultRelations> GetRelationsAsync(IEnumerable<cF4sdIdentityEntry> ids, enumFasdInformationClass informationClass, int age, CancellationToken token = default)
public async Task<cF4sdStagedSearchResultRelations> GetRelationsAsync(IEnumerable<cF4sdConnectorIds> ids, enumFasdInformationClass informationClass, int age, CancellationToken token = default)
{
List<cF4sdApiSearchResultRelation> usageRelations = await GetUsageFromOnlyAgentData(informationClass, ids.Select(id => id.Id).ToList(), age, token, null, 1);
List<cF4sdApiSearchResultRelation> usageRelations = await GetUsageFromOnlyAgentData(ids, informationClass, age, token, null, 1);
return new cF4sdStagedSearchResultRelations() { Relations = usageRelations };
}
public void RegisterTokenValidator(cF4SDTokenRegistration.enumTokenType tokenType, cDataHistoryCollectorModule module)
{
_tokenValidators[tokenType] = module;
}
public async override Task<int> ValidateTokenInfo(cDataHistoryCollectorTokenCache.cTokenInfo tokenInfo)
{
if (_tokenValidators.TryGetValue(tokenInfo.type, out var module))
return await module.ValidateTokenInfo(tokenInfo);
return 0;
}
public class cForeignUserInfoEntry
{
public Guid MainID { get; set; }
public enumDataHistoryOrigin Origin { get; set; }
public string InstanceID { get; set; }
public string ForeignID { get; set; }
}
public class cForeignUserInfo : Dictionary<enumDataHistoryOrigin, List<cForeignUserInfoEntry>> { }
public async Task<cForeignUserInfo> GetForeignUserInfoAsync(Guid MainID, CancellationToken token)
{
MethodBase CM = null; if (cLogManager.DefaultLogger.IsDebug) { CM = MethodBase.GetCurrentMethod(); LogMethodBegin(CM); }
try
{
if (!DataHistorySqlHelper.GetWellKnownSqlStatement("GetForeignUserInfo", out var QueryGetUser))
return null;
using (var Conn = new cDbConnection(Collector.mainDbConnection))
{
if (!Conn.IsOpen)
{
LogEntry($"Could not open main sql database '{Collector.mainDbConnection.Database}', aborting scan...'", LogLevels.Error);
return null;
}
var Params = new Dictionary<string, object>() { { "@ID", MainID } };
using (var Reader = await DataHistorySqlHelper.GetDataReaderAsync(Conn, QueryGetUser, Params, token))
{
if (Reader != null)
if (Reader.HasRows)
{
var _retVal = new cForeignUserInfo();
while (await Reader.ReadAsync(token))
{
try
{
var Entry = new cForeignUserInfoEntry();
Entry.MainID = Reader.GetGuid(0);
Entry.Origin = (enumDataHistoryOrigin)Reader.GetInt32(1);
Entry.InstanceID = Reader.GetString(2);
Entry.ForeignID = Reader.GetString(3);
if (!_retVal.TryGetValue(Entry.Origin, out var _lst))
{
_lst = new List<cForeignUserInfoEntry>();
_retVal.Add(Entry.Origin, _lst);
}
_lst.Add(Entry);
}
catch (Exception E)
{
cLogManager.DefaultLogger.LogException(E);
}
}
return _retVal;
}
}
}
}
catch (Exception E)
{
LogException(E);
}
finally
{
if (CM != null) LogMethodEnd(CM);
}
return null;
}
}
public class cScanTimeInfo
@@ -6736,7 +6911,13 @@ namespace C4IT.DataHistoryProvider
public Guid tenantId { get; set; }
public Guid intuneId { get; set; }
public Guid azureDeviceId { get; set; }
public cDataHistoryCollector.cForeignUserInfo ForeignUserInfo = null;
[Obsolete]
public string citrixTenantId { get; set; }
[Obsolete]
public int? citrixUserId { get; set; }
}