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
posixGroupandposixAccountentries - 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.comandldap-client.example.com; OpenLDAP 2.6.10; SSSD 2.12.0. The full workflow was run withmiguser1/miguser2(UID/GID15001–15002) and groupmigdevs(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:
- Install and configure OpenLDAP
- Secure OpenLDAP with TLS
- Manage OpenLDAP users and groups
- Configure an OpenLDAP client with SSSD
- Configure OpenLDAP password policy
- Back up and restore OpenLDAP
/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:
objectClass: top
objectClass: inetOrgPerson
objectClass: posixAccountEach migrated group entry uses:
objectClass: top
objectClass: posixGroupThis 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:
sudo install -d -m 0700 /root/local-account-migrationCopy the account files you will analyze:
sudo cp -a /etc/passwd /etc/group /etc/shadow /etc/gshadow /root/local-account-migration/Record the distribution account policy:
grep -E '^(UID_MIN|UID_MAX|GID_MIN|GID_MAX)' /etc/login.defsSample output:
UID_MIN 1000
UID_MAX 60000
GID_MIN 1000
GID_MAX 60000UID_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:
awk -F: '
$3 >= 1000 {
printf "%-20s uid=%-8s gid=%-8s home=%-30s shell=%s\n",
$1, $3, $4, $6, $7
}
' /etc/passwdReview accounts with disabled shells before you add them to the allowlist:
awk -F: '
$7 ~ /(nologin|false)$/ {
print $1 ":" $3 ":" $7
}
' /etc/passwdSample output on the lab source host:
nobody:65534:/usr/sbin/nologin
systemd-network:192:/usr/sbin/nologin
svcbackup:990:/usr/sbin/nologinExclude these categories unless a documented application requires centralized identity:
root- System accounts
- Package-created service users
- Daemon accounts
nobody- Accounts with
/usr/sbin/nologinor/bin/false, unless deliberately selected - Accounts already present in LDAP
- Temporary installation or automation accounts
Create an explicit user allowlist:
jdoe
asmith
bwilsonCreate a matching group allowlist. Include every selected user’s primary group, not only supplementary groups:
jdoe
asmith
bwilson
developers
operations
linux-adminsRFC2307 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:
LDAP_EXPORT_TMP=$(mktemp)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
fisudo install -m 0600 "$LDAP_EXPORT_TMP" /root/local-account-migration/existing-ldap-identities.ldifrm -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:
LDAP_OU_EXPORT_TMP=$(mktemp)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
fisudo install -m 0600 "$LDAP_OU_EXPORT_TMP" /root/local-account-migration/existing-ldap-ou-entries.ldifrm -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:
awk -F: '{ if ($3 in uid) print "dup uid", $3, $1, uid[$3]; uid[$3]=$1 }' /etc/passwdWhen the command prints nothing, every UID in passwd is unique on that host.
Check local GID uniqueness the same way:
awk -F: '{ if ($3 in gid) print "dup gid", $3, $1, gid[$3]; gid[$3]=$1 }' /etc/groupReview 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:
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 conflictRecommended 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.
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:
groups.ldif
users.ldif
memberships.ldif
passwords.ldif
migration-report.tsv
password-report.tsvKeep 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:
sudo install -d -m 0700 /root/local-account-migrationsudo 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())
EOFMake the script executable:
sudo chmod 0700 /root/local-account-migration/migrate-local-accounts.pyRun preflight and generation against the backed-up account files, allowlists, and LDAP export:
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.pyWhen every selected object passes preflight, sample output looks like this:
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.tsvSample migration report after a clean preflight on the lab host:
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 - migrateWhen 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:
Preflight failed with 1 unresolved conflict(s)Review the migration report:
column -t -s $'\t' /root/local-account-migration/migration-report.tsvSample output when one LDAP username already exists with a different UID or home directory:
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-mismatchWhen 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:
column -t -s $'\t' /root/local-account-migration/password-report.tsvSample output:
user password-status
miguser1 unsupported-reset-required
miguser2 unsupported-reset-requiredRocky 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:
dn: cn=developers,ou=groups,dc=example,dc=com
objectClass: top
objectClass: posixGroup
cn: developers
gidNumber: 10010
description: Migrated from ldap-server.example.comGenerate POSIX users
Example user entry:
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.comGenerate supplementary memberships
Example membership modify:
dn: cn=linux-admins,ou=groups,dc=example,dc=com
changetype: modify
add: memberUid
memberUid: jdoe
memberUid: asmithKeep these rules in mind:
- The primary group comes from the user’s
gidNumber. - Supplementary memberships come from the member list in
/etc/group. memberUidstores the plain username from the user’s LDAPuidattribute.- When a group already exists in LDAP, the generator adds only missing
memberUidvalues from the export snapshot. - A username does not need to appear as
memberUidin its primary group. - Empty
posixGroupentries 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:
grep '^dn:' /root/local-account-migration/groups.ldif /root/local-account-migration/users.ldif | sort | uniq -dWhen 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:
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.ldifPreview user adds:
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.ldifPreview membership and password changes with ldapmodify because those files contain changetype: modify:
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.ldifldapmodify -n -v -x -ZZ -H ldap://ldap-server.example.com -D "cn=admin,dc=example,dc=com" -W -f /root/local-account-migration/passwords.ldifResolve every preflight conflict before the production import.
Import Groups, Users and Memberships
Back up OpenLDAP before import:
sudo slapcat -b "dc=example,dc=com" -l /root/openldap-before-local-user-migration.ldifImport in this order:
1. Groups
2. Users
3. Supplementary memberships
4. Passwords or password resetsImport groups:
ldapadd -x -ZZ -H ldap://ldap-server.example.com -D "cn=admin,dc=example,dc=com" -W -f /root/local-account-migration/groups.ldifImport users:
ldapadd -x -ZZ -H ldap://ldap-server.example.com -D "cn=admin,dc=example,dc=com" -W -f /root/local-account-migration/users.ldifApply supplementary memberships:
ldapmodify -x -ZZ -H ldap://ldap-server.example.com -D "cn=admin,dc=example,dc=com" -W -f /root/local-account-migration/memberships.ldifVerify user counts with an authenticated search:
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 gidNumberVerify group membership:
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 memberUidRepeat the required user and group searches with the SSSD reader identity to prove that clients can retrieve the imported records:
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 loginShellDo 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
ppolicywill manage future password lifecycle
Set a temporary password. ldappasswd prompts for the new password instead of placing it on the command line:
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:
sudo awk -F: '$1 == "jdoe" {print $2}' /etc/shadowConvert a supported hash from:
$6$SALT$HASHto:
dn: uid=jdoe,ou=people,dc=example,dc=com
changetype: modify
replace: userPassword
userPassword: {CRYPT}$6$SALT$HASHOpenLDAP 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:
- Test one disposable user.
- Verify the hash prefix is supported.
- Bind directly with
ldapwhoami. - Verify through SSSD.
- Confirm password-policy interaction.
- Confirm the hash is not altered by the generator.
Apply reviewed password LDIF only after those checks:
ldapmodify -x -ZZ -H ldap://ldap-server.example.com -D "cn=admin,dc=example,dc=com" -W -f /root/local-account-migration/passwords.ldifDo 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:
passwd: files sss
group: files sssVerify identity lookup
getent passwd miguser1Sample output on the lab client when no local miguser1 entry exists:
miguser1:*:15001:15001:Migration Test miguser1:/home/miguser1:/bin/bashThe returned fields must match the source account for username, uidNumber, gidNumber, homeDirectory, and loginShell.
Verify groups:
id miguser1Sample output:
uid=15001(miguser1) gid=15001(miguser1) groups=15001(miguser1),15010(migdevs)getent group migdevsSample output:
migdevs:*:15010:miguser1,miguser2Verify the LDAP password
ldapwhoami -x -ZZ -H ldap://ldap-server.example.com -D "uid=miguser1,ou=people,dc=example,dc=com" -WVerify SSSD authentication
sudo sssctl user-checks -a auth -s sshd miguser1sudo sssctl user-checks -a acct -s sshd miguser1Sample output:
- name: miguser1
- uidNumber: 15001
- gidNumber: 15001
- gecos: Migration Test miguser1
- homeDirectory: /home/miguser1
- loginShell: /bin/bashVerify login
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:
- Freeze account creation and UID/GID changes.
- Run the conflict report again.
- Re-export selected local accounts.
- Compare with the imported LDAP entries.
- Back up local account files and SSSD configuration.
- Confirm OpenLDAP backup and rollback procedures.
- Confirm every user can authenticate from the validation client.
Do not immediately delete local accounts.
Recommended cutover sequence:
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 backupsAfter 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:
sudo userdel jdoeDo 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:
sudo groupdel developersDo not remove a shared local group while non-migrated local users still depend on it.
Expire only the affected SSSD identity:
sudo sss_cache -u jdoeVerify both name and numeric lookup:
getent passwd jdoegetent passwd 10001getent group developersgetent group 10010Preserve home data and numeric ownership.
Check files owned by a migrated UID:
sudo find / -xdev -uid 10001 -lsCheck group-owned files:
sudo find / -xdev -gid 10001 -lsWhen 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:
/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 checklistRollback sequence:
- Restore local NSS and PAM configuration.
- Restore the protected local account files when they were modified.
- Restart SSSD.
- Verify local identity and authentication.
- Remove imported LDAP entries only after local access works.
- 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:
sudo sssctl config-checkValidates SSSD syntax and file permissions when the client fails to start after cutover.
sudo sssctl domain-status example.comShows whether the domain is online and when it last contacted LDAP.
sudo sss_cache -u jdoeExpires one user after UID or attribute changes when other cached entries should stay.
sudo journalctl -u sssd -n 200 --no-pagerClient-side identity and authentication errors during post-migration login tests.
sudo journalctl -u slapd -n 200 --no-pagerServer-side import, ACL, or bind failures when LDAP data looks correct but operations fail.
References
- OpenLDAP 2.6 Administrator's Guide — Password Storage
- slapd.access(5)
- ldapadd(1)
- ldapmodify(1)
- ldappasswd(1)
- SSSD LDAP provider documentation
- passwd(5)
- group(5)
- shadow(5)
- nsswitch.conf(5)
- getent(1)
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.

