From 7a4f599f5569b93abe77e2f3eeee0bbf52065bd2 Mon Sep 17 00:00:00 2001 From: Alejandro Rosales Date: Tue, 8 Sep 2026 11:33:02 -0600 Subject: [PATCH] Classify SGU accounts into AD role groups --- docs/architecture.md | 8 +++ docs/lab-runbook.md | 1 + docs/monitoring.md | 3 +- scripts/Deploy-AuthBroker.ps1 | 57 +++++++++++++++++-- scripts/Get-SguBrokerLog.ps1 | 1 + scripts/Publish-GiteaRelease.ps1 | 1 + src/SGU.AuthBroker/BrokerEventIds.cs | 1 + src/SGU.AuthBroker/Options/BrokerOptions.cs | 22 +++++++ .../Services/ActiveDirectorySynchronizer.cs | 33 +++++++++++ src/SGU.AuthBroker/appsettings.json | 3 + .../BrokerOptionsTests.cs | 28 +++++++++ 11 files changed, 153 insertions(+), 5 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index bdf9174..94cb252 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,6 +72,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 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 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` diff --git a/docs/lab-runbook.md b/docs/lab-runbook.md index 07e8bbd..da38346 100644 --- a/docs/lab-runbook.md +++ b/docs/lab-runbook.md @@ -98,6 +98,7 @@ Get-Service SGUAuthBroker Get-NetTCPConnection -LocalPort 8443 -State Listen sc.exe qfailure SGUAuthBroker 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 diff --git a/docs/monitoring.md b/docs/monitoring.md index 94ff79b..8c099f2 100644 --- a/docs/monitoring.md +++ b/docs/monitoring.md @@ -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 disponible; - `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 respuesta SGU. diff --git a/scripts/Deploy-AuthBroker.ps1 b/scripts/Deploy-AuthBroker.ps1 index b462352..9a2d762 100644 --- a/scripts/Deploy-AuthBroker.ps1 +++ b/scripts/Deploy-AuthBroker.ps1 @@ -31,6 +31,9 @@ param( [string]$BaseDn = 'DC=lci,DC=lasalle,DC=mx', [string]$DomainNetbios = 'LCI', [string]$UpnSuffix = 'lci.lasalle.mx', + [string]$ProfessorGroupDn = '', + [string]$StudentGroupDn = '', + [string]$AdministrativeGroupDn = '', [string]$RemoteDesktopGroupDn = '', [ValidateLength(1, 64)] [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.' } +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) { - 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)) { New-ADOrganizationalUnit -Name $usersOuName -Path $BaseDn -ProtectedFromAccidentalDeletion $true -Server $LdapHost | Out-Null } @@ -117,8 +130,41 @@ 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." + } + + $roleGroup = Get-ADGroup -Identity $definition.Dn -Server $LdapHost -ErrorAction SilentlyContinue + if (-not $roleGroup -and $CreateMissingOus) { + $groupDnMatch = [regex]::Match($definition.Dn, '^CN=(?[^,]+),(?.+)$', [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) { - Import-Module ActiveDirectory -ErrorAction Stop $remoteDesktopGroup = Get-ADGroup -Identity $RemoteDesktopGroupDn -Server $LdapHost -ErrorAction Stop if ($remoteDesktopGroup.GroupCategory -ne 'Security' -or -not $remoteDesktopGroup.DistinguishedName.EndsWith(",$BaseDn", [StringComparison]::OrdinalIgnoreCase)) { @@ -186,6 +232,9 @@ $productionSettings = @{ ProfessorOuDn = "OU=Docentes,OU=Usuarios-SGU,$BaseDn" StudentOuDn = "OU=Alumnos,OU=Usuarios-SGU,$BaseDn" AdministrativeOuDn = "OU=Administrativos,OU=Usuarios-SGU,$BaseDn" + ProfessorGroupDn = $ProfessorGroupDn + StudentGroupDn = $StudentGroupDn + AdministrativeGroupDn = $AdministrativeGroupDn RemoteDesktopGroupDn = $RemoteDesktopGroupDn DefaultCompany = $DefaultCompany CreateMissingOus = [bool]$CreateMissingOus diff --git a/scripts/Get-SguBrokerLog.ps1 b/scripts/Get-SguBrokerLog.ps1 index 35dfda7..ac9cf93 100644 --- a/scripts/Get-SguBrokerLog.ps1 +++ b/scripts/Get-SguBrokerLog.ps1 @@ -32,6 +32,7 @@ $eventNames = @{ 1300 = 'DirectorySynchronizationFailure' 1301 = 'DirectoryOptionalMetadataFailure' 1302 = 'DirectoryGroupMembershipFailure' + 1303 = 'DirectoryRoleGroupMembershipAdded' } # Keep these reads unfiltered. Besides making archived and current logs behave diff --git a/scripts/Publish-GiteaRelease.ps1 b/scripts/Publish-GiteaRelease.ps1 index ea00652..d9c91a6 100644 --- a/scripts/Publish-GiteaRelease.ps1 +++ b/scripts/Publish-GiteaRelease.ps1 @@ -102,6 +102,7 @@ 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-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 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/src/SGU.AuthBroker/BrokerEventIds.cs b/src/SGU.AuthBroker/BrokerEventIds.cs index 6b279b5..d48819c 100644 --- a/src/SGU.AuthBroker/BrokerEventIds.cs +++ b/src/SGU.AuthBroker/BrokerEventIds.cs @@ -21,4 +21,5 @@ internal static class BrokerEventIds internal static readonly EventId DirectorySynchronizationFailure = new(1300, nameof(DirectorySynchronizationFailure)); internal static readonly EventId DirectoryOptionalMetadataFailure = new(1301, nameof(DirectoryOptionalMetadataFailure)); internal static readonly EventId DirectoryGroupMembershipFailure = new(1302, nameof(DirectoryGroupMembershipFailure)); + internal static readonly EventId DirectoryRoleGroupMembershipAdded = new(1303, nameof(DirectoryRoleGroupMembershipAdded)); } diff --git a/src/SGU.AuthBroker/Options/BrokerOptions.cs b/src/SGU.AuthBroker/Options/BrokerOptions.cs index fa54e6b..1a2e0c2 100644 --- a/src/SGU.AuthBroker/Options/BrokerOptions.cs +++ b/src/SGU.AuthBroker/Options/BrokerOptions.cs @@ -87,6 +87,14 @@ public sealed class BrokerOptions { 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) && @@ -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 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 DefaultCompany { get; init; } = "La Salle"; @@ -181,4 +195,12 @@ public sealed class ActiveDirectoryOptions InstitutionalRole.Administrative => AdministrativeOuDn, _ => 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) + }; } diff --git a/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs b/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs index 4c19c52..9a59562 100644 --- a/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs +++ b/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs @@ -109,6 +109,12 @@ public sealed class ActiveDirectorySynchronizer( 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. // It is not derived, transformed, written to disk, or included in logs. user.Invoke("SetPassword", [password]); @@ -195,6 +201,33 @@ public sealed class ActiveDirectorySynchronizer( } } + 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) { if (string.IsNullOrWhiteSpace(options.RemoteDesktopGroupDn)) diff --git a/src/SGU.AuthBroker/appsettings.json b/src/SGU.AuthBroker/appsettings.json index a1c52c3..c9194f9 100644 --- a/src/SGU.AuthBroker/appsettings.json +++ b/src/SGU.AuthBroker/appsettings.json @@ -50,6 +50,9 @@ "ProfessorOuDn": "OU=Docentes,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", + "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": "", "DefaultCompany": "La Salle", "CreateMissingOus": false diff --git a/tests/SGU.AuthBroker.Tests/BrokerOptionsTests.cs b/tests/SGU.AuthBroker.Tests/BrokerOptionsTests.cs index 4bc8bae..abed443 100644 --- a/tests/SGU.AuthBroker.Tests/BrokerOptionsTests.cs +++ b/tests/SGU.AuthBroker.Tests/BrokerOptionsTests.cs @@ -1,4 +1,5 @@ using SGU.AuthBroker.Options; +using SGU.AuthBroker.Core.Identity; using Xunit; namespace SGU.AuthBroker.Tests; @@ -28,4 +29,31 @@ public sealed class BrokerOptionsTests 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(options.Validate); + + Assert.Contains("security-group", exception.Message, StringComparison.OrdinalIgnoreCase); + } }