Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
991fc70600 | ||
|
|
93871b62b0 | ||
|
|
1fe2006404 | ||
|
|
7a4f599f55 |
+16
-3
@@ -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
|
||||||
@@ -72,6 +74,14 @@ is deliberately left unset because the verified page does not expose it.
|
|||||||
Missing metadata does not clear existing AD values and never changes the
|
Missing metadata does not clear existing AD values and never changes the
|
||||||
password outcome.
|
password outcome.
|
||||||
|
|
||||||
|
Every synchronization also enforces one idempotent security-group membership
|
||||||
|
from the classified institutional prefix: `AL` to `SGU-Alumnos`, `AD` to
|
||||||
|
`SGU-Administrativos`, and `DO` to `SGU-Docentes`. This happens synchronously
|
||||||
|
inside the broker before the institutional password is written to AD. A missing
|
||||||
|
or inaccessible role group therefore fails provisioning instead of leaving a
|
||||||
|
new usable account without its authorization classification. Existing accounts
|
||||||
|
are repaired automatically on their next successful SGU authentication.
|
||||||
|
|
||||||
Human-readable SGU values are decoded with BOM/header/meta detection, strict
|
Human-readable SGU values are decoded with BOM/header/meta detection, strict
|
||||||
UTF-8 validation, and a Windows-1252 fallback for the legacy portal. Names and
|
UTF-8 validation, and a Windows-1252 fallback for the legacy portal. Names and
|
||||||
titles are normalized with Spanish-aware casing; particles such as `de`, `del`
|
titles are normalized with Spanish-aware casing; particles such as `de`, `del`
|
||||||
@@ -110,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
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ Get-Service SGUAuthBroker
|
|||||||
Get-NetTCPConnection -LocalPort 8443 -State Listen
|
Get-NetTCPConnection -LocalPort 8443 -State Listen
|
||||||
sc.exe qfailure SGUAuthBroker
|
sc.exe qfailure SGUAuthBroker
|
||||||
Get-ADOrganizationalUnit -Filter * -SearchBase 'OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx'
|
Get-ADOrganizationalUnit -Filter * -SearchBase 'OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx'
|
||||||
|
Get-ADGroup -Filter 'SamAccountName -like "SGU-*"' -SearchBase 'OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx'
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4. Broker preflight from Windows 10
|
## 4. Broker preflight from Windows 10
|
||||||
|
|||||||
+2
-1
@@ -29,7 +29,8 @@ rol, `TraceId`, resultado y tiempo total. Los Event ID estables distinguen:
|
|||||||
IDs admitidos; `1202` timeout; `1203` excepción; `1204` página opcional no
|
IDs admitidos; `1202` timeout; `1203` excepción; `1204` página opcional no
|
||||||
disponible;
|
disponible;
|
||||||
- `1300` fallo de sincronización AD; `1301` metadatos opcionales no aplicados;
|
- `1300` fallo de sincronización AD; `1301` metadatos opcionales no aplicados;
|
||||||
`1302` membresía RDP opcional no aplicada.
|
`1302` membresía RDP opcional no aplicada; `1303` cuenta agregada a su grupo
|
||||||
|
institucional de Alumnos, Administrativos o Docentes.
|
||||||
|
|
||||||
No se almacena HTML, contraseña, hash de contraseña ni contenido de la
|
No se almacena HTML, contraseña, hash de contraseña ni contenido de la
|
||||||
respuesta SGU.
|
respuesta SGU.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ param(
|
|||||||
[string]$BaseDn = 'DC=lci,DC=lasalle,DC=mx',
|
[string]$BaseDn = 'DC=lci,DC=lasalle,DC=mx',
|
||||||
[string]$DomainNetbios = 'LCI',
|
[string]$DomainNetbios = 'LCI',
|
||||||
[string]$UpnSuffix = 'lci.lasalle.mx',
|
[string]$UpnSuffix = 'lci.lasalle.mx',
|
||||||
|
[string]$ProfessorGroupDn = '',
|
||||||
|
[string]$StudentGroupDn = '',
|
||||||
|
[string]$AdministrativeGroupDn = '',
|
||||||
[string]$RemoteDesktopGroupDn = '',
|
[string]$RemoteDesktopGroupDn = '',
|
||||||
[ValidateLength(1, 64)]
|
[ValidateLength(1, 64)]
|
||||||
[string]$DefaultCompany = 'La Salle',
|
[string]$DefaultCompany = 'La Salle',
|
||||||
@@ -72,10 +75,20 @@ if (-not $serverCertificate.Verify()) {
|
|||||||
throw 'The HTTPS server certificate chain is not trusted or is outside its validity period. Import the issuing CA chain; for a self-signed lab certificate, trust its public .cer in LocalMachine\Root.'
|
throw 'The HTTPS server certificate chain is not trusted or is outside its validity period. Import the issuing CA chain; for a self-signed lab certificate, trust its public .cer in LocalMachine\Root.'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Import-Module ActiveDirectory -ErrorAction Stop
|
||||||
|
$usersOuName = 'Usuarios-SGU'
|
||||||
|
$usersOuDn = "OU=$usersOuName,$BaseDn"
|
||||||
|
if ([string]::IsNullOrWhiteSpace($ProfessorGroupDn)) {
|
||||||
|
$ProfessorGroupDn = "CN=SGU-Docentes,$usersOuDn"
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrWhiteSpace($StudentGroupDn)) {
|
||||||
|
$StudentGroupDn = "CN=SGU-Alumnos,$usersOuDn"
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrWhiteSpace($AdministrativeGroupDn)) {
|
||||||
|
$AdministrativeGroupDn = "CN=SGU-Administrativos,$usersOuDn"
|
||||||
|
}
|
||||||
|
|
||||||
if ($CreateMissingOus) {
|
if ($CreateMissingOus) {
|
||||||
Import-Module ActiveDirectory -ErrorAction Stop
|
|
||||||
$usersOuName = 'Usuarios-SGU'
|
|
||||||
$usersOuDn = "OU=$usersOuName,$BaseDn"
|
|
||||||
if (-not (Get-ADOrganizationalUnit -LDAPFilter "(ou=$usersOuName)" -SearchBase $BaseDn -SearchScope OneLevel -Server $LdapHost -ErrorAction SilentlyContinue)) {
|
if (-not (Get-ADOrganizationalUnit -LDAPFilter "(ou=$usersOuName)" -SearchBase $BaseDn -SearchScope OneLevel -Server $LdapHost -ErrorAction SilentlyContinue)) {
|
||||||
New-ADOrganizationalUnit -Name $usersOuName -Path $BaseDn -ProtectedFromAccidentalDeletion $true -Server $LdapHost | Out-Null
|
New-ADOrganizationalUnit -Name $usersOuName -Path $BaseDn -ProtectedFromAccidentalDeletion $true -Server $LdapHost | Out-Null
|
||||||
}
|
}
|
||||||
@@ -117,8 +130,46 @@ if ($CreateMissingOus) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$roleGroupDefinitions = @(
|
||||||
|
[pscustomobject]@{ Role = 'Professor'; Dn = $ProfessorGroupDn; Description = 'SGU accounts with the DO institutional prefix.' }
|
||||||
|
[pscustomobject]@{ Role = 'Student'; Dn = $StudentGroupDn; Description = 'SGU accounts with the AL institutional prefix.' }
|
||||||
|
[pscustomobject]@{ Role = 'Administrative'; Dn = $AdministrativeGroupDn; Description = 'SGU accounts with the AD institutional prefix.' }
|
||||||
|
)
|
||||||
|
foreach ($definition in $roleGroupDefinitions) {
|
||||||
|
if (-not $definition.Dn.EndsWith(",$BaseDn", [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
throw "$($definition.Role)GroupDn must identify a security group beneath BaseDn."
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$roleGroup = Get-ADGroup -Identity $definition.Dn -Server $LdapHost -ErrorAction Stop
|
||||||
|
}
|
||||||
|
catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] {
|
||||||
|
$roleGroup = $null
|
||||||
|
}
|
||||||
|
if (-not $roleGroup -and $CreateMissingOus) {
|
||||||
|
$groupDnMatch = [regex]::Match($definition.Dn, '^CN=(?<Name>[^,]+),(?<Path>.+)$', [Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
||||||
|
if (-not $groupDnMatch.Success) {
|
||||||
|
throw "$($definition.Role)GroupDn must start with a simple CN component."
|
||||||
|
}
|
||||||
|
$groupName = $groupDnMatch.Groups['Name'].Value
|
||||||
|
if ($groupName.Length -gt 20) {
|
||||||
|
throw "$($definition.Role) group name exceeds the 20-character sAMAccountName limit."
|
||||||
|
}
|
||||||
|
New-ADGroup -Name $groupName -SamAccountName $groupName `
|
||||||
|
-GroupCategory Security -GroupScope Global `
|
||||||
|
-Path $groupDnMatch.Groups['Path'].Value `
|
||||||
|
-Description $definition.Description -Server $LdapHost | Out-Null
|
||||||
|
$roleGroup = Get-ADGroup -Identity $definition.Dn -Server $LdapHost -ErrorAction Stop
|
||||||
|
}
|
||||||
|
if (-not $roleGroup) {
|
||||||
|
throw "The required $($definition.Role) security group does not exist: $($definition.Dn)"
|
||||||
|
}
|
||||||
|
if ($roleGroup.GroupCategory -ne 'Security') {
|
||||||
|
throw "$($definition.Role)GroupDn must identify a security group."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($RemoteDesktopGroupDn) {
|
if ($RemoteDesktopGroupDn) {
|
||||||
Import-Module ActiveDirectory -ErrorAction Stop
|
|
||||||
$remoteDesktopGroup = Get-ADGroup -Identity $RemoteDesktopGroupDn -Server $LdapHost -ErrorAction Stop
|
$remoteDesktopGroup = Get-ADGroup -Identity $RemoteDesktopGroupDn -Server $LdapHost -ErrorAction Stop
|
||||||
if ($remoteDesktopGroup.GroupCategory -ne 'Security' -or
|
if ($remoteDesktopGroup.GroupCategory -ne 'Security' -or
|
||||||
-not $remoteDesktopGroup.DistinguishedName.EndsWith(",$BaseDn", [StringComparison]::OrdinalIgnoreCase)) {
|
-not $remoteDesktopGroup.DistinguishedName.EndsWith(",$BaseDn", [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
@@ -186,6 +237,9 @@ $productionSettings = @{
|
|||||||
ProfessorOuDn = "OU=Docentes,OU=Usuarios-SGU,$BaseDn"
|
ProfessorOuDn = "OU=Docentes,OU=Usuarios-SGU,$BaseDn"
|
||||||
StudentOuDn = "OU=Alumnos,OU=Usuarios-SGU,$BaseDn"
|
StudentOuDn = "OU=Alumnos,OU=Usuarios-SGU,$BaseDn"
|
||||||
AdministrativeOuDn = "OU=Administrativos,OU=Usuarios-SGU,$BaseDn"
|
AdministrativeOuDn = "OU=Administrativos,OU=Usuarios-SGU,$BaseDn"
|
||||||
|
ProfessorGroupDn = $ProfessorGroupDn
|
||||||
|
StudentGroupDn = $StudentGroupDn
|
||||||
|
AdministrativeGroupDn = $AdministrativeGroupDn
|
||||||
RemoteDesktopGroupDn = $RemoteDesktopGroupDn
|
RemoteDesktopGroupDn = $RemoteDesktopGroupDn
|
||||||
DefaultCompany = $DefaultCompany
|
DefaultCompany = $DefaultCompany
|
||||||
CreateMissingOus = [bool]$CreateMissingOus
|
CreateMissingOus = [bool]$CreateMissingOus
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ $eventNames = @{
|
|||||||
1300 = 'DirectorySynchronizationFailure'
|
1300 = 'DirectorySynchronizationFailure'
|
||||||
1301 = 'DirectoryOptionalMetadataFailure'
|
1301 = 'DirectoryOptionalMetadataFailure'
|
||||||
1302 = 'DirectoryGroupMembershipFailure'
|
1302 = 'DirectoryGroupMembershipFailure'
|
||||||
|
1303 = 'DirectoryRoleGroupMembershipAdded'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Keep these reads unfiltered. Besides making archived and current logs behave
|
# Keep these reads unfiltered. Besides making archived and current logs behave
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ foreach ($assetPath in $assetPaths) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$token = $env:GITEA_TOKEN
|
$token = $env:GITEA_TOKEN
|
||||||
if (-not $token) {
|
$authorizationScheme = 'token'
|
||||||
|
$authorizationParameter = $token
|
||||||
|
if (-not $authorizationParameter) {
|
||||||
$credentialInput = "protocol=$($GiteaBaseUri.Scheme)`nhost=$($GiteaBaseUri.Host)`n`n"
|
$credentialInput = "protocol=$($GiteaBaseUri.Scheme)`nhost=$($GiteaBaseUri.Host)`n`n"
|
||||||
$credentialOutput = $credentialInput | & git credential fill
|
$credentialOutput = $credentialInput | & git credential fill
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
@@ -40,17 +42,25 @@ if (-not $token) {
|
|||||||
$credentialValues[$parts[0]] = $parts[1]
|
$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) {
|
if (-not $authorizationParameter) {
|
||||||
throw 'No Gitea token is available. Set GITEA_TOKEN for this process or sign in through Git Credential Manager.'
|
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
|
Add-Type -AssemblyName System.Net.Http
|
||||||
$handler = [Net.Http.HttpClientHandler]::new()
|
$handler = [Net.Http.HttpClientHandler]::new()
|
||||||
$client = [Net.Http.HttpClient]::new($handler)
|
$client = [Net.Http.HttpClient]::new($handler)
|
||||||
$client.BaseAddress = [uri]($GiteaBaseUri.AbsoluteUri.TrimEnd('/') + '/')
|
$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')
|
$client.DefaultRequestHeaders.UserAgent.ParseAdd('SGU-CredentialProvider-Release/1.0')
|
||||||
|
|
||||||
function Invoke-GiteaJson {
|
function Invoke-GiteaJson {
|
||||||
@@ -102,6 +112,8 @@ Bootstrap reproducible para el laboratorio SGU.
|
|||||||
- `sgu-server-bootstrap-$Version.zip`: crea el bosque AD/DNS, OUs, grupo RDP, GPO, recurso `Packages`, broker mTLS y administración remota; se reanuda solo después del reinicio.
|
- `sgu-server-bootstrap-$Version.zip`: crea el bosque AD/DNS, OUs, grupo RDP, GPO, recurso `Packages`, broker mTLS y administración remota; se reanuda solo después del reinicio.
|
||||||
- `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 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.
|
||||||
|
|
||||||
@@ -154,6 +166,8 @@ Las contraseñas se solicitan de forma interactiva y no se escriben en archivos
|
|||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
$token = $null
|
$token = $null
|
||||||
|
$authorizationParameter = $null
|
||||||
|
$credentialValues = $null
|
||||||
$client.Dispose()
|
$client.Dispose()
|
||||||
$handler.Dispose()
|
$handler.Dispose()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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[] { '"', '\'' })
|
||||||
|
|||||||
@@ -21,4 +21,5 @@ internal static class BrokerEventIds
|
|||||||
internal static readonly EventId DirectorySynchronizationFailure = new(1300, nameof(DirectorySynchronizationFailure));
|
internal static readonly EventId DirectorySynchronizationFailure = new(1300, nameof(DirectorySynchronizationFailure));
|
||||||
internal static readonly EventId DirectoryOptionalMetadataFailure = new(1301, nameof(DirectoryOptionalMetadataFailure));
|
internal static readonly EventId DirectoryOptionalMetadataFailure = new(1301, nameof(DirectoryOptionalMetadataFailure));
|
||||||
internal static readonly EventId DirectoryGroupMembershipFailure = new(1302, nameof(DirectoryGroupMembershipFailure));
|
internal static readonly EventId DirectoryGroupMembershipFailure = new(1302, nameof(DirectoryGroupMembershipFailure));
|
||||||
|
internal static readonly EventId DirectoryRoleGroupMembershipAdded = new(1303, nameof(DirectoryRoleGroupMembershipAdded));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,14 @@ public sealed class BrokerOptions
|
|||||||
{
|
{
|
||||||
throw new InvalidOperationException($"The OU mapping for {role} must be beneath BaseDn.");
|
throw new InvalidOperationException($"The OU mapping for {role} must be beneath BaseDn.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string groupDn = Directory.GetGroupDn(role);
|
||||||
|
if (string.IsNullOrWhiteSpace(groupDn) ||
|
||||||
|
!groupDn.StartsWith("CN=", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
!groupDn.EndsWith($",{Directory.BaseDn}", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"The security-group mapping for {role} must identify a group beneath BaseDn.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(Directory.RemoteDesktopGroupDn) &&
|
if (!string.IsNullOrWhiteSpace(Directory.RemoteDesktopGroupDn) &&
|
||||||
@@ -168,6 +176,12 @@ public sealed class ActiveDirectoryOptions
|
|||||||
|
|
||||||
public string AdministrativeOuDn { get; init; } = "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
public string AdministrativeOuDn { get; init; } = "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
||||||
|
|
||||||
|
public string ProfessorGroupDn { get; init; } = "CN=SGU-Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
||||||
|
|
||||||
|
public string StudentGroupDn { get; init; } = "CN=SGU-Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
||||||
|
|
||||||
|
public string AdministrativeGroupDn { get; init; } = "CN=SGU-Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
||||||
|
|
||||||
public string RemoteDesktopGroupDn { get; init; } = string.Empty;
|
public string RemoteDesktopGroupDn { get; init; } = string.Empty;
|
||||||
|
|
||||||
public string DefaultCompany { get; init; } = "La Salle";
|
public string DefaultCompany { get; init; } = "La Salle";
|
||||||
@@ -181,4 +195,12 @@ public sealed class ActiveDirectoryOptions
|
|||||||
InstitutionalRole.Administrative => AdministrativeOuDn,
|
InstitutionalRole.Administrative => AdministrativeOuDn,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(role), role, null)
|
_ => throw new ArgumentOutOfRangeException(nameof(role), role, null)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public string GetGroupDn(InstitutionalRole role) => role switch
|
||||||
|
{
|
||||||
|
InstitutionalRole.Professor => ProfessorGroupDn,
|
||||||
|
InstitutionalRole.Student => StudentGroupDn,
|
||||||
|
InstitutionalRole.Administrative => AdministrativeGroupDn,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(role), role, null)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =
|
||||||
@@ -109,6 +111,12 @@ public sealed class ActiveDirectorySynchronizer(
|
|||||||
user.CommitChanges();
|
user.CommitChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Role membership is part of account provisioning, not optional
|
||||||
|
// enrichment. Do it before changing the password so a missing or
|
||||||
|
// inaccessible authorization group cannot leave a newly usable
|
||||||
|
// account without its required classification.
|
||||||
|
EnsureRoleGroupMembership(user, identity);
|
||||||
|
|
||||||
// The exact institutional password received by the broker is passed to AD.
|
// The exact institutional password received by the broker is passed to AD.
|
||||||
// It is not derived, transformed, written to disk, or included in logs.
|
// It is not derived, transformed, written to disk, or included in logs.
|
||||||
user.Invoke("SetPassword", [password]);
|
user.Invoke("SetPassword", [password]);
|
||||||
@@ -158,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);
|
||||||
@@ -195,6 +204,83 @@ 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"]);
|
||||||
|
string? userDn = Convert.ToString(user.Properties["distinguishedName"].Value);
|
||||||
|
if (string.IsNullOrWhiteSpace(userDn))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Active Directory did not return a distinguished name for {identity.UserName}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string groupDn = options.GetGroupDn(identity.Role);
|
||||||
|
using DirectoryEntry group = Bind(groupDn);
|
||||||
|
_ = group.NativeObject;
|
||||||
|
if (group.Properties["member"].Contains(userDn))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
group.Properties["member"].Add(userDn);
|
||||||
|
group.CommitChanges();
|
||||||
|
logger.LogInformation(
|
||||||
|
BrokerEventIds.DirectoryRoleGroupMembershipAdded,
|
||||||
|
"Added {InstitutionalUser} with role {Role} to Active Directory security group {GroupDn}.",
|
||||||
|
identity.UserName,
|
||||||
|
identity.Role,
|
||||||
|
groupDn);
|
||||||
|
}
|
||||||
|
|
||||||
private void TryEnsureRemoteDesktopGroupMembership(DirectoryEntry user, string institutionalUser)
|
private void TryEnsureRemoteDesktopGroupMembership(DirectoryEntry user, string institutionalUser)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(options.RemoteDesktopGroupDn))
|
if (string.IsNullOrWhiteSpace(options.RemoteDesktopGroupDn))
|
||||||
|
|||||||
@@ -50,6 +50,9 @@
|
|||||||
"ProfessorOuDn": "OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
"ProfessorOuDn": "OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||||
"StudentOuDn": "OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
"StudentOuDn": "OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||||
"AdministrativeOuDn": "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
"AdministrativeOuDn": "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||||
|
"ProfessorGroupDn": "CN=SGU-Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||||
|
"StudentGroupDn": "CN=SGU-Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||||
|
"AdministrativeGroupDn": "CN=SGU-Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||||
"RemoteDesktopGroupDn": "",
|
"RemoteDesktopGroupDn": "",
|
||||||
"DefaultCompany": "La Salle",
|
"DefaultCompany": "La Salle",
|
||||||
"CreateMissingOus": false
|
"CreateMissingOus": false
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using SGU.AuthBroker.Options;
|
using SGU.AuthBroker.Options;
|
||||||
|
using SGU.AuthBroker.Core.Identity;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace SGU.AuthBroker.Tests;
|
namespace SGU.AuthBroker.Tests;
|
||||||
@@ -28,4 +29,31 @@ public sealed class BrokerOptionsTests
|
|||||||
|
|
||||||
Assert.Contains("thumbprint", exception.Message, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("thumbprint", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(InstitutionalRole.Student, "CN=SGU-Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
|
||||||
|
[InlineData(InstitutionalRole.Administrative, "CN=SGU-Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
|
||||||
|
[InlineData(InstitutionalRole.Professor, "CN=SGU-Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
|
||||||
|
public void DefaultRoleGroupMappingsMatchInstitutionalPrefixes(InstitutionalRole role, string expectedGroupDn)
|
||||||
|
{
|
||||||
|
ActiveDirectoryOptions options = new();
|
||||||
|
|
||||||
|
Assert.Equal(expectedGroupDn, options.GetGroupDn(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateRejectsARoleGroupOutsideTheConfiguredDirectoryBase()
|
||||||
|
{
|
||||||
|
BrokerOptions options = new()
|
||||||
|
{
|
||||||
|
Directory = new ActiveDirectoryOptions
|
||||||
|
{
|
||||||
|
StudentGroupDn = "CN=SGU-Alumnos,DC=example,DC=invalid"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(options.Validate);
|
||||||
|
|
||||||
|
Assert.Contains("security-group", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/",
|
||||||
|
|||||||
Reference in New Issue
Block a user