X-Git-Url: https://git.proxmox.com/?a=blobdiff_plain;f=src%2FPVE%2FAccessControl.pm;h=a9d97e857f150053efba737845e2e27b392ad8a3;hb=HEAD;hp=5addcbfa46208169632782b6b43edf3131f8559d;hpb=965b2418eeeb0d29dad8cbc4081316e925eebae3;p=pve-access-control.git diff --git a/src/PVE/AccessControl.pm b/src/PVE/AccessControl.pm index 5addcbf..47f2d38 100644 --- a/src/PVE/AccessControl.pm +++ b/src/PVE/AccessControl.pm @@ -16,6 +16,7 @@ use JSON; use Scalar::Util 'weaken'; use URI::Escape; +use PVE::Exception qw(raise_perm_exc raise_param_exc); use PVE::OTP; use PVE::Ticket; use PVE::Tools qw(run_command lock_file file_get_contents split_list safe_print); @@ -717,8 +718,8 @@ sub verify_one_time_pw { # password should be utf8 encoded # Note: some plugins delay/sleep if auth fails -sub authenticate_user : prototype($$$$;$) { - my ($username, $password, $otp, $new_format, $tfa_challenge) = @_; +sub authenticate_user : prototype($$$;$) { + my ($username, $password, $otp, $tfa_challenge) = @_; die "no username specified\n" if !$username; @@ -744,52 +745,18 @@ sub authenticate_user : prototype($$$$;$) { $plugin->authenticate_user($cfg, $realm, $ruid, $password); - if ($new_format) { - # This is the first factor with an optional immediate 2nd factor for TOTP: - my $tfa_challenge = authenticate_2nd_new($username, $realm, $otp, undef); - return wantarray ? ($username, $tfa_challenge) : $username; - } else { - return authenticate_2nd_old($username, $realm, $otp); - } -} - -sub authenticate_2nd_old : prototype($$$) { - my ($username, $realm, $otp) = @_; - - my ($type, $tfa_data) = user_get_tfa($username, $realm, 0); - if ($type) { - if ($type eq 'incompatible') { - die "old login api disabled, user has incompatible TFA entries\n"; - } elsif ($type eq 'u2f') { - # Note that if the user did not manage to complete the initial u2f registration - # challenge we have a hash containing a 'challenge' entry in the user's tfa.cfg entry: - $tfa_data = undef if exists $tfa_data->{challenge}; - } elsif (!defined($otp)) { - # The user requires a 2nd factor but has not provided one. Return success but - # don't clear $tfa_data. - } else { - my $keys = $tfa_data->{keys}; - my $tfa_cfg = $tfa_data->{config}; - verify_one_time_pw($type, $username, $keys, $tfa_cfg, $otp); - $tfa_data = undef; - } - - # Return the type along with the rest: - if ($tfa_data) { - $tfa_data = { - type => $type, - data => $tfa_data, - }; - } - } - - return wantarray ? ($username, $tfa_data) : $username; + # This is the first factor with an optional immediate 2nd factor for TOTP: + $tfa_challenge = authenticate_2nd_new($username, $realm, $otp, undef); + return wantarray ? ($username, $tfa_challenge) : $username; } sub authenticate_2nd_new_do : prototype($$$$) { my ($username, $realm, $tfa_response, $tfa_challenge) = @_; - my ($tfa_cfg, $realm_tfa) = user_get_tfa($username, $realm, 1); + my ($tfa_cfg, $realm_tfa) = user_get_tfa($username, $realm); + # FIXME: `$tfa_cfg` is now usually never undef - use cheap check for + # whether the user has *any* entries here instead whe it is available in + # pve-rs if (!defined($tfa_cfg)) { return undef; } @@ -834,24 +801,48 @@ sub authenticate_2nd_new_do : prototype($$$$) { configure_u2f_and_wa($tfa_cfg); - my $must_save = 0; + my ($result, $tfa_done); if (defined($tfa_challenge)) { + $tfa_done = 1; $tfa_challenge = verify_ticket($tfa_challenge, 0, $username); - $must_save = $tfa_cfg->authentication_verify($username, $tfa_challenge, $tfa_response); + $result = $tfa_cfg->authentication_verify2($username, $tfa_challenge, $tfa_response); $tfa_challenge = undef; } else { $tfa_challenge = $tfa_cfg->authentication_challenge($username); + + die "missing required 2nd keys\n" + if $realm_tfa && !defined($tfa_challenge); + if (defined($tfa_response)) { if (defined($tfa_challenge)) { - $must_save = $tfa_cfg->authentication_verify($username, $tfa_challenge, $tfa_response); + $tfa_done = 1; + $result = $tfa_cfg->authentication_verify2($username, $tfa_challenge, $tfa_response); } else { die "no such challenge\n"; } } } - if ($must_save) { - cfs_write_file('priv/tfa.cfg', $tfa_cfg); + if ($tfa_done) { + if (!$result) { + # authentication_verify2 somehow returned undef - should be unreachable + die "2nd factor failed\n"; + } + + if ($result->{'needs-saving'}) { + cfs_write_file('priv/tfa.cfg', $tfa_cfg); + } + if ($result->{'totp-limit-reached'}) { + # FIXME: send mail to the user (or admin/root if no email configured) + die "failed 2nd factor: TOTP limit reached, locked\n"; + } + if ($result->{'tfa-limit-reached'}) { + # FIXME: send mail to the user (or admin/root if no email configured) + die "failed 1nd factor: TFA limit reached, user locked out\n"; + } + if (!$result->{result}) { + die "failed 2nd factor\n"; + } } return $tfa_challenge; @@ -951,6 +942,43 @@ sub domain_set_password { $plugin->store_password($cfg, $realm, $username, $password); } +sub iterate_acl_tree { + my ($path, $node, $code) = @_; + + $code->($path, $node); + + $path = '' if $path eq '/'; # avoid leading '//' + + my $children = $node->{children}; + + foreach my $child (sort keys %$children) { + iterate_acl_tree("$path/$child", $children->{$child}, $code); + } +} + +# find ACL node corresponding to normalized $path under $root +sub find_acl_tree_node { + my ($root, $path) = @_; + + my $split_path = [ split("/", $path) ]; + + if (!$split_path) { + return $root; + } + + my $node = $root; + for my $p (@$split_path) { + next if !$p; + + $node->{children} = {} if !$node->{children}; + $node->{children}->{$p} = {} if !$node->{children}->{$p}; + + $node = $node->{children}->{$p}; + } + + return $node; +} + sub add_user_group { my ($username, $usercfg, $group) = @_; @@ -971,29 +999,33 @@ sub delete_user_group { sub delete_user_acl { my ($username, $usercfg) = @_; - foreach my $acl (keys %{$usercfg->{acl}}) { + my $code = sub { + my ($path, $acl_node) = @_; - delete ($usercfg->{acl}->{$acl}->{users}->{$username}) - if $usercfg->{acl}->{$acl}->{users}->{$username}; - } + delete ($acl_node->{users}->{$username}) + if $acl_node->{users}->{$username}; + }; + + iterate_acl_tree("/", $usercfg->{acl_root}, $code); } sub delete_group_acl { my ($group, $usercfg) = @_; - foreach my $acl (keys %{$usercfg->{acl}}) { + my $code = sub { + my ($path, $acl_node) = @_; - delete ($usercfg->{acl}->{$acl}->{groups}->{$group}) - if $usercfg->{acl}->{$acl}->{groups}->{$group}; - } + delete ($acl_node->{groups}->{$group}) + if $acl_node->{groups}->{$group}; + }; + + iterate_acl_tree("/", $usercfg->{acl_root}, $code); } sub delete_pool_acl { my ($pool, $usercfg) = @_; - my $path = "/pool/$pool"; - - delete ($usercfg->{acl}->{$path}) + delete ($usercfg->{acl_root}->{children}->{pool}->{children}->{$pool}); } # we automatically create some predefined roles by splitting privs @@ -1033,9 +1065,10 @@ my $privgroups = { root => [ 'Sys.PowerMgmt', 'Sys.Modify', # edit/change node settings + 'Sys.Incoming', # incoming storage/guest migrations + 'Sys.AccessNetwork', # for, e.g., downloading ISOs from any URL ], admin => [ - 'Permissions.Modify', 'Sys.Console', 'Sys.Syslog', ], @@ -1063,6 +1096,9 @@ my $privgroups = { 'SDN.Allocate', 'SDN.Audit', ], + user => [ + 'SDN.Use', + ], audit => [ 'SDN.Audit', ], @@ -1091,9 +1127,23 @@ my $privgroups = { 'Pool.Audit', ], }, + Mapping => { + root => [], + admin => [ + 'Mapping.Modify', + ], + user => [ + 'Mapping.Use', + ], + audit => [ + 'Mapping.Audit', + ], + }, }; -my $valid_privs = {}; +my $valid_privs = { + 'Permissions.Modify' => 1, # not contained in a group +}; my $special_roles = { 'NoAccess' => {}, # no privileges @@ -1102,27 +1152,32 @@ my $special_roles = { sub create_roles { - foreach my $cat (keys %$privgroups) { + for my $cat (keys %$privgroups) { my $cd = $privgroups->{$cat}; - foreach my $p (@{$cd->{root}}, @{$cd->{admin}}, - @{$cd->{user}}, @{$cd->{audit}}) { - $valid_privs->{$p} = 1; + # create map to easily check if a privilege is valid + for my $priv (@{$cd->{root}}, @{$cd->{admin}}, @{$cd->{user}}, @{$cd->{audit}}) { + $valid_privs->{$priv} = 1; } - foreach my $p (@{$cd->{admin}}, @{$cd->{user}}, @{$cd->{audit}}) { - - $special_roles->{"PVE${cat}Admin"}->{$p} = 1; - $special_roles->{"PVEAdmin"}->{$p} = 1; + # create grouped admin roles and PVEAdmin + for my $priv (@{$cd->{admin}}, @{$cd->{user}}, @{$cd->{audit}}) { + $special_roles->{"PVE${cat}Admin"}->{$priv} = 1; + $special_roles->{"PVEAdmin"}->{$priv} = 1; } + # create grouped user and audit roles if (scalar(@{$cd->{user}})) { - foreach my $p (@{$cd->{user}}, @{$cd->{audit}}) { - $special_roles->{"PVE${cat}User"}->{$p} = 1; + for my $priv (@{$cd->{user}}, @{$cd->{audit}}) { + $special_roles->{"PVE${cat}User"}->{$priv} = 1; } } - foreach my $p (@{$cd->{audit}}) { - $special_roles->{"PVEAuditor"}->{$p} = 1; + for my $priv (@{$cd->{audit}}) { + $special_roles->{"PVEAuditor"}->{$priv} = 1; } } + # remove Mapping.Modify from PVEAdmin, only Administrator, root@pam and + # PVEMappingAdmin should be able to use that for now + delete $special_roles->{"PVEAdmin"}->{"Mapping.Modify"}; + $special_roles->{"PVETemplateUser"} = { 'VM.Clone' => 1, 'VM.Audit' => 1 }; }; @@ -1212,14 +1267,25 @@ sub check_path { |/nodes |/nodes/[[:alnum:]\.\-\_]+ |/pool - |/pool/[[:alnum:]\.\-\_]+ + |/pool/[A-Za-z0-9\.\-_]+(?:/[A-Za-z0-9\.\-_]+){0,2} |/sdn + |/sdn/controllers + |/sdn/controllers/[[:alnum:]\_\-]+ + |/sdn/dns + |/sdn/dns/[[:alnum:]]+ + |/sdn/ipams + |/sdn/ipams/[[:alnum:]]+ + |/sdn/zones |/sdn/zones/[[:alnum:]\.\-\_]+ - |/sdn/vnets/[[:alnum:]\.\-\_]+ + |/sdn/zones/[[:alnum:]\.\-\_]+/[[:alnum:]\.\-\_]+ + |/sdn/zones/[[:alnum:]\.\-\_]+/[[:alnum:]\.\-\_]+/[1-9][0-9]{0,3} |/storage |/storage/[[:alnum:]\.\-\_]+ |/vms |/vms/[1-9][0-9]{2,} + |/mapping + |/mapping/[[:alnum:]\.\-\_]+ + |/mapping/[[:alnum:]\.\-\_]+/[[:alnum:]\.\-\_]+ )$!xs; } @@ -1255,8 +1321,14 @@ PVE::JSONSchema::register_format('pve-poolid', \&verify_poolname); sub verify_poolname { my ($poolname, $noerr) = @_; - if ($poolname !~ m/^[A-Za-z0-9\.\-_]+$/) { + if (split("/", $poolname) > 3) { + die "pool name '$poolname' nested too deeply (max levels = 3)\n" if !$noerr; + return undef; + } + + # also adapt check_path above if changed! + if ($poolname !~ m!^[A-Za-z0-9\.\-_]+(?:/[A-Za-z0-9\.\-_]+){0,2}$!) { die "pool name '$poolname' contains invalid characters\n" if !$noerr; return undef; @@ -1289,6 +1361,11 @@ sub userconfig_force_defaults { if (!$cfg->{users}->{'root@pam'}) { $cfg->{users}->{'root@pam'}->{enable} = 1; } + + # add (empty) ACL tree root node + if (!$cfg->{acl_root}) { + $cfg->{acl_root} = {}; + } } sub parse_user_config { @@ -1403,6 +1480,7 @@ sub parse_user_config { $propagate = $propagate ? 1 : 0; if (my $path = normalize_path($pathtxt)) { + my $acl_node; foreach my $role (split_list($rolelist)) { if (!verify_rolename($role, 1)) { @@ -1422,15 +1500,18 @@ sub parse_user_config { if (!$cfg->{groups}->{$group}) { # group does not exist warn "user config - ignore invalid acl group '$group'\n"; } - $cfg->{acl}->{$path}->{groups}->{$group}->{$role} = $propagate; + $acl_node = find_acl_tree_node($cfg->{acl_root}, $path) if !$acl_node; + $acl_node->{groups}->{$group}->{$role} = $propagate; } elsif (PVE::Auth::Plugin::verify_username($ug, 1)) { if (!$cfg->{users}->{$ug}) { # user does not exist warn "user config - ignore invalid acl member '$ug'\n"; } - $cfg->{acl}->{$path}->{users}->{$ug}->{$role} = $propagate; + $acl_node = find_acl_tree_node($cfg->{acl_root}, $path) if !$acl_node; + $acl_node->{users}->{$ug}->{$role} = $propagate; } elsif (my ($user, $token) = split_tokenid($ug, 1)) { if (check_token_exist($cfg, $user, $token, 1)) { - $cfg->{acl}->{$path}->{tokens}->{$ug}->{$role} = $propagate; + $acl_node = find_acl_tree_node($cfg->{acl_root}, $path) if !$acl_node; + $acl_node->{tokens}->{$ug}->{$role} = $propagate; } else { warn "user config - ignore invalid acl token '$ug'\n"; } @@ -1451,7 +1532,21 @@ sub parse_user_config { } # make sure to add the pool (even if there are no members) - $cfg->{pools}->{$pool} = { vms => {}, storage => {} } if !$cfg->{pools}->{$pool}; + $cfg->{pools}->{$pool} = { vms => {}, storage => {}, pools => {} } + if !$cfg->{pools}->{$pool}; + + if ($pool =~ m!/!) { + my $curr = $pool; + while ($curr =~ m!^(.+)/[^/]+$!) { + # ensure nested pool info is correctly recorded + my $parent = $1; + $cfg->{pools}->{$curr}->{parent} = $parent; + $cfg->{pools}->{$parent} = { vms => {}, storage => {}, pools => {} } + if !$cfg->{pools}->{$parent}; + $cfg->{pools}->{$parent}->{pools}->{$curr} = 1; + $curr = $parent; + } + } $cfg->{pools}->{$pool}->{comment} = PVE::Tools::decode_text($comment) if $comment; @@ -1599,8 +1694,8 @@ sub write_user_config { } }; - foreach my $path (sort keys %{$cfg->{acl}}) { - my $d = $cfg->{acl}->{$path}; + iterate_acl_tree("/", $cfg->{acl_root}, sub { + my ($path, $d) = @_; my $rolelist_members = {}; @@ -1619,7 +1714,7 @@ sub write_user_config { } } - } + }); return $data; } @@ -1647,8 +1742,6 @@ sub parse_priv_tfa_config { sub write_priv_tfa_config { my ($filename, $cfg) = @_; - assert_new_tfa_config_available(); - return $cfg->write(); } @@ -1683,12 +1776,20 @@ sub roles { my $roles = {}; - foreach my $p (sort keys %{$cfg->{acl}}) { - my $final = ($path eq $p); + my $split = [ split("/", $path) ]; + if ($path eq '/') { + $split = [ '' ]; + } - next if !(($p eq '/') || $final || ($path =~ m|^$p/|)); + my $acl = $cfg->{acl_root}; + my $i = 0; - my $acl = $cfg->{acl}->{$p}; + while (@$split) { + my $p = shift @$split; + my $final = !@$split; + if ($p ne '') { + $acl = $acl->{children}->{$p}; + } #print "CHECKACL $path $p\n"; #print "ACL $path = " . Dumper ($acl); @@ -1757,20 +1858,20 @@ sub roles { sub remove_vm_access { my ($vmid) = @_; my $delVMaccessFn = sub { - my $usercfg = cfs_read_file("user.cfg"); + my $usercfg = cfs_read_file("user.cfg"); my $modified; - if (my $acl = $usercfg->{acl}->{"/vms/$vmid"}) { - delete $usercfg->{acl}->{"/vms/$vmid"}; + if (my $acl = $usercfg->{acl_root}->{children}->{vms}->{children}->{$vmid}) { + delete $usercfg->{acl_root}->{children}->{vms}->{children}->{$vmid}; $modified = 1; - } - if (my $pool = $usercfg->{vms}->{$vmid}) { - if (my $data = $usercfg->{pools}->{$pool}) { - delete $data->{vms}->{$vmid}; - delete $usercfg->{vms}->{$vmid}; + } + if (my $pool = $usercfg->{vms}->{$vmid}) { + if (my $data = $usercfg->{pools}->{$pool}) { + delete $data->{vms}->{$vmid}; + delete $usercfg->{vms}->{$vmid}; $modified = 1; - } - } + } + } cfs_write_file("user.cfg", $usercfg) if $modified; }; @@ -1781,18 +1882,18 @@ sub remove_storage_access { my ($storeid) = @_; my $deleteStorageAccessFn = sub { - my $usercfg = cfs_read_file("user.cfg"); + my $usercfg = cfs_read_file("user.cfg"); my $modified; - if (my $storage = $usercfg->{acl}->{"/storage/$storeid"}) { - delete $usercfg->{acl}->{"/storage/$storeid"}; - $modified = 1; - } + if (my $acl = $usercfg->{acl_root}->{children}->{storage}->{children}->{$storeid}) { + delete $usercfg->{acl_root}->{children}->{storage}->{children}->{$storeid}; + $modified = 1; + } foreach my $pool (keys %{$usercfg->{pools}}) { delete $usercfg->{pools}->{$pool}->{storage}->{$storeid}; $modified = 1; } - cfs_write_file("user.cfg", $usercfg) if $modified; + cfs_write_file("user.cfg", $usercfg) if $modified; }; lock_user_config($deleteStorageAccessFn, @@ -1836,39 +1937,9 @@ my $USER_CONTROLLED_TFA_TYPES = { oath => 1, }; -sub assert_new_tfa_config_available() { - PVE::Cluster::cfs_update(); - my $version_info = PVE::Cluster::get_node_kv('version-info'); - die "cannot update tfa config, please make sure all cluster nodes are up to date\n" - if !$version_info; - my $members = PVE::Cluster::get_members() or return; # get_members returns undef on no cluster - my $old = ''; - foreach my $node (keys $members->%*) { - my $info = $version_info->{$node}; - if (!$info) { - $old .= " cluster node '$node' is too old, did not broadcast its version info\n"; - next; - } - $info = from_json($info); - my $ver = $info->{version}; - if ($ver !~ /^(\d+\.\d+)-(\d+)/) { - $old .= " cluster node '$node' provided an invalid version string: '$ver'\n"; - next; - } - my ($maj, $rel) = ($1, $2); - if (!($maj > 7.0 || ($maj == 7.0 && $rel >= 15))) { - $old .= " cluster node '$node' is too old ($ver < 7.0-15)\n"; - next; - } - } - die "cannot update tfa config, following nodes are not up to date:\n$old" if length($old); -} - sub user_remove_tfa : prototype($) { my ($userid) = @_; - assert_new_tfa_config_available(); - my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); $tfa_cfg->remove_user($userid); cfs_write_file('priv/tfa.cfg', $tfa_cfg); @@ -1947,7 +2018,7 @@ sub add_old_keys_to_realm_tfa : prototype($$$$) { } sub user_get_tfa : prototype($$$) { - my ($username, $realm, $new_format) = @_; + my ($username, $realm) = @_; my $user_cfg = cfs_read_file('user.cfg'); my $user = $user_cfg->{users}->{$username} @@ -1963,40 +2034,12 @@ sub user_get_tfa : prototype($$$) { $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_tfa) if $realm_tfa; - if (!$keys) { - return if !$realm_tfa; - die "missing required 2nd keys\n"; - } - - if ($new_format) { - my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); - if (defined($keys) && $keys !~ /^x(?:!.*)$/) { - add_old_keys_to_realm_tfa($username, $tfa_cfg, $realm_tfa, $keys); - } - return ($tfa_cfg, $realm_tfa); + my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); + if (defined($keys) && $keys !~ /^x(?:!.*)$/) { + add_old_keys_to_realm_tfa($username, $tfa_cfg, $realm_tfa, $keys); } - # new style config starts with an 'x' and optionally contains a ! suffix - if ($keys !~ /^x(?:!.*)?$/) { - # old style config, find the type via the realm - return if !$realm_tfa; - return ($realm_tfa->{type}, { - keys => $keys, - config => $realm_tfa, - }); - } else { - my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); - my $tfa = $tfa_cfg->{users}->{$username}; - return if !$tfa; # should not happen (user.cfg wasn't cleaned up?) - - if ($realm_tfa) { - # if the realm has a tfa setting we need to verify the type: - die "auth domain '$realm' and user have mismatching TFA settings\n" - if $realm_tfa && $realm_tfa->{type} ne $tfa->{type}; - } - - return ($tfa->{type}, $tfa->{data}); - } + return ($tfa_cfg, $realm_tfa); } # bash completion helpers