]> git.proxmox.com Git - pve-access-control.git/blobdiff - PVE/AccessControl.pm
do not modify ACLs/Groups for missing users
[pve-access-control.git] / PVE / AccessControl.pm
index 512fcd2dddcd8844a5edf18cedb5d56411512739..f50a51000e5506b0c7ae20eba4b1ccc565eb841f 100644 (file)
@@ -47,9 +47,8 @@ my $pve_auth_key_files = {
 
 my $pve_auth_key_cache = {};
 
-my $ticket_lifetime = 3600*2; # 2 hours
-# TODO: set to 24h for PVE 6.0
-my $authkey_lifetime = 3600*0; # rotation disabled
+my $ticket_lifetime = 3600 * 2; # 2 hours
+my $authkey_lifetime = 3600 * 24; # rotate every 24 hours
 
 Crypt::OpenSSL::RSA->import_random_seed();
 
@@ -167,18 +166,21 @@ sub rotate_authkey {
        return if check_authkey();
 
        my $old = get_pubkey();
+       my $new = Crypt::OpenSSL::RSA->generate_key(2048);
 
        if ($old) {
            eval {
                my $pem = $old->get_public_key_x509_string();
+               # mtime is used for caching and ticket age range calculation
                PVE::Tools::file_set_contents($pve_auth_key_files->{pubold}, $pem);
            };
            die "Failed to store old auth key: $@\n" if $@;
        }
 
-       my $new = Crypt::OpenSSL::RSA->generate_key(2048);
        eval {
            my $pem = $new->get_public_key_x509_string();
+           # mtime is used for caching and ticket age range calculation,
+           # should be close to that of pubold above
            PVE::Tools::file_set_contents($pve_auth_key_files->{pub}, $pem);
        };
        if ($@) {
@@ -209,11 +211,60 @@ sub rotate_authkey {
     die $@ if $@;
 }
 
+PVE::JSONSchema::register_standard_option('tokenid', {
+    description => "API token identifier.",
+    type => "string",
+    format => "pve-tokenid",
+});
+
+our $token_subid_regex = $PVE::Auth::Plugin::realm_regex;
+
+# username@realm username realm tokenid
+our $token_full_regex = qr/((${PVE::Auth::Plugin::user_regex})\@(${PVE::Auth::Plugin::realm_regex}))!(${token_subid_regex})/;
+
+our $userid_or_token_regex = qr/^$PVE::Auth::Plugin::user_regex\@$PVE::Auth::Plugin::realm_regex(?:!$token_subid_regex)?$/;
+
+sub split_tokenid {
+    my ($tokenid, $noerr) = @_;
+
+    if ($tokenid =~ /^${token_full_regex}$/) {
+       return ($1, $4);
+    }
+
+    die "'$tokenid' is not a valid token ID - not able to split into user and token parts\n" if !$noerr;
+
+    return undef;
+}
+
+sub join_tokenid {
+    my ($username, $tokensubid) = @_;
+
+    my $joined = "${username}!${tokensubid}";
+
+    return pve_verify_tokenid($joined);
+}
+
+PVE::JSONSchema::register_format('pve-tokenid', \&pve_verify_tokenid);
+sub pve_verify_tokenid {
+    my ($tokenid, $noerr) = @_;
+
+    if ($tokenid =~ /^${token_full_regex}$/) {
+       return wantarray ? ($tokenid, $2, $3, $4) : $tokenid;
+    }
+
+    die "value '$tokenid' does not look like a valid token ID\n" if !$noerr;
+
+    return undef;
+}
+
+
 my $csrf_prevention_secret;
+my $csrf_prevention_secret_legacy;
 my $get_csrfr_secret = sub {
     if (!$csrf_prevention_secret) {
        my $input = PVE::Tools::file_get_contents($pve_www_key_fn);
-       $csrf_prevention_secret = Digest::SHA::sha1_base64($input);
+       $csrf_prevention_secret = Digest::SHA::hmac_sha256_base64($input);
+       $csrf_prevention_secret_legacy = Digest::SHA::sha1_base64($input);
     }
     return $csrf_prevention_secret;
 };
@@ -229,7 +280,16 @@ sub assemble_csrf_prevention_token {
 sub verify_csrf_prevention_token {
     my ($username, $token, $noerr) = @_;
 
-    my $secret =  &$get_csrfr_secret();
+    my $secret = $get_csrfr_secret->();
+
+    # FIXME: remove with PVE 7 and/or refactor all into PVE::Ticket ?
+    if ($token =~ m/^([A-Z0-9]{8}):(\S+)$/) {
+       my $sig = $2;
+       if (length($sig) == 27) {
+           # the legacy secret got populated by above get_csrfr_secret call
+           $secret = $csrf_prevention_secret_legacy;
+       }
+    }
 
     return PVE::Ticket::verify_csrf_prevention_token(
        $secret, $username, $token, -300, $ticket_lifetime, $noerr);
@@ -284,7 +344,7 @@ sub verify_ticket {
        return undef if !$rsa_pub;
 
        my ($min, $max) = $get_ticket_age_range->($now, $rsa_mtime, $old);
-       return undef if !$min;
+       return undef if !defined($min);
 
        return PVE::Ticket::verify_rsa_ticket(
            $rsa_pub, 'PVE', $ticket, undef, $min, $max, 1);
@@ -339,6 +399,39 @@ sub verify_ticket {
     return wantarray ? ($username, $age, $tfa_info) : $username;
 }
 
+sub verify_token {
+    my ($api_token) = @_;
+
+    die "no API token specified\n" if !$api_token;
+
+    my ($tokenid, $value);
+    if ($api_token =~ /^(.*)=(.*)$/) {
+       $tokenid = $1;
+       $value = $2;
+    } else {
+       die "no tokenid specified\n";
+    }
+
+    my ($username, $token) = split_tokenid($tokenid);
+
+    my $usercfg = cfs_read_file('user.cfg');
+    check_user_enabled($usercfg, $username);
+    check_token_exist($usercfg, $username, $token);
+
+    my $ctime = time();
+
+    my $user = $usercfg->{users}->{$username};
+    die "account expired\n" if $user->{expire} && ($user->{expire} < $ctime);
+
+    my $token_info = $user->{tokens}->{$token};
+    die "token expired\n" if $token_info->{expire} && ($token_info->{expire} < $ctime);
+
+    die "invalid token value!\n" if !PVE::Cluster::verify_token($tokenid, $value);
+
+    return wantarray ? ($tokenid) : $tokenid;
+}
+
+
 # VNC tickets
 # - they do not contain the username in plain text
 # - they are restricted to a specific resource path (example: '/vms/100')
@@ -479,6 +572,20 @@ sub check_user_enabled {
     return undef;
 }
 
+sub check_token_exist {
+    my ($usercfg, $username, $tokenid, $noerr) = @_;
+
+    my $user = check_user_exist($usercfg, $username, $noerr);
+    return undef if !$user;
+
+    return $user->{tokens}->{$tokenid}
+       if defined($user->{tokens}) && $user->{tokens}->{$tokenid};
+
+    die "no such token '$tokenid' for user '$username'\n" if !$noerr;
+
+    return undef;
+}
+
 sub verify_one_time_pw {
     my ($type, $username, $keys, $tfa_cfg, $otp) = @_;
 
@@ -520,7 +627,7 @@ sub authenticate_user {
     my $domain_cfg = cfs_read_file('domains.cfg');
 
     my $cfg = $domain_cfg->{ids}->{$realm};
-    die "auth domain '$realm' does not exists\n" if !$cfg;
+    die "auth domain '$realm' does not exist\n" if !$cfg;
     my $plugin = PVE::Auth::Plugin->lookup($cfg->{type});
     $plugin->authenticate_user($cfg, $realm, $ruid, $password);
 
@@ -670,6 +777,16 @@ my $privgroups = {
            'Datastore.Audit',
        ],
     },
+    SDN => {
+       root => [],
+       admin => [
+           'SDN.Allocate',
+           'SDN.Audit',
+       ],
+       audit => [
+           'SDN.Audit',
+       ],
+    },
     User => {
        root => [
            'Realm.Allocate',
@@ -924,10 +1041,10 @@ sub parse_user_config {
 
                if ($cfg->{users}->{$user}) { # user exists
                    $cfg->{users}->{$user}->{groups}->{$group} = 1;
-                   $cfg->{groups}->{$group}->{users}->{$user} = 1;
                } else {
                    warn "user config - ignore invalid group member '$user'\n";
                }
+               $cfg->{groups}->{$group}->{users}->{$user} = 1;
            }
 
        } elsif ($et eq 'role') {
@@ -945,13 +1062,15 @@ sub parse_user_config {
                if (defined ($valid_privs->{$priv})) {
                    $cfg->{roles}->{$role}->{$priv} = 1;
                } else {
-                   warn "user config - ignore invalid priviledge '$priv'\n";
+                   warn "user config - ignore invalid privilege '$priv'\n";
                }
            }
 
        } elsif ($et eq 'acl') {
            my ($propagate, $pathtxt, $uglist, $rolelist) = @data;
 
+           $propagate = $propagate ? 1 : 0;
+
            if (my $path = normalize_path($pathtxt)) {
                foreach my $role (split_list($rolelist)) {
 
@@ -960,20 +1079,30 @@ sub parse_user_config {
                        next;
                    }
 
+                   if (!$cfg->{roles}->{$role}) {
+                       warn "user config - ignore invalid acl role '$role'\n";
+                       next;
+                   }
+
                    foreach my $ug (split_list($uglist)) {
-                       if ($ug =~ m/^@(\S+)$/) {
-                           my $group = $1;
-                           if ($cfg->{groups}->{$group}) { # group exists
-                               $cfg->{acl}->{$path}->{groups}->{$group}->{$role} = $propagate;
-                           } else {
+                       my ($group) = $ug =~ m/^@(\S+)$/;
+
+                       if ($group && verify_groupname($group, 1)) {
+                           if (!$cfg->{groups}->{$group}) { # group does not exist
                                warn "user config - ignore invalid acl group '$group'\n";
                            }
+                           $cfg->{acl}->{$path}->{groups}->{$group}->{$role} = $propagate;
                        } elsif (PVE::Auth::Plugin::verify_username($ug, 1)) {
-                           if ($cfg->{users}->{$ug}) { # user exists
-                               $cfg->{acl}->{$path}->{users}->{$ug}->{$role} = $propagate;
-                           } else {
+                           if (!$cfg->{users}->{$ug}) { # user does not exist
                                warn "user config - ignore invalid acl member '$ug'\n";
                            }
+                           $cfg->{acl}->{$path}->{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;
+                           } else {
+                               warn "user config - ignore invalid acl token '$ug'\n";
+                           }
                        } else {
                            warn "user config - invalid user/group '$ug' in acl\n";
                        }
@@ -1020,6 +1149,34 @@ sub parse_user_config {
                }
                $cfg->{pools}->{$pool}->{storage}->{$storeid} = 1;
            }
+       } elsif ($et eq 'token') {
+           my ($tokenid, $expire, $privsep, $comment) = @data;
+
+           my ($user, $token) = split_tokenid($tokenid, 1);
+           if (!($user && $token)) {
+               warn "user config - ignore invalid tokenid '$tokenid'\n";
+               next;
+           }
+
+           $privsep = $privsep ? 1 : 0;
+
+           $expire = 0 if !$expire;
+
+           if ($expire !~ m/^\d+$/) {
+               warn "user config - ignore token '$tokenid' - (illegal characters in expire '$expire')\n";
+               next;
+           }
+           $expire = int($expire);
+
+           if (my $user_cfg = $cfg->{users}->{$user}) { # user exists
+               $user_cfg->{tokens}->{$token} = {} if !$user_cfg->{tokens}->{$token};
+               my $token_cfg = $user_cfg->{tokens}->{$token};
+               $token_cfg->{privsep} = $privsep;
+               $token_cfg->{expire} = $expire;
+               $token_cfg->{comment} = PVE::Tools::decode_text($comment) if $comment;
+           } else {
+               warn "user config - ignore token '$tokenid' - user does not exist\n";
+           }
        } else {
            warn "user config - ignore config line: $line\n";
        }
@@ -1035,7 +1192,7 @@ sub write_user_config {
 
     my $data = '';
 
-    foreach my $user (keys %{$cfg->{users}}) {
+    foreach my $user (sort keys %{$cfg->{users}}) {
        my $d = $cfg->{users}->{$user};
        my $firstname = $d->{firstname} ? PVE::Tools::encode_text($d->{firstname}) : '';
        my $lastname = $d->{lastname} ? PVE::Tools::encode_text($d->{lastname}) : '';
@@ -1045,49 +1202,59 @@ sub write_user_config {
        my $enable = $d->{enable} ? 1 : 0;
        my $keys = $d->{keys} ? $d->{keys} : '';
        $data .= "user:$user:$enable:$expire:$firstname:$lastname:$email:$comment:$keys:\n";
+
+       my $user_tokens = $d->{tokens};
+       foreach my $token (sort keys %$user_tokens) {
+           my $td = $user_tokens->{$token};
+           my $full_tokenid = join_tokenid($user, $token);
+           my $comment = $td->{comment} ? PVE::Tools::encode_text($td->{comment}) : '';
+           my $expire = int($td->{expire} || 0);
+           my $privsep = $td->{privsep} ? 1 : 0;
+           $data .= "token:$full_tokenid:$expire:$privsep:$comment:\n";
+       }
     }
 
     $data .= "\n";
 
-    foreach my $group (keys %{$cfg->{groups}}) {
+    foreach my $group (sort keys %{$cfg->{groups}}) {
        my $d = $cfg->{groups}->{$group};
-       my $list = join (',', keys %{$d->{users}});
+       my $list = join (',', sort keys %{$d->{users}});
        my $comment = $d->{comment} ? PVE::Tools::encode_text($d->{comment}) : '';
        $data .= "group:$group:$list:$comment:\n";
     }
 
     $data .= "\n";
 
-    foreach my $pool (keys %{$cfg->{pools}}) {
+    foreach my $pool (sort keys %{$cfg->{pools}}) {
        my $d = $cfg->{pools}->{$pool};
-       my $vmlist = join (',', keys %{$d->{vms}});
-       my $storelist = join (',', keys %{$d->{storage}});
+       my $vmlist = join (',', sort keys %{$d->{vms}});
+       my $storelist = join (',', sort keys %{$d->{storage}});
        my $comment = $d->{comment} ? PVE::Tools::encode_text($d->{comment}) : '';
        $data .= "pool:$pool:$comment:$vmlist:$storelist:\n";
     }
 
     $data .= "\n";
 
-    foreach my $role (keys %{$cfg->{roles}}) {
+    foreach my $role (sort keys %{$cfg->{roles}}) {
        next if $special_roles->{$role};
 
        my $d = $cfg->{roles}->{$role};
-       my $list = join (',', keys %$d);
+       my $list = join (',', sort keys %$d);
        $data .= "role:$role:$list:\n";
     }
 
     $data .= "\n";
 
-    foreach my $path (sort keys %{$cfg->{acl}}) {
-       my $d = $cfg->{acl}->{$path};
+    my $collect_rolelist_members = sub {
+       my ($acl_members, $result, $prefix, $exclude) = @_;
 
-       my $ra = {};
+       foreach my $member (keys %$acl_members) {
+           next if $exclude && $member eq $exclude;
 
-       foreach my $group (keys %{$d->{groups}}) {
            my $l0 = '';
            my $l1 = '';
-           foreach my $role (sort keys %{$d->{groups}->{$group}}) {
-               my $propagate = $d->{groups}->{$group}->{$role};
+           foreach my $role (sort keys %{$acl_members->{$member}}) {
+               my $propagate = $acl_members->{$member}->{$role};
                if ($propagate) {
                    $l1 .= ',' if $l1;
                    $l1 .= $role;
@@ -1096,37 +1263,30 @@ sub write_user_config {
                    $l0 .= $role;
                }
            }
-           $ra->{0}->{$l0}->{"\@$group"} = 1 if $l0;
-           $ra->{1}->{$l1}->{"\@$group"} = 1 if $l1;
+           $result->{0}->{$l0}->{"${prefix}${member}"} = 1 if $l0;
+           $result->{1}->{$l1}->{"${prefix}${member}"} = 1 if $l1;
        }
+    };
+
+    foreach my $path (sort keys %{$cfg->{acl}}) {
+       my $d = $cfg->{acl}->{$path};
 
-       foreach my $user (keys %{$d->{users}}) {
-           # no need to save, because root is always 'Administrator'
-           next if $user eq 'root@pam';
+       my $rolelist_members = {};
 
-           my $l0 = '';
-           my $l1 = '';
-           foreach my $role (sort keys %{$d->{users}->{$user}}) {
-               my $propagate = $d->{users}->{$user}->{$role};
-               if ($propagate) {
-                   $l1 .= ',' if $l1;
-                   $l1 .= $role;
-               } else {
-                   $l0 .= ',' if $l0;
-                   $l0 .= $role;
-               }
+       $collect_rolelist_members->($d->{'groups'}, $rolelist_members, '@');
+
+       # no need to save 'root@pam', it is always 'Administrator'
+       $collect_rolelist_members->($d->{'users'}, $rolelist_members, '', 'root@pam');
+
+       $collect_rolelist_members->($d->{'tokens'}, $rolelist_members, '');
+
+       foreach my $propagate (0,1) {
+           my $filtered = $rolelist_members->{$propagate};
+           foreach my $rolelist (sort keys %$filtered) {
+               my $uglist = join (',', sort keys %{$filtered->{$rolelist}});
+               $data .= "acl:$propagate:$path:$uglist:$rolelist:\n";
            }
-           $ra->{0}->{$l0}->{$user} = 1 if $l0;
-           $ra->{1}->{$l1}->{$user} = 1 if $l1;
-       }
 
-       foreach my $rolelist (sort keys %{$ra->{0}}) {
-           my $uglist = join (',', keys %{$ra->{0}->{$rolelist}});
-           $data .= "acl:0:$path:$uglist:$rolelist:\n";
-       }
-       foreach my $rolelist (sort keys %{$ra->{1}}) {
-           my $uglist = join (',', keys %{$ra->{1}->{$rolelist}});
-           $data .= "acl:1:$path:$uglist:$rolelist:\n";
        }
     }
 
@@ -1195,11 +1355,26 @@ sub roles {
     my ($cfg, $user, $path) = @_;
 
     # NOTE: we do not consider pools here.
-    # You need to use $rpcenv->roles() instead if you want that.
+    # NOTE: for privsep tokens, this does not filter roles by those that the
+    # corresponding user has.
+    # Use $rpcenv->permission() for any actual permission checks!
 
     return 'Administrator' if $user eq 'root@pam'; # root can do anything
 
-    my $perm = {};
+    if (pve_verify_tokenid($user, 1)) {
+       my $tokenid = $user;
+       my ($username, $token) = split_tokenid($tokenid);
+
+       my $token_info = $cfg->{users}->{$username}->{tokens}->{$token};
+       return () if !$token_info;
+
+       my $user_roles = roles($cfg, $username, $path);
+
+       # return full user privileges
+       return $user_roles if !$token_info->{privsep};
+    }
+
+    my $roles = {};
 
     foreach my $p (sort keys %{$cfg->{acl}}) {
        my $final = ($path eq $p);
@@ -1210,6 +1385,21 @@ sub roles {
 
        #print "CHECKACL $path $p\n";
        #print "ACL $path = " . Dumper ($acl);
+       if (my $ri = $acl->{tokens}->{$user}) {
+           my $new;
+           foreach my $role (keys %$ri) {
+               my $propagate = $ri->{$role};
+               if ($final || $propagate) {
+                   #print "APPLY ROLE $p $user $role\n";
+                   $new = {} if !$new;
+                   $new->{$role} = $propagate;
+               }
+           }
+           if ($new) {
+               $roles = $new; # overwrite previous settings
+               next;
+           }
+       }
 
        if (my $ri = $acl->{users}->{$user}) {
            my $new;
@@ -1218,11 +1408,11 @@ sub roles {
                if ($final || $propagate) {
                    #print "APPLY ROLE $p $user $role\n";
                    $new = {} if !$new;
-                   $new->{$role} = 1;
+                   $new->{$role} = $propagate;
                }
            }
            if ($new) {
-               $perm = $new; # overwrite previous settings
+               $roles = $new; # overwrite previous settings
                next; # user privs always override group privs
            }
        }
@@ -1236,64 +1426,25 @@ sub roles {
                    if ($final || $propagate) {
                        #print "APPLY ROLE $p \@$g $role\n";
                        $new = {} if !$new;
-                       $new->{$role} = 1;
+                       $new->{$role} = $propagate;
                    }
                }
            }
        }
        if ($new) {
-           $perm = $new; # overwrite previous settings
+           $roles = $new; # overwrite previous settings
            next;
        }
     }
 
-    return ('NoAccess') if defined ($perm->{NoAccess});
-    #return () if defined ($perm->{NoAccess});
+    return { 'NoAccess' => $roles->{NoAccess} } if defined ($roles->{NoAccess});
+    #return () if defined ($roles->{NoAccess});
 
-    #print "permission $user $path = " . Dumper ($perm);
-
-    my @ra = keys %$perm;
+    #print "permission $user $path = " . Dumper ($roles);
 
     #print "roles $user $path = " . join (',', @ra) . "\n";
 
-    return @ra;
-}
-
-sub permission {
-    my ($cfg, $user, $path) = @_;
-
-    $user = PVE::Auth::Plugin::verify_username($user, 1);
-    return {} if !$user;
-
-    my @ra = roles($cfg, $user, $path);
-
-    my $privs = {};
-
-    foreach my $role (@ra) {
-       if (my $privset = $cfg->{roles}->{$role}) {
-           foreach my $p (keys %$privset) {
-               $privs->{$p} = 1;
-           }
-       }
-    }
-
-    #print "priviledges $user $path = " . Dumper ($privs);
-
-    return $privs;
-}
-
-sub check_permissions {
-    my ($username, $path, $privlist) = @_;
-
-    $path = normalize_path($path);
-    my $usercfg = cfs_read_file('user.cfg');
-    my $perm = permission($usercfg, $username, $path);
-
-    foreach my $priv (split_list($privlist)) {
-       return undef if !$perm->{$priv};
-    };
-
-    return 1;
+    return $roles;
 }
 
 sub remove_vm_access {