Add student SGU profile synchronization

This commit is contained in:
2026-09-01 14:23:20 -06:00
parent 3b5cf723cc
commit fd3eb537a1
21 changed files with 345 additions and 43 deletions
@@ -3,16 +3,28 @@ namespace SGU.AuthBroker.Core.Profiles;
public sealed record InstitutionalProfile(
string? EmployeeNumber = null,
string? DisplayName = null,
string? GivenName = null,
string? Surname = null,
string? Email = null,
string? EmployeeType = null,
string? JobTitle = null,
string? Department = null)
string? Department = null,
string? StreetAddress = null,
string? City = null,
string? State = null,
string? PostalCode = null)
{
public bool HasValues =>
EmployeeNumber is not null ||
DisplayName is not null ||
GivenName is not null ||
Surname is not null ||
Email is not null ||
EmployeeType is not null ||
JobTitle is not null ||
Department is not null;
Department is not null ||
StreetAddress is not null ||
City is not null ||
State is not null ||
PostalCode is not null;
}
@@ -12,6 +12,18 @@ public static class SguProfileParser
private const string JobTitleId = "ctl00_contenedor_decEncabezado_lblPuesto";
private const string DepartmentId = "ctl00_contenedor_decEncabezado_lblDependencia";
private const string MenuNameId = "ctl00_lblNombreUsuario";
private const string StudentNumberId = "ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP";
private const string StudentGivenNameId = "ctl00_contenedor_HistorialAlumno1_lblNombreAlumnoHP";
private const string StudentPaternalSurnameId = "ctl00_contenedor_HistorialAlumno1_lblApPatAlumnoHP";
private const string StudentMaternalSurnameId = "ctl00_contenedor_HistorialAlumno1_lblApMatAlumnoHP";
private const string StudentEmailId = "ctl00_contenedor_HistorialAlumno1_lblCorreoAlumnoHP";
private const string StudentCareerId = "ctl00_contenedor_HistorialAlumno1_Header1_lblCarrera";
private const string StudentStreetId = "ctl00_contenedor_HistorialAlumno1_lblDomicilioAlumnoHP";
private const string StudentNeighborhoodId = "ctl00_contenedor_HistorialAlumno1_lblColoniaAlumnoHP";
private const string StudentStateId = "ctl00_contenedor_HistorialAlumno1_lblEstadoAlumnoHP";
private const string StudentCityId = "ctl00_contenedor_HistorialAlumno1_lblCiudadAlumnoHP";
private const string StudentMunicipalityId = "ctl00_contenedor_HistorialAlumno1_lblDeloMunAlumnoHP";
private const string StudentPostalCodeId = "ctl00_contenedor_HistorialAlumno1_lblCPAlumnoHP";
public static InstitutionalProfile? ParseAdministrative(string html, string expectedEmployeeNumber)
{
@@ -44,6 +56,44 @@ public static class SguProfileParser
return profile.HasValues ? profile : null;
}
public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber)
{
ArgumentNullException.ThrowIfNull(html);
ArgumentException.ThrowIfNullOrWhiteSpace(expectedStudentNumber);
string? studentNumber = NormalizeInstitutionalNumber(ExtractSpanText(html, StudentNumberId));
if (!string.Equals(studentNumber, expectedStudentNumber, StringComparison.Ordinal))
{
return null;
}
string? givenName = NormalizeName(ExtractSpanText(html, StudentGivenNameId), 64);
string? paternalSurname = NormalizeSurname(ExtractSpanText(html, StudentPaternalSurnameId), 64);
string? maternalSurname = NormalizeSurname(ExtractSpanText(html, StudentMaternalSurnameId), 64);
string? surname = NormalizeSurname(JoinNonEmpty(" ", paternalSurname, maternalSurname), 64);
string? displayName = NormalizeName(JoinNonEmpty(" ", givenName, surname), 256);
string? street = NormalizeTitle(ExtractSpanText(html, StudentStreetId), 512);
string? neighborhood = NormalizeTitle(ExtractSpanText(html, StudentNeighborhoodId), 256);
string? city = NormalizeTitle(ExtractSpanText(html, StudentCityId), 128);
string? municipality = NormalizeTitle(ExtractSpanText(html, StudentMunicipalityId), 128);
string? streetAddress = BuildStreetAddress(street, neighborhood, municipality, city);
InstitutionalProfile profile = new(
EmployeeNumber: studentNumber,
DisplayName: displayName,
GivenName: givenName,
Surname: surname,
Email: NormalizeEmail(ExtractSpanText(html, StudentEmailId)),
EmployeeType: "Alumno",
JobTitle: BuildStudentJobTitle(ExtractSpanText(html, StudentCareerId)),
StreetAddress: streetAddress,
City: city ?? municipality,
State: NormalizeTitle(ExtractSpanText(html, StudentStateId), 128),
PostalCode: NormalizePostalCode(ExtractSpanText(html, StudentPostalCodeId)));
return profile.HasValues ? profile : null;
}
private static bool TrySplitAdministrativeIdentity(
string? value,
out string? employeeNumber,
@@ -176,6 +226,104 @@ public static class SguProfileParser
return candidate is null ? null : SpanishTextNormalizer.ToTitleCase(candidate);
}
private static string? NormalizeName(string? value, int maximumLength)
{
string? candidate = Limit(value, maximumLength);
return candidate is null ? null : SpanishTextNormalizer.ToTitleCase(candidate);
}
private static string? NormalizeSurname(string? value, int maximumLength)
{
string? candidate = Limit(value, maximumLength);
return candidate is null ? null : SpanishTextNormalizer.ToSurnameCase(candidate);
}
private static string? NormalizeInstitutionalNumber(string? value)
{
string? candidate = value?.Trim();
return candidate is { Length: 6 } && candidate.All(char.IsAsciiDigit)
? candidate
: null;
}
private static string? BuildStudentJobTitle(string? value)
{
string? candidate = Limit(value, 256);
if (candidate is null)
{
return null;
}
candidate = candidate.Replace(
"COMPUTACIO NALES",
"COMPUTACIONALES",
StringComparison.OrdinalIgnoreCase);
string career = SpanishTextNormalizer.ToTitleCase(candidate);
const string degreePrefix = "Licenciatura en ";
if (career.StartsWith(degreePrefix, StringComparison.OrdinalIgnoreCase))
{
career = career[degreePrefix.Length..];
}
return TruncateAtWordBoundary($"Estudiante de {career}", 64);
}
private static string? BuildStreetAddress(
string? street,
string? neighborhood,
string? municipality,
string? city)
{
List<string> lines = [];
AddDistinct(lines, street);
AddDistinct(lines, neighborhood);
if (!string.Equals(municipality, city, StringComparison.OrdinalIgnoreCase))
{
AddDistinct(lines, municipality);
}
return Limit(string.Join("\r\n", lines), 1024);
}
private static void AddDistinct(List<string> values, string? candidate)
{
if (!string.IsNullOrWhiteSpace(candidate) &&
!values.Contains(candidate, StringComparer.OrdinalIgnoreCase))
{
values.Add(candidate);
}
}
private static string? NormalizePostalCode(string? value)
{
string? candidate = value?.Trim();
if (candidate is null ||
candidate.Length is < 4 or > 5 ||
!candidate.All(char.IsAsciiDigit))
{
return null;
}
return candidate.PadLeft(5, '0');
}
private static string? JoinNonEmpty(string separator, params string?[] values)
{
string result = string.Join(separator, values.Where(value => !string.IsNullOrWhiteSpace(value)));
return string.IsNullOrWhiteSpace(result) ? null : result;
}
private static string TruncateAtWordBoundary(string value, int maximumLength)
{
if (value.Length <= maximumLength)
{
return value;
}
int boundary = value.LastIndexOf(' ', maximumLength - 1, maximumLength);
return value[..(boundary > 0 ? boundary : maximumLength)].TrimEnd();
}
private static string? NormalizeSentence(string? value, int maximumLength)
{
string? candidate = Limit(value, maximumLength);
@@ -15,6 +15,16 @@ public static class SpanishTextNormalizer
StringComparer.OrdinalIgnoreCase);
public static string ToTitleCase(string value)
{
return ToTitleCase(value, lowercaseLeadingParticle: false);
}
public static string ToSurnameCase(string value)
{
return ToTitleCase(value, lowercaseLeadingParticle: true);
}
private static string ToTitleCase(string value, bool lowercaseLeadingParticle)
{
ArgumentNullException.ThrowIfNull(value);
@@ -23,7 +33,7 @@ public static class SpanishTextNormalizer
{
string lowercase = words[index].ToLower(SpanishCulture);
string comparisonToken = lowercase.Trim('(', ')', '[', ']', '{', '}', ',', '.', ';', ':');
words[index] = index > 0 && LowercaseParticles.Contains(comparisonToken)
words[index] = (index > 0 || lowercaseLeadingParticle) && LowercaseParticles.Contains(comparisonToken)
? lowercase
: CapitalizeCompound(lowercase);
}
+10 -2
View File
@@ -41,7 +41,12 @@ public sealed class BrokerOptions
throw new InvalidOperationException("The SGU profile response limit is outside the supported range.");
}
foreach (string profilePath in new[] { Ntlm.AdministrativeProfilePath, Ntlm.MenuProfilePath })
foreach (string profilePath in new[]
{
Ntlm.AdministrativeProfilePath,
Ntlm.StudentProfilePath,
Ntlm.MenuProfilePath
})
{
if (string.IsNullOrWhiteSpace(profilePath))
{
@@ -112,13 +117,16 @@ public sealed class NtlmOptions
public string Domain { get; init; } = string.Empty;
public int TimeoutSeconds { get; init; } = 15;
public int TimeoutSeconds { get; init; } = 30;
public int MaxRedirects { get; init; } = 5;
public string AdministrativeProfilePath { get; init; } =
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx";
public string StudentProfilePath { get; init; } =
"/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx";
public string MenuProfilePath { get; init; } = "/psulsa/menu.aspx";
public int MaxProfileBytes { get; init; } = 512 * 1024;
@@ -128,10 +128,16 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
if (profile is not null)
{
SetOptionalProperty(user, "displayName", profile.DisplayName);
SetOptionalProperty(user, "givenName", profile.GivenName);
SetOptionalProperty(user, "sn", profile.Surname);
SetOptionalProperty(user, "mail", profile.Email);
SetOptionalProperty(user, "title", profile.JobTitle);
SetOptionalProperty(user, "department", profile.Department);
SetOptionalProperty(user, "employeeType", profile.EmployeeType);
SetOptionalProperty(user, "streetAddress", profile.StreetAddress);
SetOptionalProperty(user, "l", profile.City);
SetOptionalProperty(user, "st", profile.State);
SetOptionalProperty(user, "postalCode", profile.PostalCode);
if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal))
{
SetOptionalProperty(user, "employeeID", profile.EmployeeNumber);
@@ -133,9 +133,13 @@ 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;
string path = role switch
{
InstitutionalRole.Administrative => options.AdministrativeProfilePath,
InstitutionalRole.Student => options.StudentProfilePath,
InstitutionalRole.Professor => options.MenuProfilePath,
_ => throw new ArgumentOutOfRangeException(nameof(role), role, null)
};
return new Uri(endpoint, path);
}
@@ -151,10 +155,17 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
response.Content,
options.MaxProfileBytes,
timeoutToken).ConfigureAwait(false);
return identity.Role == InstitutionalRole.Administrative
? SguProfileParser.ParseAdministrative(html, identity.NumericId) ??
SguProfileParser.ParseMenu(html)
: SguProfileParser.ParseMenu(html);
return identity.Role switch
{
InstitutionalRole.Administrative =>
SguProfileParser.ParseAdministrative(html, identity.NumericId) ??
SguProfileParser.ParseMenu(html),
InstitutionalRole.Student =>
SguProfileParser.ParseStudent(html, identity.NumericId) ??
SguProfileParser.ParseMenu(html),
InstitutionalRole.Professor => SguProfileParser.ParseMenu(html),
_ => null
};
}
catch (OperationCanceledException) when (requestCancellationToken.IsCancellationRequested)
{
+2 -1
View File
@@ -29,9 +29,10 @@
"Ntlm": {
"Endpoint": "https://sgu.ulsa.edu.mx/",
"Domain": "",
"TimeoutSeconds": 15,
"TimeoutSeconds": 30,
"MaxRedirects": 5,
"AdministrativeProfilePath": "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
"StudentProfilePath": "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
"MenuProfilePath": "/psulsa/menu.aspx",
"MaxProfileBytes": 524288,
"AllowedRedirectHosts": [
@@ -10,7 +10,7 @@ internal sealed class ProviderSettings
public string DomainNetbios { get; init; } = "LCI";
public int TimeoutSeconds { get; init; } = 20;
public int TimeoutSeconds { get; init; } = 35;
public string ClientCertificateThumbprint { get; init; } = string.Empty;
@@ -1,7 +1,7 @@
{
"BrokerEndpoint": "https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate",
"DomainNetbios": "LCI",
"TimeoutSeconds": 20,
"TimeoutSeconds": 35,
"ClientCertificateThumbprint": "0000000000000000000000000000000000000000",
"ServerCertificateThumbprint": "0000000000000000000000000000000000000000"
}