* [PATCH pve-access-control] ad: support nested groups during sync
@ 2026-07-29 15:44 Ivan Erben
0 siblings, 0 replies; only message in thread
From: Ivan Erben @ 2026-07-29 15:44 UTC (permalink / raw)
To: pve-devel; +Cc: Ivan Erben
Add an optional `use_nested_groups` setting that resolves nested
Active Directory group memberships through
LDAP_MATCHING_RULE_IN_CHAIN.
This enables ACLs based on synchronized Active Directory groups to
work correctly when nested groups are used.
Nested group resolution can be significantly slower in large Active
Directory environments and is therefore disabled by default.
Fixes: #2738
Signed-off-by: Ivan Erben <ivan@erben.sk>
---
src/PVE/Auth/AD.pm | 184 +++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 184 insertions(+)
diff --git a/src/PVE/Auth/AD.pm b/src/PVE/Auth/AD.pm
index 5d57012..8f09f0c 100755
--- a/src/PVE/Auth/AD.pm
+++ b/src/PVE/Auth/AD.pm
@@ -2,11 +2,20 @@ package PVE::Auth::AD;
use strict;
use warnings;
+
use PVE::Auth::LDAP;
use PVE::LDAP;
+use Net::LDAP::Control::Paged;
+use Net::LDAP::Constant qw(LDAP_CONTROL_PAGED);
+use Net::LDAP::Util qw(escape_filter_value);
+
use base qw(PVE::Auth::LDAP);
+# Active Directory LDAP_MATCHING_RULE_IN_CHAIN
+# Used for transitive/nested group membership resolution.
+use constant AD_MATCHING_RULE_IN_CHAIN => '1.2.840.113556.1.4.1941';
+
sub type {
return 'ad';
}
@@ -63,6 +72,16 @@ sub properties {
optional => 1,
maxLength => 256,
},
+ use_nested_groups => {
+ description =>
+ "Enable nested Active Directory group resolution using "
+ . "LDAP_MATCHING_RULE_IN_CHAIN "
+ . "(1.2.840.113556.1.4.1941). "
+ . "Expands transitive group membership during realm sync. "
+ . "May significantly increase sync time in large AD environments.",
+ type => 'boolean',
+ optional => 1,
+ },
tfa => PVE::JSONSchema::get_standard_option('tfa'),
};
}
@@ -96,9 +115,174 @@ sub options {
'sync-defaults-options' => { optional => 1 },
mode => { optional => 1 },
'case-sensitive' => { optional => 1 },
+ use_nested_groups => { optional => 1 },
};
}
+sub query_ad_nested_members {
+ my ($ldap, $base_dn, $group_dn, $fallback_members) = @_;
+
+ my $escaped_dn = escape_filter_value($group_dn);
+
+ # Use both objectCategory=person and objectClass=user:
+ #
+ # - objectCategory=person excludes computer objects
+ # - objectClass=user excludes contacts
+ #
+ # Combined filter most closely matches real AD user accounts.
+ my $filter =
+ "(&(objectCategory=person)"
+ . "(objectClass=user)"
+ . "(memberOf:"
+ . AD_MATCHING_RULE_IN_CHAIN
+ . ":=$escaped_dn))";
+
+ my $page = Net::LDAP::Control::Paged->new(size => 900);
+
+ my @args = (
+ base => $base_dn,
+ scope => 'subtree',
+ filter => $filter,
+ attrs => ['dn'],
+ control => [$page],
+ timelimit => 120,
+ );
+
+ my %seen;
+ my $cookie;
+ my $had_paging = 0;
+ my $nested_error;
+
+ eval {
+ while (1) {
+ my $mesg = $ldap->search(@args);
+
+ if ($mesg->code) {
+ die $mesg->error;
+ }
+
+ foreach my $entry ($mesg->entries) {
+ $seen{lc($entry->dn)} = $entry->dn;
+ }
+
+ my ($resp) = $mesg->control(LDAP_CONTROL_PAGED);
+ last if !$resp;
+
+ $had_paging = 1;
+ $cookie = $resp->cookie;
+
+ last if !defined($cookie) || !length($cookie);
+
+ $page->cookie($cookie);
+ }
+ };
+
+ if (my $err = $@) {
+ $nested_error = $err;
+ }
+
+ # Explicitly terminate paged search to avoid leaving
+ # server-side paging state allocated.
+ if ($had_paging) {
+ eval {
+ $page->cookie($cookie // '');
+ $page->size(0);
+ $ldap->search(@args);
+ };
+ }
+
+ if ($nested_error) {
+ chomp($nested_error);
+
+ warn "AD nested group lookup failed for '$group_dn': "
+ . "$nested_error - falling back to direct group members\n";
+
+ return $fallback_members;
+ }
+
+ return [ values %seen ];
+}
+
+sub get_groups {
+ my ($class, $config, $realm, $dnmap) = @_;
+
+ # Use default LDAP group handling unless nested
+ # membership resolution is explicitly enabled.
+ return $class->SUPER::get_groups($config, $realm, $dnmap)
+ if !$config->{use_nested_groups};
+
+ my $filter = $config->{group_filter};
+
+ # Use base_dn for nested member searches because
+ # users may exist outside the group subtree.
+ my $group_basedn = $config->{group_dn} // $config->{base_dn};
+ my $user_basedn = $config->{base_dn};
+
+ my $attr = $config->{group_name_attr};
+
+ $config->{group_classes}
+ //= 'groupOfNames, group, univentionGroup, ipausergroup';
+
+ my $classes = [
+ PVE::Tools::split_list($config->{group_classes})
+ ];
+
+ my $ldap = $class->connect_and_bind($config, $realm);
+
+ # Reuse generic LDAP group discovery and perform
+ # nested expansion only for AD realms.
+ my $groups = PVE::LDAP::query_groups(
+ $ldap,
+ $group_basedn,
+ $classes,
+ $filter,
+ $attr,
+ );
+
+ my $ret = {};
+
+ foreach my $group (@$groups) {
+ my $name = $group->{name};
+
+ if (!$name && $group->{dn} =~ m/^[^=]+=([^,]+),/) {
+ $name = PVE::Tools::trim($1);
+ }
+
+ next if !$name;
+
+ $name .= "-$realm";
+
+ eval {
+ PVE::AccessControl::verify_groupname($name);
+ };
+
+ if (my $err = $@) {
+ warn "$err";
+ next;
+ }
+
+ $ret->{$name} = { users => {} };
+
+ my $direct_members = $group->{members} // [];
+
+ my $members = query_ad_nested_members(
+ $ldap,
+ $user_basedn,
+ $group->{dn},
+ $direct_members,
+ );
+
+ foreach my $member (@$members) {
+ my $user = $dnmap->{lc($member)};
+ next if !$user;
+
+ $ret->{$name}->{users}->{$user} = 1;
+ }
+ }
+
+ return $ret;
+}
+
sub get_users {
my ($class, $config, $realm) = @_;
--
2.47.3
^ permalink raw reply related [flat|nested] only message in thread
only message in thread, other threads:[~2026-07-30 14:19 UTC | newest]
Thread overview: (only message) (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-07-29 15:44 [PATCH pve-access-control] ad: support nested groups during sync Ivan Erben
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.