Enrich AD users from SGU profile metadata
This commit is contained in:
@@ -23,7 +23,7 @@ public sealed class AuthenticationWorkflow(
|
||||
try
|
||||
{
|
||||
validation = await ntlmValidator
|
||||
.ValidateAsync(identity.UserName, password, cancellationToken)
|
||||
.ValidateAsync(identity, password, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
@@ -57,7 +57,7 @@ public sealed class AuthenticationWorkflow(
|
||||
try
|
||||
{
|
||||
DirectorySyncResult directory = await directorySynchronizer
|
||||
.SynchronizeAsync(identity, password, cancellationToken)
|
||||
.SynchronizeAsync(identity, validation.Profile, password, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new AuthenticationFlowResult(
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using SGU.AuthBroker.Core.Identity;
|
||||
|
||||
namespace SGU.AuthBroker.Core.Authentication;
|
||||
|
||||
public interface INtlmCredentialValidator
|
||||
{
|
||||
Task<NtlmValidationResult> ValidateAsync(
|
||||
string userName,
|
||||
UserIdentity identity,
|
||||
string password,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using SGU.AuthBroker.Core.Profiles;
|
||||
|
||||
namespace SGU.AuthBroker.Core.Authentication;
|
||||
|
||||
public enum NtlmValidationStatus
|
||||
@@ -7,9 +9,13 @@ public enum NtlmValidationStatus
|
||||
Unavailable
|
||||
}
|
||||
|
||||
public sealed record NtlmValidationResult(NtlmValidationStatus Status, string? ErrorCode = null)
|
||||
public sealed record NtlmValidationResult(
|
||||
NtlmValidationStatus Status,
|
||||
string? ErrorCode = null,
|
||||
InstitutionalProfile? Profile = null)
|
||||
{
|
||||
public static NtlmValidationResult Valid() => new(NtlmValidationStatus.Valid);
|
||||
public static NtlmValidationResult Valid(InstitutionalProfile? profile = null) =>
|
||||
new(NtlmValidationStatus.Valid, Profile: profile);
|
||||
|
||||
public static NtlmValidationResult Invalid() => new(NtlmValidationStatus.Invalid, "INVALID_INSTITUTIONAL_CREDENTIALS");
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using SGU.AuthBroker.Core.Identity;
|
||||
using SGU.AuthBroker.Core.Profiles;
|
||||
|
||||
namespace SGU.AuthBroker.Core.Directory;
|
||||
|
||||
@@ -6,6 +7,7 @@ public interface IActiveDirectorySynchronizer
|
||||
{
|
||||
Task<DirectorySyncResult> SynchronizeAsync(
|
||||
UserIdentity identity,
|
||||
InstitutionalProfile? profile,
|
||||
string password,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace SGU.AuthBroker.Core.Profiles;
|
||||
|
||||
public sealed record InstitutionalProfile(
|
||||
string? EmployeeNumber = null,
|
||||
string? DisplayName = null,
|
||||
string? Email = null,
|
||||
string? EmployeeType = null,
|
||||
string? JobTitle = null,
|
||||
string? Department = null)
|
||||
{
|
||||
public bool HasValues =>
|
||||
EmployeeNumber is not null ||
|
||||
DisplayName is not null ||
|
||||
Email is not null ||
|
||||
EmployeeType is not null ||
|
||||
JobTitle is not null ||
|
||||
Department is not null;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using System.Text;
|
||||
|
||||
namespace SGU.AuthBroker.Core.Profiles;
|
||||
|
||||
public static class SguProfileParser
|
||||
{
|
||||
private const string AdministrativeNameId = "ctl00_contenedor_decEncabezado_lblNombre";
|
||||
private const string EmployeeTypeId = "ctl00_contenedor_decEncabezado_lblIndicadorValue";
|
||||
private const string EmailId = "ctl00_contenedor_decEncabezado_lblCorreo";
|
||||
private const string JobTitleId = "ctl00_contenedor_decEncabezado_lblPuesto";
|
||||
private const string DepartmentId = "ctl00_contenedor_decEncabezado_lblDependencia";
|
||||
private const string MenuNameId = "ctl00_lblNombreUsuario";
|
||||
|
||||
public static InstitutionalProfile? ParseAdministrative(string html, string expectedEmployeeNumber)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(html);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(expectedEmployeeNumber);
|
||||
|
||||
string? identity = ExtractSpanText(html, AdministrativeNameId);
|
||||
if (!TrySplitAdministrativeIdentity(identity, out string? employeeNumber, out string? displayName) ||
|
||||
!string.Equals(employeeNumber, expectedEmployeeNumber, StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
InstitutionalProfile profile = new(
|
||||
EmployeeNumber: employeeNumber,
|
||||
DisplayName: Limit(displayName, 256),
|
||||
Email: NormalizeEmail(ExtractSpanText(html, EmailId)),
|
||||
EmployeeType: Limit(ExtractSpanText(html, EmployeeTypeId), 256),
|
||||
JobTitle: Limit(ExtractSpanText(html, JobTitleId), 64),
|
||||
Department: Limit(ExtractSpanText(html, DepartmentId), 64));
|
||||
return profile.HasValues ? profile : null;
|
||||
}
|
||||
|
||||
public static InstitutionalProfile? ParseMenu(string html)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(html);
|
||||
|
||||
InstitutionalProfile profile = new(
|
||||
DisplayName: Limit(ExtractSpanText(html, MenuNameId), 256));
|
||||
return profile.HasValues ? profile : null;
|
||||
}
|
||||
|
||||
private static bool TrySplitAdministrativeIdentity(
|
||||
string? value,
|
||||
out string? employeeNumber,
|
||||
out string? displayName)
|
||||
{
|
||||
employeeNumber = null;
|
||||
displayName = null;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int separator = value.IndexOf(" - ", StringComparison.Ordinal);
|
||||
if (separator != 6)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string candidateNumber = value[..separator];
|
||||
string candidateName = value[(separator + 3)..].Trim();
|
||||
if (candidateNumber.Length != 6 ||
|
||||
!candidateNumber.All(char.IsAsciiDigit) ||
|
||||
string.IsNullOrWhiteSpace(candidateName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
employeeNumber = candidateNumber;
|
||||
displayName = candidateName;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? ExtractSpanText(string html, string id)
|
||||
{
|
||||
foreach (char quote in new[] { '"', '\'' })
|
||||
{
|
||||
string marker = $"id={quote}{id}{quote}";
|
||||
int searchFrom = 0;
|
||||
while (searchFrom < html.Length)
|
||||
{
|
||||
int idIndex = html.IndexOf(marker, searchFrom, StringComparison.OrdinalIgnoreCase);
|
||||
if (idIndex < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
int spanStart = html.LastIndexOf("<span", idIndex, StringComparison.OrdinalIgnoreCase);
|
||||
int precedingTagEnd = html.LastIndexOf('>', idIndex);
|
||||
if (spanStart > precedingTagEnd)
|
||||
{
|
||||
int openingTagEnd = html.IndexOf('>', idIndex);
|
||||
int closingTagStart = openingTagEnd < 0
|
||||
? -1
|
||||
: html.IndexOf("</span", openingTagEnd + 1, StringComparison.OrdinalIgnoreCase);
|
||||
if (openingTagEnd >= 0 && closingTagStart >= 0)
|
||||
{
|
||||
string innerHtml = html[(openingTagEnd + 1)..closingTagStart];
|
||||
return NormalizeText(innerHtml);
|
||||
}
|
||||
}
|
||||
|
||||
searchFrom = idIndex + marker.Length;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? NormalizeText(string htmlFragment)
|
||||
{
|
||||
StringBuilder withoutTags = new(htmlFragment.Length);
|
||||
bool insideTag = false;
|
||||
foreach (char character in htmlFragment)
|
||||
{
|
||||
if (character == '<')
|
||||
{
|
||||
insideTag = true;
|
||||
}
|
||||
else if (character == '>')
|
||||
{
|
||||
insideTag = false;
|
||||
}
|
||||
else if (!insideTag)
|
||||
{
|
||||
withoutTags.Append(character);
|
||||
}
|
||||
}
|
||||
|
||||
string decoded = WebUtility.HtmlDecode(withoutTags.ToString());
|
||||
StringBuilder normalized = new(decoded.Length);
|
||||
bool previousWasWhitespace = true;
|
||||
foreach (char character in decoded)
|
||||
{
|
||||
bool whitespace = char.IsWhiteSpace(character) || character == '\u00A0';
|
||||
if (whitespace)
|
||||
{
|
||||
if (!previousWasWhitespace)
|
||||
{
|
||||
normalized.Append(' ');
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
normalized.Append(character);
|
||||
}
|
||||
|
||||
previousWasWhitespace = whitespace;
|
||||
}
|
||||
|
||||
string result = normalized.ToString().Trim();
|
||||
return result.Length == 0 ? null : result;
|
||||
}
|
||||
|
||||
private static string? NormalizeEmail(string? value)
|
||||
{
|
||||
string? candidate = Limit(value, 256);
|
||||
if (candidate is null ||
|
||||
!MailAddress.TryCreate(candidate, out MailAddress? address) ||
|
||||
!string.Equals(address.Address, candidate, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return address.Address;
|
||||
}
|
||||
|
||||
private static string? Limit(string? value, int maximumLength)
|
||||
{
|
||||
string? candidate = value?.Trim();
|
||||
return string.IsNullOrEmpty(candidate) || candidate.Length > maximumLength
|
||||
? null
|
||||
: candidate;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,27 @@ public sealed class BrokerOptions
|
||||
throw new InvalidOperationException("NTLM timeout or redirect limits are outside the supported range.");
|
||||
}
|
||||
|
||||
if (Ntlm.MaxProfileBytes is < 32 * 1024 or > 2 * 1024 * 1024)
|
||||
{
|
||||
throw new InvalidOperationException("The SGU profile response limit is outside the supported range.");
|
||||
}
|
||||
|
||||
foreach (string profilePath in new[] { Ntlm.AdministrativeProfilePath, Ntlm.MenuProfilePath })
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(profilePath))
|
||||
{
|
||||
throw new InvalidOperationException("SGU profile paths are required.");
|
||||
}
|
||||
|
||||
Uri profileUri = new(endpoint, profilePath);
|
||||
if (profileUri.Scheme != Uri.UriSchemeHttps ||
|
||||
!string.IsNullOrEmpty(profileUri.UserInfo) ||
|
||||
!Ntlm.AllowedRedirectHosts.Contains(profileUri.IdnHost, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("SGU profile paths must resolve to an allowed HTTPS host.");
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Directory.LdapHost) ||
|
||||
string.IsNullOrWhiteSpace(Directory.BaseDn) ||
|
||||
string.IsNullOrWhiteSpace(Directory.DomainNetbios) ||
|
||||
@@ -83,6 +104,13 @@ public sealed class NtlmOptions
|
||||
|
||||
public int MaxRedirects { get; init; } = 5;
|
||||
|
||||
public string AdministrativeProfilePath { get; init; } =
|
||||
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx";
|
||||
|
||||
public string MenuProfilePath { get; init; } = "/psulsa/menu.aspx";
|
||||
|
||||
public int MaxProfileBytes { get; init; } = 512 * 1024;
|
||||
|
||||
public string[] AllowedRedirectHosts { get; init; } = ["sgu.ulsa.edu.mx"];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
|
||||
using System.DirectoryServices;
|
||||
using SGU.AuthBroker.Core.Directory;
|
||||
using SGU.AuthBroker.Core.Identity;
|
||||
using SGU.AuthBroker.Core.Profiles;
|
||||
using SGU.AuthBroker.Options;
|
||||
|
||||
namespace SGU.AuthBroker.Services;
|
||||
@@ -19,6 +20,7 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
||||
|
||||
public async Task<DirectorySyncResult> SynchronizeAsync(
|
||||
UserIdentity identity,
|
||||
InstitutionalProfile? profile,
|
||||
string password,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -27,7 +29,7 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
||||
try
|
||||
{
|
||||
return await Task.Run(
|
||||
() => Synchronize(identity, password),
|
||||
() => Synchronize(identity, profile, password),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
@@ -40,7 +42,10 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
||||
}
|
||||
}
|
||||
|
||||
private DirectorySyncResult Synchronize(UserIdentity identity, string password)
|
||||
private DirectorySyncResult Synchronize(
|
||||
UserIdentity identity,
|
||||
InstitutionalProfile? profile,
|
||||
string password)
|
||||
{
|
||||
string targetOuDn = options.GetOuDn(identity.Role);
|
||||
using DirectoryEntry root = Bind(options.BaseDn);
|
||||
@@ -95,6 +100,8 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
||||
user.Properties["pwdLastSet"].Value = -1;
|
||||
user.CommitChanges();
|
||||
|
||||
TryApplyProfile(user, identity, profile);
|
||||
|
||||
return new DirectorySyncResult(
|
||||
options.DomainNetbios,
|
||||
identity.UserName,
|
||||
@@ -108,6 +115,54 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryApplyProfile(
|
||||
DirectoryEntry user,
|
||||
UserIdentity identity,
|
||||
InstitutionalProfile? profile)
|
||||
{
|
||||
if (profile is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
SetOptionalProperty(user, "displayName", profile.DisplayName);
|
||||
SetOptionalProperty(user, "mail", profile.Email);
|
||||
SetOptionalProperty(user, "title", profile.JobTitle);
|
||||
SetOptionalProperty(user, "department", profile.Department);
|
||||
SetOptionalProperty(user, "employeeType", profile.EmployeeType);
|
||||
if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal))
|
||||
{
|
||||
SetOptionalProperty(user, "employeeID", profile.EmployeeNumber);
|
||||
}
|
||||
|
||||
user.CommitChanges();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Metadata is intentionally best-effort. User creation, password sync,
|
||||
// and account enablement have already committed successfully.
|
||||
try
|
||||
{
|
||||
user.RefreshCache();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Discarding the optional property cache must not alter the
|
||||
// already committed password synchronization result.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetOptionalProperty(DirectoryEntry entry, string propertyName, string? value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
entry.Properties[propertyName].Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
private DirectoryEntry BindOrCreateOu(string ouDn, DirectoryEntry root)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using SGU.AuthBroker.Core.Authentication;
|
||||
using SGU.AuthBroker.Core.Identity;
|
||||
using SGU.AuthBroker.Core.Profiles;
|
||||
using SGU.AuthBroker.Options;
|
||||
|
||||
namespace SGU.AuthBroker.Services;
|
||||
@@ -9,29 +12,31 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
|
||||
private readonly NtlmOptions options = options.Ntlm;
|
||||
|
||||
public async Task<NtlmValidationResult> ValidateAsync(
|
||||
string userName,
|
||||
UserIdentity identity,
|
||||
string password,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Uri current = new(this.options.Endpoint, UriKind.Absolute);
|
||||
Uri current = GetProfileUri(identity.Role);
|
||||
HashSet<string> allowedHosts = new(
|
||||
this.options.AllowedRedirectHosts,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
NetworkCredential credential = new(userName, password, this.options.Domain);
|
||||
NetworkCredential credential = new(identity.UserName, password, this.options.Domain);
|
||||
CredentialCache credentialCache = new();
|
||||
HashSet<string> credentialedAuthorities = new(StringComparer.OrdinalIgnoreCase);
|
||||
CookieContainer cookieContainer = new();
|
||||
|
||||
using HttpClientHandler handler = new()
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
CheckCertificateRevocationList = true,
|
||||
CookieContainer = cookieContainer,
|
||||
Credentials = credentialCache,
|
||||
MaxConnectionsPerServer = 4,
|
||||
MaxResponseHeadersLength = 64,
|
||||
PreAuthenticate = false,
|
||||
UseCookies = false,
|
||||
UseCookies = true,
|
||||
UseDefaultCredentials = false,
|
||||
UseProxy = false
|
||||
};
|
||||
@@ -104,9 +109,17 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
|
||||
continue;
|
||||
}
|
||||
|
||||
return statusCode is >= 200 and < 300
|
||||
? NtlmValidationResult.Valid()
|
||||
: NtlmValidationResult.Invalid();
|
||||
if (statusCode is >= 200 and < 300)
|
||||
{
|
||||
InstitutionalProfile? profile = await TryReadProfileAsync(
|
||||
response,
|
||||
identity,
|
||||
timeout.Token,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return NtlmValidationResult.Valid(profile);
|
||||
}
|
||||
|
||||
return NtlmValidationResult.Invalid();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +131,93 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
|
||||
}
|
||||
}
|
||||
|
||||
private Uri GetProfileUri(InstitutionalRole role)
|
||||
{
|
||||
Uri endpoint = new(options.Endpoint, UriKind.Absolute);
|
||||
string path = role == InstitutionalRole.Administrative
|
||||
? options.AdministrativeProfilePath
|
||||
: options.MenuProfilePath;
|
||||
return new Uri(endpoint, path);
|
||||
}
|
||||
|
||||
private async Task<InstitutionalProfile?> TryReadProfileAsync(
|
||||
HttpResponseMessage response,
|
||||
UserIdentity identity,
|
||||
CancellationToken timeoutToken,
|
||||
CancellationToken requestCancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
string html = await ReadLimitedStringAsync(
|
||||
response.Content,
|
||||
options.MaxProfileBytes,
|
||||
timeoutToken).ConfigureAwait(false);
|
||||
return identity.Role == InstitutionalRole.Administrative
|
||||
? SguProfileParser.ParseAdministrative(html, identity.NumericId) ??
|
||||
SguProfileParser.ParseMenu(html)
|
||||
: SguProfileParser.ParseMenu(html);
|
||||
}
|
||||
catch (OperationCanceledException) when (requestCancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Profile enrichment is optional. A successful NTLM response must still
|
||||
// synchronize the exact password even if SGU changes its presentation HTML.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLimitedStringAsync(
|
||||
HttpContent content,
|
||||
int maximumBytes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (content.Headers.ContentLength is long contentLength && contentLength > maximumBytes)
|
||||
{
|
||||
throw new InvalidDataException("The SGU profile response exceeded the configured limit.");
|
||||
}
|
||||
|
||||
await using Stream stream = await content
|
||||
.ReadAsStreamAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
using MemoryStream buffer = new(Math.Min(maximumBytes, 64 * 1024));
|
||||
byte[] chunk = new byte[8192];
|
||||
while (true)
|
||||
{
|
||||
int read = await stream
|
||||
.ReadAsync(chunk.AsMemory(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (buffer.Length + read > maximumBytes)
|
||||
{
|
||||
throw new InvalidDataException("The SGU profile response exceeded the configured limit.");
|
||||
}
|
||||
|
||||
buffer.Write(chunk, 0, read);
|
||||
}
|
||||
|
||||
string? charset = content.Headers.ContentType?.CharSet?.Trim('"', '\'');
|
||||
Encoding encoding;
|
||||
try
|
||||
{
|
||||
encoding = string.IsNullOrWhiteSpace(charset)
|
||||
? Encoding.UTF8
|
||||
: Encoding.GetEncoding(charset);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
encoding = Encoding.UTF8;
|
||||
}
|
||||
|
||||
return encoding.GetString(buffer.GetBuffer(), 0, checked((int)buffer.Length));
|
||||
}
|
||||
|
||||
private static bool IsAllowedHttpsUri(Uri uri, HashSet<string> allowedHosts) =>
|
||||
uri.Scheme == Uri.UriSchemeHttps &&
|
||||
string.IsNullOrEmpty(uri.UserInfo) &&
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
"Domain": "",
|
||||
"TimeoutSeconds": 15,
|
||||
"MaxRedirects": 5,
|
||||
"AdministrativeProfilePath": "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
|
||||
"MenuProfilePath": "/psulsa/menu.aspx",
|
||||
"MaxProfileBytes": 524288,
|
||||
"AllowedRedirectHosts": [
|
||||
"sgu.ulsa.edu.mx"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user