Skip to content

fix(context): normalize SPN/objectClass to a list before dlt persistence - #12

Open
chryzsh wants to merge 1 commit into
SpecterOps:mainfrom
chryzsh:fix-ldap-spn-objectclass-normalization
Open

fix(context): normalize SPN/objectClass to a list before dlt persistence#12
chryzsh wants to merge 1 commit into
SpecterOps:mainfrom
chryzsh:fix-ldap-spn-objectclass-normalization

Conversation

@chryzsh

@chryzsh chryzsh commented Sep 8, 2026

Copy link
Copy Markdown

Description

ldap_resolved_principals failed to persist entirely against a real environment:

dlt.common.schema.exceptions.DataValidationError: In schema sccm: In Table:
ldap_resolved_principals Column: service_principal_name__v_text . Contract on
data_type with contract_mode=freeze is violated. Can't add variant column
service_principal_name__v_text for table ldap_resolved_principals because
data_types are frozen.

Reproduced identically across two separate, fresh output directories, ruling out stale pipeline state.

dlt infers a JSONL column's type from the rows it sees and, when a later row needs a different type, normally adds a <col>__v_<type> variant column to hold it. ldap_resolved_principals's schema contract is contract_mode=freeze (deliberately, to catch data-shape regressions early), so the promotion is rejected and the entire load job fails, not just the offending row. Zero rows land for the whole table.

Root cause: ADClient._entry_to_dict (clients/ad.py:201, values if len(values) > 1 else values[0]) collapses any LDAP multi-valued attribute to a bare scalar when there's exactly one value (an absent attribute is set to None earlier). servicePrincipalName is inherently multi-valued and its cardinality varies per-principal in any real domain: a user often has 0 or 1, a site server carries many. So across one real collect, context.py's _record_resolved_principal was handing dlt None, a bare string, and a list for the same column across different rows.

Impact: wiped AD node naming (44/50 AD nodes emitted as bare stubs in the affected run) and the SPN-based MSSQL-server discovery fallback.

Fix

New _as_multivalued_list() helper in context.py, applied to both service_principal_name and object_class in _record_resolved_principal, coercing None/scalar/list to always-a-list before the row is recorded. Matches what transforms.py's ad_props builder already assumed for both columns.

contract_mode=freeze is left untouched. It caught a real bug correctly; the fix is to the upstream data shape, not the contract.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

Testing

New tests in tests/ldap_resolved_principals_test.py: test_service_principal_name_scalar_is_normalized_to_list, test_service_principal_name_none_is_normalized_to_empty_list, test_object_class_scalar_is_normalized_to_list, test_resolved_principals_have_consistent_spn_type_across_mixed_cardinality (records principals with 0/1/many SPNs in one run and asserts every recorded row's type is a list). Full suite passes.

ldap_resolved_principals failed to persist entirely against a real
environment: dlt.common.schema.exceptions.DataValidationError, "Can't add
variant column service_principal_name__v_text ... data_types are frozen".
Reproduced identically across two separate fresh output directories, so
not stale state -- a deterministic bug in this environment's real data.

ADClient._entry_to_dict collapses a multi-valued LDAP attribute to a bare
scalar when there's exactly one value, and to None when absent, keeping a
list only for two-or-more. servicePrincipalName's cardinality genuinely
varies per-principal in a real domain (a user often has 0 or 1, a site
server carries many), so _record_resolved_principal was hand`ing dlt None,
a bare string, and a list for the same column across different rows in one
run -- guaranteeing the frozen-contract's variant-column rejection, which
fails the whole load job, not just the offending row. Wiped AD node naming
(44/50 nodes emitted as bare stubs) and the SPN-based MSSQL-server fallback.

Fix: new _as_multivalued_list() helper, applied to both
service_principal_name and object_class (identical collapsing bug, rarely
triggered since objectClass is almost never single-valued) before
recording -- matches what transforms.py's ad_props builder already
expected for both columns. contract_mode=freeze itself is correct and
untouched; the bug was upstream data-shape inconsistency.

Ticket: con-8bed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant