|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | 5 | import base64 |
| 6 | +import tempfile |
| 7 | +import time |
6 | 8 | from datetime import datetime |
7 | 9 | from enum import Enum |
8 | | -from typing import Any, Generic, TypeVar |
| 10 | +from typing import TYPE_CHECKING, Any, Generic, TypeVar |
9 | 11 |
|
10 | 12 | import ldap |
11 | 13 | import ldap.ldapobject |
|
17 | 19 | from .generic import GenericNetgroupMember, GenericPasswordPolicy, ProtocolName |
18 | 20 | from .nfs import NFSExport |
19 | 21 |
|
| 22 | +if TYPE_CHECKING: |
| 23 | + from .kdc import KDC |
| 24 | + |
20 | 25 | __all__ = [ |
21 | 26 | "LDAPRoleType", |
22 | 27 | "LDAPPasswordPolicy", |
@@ -239,6 +244,197 @@ def setup(self) -> None: |
239 | 244 | except ldap.TYPE_OR_VALUE_EXISTS: |
240 | 245 | pass |
241 | 246 |
|
| 247 | + def enable_gssapi(self, kdc: KDC) -> None: |
| 248 | + """ |
| 249 | + Configure Directory Server for GSSAPI/SASL authentication. |
| 250 | +
|
| 251 | + This method sets up the LDAP server to accept GSSAPI (Kerberos) authentication |
| 252 | + by creating a service principal, exporting the keytab, and configuring Directory Server. |
| 253 | +
|
| 254 | + .. code-block:: python |
| 255 | + :caption: Example usage |
| 256 | +
|
| 257 | + @pytest.mark.topology(KnownTopology.LDAP_KRB5) |
| 258 | + def test_ldap_gssapi(client: Client, ldap: LDAP, kdc: KDC): |
| 259 | + # Enable GSSAPI on LDAP server |
| 260 | + ldap.enable_gssapi(kdc) |
| 261 | +
|
| 262 | + ldap.user('testuser').add() |
| 263 | + kdc.principal('testuser').add() |
| 264 | +
|
| 265 | + # Configure SSSD to use GSSAPI |
| 266 | + client.sssd.domain["ldap_sasl_mech"] = "GSSAPI" |
| 267 | + client.sssd.start() |
| 268 | +
|
| 269 | + result = client.tools.id('testuser') |
| 270 | + assert result is not None |
| 271 | +
|
| 272 | + :param kdc: KDC role object to create service principal |
| 273 | + :type kdc: KDC |
| 274 | + """ |
| 275 | + |
| 276 | + # 1. Install required packages |
| 277 | + self.host.conn.run( |
| 278 | + "dnf install -y cyrus-sasl-gssapi krb5-workstation || " |
| 279 | + "yum install -y cyrus-sasl-gssapi krb5-workstation", |
| 280 | + ) |
| 281 | + self.host.conn.run("rpm -q cyrus-sasl-gssapi") |
| 282 | + |
| 283 | + # 2. Create LDAP service principal |
| 284 | + ldap_principal = f"ldap/{self.host.hostname}" |
| 285 | + kdc.principal(ldap_principal).add(password=None) |
| 286 | + |
| 287 | + # 3. Export keytab to LDAP server (same transfer pattern as LDAPKRB5TopologyController) |
| 288 | + keytab_path = "/etc/dirsrv/ds.keytab" |
| 289 | + keytab_staging = "/tmp/sssd-test-framework-ds.keytab" |
| 290 | + qualified_principal = kdc.qualify(ldap_principal) |
| 291 | + kdc.host.conn.run(f"rm -f {keytab_staging}", raise_on_error=False) |
| 292 | + kdc.host.conn.run(f"kadmin.local -q 'ktadd -k {keytab_staging} -norandkey \"{qualified_principal}\"'") |
| 293 | + with tempfile.NamedTemporaryFile() as tmp: |
| 294 | + kdc.host.fs.download(keytab_staging, tmp.name) |
| 295 | + self.host.fs.upload(tmp.name, keytab_path) |
| 296 | + kdc.host.conn.run(f"rm -f {keytab_staging}", raise_on_error=False) |
| 297 | + self.host.conn.run(f"chown dirsrv:dirsrv {keytab_path}") |
| 298 | + self.host.conn.run(f"chmod 600 {keytab_path}") |
| 299 | + |
| 300 | + # 4. Copy krb5.conf from KDC to LDAP server |
| 301 | + krb5_conf = kdc.config() |
| 302 | + self.host.conn.run(f"cat > /etc/krb5.conf << 'EOFKRB5'\n{krb5_conf}\nEOFKRB5") |
| 303 | + |
| 304 | + # Add default_keytab_name to krb5.conf as fallback |
| 305 | + self.host.conn.run(f"sed -i '/\\[libdefaults\\]/a\\ default_keytab_name = {keytab_path}' /etc/krb5.conf") |
| 306 | + |
| 307 | + # 5. Configure Cyrus SASL to use the keytab |
| 308 | + # This is critical - without this, the SASL GSSAPI plugin won't know where to find the keytab |
| 309 | + self.host.conn.run( |
| 310 | + f"mkdir -p /etc/sasl2 && " |
| 311 | + f"cat > /etc/sasl2/slapd.conf << 'EOFSASL'\n" |
| 312 | + f"mech_list: GSSAPI EXTERNAL PLAIN LOGIN\n" |
| 313 | + f"keytab: {keytab_path}\n" |
| 314 | + f"EOFSASL" |
| 315 | + ) |
| 316 | + |
| 317 | + # Also create for other possible SASL application names |
| 318 | + self.host.conn.run("cp /etc/sasl2/slapd.conf /etc/sasl2/ns-slapd.conf") |
| 319 | + self.host.conn.run("cp /etc/sasl2/slapd.conf /etc/sasl2/ldap.conf") |
| 320 | + |
| 321 | + # 6. Set KRB5_KTNAME environment variable for Directory Server via sysconfig |
| 322 | + # Note: systemd Environment= directives don't work reliably in containers |
| 323 | + # Use EnvironmentFile instead |
| 324 | + self.host.conn.run(f"echo 'KRB5_KTNAME={keytab_path}' > /etc/sysconfig/dirsrv-localhost") |
| 325 | + |
| 326 | + # 7. Configure SASL identity mapping in Directory Server |
| 327 | + # Align default Kerberos maps with the data suffix (sssd-qe krb_credential_cache) and |
| 328 | + # add a high-priority map for host/ldap service principals used by SSSD GSSAPI binds. |
| 329 | + realm = kdc.realm |
| 330 | + base_dn = self.naming_context |
| 331 | + binddn = self.host.binddn |
| 332 | + bindpw = self.host.bindpw |
| 333 | + |
| 334 | + sasl_ldif = "" |
| 335 | + for cn in ( |
| 336 | + "Kerberos uid mapping", |
| 337 | + "rfc 2829 dn syntax", |
| 338 | + "rfc 2829 u syntax", |
| 339 | + "uid mapping", |
| 340 | + ): |
| 341 | + sasl_ldif += ( |
| 342 | + f"dn: cn={cn},cn=mapping,cn=sasl,cn=config\n" |
| 343 | + "changetype: modify\n" |
| 344 | + "replace: nsSaslMapBaseDNTemplate\n" |
| 345 | + f"nsSaslMapBaseDNTemplate: {base_dn}\n" |
| 346 | + "\n" |
| 347 | + ) |
| 348 | + self.host.conn.run( |
| 349 | + f"ldapmodify -x -D '{binddn}' -w '{bindpw}' -H ldap://localhost", |
| 350 | + input=sasl_ldif, |
| 351 | + ) |
| 352 | + for cn in ( |
| 353 | + "SSSD service principals", |
| 354 | + "SSSD service principals no realm", |
| 355 | + ): |
| 356 | + self.host.conn.run( |
| 357 | + f"ldapmodify -x -D '{binddn}' -w '{bindpw}' -H ldap://localhost", |
| 358 | + input=(f"dn: cn={cn},cn=mapping,cn=sasl,cn=config\n" "changetype: delete\n"), |
| 359 | + raise_on_error=False, |
| 360 | + ) |
| 361 | + |
| 362 | + # cn=Directory Manager is a bind identity, not a searchable LDAP entry (BASE |
| 363 | + # search returns No such object). Map GSSAPI clients to a real entry under the |
| 364 | + # data suffix, per 389-ds server-to-server SASL examples (full target DN + |
| 365 | + # (objectclass=*)). |
| 366 | + people_ou = f"ou=People,{base_dn}" |
| 367 | + gssapi_proxy_dn = f"uid=sssd-gssapi,{people_ou}" |
| 368 | + bootstrap_ldif = ( |
| 369 | + f"dn: {people_ou}\n" |
| 370 | + "changetype: add\n" |
| 371 | + "objectClass: organizationalUnit\n" |
| 372 | + "ou: People\n" |
| 373 | + "\n" |
| 374 | + f"dn: {gssapi_proxy_dn}\n" |
| 375 | + "changetype: add\n" |
| 376 | + "objectClass: top\n" |
| 377 | + "objectClass: person\n" |
| 378 | + "objectClass: organizationalPerson\n" |
| 379 | + "objectClass: inetOrgPerson\n" |
| 380 | + "cn: SSSD GSSAPI proxy\n" |
| 381 | + "sn: proxy\n" |
| 382 | + "uid: sssd-gssapi\n" |
| 383 | + ) |
| 384 | + self.host.conn.run( |
| 385 | + f"ldapmodify -x -D '{binddn}' -w '{bindpw}' -H ldap://localhost", |
| 386 | + input=bootstrap_ldif, |
| 387 | + raise_on_error=False, |
| 388 | + ) |
| 389 | + service_map_ldif = ( |
| 390 | + "dn: cn=SSSD service principals,cn=mapping,cn=sasl,cn=config\n" |
| 391 | + "changetype: add\n" |
| 392 | + "objectClass: top\n" |
| 393 | + "objectClass: nsSaslMapping\n" |
| 394 | + "cn: SSSD service principals\n" |
| 395 | + f"nsSaslMapRegexString: ^(host|ldap)/.*@{realm}$\n" |
| 396 | + f"nsSaslMapBaseDNTemplate: {gssapi_proxy_dn}\n" |
| 397 | + "nsSaslMapFilterTemplate: (objectclass=*)\n" |
| 398 | + "nsSaslMapPriority: 10\n" |
| 399 | + "\n" |
| 400 | + "dn: cn=SSSD service principals no realm,cn=mapping,cn=sasl,cn=config\n" |
| 401 | + "changetype: add\n" |
| 402 | + "objectClass: top\n" |
| 403 | + "objectClass: nsSaslMapping\n" |
| 404 | + "cn: SSSD service principals no realm\n" |
| 405 | + "nsSaslMapRegexString: ^(host|ldap)/.*$\n" |
| 406 | + f"nsSaslMapBaseDNTemplate: {gssapi_proxy_dn}\n" |
| 407 | + "nsSaslMapFilterTemplate: (objectclass=*)\n" |
| 408 | + "nsSaslMapPriority: 11\n" |
| 409 | + ) |
| 410 | + self.host.conn.run( |
| 411 | + f"ldapmodify -x -D '{binddn}' -w '{bindpw}' -H ldap://localhost", |
| 412 | + input=service_map_ldif, |
| 413 | + ) |
| 414 | + verify = self.host.conn.run( |
| 415 | + f"ldapsearch -x -D '{binddn}' -w '{bindpw}' -H ldap://localhost " |
| 416 | + f"-b '{gssapi_proxy_dn}' -s base '(objectclass=*)' dn", |
| 417 | + raise_on_error=False, |
| 418 | + ) |
| 419 | + if verify.rc != 0 or "dn:" not in (verify.stdout or ""): |
| 420 | + raise RuntimeError( |
| 421 | + f"SASL GSSAPI proxy entry {gssapi_proxy_dn} is not searchable: " f"{verify.stdout or verify.stderr}" |
| 422 | + ) |
| 423 | + |
| 424 | + # 8. Reload systemd and restart Directory Server |
| 425 | + self.host.conn.run("systemctl daemon-reload") |
| 426 | + self.host.conn.run("systemctl restart dirsrv@localhost") |
| 427 | + |
| 428 | + # Wait for Directory Server to fully start with GSSAPI support |
| 429 | + time.sleep(3) |
| 430 | + |
| 431 | + klist = self.host.conn.run(f"klist -kt {keytab_path}", raise_on_error=False) |
| 432 | + if qualified_principal not in (klist.stdout or ""): |
| 433 | + raise RuntimeError( |
| 434 | + f"LDAP GSSAPI keytab {keytab_path} does not contain {qualified_principal}: " |
| 435 | + f"{klist.stdout or klist.stderr}" |
| 436 | + ) |
| 437 | + |
242 | 438 | def fqn(self, name: str) -> str: |
243 | 439 | """ |
244 | 440 | Return fully qualified name in form name@domain. |
|
0 commit comments