Migrate Local Linux Users and Groups to OpenLDAP

Migrate local users and groups to OpenLDAP on RHEL, Rocky Linux, AlmaLinux, Oracle Linux, and CentOS Stream while preserving UID, GID, passwords, groups, and file ownership.

Published

Updated

Read time 28 min read

Reviewed byDeepak Prasad

Migrate local Linux passwd group and shadow accounts to OpenLDAP

This guide migrates selected local Linux users and groups into OpenLDAP while preserving their numeric identities and existing file ownership.

You will learn how to:

  • Inventory local users and groups
  • Exclude system and service accounts
  • Detect duplicate names, UIDs and GIDs
  • Preserve primary and supplementary groups
  • Generate reviewable LDIF files
  • Import posixGroup and posixAccount entries
  • Reset passwords or preserve compatible shadow hashes
  • Validate the accounts through SSSD
  • Cut over systems without changing ownership
  • Roll back safely

The procedure applies to:

  • Red Hat Enterprise Linux
  • Rocky Linux
  • AlmaLinux
  • Oracle Linux
  • CentOS Stream

Tested on: Rocky Linux 10.2 on ldap-server.example.com and ldap-client.example.com; OpenLDAP 2.6.10; SSSD 2.12.0. The full workflow was run with miguser1 / miguser2 (UID/GID 1500115002) and group migdevs (15010).

The lab uses:

Setting Value
Source Linux server ldap-server.example.com (source files and generator)
OpenLDAP server ldap-server.example.com
Validation client ldap-client.example.com
LDAP suffix dc=example,dc=com
Users OU ou=people,dc=example,dc=com
Groups OU ou=groups,dc=example,dc=com
Identity schema RFC2307
User object classes inetOrgPerson, posixAccount
Group object class posixGroup
Membership attribute memberUid
LDAP transport StartTLS

Complete these lessons first:

IMPORTANT
Do not migrate every entry from /etc/passwd. Create and review an explicit list of accounts that should become centralized identities. This guide does not move slapd itself to another host; for that workflow, see Migrate OpenLDAP to a new server.

Understand What Is and Is Not Migrated

Local account files map into LDAP attributes like this:

Local source LDAP attribute
Username uid
Full name or GECOS cn, sn, givenName, gecos
UID uidNumber
Primary GID gidNumber
Home directory homeDirectory
Login shell loginShell
Shadow hash userPassword, optionally
Group name cn
Group GID gidNumber
Supplementary members memberUid

Each migrated user entry uses these object classes:

ldif
objectClass: top
objectClass: inetOrgPerson
objectClass: posixAccount

Each migrated group entry uses:

ldif
objectClass: top
objectClass: posixGroup

This guide uses RFC2307 supplementary membership with memberUid usernames, which matches the SSSD client design in Configure an OpenLDAP client with SSSD. RFC2307bis stores full member DNs instead; do not mix both models in one deployment.

LDAP stores identity metadata only. These items are not migrated automatically:

  • Home-directory files
  • Mailboxes
  • Cron jobs
  • SSH authorized keys
  • Sudo policies
  • SELinux mappings
  • Application permissions
  • File ACLs on remote systems
  • Password-policy state from /etc/shadow
  • Local system and daemon accounts

When home directories must be centralized, plan storage separately. See Configure LDAP users with NFS home directories and autofs.

Preserving uidNumber and gidNumber is critical because Linux files store numeric ownership, not usernames. Changing those values makes existing files appear to belong to another identity until ownership is corrected. SSSD also treats the numeric UID as a key identity value, so changing it can produce conflicting cached identities.


Inventory and Select Local Users and Groups

Run this section on ldap-server.example.com. Back up the source files before you build an allowlist.

Create a protected working directory:

bash
sudo install -d -m 0700 /root/local-account-migration

Copy the account files you will analyze:

bash
sudo cp -a /etc/passwd /etc/group /etc/shadow /etc/gshadow /root/local-account-migration/

Record the distribution account policy:

bash
grep -E '^(UID_MIN|UID_MAX|GID_MIN|GID_MAX)' /etc/login.defs

Sample output:

output
UID_MIN                  1000
UID_MAX                 60000
GID_MIN                  1000
GID_MAX                 60000

UID_MIN is a useful first filter, but it is not a migration policy by itself. The final list must be an explicit reviewed allowlist.

Build an initial inventory of higher-numbered accounts:

bash
awk -F: '
  $3 >= 1000 {
    printf "%-20s uid=%-8s gid=%-8s home=%-30s shell=%s\n",
           $1, $3, $4, $6, $7
  }
' /etc/passwd

Review accounts with disabled shells before you add them to the allowlist:

bash
awk -F: '
  $7 ~ /(nologin|false)$/ {
    print $1 ":" $3 ":" $7
  }
' /etc/passwd

Sample output on the lab source host:

output
nobody:65534:/usr/sbin/nologin
systemd-network:192:/usr/sbin/nologin
svcbackup:990:/usr/sbin/nologin

Exclude these categories unless a documented application requires centralized identity:

  • root
  • System accounts
  • Package-created service users
  • Daemon accounts
  • nobody
  • Accounts with /usr/sbin/nologin or /bin/false, unless deliberately selected
  • Accounts already present in LDAP
  • Temporary installation or automation accounts

Create an explicit user allowlist:

text
jdoe
asmith
bwilson

Create a matching group allowlist. Include every selected user’s primary group, not only supplementary groups:

text
jdoe
asmith
bwilson
developers
operations
linux-admins

RFC2307 supplementary membership uses plain usernames in memberUid, such as memberUid: jdoe. Store the value from the user’s LDAP uid attribute, not a client presentation name such as [email protected].

Selection criteria should include:

  • Account owner
  • Account purpose
  • Login shell
  • Home directory
  • UID and GID
  • Primary group
  • Supplementary groups
  • Last known use
  • Whether the identity exists on other servers

Do not rely on legacy migrationtools Perl scripts and global configuration files. A small reviewed generator is easier to constrain, audit, and adapt to your directory layout.


Detect Username, UID and GID Conflicts

Conflicts must be checked for username, group name, uidNumber, gidNumber, and homeDirectory. Two source servers can contain the same username with different UIDs, or different usernames with the same UID. Resolve those collisions before LDIF generation.

Export existing LDAP identities into files the generator reads during preflight. Write each export to a temporary file first so a failed ldapsearch cannot leave an empty snapshot behind:

bash
LDAP_EXPORT_TMP=$(mktemp)
bash
if ! ldapsearch -x -ZZ -o ldif-wrap=no -H ldap://ldap-server.example.com -D "cn=admin,dc=example,dc=com" -W -b "dc=example,dc=com" -LLL "(|(objectClass=posixAccount)(objectClass=posixGroup))" dn objectClass uid cn uidNumber gidNumber homeDirectory loginShell memberUid > "$LDAP_EXPORT_TMP"; then
  rm -f "$LDAP_EXPORT_TMP"
  echo "LDAP identity export failed" >&2
  exit 1
fi
bash
sudo install -m 0600 "$LDAP_EXPORT_TMP" /root/local-account-migration/existing-ldap-identities.ldif
bash
rm -f "$LDAP_EXPORT_TMP"

The POSIX identity export does not include every entry under ou=people and ou=groups. Export those OUs separately so preflight can detect a non-POSIX entry that already occupies a target DN:

bash
LDAP_OU_EXPORT_TMP=$(mktemp)
bash
if ! ldapsearch -x -ZZ -o ldif-wrap=no -H ldap://ldap-server.example.com -D "cn=admin,dc=example,dc=com" -W -b "ou=people,dc=example,dc=com" -s sub -LLL "(objectClass=*)" dn objectClass uid cn uidNumber gidNumber homeDirectory loginShell > "$LDAP_OU_EXPORT_TMP" || ! ldapsearch -x -ZZ -o ldif-wrap=no -H ldap://ldap-server.example.com -D "cn=admin,dc=example,dc=com" -W -b "ou=groups,dc=example,dc=com" -s sub -LLL "(objectClass=*)" dn objectClass cn gidNumber memberUid >> "$LDAP_OU_EXPORT_TMP"; then
  rm -f "$LDAP_OU_EXPORT_TMP"
  echo "LDAP OU export failed" >&2
  exit 1
fi
bash
sudo install -m 0600 "$LDAP_OU_EXPORT_TMP" /root/local-account-migration/existing-ldap-ou-entries.ldif
bash
rm -f "$LDAP_OU_EXPORT_TMP"

The generator reads existing-ldap-identities.ldif and existing-ldap-ou-entries.ldif during preflight, keeps separate indexes for posixAccount and posixGroup entries, rejects duplicate allowlist lines and duplicate selected UID/GID values, and classifies each selected user and group before it writes migration LDIF. Import LDIF files are written only when preflight exits successfully.

OpenLDAP does not enforce arbitrary attribute uniqueness automatically. A constraint violation for duplicate uidNumber or gidNumber occurs only when an appropriate uniqueness policy, such as slapo-unique, has been configured. Do not rely on the production Add operation to discover every collision.

Check local UID uniqueness on the source host:

bash
awk -F: '{ if ($3 in uid) print "dup uid", $3, $1, uid[$3]; uid[$3]=$1 }' /etc/passwd

When the command prints nothing, every UID in passwd is unique on that host.

Check local GID uniqueness the same way:

bash
awk -F: '{ if ($3 in gid) print "dup gid", $3, $1, gid[$3]; gid[$3]=$1 }' /etc/group

Review every selected account against this matrix:

Conflict Risk
Same username, different UID Files can resolve to the wrong identity
Different username, same UID Two names represent one numeric owner
Same group name, different GID Group permissions become inconsistent
Different group name, same GID Ambiguous group ownership
Existing LDAP DN Import fails with Already exists (68)
Same name at a different DN LDAP entry exists under another organizational unit
Same name with different gidNumber, home, or shell Attribute mismatch on an otherwise similar account
Duplicate allowlist line The same username or group name appears twice in a reviewed list
Duplicate selected UID or GID Two selected local accounts or groups share one numeric ID
Duplicate LDAP uid, uidNumber, cn, or gidNumber The directory already contains ambiguous identity data
Non-POSIX entry at target DN An inetOrgPerson or other entry already occupies the planned DN
Missing primary group User gidNumber has no matching group
Duplicate UID across source hosts Accounts cannot be merged automatically

A migration report should make the decision visible before import:

text
type    name         local-id   ldap-id   result
user    jdoe         10001      -         migrate
user    asmith       10002      12005     conflict
group   developers   10010      10010     existing-match
group   operations   10002      20002     conflict

Recommended policy:

  • Preserve existing UID and GID values whenever possible.
  • Reuse an existing LDAP group only when its meaning and GID match.
  • Stop migration on unresolved collisions.
  • Do not silently allocate a new number.
  • Inventory all participating Linux servers before selecting canonical IDs.
WARNING
The same username with different UIDs on two servers represents two different Linux identities. Decide the canonical UID and repair affected file ownership before treating them as one LDAP account.

Generate and Review Migration LDIF Files

Use a Python generator on the management host. It reads the reviewed allowlists, parses existing-ldap-identities.ldif and existing-ldap-ou-entries.ldif, validates primary groups, rejects duplicate allowlist lines and duplicate selected or LDAP numeric IDs, decodes base64 LDIF values, escapes LDIF output values, classifies password hashes, and exits non-zero when a conflict remains.

The generator supports lowercase POSIX usernames and group names that match ^[a-z_][a-z0-9_-]{0,31}$. It does not support arbitrary LDAP-special characters in account names.

It writes:

text
groups.ldif
users.ldif
memberships.ldif
passwords.ldif
migration-report.tsv
password-report.tsv

Keep password data separate so the normal user and group files can be reviewed without exposing hashes. The password report classifies each selected user as hash-preserved, locked-reset-required, empty-reset-required, or unsupported-reset-required.

Save the generator script:

bash
sudo install -d -m 0700 /root/local-account-migration
bash
sudo tee /root/local-account-migration/migrate-local-accounts.py > /dev/null <<'EOF'
#!/usr/bin/env python3
"""Generate POSIX migration LDIF with LDAP preflight checks."""

from __future__ import annotations

import base64
import os
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple

POSIX_NAME = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
CRYPT_HASH = re.compile(r"^\$[156]\$")
LDIF_FILES = ("groups.ldif", "users.ldif", "memberships.ldif", "passwords.ldif")
CONFLICT_RESULTS = {
    "name-conflict",
    "numeric-id-conflict",
    "dn-conflict",
    "attribute-mismatch",
    "missing-source-record",
    "missing-primary-group",
    "allowlist-duplicate",
    "local-duplicate-uid",
    "local-duplicate-gid",
    "ldap-duplicate-uid",
    "ldap-duplicate-gid",
    "ldap-duplicate-name",
}


@dataclass
class LdapEntry:
    dn: str
    attrs: Dict[str, List[str]] = field(default_factory=dict)


def read_allowlist(path: Path) -> List[str]:
    names: List[str] = []
    for line in path.read_text().splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        names.append(line)
    if not names:
        raise SystemExit(f"allowlist is empty: {path}")
    return names


def parse_passwd(path: Path) -> Dict[str, dict]:
    users: Dict[str, dict] = {}
    for line in path.read_text().splitlines():
        if not line or line.startswith("#"):
            continue
        parts = line.split(":")
        if len(parts) < 7:
            raise SystemExit(f"invalid passwd line: {line}")
        name, _, uid, gid, gecos, home, shell = parts[:7]
        if name in users:
            raise SystemExit(f"duplicate username in passwd snapshot: {name}")
        users[name] = {
            "uid": int(uid),
            "gid": int(gid),
            "gecos": gecos,
            "home": home,
            "shell": shell,
        }
    return users


def parse_group(path: Path) -> Tuple[Dict[str, dict], Dict[int, str]]:
    groups: Dict[str, dict] = {}
    gid_to_name: Dict[int, str] = {}
    for line in path.read_text().splitlines():
        if not line or line.startswith("#"):
            continue
        parts = line.split(":")
        if len(parts) < 4:
            raise SystemExit(f"invalid group line: {line}")
        name, _, gid, members = parts[0], parts[1], parts[2], parts[3] if len(parts) > 3 else ""
        if name in groups:
            raise SystemExit(f"duplicate group name in group snapshot: {name}")
        gid_i = int(gid)
        member_list = [m for m in members.split(",") if m]
        groups[name] = {"gid": gid_i, "members": member_list}
        gid_to_name[gid_i] = name
    return groups, gid_to_name


def parse_shadow(path: Path) -> Dict[str, str]:
    shadow: Dict[str, str] = {}
    if not path.is_file():
        return shadow
    for line in path.read_text().splitlines():
        if not line or line.startswith("#"):
            continue
        parts = line.split(":")
        if len(parts) < 2:
            continue
        shadow[parts[0]] = parts[1]
    return shadow


def parse_ldif_line(raw: str) -> Tuple[str, str]:
    if "::" in raw:
        attr, encoded = raw.split("::", 1)
        value = base64.b64decode(encoded.strip()).decode("utf-8")
        return attr.lower(), value
    attr, value = raw.split(":", 1)
    return attr.lower(), value.lstrip()


def parse_ldap_export(path: Path) -> List[LdapEntry]:
    if not path.is_file():
        return []
    entries: List[LdapEntry] = []
    current: Optional[LdapEntry] = None
    pending_attr: Optional[str] = None
    for raw in path.read_text().splitlines():
        if raw.startswith(" "):
            if current is not None and pending_attr is not None:
                current.attrs[pending_attr][-1] += raw[1:]
            continue
        if raw.startswith("dn::"):
            if current:
                entries.append(current)
            dn = base64.b64decode(raw.split("::", 1)[1].strip()).decode("utf-8")
            current = LdapEntry(dn=dn)
            pending_attr = None
            continue
        if raw.startswith("dn:"):
            if current:
                entries.append(current)
            current = LdapEntry(dn=raw.split(":", 1)[1].lstrip())
            pending_attr = None
            continue
        if current is None:
            continue
        if ":" in raw:
            key, decoded = parse_ldif_line(raw)
            current.attrs.setdefault(key, []).append(decoded)
            pending_attr = key
    if current:
        entries.append(current)
    return entries


def has_objectclass(entry: LdapEntry, name: str) -> bool:
    return name.lower() in [oc.lower() for oc in entry.attrs.get("objectclass", [])]


def build_ldap_indexes(entries: List[LdapEntry]) -> dict:
    idx = {
        "user_dn": {},
        "user_name": {},
        "uidnumber": {},
        "group_dn": {},
        "group_name": {},
        "group_gidnumber": {},
        "ou_dn": {},
    }
    for entry in entries:
        idx["ou_dn"][entry.dn.lower()] = entry
        if has_objectclass(entry, "posixAccount"):
            idx["user_dn"][entry.dn.lower()] = entry
            for uid in entry.attrs.get("uid", []):
                idx["user_name"].setdefault(uid, []).append(entry)
            for num in entry.attrs.get("uidnumber", []):
                idx["uidnumber"].setdefault(int(num), []).append(entry)
        if has_objectclass(entry, "posixGroup"):
            idx["group_dn"][entry.dn.lower()] = entry
            for cn in entry.attrs.get("cn", []):
                idx["group_name"].setdefault(cn, []).append(entry)
            for num in entry.attrs.get("gidnumber", []):
                idx["group_gidnumber"].setdefault(int(num), []).append(entry)
    return idx


def merge_ldap_entries(*entry_sets: List[LdapEntry]) -> List[LdapEntry]:
    by_dn: Dict[str, LdapEntry] = {}

    for entries in entry_sets:
        for entry in entries:
            key = entry.dn.lower()

            if key not in by_dn:
                by_dn[key] = entry
                continue

            existing = by_dn[key]
            for attr, values in entry.attrs.items():
                current = existing.attrs.setdefault(attr, [])
                for value in values:
                    if value not in current:
                        current.append(value)

    return list(by_dn.values())


def first_entry(entries: List[LdapEntry]) -> Optional[LdapEntry]:
    return entries[0] if entries else None


def detect_allowlist_duplicates(
    users_sel: List[str], groups_sel: List[str]
) -> List[Tuple[str, str, str, str, str]]:
    rows: List[Tuple[str, str, str, str, str]] = []

    def scan(kind: str, names: List[str]) -> None:
        seen: Set[str] = set()
        for name in names:
            if name in seen:
                rows.append((kind, name, "-", "-", "allowlist-duplicate"))
            seen.add(name)

    scan("user", users_sel)
    scan("group", groups_sel)
    return rows


def detect_local_duplicates(
    users_sel: List[str],
    groups_sel: List[str],
    passwd: Dict[str, dict],
    groups: Dict[str, dict],
) -> List[Tuple[str, str, str, str, str]]:
    rows: List[Tuple[str, str, str, str, str]] = []
    uid_map: Dict[int, List[str]] = {}
    gid_map: Dict[int, List[str]] = {}
    for uname in users_sel:
        if uname not in passwd:
            continue
        uid_map.setdefault(passwd[uname]["uid"], []).append(uname)
    for gname in groups_sel:
        if gname not in groups:
            continue
        gid_map.setdefault(groups[gname]["gid"], []).append(gname)
    for uid, names in sorted(uid_map.items()):
        if len(names) > 1:
            rows.append(("user", ",".join(names), str(uid), "-", "local-duplicate-uid"))
    for gid, names in sorted(gid_map.items()):
        if len(names) > 1:
            rows.append(("group", ",".join(names), str(gid), "-", "local-duplicate-gid"))
    return rows


def detect_ldap_duplicates(ldap_idx: dict) -> List[Tuple[str, str, str, str, str]]:
    rows: List[Tuple[str, str, str, str, str]] = []
    for uid, entries in sorted(ldap_idx["user_name"].items()):
        if len(entries) > 1:
            dns = ";".join(e.dn for e in entries)
            rows.append(("user", uid, "-", dns, "ldap-duplicate-name"))
    for num, entries in sorted(ldap_idx["uidnumber"].items()):
        if len(entries) > 1:
            dns = ";".join(e.dn for e in entries)
            rows.append(("user", f"uidNumber={num}", str(num), dns, "ldap-duplicate-uid"))
    for cn, entries in sorted(ldap_idx["group_name"].items()):
        if len(entries) > 1:
            dns = ";".join(e.dn for e in entries)
            rows.append(("group", cn, "-", dns, "ldap-duplicate-name"))
    for num, entries in sorted(ldap_idx["group_gidnumber"].items()):
        if len(entries) > 1:
            dns = ";".join(e.dn for e in entries)
            rows.append(("group", f"gidNumber={num}", str(num), dns, "ldap-duplicate-gid"))
    return rows


def escape_rdn(value: str) -> str:
    out = []
    for ch in value:
        if ch in ",+=<>#;\"\\":
            out.append("\\" + ch)
        elif ch == " " and (not out or len(value) - len(out) == 1):
            out.append("\\ ")
        else:
            out.append(ch)
    return "".join(out)


def ldif_line(attr: str, value: str) -> str:
    encoded = value.encode("utf-8")
    unsafe = (
        not value
        or value[0] in " :#<"
        or value[-1] == " "
        or any(b < 32 or b > 126 for b in encoded)
    )
    if unsafe:
        b64 = base64.b64encode(encoded).decode("ascii")
        return f"{attr}:: {b64}"
    return f"{attr}: {value}"


def classify_password(hash_value: Optional[str]) -> str:
    if hash_value is None or hash_value == "":
        return "empty-reset-required"
    if hash_value.startswith("!") or hash_value == "*":
        return "locked-reset-required"
    if CRYPT_HASH.match(hash_value):
        return "hash-preserved"
    return "unsupported-reset-required"


def validate_posix_name(name: str, kind: str) -> None:
    if not POSIX_NAME.match(name):
        raise SystemExit(
            f"unsupported {kind} name {name!r}: use lowercase POSIX names "
            "without LDAP-special characters"
        )


def first_attr(entry: LdapEntry, attr: str, default: str = "") -> str:
    values = entry.attrs.get(attr.lower(), [])
    return values[0] if values else default


def classify_group(
    name: str,
    local_gid: int,
    target_dn: str,
    ldap_idx: dict,
) -> Tuple[str, str]:
    target_dn_l = target_dn.lower()
    by_name = first_entry(ldap_idx["group_name"].get(name, []))
    by_gid = first_entry(ldap_idx["group_gidnumber"].get(local_gid, []))
    dn_entry = ldap_idx["ou_dn"].get(target_dn_l)

    if by_name:
        ldap_gid = int(first_attr(by_name, "gidnumber", "-1"))
        if by_name.dn.lower() != target_dn_l:
            if ldap_gid == local_gid:
                return "dn-conflict", by_name.dn
            return "name-conflict", by_name.dn
        if ldap_gid != local_gid:
            return "attribute-mismatch", by_name.dn
        return "existing-match", by_name.dn
    if by_gid and first_attr(by_gid, "cn") != name:
        return "numeric-id-conflict", by_gid.dn
    if dn_entry:
        return "dn-conflict", dn_entry.dn
    return "migrate", "-"


def classify_user(
    name: str,
    local_uid: int,
    local_gid: int,
    local_home: str,
    local_shell: str,
    target_dn: str,
    ldap_idx: dict,
) -> Tuple[str, str]:
    target_dn_l = target_dn.lower()
    by_name = first_entry(ldap_idx["user_name"].get(name, []))
    by_uid = first_entry(ldap_idx["uidnumber"].get(local_uid, []))
    dn_entry = ldap_idx["ou_dn"].get(target_dn_l)

    if by_name:
        ldap_uid = int(first_attr(by_name, "uidnumber", "-1"))
        ldap_gid = int(first_attr(by_name, "gidnumber", "-1"))
        ldap_home = first_attr(by_name, "homedirectory")
        ldap_shell = first_attr(by_name, "loginshell")

        if by_name.dn.lower() != target_dn_l:
            if ldap_uid == local_uid:
                return "dn-conflict", by_name.dn
            return "name-conflict", by_name.dn

        if ldap_uid != local_uid:
            return "name-conflict", by_name.dn

        if (
            ldap_gid != local_gid
            or ldap_home != local_home
            or ldap_shell != local_shell
        ):
            return "attribute-mismatch", by_name.dn

        return "existing-match", by_name.dn

    if by_uid and first_attr(by_uid, "uid") != name:
        return "numeric-id-conflict", by_uid.dn
    if dn_entry:
        return "dn-conflict", dn_entry.dn
    return "migrate", "-"


def primary_group_ready(
    primary_group: Optional[str],
    gid: int,
    groups_sel: List[str],
    ldap_idx: dict,
) -> Tuple[bool, str]:
    if primary_group is None:
        return False, "-"
    if primary_group in groups_sel:
        return True, "-"
    ldap_group = first_entry(ldap_idx["group_gidnumber"].get(gid, []))
    if ldap_group and first_attr(ldap_group, "cn") == primary_group:
        return True, ldap_group.dn
    return False, ldap_group.dn if ldap_group else "-"


def write_reports(workdir: Path, report_rows: List[str], password_rows: List[str]) -> None:
    workdir.mkdir(parents=True, exist_ok=True)
    (workdir / "migration-report.tsv").write_text("\n".join(report_rows) + "\n")
    (workdir / "password-report.tsv").write_text("\n".join(password_rows) + "\n")
    print(f"Wrote {workdir / 'migration-report.tsv'}")
    print(f"Wrote {workdir / 'password-report.tsv'}")


def remove_ldif_files(workdir: Path) -> None:
    for name in LDIF_FILES:
        path = workdir / name
        if path.exists():
            path.unlink()


def write_migration_ldif(
    workdir: Path,
    groups_ldif: List[str],
    users_ldif: List[str],
    memberships_ldif: List[str],
    passwords_ldif: List[str],
) -> None:
    workdir.mkdir(parents=True, exist_ok=True)
    (workdir / "groups.ldif").write_text("\n".join(groups_ldif))
    (workdir / "users.ldif").write_text("\n".join(users_ldif))
    (workdir / "memberships.ldif").write_text("\n".join(memberships_ldif))
    (workdir / "passwords.ldif").write_text("\n".join(passwords_ldif))
    for name in LDIF_FILES:
        print(f"Wrote {workdir / name}")


def append_report_rows(
    report_rows: List[str], rows: List[Tuple[str, str, str, str, str]]
) -> int:
    conflicts = 0
    for row in rows:
        report_rows.append("\t".join(row))
        if row[4] in CONFLICT_RESULTS:
            conflicts += 1
    return conflicts


def main() -> int:
    os.umask(0o077)
    workdir = Path(os.environ.get("WORKDIR", "/root/local-account-migration"))
    base_dn = os.environ.get("BASE_DN", "dc=example,dc=com")
    people_ou = os.environ.get("PEOPLE_OU", "ou=people")
    groups_ou = os.environ.get("GROUPS_OU", "ou=groups")
    source_host = os.environ.get("SOURCE_HOST", "ldap-server.example.com")

    passwd_path = Path(os.environ.get("PASSWD", workdir / "passwd"))
    group_path = Path(os.environ.get("GROUP", workdir / "group"))
    shadow_path = Path(os.environ.get("SHADOW", workdir / "shadow"))
    users_allow = Path(os.environ.get("USERS_ALLOW", workdir / "users.allowlist"))
    groups_allow = Path(os.environ.get("GROUPS_ALLOW", workdir / "groups.allowlist"))
    ldap_export = Path(
        os.environ.get("LDAP_EXPORT", workdir / "existing-ldap-identities.ldif")
    )
    ldap_ou_export = Path(
        os.environ.get("LDAP_OU_EXPORT", workdir / "existing-ldap-ou-entries.ldif")
    )

    users_sel = read_allowlist(users_allow)
    groups_sel = read_allowlist(groups_allow)
    passwd = parse_passwd(passwd_path)
    groups, gid_to_name = parse_group(group_path)
    shadow = parse_shadow(shadow_path)
    identity_entries = parse_ldap_export(ldap_export)
    ou_entries = parse_ldap_export(ldap_ou_export)
    ldap_entries = merge_ldap_entries(identity_entries, ou_entries)
    ldap_idx = build_ldap_indexes(ldap_entries)

    report_rows = ["type\tname\tlocal-id\tldap-id\tresult"]
    password_rows = ["user\tpassword-status"]
    conflicts = 0

    conflicts += append_report_rows(
        report_rows, detect_allowlist_duplicates(users_sel, groups_sel)
    )
    conflicts += append_report_rows(
        report_rows,
        detect_local_duplicates(users_sel, groups_sel, passwd, groups),
    )
    conflicts += append_report_rows(report_rows, detect_ldap_duplicates(ldap_idx))

    groups_ldif: List[str] = []
    users_ldif: List[str] = []
    memberships_ldif: List[str] = []
    passwords_ldif: List[str] = []
    selected_groups: Set[str] = set()

    for gname in groups_sel:
        validate_posix_name(gname, "group")
        if gname not in groups:
            print(f"missing group entry: {gname}", file=sys.stderr)
            report_rows.append(f"group\t{gname}\t-\t-\tmissing-source-record")
            conflicts += 1
            continue
        gid = groups[gname]["gid"]
        target_dn = f"cn={escape_rdn(gname)},{groups_ou},{base_dn}"
        result, ldap_id = classify_group(gname, gid, target_dn, ldap_idx)
        report_rows.append(f"group\t{gname}\t{gid}\t{ldap_id}\t{result}")
        if result in CONFLICT_RESULTS:
            conflicts += 1
            continue
        selected_groups.add(gname)
        if result == "migrate":
            groups_ldif.extend(
                [
                    f"dn: {target_dn}",
                    "objectClass: top",
                    "objectClass: posixGroup",
                    ldif_line("cn", gname),
                    f"gidNumber: {gid}",
                    ldif_line("description", f"Migrated from {source_host}"),
                    "",
                ]
            )

    for uname in users_sel:
        validate_posix_name(uname, "user")
        if uname not in passwd:
            print(f"missing passwd entry: {uname}", file=sys.stderr)
            report_rows.append(f"user\t{uname}\t-\t-\tmissing-source-record")
            password_rows.append(f"{uname}\tmissing-source-record")
            conflicts += 1
            continue

        data = passwd[uname]
        uid = data["uid"]
        gid = data["gid"]
        target_dn = f"uid={escape_rdn(uname)},{people_ou},{base_dn}"
        primary_group = gid_to_name.get(gid)
        ready, ldap_group_id = primary_group_ready(primary_group, gid, groups_sel, ldap_idx)

        if not ready:
            result = "missing-primary-group"
            ldap_id = ldap_group_id
        else:
            result, ldap_id = classify_user(
                uname,
                uid,
                gid,
                data["home"],
                data["shell"],
                target_dn,
                ldap_idx,
            )

        report_rows.append(f"user\t{uname}\t{uid}\t{ldap_id}\t{result}")
        pwd_status = classify_password(shadow.get(uname))
        password_rows.append(f"{uname}\t{pwd_status}")

        if result in CONFLICT_RESULTS:
            conflicts += 1
            continue
        if result == "existing-match":
            continue

        gecos = data["gecos"]
        cn = gecos.split(",", 1)[0] if gecos else uname
        parts = cn.split()
        given = parts[0] if parts else uname
        sn = parts[-1] if parts else uname

        users_ldif.extend(
            [
                f"dn: {target_dn}",
                "objectClass: top",
                "objectClass: inetOrgPerson",
                "objectClass: posixAccount",
                ldif_line("cn", cn or uname),
                ldif_line("sn", sn),
                ldif_line("givenName", given),
                ldif_line("uid", uname),
                f"uidNumber: {uid}",
                f"gidNumber: {gid}",
                ldif_line("homeDirectory", data["home"]),
                ldif_line("loginShell", data["shell"]),
                ldif_line("gecos", gecos),
                ldif_line("description", f"Migrated from {source_host}"),
                "",
            ]
        )

        if pwd_status == "hash-preserved":
            hash_value = shadow[uname]
            passwords_ldif.extend(
                [
                    f"dn: {target_dn}",
                    "changetype: modify",
                    "replace: userPassword",
                    ldif_line("userPassword", f"{{CRYPT}}{hash_value}"),
                    "",
                ]
            )

    for gname in groups_sel:
        if gname not in groups or gname not in selected_groups:
            continue
        selected_members = [m for m in groups[gname]["members"] if m in users_sel]
        if not selected_members:
            continue
        target_dn = f"cn={escape_rdn(gname)},{groups_ou},{base_dn}"
        ldap_group = first_entry(ldap_idx["group_name"].get(gname, []))
        existing_members = set(ldap_group.attrs.get("memberuid", [])) if ldap_group else set()
        missing_members = sorted(set(selected_members) - existing_members)
        if not missing_members:
            continue
        memberships_ldif.extend(
            [f"dn: {target_dn}", "changetype: modify", "add: memberUid"]
            + [ldif_line("memberUid", m) for m in missing_members]
            + [""]
        )

    write_reports(workdir, report_rows, password_rows)
    if conflicts:
        remove_ldif_files(workdir)
        print(
            f"Preflight failed with {conflicts} unresolved conflict(s)",
            file=sys.stderr,
        )
        return 1

    write_migration_ldif(workdir, groups_ldif, users_ldif, memberships_ldif, passwords_ldif)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

EOF

Make the script executable:

bash
sudo chmod 0700 /root/local-account-migration/migrate-local-accounts.py

Run preflight and generation against the backed-up account files, allowlists, and LDAP export:

bash
sudo PASSWD=/root/local-account-migration/passwd GROUP=/root/local-account-migration/group SHADOW=/root/local-account-migration/shadow LDAP_EXPORT=/root/local-account-migration/existing-ldap-identities.ldif LDAP_OU_EXPORT=/root/local-account-migration/existing-ldap-ou-entries.ldif python3 /root/local-account-migration/migrate-local-accounts.py

When every selected object passes preflight, sample output looks like this:

output
Wrote /root/local-account-migration/groups.ldif
Wrote /root/local-account-migration/users.ldif
Wrote /root/local-account-migration/memberships.ldif
Wrote /root/local-account-migration/passwords.ldif
Wrote /root/local-account-migration/migration-report.tsv
Wrote /root/local-account-migration/password-report.tsv

Sample migration report after a clean preflight on the lab host:

output
type   name      local-id  ldap-id  result
group  miguser1  15001     -        migrate
group  miguser2  15002     -        migrate
group  migdevs   15010     -        migrate
user   miguser1  15001     -        migrate
user   miguser2  15002     -        migrate

When a conflict remains, the script still writes the reports, prints the failure count to stderr, exits with status 1, and does not leave importable LDIF files behind:

output
Preflight failed with 1 unresolved conflict(s)

Review the migration report:

bash
column -t -s $'\t' /root/local-account-migration/migration-report.tsv

Sample output when one LDAP username already exists with a different UID or home directory:

output
type   name        local-id  ldap-id                                    result
group  developers  10010     cn=developers,ou=groups,dc=example,dc=com  existing-match
group  operations  10011     -                                          migrate
user   asmith      10002     uid=asmith,ou=people,dc=example,dc=com     name-conflict
user   jdoe        10001     uid=jdoe,ou=people,dc=example,dc=com         attribute-mismatch

When preflight fails, the script still writes migration-report.tsv and password-report.tsv, but it does not leave importable groups.ldif, users.ldif, memberships.ldif, or passwords.ldif files behind.

Review password handling separately:

bash
column -t -s $'\t' /root/local-account-migration/password-report.tsv

Sample output:

output
user         password-status
miguser1     unsupported-reset-required
miguser2     unsupported-reset-required

Rocky Linux 10 stores yescrypt ($y$...) hashes by default. The generator classifies those as unsupported-reset-required and leaves passwords.ldif empty unless you test {CRYPT} compatibility first.

Locked shadow values such as !$6$... and * are classified for password reset and are not written to passwords.ldif.

Generate POSIX groups

Example group entry:

ldif
dn: cn=developers,ou=groups,dc=example,dc=com
objectClass: top
objectClass: posixGroup
cn: developers
gidNumber: 10010
description: Migrated from ldap-server.example.com

Generate POSIX users

Example user entry:

ldif
dn: uid=jdoe,ou=people,dc=example,dc=com
objectClass: top
objectClass: inetOrgPerson
objectClass: posixAccount
cn: John Doe
sn: Doe
givenName: John
uid: jdoe
uidNumber: 10001
gidNumber: 10001
homeDirectory: /home/jdoe
loginShell: /bin/bash
gecos: John Doe
description: Migrated from ldap-server.example.com

Generate supplementary memberships

Example membership modify:

ldif
dn: cn=linux-admins,ou=groups,dc=example,dc=com
changetype: modify
add: memberUid
memberUid: jdoe
memberUid: asmith

Keep these rules in mind:

  • The primary group comes from the user’s gidNumber.
  • Supplementary memberships come from the member list in /etc/group.
  • memberUid stores the plain username from the user’s LDAP uid attribute.
  • When a group already exists in LDAP, the generator adds only missing memberUid values from the export snapshot.
  • A username does not need to appear as memberUid in its primary group.
  • Empty posixGroup entries are valid.
  • The generator escapes RDN values and base64-encodes generated LDIF attribute values when required, including values that begin with :.

Validate the generated files

Check for duplicate DNs:

bash
grep '^dn:' /root/local-account-migration/groups.ldif /root/local-account-migration/users.ldif | sort | uniq -d

When the command prints nothing, every DN in the reviewed files is unique.

Use -n -v only to inspect how the client parses each LDIF file. It does not prove that the server will accept the entries. Perform explicit LDAP searches for conflicts and test the workflow with disposable entries or a staging suffix before production import.

Preview group adds:

bash
ldapadd -n -v -x -ZZ -H ldap://ldap-server.example.com -D "cn=admin,dc=example,dc=com" -W -f /root/local-account-migration/groups.ldif

Preview user adds:

bash
ldapadd -n -v -x -ZZ -H ldap://ldap-server.example.com -D "cn=admin,dc=example,dc=com" -W -f /root/local-account-migration/users.ldif

Preview membership and password changes with ldapmodify because those files contain changetype: modify:

bash
ldapmodify -n -v -x -ZZ -H ldap://ldap-server.example.com -D "cn=admin,dc=example,dc=com" -W -f /root/local-account-migration/memberships.ldif
bash
ldapmodify -n -v -x -ZZ -H ldap://ldap-server.example.com -D "cn=admin,dc=example,dc=com" -W -f /root/local-account-migration/passwords.ldif

Resolve every preflight conflict before the production import.


Import Groups, Users and Memberships

Back up OpenLDAP before import:

bash
sudo slapcat -b "dc=example,dc=com" -l /root/openldap-before-local-user-migration.ldif

Import in this order:

text
1. Groups
2. Users
3. Supplementary memberships
4. Passwords or password resets

Import groups:

bash
ldapadd -x -ZZ -H ldap://ldap-server.example.com -D "cn=admin,dc=example,dc=com" -W -f /root/local-account-migration/groups.ldif

Import users:

bash
ldapadd -x -ZZ -H ldap://ldap-server.example.com -D "cn=admin,dc=example,dc=com" -W -f /root/local-account-migration/users.ldif

Apply supplementary memberships:

bash
ldapmodify -x -ZZ -H ldap://ldap-server.example.com -D "cn=admin,dc=example,dc=com" -W -f /root/local-account-migration/memberships.ldif

Verify user counts with an authenticated search:

bash
ldapsearch -x -ZZ \
  -H ldap://ldap-server.example.com \
  -D "cn=admin,dc=example,dc=com" \
  -W \
  -b "ou=people,dc=example,dc=com" \
  -LLL \
  "(objectClass=posixAccount)" \
  uid uidNumber gidNumber

Verify group membership:

bash
ldapsearch -x -ZZ \
  -H ldap://ldap-server.example.com \
  -D "cn=admin,dc=example,dc=com" \
  -W \
  -b "ou=groups,dc=example,dc=com" \
  -LLL \
  "(objectClass=posixGroup)" \
  cn gidNumber memberUid

Repeat the required user and group searches with the SSSD reader identity to prove that clients can retrieve the imported records:

bash
ldapsearch -x -ZZ \
  -H ldap://ldap-server.example.com \
  -D "uid=sssd-reader,ou=service-accounts,dc=example,dc=com" \
  -W \
  -b "ou=people,dc=example,dc=com" \
  -LLL \
  "(uid=miguser1)" \
  uid uidNumber gidNumber homeDirectory loginShell

Do not use ldapadd -c during the initial production import. Continuing after errors can leave an incomplete identity set.


Migrate or Reset User Passwords Safely

Treat /etc/shadow, generated password LDIF, and backups as password-equivalent secrets. OpenLDAP warns that password hashes must be protected as though they were cleartext because they remain vulnerable to offline attacks.

Option 1: Force users to set a new password

This is the recommended path when:

  • The source hashes use unsupported algorithms
  • Password age is unknown
  • The organization wants stronger new passwords
  • ppolicy will manage future password lifecycle

Set a temporary password. ldappasswd prompts for the new password instead of placing it on the command line:

bash
ldappasswd -x -ZZ -H ldap://ldap-server.example.com -D "cn=admin,dc=example,dc=com" -W -S "uid=miguser1,ou=people,dc=example,dc=com"

When the password-policy overlay is enabled, ldappasswd may set pwdReset: TRUE on the account. When pwdReset: TRUE applies, the user can still authenticate with the reset password, but subsequent directory operations may be restricted to changing the password. A non-ppolicy-aware application can therefore report a login or search failure even though the Bind itself succeeded. For service accounts, ensure the final password is installed without leaving the account restricted by pwdReset.

With pwdMustChange: TRUE in the applicable password policy, verify that the reset produces pwdReset: TRUE on the account before cutover. See Configure OpenLDAP password policy.

Option 2: Preserve compatible shadow hashes

Read the source hash only as root:

bash
sudo awk -F: '$1 == "jdoe" {print $2}' /etc/shadow

Convert a supported hash from:

text
$6$SALT$HASH

to:

ldif
dn: uid=jdoe,ou=people,dc=example,dc=com
changetype: modify
replace: userPassword
userPassword: {CRYPT}$6$SALT$HASH

OpenLDAP supports the {CRYPT} password scheme and can transfer hashes from an existing Unix password file without knowing the cleartext password. Compatibility depends on the operating system’s crypt(3) implementation, so every hash type must be tested before cutover.

Before bulk migration:

  1. Test one disposable user.
  2. Verify the hash prefix is supported.
  3. Bind directly with ldapwhoami.
  4. Verify through SSSD.
  5. Confirm password-policy interaction.
  6. Confirm the hash is not altered by the generator.

Apply reviewed password LDIF only after those checks:

bash
ldapmodify -x -ZZ -H ldap://ldap-server.example.com -D "cn=admin,dc=example,dc=com" -W -f /root/local-account-migration/passwords.ldif

Do not migrate shadow values that begin with !, including locked hashes such as !$6$..., or unusable values such as *.

Do not place shadow hashes in Git, world-readable files, article screenshots, support tickets, shell command arguments, or general migration reports. Protect and delete temporary password LDIF after the tested retention period.


Validate the Migration on a Separate SSSD Client

Use ldap-client.example.com, where the same usernames do not already exist in /etc/passwd.

A local account can mask the LDAP account because RHEL-family NSS configuration normally checks:

text
passwd: files sss
group:  files sss

Verify identity lookup

bash
getent passwd miguser1

Sample output on the lab client when no local miguser1 entry exists:

output
miguser1:*:15001:15001:Migration Test miguser1:/home/miguser1:/bin/bash

The returned fields must match the source account for username, uidNumber, gidNumber, homeDirectory, and loginShell.

Verify groups:

bash
id miguser1

Sample output:

output
uid=15001(miguser1) gid=15001(miguser1) groups=15001(miguser1),15010(migdevs)
bash
getent group migdevs

Sample output:

output
migdevs:*:15010:miguser1,miguser2

Verify the LDAP password

bash
ldapwhoami -x -ZZ -H ldap://ldap-server.example.com -D "uid=miguser1,ou=people,dc=example,dc=com" -W

Verify SSSD authentication

bash
sudo sssctl user-checks -a auth -s sshd miguser1
bash
sudo sssctl user-checks -a acct -s sshd miguser1

Sample output:

output
- name: miguser1
 - uidNumber: 15001
 - gidNumber: 15001
 - gecos: Migration Test miguser1
 - homeDirectory: /home/miguser1
 - loginShell: /bin/bash

Verify login

bash

Validate:

  • UID
  • Primary GID
  • Supplementary groups
  • Password
  • Login shell
  • Home-directory path
  • Sudo rules, when applicable
  • NFS home directory, when applicable

Do not cut over the source host until all selected users pass validation on the separate client.


Cut Over Source Systems Without Changing Ownership

Before cutover:

  1. Freeze account creation and UID/GID changes.
  2. Run the conflict report again.
  3. Re-export selected local accounts.
  4. Compare with the imported LDAP entries.
  5. Back up local account files and SSSD configuration.
  6. Confirm OpenLDAP backup and rollback procedures.
  7. Confirm every user can authenticate from the validation client.

Do not immediately delete local accounts.

Recommended cutover sequence:

text
1. Schedule a maintenance window
2. Stop new local account changes
3. Configure and validate SSSD
4. Remove migrated local login entries without deleting home data
5. Expire only affected SSSD caches
6. Verify getent and id return LDAP identities
7. Test password login
8. Test file and home ownership
9. Retain protected local-account backups

After successful validation and protected backups, remove the migrated local identity from the local account databases without deleting its home data. Do not merely lock or rename the account, because an entry with the same name or numeric UID can continue to mask or conflict with the LDAP identity.

Confirm the user has no active session or processes, then back up /etc/passwd, /etc/shadow, /etc/group, and /etc/gshadow.

Remove the local user account without deleting home data:

bash
sudo userdel jdoe

Do not use userdel -r, because the -r option removes the account and its home directory and mail spool.

Retire the corresponding local group only when no remaining local account depends on it:

bash
sudo groupdel developers

Do not remove a shared local group while non-migrated local users still depend on it.

Expire only the affected SSSD identity:

bash
sudo sss_cache -u jdoe

Verify both name and numeric lookup:

bash
getent passwd jdoe
bash
getent passwd 10001
bash
getent group developers
bash
getent group 10010

Preserve home data and numeric ownership.

Check files owned by a migrated UID:

bash
sudo find / -xdev -uid 10001 -ls

Check group-owned files:

bash
sudo find / -xdev -gid 10001 -ls

When the UID and GID were preserved, ownership should continue to resolve to the same username and group after NSS begins using LDAP.


Roll Back and Troubleshoot the Migration

Rollback plan

Retain:

text
/etc/passwd backup
/etc/group backup
/etc/shadow backup
/etc/gshadow backup
OpenLDAP pre-migration LDIF
Generated user and group LDIF
Migration allowlists
Conflict report
SSSD configuration backup
Cutover checklist

Rollback sequence:

  1. Restore local NSS and PAM configuration.
  2. Restore the protected local account files when they were modified.
  3. Restart SSSD.
  4. Verify local identity and authentication.
  5. Remove imported LDAP entries only after local access works.
  6. Restore LDAP from backup if the import modified existing entries.

Troubleshooting table

Symptom Likely cause Recommended check
LDAP import returns Already exists (68) DN already exists Search the exact DN before importing
Import returns constraint violation A configured uniqueness or schema constraint rejected the entry Review the server message and configured uniqueness policy
Duplicate UID or GID imported successfully No server-side uniqueness rule covers that attribute Stop cutover and correct the preflight conflict checks
ldapmodify reports duplicate memberUid Membership modify tried to add an existing value Regenerate memberships after exporting memberUid in the LDAP snapshot
Preflight failed but LDIF files exist An older generator run left partial output behind Re-run the generator; failed preflight must not leave importable LDIF
User appears with the wrong UID Local and LDAP values differ Compare /etc/passwd and uidNumber
getent still shows the local user files masks sss Test on a separate client or retire the local entry
id jdoe does not show supplementary groups Missing or incorrect RFC2307 memberUid values Confirm the group contains memberUid: jdoe, matching the user’s LDAP uid value
Password fails after {CRYPT} import Hash unsupported, altered or locked Test the original hash and reset the password
User login succeeds but files show numbers Group or user cannot be resolved Test getent passwd and getent group
Files belong to the wrong user UID/GID collision Stop cutover and restore canonical IDs
Home directory is missing Files were not migrated or mounted Check local, NFS and autofs configuration
SSSD shows two identities UID changed or stale cache exists Compare IDs and expire the affected cache
User exists in LDAP but SSH fails PAM, SSSD access or shell problem Use sssctl user-checks
Group import succeeds but primary group is absent The user references an excluded GID Import or remap the required primary group, or confirm it already exists in LDAP
Preflight reports missing-primary-group Primary group is not allowlisted and not in LDAP Add the primary group to groups.allowlist or import the existing LDAP group first
Service account was migrated accidentally Selection filter was too broad Remove it from LDAP and keep it local

Useful commands:

bash
sudo sssctl config-check

Validates SSSD syntax and file permissions when the client fails to start after cutover.

bash
sudo sssctl domain-status example.com

Shows whether the domain is online and when it last contacted LDAP.

bash
sudo sss_cache -u jdoe

Expires one user after UID or attribute changes when other cached entries should stay.

bash
sudo journalctl -u sssd -n 200 --no-pager

Client-side identity and authentication errors during post-migration login tests.

bash
sudo journalctl -u slapd -n 200 --no-pager

Server-side import, ACL, or bind failures when LDAP data looks correct but operations fail.


References


Summary

You learned how to select appropriate local accounts for migration, exclude root and system users, preserve UID and GID values, run LDAP preflight conflict checks, generate separate group, user, membership, and password LDIF files, import groups before users, recreate RFC2307 supplementary memberships, reset passwords or preserve compatible shadow hashes, validate identities and passwords on a separate SSSD client, cut over without losing file ownership, and roll back safely when validation fails.


Frequently Asked Questions

1. Which local Linux users should I migrate to OpenLDAP?

Migrate explicitly selected human or application accounts that need centralized identity. Exclude root, package-created system accounts, daemon users, and accounts that should remain local.

2. Why must I preserve UID and GID values during migration?

Linux files store numeric UID and GID ownership. Preserving those numbers allows existing files to remain owned by the same users and groups after identity resolution moves to LDAP.

3. Should groups or users be imported first?

Import POSIX groups first, then user entries, and finally supplementary memberUid values. This allows every user gidNumber to refer to an already planned group.

4. Can I migrate passwords from /etc/shadow to OpenLDAP?

Yes, compatible crypt hashes can be stored with the {CRYPT} prefix, but the hash algorithm must be tested against the target OpenLDAP system. A forced password reset is safer when compatibility is uncertain.

5. Should I migrate locked shadow passwords such as ! or *?

No. Treat those values as locked or unusable accounts. Do not import them as normal LDAP passwords.

6. Does migrating users also move their home directories?

No. LDAP stores identity attributes such as homeDirectory, but the actual files must remain local or be copied to storage such as an NFS home-directory server.

7. Why does getent still show the local account after LDAP migration?

The files source normally appears before sss in nsswitch.conf. A local account with the same name masks the LDAP identity until the local entry is retired or the account is tested from another client.

8. How are supplementary groups migrated to OpenLDAP?

For the RFC2307 model, each selected posixGroup receives memberUid values copied from the supplementary membership list in /etc/group.

9. Should I migrate shadow password-aging fields?

Not in the primary workflow. Use the OpenLDAP ppolicy overlay for centralized password expiration, history, warnings, lockout, and reset behavior.

10. How do I roll back the migration?

Keep protected copies of passwd, group, shadow, SSSD configuration, generated LDIF, and pre-migration LDAP backups. Restore local NSS and PAM behavior before removing imported LDAP entries.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive …