Compare commits

..
2 Commits
Author SHA1 Message Date
alexrg 1fe2006404 Handle absent AD role groups during deployment 2026-09-08 11:37:35 -06:00
alexrg 7a4f599f55 Classify SGU accounts into AD role groups 2026-09-08 11:33:02 -06:00
11 changed files with 158 additions and 5 deletions
+8
View File
@@ -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 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`
+1
View File
@@ -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
View File
@@ -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.
+56 -2
View File
@@ -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.'
} }
if ($CreateMissingOus) {
Import-Module ActiveDirectory -ErrorAction Stop Import-Module ActiveDirectory -ErrorAction Stop
$usersOuName = 'Usuarios-SGU' $usersOuName = 'Usuarios-SGU'
$usersOuDn = "OU=$usersOuName,$BaseDn" $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 (-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
+1
View File
@@ -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
+1
View File
@@ -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-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 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.
+1
View File
@@ -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)
};
} }
@@ -109,6 +109,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]);
@@ -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) private void TryEnsureRemoteDesktopGroupMembership(DirectoryEntry user, string institutionalUser)
{ {
if (string.IsNullOrWhiteSpace(options.RemoteDesktopGroupDn)) if (string.IsNullOrWhiteSpace(options.RemoteDesktopGroupDn))
+3
View File
@@ -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
@@ -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);
}
} }