ipa/0005-Add-ipa-idrange-fix.patch

1502 lines
51 KiB
Diff
Raw Permalink Normal View History

From aa4651526e6697e15ce4960bf1d15d1389889c7f Mon Sep 17 00:00:00 2001
From: "asharov@redhat.com" <asharov@redhat.com>
Date: Mon, 24 Jun 2024 15:33:34 +0200
Subject: [PATCH] Add ipa-idrange-fix
ipa-idrange-fix is a tool for analysis of existing IPA ranges, users
and groups outside of those ranges, and functionality to propose
and apply remediations to make sure as much users and groups as
possible end up in the IPA-managed ranges.
Fixes: https://pagure.io/freeipa/issue/9612
Signed-off-by: Aleksandr Sharov <asharov@redhat.com>
Reviewed-By: Rob Crittenden <rcritten@redhat.com>
Reviewed-By: Rob Crittenden <rcritten@redhat.com>
---
freeipa.spec.in | 2 +
install/tools/Makefile.am | 2 +
install/tools/ipa-idrange-fix.in | 8 +
install/tools/man/Makefile.am | 1 +
install/tools/man/ipa-idrange-fix.1 | 111 ++
ipaserver/install/ipa_idrange_fix.py | 1085 +++++++++++++++++
.../test_integration/test_ipa_idrange_fix.py | 189 +++
7 files changed, 1398 insertions(+)
create mode 100644 install/tools/ipa-idrange-fix.in
create mode 100644 install/tools/man/ipa-idrange-fix.1
create mode 100644 ipaserver/install/ipa_idrange_fix.py
create mode 100644 ipatests/test_integration/test_ipa_idrange_fix.py
diff --git a/freeipa.spec.in b/freeipa.spec.in
index e370290bc74d92ab239bf11e88b3fa7e4faef415..171b6ad27b57553fdd46c7d041715949bb00b163 100755
--- a/freeipa.spec.in
+++ b/freeipa.spec.in
@@ -1517,6 +1517,7 @@ fi
%{_sbindir}/ipa-pkinit-manage
%{_sbindir}/ipa-crlgen-manage
%{_sbindir}/ipa-cert-fix
+%{_sbindir}/ipa-idrange-fix
%{_sbindir}/ipa-acme-manage
%{_sbindir}/ipa-migrate
%if 0%{?fedora} >= 38
@@ -1596,6 +1597,7 @@ fi
%{_mandir}/man1/ipa-pkinit-manage.1*
%{_mandir}/man1/ipa-crlgen-manage.1*
%{_mandir}/man1/ipa-cert-fix.1*
+%{_mandir}/man1/ipa-idrange-fix.1*
%{_mandir}/man1/ipa-acme-manage.1*
%{_mandir}/man1/ipa-migrate.1*
diff --git a/install/tools/Makefile.am b/install/tools/Makefile.am
index c454fad9795c79f88e1d72688f1d15c5234cc113..ca484ec37969c9c06ae7b408b55fa30cd4e8e4fe 100644
--- a/install/tools/Makefile.am
+++ b/install/tools/Makefile.am
@@ -31,6 +31,7 @@ dist_noinst_DATA = \
ipa-pkinit-manage.in \
ipa-crlgen-manage.in \
ipa-cert-fix.in \
+ ipa-idrange-fix.in \
ipa-custodia.in \
ipa-custodia-check.in \
ipa-httpd-kdcproxy.in \
@@ -68,6 +69,7 @@ nodist_sbin_SCRIPTS = \
ipa-pkinit-manage \
ipa-crlgen-manage \
ipa-cert-fix \
+ ipa-idrange-fix \
ipa-acme-manage \
ipa-migrate \
$(NULL)
diff --git a/install/tools/ipa-idrange-fix.in b/install/tools/ipa-idrange-fix.in
new file mode 100644
index 0000000000000000000000000000000000000000..5994bd28b15e247c5a086238f36b16cc75ff24c3
--- /dev/null
+++ b/install/tools/ipa-idrange-fix.in
@@ -0,0 +1,8 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2024 FreeIPA Contributors see COPYING for license
+#
+
+from ipaserver.install.ipa_idrange_fix import IPAIDRangeFix
+
+IPAIDRangeFix.run_cli()
diff --git a/install/tools/man/Makefile.am b/install/tools/man/Makefile.am
index 34f359863afca7b6c1e792a53afc25bb8eb41fd3..e9542a77bbbb88054eae1e64311d6e9ec5bee499 100644
--- a/install/tools/man/Makefile.am
+++ b/install/tools/man/Makefile.am
@@ -29,6 +29,7 @@ dist_man1_MANS = \
ipa-pkinit-manage.1 \
ipa-crlgen-manage.1 \
ipa-cert-fix.1 \
+ ipa-idrange-fix.1 \
ipa-acme-manage.1 \
ipa-migrate.1 \
$(NULL)
diff --git a/install/tools/man/ipa-idrange-fix.1 b/install/tools/man/ipa-idrange-fix.1
new file mode 100644
index 0000000000000000000000000000000000000000..178d2e88779e135a65f3285de62d2dc3b19c175a
--- /dev/null
+++ b/install/tools/man/ipa-idrange-fix.1
@@ -0,0 +1,111 @@
+.\"
+.\" Copyright (C) 2024 FreeIPA Contributors see COPYING for license
+.\"
+.TH "ipa-idrange-fix" "1" "May 26 2024" "IPA" "IPA Manual Pages"
+.SH "NAME"
+ipa\-idrange\-fix \- Analyse and fix IPA ID ranges
+.SH "SYNOPSIS"
+ipa\-idrange\-fix [options]
+.SH "DESCRIPTION"
+
+\fIipa-idrange-fix\fR is a tool for analysis of existing IPA ranges, users and
+groups outside of those ranges, and functionality to propose and apply
+remediations to make sure as many users and groups as possible end up in the
+IPA-managed ranges. Before any changes are applied, a full backup of the system
+is \fBSTRONGLY RECOMMENDED\fR.
+
+Do not use this program in unattended mode unless you are absolutely sure
+you are consenting to the tool's proposals.
+
+You can apply the proposals manually via \fIipa idrange(1)\fR commands.
+
+This tool requires it to be run as \fBroot\fR and does not require a kerberos
+ticket. The directory server needs to be running.
+
+\fIipa-idrange-fix\fR will read current ranges from LDAP, then check their
+basic constraints, RID bases, etc. If it finds critical issues with ranges,
+manual adjustment will be required.
+
+After analyzing existing ranges, the tool will search for users and groups that
+are outside of ipa-local ranges. Then it will attempt to propose new ipa-local
+ranges in order to cover users and groups found.
+
+Finally, the tool will summarize the analysis, and, if there are proposed
+changes, will ask if the user wants to apply those. Please read the
+proposals carefully before proceeding with changes!
+
+Important note: By default, \fIipa-idrange-fix\fR will not cover the users and
+groups that have IDs under 1000 as these IDs are reserved for system and
+service users and groups. We \fBdon't recommend\fR using IDs under 1000 for
+IPA users and groups as they can possibly overlap with local ones. Please
+consider moving those users out of the range 1..1000, unless they are
+absolutely needed.
+
+.SH "OPTIONS"
+.TP
+\fB\-\-version\fR
+Show the program's version and exit.
+.TP
+\fB\-h\fR, \fB\-\-help\fR
+Show the help for this program.
+.TP
+\fB\-\-ridoffset \fIINT\fR
+An offset for newly proposed base RIDs for ranges. We introduce offset in order
+to have an ability to increase ranges in the future, increase to more than
+offset will result in RID bases overlapping, and will be denied. If set to 0,
+there will be no offset, proposed RID ranges will start directly one after
+another.
+
+Default - \fI100000\fR, allowed values - from \fI0\fR to \fI2^31\fR.
+.TP
+\fB\-\-rangegap \fIINT\fR
+A number of IDs between out of ranges IDs to be considered too big to be inside
+a proposed range. If the gap is bigger than this attribute, a new range will be
+started. If set to 0, every entity will get its own range, if allowed by
+\fI--minrange\fR.
+
+Default - \fI200000\fR, allowed values - from \fI0\fR to \fI2^31\fR.
+.TP
+\fB\-\-minrange \fIINT\fR
+A minimal amount of IDs the tool considers to be a valid range. All IDs that
+would form a range with less than this number will be considered outliers, not
+worth creating an IDrange for, and will be listed explicitly to be moved
+manually. If set to 1, a range will be proposed for every entity, even if the
+entity is single in the middle of an empty space.
+
+Default - \fI10\fR, allowed values - from \fI1\fR to \fI2^31\fR.
+.TP
+\fB\-\-allowunder1000\fR
+A flag to allow proposing ranges that start with IDs lower than \fI1000\fR.
+Remember, this is not recommended - IDs under 1000 are reserved for system and
+service users and groups. IDranges with these low IDs may result with
+overlapping of IPA and system local users and groups, which can be a serious
+security issue and generally produce a lot of issues around these entities'
+resolution.
+.TP
+\fB\-\-norounding\fR
+A flag to turn off idrange starting id and size rounding - e.g. if we find
+ID 1234, and the size 567, it will stay that way, the proposed range will
+start at ID 1234, and have a 567 size. If not specified, basic rounding to
+outer margins will be applied. Rounding will be 10^size of the proposed range.
+.TP
+\fB\-\-unattended\fR
+Run the tool in unattended mode, if any changes would be proposed, they will
+be applied automatically.
+.TP
+\fB\-v\fR, \fB\-\-verbose\fR
+Print debugging information.
+.TP
+\fB\-q\fR, \fB\-\-quiet\fR
+Output only errors (output from child processes may still be shown).
+.TP
+\fB\-\-log\-file\fR=\fIFILE\fR
+Log to the given file.
+.SH "EXIT STATUS"
+0 if the command was successful
+
+1 if an error occurred
+
+.SH "SEE ALSO"
+.BR ipa\ idrange-mod(1)
+.BR ipa\ idrange-add(1)
diff --git a/ipaserver/install/ipa_idrange_fix.py b/ipaserver/install/ipa_idrange_fix.py
new file mode 100644
index 0000000000000000000000000000000000000000..c6c67ae9330e2d0184efc09d09a84216ef0772a6
--- /dev/null
+++ b/ipaserver/install/ipa_idrange_fix.py
@@ -0,0 +1,1085 @@
+"""Tool to analyze and fix IPA ID ranges"""
+#
+# Copyright (C) 2024 FreeIPA Contributors see COPYING for license
+#
+
+import logging
+import ldap
+
+from ipalib import api, errors
+from ipapython.admintool import AdminTool
+from ipapython.dn import DN
+from ipapython import ipautil
+from typing import List, Tuple
+
+logger = logging.getLogger(__name__)
+
+
+class IDRange:
+ """Class for ID range entity"""
+
+ def __init__(self):
+ self.last_id: int = None
+ self.last_base_rid: int = None
+ self.last_secondary_rid: int = None
+ self.name: str = None
+ self.size: int = None
+ self.first_id: int = None
+ self.base_rid: int = None
+ self.secondary_base_rid: int = None
+ self.type: str = None
+ self.suffix: str = None
+ self.dn: str = None
+
+ def _count(self) -> None:
+ """Function to calculate last IDs for the range"""
+ self.last_id = self.first_id + self.size - 1
+ if self.type == "ipa-local":
+ self.last_base_rid = (
+ self.base_rid + self.size
+ if self.base_rid is not None
+ else None
+ )
+ self.last_secondary_rid = (
+ self.secondary_base_rid + self.size
+ if self.secondary_base_rid is not None
+ else None
+ )
+
+ def __repr__(self):
+ return (
+ f"IDRange(name='{self.name}', "
+ f"type={self.type}, "
+ f"size={self.size}, "
+ f"first_id={self.first_id}, "
+ f"base_rid={self.base_rid}, "
+ f"secondary_base_rid={self.secondary_base_rid})"
+ )
+
+ def __eq__(self, other):
+ return self.first_id == other.first_id
+
+
+class IDentity:
+ """A generic class for ID entity - users or groups"""
+
+ def __init__(self, **kwargs):
+ self.dn: str = kwargs.get('dn')
+ self.name: str = kwargs.get('name')
+ self.user: str = kwargs.get('user')
+ self.number: int = kwargs.get('number')
+
+ def __str__(self):
+ if self.user:
+ return (f"user '{self.name}', uid={self.number}")
+ return (f"group '{self.name}', gid={self.number}")
+
+ def debug(self):
+ if self.user:
+ return (
+ f"user(username='{self.name}', "
+ f"uid={self.number}, "
+ f"{self.dn})"
+ )
+ return (
+ f"group(groupname='{self.name}', "
+ f"gid={self.number}, "
+ f"{self.dn})"
+ )
+
+ def __eq__(self, other):
+ return self.number == other.number and self.user == other.user
+
+
+class IPAIDRangeFix(AdminTool):
+ """Tool to analyze and fix IPA ID ranges"""
+
+ command_name = "ipa-idrange-fix"
+ log_file_name = "/var/log/ipa-idrange-fix.log"
+ usage = "%prog"
+ description = "Analyze and fix IPA ID ranges"
+
+ @classmethod
+ def add_options(cls, parser, debug_option=False):
+ super(IPAIDRangeFix, cls).add_options(parser)
+ parser.add_option(
+ "--ridoffset",
+ dest="ridoffset",
+ type=int,
+ default=100000,
+ metavar=100000,
+ help="Offset for a next base RID from previous RID range. \
+Needed for future range size expansions. Has to be > 0",
+ )
+ parser.add_option(
+ "--rangegap",
+ dest="rangegap",
+ type=int,
+ default=200000,
+ metavar=200000,
+ help="Threshold for a gap between out-of-range IDs to be \
+considered a different range. Has to be > 0",
+ )
+ parser.add_option(
+ "--minrange",
+ dest="minrange",
+ type=int,
+ default=10,
+ metavar=10,
+ help="Minimal considered range size for out-of-range IDs.\
+All ranges with amount of IDs lower than this number will be discarded and \
+IDs will be listed to be moved manually. Has to be > 1",
+ )
+ parser.add_option(
+ "--allowunder1000",
+ dest="allowunder1000",
+ action="store_true",
+ default=False,
+ help="Allow idranges to start below 1000. Be careful to not \
+overlap IPA users/groups with existing system-local ones!",
+ )
+ parser.add_option(
+ "--norounding",
+ dest="norounding",
+ action="store_true",
+ default=False,
+ help="Disable IDrange rounding attempt in order to get ranges \
+exactly covering just IDs provided",
+ )
+ parser.add_option(
+ "--unattended",
+ dest="unattended",
+ action="store_true",
+ default=False,
+ help="Automatically fix all range issues found without asking \
+for confirmation",
+ )
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.realm: str = None
+ self.suffix: DN = None
+ self.proposals_rid: List[IDRange] = []
+ self.proposals_new: List[IDRange] = []
+ self.outliers: List[IDentity] = []
+ self.under1000: List[IDentity] = []
+ self.id_ranges: List[IDRange] = []
+
+ def validate_options(self, needs_root=True):
+ super().validate_options(needs_root)
+
+ def run(self):
+ api.bootstrap(in_server=True)
+ api.finalize()
+
+ self.realm = api.env.realm
+ self.suffix = ipautil.realm_to_suffix(self.realm)
+ try:
+ api.Backend.ldap2.connect()
+
+ # Reading range data
+ self.id_ranges = read_ranges(self.suffix)
+
+ # Evaluating existing ranges, if something is off, exit
+ if self.evaluate_ranges() != 0:
+ return 1
+
+ # reading out of range IDs
+ ids_out_of_range = read_outofrange_identities(
+ self.suffix, self.id_ranges
+ )
+
+ # Evaluating out of range IDs
+ self.evaluate_identities(ids_out_of_range)
+
+ # Print the proposals
+ self.print_intentions()
+
+ # If there are no proposals, we have nothing to do, exiting
+ if (len(self.proposals_rid) == 0
+ and len(self.proposals_new) == 0):
+ logger.info("\nNo changes proposed, nothing to do.")
+ return 0
+
+ logger.info("\nID ranges table after proposed changes:")
+ draw_ascii_table(self.id_ranges)
+
+ if self.options.unattended:
+ logger.info(
+ "Unattended mode, proceeding with applying changes!"
+ )
+ else:
+ response = ipautil.user_input('Enter "yes" to proceed')
+ if response.lower() != "yes":
+ logger.info("Not proceeding.")
+ return 0
+ logger.info("Proceeding.")
+
+ # Applying changes
+ for id_range in self.proposals_rid:
+ apply_ridbases(id_range)
+
+ for id_range in self.proposals_new:
+ create_range(id_range)
+
+ logger.info("All changes applied successfully!")
+
+ finally:
+ if api.Backend.ldap2.isconnected():
+ api.Backend.ldap2.disconnect()
+
+ return 0
+
+ def evaluate_ranges(self) -> int:
+ """Function to evaluate existing ID ranges"""
+ if len(self.id_ranges) == 0:
+ logger.error("No ID ranges found!")
+ return 1
+
+ draw_ascii_table(self.id_ranges)
+
+ if not ranges_overlap_check(self.id_ranges):
+ logger.error(
+ "Ranges overlap detected, cannot proceed! Please adjust \
+existing ranges manually."
+ )
+ return 1
+
+ # Checking RID bases for existing ranges
+ id_ranges_nobase = get_ranges_no_base(self.id_ranges)
+
+ if len(id_ranges_nobase) > 0:
+ logger.info(
+ "Found %s ranges without base RIDs", len(id_ranges_nobase)
+ )
+ for id_range in id_ranges_nobase:
+ logger.debug(
+ "Range '%s' has RID base %s and secondary RID base %s",
+ id_range.name,
+ id_range.base_rid,
+ id_range.secondary_base_rid,
+ )
+ propose_rid_ranges(
+ self.id_ranges,
+ self.options.ridoffset,
+ self.proposals_rid
+ )
+ else:
+ logger.info(
+ "All ID ranges have base RIDs set, RID adjustments are \
+not needed."
+ )
+ return 0
+
+ def evaluate_identities(self, ids_out_of_range: List[IDentity]) -> None:
+ """Function to evaluate out of range IDs"""
+ if len(ids_out_of_range) == 0:
+ logger.info("No out of range IDs found!")
+ else:
+ logger.info(
+ "Found overall %s IDs out of existing ID ranges.\n",
+ len(ids_out_of_range),
+ )
+ # ruling out IDs under 1000 if flag is not set
+ if not self.options.allowunder1000:
+ self.under1000, ids_out_of_range = separate_under1000(
+ ids_out_of_range
+ )
+ if len(self.under1000) > 0:
+ logger.info(
+ "Found IDs under 1000, which is not recommeneded \
+(if you definitely need ranges proposed for those, use --allowunder1000):"
+ )
+ for identity in self.under1000:
+ logger.info("%s", identity)
+
+ # Get initial divide of IDs into groups
+ groups = group_identities_by_threshold(
+ ids_out_of_range, self.options.rangegap
+ )
+
+ # Get outliers from too small groups and clean groups for
+ # further processing
+ self.outliers, cleangroups = separate_ranges_and_outliers(
+ groups, self.options.minrange
+ )
+
+ # Print the outliers, they have to be moved manually
+ if len(self.outliers) > 0:
+ logger.info(
+ "\nIdentities that don't fit the criteria to get a new "
+ "range found! Current attributes:\n"
+ "Minimal range size: %s\n"
+ "Maximum gap between IDs: %s\n"
+ "Try adjusting --minrange, --rangegap or move the "
+ "following identities into already existing ranges:",
+ self.options.minrange,
+ self.options.rangegap
+ )
+ for identity in self.outliers:
+ logger.info("%s", identity)
+
+ if len(cleangroups) > 0:
+ # Get IDrange name base
+ basename = get_rangename_base(self.id_ranges)
+
+ # Create proposals for new ranges from groups
+ for group in cleangroups:
+ newrange = propose_range(
+ group,
+ self.id_ranges,
+ self.options.ridoffset,
+ basename,
+ self.options.norounding,
+ self.options.allowunder1000
+ )
+ if newrange is not None:
+ self.proposals_new.append(newrange)
+ self.id_ranges.append(newrange)
+ self.id_ranges.sort(key=lambda x: x.first_id)
+ else:
+ logger.info(
+ "\nNo IDs fit the criteria for a new ID range to propose!"
+ )
+
+ def print_intentions(self) -> None:
+ """Function to print out the summary of the proposed changes"""
+ logger.info("\nSummary:")
+
+ if len(self.outliers) > 0:
+ logger.info("Outlier IDs that are too far away to get a range:")
+ for identity in self.outliers:
+ logger.info("%s", identity)
+
+ if len(self.under1000) > 0:
+ if self.options.allowunder1000:
+ logger.info("IDs under 1000 were treated like normal IDs.")
+ else:
+ logger.info("IDs under 1000:")
+ for identity in self.under1000:
+ logger.info("%s", identity)
+ else:
+ logger.info("No IDs under 1000 found.")
+
+ if len(self.proposals_rid) > 0:
+ logger.info("Proposed changes to existing ranges:")
+ for id_range in self.proposals_rid:
+ logger.info(
+ "Range '%s' - base RID: %s, secondary base RID: %s",
+ id_range.name,
+ id_range.base_rid,
+ id_range.secondary_base_rid,
+ )
+ else:
+ logger.info("No changes proposed for existing ranges.")
+
+ if len(self.proposals_new) > 0:
+ logger.info("Proposed new ranges:")
+ for id_range in self.proposals_new:
+ logger.info("%s", id_range)
+ else:
+ logger.info("No new ranges proposed.")
+
+# Working with output
+# region
+
+
+def draw_ascii_table(id_ranges: List[IDRange], stdout: bool = False) -> None:
+ """Function to draw a table with ID ranges in ASCII"""
+ table: str = "\n"
+ # Calculate the maximum width required for each column using column names
+ max_widths = {
+ column: max(
+ len(str(column)),
+ max(
+ (
+ len(str(getattr(id_range, column)))
+ if getattr(id_range, column) is not None
+ else 0
+ )
+ for id_range in id_ranges
+ ),
+ )
+ for column in [
+ "name",
+ "type",
+ "size",
+ "first_id",
+ "last_id",
+ "base_rid",
+ "last_base_rid",
+ "secondary_base_rid",
+ "last_secondary_rid",
+ ]
+ }
+
+ # Draw the table header
+ header = "| "
+ for column, width in max_widths.items():
+ header += f"{column.ljust(width)} | "
+ horizontal_line = "-" * (len(header) - 1)
+ table += horizontal_line + "\n"
+ table += header + "\n"
+ table += horizontal_line + "\n"
+
+ # Draw the table rows
+ for id_range in id_ranges:
+ row = "| "
+ for column, width in max_widths.items():
+ value = getattr(id_range, column)
+ if value is not None:
+ row += f"{str(value).rjust(width)} | "
+ else:
+ # Adding the separator
+ row += " " * (width + 1) + "| "
+ table += row + "\n"
+ table += horizontal_line + "\n"
+ if stdout:
+ print(table)
+ else:
+ logger.info(table)
+# endregion
+# Reading from LDAP
+# region
+
+
+def read_ranges(suffix) -> List[IDRange]:
+ """Function to read ID ranges from LDAP"""
+ id_ranges: IDRange = []
+ try:
+ ranges = api.Backend.ldap2.get_entries(
+ DN(api.env.container_ranges, suffix),
+ ldap.SCOPE_ONELEVEL,
+ "(objectclass=ipaIDRange)",
+ )
+ except errors.NotFound:
+ logger.error("LDAPError: No ranges found!")
+ except errors.ExecutionError as e:
+ logger.error("Exception while reading users: %s", e)
+ else:
+ for entry in ranges:
+ sv = entry.single_value
+ id_range = IDRange()
+ id_range.name = sv.get("cn")
+ id_range.size = int(sv.get("ipaidrangesize"))
+ id_range.first_id = int(sv.get("ipabaseid"))
+ id_range.base_rid = (
+ int(sv.get("ipabaserid")) if sv.get("ipabaserid") else None
+ )
+ id_range.secondary_base_rid = (
+ int(sv.get("ipasecondarybaserid"))
+ if sv.get("ipasecondarybaserid")
+ else None
+ )
+ id_range.suffix = suffix
+ id_range.type = sv.get("iparangetype")
+ id_range.dn = entry.dn
+
+ id_range._count()
+ logger.debug("ID range found: %s", id_range)
+
+ id_ranges.append(id_range)
+
+ id_ranges.sort(key=lambda x: x.first_id)
+ return id_ranges
+
+
+def read_outofrange_identities(suffix, id_ranges) -> List[IDentity]:
+ """Function to read out of range users and groups from LDAP"""
+ users_outofrange = read_ldap_ids(
+ DN(api.env.container_user, suffix),
+ True,
+ id_ranges
+ )
+ logger.info("Users out of range found: %s", len(users_outofrange))
+ del_outofrange = read_ldap_ids(
+ DN(api.env.container_deleteuser, suffix),
+ True,
+ id_ranges
+ )
+ logger.info("Preserved users out of range found: %s", len(del_outofrange))
+ groups_outofrange = read_ldap_ids(
+ DN(api.env.container_group, suffix),
+ False,
+ id_ranges
+ )
+ logger.info("Groups out of range found: %s", len(groups_outofrange))
+ outofrange = users_outofrange + del_outofrange + groups_outofrange
+ outofrange.sort(key=lambda x: x.number)
+ return outofrange
+
+
+def read_ldap_ids(container_dn, user: bool, id_ranges) -> List[IDentity]:
+ """Function to read IDs from containter in LDAP"""
+ id_entities = []
+ if user:
+ id_name = "user"
+ ldap_filter = get_outofrange_filter(
+ id_ranges,
+ "posixaccount",
+ "uidNumber"
+ )
+ else:
+ id_name = "group"
+ ldap_filter = get_outofrange_filter(
+ id_ranges,
+ "posixgroup",
+ "gidNumber"
+ )
+
+ logger.debug("Searching %ss in %s with filter: %s", id_name, container_dn,
+ ldap_filter)
+ try:
+ identities = api.Backend.ldap2.get_entries(
+ container_dn,
+ ldap.SCOPE_ONELEVEL,
+ ldap_filter,
+ )
+ for entry in identities:
+ id_entities.append(read_identity(entry, user))
+ except errors.NotFound:
+ logger.debug("No out of range %ss found in %s!", id_name, container_dn)
+ except errors.ExecutionError as e:
+ logger.error("Exception while reading %s: %s", container_dn, e)
+ return id_entities
+
+
+def read_identity(ldapentry, user: bool = True) -> IDentity:
+ """Function to convert LDAP entry to IDentity object"""
+ sv = ldapentry.single_value
+ id_entity = IDentity()
+ id_entity.dn = ldapentry.dn
+ id_entity.name = sv.get("cn")
+ id_entity.number = (
+ int(sv.get("uidNumber")) if user else int(sv.get("gidNumber"))
+ )
+ id_entity.user = user
+ logger.debug("Out of range found: %s", id_entity.debug())
+ return id_entity
+
+
+def get_outofrange_filter(
+ id_ranges_all: List[IDRange], object_class: str, posix_id: str
+) -> str:
+ """Function to create LDAP filter for out of range users and groups"""
+ # we need to look only for ipa-local ranges
+ id_ranges = get_ipa_local_ranges(id_ranges_all)
+
+ ldap_filter = f"(&(objectClass={object_class})(|"
+
+ # adding gaps in ranges to the filter
+ for i in range(len(id_ranges) + 1):
+ if i == 0:
+ start_condition = f"({posix_id}>=1)"
+ else:
+ start_condition = f"({posix_id}>={id_ranges[i - 1].last_id + 1})"
+
+ if i < len(id_ranges):
+ end_condition = f"({posix_id}<={id_ranges[i].first_id - 1})"
+ else:
+ end_condition = f"({posix_id}<=2147483647)"
+
+ ldap_filter += f"(&{start_condition}{end_condition})"
+
+ ldap_filter += "))"
+
+ return ldap_filter
+# endregion
+# Writing to LDAP
+# region
+
+
+def apply_ridbases(id_range: IDRange) -> None:
+ """Funtion to apply RID bases to the range in LDAP"""
+ try:
+ api.Backend.ldap2.modify_s(
+ id_range.dn,
+ [
+ (ldap.MOD_ADD, "ipaBaseRID", str(id_range.base_rid)),
+ (
+ ldap.MOD_ADD,
+ "ipaSecondaryBaseRID",
+ str(id_range.secondary_base_rid),
+ ),
+ ],
+ )
+ logger.info("RID bases updated for range '%s'", id_range.name)
+
+ except ldap.CONSTRAINT_VIOLATION as e:
+ logger.error(
+ "Failed to add RID bases to the range '%s': %s",
+ id_range.name,
+ e
+ )
+ raise RuntimeError("Constraint violation.\n") from e
+
+ except Exception as e:
+ logger.error(
+ "Exception while updating RID bases for range '%s': %s",
+ id_range.name,
+ e,
+ )
+ raise RuntimeError("Failed to update RID bases.\n") from e
+
+
+def create_range(id_range: IDRange) -> None:
+ """Function to create a new range in LDAP"""
+ try:
+ logger.info("Creating range '%s'...", id_range.name)
+
+ entry = api.Backend.ldap2.make_entry(
+ DN(id_range.dn),
+ objectclass=["ipaIDRange", "ipaDomainIDRange"],
+ ipaidrangesize=[str(id_range.size)],
+ ipabaseid=[str(id_range.first_id)],
+ ipabaserid=[str(id_range.base_rid)],
+ ipasecondarybaserid=[str(id_range.secondary_base_rid)],
+ iparangetype=[id_range.type],
+ )
+
+ api.Backend.ldap2.add_entry(entry)
+ logger.info("Range '%s' created successfully", id_range.name)
+ except Exception as e:
+ logger.error(
+ "Exception while creating range '%s': %s",
+ id_range.name,
+ e
+ )
+ raise RuntimeError("Failed to create range.\n") from e
+# endregion
+# Working with ranges
+# region
+
+
+def get_ipa_local_ranges(id_ranges: List[IDRange]) -> List[IDRange]:
+ """Function to get only ipa-local ranges from the list of ranges"""
+ ipa_local_ranges = []
+
+ for id_range in id_ranges:
+ if id_range.type == "ipa-local":
+ ipa_local_ranges.append(id_range)
+
+ return ipa_local_ranges
+
+
+def range_overlap_check(
+ range1_start: int, range1_end: int, range2_start: int, range2_end: int
+) -> bool:
+ """Function to check if two ranges overlap"""
+ # False when overlapping
+ return not (range1_start <= range2_end and range2_start <= range1_end)
+
+
+def range_overlap_check_idrange(range1: IDRange, range2: IDRange) -> bool:
+ """Function to check if two ranges overlap"""
+ # False when overlapping
+ return range_overlap_check(
+ range1.first_id, range1.last_id, range2.first_id, range2.last_id)
+
+
+def newrange_overlap_check(
+ id_ranges: List[IDRange], newrange: IDRange
+) -> bool:
+ """Function to check if proposed range overlaps with existing ones"""
+ for id_range in id_ranges:
+ if not range_overlap_check_idrange(id_range, newrange):
+ return False
+ return True
+
+
+def ranges_overlap_check(id_ranges: List[IDRange]) -> bool:
+ """Function to check if any of the existing ranges overlap"""
+ if len(id_ranges) < 2:
+ return True
+ for i in range(len(id_ranges) - 1):
+ for j in range(i + 1, len(id_ranges)):
+ if not range_overlap_check_idrange(id_ranges[i], id_ranges[j]):
+ logger.error(
+ "Ranges '%s' and '%s' overlap!",
+ id_ranges[i].name,
+ id_ranges[j].name,
+ )
+ return False
+ return True
+# endregion
+# Working with RID bases
+# region
+
+
+def propose_rid_ranges(
+ id_ranges: List[IDRange], delta: int, proposals: List[IDRange]
+) -> None:
+ """
+ Function to propose RID bases for ranges that don't have them set.
+
+ - delta represents how far we start new base off existing range,
+ used in order to allow for future expansion of existing ranges up
+ to [delta] IDs.
+ """
+ ipa_local_ranges = get_ipa_local_ranges(id_ranges)
+
+ for id_range in ipa_local_ranges:
+ proposed_base_rid = 0
+ proposed_secondary_base_rid = 0
+
+ # Calculate proposed base RID and secondary base RID
+ if id_range.base_rid is None:
+ result, proposed_base_rid = propose_rid_base(
+ id_range, ipa_local_ranges, delta, True
+ )
+ if result:
+ id_range.base_rid = proposed_base_rid
+ id_range.last_base_rid = proposed_base_rid + id_range.size
+ else:
+ # if this fails too, we print the warning and abandon the idea
+ logger.warning(
+ "Warning: Proposed base RIDs %s for '%s' both failed, \
+please adjust manually",
+ proposed_base_rid,
+ id_range.name,
+ )
+ continue
+
+ if id_range.secondary_base_rid is None:
+ result, proposed_secondary_base_rid = propose_rid_base(
+ id_range, ipa_local_ranges, delta, False, proposed_base_rid
+ )
+ if result:
+ id_range.secondary_base_rid = proposed_secondary_base_rid
+ id_range.last_secondary_rid = (
+ proposed_secondary_base_rid + id_range.size
+ )
+ else:
+ # if this fails too, we print the warning and abandon the idea
+ logger.warning(
+ "Warning: Proposed secondary base RIDs %s for '%s' \
+both failed, please adjust manually",
+ proposed_secondary_base_rid,
+ id_range.name,
+ )
+ continue
+
+ # Add range to the proposals if we changed something successfully
+ if proposed_base_rid > 0 or proposed_secondary_base_rid > 0:
+ logger.debug(
+ "Proposed RIDs for range '%s': pri %s, sec %s",
+ id_range.name,
+ proposed_base_rid,
+ proposed_secondary_base_rid,
+ )
+ proposals.append(id_range)
+
+
+def propose_rid_base(
+ idrange: IDRange,
+ ipa_local_ranges: List[IDRange],
+ delta: int,
+ primary: bool = True,
+ previous_base_rid: int = -1
+) -> Tuple[bool, str]:
+ """
+ Function to propose a base RID for a range, primary or secondary.
+ We are getting the biggest base RID + size + delta and try
+ if it's a viable option, check same kind first, then the other.
+ """
+ proposed_base_rid = max_rid(ipa_local_ranges, primary) + delta
+ if proposed_base_rid == previous_base_rid:
+ proposed_base_rid += idrange.size + delta
+ if check_rid_base(ipa_local_ranges, proposed_base_rid, idrange.size):
+ return True, proposed_base_rid
+
+ # if we fail, we try the same with biggest of a different kind
+ proposed_base_rid_orig = proposed_base_rid
+ proposed_base_rid = max_rid(ipa_local_ranges, not primary) + delta
+ if proposed_base_rid == previous_base_rid:
+ proposed_base_rid += idrange.size + delta
+ if check_rid_base(ipa_local_ranges, proposed_base_rid, idrange.size):
+ return True, proposed_base_rid
+
+ # if it fails, we return both RID proposals for the range
+ return False, f"{proposed_base_rid_orig} and {proposed_base_rid}"
+
+
+def max_rid(id_ranges: List[IDRange], primary: bool = True) -> int:
+ """Function to get maximum RID of primary or secondary RIDs"""
+ maximum_rid = 0
+ for id_range in id_ranges:
+
+ # looking only for primary RIDs
+ if primary:
+ if id_range.last_base_rid is not None:
+ maximum_rid = max(maximum_rid, id_range.last_base_rid)
+ # looking only for secondary RIDs
+ else:
+ if id_range.last_secondary_rid is not None:
+ maximum_rid = max(maximum_rid, id_range.last_secondary_rid)
+
+ return maximum_rid
+
+
+def check_rid_base(id_ranges: List[IDRange], base: int, size: int) -> bool:
+ """Function to check if proposed RID base is viable"""
+ end = base + size + 1
+
+ # Checking sanity of RID range
+ if base + size > 2147483647:
+ return False
+ if base < 1000:
+ return False
+
+ # Checking RID range overlaps
+ for id_range in id_ranges:
+ # we are interested only in ipa-local ranges
+ if id_range.type != "ipa-local":
+ continue
+
+ # if there is no base rid set, there is no secondary base rid set,
+ # so nothing to overlap with
+ if id_range.base_rid is None:
+ continue
+
+ # checking for an overlap
+ if not range_overlap_check(
+ base, end, id_range.base_rid, id_range.last_base_rid
+ ):
+ logger.debug(
+ "RID check failure: proposed Primary %s + %s, \
+intersects with %s-%s from range '%s'",
+ base,
+ size,
+ id_range.base_rid,
+ id_range.last_base_rid,
+ id_range.name,
+ )
+ return False
+
+ # if there is no secondary base rid set, nothing to overlap with
+ if id_range.secondary_base_rid is None:
+ continue
+
+ # if either start of end of the range fails inside existing range,
+ # or existing range is inside proposed one, we have an overlap
+ if not range_overlap_check(
+ base, end, id_range.secondary_base_rid, id_range.last_secondary_rid
+ ):
+ logger.debug(
+ "RID check failure: proposed Secondary %s + %s, \
+intersects with %s-%s from range '%s'",
+ base,
+ size,
+ id_range.secondary_base_rid,
+ id_range.last_secondary_rid,
+ id_range.name,
+ )
+ return False
+
+ return True
+
+
+def get_ranges_no_base(id_ranges: List[IDRange]) -> List[IDRange]:
+ """Function to get ranges without either of base RIDs set"""
+ ipa_local_ranges = get_ipa_local_ranges(id_ranges)
+ ranges_no_base = []
+ for id_range in ipa_local_ranges:
+ if id_range.base_rid is None or id_range.secondary_base_rid is None:
+ ranges_no_base.append(id_range)
+
+ return ranges_no_base
+# endregion
+# Working with IDentities out of range
+# region
+
+
+def group_identities_by_threshold(
+ identities: List[IDentity], threshold: int
+) -> List[List[IDentity]]:
+ """Function to group out of range IDs by threshold"""
+ groups: List[List[IDentity]] = []
+ currentgroup: List[IDentity] = []
+ if len(identities) == 0:
+ return groups
+
+ for i in range(len(identities) - 1):
+ # add id to current group
+ currentgroup.append(identities[i])
+
+ # If the difference with the next one is greater than the threshold,
+ # start a new group
+ if identities[i + 1].number - identities[i].number > threshold:
+ groups.append(currentgroup)
+ currentgroup = []
+
+ # Add the last ID number to the last group
+ currentgroup.append(identities[-1])
+ groups.append(currentgroup)
+
+ return groups
+
+
+def separate_under1000(
+ identities: List[IDentity],
+) -> Tuple[List[IDentity], List[IDentity]]:
+ """Function to separate IDs under 1000, expects sorted list"""
+ for i, identity in enumerate(identities):
+ if identity.number >= 1000:
+ return identities[:i], identities[i:]
+ return identities, []
+
+
+def separate_ranges_and_outliers(
+ groups: List[List[IDentity]], minrangesize=int
+) -> Tuple[List[List[IDentity]], List[List[IDentity]]]:
+ """Function to separate IDs into outliers and IDs that can get ranges"""
+ outliers = []
+ cleangroups = []
+ for group in groups:
+ # if group is smaller than minrangesize, add it's memebers to ourliers
+ if group[-1].number - group[0].number + 1 < minrangesize:
+ for identity in group:
+ outliers.append(identity)
+ # if the group is OK, add it to cleaned groups
+ else:
+ cleangroups.append(group)
+
+ return outliers, cleangroups
+
+
+def round_idrange(start: int, end: int, under1000: bool) -> Tuple[int, int]:
+ """Function to round up range margins to look pretty"""
+ # calculating power of the size
+ sizepower = len(str(end - start + 1))
+ # multiplier for the nearest rounded number
+ multiplier = 10 ** (sizepower - 1)
+ # getting rounded range margins
+ rounded_start = (start // multiplier) * multiplier
+ if not under1000:
+ rounded_start = max(rounded_start, 1000)
+ else:
+ rounded_start = max(rounded_start, 1)
+ rounded_end = ((end + multiplier) // multiplier) * multiplier - 1
+
+ return rounded_start, rounded_end
+
+
+def get_rangename_base(id_ranges: List[IDRange]) -> str:
+ """Function to get a base name for new range proposals"""
+ base_name = ""
+ # we want to use default range name as a base for new ranges
+ for id_range in id_ranges:
+ if id_range.base_rid == 1000:
+ base_name = id_range.name
+
+ # if we didn't find it, propose generic name
+ if base_name == "":
+ base_name = "Auto_added_range"
+
+ return base_name
+
+
+def get_rangename(id_ranges: List[IDRange], basename: str) -> str:
+ """
+ Function to get a new range name, we add the counter as 3-digit number
+ extension and make sure it's unique
+ """
+ counter = 1
+ full_name = f"{basename}_{counter:03}"
+ while any(id_range.name == full_name for id_range in id_ranges):
+ counter += 1
+ full_name = f"{basename}_{counter:03}"
+ return full_name
+
+
+def propose_range(
+ group: List[IDentity],
+ id_ranges: List[IDRange],
+ delta: int,
+ basename: str,
+ norounding: bool,
+ allowunder1000: bool
+) -> IDRange:
+ """Function to propose a new range for group of IDs out of ranges"""
+ startid = group[0].number
+ endid = group[-1].number
+
+ logger.debug(
+ "Proposing a range for existing IDs out of ranges with start id %s \
+and end id %s...",
+ startid,
+ endid,
+ )
+
+ # creating new range
+ newrange = IDRange()
+ newrange.type = "ipa-local"
+ newrange.name = get_rangename(id_ranges, basename)
+ newrange.suffix = id_ranges[0].suffix
+ newrange.dn = f"cn={newrange.name},cn=ranges,cn=etc,{newrange.suffix}"
+
+ if norounding:
+ newrange.first_id = startid
+ newrange.last_id = endid
+ newrange.size = newrange.last_id - newrange.first_id + 1
+ else:
+ # first trying to round up ranges to look pretty
+ newrange.first_id, newrange.last_id = round_idrange(
+ startid,
+ endid,
+ allowunder1000
+ )
+ newrange.size = newrange.last_id - newrange.first_id + 1
+
+ # if this creates an overlap, try without rounding
+ if not newrange_overlap_check(id_ranges, newrange):
+ newrange.first_id = startid
+ newrange.last_id = endid
+ newrange.size = newrange.last_id - newrange.first_id + 1
+ # if we still failed, abandon idea
+ if not newrange_overlap_check(id_ranges, newrange):
+ logger.error(
+ "ERROR! Failed to create idrange for existing IDs out of \
+ranges with start id %s and end id %s, it overlaps with existing range!",
+ startid,
+ endid,
+ )
+ return None
+
+ # creating RID bases
+ ipa_local_ranges = get_ipa_local_ranges(id_ranges)
+
+ result, proposed_base_rid = propose_rid_base(
+ newrange, ipa_local_ranges, delta, True
+ )
+ if result:
+ newrange.base_rid = proposed_base_rid
+ newrange.last_base_rid = proposed_base_rid + newrange.size
+ else:
+ # if this fails we print the warning
+ logger.warning(
+ "Warning! Proposed base RIDs %s for new range start id %s and \
+end id %s both failed, please adjust manually",
+ proposed_base_rid,
+ newrange.first_id,
+ newrange.last_id,
+ )
+
+ result, proposed_secondary_base_rid = propose_rid_base(
+ newrange, ipa_local_ranges, delta, False, proposed_base_rid
+ )
+ if result:
+ newrange.secondary_base_rid = proposed_secondary_base_rid
+ newrange.last_secondary_rid = (
+ proposed_secondary_base_rid + newrange.size
+ )
+ else:
+ # if this fails we print the warning
+ logger.warning(
+ "Warning! Proposed secondary base RIDs %s for new range start id \
+%s and end id %s both failed, please adjust manually",
+ proposed_secondary_base_rid,
+ newrange.first_id,
+ newrange.last_id,
+ )
+
+ logger.debug("Proposed range: %s", newrange)
+ return newrange
+# endregion
diff --git a/ipatests/test_integration/test_ipa_idrange_fix.py b/ipatests/test_integration/test_ipa_idrange_fix.py
new file mode 100644
index 0000000000000000000000000000000000000000..de3da9bfd221ce74f1d1bbb0dbe12e4db08b8daa
--- /dev/null
+++ b/ipatests/test_integration/test_ipa_idrange_fix.py
@@ -0,0 +1,189 @@
+#
+# Copyright (C) 2024 FreeIPA Contributors see COPYING for license
+#
+
+"""
+Module provides tests for ipa-idrange-fix CLI.
+"""
+
+import logging
+import re
+
+from ipatests.pytest_ipa.integration import tasks
+from ipatests.test_integration.base import IntegrationTest
+
+
+logger = logging.getLogger(__name__)
+
+
+class TestIpaIdrangeFix(IntegrationTest):
+ @classmethod
+ def install(cls, mh):
+ super(TestIpaIdrangeFix, cls).install(mh)
+ tasks.kinit_admin(cls.master)
+
+ def test_no_issues(self):
+ """Test ipa-idrange-fix command with no issues."""
+ result = self.master.run_command(["ipa-idrange-fix", "--unattended"])
+
+ expected_under1000 = "No IDs under 1000 found"
+ expected_nochanges = "No changes proposed for existing ranges"
+ expected_newrange = "No new ranges proposed"
+ expected_noissues = "No changes proposed, nothing to do."
+ assert expected_under1000 in result.stderr_text
+ assert expected_nochanges in result.stderr_text
+ assert expected_newrange in result.stderr_text
+ assert expected_noissues in result.stderr_text
+
+ def test_idrange_no_rid_bases(self):
+ """Test ipa-idrange-fix command with IDrange with no RID bases."""
+ self.master.run_command([
+ "ipa",
+ "idrange-add",
+ "idrange_no_rid_bases",
+ "--base-id", '10000',
+ "--range-size", '20000',
+ ])
+
+ result = self.master.run_command(["ipa-idrange-fix", "--unattended"])
+ expected_text = "RID bases updated for range 'idrange_no_rid_bases'"
+
+ # Remove IDrange with no rid bases
+ self.master.run_command(["ipa", "idrange-del", "idrange_no_rid_bases"])
+
+ assert expected_text in result.stderr_text
+
+ def test_idrange_no_rid_bases_reversed(self):
+ """
+ Test ipa-idrange-fix command with IDrange with no RID bases, but we
+ previously had a range with RID bases reversed - secondary lower than
+ primary. It is a valid configuration, so we should fix no-RID range.
+ """
+ self.master.run_command([
+ "ipa",
+ "idrange-add",
+ "idrange_no_rid_bases",
+ "--base-id", '10000',
+ "--range-size", '20000',
+ ])
+ self.master.run_command([
+ "ipa",
+ "idrange-add",
+ "idrange_reversed",
+ "--base-id", '50000',
+ "--range-size", '20000',
+ "--rid-base", '100300000'
+ "--secondary-rid-base", '301000'
+ ])
+
+ result = self.master.run_command(["ipa-idrange-fix", "--unattended"])
+ expected_text = "RID bases updated for range 'idrange_no_rid_bases'"
+
+ # Remove test IDranges
+ self.master.run_command(["ipa", "idrange-del", "idrange_no_rid_bases"])
+ self.master.run_command(["ipa", "idrange-del", "idrange_reversed"])
+
+ assert expected_text in result.stderr_text
+
+ def test_users_outofrange(self):
+ """Test ipa-idrange-fix command with users out of range."""
+ for i in range(1, 20):
+ self.master.run_command([
+ "ipa",
+ "user-add",
+ "testuser{}".format(i),
+ "--first", "Test",
+ "--last", "User {}".format(i),
+ "--uid", str(100000 + i * 10),
+ ])
+
+ result = self.master.run_command(["ipa-idrange-fix", "--unattended"])
+ expected_text = r"Range '[\w\.]+_id_range_\d{3}' created successfully"
+ match = re.search(expected_text, result.stderr_text)
+
+ # Remove users out of range and created IDrange
+ for i in range(1, 20):
+ self.master.run_command([
+ "ipa",
+ "user-del",
+ "testuser{}".format(i)
+ ])
+ if match is not None:
+ self.master.run_command([
+ "ipa",
+ "idrange-del",
+ match.group(0).split(" ")[1].replace("'", "")
+ ])
+
+ assert match is not None
+
+ def test_user_outlier(self):
+ """Test ipa-idrange-fix command with outlier user."""
+ self.master.run_command([
+ "ipa",
+ "user-add",
+ "testuser_outlier",
+ "--first", "Outlier",
+ "--last", "User",
+ "--uid", '500000',
+ ])
+
+ result = self.master.run_command(["ipa-idrange-fix", "--unattended"])
+ expected_text = "Identities that don't fit the criteria to get a new \
+range found!"
+ expected_user = "user 'Outlier User', uid=500000"
+
+ # Remove outlier user
+ self.master.run_command(["ipa", "user-del", "testuser_outlier"])
+
+ assert expected_text in result.stderr_text
+ assert expected_user in result.stderr_text
+
+ def test_user_under1000(self):
+ """Test ipa-idrange-fix command with user under 1000."""
+ self.master.run_command([
+ "ipa",
+ "user-add",
+ "testuser_under1000",
+ "--first", "Under",
+ "--last", "1000",
+ "--uid", '999',
+ ])
+
+ result = self.master.run_command(["ipa-idrange-fix", "--unattended"])
+ expected_text = "IDs under 1000:"
+ expected_user = "user 'Under 1000', uid=999"
+
+ # Remove user under 1000
+ self.master.run_command(["ipa", "user-del", "testuser_under1000"])
+
+ assert expected_text in result.stderr_text
+ assert expected_user in result.stderr_text
+
+ def test_user_preserved(self):
+ """Test ipa-idrange-fix command with preserved user."""
+ self.master.run_command([
+ "ipa",
+ "user-add",
+ "testuser_preserved",
+ "--first", "Preserved",
+ "--last", "User",
+ "--uid", '9999',
+ ])
+ self.master.run_command([
+ "ipa",
+ "user-del",
+ "testuser_preserved",
+ "--preserve"
+ ])
+
+ result = self.master.run_command(["ipa-idrange-fix", "--unattended"])
+ expected_text = "Identities that don't fit the criteria to get a new \
+range found!"
+ expected_user = "user 'Preserved User', uid=9999"
+
+ # Remove preserved user
+ self.master.run_command(["ipa", "user-del", "testuser_preserved"])
+
+ assert expected_text in result.stderr_text
+ assert expected_user in result.stderr_text
--
2.46.0