Resolve SGU staff addresses and provision local student user

This commit is contained in:
2026-09-09 09:48:06 -06:00
parent 8baa47fe1e
commit a6bd625e4e
15 changed files with 728 additions and 28 deletions
@@ -1,5 +1,7 @@
using System.Diagnostics;
using System.Net;
using System.Text;
using System.Text.Json;
using SGU.AuthBroker.Core.Authentication;
using SGU.AuthBroker.Core.Identity;
using SGU.AuthBroker.Core.Profiles;
@@ -431,9 +433,6 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
pages.Add((
options.AdministrativePersonalProfilePath,
SguProfileParser.ParseAdministrativePersonal));
pages.Add((
options.AdministrativeLocationProfilePath,
SguProfileParser.ParseAdministrativeLocation));
foreach ((string path, Func<string, InstitutionalProfile?> parser) in pages)
{
@@ -474,7 +473,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
"SGU optional staff profile enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
identity.Role,
elapsed.ElapsedMilliseconds);
break;
return profile;
}
catch (Exception exception)
{
@@ -487,6 +486,142 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
}
}
return await TryEnrichStaffLocationAsync(
client,
profile,
identity,
allowedHosts,
timeoutToken,
requestCancellationToken,
elapsed).ConfigureAwait(false);
}
private async Task<InstitutionalProfile> TryEnrichStaffLocationAsync(
HttpClient client,
InstitutionalProfile profile,
UserIdentity identity,
HashSet<string> allowedHosts,
CancellationToken timeoutToken,
CancellationToken requestCancellationToken,
Stopwatch elapsed)
{
string path = options.AdministrativeLocationProfilePath;
Uri locationPageUri = GetProfileUri(path);
try
{
string? html = await TryFetchAdditionalProfilePageAsync(
client,
locationPageUri,
allowedHosts,
timeoutToken).ConfigureAwait(false);
if (html is null)
{
logger.LogWarning(
BrokerEventIds.ProfilePageUnavailable,
"Optional SGU profile page {Path} did not return usable HTML for role {Role}; preserving fields already collected.",
path,
identity.Role);
return profile;
}
InstitutionalProfile? staticLocation = SguProfileParser.ParseAdministrativeLocation(html);
if (staticLocation is null)
{
logger.LogWarning(
BrokerEventIds.ProfileHtmlUnexpected,
"Optional SGU profile page {Path} returned HTML without its supported field IDs for role {Role}; preserving fields already collected.",
path,
identity.Role);
return profile;
}
profile = profile.Overlay(staticLocation);
if (string.IsNullOrWhiteSpace(staticLocation.PostalCode))
{
return profile;
}
string? directionJson = await TryPostProfilePageMethodAsync(
client,
GetAdministrativeLocationMethodUri("GetDireccion"),
locationPageUri,
new Dictionary<string, string>
{
["CodigoPostal"] = staticLocation.PostalCode
},
allowedHosts,
timeoutToken).ConfigureAwait(false);
if (directionJson is null)
{
return profile;
}
SguAdministrativeLocationSelection? selection =
SguProfileParser.ParseAdministrativeLocationSelection(
directionJson,
staticLocation.PostalCode);
if (selection is null)
{
logger.LogWarning(
BrokerEventIds.ProfileHtmlUnexpected,
"SGU location method GetDireccion returned an unexpected payload for role {Role}; preserving the static address fields.",
identity.Role);
return profile;
}
string? localitiesJson = null;
if (!string.IsNullOrWhiteSpace(selection.StateId))
{
localitiesJson = await TryPostProfilePageMethodAsync(
client,
GetAdministrativeLocationMethodUri("GetLocalidadListado"),
locationPageUri,
new Dictionary<string, string>
{
["pIdEstado"] = selection.StateId
},
allowedHosts,
timeoutToken).ConfigureAwait(false);
}
string? neighborhoodsJson = await TryPostProfilePageMethodAsync(
client,
GetAdministrativeLocationMethodUri("GetColoniasListado"),
locationPageUri,
new Dictionary<string, string>
{
["pIdEstado"] = string.Empty,
["pLocalidad"] = string.Empty,
["CodigoPostal"] = selection.PostalCode ?? staticLocation.PostalCode
},
allowedHosts,
timeoutToken).ConfigureAwait(false);
InstitutionalProfile? resolvedLocation = SguProfileParser.ParseAdministrativeLocation(
html,
selection,
localitiesJson,
neighborhoodsJson);
return profile.Overlay(resolvedLocation);
}
catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
{
logger.LogWarning(
BrokerEventIds.ProfileEnrichmentTimeout,
"SGU optional staff location enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
identity.Role,
elapsed.ElapsedMilliseconds);
}
catch (Exception exception)
{
logger.LogWarning(
BrokerEventIds.ProfileEnrichmentFailure,
exception,
"SGU optional staff location enrichment failed for role {Role} after {ElapsedMilliseconds} ms; preserving fields already collected.",
identity.Role,
elapsed.ElapsedMilliseconds);
}
return profile;
}
@@ -545,6 +680,46 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
return null;
}
private async Task<string?> TryPostProfilePageMethodAsync(
HttpClient client,
Uri requestedUri,
Uri referrerUri,
IReadOnlyDictionary<string, string> payload,
HashSet<string> allowedHosts,
CancellationToken cancellationToken)
{
if (!IsAllowedHttpsUri(requestedUri, allowedHosts) ||
!IsAllowedHttpsUri(referrerUri, allowedHosts))
{
return null;
}
using HttpRequestMessage request = new(HttpMethod.Post, requestedUri);
request.Headers.Referrer = referrerUri;
request.Content = new StringContent(
JsonSerializer.Serialize(payload),
Encoding.UTF8,
"application/json");
using HttpResponseMessage response = await client
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
int statusCode = (int)response.StatusCode;
if (statusCode is >= 200 and < 300)
{
return await ReadLimitedStringAsync(
response.Content,
options.MaxProfileBytes,
cancellationToken).ConfigureAwait(false);
}
logger.LogWarning(
BrokerEventIds.ProfilePageUnavailable,
"Optional SGU profile method {Path} returned HTTP {StatusCode}.",
requestedUri.AbsolutePath,
statusCode);
return null;
}
private static void AddCredential(
Uri uri,
CredentialCache credentialCache,
@@ -658,6 +833,12 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
private Uri GetProfileUri(string path) =>
new(new Uri(options.Endpoint, UriKind.Absolute), path);
private Uri GetAdministrativeLocationMethodUri(string methodName)
{
Uri pageUri = GetProfileUri(options.AdministrativeLocationProfilePath);
return new Uri($"{pageUri.GetLeftPart(UriPartial.Path).TrimEnd('/')}/{methodName}");
}
private async Task<InstitutionalProfile?> TryReadProfileAsync(
HttpResponseMessage response,
UserIdentity identity,