Compare commits

..
2 Commits
Author SHA1 Message Date
alexrg 991fc70600 Support stored Gitea credentials for releases 2026-09-08 12:16:33 -06:00
alexrg 93871b62b0 Adapt welcome wallpaper to SGU gender 2026-09-08 12:07:43 -06:00
12 changed files with 384 additions and 30 deletions
+8 -3
View File
@@ -27,8 +27,8 @@ accepted it. The broker then makes separately bounded, best-effort profile GETs.
It uses the administrative incident overview for `AD`, the student information
page for `AL`, and the portal menu as a conservative base for `DO`. After the
incident page confirms an `AD` employee number, two additional GETs in the same
in-memory session read the structured name from `datos/personales.aspx` and the
address from `datos/ubicacion.aspx`. Docentes request
in-memory session read the structured name and selected sex from
`datos/personales.aspx` and the address from `datos/ubicacion.aspx`. Docentes request
`nomina/consultanomina.aspx` for a matching employee number, email, employee
type and job title, then attempt the same two shared staff modules without
requiring any optional route to exist. A supplemental
@@ -61,7 +61,9 @@ passes the submitted password directly to ADSI `SetPassword`.
When the authenticated HTML exposes recognized stable IDs, the broker also
updates the applicable `displayName`, `givenName`, `sn`, `mail`, `title`,
`department`, `employeeType`, `employeeID`, `streetAddress`, `l`, `st`, and
`postalCode` attributes. Administrative and student numbers must match the six
`postalCode` attributes. The SGU sex value is normalized to `Male`/`Female` and
written as the managed `SGU-Gender:` line in the built-in `info` attribute while
preserving unrelated notes. Administrative and student numbers must match the six
numeric digits of the requested identity before any role-specific metadata is
trusted. Administrative personal and location pages are accepted only after
that incident-page match. Docente payroll metadata must match the requested
@@ -118,6 +120,9 @@ settings for managed clients.
That computer GPO also owns the base lock-screen image and a per-logon command
for the personalized desktop wallpaper. The client-side renderer reads the
managed `SGU-Gender: Male|Female` line from the user's built-in `info` attribute
(without requiring an irreversible AD schema extension). It uses neutral Spanish
when that optional enrichment is unavailable. The renderer also reads the
authenticated user's `displayName` plus the computer object's `location` and
immediate parent OU, then composes those values over the bundled dark-blue
background with the bundled Indivisa fonts. Missing directory attributes degrade
+8 -2
View File
@@ -16,14 +16,20 @@ El generador consulta Active Directory con la identidad ya autenticada y sin
guardar credenciales. Obtiene:
- `displayName` del usuario; si falta, utiliza `sAMAccountName`.
- La línea administrada `SGU-Gender: Male|Female` del atributo `info`; el Auth
Broker la obtiene del SGU y conserva cualquier otra nota que ya exista.
- `location` del objeto de equipo.
- La OU padre inmediata a partir de `distinguishedName`.
El texto secundario sigue estas reglas:
El saludo usa `Bienvenido/ubicado` para `Male` y `Bienvenida/ubicada` para
`Female`. Cuando el enriquecimiento no produjo este dato, utiliza la redacción
neutral `Te damos la bienvenida` y `Ubicación:`. El texto secundario sigue estas
reglas:
1. Con `location` y OU: `Estás ubicado en la Sala de Inmersión del Centro de Experiencia Digital.`
2. Con sólo uno de los datos: muestra únicamente el dato disponible.
3. Sin ambos: `Bienvenido al Laboratorio de Cómputo de Ingeniería.`
3. Sin ambos: el texto adaptado `Bienvenido/Bienvenida al Laboratorio...`; sin
sexo disponible, la forma neutral `Acceso al Laboratorio de Cómputo de Ingeniería.`
La ausencia de AD, de un atributo o de una tipografía nunca bloquea la sesión.
Los errores de generación se registran en
+18 -5
View File
@@ -27,7 +27,9 @@ foreach ($assetPath in $assetPaths) {
}
$token = $env:GITEA_TOKEN
if (-not $token) {
$authorizationScheme = 'token'
$authorizationParameter = $token
if (-not $authorizationParameter) {
$credentialInput = "protocol=$($GiteaBaseUri.Scheme)`nhost=$($GiteaBaseUri.Host)`n`n"
$credentialOutput = $credentialInput | & git credential fill
if ($LASTEXITCODE -ne 0) {
@@ -40,17 +42,25 @@ if (-not $token) {
$credentialValues[$parts[0]] = $parts[1]
}
}
$token = $credentialValues.password
if ($credentialValues.username -and $credentialValues.password) {
$authorizationScheme = 'Basic'
$basicCredential = '{0}:{1}' -f $credentialValues.username,$credentialValues.password
$authorizationParameter = [Convert]::ToBase64String(
[Text.Encoding]::UTF8.GetBytes($basicCredential))
$basicCredential = $null
}
}
if (-not $token) {
throw 'No Gitea token is available. Set GITEA_TOKEN for this process or sign in through Git Credential Manager.'
if (-not $authorizationParameter) {
throw 'No Gitea credential is available. Set GITEA_TOKEN for this process or sign in through Git Credential Manager.'
}
Add-Type -AssemblyName System.Net.Http
$handler = [Net.Http.HttpClientHandler]::new()
$client = [Net.Http.HttpClient]::new($handler)
$client.BaseAddress = [uri]($GiteaBaseUri.AbsoluteUri.TrimEnd('/') + '/')
$client.DefaultRequestHeaders.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new('token', $token)
$client.DefaultRequestHeaders.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new(
$authorizationScheme,
$authorizationParameter)
$client.DefaultRequestHeaders.UserAgent.ParseAdd('SGU-CredentialProvider-Release/1.0')
function Invoke-GiteaJson {
@@ -103,6 +113,7 @@ Bootstrap reproducible para el laboratorio SGU.
- `sgu-client-bootstrap-$Version.zip`: registra un certificado mTLS único, instala y valida el Credential Provider antes de unir el equipo al dominio, habilita RDP/WinRM y se repara al arranque.
- `sgu-linux-client-bootstrap-$Version.zip`: une clientes Debian/Ubuntu o RHEL/Fedora/Rocky/AlmaLinux con realmd, Kerberos y SSSD. Solicita interactivamente la contraseña de unión y no instala el Credential Provider de Windows.
- El Auth Broker clasifica sin tareas programadas cada cuenta autenticada: `AL` se agrega a `SGU-Alumnos`, `AD` a `SGU-Administrativos` y `DO` a `SGU-Docentes`; el bootstrap crea estos grupos de seguridad de forma idempotente.
- El enriquecimiento obtiene el sexo de los módulos SGU de personal/alumnos, lo conserva como la línea administrada `SGU-Gender: Male|Female` en Notas de AD y adapta el fondo de Windows/Linux; cuando falta utiliza redacción neutral.
- El servidor configura WEF/WEC para registrar sesiones y fallos, inventariar el estado alcanzable de las máquinas cada cinco minutos y conservar durante 183 días tanto esos eventos como el diagnóstico estructurado del Auth Broker.
- Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host.
@@ -155,6 +166,8 @@ Las contraseñas se solicitan de forma interactiva y no se escriben en archivos
}
finally {
$token = $null
$authorizationParameter = $null
$credentialValues = $null
$client.Dispose()
$handler.Dispose()
}
+59 -6
View File
@@ -8,6 +8,8 @@ param(
[string]$ComputerName = $env:COMPUTERNAME,
[string]$Location,
[string]$OrganizationalUnit,
[ValidateSet('Male', 'Female')]
[string]$Gender,
[ValidateRange(640, 16384)]
[int]$CanvasWidth,
[ValidateRange(480, 16384)]
@@ -67,6 +69,22 @@ function Get-ImmediateOrganizationalUnit {
return $null
}
function Get-SguGenderFromInfo {
param([string]$Info)
if (-not $Info) {
return $null
}
foreach ($line in $Info -split '\r?\n') {
if ($line -match '^\s*SGU-Gender:\s*(Male|Female)\s*$') {
return [Globalization.CultureInfo]::InvariantCulture.TextInfo.ToTitleCase(
$Matches[1].ToLowerInvariant())
}
}
return $null
}
function Get-DirectoryWelcomeMetadata {
param(
[Parameter(Mandatory)][string]$UserName,
@@ -93,6 +111,7 @@ function Get-DirectoryWelcomeMetadata {
$userSearcher.Filter = '(&(objectCategory=person)(objectClass=user)(sAMAccountName={0}))' -f `
(ConvertTo-LdapFilterValue -Value $UserName)
[void]$userSearcher.PropertiesToLoad.Add('displayName')
[void]$userSearcher.PropertiesToLoad.Add('info')
$userResult = $userSearcher.FindOne()
$directoryDisplayName = if ($userResult -and $userResult.Properties['displayname'].Count) {
[string]$userResult.Properties['displayname'][0]
@@ -100,6 +119,12 @@ function Get-DirectoryWelcomeMetadata {
else {
$null
}
$directoryGender = if ($userResult -and $userResult.Properties['info'].Count) {
Get-SguGenderFromInfo -Info ([string]$userResult.Properties['info'][0])
}
else {
$null
}
}
finally {
$userSearcher.Dispose()
@@ -136,6 +161,7 @@ function Get-DirectoryWelcomeMetadata {
[pscustomobject]@{
DisplayName = $directoryDisplayName
Gender = $directoryGender
Location = $directoryLocation
OrganizationalUnit = Get-ImmediateOrganizationalUnit -DistinguishedName $computerDn
}
@@ -156,11 +182,20 @@ function Get-SpanishArticle {
function Get-WelcomeLocationText {
param(
[string]$Room,
[string]$OuName
[string]$OuName,
[string]$Gender
)
$located = 'Est{0}s ubicado en' -f [char]0x00E1
$engineeringLab = 'Bienvenido al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED
$located = switch ($Gender) {
'Male' { 'Est{0}s ubicado en' -f [char]0x00E1 }
'Female' { 'Est{0}s ubicada en' -f [char]0x00E1 }
default { 'Ubicaci{0}n:' -f [char]0x00F3 }
}
$engineeringLab = switch ($Gender) {
'Male' { 'Bienvenido al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED }
'Female' { 'Bienvenida al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED }
default { 'Acceso al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED }
}
$Room = if ($Room) { $Room.Trim() } else { $null }
$OuName = if ($OuName) { $OuName.Trim() } else { $null }
@@ -184,6 +219,16 @@ function Get-WelcomeLocationText {
return $engineeringLab
}
function Get-WelcomeHeading {
param([string]$Gender)
switch ($Gender) {
'Male' { return 'Bienvenido,' }
'Female' { return 'Bienvenida,' }
default { return 'Te damos la bienvenida,' }
}
}
function Get-AvailableFontFamily {
param(
[Parameter(Mandatory)][string[]]$Candidates,
@@ -270,10 +315,15 @@ if (-not $DisplayName) {
if (-not $PSBoundParameters.ContainsKey('Location') -and $metadata) {
$Location = $metadata.Location
}
$genderWasProvided = $PSBoundParameters.ContainsKey('Gender')
if (-not $genderWasProvided -and $metadata) {
$Gender = $metadata.Gender
}
$welcomeHeading = Get-WelcomeHeading -Gender $Gender
if (-not $PSBoundParameters.ContainsKey('OrganizationalUnit') -and $metadata) {
$OrganizationalUnit = $metadata.OrganizationalUnit
}
$locationText = Get-WelcomeLocationText -Room $Location -OuName $OrganizationalUnit
$locationText = Get-WelcomeLocationText -Room $Location -OuName $OrganizationalUnit -Gender $Gender
if (-not $CanvasWidth -or -not $CanvasHeight) {
try {
@@ -366,7 +416,7 @@ try {
$format.Trimming = [Drawing.StringTrimming]::EllipsisWord
try {
$graphics.FillRectangle($panelBrush, $panelX, $panelY, $panelWidth, $panelHeight)
Draw-CenteredText -Graphics $graphics -Text 'Bienvenido,' -Font $welcomeFont `
Draw-CenteredText -Graphics $graphics -Text $welcomeHeading -Font $welcomeFont `
-Brush $accentBrush -Bounds ([Drawing.RectangleF]::new($panelX, $panelY + 24*$scale, $panelWidth, 50*$scale)) -Format $format
Draw-CenteredText -Graphics $graphics -Text $DisplayName -Font $nameFont `
-Brush $whiteBrush -Bounds ([Drawing.RectangleF]::new($panelX + 30*$scale, $panelY + 64*$scale, $panelWidth - 60*$scale, 105*$scale)) -Format $format
@@ -433,12 +483,15 @@ namespace Sgu {
}
}
Write-WelcomeLog -Message ("OK computer={0}; location={1}; ou={2}; output={3}" -f $ComputerName,[bool]$Location,[bool]$OrganizationalUnit,$OutputPath)
$genderLogValue = if ($Gender) { $Gender } else { 'Neutral' }
Write-WelcomeLog -Message ("OK computer={0}; gender={1}; location={2}; ou={3}; output={4}" -f $ComputerName,$genderLogValue,[bool]$Location,[bool]$OrganizationalUnit,$OutputPath)
[pscustomobject]@{
DisplayName = $DisplayName
ComputerName = $ComputerName
Location = $Location
OrganizationalUnit = $OrganizationalUnit
Gender = $Gender
WelcomeHeading = $welcomeHeading
LocationText = $locationText
OutputPath = $OutputPath
Applied = -not $SkipApply
+35 -7
View File
@@ -53,6 +53,7 @@ computer_name=${computer_name^^}
location=''
distinguished_name=''
organizational_unit=''
gender=''
read_ldif_value() {
local attribute=$1
@@ -108,9 +109,18 @@ if [[ -n $DOMAIN_CONTROLLER && -n $BASE_DN ]] &&
user_result=$(ldapsearch -LLL -N -o ldif-wrap=no -Y GSSAPI \
-H "ldap://${ldap_server}" -b "$BASE_DN" \
"(&(objectCategory=person)(objectClass=user)(sAMAccountName=${account_name}))" \
displayName 2>/dev/null || true)
displayName info 2>/dev/null || true)
directory_display_name=$(read_ldif_value displayName "$user_result")
[[ -n $directory_display_name ]] && display_name=$directory_display_name
directory_info=$(read_ldif_value info "$user_result")
gender=$(printf '%s\n' "$directory_info" | awk -F: '
tolower($1) ~ /^[[:space:]]*sgu-gender[[:space:]]*$/ {
value=tolower($2); gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
if (value == "male") print "Male"
else if (value == "female") print "Female"
exit
}
')
fi
else
log_message 'WARN AD metadata query skipped because Kerberos or LDAP session data was unavailable.'
@@ -136,6 +146,24 @@ with_article() {
fi
}
case "$gender" in
Male)
welcome_text='Bienvenido,'
located_text='Estás ubicado en'
engineering_lab_text='Bienvenido al Laboratorio de Cómputo de Ingeniería.'
;;
Female)
welcome_text='Bienvenida,'
located_text='Estás ubicada en'
engineering_lab_text='Bienvenida al Laboratorio de Cómputo de Ingeniería.'
;;
*)
welcome_text='Te damos la bienvenida,'
located_text='Ubicación:'
engineering_lab_text='Acceso al Laboratorio de Cómputo de Ingeniería.'
;;
esac
if [[ -n $location && -n $organizational_unit ]]; then
room_phrase=$(with_article "$location")
ou_article=$(article_for "$organizational_unit")
@@ -146,13 +174,13 @@ if [[ -n $location && -n $organizational_unit ]]; then
else
ou_phrase="de ${organizational_unit}"
fi
location_text="Estás ubicado en ${room_phrase} ${ou_phrase}."
location_text="${located_text} ${room_phrase} ${ou_phrase}."
elif [[ -n $location ]]; then
location_text="Estás ubicado en $(with_article "$location")."
location_text="${located_text} $(with_article "$location")."
elif [[ -n $organizational_unit ]]; then
location_text="Estás ubicado en $(with_article "$organizational_unit")."
location_text="${located_text} $(with_article "$organizational_unit")."
else
location_text='Bienvenido al Laboratorio de Cómputo de Ingeniería.'
location_text=$engineering_lab_text
fi
width=1600
@@ -210,7 +238,7 @@ if ! "${image_command[@]}" "$BASE_IMAGE" \
-gravity center \
-font "$sans_font" -weight 700 -style Normal -pointsize "$welcome_size" \
-fill '#D3E2FF' -stroke 'rgba(0,0,0,0.48)' -strokewidth 1 \
-annotate "+0-$(( 92 * scale / 100 ))" 'Bienvenido,' \
-annotate "+0-$(( 92 * scale / 100 ))" "$welcome_text" \
-font "$serif_font" -weight 700 -style Italic -pointsize "$name_size" \
-fill white -annotate "+0-$(( 22 * scale / 100 ))" "$display_name" \
-font "$sans_font" -weight 400 -style Normal -pointsize "$location_size" \
@@ -242,7 +270,7 @@ if [[ $applied == false ]] && command -v xfconf-query >/dev/null 2>&1; then
fi
if [[ $applied == true ]]; then
log_message "OK computer=${computer_name}; location=$([[ -n $location ]] && printf true || printf false); ou=$([[ -n $organizational_unit ]] && printf true || printf false); output=${output_path}"
log_message "OK computer=${computer_name}; gender=${gender:-Neutral}; location=$([[ -n $location ]] && printf true || printf false); ou=$([[ -n $organizational_unit ]] && printf true || printf false); output=${output_path}"
else
log_message 'WARN Wallpaper rendered, but no supported desktop background API was found.'
fi
@@ -0,0 +1,7 @@
namespace SGU.AuthBroker.Core.Profiles;
public enum InstitutionalGender
{
Male,
Female
}
@@ -12,7 +12,8 @@ public sealed record InstitutionalProfile(
string? StreetAddress = null,
string? City = null,
string? State = null,
string? PostalCode = null)
string? PostalCode = null,
InstitutionalGender? Gender = null)
{
public bool HasValues =>
EmployeeNumber is not null ||
@@ -26,7 +27,8 @@ public sealed record InstitutionalProfile(
StreetAddress is not null ||
City is not null ||
State is not null ||
PostalCode is not null;
PostalCode is not null ||
Gender is not null;
public InstitutionalProfile Overlay(InstitutionalProfile? values) =>
values is null
@@ -44,6 +46,7 @@ public sealed record InstitutionalProfile(
StreetAddress = values.StreetAddress ?? StreetAddress,
City = values.City ?? City,
State = values.State ?? State,
PostalCode = values.PostalCode ?? PostalCode
PostalCode = values.PostalCode ?? PostalCode,
Gender = values.Gender ?? Gender
};
}
@@ -14,6 +14,8 @@ public static class SguProfileParser
private const string AdministrativeGivenNameId = "ctl00_contenedor_txtNombre";
private const string AdministrativePaternalSurnameId = "ctl00_contenedor_txtApaterno";
private const string AdministrativeMaternalSurnameId = "ctl00_contenedor_txtAmaterno";
private const string AdministrativeGenderId = "ctl00_contenedor_ddlsexo";
private const string AdministrativeGenderName = "ctl00$contenedor$ddlsexo";
private const string AdministrativeStreetId = "ctl00_contenedor_txtCalle";
private const string AdministrativeExteriorNumberId = "ctl00_contenedor_txtNoExt";
private const string AdministrativeInteriorNumberId = "ctl00_contenedor_txtNoInt";
@@ -34,6 +36,7 @@ public static class SguProfileParser
private const string StudentCityId = "ctl00_contenedor_HistorialAlumno1_lblCiudadAlumnoHP";
private const string StudentMunicipalityId = "ctl00_contenedor_HistorialAlumno1_lblDeloMunAlumnoHP";
private const string StudentPostalCodeId = "ctl00_contenedor_HistorialAlumno1_lblCPAlumnoHP";
private const string StudentGenderId = "ctl00_contenedor_HistorialAlumno1_lblSexoAlumnoHP";
public static InstitutionalProfile? ParseAdministrative(string html, string expectedEmployeeNumber)
=> ParseStaffHeader(html, expectedEmployeeNumber);
@@ -91,7 +94,11 @@ public static class SguProfileParser
InstitutionalProfile profile = new(
DisplayName: displayName,
GivenName: givenName,
Surname: surname);
Surname: surname,
Gender: ParseStaffGender(ExtractSelectedOptionValue(
html,
AdministrativeGenderId,
AdministrativeGenderName)));
return profile.HasValues ? profile : null;
}
@@ -151,7 +158,8 @@ public static class SguProfileParser
StreetAddress: streetAddress,
City: city ?? municipality,
State: NormalizeTitle(ExtractSpanText(html, StudentStateId), 128),
PostalCode: NormalizePostalCode(ExtractSpanText(html, StudentPostalCodeId)));
PostalCode: NormalizePostalCode(ExtractSpanText(html, StudentPostalCodeId)),
Gender: ParseStudentGender(ExtractSpanText(html, StudentGenderId)));
return profile.HasValues ? profile : null;
}
@@ -292,11 +300,68 @@ public static class SguProfileParser
return nonPlaceholderOptions.Count == 1 ? nonPlaceholderOptions[0] : null;
}
private static string? ExtractSelectedOptionValue(string html, string id, string name)
{
string? openingTag = FindOpeningTag(html, "select", id) ??
FindOpeningTagByAttribute(html, "select", "name", name);
if (openingTag is null)
{
return null;
}
int openingTagIndex = html.IndexOf(openingTag, StringComparison.OrdinalIgnoreCase);
int contentStart = openingTagIndex + openingTag.Length;
int contentEnd = html.IndexOf("</select", contentStart, StringComparison.OrdinalIgnoreCase);
if (openingTagIndex < 0 || contentEnd < 0)
{
return null;
}
string? selectedValue = ExtractAttributeValue(openingTag, "value");
string optionsHtml = html[contentStart..contentEnd];
int searchFrom = 0;
while (searchFrom < optionsHtml.Length)
{
int optionStart = optionsHtml.IndexOf("<option", searchFrom, StringComparison.OrdinalIgnoreCase);
if (optionStart < 0)
{
break;
}
int optionTagEnd = optionsHtml.IndexOf('>', optionStart);
if (optionTagEnd < 0)
{
break;
}
string optionTag = optionsHtml[optionStart..(optionTagEnd + 1)];
string? optionValue = ExtractAttributeValue(optionTag, "value");
if (optionValue is not null &&
(HasAttribute(optionTag, "selected") ||
(selectedValue is not null &&
string.Equals(optionValue, selectedValue, StringComparison.Ordinal))))
{
return NormalizeText(optionValue);
}
searchFrom = optionTagEnd + 1;
}
return null;
}
private static string? FindOpeningTag(string html, string tagName, string id)
=> FindOpeningTagByAttribute(html, tagName, "id", id);
private static string? FindOpeningTagByAttribute(
string html,
string tagName,
string attributeName,
string attributeValue)
{
foreach (char quote in new[] { '"', '\'' })
{
string marker = $"id={quote}{id}{quote}";
string marker = $"{attributeName}={quote}{attributeValue}{quote}";
int searchFrom = 0;
while (searchFrom < html.Length)
{
@@ -324,6 +389,20 @@ public static class SguProfileParser
return null;
}
private static InstitutionalGender? ParseStaffGender(string? value) => value?.Trim() switch
{
"1" => InstitutionalGender.Male,
"2" => InstitutionalGender.Female,
_ => null
};
private static InstitutionalGender? ParseStudentGender(string? value) => value?.Trim().ToUpperInvariant() switch
{
"M" => InstitutionalGender.Male,
"F" => InstitutionalGender.Female,
_ => null
};
private static string? ExtractAttributeValue(string openingTag, string attributeName)
{
foreach (char quote in new[] { '"', '\'' })
@@ -11,6 +11,8 @@ public sealed class ActiveDirectorySynchronizer(
BrokerOptions options,
ILogger<ActiveDirectorySynchronizer> logger) : IActiveDirectorySynchronizer
{
private const string GenderMetadataPrefix = "SGU-Gender:";
private const int InfoAttributeMaximumLength = 1024;
private const int AccountDisabled = 0x0002;
private const int NormalAccount = 0x0200;
private static readonly AuthenticationTypes BindFlags =
@@ -164,6 +166,7 @@ public sealed class ActiveDirectorySynchronizer(
SetOptionalProperty(user, "l", profile.City);
SetOptionalProperty(user, "st", profile.State);
SetOptionalProperty(user, "postalCode", profile.PostalCode);
SetGenderMetadata(user, profile.Gender, identity.UserName, logger);
if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal))
{
SetOptionalProperty(user, "employeeID", profile.EmployeeNumber);
@@ -201,6 +204,56 @@ public sealed class ActiveDirectorySynchronizer(
}
}
private static void SetGenderMetadata(
DirectoryEntry entry,
InstitutionalGender? gender,
string institutionalUser,
ILogger logger)
{
if (gender is null)
{
return;
}
string existing = Convert.ToString(entry.Properties["info"].Value) ?? string.Empty;
string? updated = MergeGenderMetadata(existing, gender);
if (updated is null)
{
logger.LogWarning(
BrokerEventIds.DirectoryOptionalMetadataFailure,
"Gender metadata was not written for {InstitutionalUser} because the Active Directory info attribute has no remaining capacity.",
institutionalUser);
return;
}
entry.Properties["info"].Value = updated;
}
internal static string? MergeGenderMetadata(
string? existing,
InstitutionalGender? gender)
{
if (gender is null)
{
return null;
}
string managedLine = $"{GenderMetadataPrefix} {gender}";
string normalizedExisting = (existing ?? string.Empty)
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n');
string[] preservedLines = string.IsNullOrEmpty(normalizedExisting)
? []
: normalizedExisting
.Split('\n')
.Where(line => !line.TrimStart().StartsWith(
GenderMetadataPrefix,
StringComparison.OrdinalIgnoreCase))
.ToArray();
string updated = string.Join("\r\n", preservedLines.Append(managedLine));
return updated.Length <= InfoAttributeMaximumLength ? updated : null;
}
private void EnsureRoleGroupMembership(DirectoryEntry user, UserIdentity identity)
{
user.RefreshCache(["distinguishedName"]);
@@ -133,6 +133,64 @@ public sealed class SguProfileParserTests
Assert.Null(profile.Email);
}
[Theory]
[InlineData("ctl00_contenedor_ddlsexo", "ctl00$contenedor$ddlsexo", "1", InstitutionalGender.Male)]
[InlineData("alternate-id", "ctl00$contenedor$ddlsexo", "2", InstitutionalGender.Female)]
public void ParsesStaffGenderFromTheSelectedPersonalDataOption(
string id,
string name,
string selectedValue,
InstitutionalGender expected)
{
string html = $"""
<select id="{id}" name="{name}">
<option value="">Seleccione...</option>
<option value="1"{(selectedValue == "1" ? " selected=\"selected\"" : string.Empty)}>Masculino</option>
<option value="2"{(selectedValue == "2" ? " selected=\"selected\"" : string.Empty)}>Femenino</option>
</select>
""";
InstitutionalProfile? profile = SguProfileParser.ParseAdministrativePersonal(html);
Assert.NotNull(profile);
Assert.Equal(expected, profile.Gender);
}
[Theory]
[InlineData("M", InstitutionalGender.Male)]
[InlineData("f", InstitutionalGender.Female)]
public void ParsesStudentGenderFromTheInformationSpan(
string source,
InstitutionalGender expected)
{
string html = $"""
<span id="ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP">123456</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblSexoAlumnoHP">{source}</span>
""";
InstitutionalProfile? profile = SguProfileParser.ParseStudent(html, "123456");
Assert.NotNull(profile);
Assert.Equal(expected, profile.Gender);
}
[Fact]
public void IgnoresUnknownGenderValuesWithoutFailingProfileParsing()
{
const string html = """
<input id="ctl00_contenedor_txtNombre" value="PERSONA" />
<select id="ctl00_contenedor_ddlsexo">
<option selected="selected" value="9">SIN CLASIFICAR</option>
</select>
""";
InstitutionalProfile? profile = SguProfileParser.ParseAdministrativePersonal(html);
Assert.NotNull(profile);
Assert.Equal("Persona", profile.DisplayName);
Assert.Null(profile.Gender);
}
[Fact]
public void ParsesAdministrativeAddressFromInputsAndSelectedOptions()
{
@@ -182,7 +240,8 @@ public sealed class SguProfileParserTests
InstitutionalProfile personal = new(
DisplayName: "María del Carmen de la Fuente",
GivenName: "María del Carmen",
Surname: "de la Fuente");
Surname: "de la Fuente",
Gender: InstitutionalGender.Female);
InstitutionalProfile location = new(
StreetAddress: "Calle Uno 10",
City: "Ciudad de México",
@@ -200,6 +259,7 @@ public sealed class SguProfileParserTests
Assert.Equal("Ingeniería", combined.Department);
Assert.Equal("Calle Uno 10", combined.StreetAddress);
Assert.Equal("01000", combined.PostalCode);
Assert.Equal(InstitutionalGender.Female, combined.Gender);
}
[Fact]
@@ -215,6 +275,7 @@ public sealed class SguProfileParserTests
<span id="ctl00_contenedor_HistorialAlumno1_lblCorreoAlumnoHP">
<a href="mailto:alumna@lasalle.mx">ALUMNA@LASALLE.MX</a>
</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblSexoAlumnoHP">F</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblCURPAlumnoHP">
DATO-SENSIBLE-QUE-NO-DEBE-EXTRAERSE
</span>
@@ -262,6 +323,7 @@ public sealed class SguProfileParserTests
Assert.Equal("Ciudad de México", profile.City);
Assert.Equal("Ciudad de México", profile.State);
Assert.Equal("08500", profile.PostalCode);
Assert.Equal(InstitutionalGender.Female, profile.Gender);
}
[Fact]
@@ -0,0 +1,34 @@
using SGU.AuthBroker.Core.Profiles;
using SGU.AuthBroker.Services;
using Xunit;
namespace SGU.AuthBroker.Tests;
public sealed class ActiveDirectorySynchronizerTests
{
[Fact]
public void GenderMetadataPreservesUnmanagedNotesAndReplacesItsManagedLine()
{
const string existing = " Responsable de laboratorio \r\n\r\nSGU-Gender: Male\r\nTurno vespertino";
string? updated = ActiveDirectorySynchronizer.MergeGenderMetadata(
existing,
InstitutionalGender.Female);
Assert.Equal(
" Responsable de laboratorio \r\n\r\nTurno vespertino\r\nSGU-Gender: Female",
updated);
}
[Fact]
public void GenderMetadataDoesNotTruncateAnExistingFullNotesField()
{
string existing = new('x', 1024);
string? updated = ActiveDirectorySynchronizer.MergeGenderMetadata(
existing,
InstitutionalGender.Male);
Assert.Null(updated);
}
}
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
using Microsoft.Extensions.Logging.Abstractions;
using SGU.AuthBroker.Core.Authentication;
using SGU.AuthBroker.Core.Identity;
using SGU.AuthBroker.Core.Profiles;
using SGU.AuthBroker.Options;
using SGU.AuthBroker.Services;
using Xunit;
@@ -132,6 +133,10 @@ public sealed class NtlmCredentialValidatorTests
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
<input id="ctl00_contenedor_txtAmaterno" value="O'CONNOR" />
<select name="ctl00$contenedor$ddlsexo">
<option value="1">Masculino</option>
<option selected="selected" value="2">Femenino</option>
</select>
"""),
Response(
HttpStatusCode.OK,
@@ -168,6 +173,7 @@ public sealed class NtlmCredentialValidatorTests
Assert.Equal("Álvaro Obregón", result.Profile.City);
Assert.Equal("Ciudad de México", result.Profile.State);
Assert.Equal("01000", result.Profile.PostalCode);
Assert.Equal(InstitutionalGender.Female, result.Profile.Gender);
Assert.Equal(
[
"/psulsa/",
@@ -224,6 +230,10 @@ public sealed class NtlmCredentialValidatorTests
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
<input id="ctl00_contenedor_txtAmaterno" value="O'CONNOR" />
<select id="ctl00_contenedor_ddlsexo">
<option selected="selected" value="1">Masculino</option>
<option value="2">Femenino</option>
</select>
"""),
Response(
HttpStatusCode.OK,
@@ -262,6 +272,7 @@ public sealed class NtlmCredentialValidatorTests
Assert.Equal("Álvaro Obregón", result.Profile.City);
Assert.Equal("Ciudad de México", result.Profile.State);
Assert.Equal("01000", result.Profile.PostalCode);
Assert.Equal(InstitutionalGender.Male, result.Profile.Gender);
Assert.Equal(
[
"/psulsa/",