feat: add Windows DNS integration and LDAP/AD authentication

Windows DNS (WinRM):
- New dns_service.py: create/delete A-records via PowerShell over WinRM (NTLM)
- Idempotent create (removes existing record first), graceful delete
- DNS failures are non-fatal — deployment continues, error logged
- test-dns endpoint: GET /api/settings/test-dns
- Integrated into deploy_customer() and undeploy_customer()

LDAP / Active Directory auth:
- New ldap_service.py: service-account bind + user search + user bind (ldap3)
- Optional AD group restriction via ldap_group_dn
- Login flow: LDAP first → local fallback (prevents admin lockout)
- LDAP users auto-created with auth_provider="ldap" and role="viewer"
- test-ldap endpoint: GET /api/settings/test-ldap
- reset-password/reset-mfa guards extended to block LDAP users

All credentials (dns_password, ldap_bind_password) encrypted with Fernet.
New DB columns added via backwards-compatible migrations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 21:06:51 +01:00
parent bc9aa6624f
commit 7793ca3666
11 changed files with 623 additions and 15 deletions

View File

@@ -15,7 +15,7 @@ from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user
from app.models import SystemConfig, User
from app.services import npm_service
from app.services import dns_service, ldap_service, npm_service
from app.utils.config import get_system_config
from app.utils.security import encrypt_value
from app.utils.validators import SystemConfigUpdate
@@ -86,6 +86,14 @@ async def update_settings(
raw_secret = update_data.pop("azure_client_secret")
row.azure_client_secret_encrypted = encrypt_value(raw_secret)
# Handle DNS password encryption
if "dns_password" in update_data:
row.dns_password_encrypted = encrypt_value(update_data.pop("dns_password"))
# Handle LDAP bind password encryption
if "ldap_bind_password" in update_data:
row.ldap_bind_password_encrypted = encrypt_value(update_data.pop("ldap_bind_password"))
for field, value in update_data.items():
if hasattr(row, field):
setattr(row, field, value)
@@ -164,6 +172,64 @@ async def list_npm_certificates(
return result["certificates"]
@router.get("/test-dns")
async def test_dns(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Test connectivity to the Windows DNS server via WinRM.
Returns:
Dict with ``ok`` and ``message``.
"""
config = get_system_config(db)
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="System configuration not initialized.",
)
if not config.dns_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Windows DNS integration is not enabled.",
)
if not config.dns_server or not config.dns_username or not config.dns_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="DNS server, username, or password not configured.",
)
return await dns_service.test_dns_connection(config)
@router.get("/test-ldap")
async def test_ldap(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Test connectivity to the LDAP / Active Directory server.
Returns:
Dict with ``ok`` and ``message``.
"""
config = get_system_config(db)
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="System configuration not initialized.",
)
if not config.ldap_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="LDAP authentication is not enabled.",
)
if not config.ldap_server or not config.ldap_bind_dn or not config.ldap_bind_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="LDAP server, bind DN, or bind password not configured.",
)
return await ldap_service.test_ldap_connection(config)
@router.get("/branding")
async def get_branding(db: Session = Depends(get_db)):
"""Public endpoint — returns branding info for the login page (no auth required)."""