diff --git a/docs/architecture.md b/docs/architecture.md index 94cb252..e06deee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/welcome-wallpaper.md b/docs/welcome-wallpaper.md index 83893e0..3e2183b 100644 --- a/docs/welcome-wallpaper.md +++ b/docs/welcome-wallpaper.md @@ -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 diff --git a/scripts/Publish-GiteaRelease.ps1 b/scripts/Publish-GiteaRelease.ps1 index d9c91a6..f76a578 100644 --- a/scripts/Publish-GiteaRelease.ps1 +++ b/scripts/Publish-GiteaRelease.ps1 @@ -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-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. diff --git a/scripts/Set-SguWelcomeWallpaper.ps1 b/scripts/Set-SguWelcomeWallpaper.ps1 index 700774b..b55f61e 100644 --- a/scripts/Set-SguWelcomeWallpaper.ps1 +++ b/scripts/Set-SguWelcomeWallpaper.ps1 @@ -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 diff --git a/scripts/Set-SguWelcomeWallpaper.sh b/scripts/Set-SguWelcomeWallpaper.sh index 6fbef6c..3077c51 100644 --- a/scripts/Set-SguWelcomeWallpaper.sh +++ b/scripts/Set-SguWelcomeWallpaper.sh @@ -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 diff --git a/src/SGU.AuthBroker.Core/Profiles/InstitutionalGender.cs b/src/SGU.AuthBroker.Core/Profiles/InstitutionalGender.cs new file mode 100644 index 0000000..18bae6f --- /dev/null +++ b/src/SGU.AuthBroker.Core/Profiles/InstitutionalGender.cs @@ -0,0 +1,7 @@ +namespace SGU.AuthBroker.Core.Profiles; + +public enum InstitutionalGender +{ + Male, + Female +} diff --git a/src/SGU.AuthBroker.Core/Profiles/InstitutionalProfile.cs b/src/SGU.AuthBroker.Core/Profiles/InstitutionalProfile.cs index 90ce581..b3af73f 100644 --- a/src/SGU.AuthBroker.Core/Profiles/InstitutionalProfile.cs +++ b/src/SGU.AuthBroker.Core/Profiles/InstitutionalProfile.cs @@ -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 }; } diff --git a/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs b/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs index fcab71f..43dc2ea 100644 --- a/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs +++ b/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs @@ -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("', 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[] { '"', '\'' }) diff --git a/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs b/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs index 9a59562..b0843d4 100644 --- a/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs +++ b/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs @@ -11,6 +11,8 @@ public sealed class ActiveDirectorySynchronizer( BrokerOptions options, ILogger 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"]); diff --git a/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs b/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs index cd2225d..2eb8144 100644 --- a/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs +++ b/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs @@ -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 = $""" + + """; + + 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 = $""" + 123456 + {source} + """; + + InstitutionalProfile? profile = SguProfileParser.ParseStudent(html, "123456"); + + Assert.NotNull(profile); + Assert.Equal(expected, profile.Gender); + } + + [Fact] + public void IgnoresUnknownGenderValuesWithoutFailingProfileParsing() + { + const string html = """ + + + """; + + 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 ALUMNA@LASALLE.MX + F DATO-SENSIBLE-QUE-NO-DEBE-EXTRAERSE @@ -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] diff --git a/tests/SGU.AuthBroker.Tests/ActiveDirectorySynchronizerTests.cs b/tests/SGU.AuthBroker.Tests/ActiveDirectorySynchronizerTests.cs new file mode 100644 index 0000000..8c9196d --- /dev/null +++ b/tests/SGU.AuthBroker.Tests/ActiveDirectorySynchronizerTests.cs @@ -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); + } +} diff --git a/tests/SGU.AuthBroker.Tests/NtlmCredentialValidatorTests.cs b/tests/SGU.AuthBroker.Tests/NtlmCredentialValidatorTests.cs index b7021a0..4a546de 100644 --- a/tests/SGU.AuthBroker.Tests/NtlmCredentialValidatorTests.cs +++ b/tests/SGU.AuthBroker.Tests/NtlmCredentialValidatorTests.cs @@ -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 + """), 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 + """), 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/",