On Mon, Feb 10, 2025 at 4:43=E2=80=AFAM Fabian Gr=C3=BCnbichler <f.gruenbich...@proxmox.com> wrote: > > On February 6, 2025 6:06 am, Thomas Skinner wrote: > > On Fri, Jan 24, 2025 at 4:18=E2=80=AFAM Fabian Gr=C3=BCnbichler > > <f.gruenbich...@proxmox.com> wrote: > >> > >> On December 24, 2024 9:24 pm, Thomas Skinner wrote: > >> > Signed-off-by: Thomas Skinner <tho...@atskinner.net> > >> > --- > >> > src/PVE/API2/OpenId.pm | 68 +++++++++++++++++++++++++++++++++++++= +++ > >> > src/PVE/AccessControl.pm | 13 +++++--- > >> > src/PVE/Auth/OpenId.pm | 30 ++++++++++++++++++ > >> > 3 files changed, 107 insertions(+), 4 deletions(-) > >> > > >> > diff --git a/src/PVE/API2/OpenId.pm b/src/PVE/API2/OpenId.pm > >> > index 77410e6..5cfe5a1 100644 > >> > --- a/src/PVE/API2/OpenId.pm > >> > +++ b/src/PVE/API2/OpenId.pm > >> > @@ -13,6 +13,7 @@ use PVE::Cluster qw(cfs_read_file cfs_write_file); > >> > use PVE::AccessControl; > >> > use PVE::JSONSchema qw(get_standard_option); > >> > use PVE::Auth::Plugin; > >> > +use PVE::Auth::OpenId; > >> > > >> > use PVE::RESTHandler; > >> > > >> > @@ -220,6 +221,73 @@ __PACKAGE__->register_method ({ > >> > $rpcenv->check_user_enabled($username); > >> > } > >> > > >> > + if (defined(my $groups_claim =3D $config->{'groups-claim'}= )) { > >> > + if (defined(my $groups_list =3D $info->{$groups_claim}= )) { > >> > + if (ref($groups_list) eq 'ARRAY') { > >> > + PVE::AccessControl::lock_user_config(sub { > >> > + my $usercfg =3D cfs_read_file("user.cfg"); > >> > + > >> > + # replace any invalid characters with > >> > + my $replace_character =3D $config->{'group= s-replace-character'} // '_'; > >> > + my @oidc_groups_list =3D map { > >> > >> style nit: we mostly use array references in our code, i.e. > >> > >> my $oidc_group_list =3D [ map ... ]; > >> > >> > + $_ =3D~ s/[^$PVE::Auth::OpenId::groupn= ame_regex_chars]/$replace_character/gr > >> > + } $groups_list->@*; > >> > + > >> > + # list groups that exist in pve > >> > + my @existing_groups_list =3D keys %{$userc= fg->{groups}}; > >> > >> same here > >> > >> > + > >> > + my @groups_intersect; > >> > >> and here > >> > >> > + if ( $config->{'groups-autocreate'} ) { > >> > + # populate all groups in claim > >> > + @groups_intersect =3D @oidc_groups_lis= t; > >> > >> and here ;) > >> > >> > + } > >> > + else { > >> > >> style nit: the '}' and 'else {' should be on a single line > >> > >> > + # only populate groups that are in the= oidc list and exist in pve > >> > + @groups_intersect =3D @{PVE::Tools::ar= ray_intersect( > >> > + \@oidc_groups_list, > >> > + \@existing_groups_list, > >> > + )}; > >> > >> then this simply becomes > >> > >> $groups_intersect =3D PVE::Tools::array_intersect($oidc_groups_list, $= existing_groups_list); > > > > Understood for all the above and will fix. Apologies for missing them > > when they are clearly in the style guide. Is there any linting setup > > that you'd recommend for my dev environment that would help catch > > these for future patches before I send them? I don't want to recreate > > the wheel if the Proxmox team already has something in-place that > > could be shared. > > there's perlcritic that can help with this (see bottom part of the perl > style guide), but given perl's nature, there isn't really a proper tool > that really handles everything..
Okay, that's what I was figuring. I've been using the perlcritic, and it's worked for syntax pretty well. I tried perltidy, too, but it's not nearly perfect at formatting sections of code. Thanks! > > > >> but since the existing groups are already a hash, you could also do th= e > >> intersection + conversion to a hash in a single go here (see below for > >> why a hash is nicer anyway): > >> > >> if ($config->{'groups-autocreate'}) { > >> # populate all groups in claim > >> $groups_intersect =3D { map { $_ =3D> = 1 } $oidc_groups_list->@* }; > >> } else { > >> # populate only existing groups > >> for my $group ($oidc_groups_list->@*) = { > >> $groups_intersect->{$group} =3D 1 = if $usercfg->{groups}->{$group}; > >> } > >> } > >> > >> > >> > + } > >> > + > >> > + # if groups should be overwritten, find an= d delete the ones to remove > >> > + if ( $config->{'groups-overwrite'} ) { > >> > + # get the groups that need to be remov= ed from the user > >> > + my %groups_remove_user; > >> > + $groups_remove_user{ $_ } =3D undef > >> > + for keys %{$usercfg->{users}->{$us= ername}->{groups}}; > >> > + delete $groups_remove_user{ $_ } for @= groups_intersect; > >> > + > >> > + # ensure user is not a member of these= groups > >> > + PVE::AccessControl::delete_user_group_= single( > >> > + $username, > >> > + $usercfg, > >> > + $_, > >> > + ) for keys %groups_remove_user; > >> > + } > >> > + > >> > + # get the groups that need to be added to = the user > >> > + my %groups_add_user; > >> > + $groups_add_user{ $_ } =3D undef for @grou= ps_intersect; > >> > + delete $groups_add_user{ $_ } > >> > + for keys %{$usercfg->{users}->{$userna= me}->{groups}}; > >> > + > >> > + # ensure user is a member of these groups > >> > + PVE::AccessControl::add_user_group( > >> > + $username, > >> > + $usercfg, > >> > + $_ > >> > + ) for keys %groups_add_user; > >> > + > >> > >> > >> we don't do post-for either ;) > >> > >> you can just do > >> > >> # converts group intersection to hash (but see above ;)) > >> my $groups_intersect_hash =3D { map { $_ =3D> 1 } $groups_intersect->@= }; > >> > >> if ($config->{'groups-overwrite'}) { > >> my $user_groups =3D $usercfg->{users}->{$username}->{groups}; > >> for my $group (keys $user_groups->%*) { > >> delete $user_groups->{$group} if !groups_intersect_hash->{$gro= up}; > >> } > >> } > >> > >> and similarly for the addition, although with addition, you could even > >> do it unconditionally since it is both cheap and idempotent (it just > >> sets two booleans in the $usercfg hash). > >> > >> or you could make it a bit easier, and just delete all groups and then > >> add all the groups in the intersecting set, which would be similar to > >> how we do it when directly overwriting a user's group via the API/pveu= m: > >> > >> https://git.proxmox.com/?p=3Dpve-access-control.git;a=3Dblob;f=3Dsrc/P= VE/API2/User.pm;h=3D535e58e0b3dbd4c8d8c882b7845173489803d8d0;hb=3DHEAD#l417 > >> > >> the changes will only persisted on-disk once you hit the cfs_write in = the > >> next line, so this shouldn't be an issue. > > > > The reason that I made an adjustment to the behavior was purely for > > future auditing (only report groups added/removed), which speaks to > > the below comments about logging. However, even just having logging of > > group membership caused by OIDC would be sufficient. Plus, simplifying > > this makes a lot more sense to me in hindsight. > > > >> some higher-level questions: > >> > >> do we want to have a bit more logging here, although it would only end > >> up in syslog/journal.. maybe at least for groups which are created? > > > > I don't mind adding more logging at all. I'm all for supporting more > > auditable actions. Do you have any other suggestions of where logging > > would be useful? My current thoughts are: groups auto created, user > > removed from groups, user added to groups. > > I think for groups being created it makes sense, user group > modifications maybe as well, if you want to structure the code in a way > that gives you that information ;) Me, too. I put in the following info messages: - Message with all groups auto created from OIDC (only happens when they are created the first time) - Message if all groups were removed (only happens when override box is che= cked) - Message with groups assigned by OIDC (happens on every login) > > > >> do we want to mangle the group names to include the OIDC-realm name, > >> like we do for LDAP/AD syncing? that way it is more clear that those > >> groups originated from OIDC.. downside is that you can't use a group > >> shared between OIDC and other realms.. > > > > Yes, this was what went through my mind in consideration. Adding the > > prefix definitely would make them nearly unique across providers. On > > the flipside, the groups names should be able to be controlled at the > > IdP. What do you think about adding an advanced option for adding a > > prefix to the front of the group name as the realm name, that way the > > user could have both options (I'm thinking a boolean that adds what > > you suggested above, default opted out)? Actually might not be a bad > > idea for other auth types, too. > > the main reason to "namespace" the groups is to prevent accidents (group > already exists with different semantics and has ACLs, OIDC adds user to > group, accidentally gave access to some resource as a result) > > I don't think there is a huge use case for the non-namespaced variant - > if you want to pre-configure a group that is referenced via OIDC, you > can still do that after all.. Yeah, I agree that it's got to be an all or nothing if "namespacing" is going to happen with characters that could exist in the group name. I will suffix with the realm like the AD/LDAP for consistency (bonus: this would help if someone migrates from AD/LDAP to OIDC and uses the same realm name). > > > >> > + cfs_write_file("user.cfg", $usercfg); > >> > + }, "openid group mapping failed"); > >> > + } else { > >> > + syslog('err', "groups list is not an array; gr= oups will not be updated"); > >> > + } > >> > + } else { > >> > + syslog('err', "groups claim '$groups_claim' is not= found in claims"); > >> > + } > >> > + } > >> > + > >> > my $ticket =3D PVE::AccessControl::assemble_ticket($userna= me); > >> > my $csrftoken =3D PVE::AccessControl::assemble_csrf_preven= tion_token($username); > >> > my $cap =3D $rpcenv->compute_api_permission($username); > >> > diff --git a/src/PVE/AccessControl.pm b/src/PVE/AccessControl.pm > >> > index 47f2d38..d643a00 100644 > >> > --- a/src/PVE/AccessControl.pm > >> > +++ b/src/PVE/AccessControl.pm > >> > @@ -990,12 +990,17 @@ sub delete_user_group { > >> > my ($username, $usercfg) =3D @_; > >> > > >> > foreach my $group (keys %{$usercfg->{groups}}) { > >> > - > >> > - delete ($usercfg->{groups}->{$group}->{users}->{$username}) > >> > - if $usercfg->{groups}->{$group}->{users}->{$username}; > >> > + delete_user_group_single($username, $usercfg, $group); > >> > >> to be honest I am not sure why that post-if is there in the first plac= e.. > >> > >> when parsing the config, the group is initialized with an empty 'users= ' > >> hash as members before filling using the member list, so there is no > >> risk of auto-vivifying something here.. > >> > >> if you want to delete a user's group membership, you can just > >> > >> delete $usercfg->{groups}->{$group}->{users}->{$username}; > >> > >> and we don't need this refactoring > > > > Will update and remove the refactor. Thanks for pointing that out. > > > >> > } > >> > } > >> > > >> > +sub delete_user_group_single { > >> > + my ($username, $usercfg, $group) =3D @_; > >> > + > >> > + delete ($usercfg->{groups}->{$group}->{users}->{$username}) > >> > + if $usercfg->{groups}->{$group}->{users}->{$username}; > >> > +} > >> > + > >> > sub delete_user_acl { > >> > my ($username, $usercfg) =3D @_; > >> > > >> > @@ -1293,7 +1298,7 @@ PVE::JSONSchema::register_format('pve-groupid'= , \&verify_groupname); > >> > sub verify_groupname { > >> > my ($groupname, $noerr) =3D @_; > >> > > >> > - if ($groupname !~ m/^[A-Za-z0-9\.\-_]+$/) { > >> > + if ($groupname !~ m/^[$PVE::Auth::OpenId::groupname_regex_chars= ]+$/) { > >> > >> see below ;) if the RE moves there, we could actually move the whole > >> helper and format registration (maybe as a separate commit/patch?) > > > > Would a separate patch for this be preferred? I don't mind working it > > into this one as well. > > yes, this makes sense as a standalone patch IMHO. Looking at the registrations, it looks like the group format is registered in src/PVE/AccessControl.pm. I've put the RE there for consistency. Can submit another patch for moving the registrations from AccessControl.pm if desired, but at least this seems like the sensible place for this change. > >> > > >> > die "group name '$groupname' contains invalid characters\n" if= !$noerr; > >> > > >> > diff --git a/src/PVE/Auth/OpenId.pm b/src/PVE/Auth/OpenId.pm > >> > index c8e4db9..d7b5574 100755 > >> > --- a/src/PVE/Auth/OpenId.pm > >> > +++ b/src/PVE/Auth/OpenId.pm > >> > @@ -9,6 +9,8 @@ use PVE::Cluster qw(cfs_register_file cfs_read_file = cfs_write_file cfs_lock_file > >> > > >> > use base qw(PVE::Auth::Plugin); > >> > > >> > +our $groupname_regex_chars =3D qr/A-Za-z0-9\.\-_/; > >> > >> this should probably be in PVE::Auth::Plugin, where the user and realm > >> REs are as well. > > > > I'm just recently diving into plugins in another patch I'm working on > > and definitely see where this makes more sense. > > > >> > + > >> > sub type { > >> > return 'openid'; > >> > } > >> > @@ -42,6 +44,30 @@ sub properties { > >> > type =3D> 'string', > >> > optional =3D> 1, > >> > }, > >> > + "groups-claim" =3D> { > >> > + description =3D> "OpenID claim used to retrieve groups wit= h.", > >> > + type =3D> 'string', > >> > >> this should probably be restricted (our usual safeid might be too > >> strict (I know some OIDC providers like to user things like @ and : in > >> attributes, would have to check what the spec says here?), but some > >> sensible set of characters at least would be nice ;)) > > > > If this field must be restricted, then I'd suggest we start with > > printable ASCII characters as a reasonable limitation. That would > > cover everything that would likely end up in a JWT. If anything > > outside of that is needed, a patch could be submitted. Could start > > with a 128 character limit on length as well. Thoughts? > > sounds sensible. I ended up going with 256 characters to match the other fields that were already there. Unlikely to be that long, but at least it'll handle nearly anything that's thrown at it. > > > >> > + optional =3D> 1, > >> > + }, > >> > + "groups-autocreate" =3D> { > >> > + description =3D> "Automatically create groups if they do n= ot exist.", > >> > + optional =3D> 1, > >> > + type =3D> 'boolean', > >> > + default =3D> 0, > >> > + }, > >> > + "groups-overwrite" =3D> { > >> > + description =3D> "All groups will be overwritten for the u= ser on login.", > >> > + type =3D> 'boolean', > >> > + default =3D> 0, > >> > + optional =3D> 1, > >> > + }, > >> > + "groups-replace-character" =3D> { > >> > + description =3D> "Character used to replace any invalid ch= aracters in groups from provider.", > >> > + type =3D> 'string', > >> > + pattern =3D> qr/^[$groupname_regex_chars]$/, > >> > + default =3D> '_', > >> > + optional =3D> 1, > >> > + }, > >> > prompt =3D> { > >> > description =3D> "Specifies whether the Authorization Serv= er prompts the End-User for" > >> > ." reauthentication and consent.", > >> > @@ -73,6 +99,10 @@ sub options { > >> > "client-key" =3D> { optional =3D> 1 }, > >> > autocreate =3D> { optional =3D> 1 }, > >> > "username-claim" =3D> { optional =3D> 1, fixed =3D> 1 }, > >> > + "groups-claim" =3D> { optional =3D> 1 }, > >> > + "groups-autocreate" =3D> { optional =3D> 1 }, > >> > + "groups-overwrite" =3D> { optional =3D> 1 }, > >> > + "groups-replace-character" =3D> { optional =3D> 1}, > >> > prompt =3D> { optional =3D> 1 }, > >> > scopes =3D> { optional =3D> 1 }, > >> > "acr-values" =3D> { optional =3D> 1 }, > >> > -- > >> > 2.39.5 > >> > > >> > > >> > _______________________________________________ > >> > pve-devel mailing list > >> > pve-devel@lists.proxmox.com > >> > https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel > >> > > >> > > >> > > >> > > > > > _______________________________________________ pve-devel mailing list pve-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel