Files
webmin/acl/edit_user.cgi
Ilia Ross e18e9a4213 Fix inherited permission controls for grouped users
This PR fixes global and RPC controls appearing for group members even though saving ignores their edits.

It detects group membership for existing users and clones, even when the editor cannot assign groups.

The check also works for database-backed accounts without relying on local ACL files.
2026-10-23 20:19:53 +02:00

507 lines
16 KiB
Perl
Executable File

#!/usr/local/bin/perl
# edit_user.cgi
# Edit a new or existing webmin user
use strict;
use warnings;
no warnings 'redefine';
no warnings 'uninitialized';
require './acl-lib.pl'; ## no critic
our (%in, %text, %config, %gconfig, %access, $config_directory, $base_remote_user, $remote_user);
&foreign_require("webmin", "webmin-lib.pl");
&ReadParse();
my ($u, %user, $safe);
if ($in{'user'}) {
# Editing an existing user
&can_edit_user($in{'user'}) || &error($text{'edit_euser'});
&ui_print_header(undef, $text{'edit_title'}, "");
$u = &get_user($in{'user'});
$u || &error($text{'edit_egone'});
%user = %$u;
my %gacl = &get_module_acl($in{'user'}, '');
$safe = $gacl{'_safe'};
}
else {
# Creating a new user
$access{'create'} || &error($text{'edit_ecreate'});
if ($in{'clone'}) {
# Initial settings come from clone
$u = &get_user($in{'clone'});
%user = %$u;
delete($user{'name'});
my %gacl = &get_module_acl($in{'clone'}, '');
$safe = $gacl{'_safe'};
}
else {
# User starts out empty
%user = ( );
$safe = $in{'safe'};
}
&ui_print_header(undef, $safe ? $text{'edit_title3'}
: $text{'edit_title2'}, "");
}
my $me = &get_user($base_remote_user);
my %uaccess = &get_module_acl($in{'user'} || "", "", 1);
if (!$in{'user'} && $uaccess{'rpc'} == 2) {
# Don't offer the confusing 'root' or 'admin' RPC option by default
$uaccess{'rpc'} = 0;
}
if (!$in{'user'} && $safe) {
# Safe users should not inherit new permissions by default
$uaccess{'negative'} = 1;
}
# Give up if readonly
if ($user{'readonly'} && !$in{'readwrite'}) {
my %minfo = &get_module_info($user{'readonly'});
print &text('edit_readonly', $minfo{'desc'},
"edit_user.cgi?user=$in{'user'}&readwrite=1"),"<p>\n";
&ui_print_footer("", $text{'index_return'});
exit;
}
print &ui_form_start("save_user.cgi", "post");
if ($in{'user'}) {
print &ui_hidden("old", $user{'name'});
print &ui_hidden("oldpass", $user{'pass'});
}
if ($in{'clone'}) {
print &ui_hidden("clone", $in{'clone'});
}
print &ui_hidden("safe", $safe);
print &ui_hidden_table_start($text{'edit_rights'}, "width=100%", 2, "rights",
1, [ "width=30%" ]);
# Username
print &ui_table_row($text{'edit_user'},
$access{'rename'} || !$in{'user'} ?
&ui_textbox("name", $user{'name'}, 30,
0, undef, "autocomplete=off") : $user{'name'});
# Source user for clone
if ($in{'clone'}) {
print &ui_table_row($text{'edit_cloneof'}, "<tt>$in{'clone'}</tt>");
}
# Find and show parent group
my @glist = &list_groups();
my @mcan = $access{'gassign'} eq '*' ?
( ( map { $_->{'name'} } @glist ), '_none' ) :
split(/\s+/, $access{'gassign'});
my %gcan = map { $_, 1 } @mcan;
# Find the user's group even when this editor cannot change it.
my $memg;
foreach my $g (@glist) {
if (&indexof($user{'name'}, @{$g->{'members'}}) >= 0 ||
$in{'clone'} &&
&indexof($in{'clone'}, @{$g->{'members'}}) >= 0) {
# A clone starts in the same group as its source user.
$memg = $g;
}
}
# Show the group selector when this editor may change membership.
if (@glist && %gcan) {
my @opts = ( );
if ($gcan{'_none'}) {
# Allow leaving a group only when the editor has permission.
push(@opts, [ undef, "&lt;$text{'edit_none'}&gt;" ]);
}
# Keep the current group available even if it cannot be newly assigned.
foreach my $g (@glist) {
next if (!$gcan{$g->{'name'}} && $memg ne $g);
push(@opts, [ $g->{'name'} ]);
}
print &ui_table_row($text{'edit_group'},
&ui_select("group", $memg->{'name'}, \@opts));
}
# Show password type menu and current password
my $passmode = !$in{'user'} ? 0 :
$user{'pass'} eq 'x' ? 3 :
$user{'sync'} ? 2 :
$user{'pass'} eq 'e' ? 5 :
$user{'pass'} eq '*LK*' ? 4 : 1;
my %miniserv;
&get_miniserv_config(\%miniserv);
my @opts = ( [ 0, "$text{'edit_set'} .." ] );
if ($in{'user'}) {
push(@opts, [ 1, $text{'edit_dont'} ]);
}
push(@opts, [ 3, $text{'edit_unix'} ]);
if ($user{'sync'}) {
push(@opts, [ 2, $text{'edit_same'} ]);
}
if ($miniserv{'extauth'}) {
push(@opts, [ 5, $text{'edit_extauth'} ]);
}
push(@opts, [ 4, $text{'edit_lock'} ]);
my ($lockbox, $tempbox) = ("", "");
if ($passmode == 1) {
$lockbox = &ui_checkbox("lock", 1, $text{'edit_templock'},
$user{'pass'} =~ /^\!/ ? 1 : 0);
}
if ($passmode != 3 && $passmode != 4) {
$tempbox = &ui_checkbox("temp", 1, $text{'edit_temppass'},
$user{'temppass'});
}
my $expmsg = "";
if ($user{'lastchange'} && $miniserv{'pass_maxdays'}) {
my $daysold = int((time() - $user{'lastchange'})/(24*60*60));
if ($miniserv{'pass_lockdays'} &&
$daysold > $miniserv{'pass_lockdays'}) {
$expmsg = "<br>"."<font color=#ff0000>".
&text('edit_passlocked', $daysold)."</font>";
}
elsif ($daysold > $miniserv{'pass_maxdays'}) {
$expmsg = "<br>"."<font color=#ffaa00>".
&text('edit_passmax', $daysold)."</font>";
}
elsif ($daysold) {
$expmsg = "<br>".&text('edit_passold', $daysold);
}
else {
$expmsg = "<br>".$text{'edit_passtoday'};
}
}
my $js = "onChange='form.pass.disabled = value != 0;'";
print &ui_table_row($text{'edit_pass'},
&ui_select("pass_def", $passmode, \@opts, 1, 0, 0, 0, $js)." ".
&ui_password("pass", undef, 25, $passmode != 0, undef,
"autocomplete=off").
($lockbox || $tempbox ? "<br>" : "").$lockbox.$tempbox.$expmsg);
# Real name
print &ui_table_row($text{'edit_real'},
&ui_textbox("real", $user{'real'}, 60));
# Contact email for recovery
print &ui_table_row($text{'edit_email'},
&ui_textbox("email", $user{'email'}, 60));
# Storage type
if ($in{'user'}) {
print &ui_table_row($text{'edit_proto'},
$text{'edit_proto_'.($user{'proto'} || '')});
}
# Safe or not?
my $smsg;
if ($in{'user'} && $safe) {
$smsg = &ui_radio("unsafe", 0, [ [ 0, $text{'edit_safe1'} ],
[ 1, $text{'edit_safe0'} ] ]);
}
else {
$smsg = $safe ? $text{'edit_safe1'} : $text{'edit_safe0'};
}
print &ui_table_row($text{'edit_safe'}, $smsg);
print &ui_hidden_table_end("rights");
# Start of UI options section
my $showui = $access{'chcert'} || $access{'lang'} ||
$access{'cats'} || $access{'theme'};
if ($showui) {
print &ui_hidden_table_start($text{'edit_ui'}, "width=100%", 2, "ui",
0, [ "width=30%" ]);
}
if ($access{'chcert'}) {
# SSL certificate name
print &ui_table_row($text{'edit_cert'},
&ui_opt_textbox("cert", $user{'cert'}, 50, $text{'edit_none'}));
}
if ($access{'lang'}) {
# Current language
my $ulang = safe_language($user{'lang'});
print &ui_table_row($text{'edit_lang'},
&ui_radio("lang_def", $ulang ? 0 : 1,
[ [ 1, $text{'default'} ],
[ 0, &ui_select("lang", $ulang,
[ map { [ $_->{'lang'}, $_->{'desc'}."" ] }
&list_languages() ]) ]
]));
}
if ($access{'locale'}) {
# Current locale
eval { require DateTime; DateTime->import;
require DateTime::Locale; DateTime::Locale->import;
require DateTime::TimeZone; DateTime::TimeZone->import;
1 };
if (!$@ && $] > 5.011) {
my $locales = &list_locales();
my %localesrev = reverse %{$locales};
my $locale_auto = &parse_accepted_language();
print &ui_table_row($text{'edit_locale'},
&ui_radio("locale_def", $user{'locale'} ? 0 : 1,
[ [ 1, $text{'default'} ],
[ 0, &ui_select("locale", $user{'locale'} || $gconfig{'locale'} || &get_default_system_locale(),
[ map { [ $localesrev{$_}, $_ ] } sort values %{$locales} ]) ] ]),
undef, [ "valign=middle","valign=middle" ]);
}
}
if ($access{'cats'}) {
# Show categorized modules?
print &ui_table_row($text{'edit_notabs'},
&ui_radio("notabs", $user{'notabs'} || 0,
[ [ 1, $text{'yes'} ],
[ 2, $text{'no'} ],
[ 0, $text{'default'} ] ]));
}
my @all = &webmin::list_visible_themes($user{'theme'});
my @themes = grep { !$_->{'overlay'} } @all;
my @overlays = grep { $_->{'overlay'} } @all;
if ($access{'theme'}) {
my $tconf_link;
my %tinfo = &webmin::get_theme_info($user{'theme'});
if ($user{'theme'} && $user{'theme'} eq $tinfo{'dir'} &&
$user{'name'} eq $remote_user &&
$tinfo{'config_link'}) {
$tconf_link = &ui_tag('span', &ui_link(
"@{[&get_webprefix()]}/$tinfo{'config_link'}",
&ui_tag('span', '⚙',
{ class => 'theme-config-char',
title => $text{'themes_configure'} }),
'text-link'), { style => 'position: relative;' });
}
# Current theme
my @topts = ( );
push(@topts, !$user{'theme'} ? [ '', $text{'edit_themedef'} ] : ());
foreach my $t (@themes) {
push(@topts, [ $t->{'dir'}, $t->{'desc'} ]);
}
print &ui_table_row($text{'edit_theme'},
&ui_radio("theme_def", defined($user{'theme'}) ? 0 : 1,
[ [ 1, $text{'edit_themeglobal'} ],
[ 0, &ui_select("theme", $user{'theme'}, \@topts).
$tconf_link ] ]));
}
if ($access{'theme'} && @overlays) {
# Overlay theme, if any
print &ui_table_row($text{'edit_overlay'},
&ui_radio("overlay_def", defined($user{'overlay'}) ? 0 : 1,
[ [ 1, $text{'edit_overlayglobal'} ],
[ 0, &ui_select("overlay", $user{'overlay'},
[ map { [ $_->{'dir'}, $_->{'desc'} ] } @overlays ]
) ] ]));
}
if ($showui) {
print &ui_hidden_table_end("ui");
}
# Start of security options section
my $showsecurity = $access{'logouttime'} || $access{'ips'} ||
$access{'minsize'} ||
$access{'times'};
if ($showsecurity) {
print &ui_hidden_table_start($text{'edit_security'}, "width=100%", 2,
"security", 0, [ "width=30%" ]);
}
if ($access{'logouttime'}) {
# Show logout time
print &ui_table_row($text{'edit_logout'},
&ui_opt_textbox("logouttime", $user{'logouttime'}, 5,
$text{'default'})." ".$text{'edit_mins'});
}
if ($access{'minsize'}) {
# Show minimum password length, for just this user
print &ui_table_row($text{'edit_minsize'},
&ui_opt_textbox("minsize", $user{'minsize'}, 5,
$text{'default'})." ".$text{'edit_chars'});
}
if ($access{'nochange'} && $miniserv{'pass_maxdays'}) {
# Opt out of forced password change, for this user
print &ui_table_row($text{'edit_nochange'},
&ui_radio("nochange", $user{'nochange'},
[ [ 0, $text{'yes'} ], [ 1, $text{'no'} ] ]));
}
if ($access{'ips'}) {
# Allowed IP addresses
print &ui_table_row(&hlink("<b>$text{'edit_ips'}</b>", "ips"),
&ui_radio("ipmode", $user{'allow'} ? 1 :
$user{'deny'} ? 2 : 0,
[ [ 0, $text{'edit_all'}."<br>" ],
[ 1, $text{'edit_allow'}."<br>" ],
[ 2, $text{'edit_deny'}."<br>" ] ]).
&ui_textarea("ips",
join("\n", split(/\s+/, $user{'allow'} ||
$user{'deny'} || "")),
4, 30));
}
if ($access{'times'}) {
# Show allowed days of the week
my %days = map { $_, 1 } split(/,/, $user{'days'} || '');
my $daysels = "";
for(my $i=0; $i<7; $i++) {
$daysels .= &ui_checkbox("days", $i, $text{'day_'.$i},
$days{$i});
}
print &ui_table_row($text{'edit_days'},
&ui_radio("days_def", !defined($user{'days'}) || $user{'days'} eq '' ? 1 : 0,
[ [ 1, $text{'edit_alldays'} ],
[ 0, $text{'edit_seldays'} ] ])."<br>".
$daysels);
# Show allow hour/minute range
my ($hf, $mf) = split(/\./, $user{'hoursfrom'} || '');
my ($ht, $mt) = split(/\./, $user{'hoursto'} || '');
print &ui_table_row($text{'edit_hours'},
&ui_radio("hours_def", !defined($hf) || $hf eq '' ? 1 : 0,
[ [ 1, $text{'edit_allhours'} ],
[ 0, &text('edit_selhours',
&ui_textbox("hours_hfrom", $hf, 2),
&ui_textbox("hours_mfrom", $mf, 2),
&ui_textbox("hours_hto", $ht, 2),
&ui_textbox("hours_mto", $mt, 2)) ] ]));
}
# Two-factor details
if ($user{'twofactor_provider'}) {
my ($prov) = grep { $_->[0] eq $user{'twofactor_provider'} }
&webmin::list_twofactor_providers();
print &ui_table_row($text{'edit_twofactor'},
&text('edit_twofactorprov', "<i>$prov->[1]</i>",
"<tt>$user{'twofactor_id'}</tt>")."<br>\n".
&ui_checkbox('cancel', 1, $text{'edit_twofactorcancel'}, 0));
}
elsif ($miniserv{'twofactor_provider'}) {
print &ui_table_row($text{'edit_twofactor'},
$text{'edit_twofactornone'}." ".
&ui_submit($text{'edit_twofactoradd'}, "twofactor"));
}
# RPC is a global permission and cannot be edited on a group member.
if ($access{'acl'} && !$memg->{'name'} && !$safe) {
print &ui_table_row(&hlink($text{'acl_rpc'}, 'rpc'),
&ui_radio("rpc", int($uaccess{'rpc'}),
[ [ 1, $text{'acl_rpc1'} ],
$uaccess{'rpc'} == 2 ? ( [ 2, $text{'acl_rpc2'} ] ) : ( ),
[ 3, $text{'acl_rpc3'} ],
[ 0, $text{'acl_rpc0'} ] ]));
}
print &ui_hidden_table_end("security");
# Work out which modules can be selected
@mcan = $access{'mode'} == 1 ? @{$me->{'modules'}} :
$access{'mode'} == 2 ? split(/\s+/, $access{'mods'}) :
&list_modules();
my (%mcan, %has);
map { $mcan{$_}++ } @mcan;
map { $has{$_}++ } @{$user{'modules'}};
# Start of modules section
my @groups = &list_groups();
print &ui_hidden_table_start(@groups ? $text{'edit_modsg'} : $text{'edit_mods'},
"width=100%", 2, "mods");
# Build list of modules, based on safe mode
my @allmods = &list_module_infos();
if ($safe) {
@allmods = grep { $has{$_->{'dir'}} ||
&get_safe_acl($_->{'dir'}) } @allmods;
}
# Show available modules, under categories
my @mlist = grep { $access{'others'} || $has{$_->{'dir'}} ||
$mcan{$_->{'dir'}} } @allmods;
my @links = ( &select_all_link("mod", 0, $text{'edit_selall'}),
&select_invert_link("mod", 0, $text{'edit_invert'}) );
my @cats = &unique(map { $_->{'category'} || '' } @mlist);
my %catnames;
&read_file("$config_directory/webmin.catnames", \%catnames);
my $grids = "";
foreach my $c (sort { $b cmp $a } @cats) {
my @cmlist = grep { ($_->{'category'} || '') eq $c } @mlist;
$grids .= "<b>".($catnames{$c} ||
$text{'category_'.$c} || '')."</b><br>\n";
my @grid = ( );
my $sw = 0;
foreach my $m (@cmlist) {
next if ($m->{'noacl'});
my $md = $m->{'dir'};
my $fromgroup = $memg &&
&indexof($md, @{$memg->{'modules'}}) >= 0;
if ($mcan{$md} && $fromgroup) {
# Module comes from group
push(@grid, (sprintf "<img src=images/%s.gif> %s\n",
$has{$md} ? 'tick' : 'empty', $m->{'desc'}).
($has{$md} ? &ui_hidden("mod", $md) : ""));
}
elsif ($mcan{$md}) {
my $label;
if ($access{'acl'} && $in{'user'} && !$safe &&
&can_module_acl($m)) {
# Show link for editing ACL
$label = ui_link("edit_acl.cgi?" .
"mod=" . urlize($m->{'dir'}) .
"&user=". urlize($in{'user'}),
$m->{'desc'}) . "\n";
}
else {
# No privileges to edit ACL
$label = $m->{'desc'};
}
push(@grid, &ui_checkbox("mod", $md, $label,$has{$md}));
}
else {
push(@grid, (sprintf "<img src=images/%s.gif> %s\n",
$has{$md} ? 'tick' : 'empty', $m->{'desc'}));
}
}
$grids .= &ui_grid_table(\@grid, 2, 100, [ "width=50%", "width=50%" ]);
}
print &ui_table_row(undef, &ui_links_row(\@links).
$grids.
&ui_links_row(\@links), 2);
print &ui_hidden_table_end("mods");
# Group members inherit global permissions, even without a local ACL file.
# Hide these fields because save_user.cgi ignores individual edits.
if ($access{'acl'} && !$memg->{'name'} && !$safe) {
print &ui_hidden_table_start($text{'edit_global'}, "width=100%", 2,
"global", 0, [ "width=30%" ]);
print &ui_hidden("acl_security_form", 1);
&foreign_require("", "acl_security.pl");
&foreign_call("", "acl_security_form", \%uaccess);
print &ui_hidden_table_end("global");
}
# Generate form end buttons
my @buts = ( );
push(@buts, [ undef, $in{'user'} ? $text{'save'} : $text{'create'} ]);
if ($in{'user'}) {
if ($access{'create'}) {
push(@buts, [ "but_clone", $text{'edit_clone'} ]);
}
if (&foreign_available("webminlog")) {
push(@buts, [ "but_log", $text{'edit_log'} ]);
}
if ($access{'switch'} && $main::session_id && $in{'user'} ne $remote_user) {
push(@buts, [ "but_switch", $text{'edit_switch'} ]);
}
if ($gconfig{'forgot_pass'}) {
push(@buts, [ "but_forgot", $text{'edit_forgot'} ]);
}
if ($access{'delete'}) {
push(@buts, [ "but_delete", $text{'delete'} ]);
}
}
print &ui_form_end(\@buts);
&ui_print_footer("", $text{'index_return'});