Adapt welcome wallpaper to SGU gender

This commit is contained in:
2026-09-08 12:07:43 -06:00
parent 1fe2006404
commit 93871b62b0
12 changed files with 367 additions and 25 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 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 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 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 in-memory session read the structured name and selected sex from
address from `datos/ubicacion.aspx`. Docentes request `datos/personales.aspx` and the address from `datos/ubicacion.aspx`. Docentes request
`nomina/consultanomina.aspx` for a matching employee number, email, employee `nomina/consultanomina.aspx` for a matching employee number, email, employee
type and job title, then attempt the same two shared staff modules without type and job title, then attempt the same two shared staff modules without
requiring any optional route to exist. A supplemental 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 When the authenticated HTML exposes recognized stable IDs, the broker also
updates the applicable `displayName`, `givenName`, `sn`, `mail`, `title`, updates the applicable `displayName`, `givenName`, `sn`, `mail`, `title`,
`department`, `employeeType`, `employeeID`, `streetAddress`, `l`, `st`, and `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 numeric digits of the requested identity before any role-specific metadata is
trusted. Administrative personal and location pages are accepted only after trusted. Administrative personal and location pages are accepted only after
that incident-page match. Docente payroll metadata must match the requested 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 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 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 authenticated user's `displayName` plus the computer object's `location` and
immediate parent OU, then composes those values over the bundled dark-blue immediate parent OU, then composes those values over the bundled dark-blue
background with the bundled Indivisa fonts. Missing directory attributes degrade 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: guardar credenciales. Obtiene:
- `displayName` del usuario; si falta, utiliza `sAMAccountName`. - `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. - `location` del objeto de equipo.
- La OU padre inmediata a partir de `distinguishedName`. - 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.` 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. 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. 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 Los errores de generación se registran en
+1
View File
@@ -103,6 +103,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-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. - `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 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. - 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. - Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host.
+59 -6
View File
@@ -8,6 +8,8 @@ param(
[string]$ComputerName = $env:COMPUTERNAME, [string]$ComputerName = $env:COMPUTERNAME,
[string]$Location, [string]$Location,
[string]$OrganizationalUnit, [string]$OrganizationalUnit,
[ValidateSet('Male', 'Female')]
[string]$Gender,
[ValidateRange(640, 16384)] [ValidateRange(640, 16384)]
[int]$CanvasWidth, [int]$CanvasWidth,
[ValidateRange(480, 16384)] [ValidateRange(480, 16384)]
@@ -67,6 +69,22 @@ function Get-ImmediateOrganizationalUnit {
return $null 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 { function Get-DirectoryWelcomeMetadata {
param( param(
[Parameter(Mandatory)][string]$UserName, [Parameter(Mandatory)][string]$UserName,
@@ -93,6 +111,7 @@ function Get-DirectoryWelcomeMetadata {
$userSearcher.Filter = '(&(objectCategory=person)(objectClass=user)(sAMAccountName={0}))' -f ` $userSearcher.Filter = '(&(objectCategory=person)(objectClass=user)(sAMAccountName={0}))' -f `
(ConvertTo-LdapFilterValue -Value $UserName) (ConvertTo-LdapFilterValue -Value $UserName)
[void]$userSearcher.PropertiesToLoad.Add('displayName') [void]$userSearcher.PropertiesToLoad.Add('displayName')
[void]$userSearcher.PropertiesToLoad.Add('info')
$userResult = $userSearcher.FindOne() $userResult = $userSearcher.FindOne()
$directoryDisplayName = if ($userResult -and $userResult.Properties['displayname'].Count) { $directoryDisplayName = if ($userResult -and $userResult.Properties['displayname'].Count) {
[string]$userResult.Properties['displayname'][0] [string]$userResult.Properties['displayname'][0]
@@ -100,6 +119,12 @@ function Get-DirectoryWelcomeMetadata {
else { else {
$null $null
} }
$directoryGender = if ($userResult -and $userResult.Properties['info'].Count) {
Get-SguGenderFromInfo -Info ([string]$userResult.Properties['info'][0])
}
else {
$null
}
} }
finally { finally {
$userSearcher.Dispose() $userSearcher.Dispose()
@@ -136,6 +161,7 @@ function Get-DirectoryWelcomeMetadata {
[pscustomobject]@{ [pscustomobject]@{
DisplayName = $directoryDisplayName DisplayName = $directoryDisplayName
Gender = $directoryGender
Location = $directoryLocation Location = $directoryLocation
OrganizationalUnit = Get-ImmediateOrganizationalUnit -DistinguishedName $computerDn OrganizationalUnit = Get-ImmediateOrganizationalUnit -DistinguishedName $computerDn
} }
@@ -156,11 +182,20 @@ function Get-SpanishArticle {
function Get-WelcomeLocationText { function Get-WelcomeLocationText {
param( param(
[string]$Room, [string]$Room,
[string]$OuName [string]$OuName,
[string]$Gender
) )
$located = 'Est{0}s ubicado en' -f [char]0x00E1 $located = switch ($Gender) {
$engineeringLab = 'Bienvenido al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED '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 } $Room = if ($Room) { $Room.Trim() } else { $null }
$OuName = if ($OuName) { $OuName.Trim() } else { $null } $OuName = if ($OuName) { $OuName.Trim() } else { $null }
@@ -184,6 +219,16 @@ function Get-WelcomeLocationText {
return $engineeringLab 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 { function Get-AvailableFontFamily {
param( param(
[Parameter(Mandatory)][string[]]$Candidates, [Parameter(Mandatory)][string[]]$Candidates,
@@ -270,10 +315,15 @@ if (-not $DisplayName) {
if (-not $PSBoundParameters.ContainsKey('Location') -and $metadata) { if (-not $PSBoundParameters.ContainsKey('Location') -and $metadata) {
$Location = $metadata.Location $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) { if (-not $PSBoundParameters.ContainsKey('OrganizationalUnit') -and $metadata) {
$OrganizationalUnit = $metadata.OrganizationalUnit $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) { if (-not $CanvasWidth -or -not $CanvasHeight) {
try { try {
@@ -366,7 +416,7 @@ try {
$format.Trimming = [Drawing.StringTrimming]::EllipsisWord $format.Trimming = [Drawing.StringTrimming]::EllipsisWord
try { try {
$graphics.FillRectangle($panelBrush, $panelX, $panelY, $panelWidth, $panelHeight) $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 -Brush $accentBrush -Bounds ([Drawing.RectangleF]::new($panelX, $panelY + 24*$scale, $panelWidth, 50*$scale)) -Format $format
Draw-CenteredText -Graphics $graphics -Text $DisplayName -Font $nameFont ` 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 -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]@{ [pscustomobject]@{
DisplayName = $DisplayName DisplayName = $DisplayName
ComputerName = $ComputerName ComputerName = $ComputerName
Location = $Location Location = $Location
OrganizationalUnit = $OrganizationalUnit OrganizationalUnit = $OrganizationalUnit
Gender = $Gender
WelcomeHeading = $welcomeHeading
LocationText = $locationText LocationText = $locationText
OutputPath = $OutputPath OutputPath = $OutputPath
Applied = -not $SkipApply Applied = -not $SkipApply
+35 -7
View File
@@ -53,6 +53,7 @@ computer_name=${computer_name^^}
location='' location=''
distinguished_name='' distinguished_name=''
organizational_unit='' organizational_unit=''
gender=''
read_ldif_value() { read_ldif_value() {
local attribute=$1 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 \ user_result=$(ldapsearch -LLL -N -o ldif-wrap=no -Y GSSAPI \
-H "ldap://${ldap_server}" -b "$BASE_DN" \ -H "ldap://${ldap_server}" -b "$BASE_DN" \
"(&(objectCategory=person)(objectClass=user)(sAMAccountName=${account_name}))" \ "(&(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") directory_display_name=$(read_ldif_value displayName "$user_result")
[[ -n $directory_display_name ]] && display_name=$directory_display_name [[ -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 fi
else else
log_message 'WARN AD metadata query skipped because Kerberos or LDAP session data was unavailable.' log_message 'WARN AD metadata query skipped because Kerberos or LDAP session data was unavailable.'
@@ -136,6 +146,24 @@ with_article() {
fi 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 if [[ -n $location && -n $organizational_unit ]]; then
room_phrase=$(with_article "$location") room_phrase=$(with_article "$location")
ou_article=$(article_for "$organizational_unit") ou_article=$(article_for "$organizational_unit")
@@ -146,13 +174,13 @@ if [[ -n $location && -n $organizational_unit ]]; then
else else
ou_phrase="de ${organizational_unit}" ou_phrase="de ${organizational_unit}"
fi fi
location_text="Estás ubicado en ${room_phrase} ${ou_phrase}." location_text="${located_text} ${room_phrase} ${ou_phrase}."
elif [[ -n $location ]]; then 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 elif [[ -n $organizational_unit ]]; then
location_text="Estás ubicado en $(with_article "$organizational_unit")." location_text="${located_text} $(with_article "$organizational_unit")."
else else
location_text='Bienvenido al Laboratorio de Cómputo de Ingeniería.' location_text=$engineering_lab_text
fi fi
width=1600 width=1600
@@ -210,7 +238,7 @@ if ! "${image_command[@]}" "$BASE_IMAGE" \
-gravity center \ -gravity center \
-font "$sans_font" -weight 700 -style Normal -pointsize "$welcome_size" \ -font "$sans_font" -weight 700 -style Normal -pointsize "$welcome_size" \
-fill '#D3E2FF' -stroke 'rgba(0,0,0,0.48)' -strokewidth 1 \ -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" \ -font "$serif_font" -weight 700 -style Italic -pointsize "$name_size" \
-fill white -annotate "+0-$(( 22 * scale / 100 ))" "$display_name" \ -fill white -annotate "+0-$(( 22 * scale / 100 ))" "$display_name" \
-font "$sans_font" -weight 400 -style Normal -pointsize "$location_size" \ -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 fi
if [[ $applied == true ]]; then 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 else
log_message 'WARN Wallpaper rendered, but no supported desktop background API was found.' log_message 'WARN Wallpaper rendered, but no supported desktop background API was found.'
fi 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? StreetAddress = null,
string? City = null, string? City = null,
string? State = null, string? State = null,
string? PostalCode = null) string? PostalCode = null,
InstitutionalGender? Gender = null)
{ {
public bool HasValues => public bool HasValues =>
EmployeeNumber is not null || EmployeeNumber is not null ||
@@ -26,7 +27,8 @@ public sealed record InstitutionalProfile(
StreetAddress is not null || StreetAddress is not null ||
City is not null || City is not null ||
State is not null || State is not null ||
PostalCode is not null; PostalCode is not null ||
Gender is not null;
public InstitutionalProfile Overlay(InstitutionalProfile? values) => public InstitutionalProfile Overlay(InstitutionalProfile? values) =>
values is null values is null
@@ -44,6 +46,7 @@ public sealed record InstitutionalProfile(
StreetAddress = values.StreetAddress ?? StreetAddress, StreetAddress = values.StreetAddress ?? StreetAddress,
City = values.City ?? City, City = values.City ?? City,
State = values.State ?? State, 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 AdministrativeGivenNameId = "ctl00_contenedor_txtNombre";
private const string AdministrativePaternalSurnameId = "ctl00_contenedor_txtApaterno"; private const string AdministrativePaternalSurnameId = "ctl00_contenedor_txtApaterno";
private const string AdministrativeMaternalSurnameId = "ctl00_contenedor_txtAmaterno"; 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 AdministrativeStreetId = "ctl00_contenedor_txtCalle";
private const string AdministrativeExteriorNumberId = "ctl00_contenedor_txtNoExt"; private const string AdministrativeExteriorNumberId = "ctl00_contenedor_txtNoExt";
private const string AdministrativeInteriorNumberId = "ctl00_contenedor_txtNoInt"; 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 StudentCityId = "ctl00_contenedor_HistorialAlumno1_lblCiudadAlumnoHP";
private const string StudentMunicipalityId = "ctl00_contenedor_HistorialAlumno1_lblDeloMunAlumnoHP"; private const string StudentMunicipalityId = "ctl00_contenedor_HistorialAlumno1_lblDeloMunAlumnoHP";
private const string StudentPostalCodeId = "ctl00_contenedor_HistorialAlumno1_lblCPAlumnoHP"; private const string StudentPostalCodeId = "ctl00_contenedor_HistorialAlumno1_lblCPAlumnoHP";
private const string StudentGenderId = "ctl00_contenedor_HistorialAlumno1_lblSexoAlumnoHP";
public static InstitutionalProfile? ParseAdministrative(string html, string expectedEmployeeNumber) public static InstitutionalProfile? ParseAdministrative(string html, string expectedEmployeeNumber)
=> ParseStaffHeader(html, expectedEmployeeNumber); => ParseStaffHeader(html, expectedEmployeeNumber);
@@ -91,7 +94,11 @@ public static class SguProfileParser
InstitutionalProfile profile = new( InstitutionalProfile profile = new(
DisplayName: displayName, DisplayName: displayName,
GivenName: givenName, GivenName: givenName,
Surname: surname); Surname: surname,
Gender: ParseStaffGender(ExtractSelectedOptionValue(
html,
AdministrativeGenderId,
AdministrativeGenderName)));
return profile.HasValues ? profile : null; return profile.HasValues ? profile : null;
} }
@@ -151,7 +158,8 @@ public static class SguProfileParser
StreetAddress: streetAddress, StreetAddress: streetAddress,
City: city ?? municipality, City: city ?? municipality,
State: NormalizeTitle(ExtractSpanText(html, StudentStateId), 128), 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; return profile.HasValues ? profile : null;
} }
@@ -292,11 +300,68 @@ public static class SguProfileParser
return nonPlaceholderOptions.Count == 1 ? nonPlaceholderOptions[0] : null; 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) 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[] { '"', '\'' }) foreach (char quote in new[] { '"', '\'' })
{ {
string marker = $"id={quote}{id}{quote}"; string marker = $"{attributeName}={quote}{attributeValue}{quote}";
int searchFrom = 0; int searchFrom = 0;
while (searchFrom < html.Length) while (searchFrom < html.Length)
{ {
@@ -324,6 +389,20 @@ public static class SguProfileParser
return null; 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) private static string? ExtractAttributeValue(string openingTag, string attributeName)
{ {
foreach (char quote in new[] { '"', '\'' }) foreach (char quote in new[] { '"', '\'' })
@@ -11,6 +11,8 @@ public sealed class ActiveDirectorySynchronizer(
BrokerOptions options, BrokerOptions options,
ILogger<ActiveDirectorySynchronizer> logger) : IActiveDirectorySynchronizer ILogger<ActiveDirectorySynchronizer> logger) : IActiveDirectorySynchronizer
{ {
private const string GenderMetadataPrefix = "SGU-Gender:";
private const int InfoAttributeMaximumLength = 1024;
private const int AccountDisabled = 0x0002; private const int AccountDisabled = 0x0002;
private const int NormalAccount = 0x0200; private const int NormalAccount = 0x0200;
private static readonly AuthenticationTypes BindFlags = private static readonly AuthenticationTypes BindFlags =
@@ -164,6 +166,7 @@ public sealed class ActiveDirectorySynchronizer(
SetOptionalProperty(user, "l", profile.City); SetOptionalProperty(user, "l", profile.City);
SetOptionalProperty(user, "st", profile.State); SetOptionalProperty(user, "st", profile.State);
SetOptionalProperty(user, "postalCode", profile.PostalCode); SetOptionalProperty(user, "postalCode", profile.PostalCode);
SetGenderMetadata(user, profile.Gender, identity.UserName, logger);
if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal)) if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal))
{ {
SetOptionalProperty(user, "employeeID", profile.EmployeeNumber); 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) private void EnsureRoleGroupMembership(DirectoryEntry user, UserIdentity identity)
{ {
user.RefreshCache(["distinguishedName"]); user.RefreshCache(["distinguishedName"]);
@@ -133,6 +133,64 @@ public sealed class SguProfileParserTests
Assert.Null(profile.Email); 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] [Fact]
public void ParsesAdministrativeAddressFromInputsAndSelectedOptions() public void ParsesAdministrativeAddressFromInputsAndSelectedOptions()
{ {
@@ -182,7 +240,8 @@ public sealed class SguProfileParserTests
InstitutionalProfile personal = new( InstitutionalProfile personal = new(
DisplayName: "María del Carmen de la Fuente", DisplayName: "María del Carmen de la Fuente",
GivenName: "María del Carmen", GivenName: "María del Carmen",
Surname: "de la Fuente"); Surname: "de la Fuente",
Gender: InstitutionalGender.Female);
InstitutionalProfile location = new( InstitutionalProfile location = new(
StreetAddress: "Calle Uno 10", StreetAddress: "Calle Uno 10",
City: "Ciudad de México", City: "Ciudad de México",
@@ -200,6 +259,7 @@ public sealed class SguProfileParserTests
Assert.Equal("Ingeniería", combined.Department); Assert.Equal("Ingeniería", combined.Department);
Assert.Equal("Calle Uno 10", combined.StreetAddress); Assert.Equal("Calle Uno 10", combined.StreetAddress);
Assert.Equal("01000", combined.PostalCode); Assert.Equal("01000", combined.PostalCode);
Assert.Equal(InstitutionalGender.Female, combined.Gender);
} }
[Fact] [Fact]
@@ -215,6 +275,7 @@ public sealed class SguProfileParserTests
<span id="ctl00_contenedor_HistorialAlumno1_lblCorreoAlumnoHP"> <span id="ctl00_contenedor_HistorialAlumno1_lblCorreoAlumnoHP">
<a href="mailto:alumna@lasalle.mx">ALUMNA@LASALLE.MX</a> <a href="mailto:alumna@lasalle.mx">ALUMNA@LASALLE.MX</a>
</span> </span>
<span id="ctl00_contenedor_HistorialAlumno1_lblSexoAlumnoHP">F</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblCURPAlumnoHP"> <span id="ctl00_contenedor_HistorialAlumno1_lblCURPAlumnoHP">
DATO-SENSIBLE-QUE-NO-DEBE-EXTRAERSE DATO-SENSIBLE-QUE-NO-DEBE-EXTRAERSE
</span> </span>
@@ -262,6 +323,7 @@ public sealed class SguProfileParserTests
Assert.Equal("Ciudad de México", profile.City); Assert.Equal("Ciudad de México", profile.City);
Assert.Equal("Ciudad de México", profile.State); Assert.Equal("Ciudad de México", profile.State);
Assert.Equal("08500", profile.PostalCode); Assert.Equal("08500", profile.PostalCode);
Assert.Equal(InstitutionalGender.Female, profile.Gender);
} }
[Fact] [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 Microsoft.Extensions.Logging.Abstractions;
using SGU.AuthBroker.Core.Authentication; using SGU.AuthBroker.Core.Authentication;
using SGU.AuthBroker.Core.Identity; using SGU.AuthBroker.Core.Identity;
using SGU.AuthBroker.Core.Profiles;
using SGU.AuthBroker.Options; using SGU.AuthBroker.Options;
using SGU.AuthBroker.Services; using SGU.AuthBroker.Services;
using Xunit; using Xunit;
@@ -132,6 +133,10 @@ public sealed class NtlmCredentialValidatorTests
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" /> <input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" /> <input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
<input id="ctl00_contenedor_txtAmaterno" value="O'CONNOR" /> <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( Response(
HttpStatusCode.OK, HttpStatusCode.OK,
@@ -168,6 +173,7 @@ public sealed class NtlmCredentialValidatorTests
Assert.Equal("Álvaro Obregón", result.Profile.City); Assert.Equal("Álvaro Obregón", result.Profile.City);
Assert.Equal("Ciudad de México", result.Profile.State); Assert.Equal("Ciudad de México", result.Profile.State);
Assert.Equal("01000", result.Profile.PostalCode); Assert.Equal("01000", result.Profile.PostalCode);
Assert.Equal(InstitutionalGender.Female, result.Profile.Gender);
Assert.Equal( Assert.Equal(
[ [
"/psulsa/", "/psulsa/",
@@ -224,6 +230,10 @@ public sealed class NtlmCredentialValidatorTests
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" /> <input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" /> <input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
<input id="ctl00_contenedor_txtAmaterno" value="O'CONNOR" /> <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( Response(
HttpStatusCode.OK, HttpStatusCode.OK,
@@ -262,6 +272,7 @@ public sealed class NtlmCredentialValidatorTests
Assert.Equal("Álvaro Obregón", result.Profile.City); Assert.Equal("Álvaro Obregón", result.Profile.City);
Assert.Equal("Ciudad de México", result.Profile.State); Assert.Equal("Ciudad de México", result.Profile.State);
Assert.Equal("01000", result.Profile.PostalCode); Assert.Equal("01000", result.Profile.PostalCode);
Assert.Equal(InstitutionalGender.Male, result.Profile.Gender);
Assert.Equal( Assert.Equal(
[ [
"/psulsa/", "/psulsa/",