fix(migrate): classify by directory identity before local password (H16) - #63
Conversation
ec903a9 to
ee00f6f
Compare
CodeQL note — the flagged alerts are pre-existing patterns, re-attributedCodeQL reports "new alerts in code changed by this pull request". These are alerts that already exist on Baseline on Per rule:
Happy to dismiss them individually, or — probably more useful — treat the 52 🤖 Generated with Claude Code |
ee00f6f to
a910a4a
Compare
b004882 to
b61beac
Compare
classifyUser tested u.PasswordHash != "" BEFORE the directory signal, so any
AD-backed user who also carried a local hash migrated as an app-LOCAL user keyed
by that credential. Three things went wrong at once:
- the central ended up holding a STANDING PASSWORD for someone whose identity
is directory-governed, so the migrated account outlived AD-side disablement,
lockout and password policy — exactly what this package's doc comment
promises never happens ("no record or password is copied");
- Classify never BLOCKS a local user, so an AD population misclassified this
way sailed straight past the "central is on a different AD" guard that exists
to stop unauthenticatable users being imported;
- Apply flipped allow_local_users on the target for accounts that must never
use the app-local login path.
Composes with SA-7: that bug was one way an AD user acquired a local hash in the
first place. Fixing SA-7 reduces the population going forward but neither
eliminates it (master-admin break-glass stays legitimate) nor cleans existing
data, so this inversion is independently necessary.
Fix: resolve the directory key first; only a user with NO directory identity is
local. A KindAD entry no longer carries a password hash — the central re-binds
that person from the same AD, so copying the credential would recreate the very
shadow account this prevents. A HadLocalPassword BOOLEAN (never the hash) travels
instead so the dry run can tell the operator which break-glass logins do not
survive the move.
directoryKey prefers SAMAccountName but falls back to ldap/kerberos mappings,
because handleImportLDAPUsers provisions a user with an ldap mapping and NO
SAMAccountName until first login — without the fallback such a user is
misclassified as local or dropped entirely. The `local` provider is deliberately
not consulted: every LDAP/Kerberos JIT provision writes BOTH an ldap and a local
mapping, which is exactly how localUsername happily returned an AD username under
the old precedence.
Selection is deterministic across backends: GetMappingsForUser returns insertion
order on Bolt and UNORDERED rows on Postgres, and a user commonly carries both a
UPN and a sAMAccountName form. The bare form wins, ties break lexicographically,
so both backends produce an identical bundle.
OwnerAppID != "" short-circuits to local: an app-local user is local by
construction and must never be resolved against the directory.
Note on the two directory predicates: handler.isDirectoryBacked (SA-7) is
deny-by-default; migrate.directoryKey is an allow-list of ldap/kerberos. The
asymmetry is INTENTIONAL — one returns a verdict, the other must return a key.
Documented in both places; do not unify (internal/migrate importing from
internal/handler would invert the dependency direction).
WIRE BREAK: SchemaRev 1 -> 2. Classification happens on the SOURCE side, so a
patched central cannot trust the Kind values in a rev-1 bundle. guardMigrationCall
exact-matches the rev, so this surfaces as "upgrade the older deployment first"
rather than a silent import of buggy classification. In-flight migrations must
upgrade the source first — release notes.
Also fixed: dishonest preflight counts. Apply skips a user with no effective
roles entirely while Classify counted them as migrating, so preflight promised N
and Apply delivered fewer with nothing explaining the gap. Report.NoRoles counts
them separately with a note. The AD block checks still run for them — "the
central cannot authenticate this person" is worth saying regardless of roles.
Tests (internal/migrate/classify_test.go): directory user with a local hash ->
AD, no hash carried, HadLocalPassword set; imported LDAP user with no
SAMAccountName still directory; genuine local user unchanged and keeps their
hash; app-local user never directory even with a stray SAMAccountName;
directoryKey order-independent across insertion orders; NoRoles accounting
matches Apply. The first two fail with the precedence reverted. Full suite green.
Stacked on the H15 audience fix — same functions, same hunks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
b61beac to
7996f81
Compare
Update — three more issues fixed before mergeAn adversarial review pass over this diff found three things worth fixing rather than shipping. One was a real security gap. The allow-list missed the documented multi-directory provider form
So H16 was unfixed for any deployment that followed the documentation: their directory users classified as Now
|
Fifth of six. Stacked on #62.
SchemaRev1 → 2. Classification happens on the SOURCE side, so a patched central cannot trust theKindvalues in a rev-1 bundle.guardMigrationCallexact-matches the rev, so this surfaces as a clear "incompatible migration bundle" rather than a silent import of buggy classification.Any in-flight migration must upgrade the source deployment first. This belongs in the release notes.
The bug
classifyUsertestedu.PasswordHash != ""before the directory signal, so any AD-backed user who also carried a local hash migrated as an app-local user keyed by that credential. Three things went wrong at once:Classifynever blocks a local user, so an AD population misclassified this way sailed straight past the "central is on a different AD" guard.Applyflippedallow_local_userson the target for accounts that must never use the app-local login path.This composes with SA-7 (#61): that bug was one way an AD user acquired a local hash in the first place. Fixing SA-7 reduces the population going forward but neither eliminates it (master-admin break-glass stays legitimate) nor cleans existing data, so the precedence inversion is independently necessary.
Approach
Resolve the directory key first; only a user with no directory identity is local. A
KindADentry no longer carries a password hash — the central re-binds that person from the same AD, so copying the credential would recreate the very shadow account this prevents. AHadLocalPasswordboolean (never the hash) travels instead, so the dry run can tell the operator which break-glass logins do not survive.directoryKeyprefersSAMAccountNamebut falls back toldap/kerberosmappings, becausehandleImportLDAPUsersprovisions a user with anldapmapping and noSAMAccountNameuntil first login. Thelocalprovider is deliberately not consulted: every JIT provision writes both anldapand alocalmapping, which is exactly howlocalUsernamehappily returned an AD username under the old precedence.Selection is deterministic across backends —
GetMappingsForUserreturns insertion order on Bolt and unordered rows on Postgres, so the bare (non-@) form wins and ties break lexicographically.Two issues an adversarial review caught in the first cut
directoryKeyfailed open. A swallowed error fromGetMappingsForUserreads as "no directory identity" → classifies LOCAL → exports the password hash. It now fails closed;TestClassifyFailsClosedOnMappingErrorpins it (and is written so it genuinely fails against the old code — the first draft of that test could not fail).allow_local_usersflipped for zero-role entries thatApplyskips andClassifyno longer counts — opening the target's local-login gate for a user never created.Also fixed: dishonest preflight counts
Applyskips a user with no effective roles entirely whileClassifycounted them as migrating, so preflight promised N and Apply delivered fewer with nothing explaining the gap.Report.NoRolesnow counts them separately, with a note.Note on the two directory predicates
handler.isDirectoryBacked(SA-7) is deny-by-default;migrate.directoryKeyis an allow-list ofldap/kerberos. The asymmetry is intentional — one returns a verdict, the other must return a KEY. Do not unify them;internal/migrateimporting frominternal/handlerwould invert the dependency direction.🤖 Generated with Claude Code