]> git.proxmox.com Git - pve-access-control.git/commitdiff
buildsys: split packaging and source build-systems
authorThomas Lamprecht <t.lamprecht@proxmox.com>
Sun, 9 May 2021 17:37:47 +0000 (19:37 +0200)
committerThomas Lamprecht <t.lamprecht@proxmox.com>
Sun, 9 May 2021 17:48:50 +0000 (19:48 +0200)
Much nicer to handle and work with than entangling all together in a
single spaghetti pile.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
86 files changed:
Makefile
PVE/API2/ACL.pm [deleted file]
PVE/API2/AccessControl.pm [deleted file]
PVE/API2/Domains.pm [deleted file]
PVE/API2/Group.pm [deleted file]
PVE/API2/Makefile [deleted file]
PVE/API2/Role.pm [deleted file]
PVE/API2/User.pm [deleted file]
PVE/AccessControl.pm [deleted file]
PVE/Auth/AD.pm [deleted file]
PVE/Auth/LDAP.pm [deleted file]
PVE/Auth/Makefile [deleted file]
PVE/Auth/PAM.pm [deleted file]
PVE/Auth/PVE.pm [deleted file]
PVE/Auth/Plugin.pm [deleted file]
PVE/CLI/Makefile [deleted file]
PVE/CLI/pveum.pm [deleted file]
PVE/Makefile [deleted file]
PVE/RPCEnvironment.pm [deleted file]
PVE/TokenConfig.pm [deleted file]
oathkeygen [deleted file]
pveum [deleted file]
src/Makefile [new file with mode: 0644]
src/PVE/API2/ACL.pm [new file with mode: 0644]
src/PVE/API2/AccessControl.pm [new file with mode: 0644]
src/PVE/API2/Domains.pm [new file with mode: 0644]
src/PVE/API2/Group.pm [new file with mode: 0644]
src/PVE/API2/Makefile [new file with mode: 0644]
src/PVE/API2/Role.pm [new file with mode: 0644]
src/PVE/API2/User.pm [new file with mode: 0644]
src/PVE/AccessControl.pm [new file with mode: 0644]
src/PVE/Auth/AD.pm [new file with mode: 0755]
src/PVE/Auth/LDAP.pm [new file with mode: 0755]
src/PVE/Auth/Makefile [new file with mode: 0644]
src/PVE/Auth/PAM.pm [new file with mode: 0755]
src/PVE/Auth/PVE.pm [new file with mode: 0755]
src/PVE/Auth/Plugin.pm [new file with mode: 0755]
src/PVE/CLI/Makefile [new file with mode: 0644]
src/PVE/CLI/pveum.pm [new file with mode: 0755]
src/PVE/Makefile [new file with mode: 0644]
src/PVE/RPCEnvironment.pm [new file with mode: 0644]
src/PVE/TokenConfig.pm [new file with mode: 0644]
src/oathkeygen [new file with mode: 0755]
src/pveum [new file with mode: 0755]
src/test/Makefile [new file with mode: 0644]
src/test/auth-test.pl [new file with mode: 0644]
src/test/dump-perm.pl [new file with mode: 0755]
src/test/dump-users.pl [new file with mode: 0755]
src/test/parser_writer.pl [new file with mode: 0755]
src/test/perm-test1.pl [new file with mode: 0755]
src/test/perm-test2.pl [new file with mode: 0755]
src/test/perm-test3.pl [new file with mode: 0755]
src/test/perm-test4.pl [new file with mode: 0755]
src/test/perm-test5.pl [new file with mode: 0755]
src/test/perm-test6.pl [new file with mode: 0755]
src/test/perm-test7.pl [new file with mode: 0755]
src/test/perm-test8.pl [new file with mode: 0644]
src/test/test1.cfg [new file with mode: 0644]
src/test/test2.cfg [new file with mode: 0644]
src/test/test3.cfg [new file with mode: 0644]
src/test/test4.cfg [new file with mode: 0644]
src/test/test5.cfg [new file with mode: 0644]
src/test/test6.cfg [new file with mode: 0644]
src/test/test7.cfg [new file with mode: 0644]
src/test/test8.cfg [new file with mode: 0644]
test/Makefile [deleted file]
test/auth-test.pl [deleted file]
test/dump-perm.pl [deleted file]
test/dump-users.pl [deleted file]
test/parser_writer.pl [deleted file]
test/perm-test1.pl [deleted file]
test/perm-test2.pl [deleted file]
test/perm-test3.pl [deleted file]
test/perm-test4.pl [deleted file]
test/perm-test5.pl [deleted file]
test/perm-test6.pl [deleted file]
test/perm-test7.pl [deleted file]
test/perm-test8.pl [deleted file]
test/test1.cfg [deleted file]
test/test2.cfg [deleted file]
test/test3.cfg [deleted file]
test/test4.cfg [deleted file]
test/test5.cfg [deleted file]
test/test6.cfg [deleted file]
test/test7.cfg [deleted file]
test/test8.cfg [deleted file]

index c9e3c1755eb922377e1675774755b616b0b98111..4db6f8fa9316b2128098b846a3f44e28e1578f08 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -3,66 +3,23 @@ include /usr/share/dpkg/architecture.mk
 
 PACKAGE=libpve-access-control
 
+DEB=${PACKAGE}_${DEB_VERSION_UPSTREAM_REVISION}_all.deb
+DSC=${PACKAGE}_${DEB_VERSION_UPSTREAM_REVISION}.dsc
 
 BUILDDIR ?= ${PACKAGE}-${DEB_VERSION_UPSTREAM}
 
-DESTDIR=
-PREFIX=/usr
-BINDIR=${PREFIX}/bin
-SBINDIR=${PREFIX}/sbin
-MANDIR=${PREFIX}/share/man
-DOCDIR=${PREFIX}/share/doc/${PACKAGE}
-MAN1DIR=${MANDIR}/man1/
-BASHCOMPLDIR=${PREFIX}/share/bash-completion/completions/
-ZSHCOMPLDIR=${PREFIX}/share/zsh/vendor-completions/
-
-export PERLDIR=${PREFIX}/share/perl5
-
 GITVERSION:=$(shell git rev-parse HEAD)
 
-DEB=${PACKAGE}_${DEB_VERSION_UPSTREAM_REVISION}_all.deb
-DSC=${PACKAGE}_${DEB_VERSION_UPSTREAM_REVISION}.dsc
-
-# this requires package pve-doc-generator
-export NOVIEW=1
-include /usr/share/pve-doc-generator/pve-doc-generator.mk
-
 all:
 
 .PHONY: dinstall
 dinstall: deb
        dpkg -i ${DEB}
 
-pveum.bash-completion: PVE/CLI/pveum.pm
-       perl -I. -T -e "use PVE::CLI::pveum; PVE::CLI::pveum->generate_bash_completions();" >$@.tmp
-       mv $@.tmp $@
-
-pveum.zsh-completion: PVE/CLI/pveum.pm
-       perl -I. -T -e "use PVE::CLI::pveum; PVE::CLI::pveum->generate_zsh_completions();" >$@.tmp
-       mv $@.tmp $@
-
-.PHONY: install
-install: pveum.1 oathkeygen pveum.bash-completion pveum.zsh-completion
-       install -d ${DESTDIR}${BINDIR}
-       install -d ${DESTDIR}${SBINDIR}
-       install -m 0755 pveum ${DESTDIR}${SBINDIR}
-       install -m 0755 oathkeygen ${DESTDIR}${BINDIR}
-       make -C PVE install
-       install -d ${DESTDIR}/${MAN1DIR}
-       install -d ${DESTDIR}/${DOCDIR}
-       install -m 0644 pveum.1 ${DESTDIR}/${MAN1DIR}
-       install -m 0644 -D pveum.bash-completion ${DESTDIR}${BASHCOMPLDIR}/pveum
-       install -m 0644 -D pveum.zsh-completion ${DESTDIR}${ZSHCOMPLDIR}/_pveum
-
-.PHONY: test
-test:
-       perl -I. ./pveum verifyapi
-       perl -I. -T -e "use PVE::CLI::pveum; PVE::CLI::pveum->verify_api();"
-       make -C test check
-
 ${BUILDDIR}:
        rm -rf ${BUILDDIR}
-       rsync -a * ${BUILDDIR}
+       cp -a src ${BUILDDIR}
+       cp -a debian ${BUILDDIR}/
        echo "git clone git://git.proxmox.com/git/pve-access-control.git\\ngit checkout ${GITVERSION}" > ${BUILDDIR}/debian/SOURCE
 
 .PHONY: deb
@@ -83,9 +40,7 @@ upload: ${DEB}
 
 .PHONY: clean
 clean:
-       rm -rf ${BUILDDIR}
-       make cleanup-docgen
-       rm -rf *.deb *.buildinfo *.changes ${PACKAGE}*.tar.gz *.dsc
+       rm -rf ${BUILDDIR} *.deb *.buildinfo *.changes ${PACKAGE}*.tar.gz *.dsc
        find . -name '*~' -exec rm {} ';'
 
 .PHONY: distclean
diff --git a/PVE/API2/ACL.pm b/PVE/API2/ACL.pm
deleted file mode 100644 (file)
index 857c672..0000000
+++ /dev/null
@@ -1,206 +0,0 @@
-package PVE::API2::ACL;
-
-use strict;
-use warnings;
-use PVE::Cluster qw (cfs_read_file cfs_write_file);
-use PVE::Tools qw(split_list);
-use PVE::AccessControl;
-use PVE::Exception qw(raise_param_exc);
-use PVE::JSONSchema qw(get_standard_option register_standard_option);
-
-use PVE::SafeSyslog;
-
-use PVE::RESTHandler;
-
-use base qw(PVE::RESTHandler);
-
-register_standard_option('acl-propagate', {
-    description => "Allow to propagate (inherit) permissions.",
-    type => 'boolean',
-    optional => 1,
-    default => 1,
-});
-register_standard_option('acl-path', {
-    description => "Access control path",
-    type => 'string',
-});
-
-__PACKAGE__->register_method ({
-    name => 'read_acl',
-    path => '',
-    method => 'GET',
-    description => "Get Access Control List (ACLs).",
-    permissions => {
-       description => "The returned list is restricted to objects where you have rights to modify permissions.",
-       user => 'all',
-    },
-    parameters => {
-       additionalProperties => 0,
-       properties => {},
-    },
-    returns => {
-       type => 'array',
-       items => {
-           type => "object",
-           additionalProperties => 0,
-           properties => {
-               propagate => get_standard_option('acl-propagate'),
-               path => get_standard_option('acl-path'),
-               type => { type => 'string', enum => ['user', 'group', 'token'] },
-               ugid => { type => 'string' },
-               roleid => { type => 'string' },
-           },
-       },
-    },
-    code => sub {
-       my ($param) = @_;
-
-       my $rpcenv = PVE::RPCEnvironment::get();
-       my $authuser = $rpcenv->get_user();
-       my $res = [];
-
-       my $usercfg = $rpcenv->{user_cfg};
-       if (!$usercfg || !$usercfg->{acl}) {
-           return $res;
-       }
-
-       my $audit = $rpcenv->check($authuser, '/access', ['Sys.Audit'], 1);
-
-       my $acl = $usercfg->{acl};
-       foreach my $path (keys %$acl) {
-           foreach my $type (qw(user group token)) {
-               my $d = $acl->{$path}->{"${type}s"};
-               next if !$d;
-               next if !($audit || $rpcenv->check_perm_modify($authuser, $path, 1));
-               foreach my $id (keys %$d) {
-                   foreach my $role (keys %{$d->{$id}}) {
-                       my $propagate = $d->{$id}->{$role};
-                       push @$res, {
-                           path => $path,
-                           type => $type,
-                           ugid => $id,
-                           roleid => $role,
-                           propagate => $propagate,
-                       };
-                   }
-               }
-           }
-       }
-
-       return $res;
-    }});
-
-__PACKAGE__->register_method ({
-    name => 'update_acl',
-    protected => 1,
-    path => '',
-    method => 'PUT',
-    permissions => {
-       check => ['perm-modify', '{path}'],
-    },
-    description => "Update Access Control List (add or remove permissions).",
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           propagate => get_standard_option('acl-propagate'),
-           path => get_standard_option('acl-path'),
-           users => {
-               description => "List of users.",
-               type => 'string',  format => 'pve-userid-list',
-               optional => 1,
-           },
-           groups => {
-               description => "List of groups.",
-               type => 'string', format => 'pve-groupid-list',
-               optional => 1,
-           },
-           tokens => {
-               description => "List of API tokens.",
-               type => 'string', format => 'pve-tokenid-list',
-               optional => 1,
-           },
-           roles => {
-               description => "List of roles.",
-               type => 'string', format => 'pve-roleid-list',
-           },
-           delete => {
-               description => "Remove permissions (instead of adding it).",
-               type => 'boolean',
-               optional => 1,
-           },
-       },
-    },
-    returns => { type => 'null' },
-    code => sub {
-       my ($param) = @_;
-
-       if (!($param->{users} || $param->{groups} || $param->{tokens})) {
-           raise_param_exc({ map { $_ => "either 'users', 'groups' or 'tokens' is required." } qw(users groups tokens) });
-       }
-
-       my $path = PVE::AccessControl::normalize_path($param->{path});
-       raise_param_exc({ path => "invalid ACL path '$param->{path}'" }) if !$path;
-
-       if (!$param->{delete} && !PVE::AccessControl::check_path($path)) {
-           raise_param_exc({ path => "invalid ACL path '$param->{path}'" });
-       }
-
-       PVE::AccessControl::lock_user_config(
-           sub {
-
-               my $cfg = cfs_read_file("user.cfg");
-
-               my $propagate = 1;
-
-               if (defined($param->{propagate})) {
-                   $propagate = $param->{propagate} ? 1 : 0;
-               }
-
-               foreach my $role (split_list($param->{roles})) {
-                   die "role '$role' does not exist\n"
-                       if !$cfg->{roles}->{$role};
-
-                   foreach my $group (split_list($param->{groups})) {
-
-                       die "group '$group' does not exist\n"
-                           if !$cfg->{groups}->{$group};
-
-                       if ($param->{delete}) {
-                           delete($cfg->{acl}->{$path}->{groups}->{$group}->{$role});
-                       } else {
-                           $cfg->{acl}->{$path}->{groups}->{$group}->{$role} = $propagate;
-                       }
-                   }
-
-                   foreach my $userid (split_list($param->{users})) {
-                       my $username = PVE::AccessControl::verify_username($userid);
-
-                       die "user '$username' does not exist\n"
-                           if !$cfg->{users}->{$username};
-
-                       if ($param->{delete}) {
-                           delete($cfg->{acl}->{$path}->{users}->{$username}->{$role});
-                       } else {
-                           $cfg->{acl}->{$path}->{users}->{$username}->{$role} = $propagate;
-                       }
-                   }
-
-                   foreach my $tokenid (split_list($param->{tokens})) {
-                       my ($username, $token) = PVE::AccessControl::split_tokenid($tokenid);
-                       PVE::AccessControl::check_token_exist($cfg, $username, $token);
-
-                       if ($param->{delete}) {
-                           delete $cfg->{acl}->{$path}->{tokens}->{$tokenid}->{$role};
-                       } else {
-                           $cfg->{acl}->{$path}->{tokens}->{$tokenid}->{$role} = $propagate;
-                       }
-                   }
-               }
-
-               cfs_write_file("user.cfg", $cfg);
-           }, "ACL update failed");
-
-       return undef;
-    }});
-
-1;
diff --git a/PVE/API2/AccessControl.pm b/PVE/API2/AccessControl.pm
deleted file mode 100644 (file)
index a77694b..0000000
+++ /dev/null
@@ -1,722 +0,0 @@
-package PVE::API2::AccessControl;
-
-use strict;
-use warnings;
-
-use JSON;
-use MIME::Base64;
-
-use PVE::Exception qw(raise raise_perm_exc raise_param_exc);
-use PVE::SafeSyslog;
-use PVE::RPCEnvironment;
-use PVE::Cluster qw(cfs_read_file);
-use PVE::DataCenterConfig;
-use PVE::RESTHandler;
-use PVE::AccessControl;
-use PVE::JSONSchema qw(get_standard_option);
-use PVE::API2::Domains;
-use PVE::API2::User;
-use PVE::API2::Group;
-use PVE::API2::Role;
-use PVE::API2::ACL;
-use PVE::Auth::Plugin;
-use PVE::OTP;
-use PVE::Tools;
-
-my $u2f_available = 0;
-eval {
-    require PVE::U2F;
-    $u2f_available = 1;
-};
-
-use base qw(PVE::RESTHandler);
-
-__PACKAGE__->register_method ({
-    subclass => "PVE::API2::User",
-    path => 'users',
-});
-
-__PACKAGE__->register_method ({
-    subclass => "PVE::API2::Group",
-    path => 'groups',
-});
-
-__PACKAGE__->register_method ({
-    subclass => "PVE::API2::Role",
-    path => 'roles',
-});
-
-__PACKAGE__->register_method ({
-    subclass => "PVE::API2::ACL",
-    path => 'acl',
-});
-
-__PACKAGE__->register_method ({
-    subclass => "PVE::API2::Domains",
-    path => 'domains',
-});
-
-__PACKAGE__->register_method ({
-    name => 'index',
-    path => '',
-    method => 'GET',
-    description => "Directory index.",
-    permissions => {
-       user => 'all',
-    },
-    parameters => {
-       additionalProperties => 0,
-       properties => {},
-    },
-    returns => {
-       type => 'array',
-       items => {
-           type => "object",
-           properties => {
-               subdir => { type => 'string' },
-           },
-       },
-       links => [ { rel => 'child', href => "{subdir}" } ],
-    },
-    code => sub {
-       my ($param) = @_;
-
-       my $res = [];
-
-       my $ma = __PACKAGE__->method_attributes();
-
-       foreach my $info (@$ma) {
-           next if !$info->{subclass};
-
-           my $subpath = $info->{match_re}->[0];
-
-           push @$res, { subdir => $subpath };
-       }
-
-       push @$res, { subdir => 'ticket' };
-       push @$res, { subdir => 'password' };
-
-       return $res;
-    }});
-
-
-my $verify_auth = sub {
-    my ($rpcenv, $username, $pw_or_ticket, $otp, $path, $privs) = @_;
-
-    my $normpath = PVE::AccessControl::normalize_path($path);
-
-    my $ticketuser;
-    if (($ticketuser = PVE::AccessControl::verify_ticket($pw_or_ticket, 1)) &&
-       ($ticketuser eq $username)) {
-       # valid ticket
-    } elsif (PVE::AccessControl::verify_vnc_ticket($pw_or_ticket, $username, $normpath, 1)) {
-       # valid vnc ticket
-    } else {
-       $username = PVE::AccessControl::authenticate_user($username, $pw_or_ticket, $otp);
-    }
-
-    my $privlist = [ PVE::Tools::split_list($privs) ];
-    if (!($normpath && scalar(@$privlist) && $rpcenv->check($username, $normpath, $privlist))) {
-       die "no permission ($path, $privs)\n";
-    }
-
-    return { username => $username };
-};
-
-my $create_ticket = sub {
-    my ($rpcenv, $username, $pw_or_ticket, $otp) = @_;
-
-    my ($ticketuser, undef, $tfa_info) = PVE::AccessControl::verify_ticket($pw_or_ticket, 1);
-    if (defined($ticketuser) && ($ticketuser eq 'root@pam' || $ticketuser eq $username)) {
-       if (defined($tfa_info)) {
-           die "incomplete ticket\n";
-       }
-       # valid ticket. Note: root@pam can create tickets for other users
-    } else {
-       ($username, $tfa_info) = PVE::AccessControl::authenticate_user($username, $pw_or_ticket, $otp);
-    }
-
-    my %extra;
-    my $ticket_data = $username;
-    if (defined($tfa_info)) {
-       $extra{NeedTFA} = 1;
-       if ($tfa_info->{type} eq 'u2f') {
-           my $u2finfo = $tfa_info->{data};
-           my $u2f = get_u2f_instance($rpcenv, $u2finfo->@{qw(publicKey keyHandle)});
-           my $challenge = $u2f->auth_challenge()
-               or die "failed to get u2f challenge\n";
-           $challenge = decode_json($challenge);
-           $extra{U2FChallenge} = $challenge;
-           $ticket_data = "u2f!$username!$challenge->{challenge}";
-       } else {
-           # General half-login / 'missing 2nd factor' ticket:
-           $ticket_data = "tfa!$username";
-       }
-    }
-
-    my $ticket = PVE::AccessControl::assemble_ticket($ticket_data);
-    my $csrftoken = PVE::AccessControl::assemble_csrf_prevention_token($username);
-
-    return {
-       ticket => $ticket,
-       username => $username,
-       CSRFPreventionToken => $csrftoken,
-       %extra,
-    };
-};
-
-my $compute_api_permission = sub {
-    my ($rpcenv, $authuser) = @_;
-
-    my $usercfg = $rpcenv->{user_cfg};
-
-    my $res = {};
-    my $priv_re_map = {
-       vms => qr/VM\.|Permissions\.Modify/,
-       access => qr/(User|Group)\.|Permissions\.Modify/,
-       storage => qr/Datastore\.|Permissions\.Modify/,
-       nodes => qr/Sys\.|Permissions\.Modify/,
-       sdn => qr/SDN\.|Permissions\.Modify/,
-       dc => qr/Sys\.Audit|SDN\./,
-    };
-    map { $res->{$_} = {} } keys %$priv_re_map;
-
-    my $required_paths = ['/', '/nodes', '/access/groups', '/vms', '/storage', '/sdn'];
-
-    my $checked_paths = {};
-    foreach my $path (@$required_paths, keys %{$usercfg->{acl}}) {
-       next if $checked_paths->{$path};
-       $checked_paths->{$path} = 1;
-
-       my $path_perm = $rpcenv->permissions($authuser, $path);
-
-       my $toplevel = ($path =~ /^\/(\w+)/) ? $1 : 'dc';
-       if ($toplevel eq 'pool') {
-           foreach my $priv (keys %$path_perm) {
-               if ($priv =~ m/^VM\./) {
-                   $res->{vms}->{$priv} = 1;
-               } elsif ($priv =~ m/^Datastore\./) {
-                   $res->{storage}->{$priv} = 1;
-               } elsif ($priv eq 'Permissions.Modify') {
-                   $res->{storage}->{$priv} = 1;
-                   $res->{vms}->{$priv} = 1;
-               }
-           }
-       } else {
-           my $priv_regex = $priv_re_map->{$toplevel} // next;
-           foreach my $priv (keys %$path_perm) {
-               next if $priv !~ m/^($priv_regex)/;
-               $res->{$toplevel}->{$priv} = 1;
-           }
-       }
-    }
-
-    return $res;
-};
-
-__PACKAGE__->register_method ({
-    name => 'get_ticket',
-    path => 'ticket',
-    method => 'GET',
-    permissions => { user => 'world' },
-    description => "Dummy. Useful for formatters which want to provide a login page.",
-    parameters => {
-       additionalProperties => 0,
-    },
-    returns => { type => "null" },
-    code => sub { return undef; }});
-
-__PACKAGE__->register_method ({
-    name => 'create_ticket',
-    path => 'ticket',
-    method => 'POST',
-    permissions => {
-       description => "You need to pass valid credientials.",
-       user => 'world'
-    },
-    protected => 1, # else we can't access shadow files
-    allowtoken => 0, # we don't want tokens to create tickets
-    description => "Create or verify authentication ticket.",
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           username => {
-               description => "User name",
-               type => 'string',
-               maxLength => 64,
-               completion => \&PVE::AccessControl::complete_username,
-           },
-           realm =>  get_standard_option('realm', {
-               description => "You can optionally pass the realm using this parameter. Normally the realm is simply added to the username <username>\@<relam>.",
-               optional => 1,
-               completion => \&PVE::AccessControl::complete_realm,
-           }),
-           password => {
-               description => "The secret password. This can also be a valid ticket.",
-               type => 'string',
-           },
-           otp => {
-               description => "One-time password for Two-factor authentication.",
-               type => 'string',
-               optional => 1,
-           },
-           path => {
-               description => "Verify ticket, and check if user have access 'privs' on 'path'",
-               type => 'string',
-               requires => 'privs',
-               optional => 1,
-               maxLength => 64,
-           },
-           privs => {
-               description => "Verify ticket, and check if user have access 'privs' on 'path'",
-               type => 'string' , format => 'pve-priv-list',
-               requires => 'path',
-               optional => 1,
-               maxLength => 64,
-           },
-       }
-    },
-    returns => {
-       type => "object",
-       properties => {
-           username => { type => 'string' },
-           ticket => { type => 'string', optional => 1},
-           CSRFPreventionToken => { type => 'string', optional => 1 },
-           clustername => { type => 'string', optional => 1 },
-           # cap => computed api permissions, unless there's a u2f challenge
-       }
-    },
-    code => sub {
-       my ($param) = @_;
-
-       my $username = $param->{username};
-       $username .= "\@$param->{realm}" if $param->{realm};
-
-       $username = PVE::AccessControl::lookup_username($username);
-       my $rpcenv = PVE::RPCEnvironment::get();
-
-       my $res;
-       eval {
-           # test if user exists and is enabled
-           $rpcenv->check_user_enabled($username);
-
-           if ($param->{path} && $param->{privs}) {
-               $res = &$verify_auth($rpcenv, $username, $param->{password}, $param->{otp},
-                                    $param->{path}, $param->{privs});
-           } else {
-               $res = &$create_ticket($rpcenv, $username, $param->{password}, $param->{otp});
-           }
-       };
-       if (my $err = $@) {
-           my $clientip = $rpcenv->get_client_ip() || '';
-           syslog('err', "authentication failure; rhost=$clientip user=$username msg=$err");
-           # do not return any info to prevent user enumeration attacks
-           die PVE::Exception->new("authentication failure\n", code => 401);
-       }
-
-       $res->{cap} = &$compute_api_permission($rpcenv, $username)
-           if !defined($res->{NeedTFA});
-
-       my $clinfo = PVE::Cluster::get_clinfo();
-       if ($clinfo->{cluster}->{name} && $rpcenv->check($username, '/', ['Sys.Audit'], 1)) {
-           $res->{clustername} = $clinfo->{cluster}->{name};
-       }
-
-       PVE::Cluster::log_msg('info', 'root@pam', "successful auth for user '$username'");
-
-       return $res;
-    }});
-
-__PACKAGE__->register_method ({
-    name => 'change_password',
-    path => 'password',
-    method => 'PUT',
-    permissions => {
-       description => "Each user is allowed to change his own password. A user can change the password of another user if he has 'Realm.AllocateUser' (on the realm of user <userid>) and 'User.Modify' permission on /access/groups/<group> on a group where user <userid> is member of.",
-       check => [ 'or',
-                  ['userid-param', 'self'],
-                  [ 'and',
-                    [ 'userid-param', 'Realm.AllocateUser'],
-                    [ 'userid-group', ['User.Modify']]
-                  ]
-           ],
-    },
-    protected => 1, # else we can't access shadow files
-    allowtoken => 0, # we don't want tokens to change the regular user password
-    description => "Change user password.",
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           userid => get_standard_option('userid-completed'),
-           password => {
-               description => "The new password.",
-               type => 'string',
-               minLength => 5,
-               maxLength => 64,
-           },
-       }
-    },
-    returns => { type => "null" },
-    code => sub {
-       my ($param) = @_;
-
-       my $rpcenv = PVE::RPCEnvironment::get();
-       my $authuser = $rpcenv->get_user();
-
-       my ($userid, $ruid, $realm) = PVE::AccessControl::verify_username($param->{userid});
-
-       $rpcenv->check_user_exist($userid);
-
-       if ($authuser eq 'root@pam') {
-           # OK - root can change anything
-       } else {
-           if ($authuser eq $userid) {
-               $rpcenv->check_user_enabled($userid);
-               # OK - each user can change its own password
-           } else {
-               # only root may change root password
-               raise_perm_exc() if $userid eq 'root@pam';
-               # do not allow to change system user passwords
-               raise_perm_exc() if $realm eq 'pam';
-           }
-       }
-
-       PVE::AccessControl::domain_set_password($realm, $ruid, $param->{password});
-
-       PVE::Cluster::log_msg('info', 'root@pam', "changed password for user '$userid'");
-
-       return undef;
-    }});
-
-sub get_u2f_config() {
-    die "u2f support not available\n" if !$u2f_available;
-
-    my $dc = cfs_read_file('datacenter.cfg');
-    my $u2f = $dc->{u2f};
-    die "u2f not configured in datacenter.cfg\n" if !$u2f;
-    return $u2f;
-}
-
-sub get_u2f_instance {
-    my ($rpcenv, $publicKey, $keyHandle) = @_;
-
-    # We store the public key base64 encoded (as the api provides it in binary)
-    $publicKey = decode_base64($publicKey) if defined($publicKey);
-
-    my $u2fconfig = get_u2f_config();
-    my $u2f = PVE::U2F->new();
-
-    # via the 'Host' header (in case a node has multiple hosts available).
-    my $origin = $u2fconfig->{origin};
-    if (!defined($origin)) {
-       $origin = $rpcenv->get_request_host(1);
-       if ($origin) {
-           $origin = "https://$origin";
-       } else {
-           die "failed to figure out u2f origin\n";
-       }
-    }
-
-    my $appid = $u2fconfig->{appid} // $origin;
-    $u2f->set_appid($appid);
-    $u2f->set_origin($origin);
-    $u2f->set_publicKey($publicKey) if defined($publicKey);
-    $u2f->set_keyHandle($keyHandle) if defined($keyHandle);
-    return $u2f;
-}
-
-sub verify_user_tfa_config {
-    my ($type, $tfa_cfg, $value) = @_;
-
-    if (!defined($type)) {
-       die "missing tfa 'type'\n";
-    }
-
-    if ($type ne 'oath') {
-       die "invalid type for custom tfa authentication\n";
-    }
-
-    my $secret = $tfa_cfg->{keys}
-       or die "missing TOTP secret\n";
-    $tfa_cfg = $tfa_cfg->{config};
-    # Copy the hash to verify that we have no unexpected keys without modifying the original hash.
-    $tfa_cfg = {%$tfa_cfg};
-
-    # We can only verify 1 secret but oath_verify_otp allows multiple:
-    if (scalar(PVE::Tools::split_list($secret)) != 1) {
-       die "only exactly one secret key allowed\n";
-    }
-
-    my $digits = delete($tfa_cfg->{digits}) // 6;
-    my $step = delete($tfa_cfg->{step}) // 30;
-    # Maybe also this?
-    #     my $algorithm = delete($tfa_cfg->{algorithm}) // 'sha1';
-
-    if (length(my $more = join(', ', keys %$tfa_cfg))) {
-       die "unexpected tfa config keys: $more\n";
-    }
-
-    PVE::OTP::oath_verify_otp($value, $secret, $step, $digits);
-}
-
-__PACKAGE__->register_method ({
-    name => 'change_tfa',
-    path => 'tfa',
-    method => 'PUT',
-    permissions => {
-       description => 'A user can change their own u2f or totp token.',
-       check => [ 'or',
-                  ['userid-param', 'self'],
-                  [ 'and',
-                    [ 'userid-param', 'Realm.AllocateUser'],
-                    [ 'userid-group', ['User.Modify']]
-                  ]
-           ],
-    },
-    protected => 1, # else we can't access shadow files
-    allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
-    description => "Change user u2f authentication.",
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           userid => get_standard_option('userid', {
-               completion => \&PVE::AccessControl::complete_username,
-           }),
-           password => {
-               optional => 1, # Only required if not root@pam
-               description => "The current password.",
-               type => 'string',
-               minLength => 5,
-               maxLength => 64,
-           },
-           action => {
-               description => 'The action to perform',
-               type => 'string',
-               enum => [qw(delete new confirm)],
-           },
-           response => {
-               optional => 1,
-               description =>
-                   'Either the the response to the current u2f registration challenge,'
-                   .' or, when adding TOTP, the currently valid TOTP value.',
-               type => 'string',
-           },
-           key => {
-               optional => 1,
-               description => 'When adding TOTP, the shared secret value.',
-               type => 'string',
-               format => 'pve-tfa-secret',
-           },
-           config => {
-               optional => 1,
-               description => 'A TFA configuration. This must currently be of type TOTP of not set at all.',
-               type => 'string',
-               format => 'pve-tfa-config',
-               maxLength => 128,
-           },
-       }
-    },
-    returns => { type => 'object' },
-    code => sub {
-       my ($param) = @_;
-
-       my $rpcenv = PVE::RPCEnvironment::get();
-       my $authuser = $rpcenv->get_user();
-
-       my $action = delete $param->{action};
-       my $response = delete $param->{response};
-       my $password = delete($param->{password}) // '';
-       my $key = delete($param->{key});
-       my $config = delete($param->{config});
-
-       my ($userid, $ruid, $realm) = PVE::AccessControl::verify_username($param->{userid});
-       $rpcenv->check_user_exist($userid);
-
-       # Only root may modify root
-       raise_perm_exc() if $userid eq 'root@pam' && $authuser ne 'root@pam';
-
-       # Regular users need to confirm their password to change u2f settings.
-       if ($authuser ne 'root@pam') {
-           raise_param_exc({ 'password' => 'password is required to modify u2f data' })
-               if !defined($password);
-           my $domain_cfg = cfs_read_file('domains.cfg');
-           my $cfg = $domain_cfg->{ids}->{$realm};
-           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);
-       }
-
-       if ($action eq 'delete') {
-           PVE::AccessControl::user_set_tfa($userid, $realm, undef, undef);
-           PVE::Cluster::log_msg('info', $authuser, "deleted u2f data for user '$userid'");
-       } elsif ($action eq 'new') {
-           if (defined($config)) {
-               $config = PVE::Auth::Plugin::parse_tfa_config($config);
-               my $type = delete($config->{type});
-               my $tfa_cfg = {
-                   keys => $key,
-                   config => $config,
-               };
-               verify_user_tfa_config($type, $tfa_cfg, $response);
-               PVE::AccessControl::user_set_tfa($userid, $realm, $type, $tfa_cfg);
-           } else {
-               # The default is U2F:
-               my $u2f = get_u2f_instance($rpcenv);
-               my $challenge = $u2f->registration_challenge()
-                   or raise("failed to get u2f challenge");
-               $challenge = decode_json($challenge);
-               PVE::AccessControl::user_set_tfa($userid, $realm, 'u2f', $challenge);
-               return $challenge;
-           }
-       } elsif ($action eq 'confirm') {
-           raise_param_exc({ 'response' => "confirm action requires the 'response' parameter to be set" })
-               if !defined($response);
-
-           my ($type, $u2fdata) = PVE::AccessControl::user_get_tfa($userid, $realm);
-           raise("no u2f data available")
-               if (!defined($type) || $type ne 'u2f');
-
-           my $challenge = $u2fdata->{challenge}
-               or raise("no active challenge");
-
-           my $u2f = get_u2f_instance($rpcenv);
-           $u2f->set_challenge($challenge);
-           my ($keyHandle, $publicKey) = $u2f->registration_verify($response);
-           PVE::AccessControl::user_set_tfa($userid, $realm, 'u2f', {
-               keyHandle => $keyHandle,
-               publicKey => $publicKey, # already base64 encoded
-           });
-       } else {
-           die "invalid action: $action\n";
-       }
-
-       return {};
-    }});
-
-__PACKAGE__->register_method({
-    name => 'verify_tfa',
-    path => 'tfa',
-    method => 'POST',
-    permissions => { user => 'all' },
-    protected => 1, # else we can't access shadow files
-    allowtoken => 0, # we don't want tokens to access TFA information
-    description => 'Finish a u2f challenge.',
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           response => {
-               type => 'string',
-               description => 'The response to the current authentication challenge.',
-           },
-       }
-    },
-    returns => {
-       type => 'object',
-       properties => {
-           ticket => { type => 'string' },
-           # cap
-       }
-    },
-    code => sub {
-       my ($param) = @_;
-
-       my $rpcenv = PVE::RPCEnvironment::get();
-       my $authuser = $rpcenv->get_user();
-       my ($username, undef, $realm) = PVE::AccessControl::verify_username($authuser);
-
-       my ($tfa_type, $tfa_data) = PVE::AccessControl::user_get_tfa($username, $realm);
-       if (!defined($tfa_type)) {
-           raise('no u2f data available');
-       }
-
-       eval {
-           if ($tfa_type eq 'u2f') {
-               my $challenge = $rpcenv->get_u2f_challenge()
-                  or raise('no active challenge');
-
-               my $keyHandle = $tfa_data->{keyHandle};
-               my $publicKey = $tfa_data->{publicKey};
-               raise("incomplete u2f setup")
-                   if !defined($keyHandle) || !defined($publicKey);
-
-               my $u2f = get_u2f_instance($rpcenv, $publicKey, $keyHandle);
-               $u2f->set_challenge($challenge);
-
-               my ($counter, $present) = $u2f->auth_verify($param->{response});
-               # Do we want to do anything with these?
-           } else {
-               # sanity check before handing off to the verification code:
-               my $keys = $tfa_data->{keys} or die "missing tfa keys\n";
-               my $config = $tfa_data->{config} or die "bad tfa entry\n";
-               PVE::AccessControl::verify_one_time_pw($tfa_type, $authuser, $keys, $config, $param->{response});
-           }
-       };
-       if (my $err = $@) {
-           my $clientip = $rpcenv->get_client_ip() || '';
-           syslog('err', "authentication verification failure; rhost=$clientip user=$authuser msg=$err");
-           die PVE::Exception->new("authentication failure\n", code => 401);
-       }
-
-       return {
-           ticket => PVE::AccessControl::assemble_ticket($authuser),
-           cap => &$compute_api_permission($rpcenv, $authuser),
-       }
-    }});
-
-__PACKAGE__->register_method({
-    name => 'permissions',
-    path => 'permissions',
-    method => 'GET',
-    description => 'Retrieve effective permissions of given user/token.',
-    permissions => {
-       description => "Each user/token is allowed to dump their own permissions. A user can dump the permissions of another user if they have 'Sys.Audit' permission on /access.",
-       user => 'all',
-    },
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           userid => {
-               type => 'string',
-               description => "User ID or full API token ID",
-               pattern => $PVE::AccessControl::userid_or_token_regex,
-               optional => 1,
-           },
-           path => get_standard_option('acl-path', {
-               description => "Only dump this specific path, not the whole tree.",
-               optional => 1,
-           }),
-       },
-    },
-    returns => {
-       type => 'object',
-       description => 'Map of "path" => (Map of "privilege" => "propagate boolean").',
-    },
-    code => sub {
-       my ($param) = @_;
-
-       my $rpcenv = PVE::RPCEnvironment::get();
-
-       my $userid = $param->{userid};
-       if (defined($userid)) {
-           $rpcenv->check($rpcenv->get_user(), '/access', ['Sys.Audit']);
-       } else {
-           $userid = $rpcenv->get_user();
-       }
-
-       my $res;
-
-       if (my $path = $param->{path}) {
-           my $perms = $rpcenv->permissions($userid, $path);
-           if ($perms) {
-               $res = { $path => $perms };
-           } else {
-               $res = {};
-           }
-       } else {
-           $res = $rpcenv->get_effective_permissions($userid);
-       }
-
-       return $res;
-    }});
-
-1;
diff --git a/PVE/API2/Domains.pm b/PVE/API2/Domains.pm
deleted file mode 100644 (file)
index 9c2b254..0000000
+++ /dev/null
@@ -1,484 +0,0 @@
-package PVE::API2::Domains;
-
-use strict;
-use warnings;
-
-use PVE::Exception qw(raise_param_exc);
-use PVE::Tools qw(extract_param);
-use PVE::Cluster qw (cfs_read_file cfs_write_file);
-use PVE::AccessControl;
-use PVE::JSONSchema qw(get_standard_option);
-
-use PVE::SafeSyslog;
-use PVE::RESTHandler;
-use PVE::Auth::Plugin;
-
-my $domainconfigfile = "domains.cfg";
-
-use base qw(PVE::RESTHandler);
-
-__PACKAGE__->register_method ({
-    name => 'index',
-    path => '',
-    method => 'GET',
-    description => "Authentication domain index.",
-    permissions => {
-       description => "Anyone can access that, because we need that list for the login box (before the user is authenticated).",
-       user => 'world',
-    },
-    parameters => {
-       additionalProperties => 0,
-       properties => {},
-    },
-    returns => {
-       type => 'array',
-       items => {
-           type => "object",
-           properties => {
-               realm => { type => 'string' },
-               type => { type => 'string' },
-               tfa => {
-                   description => "Two-factor authentication provider.",
-                   type => 'string',
-                   enum => [ 'yubico', 'oath' ],
-                   optional => 1,
-               },
-               comment => {
-                   description => "A comment. The GUI use this text when you select a domain (Realm) on the login window.",
-                   type => 'string',
-                   optional => 1,
-               },
-           },
-       },
-       links => [ { rel => 'child', href => "{realm}" } ],
-    },
-    code => sub {
-       my ($param) = @_;
-
-       my $res = [];
-
-       my $cfg = cfs_read_file($domainconfigfile);
-       my $ids = $cfg->{ids};
-
-       foreach my $realm (keys %$ids) {
-           my $d = $ids->{$realm};
-           my $entry = { realm => $realm, type => $d->{type} };
-           $entry->{comment} = $d->{comment} if $d->{comment};
-           $entry->{default} = 1 if $d->{default};
-           if ($d->{tfa} && (my $tfa_cfg = PVE::Auth::Plugin::parse_tfa_config($d->{tfa}))) {
-               $entry->{tfa} = $tfa_cfg->{type};
-           }
-           push @$res, $entry;
-       }
-
-       return $res;
-    }});
-
-__PACKAGE__->register_method ({
-    name => 'create',
-    protected => 1,
-    path => '',
-    method => 'POST',
-    permissions => {
-       check => ['perm', '/access/realm', ['Realm.Allocate']],
-    },
-    description => "Add an authentication server.",
-    parameters => PVE::Auth::Plugin->createSchema(),
-    returns => { type => 'null' },
-    code => sub {
-       my ($param) = @_;
-
-       # always extract, add it with hook
-       my $password = extract_param($param, 'password');
-
-       PVE::Auth::Plugin::lock_domain_config(
-           sub {
-
-               my $cfg = cfs_read_file($domainconfigfile);
-               my $ids = $cfg->{ids};
-
-               my $realm = extract_param($param, 'realm');
-               my $type = $param->{type};
-
-               die "domain '$realm' already exists\n"
-                   if $ids->{$realm};
-
-               die "unable to use reserved name '$realm'\n"
-                   if ($realm eq 'pam' || $realm eq 'pve');
-
-               die "unable to create builtin type '$type'\n"
-                   if ($type eq 'pam' || $type eq 'pve');
-
-               my $plugin = PVE::Auth::Plugin->lookup($type);
-               my $config = $plugin->check_config($realm, $param, 1, 1);
-
-               if ($config->{default}) {
-                   foreach my $r (keys %$ids) {
-                       delete $ids->{$r}->{default};
-                   }
-               }
-
-               $ids->{$realm} = $config;
-
-               my $opts = $plugin->options();
-               if (defined($password) && !defined($opts->{password})) {
-                   $password = undef;
-                   warn "ignoring password parameter";
-               }
-               $plugin->on_add_hook($realm, $config, password => $password);
-
-               cfs_write_file($domainconfigfile, $cfg);
-           }, "add auth server failed");
-
-       return undef;
-    }});
-
-__PACKAGE__->register_method ({
-    name => 'update',
-    path => '{realm}',
-    method => 'PUT',
-    permissions => {
-       check => ['perm', '/access/realm', ['Realm.Allocate']],
-    },
-    description => "Update authentication server settings.",
-    protected => 1,
-    parameters => PVE::Auth::Plugin->updateSchema(),
-    returns => { type => 'null' },
-    code => sub {
-       my ($param) = @_;
-
-       # always extract, update in hook
-       my $password = extract_param($param, 'password');
-
-       PVE::Auth::Plugin::lock_domain_config(
-           sub {
-
-               my $cfg = cfs_read_file($domainconfigfile);
-               my $ids = $cfg->{ids};
-
-               my $digest = extract_param($param, 'digest');
-               PVE::SectionConfig::assert_if_modified($cfg, $digest);
-
-               my $realm = extract_param($param, 'realm');
-
-               die "domain '$realm' does not exist\n"
-                   if !$ids->{$realm};
-
-               my $delete_str = extract_param($param, 'delete');
-               die "no options specified\n" if !$delete_str && !scalar(keys %$param);
-
-               my $delete_pw = 0;
-               foreach my $opt (PVE::Tools::split_list($delete_str)) {
-                   delete $ids->{$realm}->{$opt};
-                   $delete_pw = 1 if $opt eq 'password';
-               }
-
-               my $plugin = PVE::Auth::Plugin->lookup($ids->{$realm}->{type});
-               my $config = $plugin->check_config($realm, $param, 0, 1);
-
-               if ($config->{default}) {
-                   foreach my $r (keys %$ids) {
-                       delete $ids->{$r}->{default};
-                   }
-               }
-
-               foreach my $p (keys %$config) {
-                   $ids->{$realm}->{$p} = $config->{$p};
-               }
-
-               my $opts = $plugin->options();
-               if ($delete_pw || defined($password)) {
-                   $plugin->on_update_hook($realm, $config, password => $password);
-               } else {
-                   $plugin->on_update_hook($realm, $config);
-               }
-
-               cfs_write_file($domainconfigfile, $cfg);
-           }, "update auth server failed");
-
-       return undef;
-    }});
-
-# fixme: return format!
-__PACKAGE__->register_method ({
-    name => 'read',
-    path => '{realm}',
-    method => 'GET',
-    description => "Get auth server configuration.",
-    permissions => {
-       check => ['perm', '/access/realm', ['Realm.Allocate', 'Sys.Audit'], any => 1],
-    },
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           realm =>  get_standard_option('realm'),
-       },
-    },
-    returns => {},
-    code => sub {
-       my ($param) = @_;
-
-       my $cfg = cfs_read_file($domainconfigfile);
-
-       my $realm = $param->{realm};
-
-       my $data = $cfg->{ids}->{$realm};
-       die "domain '$realm' does not exist\n" if !$data;
-
-       $data->{digest} = $cfg->{digest};
-
-       return $data;
-    }});
-
-
-__PACKAGE__->register_method ({
-    name => 'delete',
-    path => '{realm}',
-    method => 'DELETE',
-    permissions => {
-       check => ['perm', '/access/realm', ['Realm.Allocate']],
-    },
-    description => "Delete an authentication server.",
-    protected => 1,
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           realm =>  get_standard_option('realm'),
-       }
-    },
-    returns => { type => 'null' },
-    code => sub {
-       my ($param) = @_;
-
-       PVE::Auth::Plugin::lock_domain_config(
-           sub {
-
-               my $cfg = cfs_read_file($domainconfigfile);
-               my $ids = $cfg->{ids};
-               my $realm = $param->{realm};
-
-               die "authentication domain '$realm' does not exist\n" if !$ids->{$realm};
-
-               my $plugin = PVE::Auth::Plugin->lookup($ids->{$realm}->{type});
-
-               $plugin->on_delete_hook($realm, $ids->{$realm});
-
-               delete $ids->{$realm};
-
-               cfs_write_file($domainconfigfile, $cfg);
-           }, "delete auth server failed");
-
-       return undef;
-    }});
-
-my $update_users = sub {
-    my ($usercfg, $realm, $synced_users, $opts) = @_;
-
-    print "syncing users\n";
-    $usercfg->{users} = {} if !defined($usercfg->{users});
-    my $users = $usercfg->{users};
-
-    my $oldusers = {};
-    if ($opts->{'full'}) {
-       print "full sync, deleting outdated existing users first\n";
-       foreach my $userid (sort keys %$users) {
-           next if $userid !~ m/\@$realm$/;
-
-           $oldusers->{$userid} = delete $users->{$userid};
-           if ($opts->{'purge'} && !$synced_users->{$userid}) {
-               PVE::AccessControl::delete_user_acl($userid, $usercfg);
-               print "purged user '$userid' and all its ACL entries\n";
-           } elsif (!defined($synced_users->{$userid})) {
-               print "remove user '$userid'\n";
-           }
-       }
-    }
-
-    foreach my $userid (sort keys %$synced_users) {
-       my $synced_user = $synced_users->{$userid} // {};
-       if (!defined($users->{$userid})) {
-           my $user = $users->{$userid} = $synced_user;
-
-           my $olduser = $oldusers->{$userid} // {};
-           if (defined(my $enabled = $olduser->{enable})) {
-               $user->{enable} = $enabled;
-           } elsif ($opts->{'enable-new'}) {
-               $user->{enable} = 1;
-           }
-
-           if (defined($olduser->{tokens})) {
-               $user->{tokens} = $olduser->{tokens};
-           }
-           if (defined($oldusers->{$userid})) {
-               print "updated user '$userid'\n";
-           } else {
-               print "added user '$userid'\n";
-           }
-       } else {
-           my $olduser = $users->{$userid};
-           foreach my $attr (keys %$synced_user) {
-               $olduser->{$attr} = $synced_user->{$attr};
-           }
-           print "updated user '$userid'\n";
-       }
-    }
-};
-
-my $update_groups = sub {
-    my ($usercfg, $realm, $synced_groups, $opts) = @_;
-
-    print "syncing groups\n";
-    $usercfg->{groups} = {} if !defined($usercfg->{groups});
-    my $groups = $usercfg->{groups};
-    my $oldgroups = {};
-
-    if ($opts->{full}) {
-       print "full sync, deleting outdated existing groups first\n";
-       foreach my $groupid (sort keys %$groups) {
-           next if $groupid !~ m/\-$realm$/;
-
-           my $oldgroups->{$groupid} = delete $groups->{$groupid};
-           if ($opts->{purge} && !$synced_groups->{$groupid}) {
-               print "purged group '$groupid' and all its ACL entries\n";
-               PVE::AccessControl::delete_group_acl($groupid, $usercfg)
-           } elsif (!defined($synced_groups->{$groupid})) {
-               print "removed group '$groupid'\n";
-           }
-       }
-    }
-
-    foreach my $groupid (sort keys %$synced_groups) {
-       my $synced_group = $synced_groups->{$groupid};
-       if (!defined($groups->{$groupid})) {
-           $groups->{$groupid} = $synced_group;
-           if (defined($oldgroups->{$groupid})) {
-               print "updated group '$groupid'\n";
-           } else {
-               print "added group '$groupid'\n";
-           }
-       } else {
-           my $group = $groups->{$groupid};
-           foreach my $attr (keys %$synced_group) {
-               $group->{$attr} = $synced_group->{$attr};
-           }
-           print "updated group '$groupid'\n";
-       }
-    }
-};
-
-my $parse_sync_opts = sub {
-    my ($param, $realmconfig) = @_;
-
-    my $sync_opts_fmt = PVE::JSONSchema::get_format('realm-sync-options');
-
-    my $cfg_defaults = {};
-    if (defined(my $cfg_opts = $realmconfig->{'sync-defaults-options'})) {
-       $cfg_defaults = PVE::JSONSchema::parse_property_string($sync_opts_fmt, $cfg_opts);
-    }
-
-    my $res = {};
-    for my $opt (sort keys %$sync_opts_fmt) {
-       my $fmt = $sync_opts_fmt->{$opt};
-
-       $res->{$opt} = $param->{$opt} // $cfg_defaults->{$opt} // $fmt->{default};
-
-       raise_param_exc({
-           "$opt" => 'Not passed as parameter and not defined in realm default sync options.'
-       }) if !defined($res->{$opt});
-    }
-    return $res;
-};
-
-__PACKAGE__->register_method ({
-    name => 'sync',
-    path => '{realm}/sync',
-    method => 'POST',
-    permissions => {
-       description => "'Realm.AllocateUser' on '/access/realm/<realm>' and "
-           ." 'User.Modify' permissions to '/access/groups/'.",
-       check => [ 'and',
-           [ 'userid-param', 'Realm.AllocateUser' ],
-           [ 'userid-group', ['User.Modify'] ],
-       ],
-    },
-    description => "Syncs users and/or groups from the configured LDAP to user.cfg."
-       ." NOTE: Synced groups will have the name 'name-\$realm', so make sure"
-       ." those groups do not exist to prevent overwriting.",
-    protected => 1,
-    parameters => {
-       additionalProperties => 0,
-       properties => get_standard_option('realm-sync-options', {
-           realm => get_standard_option('realm'),
-           'dry-run' => {
-               description => "If set, does not write anything.",
-               type => 'boolean',
-               optional => 1,
-               default => 0,
-           },
-       }),
-    },
-    returns => {
-       description => 'Worker Task-UPID',
-       type => 'string'
-    },
-    code => sub {
-       my ($param) = @_;
-
-       my $rpcenv = PVE::RPCEnvironment::get();
-       my $authuser = $rpcenv->get_user();
-
-       my $dry_run = extract_param($param, 'dry-run');
-       my $realm = $param->{realm};
-       my $cfg = cfs_read_file($domainconfigfile);
-       my $realmconfig = $cfg->{ids}->{$realm};
-
-       raise_param_exc({ 'realm' => 'Realm does not exist.' }) if !defined($realmconfig);
-       my $type = $realmconfig->{type};
-
-       if ($type ne 'ldap' && $type ne 'ad') {
-           die "Cannot sync realm type '$type'! Only LDAP/AD realms can be synced.\n";
-       }
-
-       my $opts = $parse_sync_opts->($param, $realmconfig); # can throw up
-
-       my $scope = $opts->{scope};
-       my $whatstring = $scope eq 'both' ? "users and groups" : $scope;
-
-       my $plugin = PVE::Auth::Plugin->lookup($type);
-
-       my $worker = sub {
-           print "(dry test run) " if $dry_run;
-           print "starting sync for realm $realm\n";
-
-           my ($synced_users, $dnmap) = $plugin->get_users($realmconfig, $realm);
-           my $synced_groups = {};
-           if ($scope eq 'groups' || $scope eq 'both') {
-               $synced_groups = $plugin->get_groups($realmconfig, $realm, $dnmap);
-           }
-
-           PVE::AccessControl::lock_user_config(sub {
-               my $usercfg = cfs_read_file("user.cfg");
-               print "got data from server, updating $whatstring\n";
-
-               if ($scope eq 'users' || $scope eq 'both') {
-                   $update_users->($usercfg, $realm, $synced_users, $opts);
-               }
-
-               if ($scope eq 'groups' || $scope eq 'both') {
-                   $update_groups->($usercfg, $realm, $synced_groups, $opts);
-               }
-
-               if ($dry_run) {
-                   print "\nNOTE: Dry test run, changes were NOT written to the configuration.\n";
-                   return;
-               }
-               cfs_write_file("user.cfg", $usercfg);
-               print "successfully updated $whatstring configuration\n";
-           }, "syncing $whatstring failed");
-       };
-
-       my $workerid = !$dry_run ? 'auth-realm-sync' : 'auth-realm-sync-test';
-       return $rpcenv->fork_worker($workerid, $realm, $authuser, $worker);
-    }});
-
-1;
diff --git a/PVE/API2/Group.pm b/PVE/API2/Group.pm
deleted file mode 100644 (file)
index c463bd6..0000000
+++ /dev/null
@@ -1,240 +0,0 @@
-package PVE::API2::Group;
-
-use strict;
-use warnings;
-use PVE::Cluster qw (cfs_read_file cfs_write_file);
-use PVE::AccessControl;
-use PVE::SafeSyslog;
-use PVE::RESTHandler;
-use PVE::JSONSchema qw(get_standard_option register_standard_option);
-
-use base qw(PVE::RESTHandler);
-
-register_standard_option('group-id', {
-    type => 'string',
-    format => 'pve-groupid',
-    completion => \&PVE::AccessControl::complete_group,
-});
-
-register_standard_option('group-comment', { type => 'string', optional => 1 });
-
-__PACKAGE__->register_method ({
-    name => 'index', 
-    path => '', 
-    method => 'GET',
-    description => "Group index.",
-    permissions => { 
-       description => "The returned list is restricted to groups where you have 'User.Modify', 'Sys.Audit'  or 'Group.Allocate' permissions on /access/groups/<group>.",
-       user => 'all',
-    },
-    parameters => {
-       additionalProperties => 0,
-       properties => {},
-    },
-    returns => {
-       type => 'array',
-       items => {
-           type => "object",
-           properties => {
-               groupid => get_standard_option('group-id'),
-               comment => get_standard_option('group-comment'),
-               users => {
-                   type => 'string',
-                   format => 'pve-userid-list',
-                   optional => 1,
-                   description => 'list of users which form this group',
-               },
-           },
-       },
-       links => [ { rel => 'child', href => "{groupid}" } ],
-    },
-    code => sub {
-       my ($param) = @_;
-    
-       my $res = [];
-
-       my $rpcenv = PVE::RPCEnvironment::get();
-       my $usercfg = cfs_read_file("user.cfg");
-       my $authuser = $rpcenv->get_user();
-
-       my $privs = [ 'User.Modify', 'Sys.Audit', 'Group.Allocate'];
-
-       foreach my $group (keys %{$usercfg->{groups}}) {
-           next if !$rpcenv->check_any($authuser, "/access/groups/$group", $privs, 1);
-           my $data = $usercfg->{groups}->{$group};
-           my $entry = { groupid => $group };
-           $entry->{comment} = $data->{comment} if defined($data->{comment});
-           $entry->{users} = join (',', sort keys %{$data->{users}}) if defined($data->{users});
-           push @$res, $entry;
-       }
-
-       return $res;
-    }});
-
-__PACKAGE__->register_method ({
-    name => 'create_group', 
-    protected => 1,
-    path => '', 
-    method => 'POST',
-    permissions => { 
-       check => ['perm', '/access/groups', ['Group.Allocate']],
-    },
-    description => "Create new group.",
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           groupid => get_standard_option('group-id'),
-           comment => get_standard_option('group-comment'),
-       },
-    },
-    returns => { type => 'null' },
-    code => sub {
-       my ($param) = @_;
-
-       PVE::AccessControl::lock_user_config(
-           sub {
-                       
-               my $usercfg = cfs_read_file("user.cfg");
-
-               my $group = $param->{groupid};
-       
-               die "group '$group' already exists\n" 
-                   if $usercfg->{groups}->{$group};
-
-               $usercfg->{groups}->{$group} = { users => {} };
-
-               $usercfg->{groups}->{$group}->{comment} = $param->{comment} if $param->{comment};
-
-               
-               cfs_write_file("user.cfg", $usercfg);
-           }, "create group failed");
-
-       return undef;
-    }});
-
-__PACKAGE__->register_method ({
-    name => 'update_group', 
-    protected => 1,
-    path => '{groupid}', 
-    method => 'PUT',
-    permissions => { 
-       check => ['perm', '/access/groups', ['Group.Allocate']],
-    },
-    description => "Update group data.",
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           groupid => get_standard_option('group-id'),
-           comment => get_standard_option('group-comment'),
-       },
-    },
-    returns => { type => 'null' },
-    code => sub {
-       my ($param) = @_;
-
-       PVE::AccessControl::lock_user_config(
-           sub {
-                       
-               my $usercfg = cfs_read_file("user.cfg");
-
-               my $group = $param->{groupid};
-       
-               my $data = $usercfg->{groups}->{$group};
-
-               die "group '$group' does not exist\n" 
-                   if !$data;
-
-               $data->{comment} = $param->{comment} if defined($param->{comment});
-               
-               cfs_write_file("user.cfg", $usercfg);
-           }, "update group failed");
-
-       return undef;
-    }});
-
-__PACKAGE__->register_method ({
-    name => 'read_group', 
-    path => '{groupid}', 
-    method => 'GET',
-    permissions => { 
-       check => ['perm', '/access/groups', ['Sys.Audit', 'Group.Allocate'], any => 1],
-   },
-    description => "Get group configuration.",
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           groupid => get_standard_option('group-id'),
-       },
-    },
-    returns => {
-       type => "object",
-       additionalProperties => 0,
-       properties => {
-           comment => get_standard_option('group-comment'),
-           members => {
-               type => 'array',
-               items => get_standard_option('userid-completed')
-           },
-       },
-    },
-    code => sub {
-       my ($param) = @_;
-
-       my $group = $param->{groupid};
-
-       my $usercfg = cfs_read_file("user.cfg");
-       my $data = $usercfg->{groups}->{$group};
-
-       die "group '$group' does not exist\n" if !$data;
-
-       my $members = $data->{users} ? [ keys %{$data->{users}} ] : [];
-
-       my $res = { members => $members };
-
-       $res->{comment} = $data->{comment} if defined($data->{comment});
-
-       return $res;
-    }});
-
-
-__PACKAGE__->register_method ({
-    name => 'delete_group', 
-    protected => 1,
-    path => '{groupid}', 
-    method => 'DELETE',
-    permissions => { 
-       check => ['perm', '/access/groups', ['Group.Allocate']],
-    },
-    description => "Delete group.",
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           groupid => get_standard_option('group-id'),
-       }
-    },
-    returns => { type => 'null' },
-    code => sub {
-       my ($param) = @_;
-
-       PVE::AccessControl::lock_user_config(
-           sub {
-
-               my $usercfg = cfs_read_file("user.cfg");
-
-               my $group = $param->{groupid};
-
-               die "group '$group' does not exist\n" 
-                   if !$usercfg->{groups}->{$group};
-       
-               delete ($usercfg->{groups}->{$group});
-
-               PVE::AccessControl::delete_group_acl($group, $usercfg);
-
-               cfs_write_file("user.cfg", $usercfg);
-           }, "delete group failed");
-       
-       return undef;
-    }});
-
-1;
diff --git a/PVE/API2/Makefile b/PVE/API2/Makefile
deleted file mode 100644 (file)
index 1bf8c05..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-
-API2_SOURCES=                  \
-       AccessControl.pm        \
-       Domains.pm              \
-       ACL.pm                  \
-       Role.pm                 \
-       Group.pm                \
-       User.pm
-
-.PHONY: install
-install:
-       for i in ${API2_SOURCES}; do install -D -m 0644 $$i ${DESTDIR}${PERLDIR}/PVE/API2/$$i; done
diff --git a/PVE/API2/Role.pm b/PVE/API2/Role.pm
deleted file mode 100644 (file)
index 70a92b6..0000000
+++ /dev/null
@@ -1,228 +0,0 @@
-package PVE::API2::Role;
-
-use strict;
-use warnings;
-use PVE::Cluster qw (cfs_read_file cfs_write_file);
-use PVE::AccessControl;
-use PVE::JSONSchema qw(get_standard_option register_standard_option);
-
-use PVE::SafeSyslog;
-
-use PVE::RESTHandler;
-
-use base qw(PVE::RESTHandler);
-
-register_standard_option('role-id', {
-    type => 'string',
-    format => 'pve-roleid',
-});
-register_standard_option('role-privs', {
-    type => 'string' ,
-    format => 'pve-priv-list',
-    optional => 1,
-});
-
-__PACKAGE__->register_method ({
-    name => 'index',
-    path => '',
-    method => 'GET',
-    description => "Role index.",
-    permissions => {
-       user => 'all',
-    },
-    parameters => {
-       additionalProperties => 0,
-       properties => {},
-    },
-    returns => {
-       type => 'array',
-       items => {
-           type => "object",
-           properties => {
-               roleid => get_standard_option('role-id'),
-               privs =>  get_standard_option('role-privs'),
-               special => { type => 'boolean', optional => 1, default => 0 },
-           },
-       },
-       links => [ { rel => 'child', href => "{roleid}" } ],
-    },
-    code => sub {
-       my ($param) = @_;
-
-       my $res = [];
-
-       my $usercfg = cfs_read_file("user.cfg");
-
-       foreach my $role (keys %{$usercfg->{roles}}) {
-           my $privs = join(',', sort keys %{$usercfg->{roles}->{$role}});
-           push @$res, {
-               roleid => $role,
-               privs => $privs,
-               special => PVE::AccessControl::role_is_special($role),
-           };
-       }
-
-       return $res;
-}});
-
-__PACKAGE__->register_method ({
-    name => 'create_role',
-    protected => 1,
-    path => '',
-    method => 'POST',
-    permissions => {
-       check => ['perm', '/access', ['Sys.Modify']],
-    },
-    description => "Create new role.",
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           roleid => get_standard_option('role-id'),
-           privs =>  get_standard_option('role-privs'),
-       },
-    },
-    returns => { type => 'null' },
-    code => sub {
-       my ($param) = @_;
-
-       PVE::AccessControl::lock_user_config(
-           sub {
-
-               my $usercfg = cfs_read_file("user.cfg");
-
-               my $role = $param->{roleid};
-
-               die "role '$role' already exists\n"
-                   if $usercfg->{roles}->{$role};
-
-               $usercfg->{roles}->{$role} = {};
-
-               PVE::AccessControl::add_role_privs($role, $usercfg, $param->{privs});
-
-               cfs_write_file("user.cfg", $usercfg);
-           }, "create role failed");
-
-       return undef;
-}});
-
-__PACKAGE__->register_method ({
-    name => 'update_role',
-    protected => 1,
-    path => '{roleid}',
-    method => 'PUT',
-    permissions => {
-       check => ['perm', '/access', ['Sys.Modify']],
-    },
-    description => "Update an existing role.",
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           roleid => get_standard_option('role-id'),
-           privs =>  get_standard_option('role-privs'),
-           append => { type => 'boolean', optional => 1, requires => 'privs' },
-       },
-    },
-    returns => { type => 'null' },
-    code => sub {
-       my ($param) = @_;
-
-       my $role = $param->{roleid};
-
-       die "auto-generated role '$role' cannot be modified\n"
-           if PVE::AccessControl::role_is_special($role);
-
-       PVE::AccessControl::lock_user_config(
-           sub {
-
-               my $usercfg = cfs_read_file("user.cfg");
-
-               die "role '$role' does not exist\n"
-                   if !$usercfg->{roles}->{$role};
-
-               $usercfg->{roles}->{$role} = {} if !$param->{append};
-
-               PVE::AccessControl::add_role_privs($role, $usercfg, $param->{privs});
-
-               cfs_write_file("user.cfg", $usercfg);
-           }, "update role failed");
-
-       return undef;
-}});
-
-__PACKAGE__->register_method ({
-    name => 'read_role',
-    path => '{roleid}',
-    method => 'GET',
-    permissions => {
-       user => 'all',
-    },
-    description => "Get role configuration.",
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           roleid => get_standard_option('role-id'),
-       },
-    },
-    returns => {
-       type => "object",
-       additionalProperties => 0,
-       properties => PVE::AccessControl::create_priv_properties(),
-    },
-    code => sub {
-       my ($param) = @_;
-
-       my $usercfg = cfs_read_file("user.cfg");
-
-       my $role = $param->{roleid};
-
-       my $data = $usercfg->{roles}->{$role};
-
-       die "role '$role' does not exist\n" if !$data;
-
-       return $data;
-    }
-});
-
-__PACKAGE__->register_method ({
-    name => 'delete_role',
-    protected => 1,
-    path => '{roleid}',
-    method => 'DELETE',
-    permissions => {
-       check => ['perm', '/access', ['Sys.Modify']],
-    },
-    description => "Delete role.",
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           roleid => get_standard_option('role-id'),
-       },
-    },
-    returns => { type => 'null' },
-    code => sub {
-       my ($param) = @_;
-
-       my $role = $param->{roleid};
-
-       die "auto-generated role '$role' cannot be deleted\n"
-           if PVE::AccessControl::role_is_special($role);
-
-       PVE::AccessControl::lock_user_config(
-           sub {
-               my $usercfg = cfs_read_file("user.cfg");
-
-               die "role '$role' does not exist\n"
-                   if !$usercfg->{roles}->{$role};
-
-               delete ($usercfg->{roles}->{$role});
-
-               # fixme: delete role from acl?
-
-               cfs_write_file("user.cfg", $usercfg);
-           }, "delete role failed");
-
-       return undef;
-    }
-});
-
-1;
diff --git a/PVE/API2/User.pm b/PVE/API2/User.pm
deleted file mode 100644 (file)
index 05de57f..0000000
+++ /dev/null
@@ -1,747 +0,0 @@
-package PVE::API2::User;
-
-use strict;
-use warnings;
-use PVE::Exception qw(raise raise_perm_exc raise_param_exc);
-use PVE::Cluster qw (cfs_read_file cfs_write_file);
-use PVE::Tools qw(split_list extract_param);
-use PVE::AccessControl;
-use PVE::JSONSchema qw(get_standard_option register_standard_option);
-use PVE::TokenConfig;
-
-use PVE::SafeSyslog;
-
-use PVE::RESTHandler;
-
-use base qw(PVE::RESTHandler);
-
-register_standard_option('user-enable', {
-    description => "Enable the account (default). You can set this to '0' to disable the account",
-    type => 'boolean',
-    optional => 1,
-    default => 1,
-});
-register_standard_option('user-expire', {
-    description => "Account expiration date (seconds since epoch). '0' means no expiration date.",
-    type => 'integer',
-    minimum => 0,
-    optional => 1,
-});
-register_standard_option('user-firstname', { type => 'string', optional => 1 });
-register_standard_option('user-lastname', { type => 'string', optional => 1 });
-register_standard_option('user-email', { type => 'string', optional => 1, format => 'email-opt' });
-register_standard_option('user-comment', { type => 'string', optional => 1 });
-register_standard_option('user-keys', {
-    description => "Keys for two factor auth (yubico).",
-    type => 'string',
-    optional => 1,
-});
-register_standard_option('group-list', {
-    type => 'string', format => 'pve-groupid-list',
-    optional => 1,
-    completion => \&PVE::AccessControl::complete_group,
-});
-register_standard_option('token-subid', {
-    type => 'string',
-    pattern => $PVE::AccessControl::token_subid_regex,
-    description => 'User-specific token identifier.',
-});
-register_standard_option('token-expire', {
-    description => "API token expiration date (seconds since epoch). '0' means no expiration date.",
-    type => 'integer',
-    minimum => 0,
-    optional => 1,
-    default => 'same as user',
-});
-register_standard_option('token-privsep', {
-    description => "Restrict API token privileges with separate ACLs (default), or give full privileges of corresponding user.",
-    type => 'boolean',
-    optional => 1,
-    default => 1,
-});
-register_standard_option('token-comment', { type => 'string', optional => 1 });
-register_standard_option('token-info', {
-    type => 'object',
-    properties => {
-       expire => get_standard_option('token-expire'),
-       privsep => get_standard_option('token-privsep'),
-       comment => get_standard_option('token-comment'),
-    }
-});
-
-my $token_info_extend = sub {
-    my ($props) = @_;
-
-    my $obj = get_standard_option('token-info');
-    my $base_props = $obj->{properties};
-    $obj->{properties} = {};
-
-    foreach my $prop (keys %$base_props) {
-       $obj->{properties}->{$prop} = $base_props->{$prop};
-    }
-
-    foreach my $add_prop (keys %$props) {
-       $obj->{properties}->{$add_prop} = $props->{$add_prop};
-    }
-
-    return $obj;
-};
-
-my $extract_user_data = sub {
-    my ($data, $full) = @_;
-
-    my $res = {};
-
-    foreach my $prop (qw(enable expire firstname lastname email comment keys)) {
-       $res->{$prop} = $data->{$prop} if defined($data->{$prop});
-    }
-
-    return $res if !$full;
-
-    $res->{groups} = $data->{groups} ? [ keys %{$data->{groups}} ] : [];
-    $res->{tokens} = $data->{tokens};
-
-    return $res;
-};
-
-__PACKAGE__->register_method ({
-    name => 'index',
-    path => '',
-    method => 'GET',
-    description => "User index.",
-    permissions => {
-       description => "The returned list is restricted to users where you have 'User.Modify' or 'Sys.Audit' permissions on '/access/groups' or on a group the user belongs too. But it always includes the current (authenticated) user.",
-       user => 'all',
-    },
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           enabled => {
-               type => 'boolean',
-               description => "Optional filter for enable property.",
-               optional => 1,
-           },
-           full => {
-               type => 'boolean',
-               description => "Include group and token information.",
-               optional => 1,
-               default => 0,
-           }
-       },
-    },
-    returns => {
-       type => 'array',
-       items => {
-           type => "object",
-           properties => {
-               userid => get_standard_option('userid-completed'),
-               enable => get_standard_option('user-enable'),
-               expire => get_standard_option('user-expire'),
-               firstname => get_standard_option('user-firstname'),
-               lastname => get_standard_option('user-lastname'),
-               email => get_standard_option('user-email'),
-               comment => get_standard_option('user-comment'),
-               keys => get_standard_option('user-keys'),
-               groups => get_standard_option('group-list'),
-               tokens => {
-                   type => 'array',
-                   optional => 1,
-                   items => $token_info_extend->({
-                       tokenid => get_standard_option('token-subid'),
-                   }),
-               }
-           },
-       },
-       links => [ { rel => 'child', href => "{userid}" } ],
-    },
-    code => sub {
-       my ($param) = @_;
-
-       my $rpcenv = PVE::RPCEnvironment::get();
-       my $usercfg = $rpcenv->{user_cfg};
-       my $authuser = $rpcenv->get_user();
-
-       my $res = [];
-
-       my $privs = [ 'User.Modify', 'Sys.Audit' ];
-       my $canUserMod = $rpcenv->check_any($authuser, "/access/groups", $privs, 1);
-       my $groups = $rpcenv->filter_groups($authuser, $privs, 1);
-       my $allowed_users = $rpcenv->group_member_join([keys %$groups]);
-
-       foreach my $user (keys %{$usercfg->{users}}) {
-           if (!($canUserMod || $user eq $authuser)) {
-               next if !$allowed_users->{$user};
-           }
-
-           my $entry = &$extract_user_data($usercfg->{users}->{$user}, $param->{full});
-
-           if (defined($param->{enabled})) {
-               next if $entry->{enable} && !$param->{enabled};
-               next if !$entry->{enable} && $param->{enabled};
-           }
-
-           $entry->{groups} = join(',', @{$entry->{groups}}) if $entry->{groups};
-           $entry->{tokens} = [ map { { tokenid => $_, %{$entry->{tokens}->{$_}} } } sort keys %{$entry->{tokens}} ]
-               if defined($entry->{tokens});
-
-           $entry->{userid} = $user;
-           push @$res, $entry;
-       }
-
-       return $res;
-    }});
-
-__PACKAGE__->register_method ({
-    name => 'create_user',
-    protected => 1,
-    path => '',
-    method => 'POST',
-    permissions => {
-       description => "You need 'Realm.AllocateUser' on '/access/realm/<realm>' on the realm of user <userid>, and 'User.Modify' permissions to '/access/groups/<group>' for any group specified (or 'User.Modify' on '/access/groups' if you pass no groups.",
-       check => [ 'and',
-                  [ 'userid-param', 'Realm.AllocateUser'],
-                  [ 'userid-group', ['User.Modify'], groups_param => 1],
-           ],
-    },
-    description => "Create new user.",
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           userid => get_standard_option('userid-completed'),
-           enable => get_standard_option('user-enable'),
-           expire => get_standard_option('user-expire'),
-           firstname => get_standard_option('user-firstname'),
-           lastname => get_standard_option('user-lastname'),
-           email => get_standard_option('user-email'),
-           comment => get_standard_option('user-comment'),
-           keys => get_standard_option('user-keys'),
-           password => {
-               description => "Initial password.",
-               type => 'string',
-               optional => 1,
-               minLength => 5,
-               maxLength => 64
-           },
-           groups => get_standard_option('group-list'),
-       },
-    },
-    returns => { type => 'null' },
-    code => sub {
-       my ($param) = @_;
-
-       PVE::AccessControl::lock_user_config(sub {
-           my ($username, $ruid, $realm) = PVE::AccessControl::verify_username($param->{userid});
-
-           my $usercfg = cfs_read_file("user.cfg");
-
-           # ensure "user exists" check works for case insensitive realms
-           $username = PVE::AccessControl::lookup_username($username, 1);
-           die "user '$username' already exists\n" if $usercfg->{users}->{$username};
-
-           PVE::AccessControl::domain_set_password($realm, $ruid, $param->{password})
-               if defined($param->{password});
-
-           my $enable = defined($param->{enable}) ? $param->{enable} : 1;
-           $usercfg->{users}->{$username} = { enable => $enable };
-           $usercfg->{users}->{$username}->{expire} = $param->{expire} if $param->{expire};
-
-           if ($param->{groups}) {
-               foreach my $group (split_list($param->{groups})) {
-                   if ($usercfg->{groups}->{$group}) {
-                       PVE::AccessControl::add_user_group($username, $usercfg, $group);
-                   } else {
-                       die "no such group '$group'\n";
-                   }
-               }
-           }
-
-           $usercfg->{users}->{$username}->{firstname} = $param->{firstname} if $param->{firstname};
-           $usercfg->{users}->{$username}->{lastname} = $param->{lastname} if $param->{lastname};
-           $usercfg->{users}->{$username}->{email} = $param->{email} if $param->{email};
-           $usercfg->{users}->{$username}->{comment} = $param->{comment} if $param->{comment};
-           $usercfg->{users}->{$username}->{keys} = $param->{keys} if $param->{keys};
-
-           cfs_write_file("user.cfg", $usercfg);
-       }, "create user failed");
-
-       return undef;
-    }});
-
-__PACKAGE__->register_method ({
-    name => 'read_user',
-    path => '{userid}',
-    method => 'GET',
-    description => "Get user configuration.",
-    permissions => {
-       check => ['userid-group', ['User.Modify', 'Sys.Audit']],
-    },
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           userid => get_standard_option('userid-completed'),
-       },
-    },
-    returns => {
-       additionalProperties => 0,
-       properties => {
-           enable => get_standard_option('user-enable'),
-           expire => get_standard_option('user-expire'),
-           firstname => get_standard_option('user-firstname'),
-           lastname => get_standard_option('user-lastname'),
-           email => get_standard_option('user-email'),
-           comment => get_standard_option('user-comment'),
-           keys => get_standard_option('user-keys'),
-           groups => {
-               type => 'array',
-               optional => 1,
-               items => {
-                   type => 'string',
-                   format => 'pve-groupid',
-               },
-           },
-           tokens => {
-               optional => 1,
-               type => 'object',
-           },
-       },
-       type => "object"
-    },
-    code => sub {
-       my ($param) = @_;
-
-       my ($username, undef, $domain) =
-           PVE::AccessControl::verify_username($param->{userid});
-
-       my $usercfg = cfs_read_file("user.cfg");
-
-       my $data = PVE::AccessControl::check_user_exist($usercfg, $username);
-
-       return &$extract_user_data($data, 1);
-    }});
-
-__PACKAGE__->register_method ({
-    name => 'update_user',
-    protected => 1,
-    path => '{userid}',
-    method => 'PUT',
-    permissions => {
-       check => ['userid-group', ['User.Modify'], groups_param => 1 ],
-    },
-    description => "Update user configuration.",
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           userid => get_standard_option('userid-completed'),
-           enable => get_standard_option('user-enable'),
-           expire => get_standard_option('user-expire'),
-           firstname => get_standard_option('user-firstname'),
-           lastname => get_standard_option('user-lastname'),
-           email => get_standard_option('user-email'),
-           comment => get_standard_option('user-comment'),
-           keys => get_standard_option('user-keys'),
-           groups => get_standard_option('group-list'),
-           append => {
-               type => 'boolean',
-               optional => 1,
-               requires => 'groups',
-           },
-       },
-    },
-    returns => { type => 'null' },
-    code => sub {
-       my ($param) = @_;
-
-       my ($username, $ruid, $realm) =
-           PVE::AccessControl::verify_username($param->{userid});
-
-       PVE::AccessControl::lock_user_config(
-           sub {
-
-               my $usercfg = cfs_read_file("user.cfg");
-
-               PVE::AccessControl::check_user_exist($usercfg, $username);
-
-               $usercfg->{users}->{$username}->{enable} = $param->{enable} if defined($param->{enable});
-
-               $usercfg->{users}->{$username}->{expire} = $param->{expire} if defined($param->{expire});
-
-               PVE::AccessControl::delete_user_group($username, $usercfg)
-                   if (!$param->{append} && defined($param->{groups}));
-
-               if ($param->{groups}) {
-                   foreach my $group (split_list($param->{groups})) {
-                       if ($usercfg->{groups}->{$group}) {
-                           PVE::AccessControl::add_user_group($username, $usercfg, $group);
-                       } else {
-                           die "no such group '$group'\n";
-                       }
-                   }
-               }
-
-               $usercfg->{users}->{$username}->{firstname} = $param->{firstname} if defined($param->{firstname});
-               $usercfg->{users}->{$username}->{lastname} = $param->{lastname} if defined($param->{lastname});
-               $usercfg->{users}->{$username}->{email} = $param->{email} if defined($param->{email});
-               $usercfg->{users}->{$username}->{comment} = $param->{comment} if defined($param->{comment});
-               $usercfg->{users}->{$username}->{keys} = $param->{keys} if defined($param->{keys});
-
-               cfs_write_file("user.cfg", $usercfg);
-           }, "update user failed");
-
-       return undef;
-    }});
-
-__PACKAGE__->register_method ({
-    name => 'delete_user',
-    protected => 1,
-    path => '{userid}',
-    method => 'DELETE',
-    description => "Delete user.",
-    permissions => {
-       check => [ 'and',
-                  [ 'userid-param', 'Realm.AllocateUser'],
-                  [ 'userid-group', ['User.Modify']],
-           ],
-    },
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           userid => get_standard_option('userid-completed'),
-       }
-    },
-    returns => { type => 'null' },
-    code => sub {
-       my ($param) = @_;
-
-       my $rpcenv = PVE::RPCEnvironment::get();
-       my $authuser = $rpcenv->get_user();
-
-       my ($userid, $ruid, $realm) =
-           PVE::AccessControl::verify_username($param->{userid});
-
-       PVE::AccessControl::lock_user_config(
-           sub {
-
-               my $usercfg = cfs_read_file("user.cfg");
-
-               my $domain_cfg = cfs_read_file('domains.cfg');
-               if (my $cfg = $domain_cfg->{ids}->{$realm}) {
-                   my $plugin = PVE::Auth::Plugin->lookup($cfg->{type});
-                   $plugin->delete_user($cfg, $realm, $ruid);
-               }
-
-               # Remove TFA data before removing the user entry as the user entry tells us whether
-               # we need ot update priv/tfa.cfg.
-               PVE::AccessControl::user_set_tfa($userid, $realm, undef, undef, $usercfg, $domain_cfg);
-
-               delete $usercfg->{users}->{$userid};
-
-               PVE::AccessControl::delete_user_group($userid, $usercfg);
-               PVE::AccessControl::delete_user_acl($userid, $usercfg);
-               cfs_write_file("user.cfg", $usercfg);
-           }, "delete user failed");
-
-       return undef;
-    }});
-
-__PACKAGE__->register_method ({
-    name => 'read_user_tfa_type',
-    path => '{userid}/tfa',
-    method => 'GET',
-    protected => 1,
-    description => "Get user TFA types (Personal and Realm).",
-    permissions => {
-       check => [ 'or',
-           ['userid-param', 'self'],
-           ['userid-group', ['User.Modify', 'Sys.Audit']],
-       ],
-    },
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           userid => get_standard_option('userid-completed'),
-       },
-    },
-    returns => {
-       additionalProperties => 0,
-       properties => {
-           realm => {
-               type => 'string',
-               enum => [qw(oath yubico)],
-               description => "The type of TFA the users realm has set, if any.",
-               optional => 1,
-           },
-           user => {
-               type => 'string',
-               enum => [qw(oath u2f)],
-               description => "The type of TFA the user has set, if any.",
-               optional => 1,
-           },
-       },
-       type => "object"
-    },
-    code => sub {
-       my ($param) = @_;
-
-       my ($username, undef, $realm) = PVE::AccessControl::verify_username($param->{userid});
-
-
-       my $domain_cfg = cfs_read_file('domains.cfg');
-       my $realm_cfg = $domain_cfg->{ids}->{$realm};
-       die "auth domain '$realm' does not exist\n" if !$realm_cfg;
-
-       my $realm_tfa = {};
-       $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_cfg->{tfa})
-           if $realm_cfg->{tfa};
-
-       my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
-       my $tfa = $tfa_cfg->{users}->{$username};
-
-       my $res = {};
-       $res->{realm} = $realm_tfa->{type} if $realm_tfa->{type};
-       $res->{user} = $tfa->{type} if $tfa->{type};
-       return $res;
-    }});
-
-__PACKAGE__->register_method ({
-    name => 'token_index',
-    path => '{userid}/token',
-    method => 'GET',
-    description => "Get user API tokens.",
-    permissions => {
-       check => ['or',
-                   ['userid-param', 'self'],
-                   ['perm', '/access/users/{userid}', ['User.Modify']],
-       ],
-    },
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           userid => get_standard_option('userid-completed'),
-       },
-    },
-    returns => {
-       type => "array",
-       items => $token_info_extend->({
-                   tokenid => get_standard_option('token-subid'),
-       }),
-       links => [ { rel => 'child', href => "{tokenid}" } ],
-    },
-    code => sub {
-       my ($param) = @_;
-
-       my $userid = PVE::AccessControl::verify_username($param->{userid});
-       my $usercfg = cfs_read_file("user.cfg");
-
-       my $user = PVE::AccessControl::check_user_exist($usercfg, $userid);
-
-       my $tokens = $user->{tokens} // {};
-       return [ map { $tokens->{$_}->{tokenid} = $_; $tokens->{$_} } keys %$tokens];
-    }});
-
-__PACKAGE__->register_method ({
-    name => 'read_token',
-    path => '{userid}/token/{tokenid}',
-    method => 'GET',
-    description => "Get specific API token information.",
-    permissions => {
-       check => ['or',
-                   ['userid-param', 'self'],
-                   ['perm', '/access/users/{userid}', ['User.Modify']],
-       ],
-    },
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           userid => get_standard_option('userid-completed'),
-           tokenid => get_standard_option('token-subid'),
-       },
-    },
-    returns => get_standard_option('token-info'),
-    code => sub {
-       my ($param) = @_;
-
-       my $userid = PVE::AccessControl::verify_username($param->{userid});
-       my $tokenid = $param->{tokenid};
-
-       my $usercfg = cfs_read_file("user.cfg");
-
-       return PVE::AccessControl::check_token_exist($usercfg, $userid, $tokenid);
-    }});
-
-__PACKAGE__->register_method ({
-    name => 'generate_token',
-    path => '{userid}/token/{tokenid}',
-    method => 'POST',
-    description => "Generate a new API token for a specific user. NOTE: returns API token value, which needs to be stored as it cannot be retrieved afterwards!",
-    protected => 1,
-    permissions => {
-       check => ['or',
-                   ['userid-param', 'self'],
-                   ['perm', '/access/users/{userid}', ['User.Modify']],
-       ],
-    },
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           userid => get_standard_option('userid-completed'),
-           tokenid => get_standard_option('token-subid'),
-           expire => get_standard_option('token-expire'),
-           privsep => get_standard_option('token-privsep'),
-           comment => get_standard_option('token-comment'),
-       },
-    },
-    returns => {
-       additionalProperties => 0,
-       type => "object",
-       properties => {
-           info => get_standard_option('token-info'),
-           value => {
-               type => 'string',
-               description => 'API token value used for authentication.',
-           },
-           'full-tokenid' => {
-               type => 'string',
-               format_description => '<userid>!<tokenid>',
-               description => 'The full token id.',
-           },
-       },
-    },
-    code => sub {
-       my ($param) = @_;
-
-       my $userid = PVE::AccessControl::verify_username(extract_param($param, 'userid'));
-       my $tokenid = extract_param($param, 'tokenid');
-
-       my $usercfg = cfs_read_file("user.cfg");
-
-       my $token = PVE::AccessControl::check_token_exist($usercfg, $userid, $tokenid, 1);
-       my ($full_tokenid, $value);
-
-       PVE::AccessControl::check_user_exist($usercfg, $userid);
-       raise_param_exc({ 'tokenid' => 'Token already exists.' }) if defined($token);
-
-       my $generate_and_add_token = sub {
-           $usercfg = cfs_read_file("user.cfg");
-           PVE::AccessControl::check_user_exist($usercfg, $userid);
-           die "Token already exists.\n" if defined(PVE::AccessControl::check_token_exist($usercfg, $userid, $tokenid, 1));
-
-           $full_tokenid = PVE::AccessControl::join_tokenid($userid, $tokenid);
-           $value = PVE::TokenConfig::generate_token($full_tokenid);
-
-           $token = {};
-           $token->{privsep} = defined($param->{privsep}) ? $param->{privsep} : 1;
-           $token->{expire} = $param->{expire} if defined($param->{expire});
-           $token->{comment} = $param->{comment} if $param->{comment};
-
-           $usercfg->{users}->{$userid}->{tokens}->{$tokenid} = $token;
-           cfs_write_file("user.cfg", $usercfg);
-       };
-
-       PVE::AccessControl::lock_user_config($generate_and_add_token, 'generating token failed');
-
-       return {
-           info => $token,
-           value => $value,
-           'full-tokenid' => $full_tokenid,
-       };
-    }});
-
-
-__PACKAGE__->register_method ({
-    name => 'update_token_info',
-    path => '{userid}/token/{tokenid}',
-    method => 'PUT',
-    description => "Update API token for a specific user.",
-    protected => 1,
-    permissions => {
-       check => ['or',
-                   ['userid-param', 'self'],
-                   ['perm', '/access/users/{userid}', ['User.Modify']],
-       ],
-    },
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           userid => get_standard_option('userid-completed'),
-           tokenid => get_standard_option('token-subid'),
-           expire => get_standard_option('token-expire'),
-           privsep => get_standard_option('token-privsep'),
-           comment => get_standard_option('token-comment'),
-       },
-    },
-    returns => get_standard_option('token-info', { description => "Updated token information." }),
-    code => sub {
-       my ($param) = @_;
-
-       my $userid = PVE::AccessControl::verify_username(extract_param($param, 'userid'));
-       my $tokenid = extract_param($param, 'tokenid');
-
-       my $usercfg = cfs_read_file("user.cfg");
-       my $token = PVE::AccessControl::check_token_exist($usercfg, $userid, $tokenid);
-
-       my $update_token = sub {
-           $usercfg = cfs_read_file("user.cfg");
-           $token = PVE::AccessControl::check_token_exist($usercfg, $userid, $tokenid);
-
-           my $full_tokenid = PVE::AccessControl::join_tokenid($userid, $tokenid);
-
-           $token->{privsep} = $param->{privsep} if defined($param->{privsep});
-           $token->{expire} = $param->{expire} if defined($param->{expire});
-           $token->{comment} = $param->{comment} if $param->{comment};
-
-           $usercfg->{users}->{$userid}->{tokens}->{$tokenid} = $token;
-           cfs_write_file("user.cfg", $usercfg);
-       };
-
-       PVE::AccessControl::lock_user_config($update_token, 'updating token info failed');
-
-       return $token;
-    }});
-
-
-__PACKAGE__->register_method ({
-    name => 'remove_token',
-    path => '{userid}/token/{tokenid}',
-    method => 'DELETE',
-    description => "Remove API token for a specific user.",
-    protected => 1,
-    permissions => {
-       check => ['or',
-                   ['userid-param', 'self'],
-                   ['perm', '/access/users/{userid}', ['User.Modify']],
-       ],
-    },
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           userid => get_standard_option('userid-completed'),
-           tokenid => get_standard_option('token-subid'),
-       },
-    },
-    returns => { type => 'null' },
-    code => sub {
-       my ($param) = @_;
-
-       my $userid = PVE::AccessControl::verify_username(extract_param($param, 'userid'));
-       my $tokenid = extract_param($param, 'tokenid');
-
-       my $usercfg = cfs_read_file("user.cfg");
-       my $token = PVE::AccessControl::check_token_exist($usercfg, $userid, $tokenid);
-
-       my $update_token = sub {
-           $usercfg = cfs_read_file("user.cfg");
-
-           PVE::AccessControl::check_token_exist($usercfg, $userid, $tokenid);
-
-           my $full_tokenid = PVE::AccessControl::join_tokenid($userid, $tokenid);
-           PVE::TokenConfig::delete_token($full_tokenid);
-           delete $usercfg->{users}->{$userid}->{tokens}->{$tokenid};
-
-           cfs_write_file("user.cfg", $usercfg);
-       };
-
-       PVE::AccessControl::lock_user_config($update_token, 'deleting token failed');
-
-       return;
-    }});
-1;
diff --git a/PVE/AccessControl.pm b/PVE/AccessControl.pm
deleted file mode 100644 (file)
index f7d4e78..0000000
+++ /dev/null
@@ -1,1727 +0,0 @@
-package PVE::AccessControl;
-
-use strict;
-use warnings;
-use Encode;
-use Crypt::OpenSSL::Random;
-use Crypt::OpenSSL::RSA;
-use Net::SSLeay;
-use Net::IP;
-use MIME::Base64;
-use Digest::SHA;
-use IO::File;
-use File::stat;
-use JSON;
-
-use PVE::OTP;
-use PVE::Ticket;
-use PVE::Tools qw(run_command lock_file file_get_contents split_list safe_print);
-use PVE::Cluster qw(cfs_register_file cfs_read_file cfs_write_file cfs_lock_file);
-use PVE::JSONSchema qw(register_standard_option get_standard_option);
-
-use PVE::Auth::Plugin;
-use PVE::Auth::AD;
-use PVE::Auth::LDAP;
-use PVE::Auth::PVE;
-use PVE::Auth::PAM;
-
-# load and initialize all plugins
-
-PVE::Auth::AD->register();
-PVE::Auth::LDAP->register();
-PVE::Auth::PVE->register();
-PVE::Auth::PAM->register();
-PVE::Auth::Plugin->init();
-
-# $authdir must be writable by root only!
-my $confdir = "/etc/pve";
-my $authdir = "$confdir/priv";
-
-my $pve_www_key_fn = "$confdir/pve-www.key";
-
-my $pve_auth_key_files = {
-    priv => "$authdir/authkey.key",
-    pub =>  "$confdir/authkey.pub",
-    pubold => "$confdir/authkey.pub.old",
-};
-
-my $pve_auth_key_cache = {};
-
-my $ticket_lifetime = 3600 * 2; # 2 hours
-my $auth_graceperiod = 60 * 5; # 5 minutes
-my $authkey_lifetime = 3600 * 24; # rotate every 24 hours
-
-Crypt::OpenSSL::RSA->import_random_seed();
-
-cfs_register_file('user.cfg',
-                 \&parse_user_config,
-                 \&write_user_config);
-cfs_register_file('priv/tfa.cfg',
-                 \&parse_priv_tfa_config,
-                 \&write_priv_tfa_config);
-
-sub verify_username {
-    PVE::Auth::Plugin::verify_username(@_);
-}
-
-sub pve_verify_realm {
-    PVE::Auth::Plugin::pve_verify_realm(@_);
-}
-
-sub lock_user_config {
-    my ($code, $errmsg) = @_;
-
-    cfs_lock_file("user.cfg", undef, $code);
-    if (my $err = $@) {
-       $errmsg ? die "$errmsg: $err" : die $err;
-    }
-}
-
-my $cache_read_key = sub {
-    my ($type) = @_;
-
-    my $path = $pve_auth_key_files->{$type};
-
-    my $read_key_and_mtime = sub {
-       my $fh = IO::File->new($path, "r");
-
-       return undef if !defined($fh);
-
-       my $st = stat($fh);
-       my $pem = PVE::Tools::safe_read_from($fh, 0, 0, $path);
-
-       close $fh;
-
-       my $key;
-       if ($type eq 'pub' || $type eq 'pubold') {
-           $key = eval { Crypt::OpenSSL::RSA->new_public_key($pem); };
-       } elsif ($type eq 'priv') {
-           $key = eval { Crypt::OpenSSL::RSA->new_private_key($pem); };
-       } else {
-           die "Invalid authkey type '$type'\n";
-       }
-
-       return { key => $key, mtime => $st->mtime };
-    };
-
-    if (!defined($pve_auth_key_cache->{$type})) {
-       $pve_auth_key_cache->{$type} = $read_key_and_mtime->();
-    } else {
-       my $st = stat($path);
-       if (!$st || $st->mtime != $pve_auth_key_cache->{$type}->{mtime}) {
-           $pve_auth_key_cache->{$type} = $read_key_and_mtime->();
-       }
-    }
-
-    return $pve_auth_key_cache->{$type};
-};
-
-sub get_pubkey {
-    my ($old) = @_;
-
-    my $type = $old ? 'pubold' : 'pub';
-
-    my $res = $cache_read_key->($type);
-    return undef if !defined($res);
-
-    return wantarray ? ($res->{key}, $res->{mtime}) : $res->{key};
-}
-
-sub get_privkey {
-    my $res = $cache_read_key->('priv');
-
-    if (!defined($res) || !check_authkey(1)) {
-       rotate_authkey();
-       $res = $cache_read_key->('priv');
-    }
-
-    return wantarray ? ($res->{key}, $res->{mtime}) : $res->{key};
-}
-
-sub check_authkey {
-    my ($quiet) = @_;
-
-    # skip check if non-quorate, as rotation is not possible anyway
-    return 1 if !PVE::Cluster::check_cfs_quorum(1);
-
-    my ($pub_key, $mtime) = get_pubkey();
-    if (!$pub_key) {
-       warn "auth key pair missing, generating new one..\n"  if !$quiet;
-       return 0;
-    } else {
-       my $now = time();
-       if ($now - $mtime >= $authkey_lifetime) {
-           warn "auth key pair too old, rotating..\n" if !$quiet;;
-           return 0;
-       } elsif ($mtime > $now + $auth_graceperiod) {
-           # a nodes RTC had a time set in the future during key generation -> ticket
-           # validity is clamped to 0+5 min grace period until now >= mtime again
-           my (undef, $old_mtime) = get_pubkey(1);
-           if ($old_mtime && $mtime >= $old_mtime && $mtime - $old_mtime < $ticket_lifetime) {
-               warn "auth key pair generated in the future (key $mtime > host $now),"
-                   ." but old key still exists and in valid grace period so avoid automatic"
-                   ." fixup. Cluster time not in sync?\n" if !$quiet;
-               return 1;
-           }
-           warn "auth key pair generated in the future (key $mtime > host $now), rotating..\n" if !$quiet;
-           return 0;
-       } else {
-           warn "auth key new enough, skipping rotation\n" if !$quiet;;
-           return 1;
-       }
-    }
-}
-
-sub rotate_authkey {
-    return if $authkey_lifetime == 0;
-
-    PVE::Cluster::cfs_lock_authkey(undef, sub {
-       # re-check with lock to avoid double rotation in clusters
-       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 $@;
-       }
-
-       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 ($@) {
-           if ($old) {
-               warn "Failed to store new auth key - $@\n";
-               warn "Reverting to previous auth key\n";
-               eval {
-                   my $pem = $old->get_public_key_x509_string();
-                   PVE::Tools::file_set_contents($pve_auth_key_files->{pub}, $pem);
-               };
-               die "Failed to restore old auth key: $@\n" if $@;
-           } else {
-               die "Failed to store new auth key - $@\n";
-           }
-       }
-
-       eval {
-           my $pem = $new->get_private_key_string();
-           PVE::Tools::file_set_contents($pve_auth_key_files->{priv}, $pem);
-       };
-       if ($@) {
-           warn "Failed to store new auth key - $@\n";
-           warn "Deleting auth key to force regeneration\n";
-           unlink $pve_auth_key_files->{pub};
-           unlink $pve_auth_key_files->{priv};
-       }
-    });
-    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::hmac_sha256_base64($input);
-       $csrf_prevention_secret_legacy = Digest::SHA::sha1_base64($input);
-    }
-    return $csrf_prevention_secret;
-};
-
-sub assemble_csrf_prevention_token {
-    my ($username) = @_;
-
-    my $secret =  &$get_csrfr_secret();
-
-    return PVE::Ticket::assemble_csrf_prevention_token ($secret, $username);
-}
-
-sub verify_csrf_prevention_token {
-    my ($username, $token, $noerr) = @_;
-
-    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, -$auth_graceperiod, $ticket_lifetime, $noerr);
-}
-
-my $get_ticket_age_range = sub {
-    my ($now, $mtime, $rotated) = @_;
-
-    my $key_age = $now - $mtime;
-    $key_age = 0 if $key_age < 0;
-
-    my $min = -$auth_graceperiod;
-    my $max = $ticket_lifetime;
-
-    if ($rotated) {
-       # ticket creation after rotation is not allowed
-       $min = $key_age - $auth_graceperiod;
-    } else {
-       if ($key_age > $authkey_lifetime && $authkey_lifetime > 0) {
-           if (PVE::Cluster::check_cfs_quorum(1)) {
-               # key should have been rotated, clamp range accordingly
-               $min = $key_age - $authkey_lifetime;
-           } else {
-               warn "Cluster not quorate - extending auth key lifetime!\n";
-           }
-       }
-
-       $max = $key_age + $auth_graceperiod if $key_age < $ticket_lifetime;
-    }
-
-    return undef if $min > $ticket_lifetime;
-    return ($min, $max);
-};
-
-sub assemble_ticket {
-    my ($data) = @_;
-
-    my $rsa_priv = get_privkey();
-
-    return PVE::Ticket::assemble_rsa_ticket($rsa_priv, 'PVE', $data);
-}
-
-sub verify_ticket {
-    my ($ticket, $noerr) = @_;
-
-    my $now = time();
-
-    my $check = sub {
-       my ($old) = @_;
-
-       my ($rsa_pub, $rsa_mtime) = get_pubkey($old);
-       return undef if !$rsa_pub;
-
-       my ($min, $max) = $get_ticket_age_range->($now, $rsa_mtime, $old);
-       return undef if !defined($min);
-
-       return PVE::Ticket::verify_rsa_ticket(
-           $rsa_pub, 'PVE', $ticket, undef, $min, $max, 1);
-    };
-
-    my ($data, $age) = $check->();
-
-    # check with old, rotated key if current key failed
-    ($data, $age) = $check->(1) if !defined($data);
-
-    my $auth_failure = sub {
-       if ($noerr) {
-           return undef;
-       } else {
-           # raise error via undef ticket
-           PVE::Ticket::verify_rsa_ticket(undef, 'PVE');
-       }
-    };
-
-    if (!defined($data)) {
-       return $auth_failure->();
-    }
-
-    my ($username, $tfa_info);
-    if ($data =~ m{^u2f!([^!]+)!([0-9a-zA-Z/.=_\-+]+)$}) {
-       # Ticket for u2f-users:
-       ($username, my $challenge) = ($1, $2);
-       if ($challenge eq 'verified') {
-           # u2f challenge was completed
-           $challenge = undef;
-       } elsif (!wantarray) {
-           # The caller is not aware there could be an ongoing challenge,
-           # so we treat this ticket as invalid:
-           return $auth_failure->();
-       }
-       $tfa_info = {
-           type => 'u2f',
-           challenge => $challenge,
-       };
-    } elsif ($data =~ /^tfa!(.*)$/) {
-       # TOTP and Yubico don't require a challenge so this is the generic
-       # 'missing 2nd factor ticket'
-       $username = $1;
-       $tfa_info = { type => 'tfa' };
-    } else {
-       # Regular ticket (full access)
-       $username = $data;
-    }
-
-    return undef if !PVE::Auth::Plugin::verify_username($username, $noerr);
-
-    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')
-sub assemble_vnc_ticket {
-    my ($username, $path) = @_;
-
-    my $rsa_priv = get_privkey();
-
-    $path = normalize_path($path);
-
-    my $secret_data = "$username:$path";
-
-    return PVE::Ticket::assemble_rsa_ticket(
-       $rsa_priv, 'PVEVNC', undef, $secret_data);
-}
-
-sub verify_vnc_ticket {
-    my ($ticket, $username, $path, $noerr) = @_;
-
-    my $secret_data = "$username:$path";
-
-    my ($rsa_pub, $rsa_mtime) = get_pubkey();
-    if (!$rsa_pub || (time() - $rsa_mtime > $authkey_lifetime && $authkey_lifetime > 0)) {
-       if ($noerr) {
-           return undef;
-       } else {
-           # raise error via undef ticket
-           PVE::Ticket::verify_rsa_ticket($rsa_pub, 'PVEVNC');
-       }
-    }
-
-    return PVE::Ticket::verify_rsa_ticket(
-       $rsa_pub, 'PVEVNC', $ticket, $secret_data, -20, 40, $noerr);
-}
-
-sub assemble_spice_ticket {
-    my ($username, $vmid, $node) = @_;
-
-    my $secret = &$get_csrfr_secret();
-
-    return PVE::Ticket::assemble_spice_ticket(
-       $secret, $username, $vmid, $node);
-}
-
-sub verify_spice_connect_url {
-    my ($connect_str) = @_;
-
-    my $secret = &$get_csrfr_secret();
-
-    return PVE::Ticket::verify_spice_connect_url($secret, $connect_str);
-}
-
-sub read_x509_subject_spice {
-    my ($filename) = @_;
-
-    # read x509 subject
-    my $bio = Net::SSLeay::BIO_new_file($filename, 'r');
-    die "Could not open $filename using OpenSSL\n"
-       if !$bio;
-
-    my $x509 = Net::SSLeay::PEM_read_bio_X509($bio);
-    Net::SSLeay::BIO_free($bio);
-
-    die "Could not parse X509 certificate in $filename\n"
-       if !$x509;
-
-    my $nameobj = Net::SSLeay::X509_get_subject_name($x509);
-    my $subject = Net::SSLeay::X509_NAME_oneline($nameobj);
-    Net::SSLeay::X509_free($x509);
-
-    # remote-viewer wants comma as seperator (not '/')
-    $subject =~ s!^/!!;
-    $subject =~ s!/(\w+=)!,$1!g;
-
-    return $subject;
-}
-
-# helper to generate SPICE remote-viewer configuration
-sub remote_viewer_config {
-    my ($authuser, $vmid, $node, $proxy, $title, $port) = @_;
-
-    if (!$proxy) {
-       my $host = `hostname -f` || PVE::INotify::nodename();
-       chomp $host;
-       $proxy = $host;
-    }
-
-    my ($ticket, $proxyticket) = assemble_spice_ticket($authuser, $vmid, $node);
-
-    my $filename = "/etc/pve/local/pve-ssl.pem";
-    my $subject = read_x509_subject_spice($filename);
-
-    my $cacert = PVE::Tools::file_get_contents("/etc/pve/pve-root-ca.pem", 8192);
-    $cacert =~ s/\n/\\n/g;
-
-    $proxy = "[$proxy]" if Net::IP::ip_is_ipv6($proxy);
-    my $config = {
-       'secure-attention' => "Ctrl+Alt+Ins",
-       'toggle-fullscreen' => "Shift+F11",
-       'release-cursor' => "Ctrl+Alt+R",
-       type => 'spice',
-       title => $title,
-       host => $proxyticket, # this breaks tls hostname verification, so we need to use 'host-subject'
-       proxy => "http://$proxy:3128",
-       'tls-port' => $port,
-       'host-subject' => $subject,
-       ca => $cacert,
-       password => $ticket,
-       'delete-this-file' => 1,
-    };
-
-    return ($ticket, $proxyticket, $config);
-}
-
-sub check_user_exist {
-    my ($usercfg, $username, $noerr) = @_;
-
-    $username = PVE::Auth::Plugin::verify_username($username, $noerr);
-    return undef if !$username;
-
-    return $usercfg->{users}->{$username} if $usercfg && $usercfg->{users}->{$username};
-
-    die "no such user ('$username')\n" if !$noerr;
-
-    return undef;
-}
-
-sub check_user_enabled {
-    my ($usercfg, $username, $noerr) = @_;
-
-    my $data = check_user_exist($usercfg, $username, $noerr);
-    return undef if !$data;
-
-    return 1 if $data->{enable};
-
-    die "user '$username' is disabled\n" if !$noerr;
-
-    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) = @_;
-
-    die "missing one time password for two-factor authentication '$type'\n" if !$otp;
-
-    # fixme: proxy support?
-    my $proxy;
-
-    if ($type eq 'yubico') {
-       PVE::OTP::yubico_verify_otp($otp, $keys, $tfa_cfg->{url},
-                                   $tfa_cfg->{id}, $tfa_cfg->{key}, $proxy);
-    } elsif ($type eq 'oath') {
-       PVE::OTP::oath_verify_otp($otp, $keys, $tfa_cfg->{step}, $tfa_cfg->{digits});
-    } else {
-       die "unknown tfa type '$type'\n";
-    }
-}
-
-# password should be utf8 encoded
-# Note: some plugins delay/sleep if auth fails
-sub authenticate_user {
-    my ($username, $password, $otp) = @_;
-
-    die "no username specified\n" if !$username;
-
-    my ($ruid, $realm);
-
-    ($username, $ruid, $realm) = PVE::Auth::Plugin::verify_username($username);
-
-    my $usercfg = cfs_read_file('user.cfg');
-
-    check_user_enabled($usercfg, $username);
-
-    my $ctime = time();
-    my $expire = $usercfg->{users}->{$username}->{expire};
-
-    die "account expired\n" if $expire && ($expire < $ctime);
-
-    my $domain_cfg = cfs_read_file('domains.cfg');
-
-    my $cfg = $domain_cfg->{ids}->{$realm};
-    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);
-
-    my ($type, $tfa_data) = user_get_tfa($username, $realm);
-    if ($type) {
-       if ($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;
-}
-
-sub domain_set_password {
-    my ($realm, $username, $password) = @_;
-
-    die "no auth domain specified" if !$realm;
-
-    my $domain_cfg = cfs_read_file('domains.cfg');
-
-    my $cfg = $domain_cfg->{ids}->{$realm};
-    die "auth domain '$realm' does not exist\n" if !$cfg;
-    my $plugin = PVE::Auth::Plugin->lookup($cfg->{type});
-    $plugin->store_password($cfg, $realm, $username, $password);
-}
-
-sub add_user_group {
-    my ($username, $usercfg, $group) = @_;
-
-    $usercfg->{users}->{$username}->{groups}->{$group} = 1;
-    $usercfg->{groups}->{$group}->{users}->{$username} = 1;
-}
-
-sub delete_user_group {
-    my ($username, $usercfg) = @_;
-
-    foreach my $group (keys %{$usercfg->{groups}}) {
-
-       delete ($usercfg->{groups}->{$group}->{users}->{$username})
-           if $usercfg->{groups}->{$group}->{users}->{$username};
-    }
-}
-
-sub delete_user_acl {
-    my ($username, $usercfg) = @_;
-
-    foreach my $acl (keys %{$usercfg->{acl}}) {
-
-       delete ($usercfg->{acl}->{$acl}->{users}->{$username})
-           if $usercfg->{acl}->{$acl}->{users}->{$username};
-    }
-}
-
-sub delete_group_acl {
-    my ($group, $usercfg) = @_;
-
-    foreach my $acl (keys %{$usercfg->{acl}}) {
-
-       delete ($usercfg->{acl}->{$acl}->{groups}->{$group})
-           if $usercfg->{acl}->{$acl}->{groups}->{$group};
-    }
-}
-
-sub delete_pool_acl {
-    my ($pool, $usercfg) = @_;
-
-    my $path = "/pool/$pool";
-
-    delete ($usercfg->{acl}->{$path})
-}
-
-# we automatically create some predefined roles by splitting privs
-# into 3 groups (per category)
-# root: only root is allowed to do that
-# admin: an administrator can to that
-# user: a normal user/customer can to that
-my $privgroups = {
-    VM => {
-       root => [],
-       admin => [
-           'VM.Config.Disk',
-           'VM.Config.CPU',
-           'VM.Config.Memory',
-           'VM.Config.Network',
-           'VM.Config.HWType',
-           'VM.Config.Options', # covers all other things
-           'VM.Allocate',
-           'VM.Clone',
-           'VM.Migrate',
-           'VM.Monitor',
-           'VM.Snapshot',
-           'VM.Snapshot.Rollback',
-       ],
-       user => [
-           'VM.Config.CDROM', # change CDROM media
-           'VM.Config.Cloudinit',
-           'VM.Console',
-           'VM.Backup',
-           'VM.PowerMgmt',
-       ],
-       audit => [
-           'VM.Audit',
-       ],
-    },
-    Sys => {
-       root => [
-           'Sys.PowerMgmt',
-           'Sys.Modify', # edit/change node settings
-       ],
-       admin => [
-           'Permissions.Modify',
-           'Sys.Console',
-           'Sys.Syslog',
-       ],
-       user => [],
-       audit => [
-           'Sys.Audit',
-       ],
-    },
-    Datastore => {
-       root => [],
-       admin => [
-           'Datastore.Allocate',
-           'Datastore.AllocateTemplate',
-       ],
-       user => [
-           'Datastore.AllocateSpace',
-       ],
-       audit => [
-           'Datastore.Audit',
-       ],
-    },
-    SDN => {
-       root => [],
-       admin => [
-           'SDN.Allocate',
-           'SDN.Audit',
-       ],
-       audit => [
-           'SDN.Audit',
-       ],
-    },
-    User => {
-       root => [
-           'Realm.Allocate',
-       ],
-       admin => [
-           'User.Modify',
-           'Group.Allocate', # edit/change group settings
-           'Realm.AllocateUser',
-       ],
-       user => [],
-       audit => [],
-    },
-    Pool => {
-       root => [],
-       admin => [
-           'Pool.Allocate', # create/delete pools
-       ],
-       user => [],
-       audit => [],
-    },
-};
-
-my $valid_privs = {};
-
-my $special_roles = {
-    'NoAccess' => {}, # no privileges
-    'Administrator' => $valid_privs, # all privileges
-};
-
-sub create_roles {
-
-    foreach my $cat (keys %$privgroups) {
-       my $cd = $privgroups->{$cat};
-       foreach my $p (@{$cd->{root}}, @{$cd->{admin}},
-                      @{$cd->{user}}, @{$cd->{audit}}) {
-           $valid_privs->{$p} = 1;
-       }
-       foreach my $p (@{$cd->{admin}}, @{$cd->{user}}, @{$cd->{audit}}) {
-
-           $special_roles->{"PVE${cat}Admin"}->{$p} = 1;
-           $special_roles->{"PVEAdmin"}->{$p} = 1;
-       }
-       if (scalar(@{$cd->{user}})) {
-           foreach my $p (@{$cd->{user}}, @{$cd->{audit}}) {
-               $special_roles->{"PVE${cat}User"}->{$p} = 1;
-           }
-       }
-       foreach my $p (@{$cd->{audit}}) {
-           $special_roles->{"PVEAuditor"}->{$p} = 1;
-       }
-    }
-
-    $special_roles->{"PVETemplateUser"} = { 'VM.Clone' => 1, 'VM.Audit' => 1 };
-};
-
-create_roles();
-
-sub create_priv_properties {
-    my $properties = {};
-    foreach my $priv (keys %$valid_privs) {
-       $properties->{$priv} = {
-           type => 'boolean',
-           optional => 1,
-       };
-    }
-    return $properties;
-}
-
-sub role_is_special {
-    my ($role) = @_;
-    return (exists $special_roles->{$role}) ? 1 : 0;
-}
-
-sub add_role_privs {
-    my ($role, $usercfg, $privs) = @_;
-
-    return if !$privs;
-
-    die "role '$role' does not exist\n" if !$usercfg->{roles}->{$role};
-
-    foreach my $priv (split_list($privs)) {
-       if (defined ($valid_privs->{$priv})) {
-           $usercfg->{roles}->{$role}->{$priv} = 1;
-       } else {
-           die "invalid privilege '$priv'\n";
-       }
-    }
-}
-
-sub lookup_username {
-    my ($username, $noerr) = @_;
-
-    $username =~ m!^(${PVE::Auth::Plugin::user_regex})\@(${PVE::Auth::Plugin::realm_regex})$!;
-
-    my $realm = $2;
-    my $domain_cfg = cfs_read_file("domains.cfg");
-    my $casesensitive = $domain_cfg->{ids}->{$realm}->{'case-sensitive'} // 1;
-    my $usercfg = cfs_read_file('user.cfg');
-
-    if (!$casesensitive) {
-       my @matches = grep { lc $username eq lc $_ } (keys %{$usercfg->{users}});
-
-       die "ambiguous case insensitive match of username '$username', cannot safely grant access!\n"
-           if scalar @matches > 1 && !$noerr;
-
-       return $matches[0]
-    }
-
-    return $username;
-}
-
-sub normalize_path {
-    my $path = shift;
-
-    $path =~ s|/+|/|g;
-
-    $path =~ s|/$||;
-
-    $path = '/' if !$path;
-
-    $path = "/$path" if $path !~ m|^/|;
-
-    return undef if $path !~ m|^[[:alnum:]\.\-\_\/]+$|;
-
-    return $path;
-}
-
-sub check_path {
-    my ($path) = @_;
-    return $path =~ m!^(
-       /
-       |/access
-       |/access/groups
-       |/access/realm
-       |/nodes
-       |/nodes/[[:alnum:]\.\-\_]+
-       |/pool
-       |/pool/[[:alnum:]\.\-\_]+
-       |/sdn
-       |/storage
-       |/storage/[[:alnum:]\.\-\_]+
-       |/vms
-       |/vms/[1-9][0-9]{2,}
-    )$!xs;
-}
-
-PVE::JSONSchema::register_format('pve-groupid', \&verify_groupname);
-sub verify_groupname {
-    my ($groupname, $noerr) = @_;
-
-    if ($groupname !~ m/^[A-Za-z0-9\.\-_]+$/) {
-
-       die "group name '$groupname' contains invalid characters\n" if !$noerr;
-
-       return undef;
-    }
-
-    return $groupname;
-}
-
-PVE::JSONSchema::register_format('pve-roleid', \&verify_rolename);
-sub verify_rolename {
-    my ($rolename, $noerr) = @_;
-
-    if ($rolename !~ m/^[A-Za-z0-9\.\-_]+$/) {
-
-       die "role name '$rolename' contains invalid characters\n" if !$noerr;
-
-       return undef;
-    }
-
-    return $rolename;
-}
-
-PVE::JSONSchema::register_format('pve-poolid', \&verify_poolname);
-sub verify_poolname {
-    my ($poolname, $noerr) = @_;
-
-    if ($poolname !~ m/^[A-Za-z0-9\.\-_]+$/) {
-
-       die "pool name '$poolname' contains invalid characters\n" if !$noerr;
-
-       return undef;
-    }
-
-    return $poolname;
-}
-
-PVE::JSONSchema::register_format('pve-priv', \&verify_privname);
-sub verify_privname {
-    my ($priv, $noerr) = @_;
-
-    if (!$valid_privs->{$priv}) {
-       die "invalid privilege '$priv'\n" if !$noerr;
-
-       return undef;
-    }
-
-    return $priv;
-}
-
-sub userconfig_force_defaults {
-    my ($cfg) = @_;
-
-    foreach my $r (keys %$special_roles) {
-       $cfg->{roles}->{$r} = $special_roles->{$r};
-    }
-
-    # add root user if not exists
-    if (!$cfg->{users}->{'root@pam'}) {
-       $cfg->{users}->{'root@pam'}->{enable} = 1;
-    }
-}
-
-sub parse_user_config {
-    my ($filename, $raw) = @_;
-
-    my $cfg = {};
-
-    userconfig_force_defaults($cfg);
-
-    $raw = '' if !defined($raw);
-    while ($raw =~ /^\s*(.+?)\s*$/gm) {
-       my $line = $1;
-       my @data;
-
-       foreach my $d (split (/:/, $line)) {
-           $d =~ s/^\s+//;
-           $d =~ s/\s+$//;
-           push @data, $d
-       }
-
-       my $et = shift @data;
-
-       if ($et eq 'user') {
-           my ($user, $enable, $expire, $firstname, $lastname, $email, $comment, $keys) = @data;
-
-           my (undef, undef, $realm) = PVE::Auth::Plugin::verify_username($user, 1);
-           if (!$realm) {
-               warn "user config - ignore user '$user' - invalid user name\n";
-               next;
-           }
-
-           $enable = $enable ? 1 : 0;
-
-           $expire = 0 if !$expire;
-
-           if ($expire !~ m/^\d+$/) {
-               warn "user config - ignore user '$user' - (illegal characters in expire '$expire')\n";
-               next;
-           }
-           $expire = int($expire);
-
-           #if (!verify_groupname ($group, 1)) {
-           #    warn "user config - ignore user '$user' - invalid characters in group name\n";
-           #    next;
-           #}
-
-           $cfg->{users}->{$user} = {
-               enable => $enable,
-               # group => $group,
-           };
-           $cfg->{users}->{$user}->{firstname} = PVE::Tools::decode_text($firstname) if $firstname;
-           $cfg->{users}->{$user}->{lastname} = PVE::Tools::decode_text($lastname) if $lastname;
-           $cfg->{users}->{$user}->{email} = $email;
-           $cfg->{users}->{$user}->{comment} = PVE::Tools::decode_text($comment) if $comment;
-           $cfg->{users}->{$user}->{expire} = $expire;
-           # keys: allowed yubico key ids or oath secrets (base32 encoded)
-           $cfg->{users}->{$user}->{keys} = $keys if $keys;
-
-           #$cfg->{users}->{$user}->{groups}->{$group} = 1;
-           #$cfg->{groups}->{$group}->{$user} = 1;
-
-       } elsif ($et eq 'group') {
-           my ($group, $userlist, $comment) = @data;
-
-           if (!verify_groupname($group, 1)) {
-               warn "user config - ignore group '$group' - invalid characters in group name\n";
-               next;
-           }
-
-           # make sure to add the group (even if there are no members)
-           $cfg->{groups}->{$group} = { users => {} } if !$cfg->{groups}->{$group};
-
-           $cfg->{groups}->{$group}->{comment} = PVE::Tools::decode_text($comment) if $comment;
-
-           foreach my $user (split_list($userlist)) {
-
-               if (!PVE::Auth::Plugin::verify_username($user, 1)) {
-                   warn "user config - ignore invalid group member '$user'\n";
-                   next;
-               }
-
-               if ($cfg->{users}->{$user}) { # user exists
-                   $cfg->{users}->{$user}->{groups}->{$group} = 1;
-               } else {
-                   warn "user config - ignore invalid group member '$user'\n";
-               }
-               $cfg->{groups}->{$group}->{users}->{$user} = 1;
-           }
-
-       } elsif ($et eq 'role') {
-           my ($role, $privlist) = @data;
-
-           if (!verify_rolename($role, 1)) {
-               warn "user config - ignore role '$role' - invalid characters in role name\n";
-               next;
-           }
-
-           # make sure to add the role (even if there are no privileges)
-           $cfg->{roles}->{$role} = {} if !$cfg->{roles}->{$role};
-
-           foreach my $priv (split_list($privlist)) {
-               if (defined ($valid_privs->{$priv})) {
-                   $cfg->{roles}->{$role}->{$priv} = 1;
-               } else {
-                   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)) {
-
-                   if (!verify_rolename($role, 1)) {
-                       warn "user config - ignore invalid role name '$role' in acl\n";
-                       next;
-                   }
-
-                   if (!$cfg->{roles}->{$role}) {
-                       warn "user config - ignore invalid acl role '$role'\n";
-                       next;
-                   }
-
-                   foreach my $ug (split_list($uglist)) {
-                       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 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";
-                       }
-                   }
-               }
-           } else {
-               warn "user config - ignore invalid path in acl '$pathtxt'\n";
-           }
-       } elsif ($et eq 'pool') {
-           my ($pool, $comment, $vmlist, $storelist) = @data;
-
-           if (!verify_poolname($pool, 1)) {
-               warn "user config - ignore pool '$pool' - invalid characters in pool name\n";
-               next;
-           }
-
-           # make sure to add the pool (even if there are no members)
-           $cfg->{pools}->{$pool} = { vms => {}, storage => {} } if !$cfg->{pools}->{$pool};
-
-           $cfg->{pools}->{$pool}->{comment} = PVE::Tools::decode_text($comment) if $comment;
-
-           foreach my $vmid (split_list($vmlist)) {
-               if ($vmid !~ m/^\d+$/) {
-                   warn "user config - ignore invalid vmid '$vmid' in pool '$pool'\n";
-                   next;
-               }
-               $vmid = int($vmid);
-
-               if ($cfg->{vms}->{$vmid}) {
-                   warn "user config - ignore duplicate vmid '$vmid' in pool '$pool'\n";
-                   next;
-               }
-
-               $cfg->{pools}->{$pool}->{vms}->{$vmid} = 1;
-
-               # record vmid ==> pool relation
-               $cfg->{vms}->{$vmid} = $pool;
-           }
-
-           foreach my $storeid (split_list($storelist)) {
-               if ($storeid !~ m/^[a-z][a-z0-9\-\_\.]*[a-z0-9]$/i) {
-                   warn "user config - ignore invalid storage '$storeid' in pool '$pool'\n";
-                   next;
-               }
-               $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";
-       }
-    }
-
-    userconfig_force_defaults($cfg);
-
-    return $cfg;
-}
-
-sub write_user_config {
-    my ($filename, $cfg) = @_;
-
-    my $data = '';
-
-    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}) : '';
-       my $email = $d->{email} || '';
-       my $comment = $d->{comment} ? PVE::Tools::encode_text($d->{comment}) : '';
-       my $expire = int($d->{expire} || 0);
-       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 (sort keys %{$cfg->{groups}}) {
-       my $d = $cfg->{groups}->{$group};
-       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 (sort keys %{$cfg->{pools}}) {
-       my $d = $cfg->{pools}->{$pool};
-       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 (sort keys %{$cfg->{roles}}) {
-       next if $special_roles->{$role};
-
-       my $d = $cfg->{roles}->{$role};
-       my $list = join (',', sort keys %$d);
-       $data .= "role:$role:$list:\n";
-    }
-
-    $data .= "\n";
-
-    my $collect_rolelist_members = sub {
-       my ($acl_members, $result, $prefix, $exclude) = @_;
-
-       foreach my $member (keys %$acl_members) {
-           next if $exclude && $member eq $exclude;
-
-           my $l0 = '';
-           my $l1 = '';
-           foreach my $role (sort keys %{$acl_members->{$member}}) {
-               my $propagate = $acl_members->{$member}->{$role};
-               if ($propagate) {
-                   $l1 .= ',' if $l1;
-                   $l1 .= $role;
-               } else {
-                   $l0 .= ',' if $l0;
-                   $l0 .= $role;
-               }
-           }
-           $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};
-
-       my $rolelist_members = {};
-
-       $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";
-           }
-
-       }
-    }
-
-    return $data;
-}
-
-# The TFA configuration in priv/tfa.cfg format contains one line per user of
-# the form:
-#     USER:TYPE:DATA
-# DATA is a base64 encoded json string and its format depends on the type.
-sub parse_priv_tfa_config {
-    my ($filename, $raw) = @_;
-
-    my $users = {};
-    my $cfg = { users => $users };
-
-    $raw = '' if !defined($raw);
-    while ($raw =~ /^\s*(.+?)\s*$/gm) {
-       my $line = $1;
-       my ($user, $type, $data) = split(/:/, $line, 3);
-
-       my (undef, undef, $realm) = PVE::Auth::Plugin::verify_username($user, 1);
-       if (!$realm) {
-           warn "user tfa config - ignore user '$user' - invalid user name\n";
-           next;
-       }
-
-       $data = decode_json(decode_base64($data));
-
-       $users->{$user} = {
-           type => $type,
-           data => $data,
-       };
-    }
-
-    return $cfg;
-}
-
-sub write_priv_tfa_config {
-    my ($filename, $cfg) = @_;
-
-    my $output = '';
-
-    my $users = $cfg->{users};
-    foreach my $user (sort keys %$users) {
-       my $info = $users->{$user};
-       next if !%$info; # skip empty entries
-
-       $info = {%$info}; # copy to verify contents:
-
-       my $type = delete $info->{type};
-       my $data = delete $info->{data};
-
-       if (my @keys = keys %$info) {
-           die "invalid keys in TFA config for user $user: " . join(', ', @keys) . "\n";
-       }
-
-       $data = encode_base64(encode_json($data), '');
-       $output .= "${user}:${type}:${data}\n";
-    }
-
-    return $output;
-}
-
-sub roles {
-    my ($cfg, $user, $path) = @_;
-
-    # NOTE: we do not consider pools here.
-    # 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
-
-    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);
-
-       next if !(($p eq '/') || $final || ($path =~ m|^$p/|));
-
-       my $acl = $cfg->{acl}->{$p};
-
-       #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;
-           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; # user privs always override group privs
-           }
-       }
-
-       my $new;
-       foreach my $g (keys %{$acl->{groups}}) {
-           next if !$cfg->{groups}->{$g}->{users}->{$user};
-           if (my $ri = $acl->{groups}->{$g}) {
-               foreach my $role (keys %$ri) {
-                   my $propagate = $ri->{$role};
-                   if ($final || $propagate) {
-                       #print "APPLY ROLE $p \@$g $role\n";
-                       $new = {} if !$new;
-                       $new->{$role} = $propagate;
-                   }
-               }
-           }
-       }
-       if ($new) {
-           $roles = $new; # overwrite previous settings
-           next;
-       }
-    }
-
-    return { 'NoAccess' => $roles->{NoAccess} } if defined ($roles->{NoAccess});
-    #return () if defined ($roles->{NoAccess});
-
-    #print "permission $user $path = " . Dumper ($roles);
-
-    #print "roles $user $path = " . join (',', @ra) . "\n";
-
-    return $roles;
-}
-
-sub remove_vm_access {
-    my ($vmid) = @_;
-    my $delVMaccessFn = sub {
-        my $usercfg = cfs_read_file("user.cfg");
-       my $modified;
-
-        if (my $acl = $usercfg->{acl}->{"/vms/$vmid"}) {
-            delete $usercfg->{acl}->{"/vms/$vmid"};
-           $modified = 1;
-        }
-        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;
-    };
-
-    lock_user_config($delVMaccessFn, "access permissions cleanup for VM $vmid failed");
-}
-
-sub remove_storage_access {
-    my ($storeid) = @_;
-
-    my $deleteStorageAccessFn = sub {
-        my $usercfg = cfs_read_file("user.cfg");
-       my $modified;
-
-        if (my $storage = $usercfg->{acl}->{"/storage/$storeid"}) {
-            delete $usercfg->{acl}->{"/storage/$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;
-    };
-
-    lock_user_config($deleteStorageAccessFn,
-                    "access permissions cleanup for storage $storeid failed");
-}
-
-sub add_vm_to_pool {
-    my ($vmid, $pool) = @_;
-
-    my $addVMtoPoolFn = sub {
-       my $usercfg = cfs_read_file("user.cfg");
-       if (my $data = $usercfg->{pools}->{$pool}) {
-           $data->{vms}->{$vmid} = 1;
-           $usercfg->{vms}->{$vmid} = $pool;
-           cfs_write_file("user.cfg", $usercfg);
-       }
-    };
-
-    lock_user_config($addVMtoPoolFn, "can't add VM $vmid to pool '$pool'");
-}
-
-sub remove_vm_from_pool {
-    my ($vmid) = @_;
-
-    my $delVMfromPoolFn = sub {
-       my $usercfg = cfs_read_file("user.cfg");
-       if (my $pool = $usercfg->{vms}->{$vmid}) {
-           if (my $data = $usercfg->{pools}->{$pool}) {
-               delete $data->{vms}->{$vmid};
-               delete $usercfg->{vms}->{$vmid};
-               cfs_write_file("user.cfg", $usercfg);
-           }
-       }
-    };
-
-    lock_user_config($delVMfromPoolFn, "pool cleanup for VM $vmid failed");
-}
-
-my $USER_CONTROLLED_TFA_TYPES = {
-    u2f => 1,
-    oath => 1,
-};
-
-# Delete an entry by setting $data=undef in which case $type is ignored.
-# Otherwise both must be valid.
-sub user_set_tfa {
-    my ($userid, $realm, $type, $data, $cached_usercfg, $cached_domaincfg) = @_;
-
-    if (defined($data) && !defined($type)) {
-       # This is an internal usage error and should not happen
-       die "cannot set tfa data without a type\n";
-    }
-
-    my $user_cfg = $cached_usercfg || cfs_read_file('user.cfg');
-    my $user = $user_cfg->{users}->{$userid}
-       or die "user '$userid' not found\n";
-
-    my $domain_cfg = $cached_domaincfg || cfs_read_file('domains.cfg');
-    my $realm_cfg = $domain_cfg->{ids}->{$realm};
-    die "auth domain '$realm' does not exist\n" if !$realm_cfg;
-
-    my $realm_tfa = $realm_cfg->{tfa};
-    if (defined($realm_tfa)) {
-       $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_tfa);
-       # If the realm has a TFA setting, we're only allowed to use that.
-       if (defined($data)) {
-           my $required_type = $realm_tfa->{type};
-           if ($required_type ne $type) {
-               die "realm '$realm' only allows TFA of type '$required_type\n";
-           }
-
-           if (defined($data->{config})) {
-               # XXX: Is it enough if the type matches? Or should the configuration also match?
-           }
-
-           # realm-configured tfa always uses a simple key list, so use the user.cfg
-           $user->{keys} = $data->{keys};
-       } else {
-           die "realm '$realm' does not allow removing the 2nd factor\n";
-       }
-    } else {
-       # Without a realm-enforced TFA setting the user can add a u2f or totp entry by themselves.
-       # The 'yubico' type requires yubico server settings, which have to be configured on the
-       # realm, so this is not supported here:
-       die "domain '$realm' does not support TFA type '$type'\n"
-           if defined($data) && !$USER_CONTROLLED_TFA_TYPES->{$type};
-    }
-
-    # Custom TFA entries are stored in priv/tfa.cfg as they can be more complet: u2f uses a
-    # public key and a key handle, TOTP requires the usual totp settings...
-
-    my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
-    my $tfa = ($tfa_cfg->{users}->{$userid} //= {});
-
-    if (defined($data)) {
-       $tfa->{type} = $type;
-       $tfa->{data} = $data;
-       cfs_write_file('priv/tfa.cfg', $tfa_cfg);
-
-       $user->{keys} = "x!$type";
-    } else {
-       delete $tfa_cfg->{users}->{$userid};
-       cfs_write_file('priv/tfa.cfg', $tfa_cfg);
-
-       delete $user->{keys};
-    }
-
-    cfs_write_file('user.cfg', $user_cfg);
-}
-
-sub user_get_tfa {
-    my ($username, $realm) = @_;
-
-    my $user_cfg = cfs_read_file('user.cfg');
-    my $user = $user_cfg->{users}->{$username}
-       or die "user '$username' not found\n";
-
-    my $keys = $user->{keys};
-
-    my $domain_cfg = cfs_read_file('domains.cfg');
-    my $realm_cfg = $domain_cfg->{ids}->{$realm};
-    die "auth domain '$realm' does not exist\n" if !$realm_cfg;
-
-    my $realm_tfa = $realm_cfg->{tfa};
-    $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";
-    }
-
-    # new style config starts with an 'x' and optionally contains a !<type> 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});
-    }
-}
-
-# bash completion helpers
-
-register_standard_option('userid-completed',
-    get_standard_option('userid', { completion => \&complete_username}),
-);
-
-sub complete_username {
-
-    my $user_cfg = cfs_read_file('user.cfg');
-
-    return [ keys %{$user_cfg->{users}} ];
-}
-
-sub complete_group {
-
-    my $user_cfg = cfs_read_file('user.cfg');
-
-    return [ keys %{$user_cfg->{groups}} ];
-}
-
-sub complete_realm {
-
-    my $domain_cfg = cfs_read_file('domains.cfg');
-
-    return [ keys %{$domain_cfg->{ids}} ];
-}
-
-1;
diff --git a/PVE/Auth/AD.pm b/PVE/Auth/AD.pm
deleted file mode 100755 (executable)
index 88b2098..0000000
+++ /dev/null
@@ -1,148 +0,0 @@
-package PVE::Auth::AD;
-
-use strict;
-use warnings;
-use PVE::Auth::LDAP;
-use PVE::LDAP;
-
-use base qw(PVE::Auth::LDAP);
-
-sub type {
-    return 'ad';
-}
-
-sub properties {
-    return {
-       server1 => {
-           description => "Server IP address (or DNS name)",
-           type => 'string',
-           format => 'address',
-           maxLength => 256,
-       },
-       server2 => {
-           description => "Fallback Server IP address (or DNS name)",
-           type => 'string',
-           optional => 1,
-           format => 'address',
-           maxLength => 256,
-       },
-       secure => {
-           description => "Use secure LDAPS protocol. DEPRECATED: use 'mode' instead.",
-           type => 'boolean',
-           optional => 1,
-       },
-       sslversion => {
-           description => "LDAPS TLS/SSL version. It's not recommended to use version older than 1.2!",
-           type => 'string',
-           enum => [qw(tlsv1 tlsv1_1 tlsv1_2 tlsv1_3)],
-           optional => 1,
-       },
-       default => {
-           description => "Use this as default realm",
-           type => 'boolean',
-           optional => 1,
-       },
-       comment => {
-           description => "Description.",
-           type => 'string',
-           optional => 1,
-           maxLength => 4096,
-       },
-       port => {
-           description => "Server port.",
-           type => 'integer',
-           minimum => 1,
-           maximum => 65535,
-           optional => 1,
-       },
-       domain => {
-           description => "AD domain name",
-           type => 'string',
-           pattern => '\S+',
-           optional => 1,
-           maxLength => 256,
-       },
-       tfa => PVE::JSONSchema::get_standard_option('tfa'),
-    };
-}
-
-sub options {
-    return {
-       server1 => {},
-       server2 => { optional => 1 },
-       domain => {},
-       port => { optional => 1 },
-       secure => { optional => 1 },
-       sslversion => { optional => 1 },
-       default => { optional => 1 },,
-       comment => { optional => 1 },
-       tfa => { optional => 1 },
-       verify => { optional => 1 },
-       capath => { optional => 1 },
-       cert => { optional => 1 },
-       certkey => { optional => 1 },
-       base_dn => { optional => 1 },
-       bind_dn => { optional => 1 },
-       password => { optional => 1 },
-       user_attr => { optional => 1 },
-       filter => { optional => 1 },
-       sync_attributes => { optional => 1 },
-       user_classes => { optional => 1 },
-       group_dn => { optional => 1 },
-       group_name_attr => { optional => 1 },
-       group_filter => { optional => 1 },
-       group_classes => { optional => 1 },
-       'sync-defaults-options' => { optional => 1 },
-       mode => { optional => 1 },
-       'case-sensitive' => { optional => 1 },
-    };
-}
-
-sub get_users {
-    my ($class, $config, $realm) = @_;
-
-    $config->{user_attr} //= 'sAMAccountName';
-
-    return $class->SUPER::get_users($config, $realm);
-}
-
-sub authenticate_user {
-    my ($class, $config, $realm, $username, $password) = @_;
-
-    my $servers = [$config->{server1}];
-    push @$servers, $config->{server2} if $config->{server2};
-
-    my ($scheme, $port) = $class->get_scheme_and_port($config);
-
-    my %ad_args;
-    if ($config->{verify}) {
-       $ad_args{verify} = 'require';
-       $ad_args{clientcert} = $config->{cert} if $config->{cert};
-       $ad_args{clientkey} = $config->{certkey} if $config->{certkey};
-       if (defined(my $capath = $config->{capath})) {
-           if (-d $capath) {
-               $ad_args{capath} = $capath;
-           } else {
-               $ad_args{cafile} = $capath;
-           }
-       }
-    } elsif (defined($config->{verify})) {
-       $ad_args{verify} = 'none';
-    }
-
-    if ($scheme ne 'ldap') {
-       $ad_args{sslversion} = $config->{sslversion} // 'tlsv1_2';
-    }
-
-    my $ldap = PVE::LDAP::ldap_connect($servers, $scheme, $port, \%ad_args);
-
-    $username = "$username\@$config->{domain}"
-       if $username !~ m/@/ && $config->{domain};
-
-    PVE::LDAP::auth_user_dn($ldap, $username, $password);
-
-    $ldap->unbind();
-    return 1;
-}
-
-1;
diff --git a/PVE/Auth/LDAP.pm b/PVE/Auth/LDAP.pm
deleted file mode 100755 (executable)
index 97d0778..0000000
+++ /dev/null
@@ -1,454 +0,0 @@
-package PVE::Auth::LDAP;
-
-use strict;
-use warnings;
-
-use PVE::Auth::Plugin;
-use PVE::JSONSchema;
-use PVE::LDAP;
-use PVE::Tools;
-
-use base qw(PVE::Auth::Plugin);
-
-sub type {
-    return 'ldap';
-}
-
-sub properties {
-    return {
-       base_dn => {
-           description => "LDAP base domain name",
-           type => 'string',
-           pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*',
-           optional => 1,
-           maxLength => 256,
-       },
-       user_attr => {
-           description => "LDAP user attribute name",
-           type => 'string',
-           pattern => '\S{2,}',
-           optional => 1,
-           maxLength => 256,
-       },
-       bind_dn => {
-           description => "LDAP bind domain name",
-           type => 'string',
-           pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*',
-           optional => 1,
-           maxLength => 256,
-       },
-       password => {
-           description => "LDAP bind password. Will be stored in '/etc/pve/priv/realm/<REALM>.pw'.",
-           type => 'string',
-           optional => 1,
-       },
-       verify => {
-           description => "Verify the server's SSL certificate",
-           type => 'boolean',
-           optional => 1,
-           default => 0,
-       },
-       capath => {
-           description => "Path to the CA certificate store",
-           type => 'string',
-           optional => 1,
-           default => '/etc/ssl/certs',
-       },
-       cert => {
-           description => "Path to the client certificate",
-           type => 'string',
-           optional => 1,
-       },
-       certkey => {
-           description => "Path to the client certificate key",
-           type => 'string',
-           optional => 1,
-       },
-       filter => {
-           description => "LDAP filter for user sync.",
-           type => 'string',
-           optional => 1,
-           maxLength => 2048,
-       },
-       sync_attributes => {
-           description => "Comma separated list of key=value pairs for specifying"
-              ." which LDAP attributes map to which PVE user field. For example,"
-              ." to map the LDAP attribute 'mail' to PVEs 'email', write "
-              ." 'email=mail'. By default, each PVE user field is represented "
-              ." by an LDAP attribute of the same name.",
-           optional => 1,
-           type => 'string',
-           pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*',
-       },
-       user_classes => {
-           description => "The objectclasses for users.",
-           type => 'string',
-           default => 'inetorgperson, posixaccount, person, user',
-           format => 'ldap-simple-attr-list',
-           optional => 1,
-       },
-       group_dn => {
-           description => "LDAP base domain name for group sync. If not set, the"
-               ." base_dn will be used.",
-           type => 'string',
-           pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*',
-           optional => 1,
-           maxLength => 256,
-       },
-       group_name_attr => {
-           description => "LDAP attribute representing a groups name. If not set"
-               ." or found, the first value of the DN will be used as name.",
-           type => 'string',
-           format => 'ldap-simple-attr',
-           optional => 1,
-           maxLength => 256,
-       },
-       group_filter => {
-           description => "LDAP filter for group sync.",
-           type => 'string',
-           optional => 1,
-           maxLength => 2048,
-       },
-       group_classes => {
-           description => "The objectclasses for groups.",
-           type => 'string',
-           default => 'groupOfNames, group, univentionGroup, ipausergroup',
-           format => 'ldap-simple-attr-list',
-           optional => 1,
-       },
-       'sync-defaults-options' => {
-           description => "The default options for behavior of synchronizations.",
-           type => 'string',
-           format => 'realm-sync-options',
-           optional => 1,
-       },
-       mode => {
-           description => "LDAP protocol mode.",
-           type => 'string',
-           enum => [ 'ldap', 'ldaps', 'ldap+starttls'],
-           optional => 1,
-           default => 'ldap',
-       },
-        'case-sensitive' => {
-           description => "username is case-sensitive",
-           type => 'boolean',
-           optional => 1,
-           default => 1,
-       }
-    };
-}
-
-sub options {
-    return {
-       server1 => {},
-       server2 => { optional => 1 },
-       base_dn => {},
-       bind_dn => { optional => 1 },
-       password => { optional => 1 },
-       user_attr => {},
-       port => { optional => 1 },
-       secure => { optional => 1 },
-       sslversion => { optional => 1 },
-       default => { optional => 1 },
-       comment => { optional => 1 },
-       tfa => { optional => 1 },
-       verify => { optional => 1 },
-       capath => { optional => 1 },
-       cert => { optional => 1 },
-       certkey => { optional => 1 },
-       filter => { optional => 1 },
-       sync_attributes => { optional => 1 },
-       user_classes => { optional => 1 },
-       group_dn => { optional => 1 },
-       group_name_attr => { optional => 1 },
-       group_filter => { optional => 1 },
-       group_classes => { optional => 1 },
-       'sync-defaults-options' => { optional => 1 },
-       mode => { optional => 1 },
-       'case-sensitive' => { optional => 1 },
-    };
-}
-
-sub get_scheme_and_port {
-    my ($class, $config) = @_;
-
-    my $scheme = $config->{mode} // ($config->{secure} ? 'ldaps' : 'ldap');
-
-    my $default_port = $scheme eq 'ldaps' ? 636 : 389;
-    my $port = $config->{port} // $default_port;
-
-    return ($scheme, $port);
-}
-
-sub connect_and_bind {
-    my ($class, $config, $realm) = @_;
-
-    my $servers = [$config->{server1}];
-    push @$servers, $config->{server2} if $config->{server2};
-
-    my ($scheme, $port) = $class->get_scheme_and_port($config);
-
-    my %ldap_args;
-    if ($config->{verify}) {
-       $ldap_args{verify} = 'require';
-       $ldap_args{clientcert} = $config->{cert} if $config->{cert};
-       $ldap_args{clientkey} = $config->{certkey} if $config->{certkey};
-       if (defined(my $capath = $config->{capath})) {
-           if (-d $capath) {
-               $ldap_args{capath} = $capath;
-           } else {
-               $ldap_args{cafile} = $capath;
-           }
-       }
-    } else {
-       $ldap_args{verify} = 'none';
-    }
-
-    if ($scheme ne 'ldap') {
-       $ldap_args{sslversion} = $config->{sslversion} || 'tlsv1_2';
-    }
-
-    my $ldap = PVE::LDAP::ldap_connect($servers, $scheme, $port, \%ldap_args);
-
-    if ($config->{bind_dn}) {
-       my $bind_dn = $config->{bind_dn};
-       my $bind_pass = ldap_get_credentials($realm);
-       die "missing password for realm $realm\n" if !defined($bind_pass);
-       PVE::LDAP::ldap_bind($ldap, $bind_dn, $bind_pass);
-    } elsif ($config->{cert} && $config->{certkey}) {
-       warn "skipping anonymous bind with clientcert\n";
-    } else {
-       PVE::LDAP::ldap_bind($ldap);
-    }
-
-    if (!$config->{base_dn}) {
-       my $root = $ldap->root_dse(attrs => [ 'defaultNamingContext' ]);
-       $config->{base_dn} = $root->get_value('defaultNamingContext');
-    }
-
-    return $ldap;
-}
-
-# returns:
-# {
-#     'username@realm' => {
-#      'attr1' => 'value1',
-#      'attr2' => 'value2',
-#      ...
-#     },
-#     ...
-# }
-#
-# or in list context:
-# (
-#     {
-#      'username@realm' => {
-#          'attr1' => 'value1',
-#          'attr2' => 'value2',
-#          ...
-#      },
-#      ...
-#     },
-#     {
-#      'uid=username,dc=....' => 'username@realm',
-#      ...
-#     }
-# )
-# the map of dn->username is needed for group membership sync
-sub get_users {
-    my ($class, $config, $realm) = @_;
-
-    my $ldap = $class->connect_and_bind($config, $realm);
-
-    my $user_name_attr = $config->{user_attr} // 'uid';
-    my $ldap_attribute_map = {
-       $user_name_attr => 'username',
-       enable => 'enable',
-       expire => 'expire',
-       firstname => 'firstname',
-       lastname => 'lastname',
-       email => 'email',
-       comment => 'comment',
-       keys => 'keys',
-    };
-
-    foreach my $attr (PVE::Tools::split_list($config->{sync_attributes})) {
-       my ($ours, $ldap) = ($attr =~ m/^\s*(\w+)=(.*)\s*$/);
-       $ldap_attribute_map->{$ldap} = $ours;
-    }
-
-    my $filter = $config->{filter};
-    my $basedn = $config->{base_dn};
-
-    $config->{user_classes} //= 'inetorgperson, posixaccount, person, user';
-    my $classes = [PVE::Tools::split_list($config->{user_classes})];
-
-    my $users = PVE::LDAP::query_users($ldap, $filter, [keys %$ldap_attribute_map], $basedn, $classes);
-
-    my $ret = {};
-    my $dnmap = {};
-
-    foreach my $user (@$users) {
-       my $user_attributes = $user->{attributes};
-       my $userid = $user_attributes->{$user_name_attr}->[0];
-       my $username = "$userid\@$realm";
-
-       # we cannot sync usernames that do not meet our criteria
-       eval { PVE::Auth::Plugin::verify_username($username) };
-       if (my $err = $@) {
-           warn "$err";
-           next;
-       }
-
-       $ret->{$username} = {};
-
-       foreach my $attr (keys %$user_attributes) {
-           if (my $ours = $ldap_attribute_map->{$attr}) {
-               $ret->{$username}->{$ours} = $user_attributes->{$attr}->[0];
-           }
-       }
-
-       if (wantarray) {
-           my $dn = $user->{dn};
-           $dnmap->{$dn} = $username;
-       }
-    }
-
-    return wantarray ? ($ret, $dnmap) : $ret;
-}
-
-# needs a map for dn -> username, we get this from the get_users call
-# otherwise we cannot determine the group membership
-sub get_groups {
-    my ($class, $config, $realm, $dnmap) = @_;
-
-    my $filter = $config->{group_filter};
-    my $basedn = $config->{group_dn} // $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);
-
-    my $groups = PVE::LDAP::query_groups($ldap, $basedn, $classes, $filter, $attr);
-
-    my $ret = {};
-
-    foreach my $group (@$groups) {
-       my $name = $group->{name};
-       if (!$name && $group->{dn} =~ m/^[^=]+=([^,]+),/){
-           $name = PVE::Tools::trim($1);
-       }
-       if ($name) {
-           $name .= "-$realm";
-
-           # we cannot sync groups that do not meet our criteria
-           eval { PVE::AccessControl::verify_groupname($name) };
-           if (my $err = $@) {
-               warn "$err";
-               next;
-           }
-
-           $ret->{$name} = { users => {} };
-           foreach my $member (@{$group->{members}}) {
-               if (my $user = $dnmap->{$member}) {
-                   $ret->{$name}->{users}->{$user} = 1;
-               }
-           }
-       }
-    }
-
-    return $ret;
-}
-
-sub authenticate_user {
-    my ($class, $config, $realm, $username, $password) = @_;
-
-    my $ldap = $class->connect_and_bind($config, $realm);
-
-    my $user_dn = PVE::LDAP::get_user_dn($ldap, $username, $config->{user_attr}, $config->{base_dn});
-    PVE::LDAP::auth_user_dn($ldap, $user_dn, $password);
-
-    $ldap->unbind();
-    return 1;
-}
-
-my $ldap_pw_dir = "/etc/pve/priv/realm";
-
-sub ldap_cred_file_name {
-    my ($realmid) = @_;
-    return "${ldap_pw_dir}/${realmid}.pw";
-}
-
-sub get_cred_file {
-    my ($realmid) = @_;
-
-    my $cred_file = ldap_cred_file_name($realmid);
-    if (-e $cred_file) {
-       return $cred_file;
-    } elsif (-e "/etc/pve/priv/ldap/${realmid}.pw") {
-       # FIXME: remove fallback with 7.0 by doing a rename on upgrade from 6.x
-       return "/etc/pve/priv/ldap/${realmid}.pw";
-    }
-
-    return $cred_file;
-}
-
-sub ldap_set_credentials {
-    my ($password, $realmid) = @_;
-
-    my $cred_file = ldap_cred_file_name($realmid);
-    mkdir $ldap_pw_dir;
-
-    PVE::Tools::file_set_contents($cred_file, $password);
-
-    return $cred_file;
-}
-
-sub ldap_get_credentials {
-    my ($realmid) = @_;
-
-    if (my $cred_file = get_cred_file($realmid)) {
-       return PVE::Tools::file_read_firstline($cred_file);
-    }
-    return undef;
-}
-
-sub ldap_delete_credentials {
-    my ($realmid) = @_;
-
-    if (my $cred_file = get_cred_file($realmid)) {
-       return if ! -e $cred_file; # nothing to do
-       unlink($cred_file) or warn "removing LDAP credentials '$cred_file' failed: $!\n";
-    }
-}
-
-sub on_add_hook {
-    my ($class, $realm, $config, %param) = @_;
-
-    if (defined($param{password})) {
-       ldap_set_credentials($param{password}, $realm);
-    } else {
-       ldap_delete_credentials($realm);
-    }
-}
-
-sub on_update_hook {
-    my ($class, $realm, $config, %param) = @_;
-
-    return if !exists($param{password});
-
-    if (defined($param{password})) {
-       ldap_set_credentials($param{password}, $realm);
-    } else {
-       ldap_delete_credentials($realm);
-    }
-}
-
-sub on_delete_hook {
-    my ($class, $realm, $config) = @_;
-
-    ldap_delete_credentials($realm);
-}
-
-1;
diff --git a/PVE/Auth/Makefile b/PVE/Auth/Makefile
deleted file mode 100644 (file)
index 58ae362..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-
-AUTH_SOURCES=                  \
-       Plugin.pm               \
-       PVE.pm                  \
-       PAM.pm                  \
-       AD.pm                   \
-       LDAP.pm
-
-.PHONY: install
-install:
-       for i in ${AUTH_SOURCES}; do install -D -m 0644 $$i ${DESTDIR}${PERLDIR}/PVE/Auth/$$i; done
diff --git a/PVE/Auth/PAM.pm b/PVE/Auth/PAM.pm
deleted file mode 100755 (executable)
index d016f83..0000000
+++ /dev/null
@@ -1,76 +0,0 @@
-package PVE::Auth::PAM;
-
-use strict;
-use warnings;
-
-use PVE::Tools qw(run_command);
-use PVE::Auth::Plugin;
-use Authen::PAM qw(:constants);
-
-use base qw(PVE::Auth::Plugin);
-
-sub type {
-    return 'pam';
-}
-
-sub options {
-    return {
-       default => { optional => 1 },
-       comment => { optional => 1 },
-       tfa => { optional => 1 },
-    };
-}
-
-sub authenticate_user {
-    my ($class, $config, $realm, $username, $password) = @_;
-
-    # user (www-data) need to be able to read /etc/passwd /etc/shadow
-    die "no password\n" if !$password;
-
-    my $pamh = new Authen::PAM('proxmox-ve-auth', $username, sub {
-       my @res;
-       while(@_) {
-           my $msg_type = shift;
-           my $msg = shift;
-           push @res, (0, $password);
-       }
-       push @res, 0;
-       return @res;
-    });
-
-    if (!ref ($pamh)) {
-       my $err = $pamh->pam_strerror($pamh);
-       die "error during PAM init: $err";
-    }
-
-    my $res;
-
-    if (($res = $pamh->pam_authenticate(0)) != PAM_SUCCESS) {
-       my $err = $pamh->pam_strerror($res);
-       die "$err\n";
-    }
-
-    if (($res = $pamh->pam_acct_mgmt (0)) != PAM_SUCCESS) {
-       my $err = $pamh->pam_strerror($res);
-       die "$err\n";
-    }
-
-    $pamh = 0; # call destructor
-
-    return 1;
-}
-
-
-sub store_password {
-    my ($class, $config, $realm, $username, $password) = @_;
-
-    my $cmd = ['usermod'];
-
-    my $epw = PVE::Tools::encrypt_pw($password);
-
-    push @$cmd, '-p', $epw, $username;
-
-    run_command($cmd, errmsg => 'change password failed');
-}
-
-1;
diff --git a/PVE/Auth/PVE.pm b/PVE/Auth/PVE.pm
deleted file mode 100755 (executable)
index de39d35..0000000
+++ /dev/null
@@ -1,116 +0,0 @@
-package PVE::Auth::PVE;
-
-use strict;
-use warnings;
-use Encode;
-
-use PVE::Tools;
-use PVE::Auth::Plugin;
-use PVE::Cluster qw(cfs_register_file cfs_read_file cfs_write_file cfs_lock_file);
-
-use base qw(PVE::Auth::Plugin);
-
-my $shadowconfigfile = "priv/shadow.cfg";
-
-cfs_register_file($shadowconfigfile, 
-                 \&parse_shadow_passwd, 
-                 \&write_shadow_config);
-
-sub parse_shadow_passwd {
-    my ($filename, $raw) = @_;
-
-    my $shadow = {};
-
-    return $shadow if !defined($raw);
-
-    while ($raw =~ /^\s*(.+?)\s*$/gm) {
-       my $line = $1;
-
-       if ($line !~ m/^\S+:\S+:$/) {
-           warn "pve shadow password: ignore invalid line $.\n";
-           next;
-       }
-
-       my ($userid, $crypt_pass) = split (/:/, $line);
-       $shadow->{users}->{$userid}->{shadow} = $crypt_pass;
-    }
-
-    return $shadow;
-}
-
-sub write_shadow_config {
-    my ($filename, $cfg) = @_;
-
-    my $data = '';
-    foreach my $userid (keys %{$cfg->{users}}) {
-       my $crypt_pass = $cfg->{users}->{$userid}->{shadow};
-       $data .= "$userid:$crypt_pass:\n";
-    }
-
-    return $data
-}
-
-sub lock_shadow_config {
-    my ($code, $errmsg) = @_;
-
-    cfs_lock_file($shadowconfigfile, undef, $code);
-    my $err = $@;
-    if ($err) {
-       $errmsg ? die "$errmsg: $err" : die $err;
-    }
-}
-
-sub type {
-    return 'pve';
-}
-
-sub options {
-    return {
-       default => { optional => 1 },
-       comment => { optional => 1 },
-       tfa => { optional => 1 },
-    };
-}
-
-sub authenticate_user {
-    my ($class, $config, $realm, $username, $password) = @_;
-
-    die "no password\n" if !$password;
-
-    my $shadow_cfg = cfs_read_file($shadowconfigfile);
-    
-    if ($shadow_cfg->{users}->{$username}) {
-       my $encpw = crypt(Encode::encode('utf8', $password),
-                         $shadow_cfg->{users}->{$username}->{shadow});
-       die "invalid credentials\n" if ($encpw ne $shadow_cfg->{users}->{$username}->{shadow});
-    } else {
-       die "no password set\n";
-    }
-
-    return 1;
-}
-
-sub store_password {
-    my ($class, $config, $realm, $username, $password) = @_;
-
-    lock_shadow_config(sub {
-       my $shadow_cfg = cfs_read_file($shadowconfigfile);
-       my $epw = PVE::Tools::encrypt_pw($password);
-       $shadow_cfg->{users}->{$username}->{shadow} = $epw;
-       cfs_write_file($shadowconfigfile, $shadow_cfg);
-    });
-}
-
-sub delete_user {
-    my ($class, $config, $realm, $username) = @_;
-    lock_shadow_config(sub {
-       my $shadow_cfg = cfs_read_file($shadowconfigfile);
-
-       delete $shadow_cfg->{users}->{$username};
-
-       cfs_write_file($shadowconfigfile, $shadow_cfg);
-   });
-}
-
-1;
diff --git a/PVE/Auth/Plugin.pm b/PVE/Auth/Plugin.pm
deleted file mode 100755 (executable)
index 1413053..0000000
+++ /dev/null
@@ -1,299 +0,0 @@
-package PVE::Auth::Plugin;
-
-use strict;
-use warnings;
-
-use Digest::SHA;
-use Encode;
-
-use PVE::Cluster qw(cfs_register_file cfs_read_file cfs_lock_file);
-use PVE::JSONSchema qw(get_standard_option);
-use PVE::SectionConfig;
-use PVE::Tools;
-
-use base qw(PVE::SectionConfig);
-
-my $domainconfigfile = "domains.cfg";
-
-cfs_register_file($domainconfigfile,
-                 sub { __PACKAGE__->parse_config(@_); },
-                 sub { __PACKAGE__->write_config(@_); });
-
-sub lock_domain_config {
-    my ($code, $errmsg) = @_;
-
-    cfs_lock_file($domainconfigfile, undef, $code);
-    my $err = $@;
-    if ($err) {
-       $errmsg ? die "$errmsg: $err" : die $err;
-    }
-}
-
-our $realm_regex = qr/[A-Za-z][A-Za-z0-9\.\-_]+/;
-our $user_regex = qr![^\s:/]+!;
-
-PVE::JSONSchema::register_format('pve-realm', \&pve_verify_realm);
-sub pve_verify_realm {
-    my ($realm, $noerr) = @_;
-
-    if ($realm !~ m/^${realm_regex}$/) {
-       return undef if $noerr;
-       die "value does not look like a valid realm\n";
-    }
-    return $realm;
-}
-
-PVE::JSONSchema::register_standard_option('realm', {
-    description => "Authentication domain ID",
-    type => 'string', format => 'pve-realm',
-    maxLength => 32,
-});
-
-my $realm_sync_options_desc = {
-    scope => {
-       description => "Select what to sync.",
-       type => 'string',
-       enum => [qw(users groups both)],
-       optional => '1',
-    },
-    full => {
-       description => "If set, uses the LDAP Directory as source of truth,"
-           ." deleting users or groups not returned from the sync. Otherwise"
-           ." only syncs information which is not already present, and does not"
-           ." deletes or modifies anything else.",
-       type => 'boolean',
-       optional => '1',
-    },
-    'enable-new' => {
-       description => "Enable newly synced users immediately.",
-       type => 'boolean',
-       default => '1',
-       optional => '1',
-    },
-    purge => {
-       description => "Remove ACLs for users or groups which were removed from"
-           ." the config during a sync.",
-       type => 'boolean',
-       optional => '1',
-    },
-};
-PVE::JSONSchema::register_standard_option('realm-sync-options', $realm_sync_options_desc);
-PVE::JSONSchema::register_format('realm-sync-options', $realm_sync_options_desc);
-
-PVE::JSONSchema::register_format('pve-userid', \&verify_username);
-sub verify_username {
-    my ($username, $noerr) = @_;
-
-    $username = '' if !$username;
-    my $len = length($username);
-    if ($len < 3) {
-       die "user name '$username' is too short\n" if !$noerr;
-       return undef;
-    }
-    if ($len > 64) {
-       die "user name '$username' is too long ($len > 64)\n" if !$noerr;
-       return undef;
-    }
-
-    # we only allow a limited set of characters
-    # colon is not allowed, because we store usernames in
-    # colon separated lists)!
-    # slash is not allowed because it is used as pve API delimiter
-    # also see "man useradd"
-    if ($username =~ m!^(${user_regex})\@(${realm_regex})$!) {
-       return wantarray ? ($username, $1, $2) : $username;
-    }
-
-    die "value '$username' does not look like a valid user name\n" if !$noerr;
-
-    return undef;
-}
-
-PVE::JSONSchema::register_standard_option('userid', {
-    description => "User ID",
-    type => 'string', format => 'pve-userid',
-    maxLength => 64,
-});
-
-my $tfa_format = {
-    type => {
-        description => "The type of 2nd factor authentication.",
-        format_description => 'TFATYPE',
-        type => 'string',
-        enum => [qw(yubico oath)],
-    },
-    id => {
-        description => "Yubico API ID.",
-        format_description => 'ID',
-        type => 'string',
-        optional => 1,
-    },
-    key => {
-        description => "Yubico API Key.",
-        format_description => 'KEY',
-        type => 'string',
-        optional => 1,
-    },
-    url => {
-        description => "Yubico API URL.",
-        format_description => 'URL',
-        type => 'string',
-        optional => 1,
-    },
-    digits => {
-        description => "TOTP digits.",
-        format_description => 'COUNT',
-        type => 'integer',
-        minimum => 6, maximum => 8,
-        default => 6,
-        optional => 1,
-    },
-    step => {
-        description => "TOTP time period.",
-        format_description => 'SECONDS',
-        type => 'integer',
-        minimum => 10,
-        default => 30,
-        optional => 1,
-    },
-};
-
-PVE::JSONSchema::register_format('pve-tfa-config', $tfa_format);
-
-PVE::JSONSchema::register_standard_option('tfa', {
-    description => "Use Two-factor authentication.",
-    type => 'string', format => 'pve-tfa-config',
-    optional => 1,
-    maxLength => 128,
-});
-
-sub parse_tfa_config {
-    my ($data) = @_;
-
-    return PVE::JSONSchema::parse_property_string($tfa_format, $data);
-}
-
-my $defaultData = {
-    propertyList => {
-       type => { description => "Realm type." },
-       realm => get_standard_option('realm'),
-    },
-};
-
-sub private {
-    return $defaultData;
-}
-
-sub parse_section_header {
-    my ($class, $line) = @_;
-
-    if ($line =~ m/^(\S+):\s*(\S+)\s*$/) {
-       my ($type, $realm) = (lc($1), $2);
-       my $errmsg = undef; # set if you want to skip whole section
-       eval { pve_verify_realm($realm); };
-       $errmsg = $@ if $@;
-       my $config = {}; # to return additional attributes
-       return ($type, $realm, $errmsg, $config);
-    }
-    return undef;
-}
-
-sub parse_config {
-    my ($class, $filename, $raw) = @_;
-
-    my $cfg = $class->SUPER::parse_config($filename, $raw);
-
-    my $default;
-    foreach my $realm (keys %{$cfg->{ids}}) {
-       my $data = $cfg->{ids}->{$realm};
-       # make sure there is only one default marker
-       if ($data->{default}) {
-           if ($default) {
-               delete $data->{default};
-           } else {
-               $default = $realm;
-           }
-       }
-
-       if ($data->{comment}) {
-           $data->{comment} = PVE::Tools::decode_text($data->{comment});
-       }
-
-    }
-
-    # add default domains
-
-    $cfg->{ids}->{pve}->{type} = 'pve'; # force type
-    $cfg->{ids}->{pve}->{comment} = "Proxmox VE authentication server"
-       if !$cfg->{ids}->{pve}->{comment};
-
-    $cfg->{ids}->{pam}->{type} = 'pam'; # force type
-    $cfg->{ids}->{pam}->{plugin} =  'PVE::Auth::PAM';
-    $cfg->{ids}->{pam}->{comment} = "Linux PAM standard authentication"
-       if !$cfg->{ids}->{pam}->{comment};
-
-    return $cfg;
-};
-
-sub write_config {
-    my ($class, $filename, $cfg) = @_;
-
-    foreach my $realm (keys %{$cfg->{ids}}) {
-       my $data = $cfg->{ids}->{$realm};
-       if ($data->{comment}) {
-           $data->{comment} = PVE::Tools::encode_text($data->{comment});
-       }
-    }
-
-    $class->SUPER::write_config($filename, $cfg);
-}
-
-sub authenticate_user {
-    my ($class, $config, $realm, $username, $password) = @_;
-
-    die "overwrite me";
-}
-
-sub store_password {
-    my ($class, $config, $realm, $username, $password) = @_;
-
-    my $type = $class->type();
-
-    die "can't set password on auth type '$type'\n";
-}
-
-sub delete_user {
-    my ($class, $config, $realm, $username) = @_;
-
-    # do nothing by default
-}
-
-# called during addition of realm (before the new domain config got written)
-# `password` is moved to %param to avoid writing it out to the config
-# die to abort additon if there are (grave) problems
-# NOTE: runs in a domain config *locked* context
-sub on_add_hook {
-    my ($class, $realm, $config, %param) = @_;
-    # do nothing by default
-}
-
-# called during domain configuration update (before the updated domain config got
-# written). `password` is moved to %param to avoid writing it out to the config
-# die to abort the update if there are (grave) problems
-# NOTE: runs in a domain config *locked* context
-sub on_update_hook {
-    my ($class, $realm, $config, %param) = @_;
-    # do nothing by default
-}
-
-# called during deletion of realms (before the new domain config got written)
-# and if the activate check on addition fails, to cleanup all storage traces
-# which on_add_hook may have created.
-# die to abort deletion if there are (very grave) problems
-# NOTE: runs in a storage config *locked* context
-sub on_delete_hook {
-    my ($class, $realm, $config) = @_;
-    # do nothing by default
-}
-
-1;
diff --git a/PVE/CLI/Makefile b/PVE/CLI/Makefile
deleted file mode 100644 (file)
index 3054212..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-SOURCES=pveum.pm
-
-.PHONY: install
-install: ${SOURCES}
-       install -d -m 0755 ${DESTDIR}${PERLDIR}/PVE/CLI
-       for i in ${SOURCES}; do install -D -m 0644 $$i ${DESTDIR}${PERLDIR}/PVE/CLI/$$i; done
-
-
-clean:
diff --git a/PVE/CLI/pveum.pm b/PVE/CLI/pveum.pm
deleted file mode 100755 (executable)
index cbbdb7d..0000000
+++ /dev/null
@@ -1,187 +0,0 @@
-package PVE::CLI::pveum;
-
-use strict;
-use warnings;
-
-use PVE::AccessControl;
-use PVE::RPCEnvironment;
-use PVE::API2::User;
-use PVE::API2::Group;
-use PVE::API2::Role;
-use PVE::API2::ACL;
-use PVE::API2::AccessControl;
-use PVE::API2::Pool;
-use PVE::API2::Domains;
-use PVE::CLIFormatter;
-use PVE::CLIHandler;
-use PVE::JSONSchema qw(get_standard_option);
-use PVE::PTY;
-use PVE::RESTHandler;
-use PVE::Tools qw(extract_param);
-
-use base qw(PVE::CLIHandler);
-
-sub setup_environment {
-    PVE::RPCEnvironment->setup_default_cli_env();
-}
-
-sub param_mapping {
-    my ($name) = @_;
-
-    my $mapping = {
-       'change_password' => [
-           PVE::CLIHandler::get_standard_mapping('pve-password'),
-       ],
-       'create_ticket' => [
-           PVE::CLIHandler::get_standard_mapping('pve-password', {
-               func => sub {
-                   # do not accept values given on cmdline
-                   return PVE::PTY::read_password('Enter password: ');
-               },
-           }),
-       ]
-    };
-
-    return $mapping->{$name};
-}
-
-my $print_api_result = sub {
-    my ($data, $schema, $options) = @_;
-    PVE::CLIFormatter::print_api_result($data, $schema, undef, $options);
-};
-
-my $print_perm_result = sub {
-    my ($data, $schema, $options) = @_;
-
-    if (!defined($options->{'output-format'}) || $options->{'output-format'} eq 'text') {
-       my $table_schema = {
-           type => 'array',
-           items => {
-               type => 'object',
-               properties => {
-                   'path' => { type => 'string', title => 'ACL path' },
-                   'permissions' => { type => 'string', title => 'Permissions' },
-               },
-           },
-       };
-       my $table_data = [];
-       foreach my $path (sort keys %$data) {
-           my $value = '';
-           my $curr = $data->{$path};
-           foreach my $perm (sort keys %$curr) {
-               $value .= "\n" if $value;
-               $value .= $perm;
-               $value .= " (*)" if $curr->{$perm};
-           }
-           push @$table_data, { path => $path, permissions => $value };
-       }
-       PVE::CLIFormatter::print_api_result($table_data, $table_schema, undef, $options);
-       print "Permissions marked with '(*)' have the 'propagate' flag set.\n";
-    } else {
-       PVE::CLIFormatter::print_api_result($data, $schema, undef, $options);
-    }
-};
-
-__PACKAGE__->register_method({
-    name => 'token_permissions',
-    path => 'token_permissions',
-    method => 'GET',
-    description => 'Retrieve effective permissions of given token.',
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           userid => get_standard_option('userid'),
-           tokenid => get_standard_option('token-subid'),
-           path => get_standard_option('acl-path', {
-               description => "Only dump this specific path, not the whole tree.",
-               optional => 1,
-           }),
-       },
-    },
-    returns => {
-       type => 'object',
-       description => 'Hash of structure "path" => "privilege" => "propagate boolean".',
-    },
-    code => sub {
-       my ($param) = @_;
-
-       my $token_subid = extract_param($param, "tokenid");
-       $param->{userid} = PVE::AccessControl::join_tokenid($param->{userid}, $token_subid);
-
-       return PVE::API2::AccessControl->permissions($param);
-    }});
-
-our $cmddef = {
-    user => {
-       add    => [ 'PVE::API2::User', 'create_user', ['userid'] ],
-       modify => [ 'PVE::API2::User', 'update_user', ['userid'] ],
-       delete => [ 'PVE::API2::User', 'delete_user', ['userid'] ],
-       list   => [ 'PVE::API2::User', 'index', [], {}, $print_api_result, $PVE::RESTHandler::standard_output_options],
-       permissions => [ 'PVE::API2::AccessControl', 'permissions', ['userid'], {}, $print_perm_result, $PVE::RESTHandler::standard_output_options],
-       tfa => {
-           delete => [ 'PVE::API2::AccessControl', 'change_tfa', ['userid'], { action => 'delete', key => undef, config => undef, response => undef, }, ],
-       },
-       token => {
-           add    => [ 'PVE::API2::User', 'generate_token', ['userid', 'tokenid'], {}, $print_api_result, $PVE::RESTHandler::standard_output_options ],
-           modify    => [ 'PVE::API2::User', 'update_token_info', ['userid', 'tokenid'], {}, $print_api_result, $PVE::RESTHandler::standard_output_options ],
-           remove    => [ 'PVE::API2::User', 'remove_token', ['userid', 'tokenid'], {}, $print_api_result, $PVE::RESTHandler::standard_output_options ],
-           list   => [ 'PVE::API2::User', 'token_index', ['userid'], {}, $print_api_result, $PVE::RESTHandler::standard_output_options],
-           permissions => [ __PACKAGE__, 'token_permissions', ['userid', 'tokenid'], {}, $print_perm_result, $PVE::RESTHandler::standard_output_options],
-       }
-    },
-    group => {
-       add    => [ 'PVE::API2::Group', 'create_group', ['groupid'] ],
-       modify => [ 'PVE::API2::Group', 'update_group', ['groupid'] ],
-       delete => [ 'PVE::API2::Group', 'delete_group', ['groupid'] ],
-       list   => [ 'PVE::API2::Group', 'index', [], {}, $print_api_result, $PVE::RESTHandler::standard_output_options],
-    },
-    role => {
-       add    => [ 'PVE::API2::Role', 'create_role', ['roleid'] ],
-       modify => [ 'PVE::API2::Role', 'update_role', ['roleid'] ],
-       delete => [ 'PVE::API2::Role', 'delete_role', ['roleid'] ],
-       list   => [ 'PVE::API2::Role', 'index', [], {}, $print_api_result, $PVE::RESTHandler::standard_output_options],
-    },
-    acl => {
-       modify => [ 'PVE::API2::ACL', 'update_acl', ['path'], { delete => 0 }],
-       delete => [ 'PVE::API2::ACL', 'update_acl', ['path'], { delete => 1 }],
-       list   => [ 'PVE::API2::ACL', 'read_acl', [], {}, $print_api_result, $PVE::RESTHandler::standard_output_options],
-    },
-    pool => {
-       add => [ 'PVE::API2::Pool', 'create_pool', ['poolid'] ],
-       modify => [ 'PVE::API2::Pool', 'update_pool', ['poolid'] ],
-       delete => [ 'PVE::API2::Pool', 'delete_pool', ['poolid'] ],
-       list   => [ 'PVE::API2::Pool', 'index', [], {}, $print_api_result, $PVE::RESTHandler::standard_output_options],
-    },
-    realm => {
-       add    => [ 'PVE::API2::Domains', 'create', ['realm'] ],
-       modify => [ 'PVE::API2::Domains', 'update', ['realm'] ],
-       delete => [ 'PVE::API2::Domains', 'delete', ['realm'] ],
-       list   => [ 'PVE::API2::Domains', 'index', [], {}, $print_api_result, $PVE::RESTHandler::standard_output_options],
-       sync   => [ 'PVE::API2::Domains', 'sync', ['realm'], ],
-    },
-
-    ticket => [ 'PVE::API2::AccessControl', 'create_ticket', ['username'], undef,
-               sub {
-                   my ($res) = @_;
-                   print "$res->{ticket}\n";
-               }],
-
-    passwd => [ 'PVE::API2::AccessControl', 'change_password', ['userid'] ],
-
-    useradd => { alias => 'user add' },
-    usermod => { alias => 'user modify' },
-    userdel => { alias => 'user delete' },
-
-    groupadd => { alias => 'group add' },
-    groupmod => { alias => 'group modify' },
-    groupdel => { alias => 'group delete' },
-
-    roleadd => { alias => 'role add' },
-    rolemod => { alias => 'role modify' },
-    roledel => { alias => 'role delete' },
-
-    aclmod => { alias => 'acl modify' },
-    acldel => { alias => 'acl delete' },
-};
-
-1;
diff --git a/PVE/Makefile b/PVE/Makefile
deleted file mode 100644 (file)
index c839d8f..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-.PHONY: install
-install:
-       make -C Auth install
-       install -D -m 0644 AccessControl.pm ${DESTDIR}${PERLDIR}/PVE/AccessControl.pm
-       install -D -m 0644 RPCEnvironment.pm ${DESTDIR}${PERLDIR}/PVE/RPCEnvironment.pm
-       install -D -m 0644 TokenConfig.pm ${DESTDIR}${PERLDIR}/PVE/TokenConfig.pm
-       make -C API2 install
-       make -C CLI install
diff --git a/PVE/RPCEnvironment.pm b/PVE/RPCEnvironment.pm
deleted file mode 100644 (file)
index e66107b..0000000
+++ /dev/null
@@ -1,493 +0,0 @@
-package PVE::RPCEnvironment;
-
-use strict;
-use warnings;
-
-use PVE::RESTEnvironment;
-
-use PVE::Exception qw(raise raise_perm_exc);
-use PVE::SafeSyslog;
-use PVE::Tools;
-use PVE::INotify;
-use PVE::Cluster;
-use PVE::ProcFSTools;
-use PVE::AccessControl;
-
-use base qw(PVE::RESTEnvironment);
-
-# ACL cache
-
-my $compile_acl_path = sub {
-    my ($self, $user, $path) = @_;
-
-    my $cfg = $self->{user_cfg};
-
-    return undef if !$cfg->{roles};
-
-    die "internal error" if $user eq 'root@pam';
-
-    my $cache = $self->{aclcache};
-    $cache->{$user} = {} if !$cache->{$user};
-    my $data = $cache->{$user};
-
-    my ($username, undef) = PVE::AccessControl::split_tokenid($user, 1);
-    die "internal error" if $username && $username ne 'root@pam' && !defined($cache->{$username});
-
-    if (!$data->{poolroles}) {
-       $data->{poolroles} = {};
-
-       foreach my $pool (keys %{$cfg->{pools}}) {
-           my $d = $cfg->{pools}->{$pool};
-           my $pool_roles = PVE::AccessControl::roles($cfg, $user, "/pool/$pool"); # pool roles
-           next if !scalar(keys %$pool_roles);
-           foreach my $vmid (keys %{$d->{vms}}) {
-               for my $role (keys %$pool_roles) {
-                   $data->{poolroles}->{"/vms/$vmid"}->{$role} = 1;
-               }
-           }
-           foreach my $storeid (keys %{$d->{storage}}) {
-               for my $role (keys %$pool_roles) {
-                   $data->{poolroles}->{"/storage/$storeid"}->{$role} = 1;
-               }
-           }
-       }
-    }
-
-    my $roles = PVE::AccessControl::roles($cfg, $user, $path);
-
-    # apply roles inherited from pools
-    # Note: assume we do not want to propagate those privs
-    if ($data->{poolroles}->{$path}) {
-       if (!defined($roles->{NoAccess})) {
-           if ($data->{poolroles}->{$path}->{NoAccess}) {
-               $roles = { 'NoAccess' => 0 };
-           } else {
-               foreach my $role (keys %{$data->{poolroles}->{$path}}) {
-                   $roles->{$role} = 0 if !defined($roles->{$role});
-               }
-           }
-       }
-    }
-
-    $data->{roles}->{$path} = $roles;
-
-    my $privs = {};
-    foreach my $role (keys %$roles) {
-       if (my $privset = $cfg->{roles}->{$role}) {
-           foreach my $p (keys %$privset) {
-               $privs->{$p} = $roles->{$role};
-           }
-       }
-    }
-
-    if ($username && $username ne 'root@pam') {
-       # intersect user and token permissions
-       my $user_privs = $cache->{$username}->{privs}->{$path};
-       $privs = { map { $_ => $user_privs->{$_} && $privs->{$_} } keys %$privs };
-    }
-
-    $data->{privs}->{$path} = $privs;
-
-    return $privs;
-};
-
-sub permissions {
-    my ($self, $user, $path) = @_;
-
-    if ($user eq 'root@pam') { # root can do anything
-       my $cfg = $self->{user_cfg};
-       return { map { $_ => 1 } keys %{$cfg->{roles}->{'Administrator'}} };
-    }
-
-    if (PVE::AccessControl::pve_verify_tokenid($user, 1)) {
-       my ($username, $token) = PVE::AccessControl::split_tokenid($user);
-       my $cfg = $self->{user_cfg};
-       my $token_info = $cfg->{users}->{$username}->{tokens}->{$token};
-
-       return {} if !$token_info;
-
-       # ensure cache for user is populated
-       my $user_perms = $self->permissions($username, $path);
-
-       # return user privs for non-privsep tokens
-       return $user_perms if !$token_info->{privsep};
-    } else {
-       $user = PVE::AccessControl::verify_username($user, 1);
-       return {} if !$user;
-    }
-
-    my $cache = $self->{aclcache};
-    $cache->{$user} = {} if !$cache->{$user};
-
-    my $acl = $cache->{$user};
-
-    my $perm = $acl->{privs}->{$path};
-    return $perm if $perm;
-
-    return &$compile_acl_path($self, $user, $path);
-}
-
-sub get_effective_permissions {
-    my ($self, $user) = @_;
-
-    # default / top level paths
-    my $paths = {
-       '/' => 1,
-       '/access' => 1,
-       '/access/groups' => 1,
-       '/nodes' => 1,
-       '/pools' => 1,
-       '/storage' => 1,
-       '/vms' => 1,
-    };
-
-    my $cfg = $self->{user_cfg};
-
-    # paths explicitly listed in ACLs
-    foreach my $acl_path (keys %{$cfg->{acl}}) {
-       $paths->{$acl_path} = 1;
-    }
-
-    # paths referenced by pool definitions
-    foreach my $pool (keys %{$cfg->{pools}}) {
-       my $d = $cfg->{pools}->{$pool};
-       foreach my $vmid (keys %{$d->{vms}}) {
-           $paths->{"/vms/$vmid"} = 1;
-       }
-       foreach my $storeid (keys %{$d->{storage}}) {
-           $paths->{"/storage/$storeid"} = 1;
-       }
-    }
-
-    my $perms = {};
-    foreach my $path (keys %$paths) {
-       my $path_perms = $self->permissions($user, $path);
-       # filter paths where user has NO permissions
-       $perms->{$path} = $path_perms if %$path_perms;
-    }
-    return $perms;
-}
-
-sub check {
-    my ($self, $user, $path, $privs, $noerr) = @_;
-
-    my $perm = $self->permissions($user, $path);
-
-    foreach my $priv (@$privs) {
-       PVE::AccessControl::verify_privname($priv);
-       if (!defined($perm->{$priv})) {
-           return undef if $noerr;
-           raise_perm_exc("$path, $priv");
-       }
-    };
-
-    return 1;
-};
-
-sub check_any {
-    my ($self, $user, $path, $privs, $noerr) = @_;
-
-    my $perm = $self->permissions($user, $path);
-
-    my $found = 0;
-    foreach my $priv (@$privs) {
-       PVE::AccessControl::verify_privname($priv);
-       if (defined($perm->{$priv})) {
-           $found = 1;
-           last;
-       }
-    };
-
-    return 1 if $found;
-
-    return undef if $noerr;
-
-    raise_perm_exc("$path, " . join("|", @$privs));
-};
-
-sub check_full {
-    my ($self, $username, $path, $privs, $any, $noerr) = @_;
-    if ($any) {
-       return $self->check_any($username, $path, $privs, $noerr);
-    } else {
-       return $self->check($username, $path, $privs, $noerr);
-    }
-}
-
-sub check_user_enabled {
-    my ($self, $user, $noerr) = @_;
-
-    my $cfg = $self->{user_cfg};
-    return PVE::AccessControl::check_user_enabled($cfg, $user, $noerr);
-}
-
-sub check_user_exist {
-    my ($self, $user, $noerr) = @_;
-
-    my $cfg = $self->{user_cfg};
-    return PVE::AccessControl::check_user_exist($cfg, $user, $noerr);
-}
-
-sub check_pool_exist {
-    my ($self, $pool, $noerr) = @_;
-
-    my $cfg = $self->{user_cfg};
-
-    return 1 if $cfg->{pools}->{$pool};
-
-    return undef if $noerr;
-
-    raise_perm_exc("pool '$pool' does not exist");
-}
-
-sub check_vm_perm {
-    my ($self, $user, $vmid, $pool, $privs, $any, $noerr) = @_;
-
-    my $cfg = $self->{user_cfg};
-
-    if ($pool) {
-       return if $self->check_full($user, "/pool/$pool", $privs, $any, 1);
-    }
-    return $self->check_full($user, "/vms/$vmid", $privs, $any, $noerr);
-};
-
-sub is_group_member {
-    my ($self, $group, $user) = @_;
-
-    my $cfg = $self->{user_cfg};
-
-    return 0 if !$cfg->{groups}->{$group};
-
-    return defined($cfg->{groups}->{$group}->{users}->{$user});
-}
-
-sub filter_groups {
-    my ($self, $user, $privs, $any) = @_;
-
-    my $cfg = $self->{user_cfg};
-
-    my $groups = {};
-    foreach my $group (keys %{$cfg->{groups}}) {
-       my $path = "/access/groups/$group";
-       if ($self->check_full($user, $path, $privs, $any, 1)) {
-           $groups->{$group} = $cfg->{groups}->{$group};
-       }
-    }
-
-    return $groups;
-}
-
-sub group_member_join {
-    my ($self, $grouplist) = @_;
-
-    my $users = {};
-
-    my $cfg = $self->{user_cfg};
-    foreach my $group (@$grouplist) {
-       my $data = $cfg->{groups}->{$group};
-       next if !$data;
-       foreach my $user (keys %{$data->{users}}) {
-           $users->{$user} = 1;
-       }
-    }
-
-    return $users;
-}
-
-sub check_perm_modify {
-    my ($self, $username, $path, $noerr) = @_;
-
-    return $self->check($username, '/access', [ 'Permissions.Modify' ], $noerr) if !$path;
-
-    my $testperms = [ 'Permissions.Modify' ];
-    if ($path =~ m|^/storage/.+$|) {
-       push @$testperms, 'Datastore.Allocate';
-    } elsif ($path =~ m|^/vms/.+$|) {
-       push @$testperms, 'VM.Allocate';
-    } elsif ($path =~ m|^/pool/.+$|) {
-       push @$testperms, 'Pool.Allocate';
-    }
-
-    return $self->check_any($username, $path, $testperms, $noerr);
-}
-
-sub exec_api2_perm_check {
-    my ($self, $check, $username, $param, $noerr) = @_;
-
-    # syslog("info", "CHECK " . join(', ', @$check));
-
-    my $ind = 0;
-    my $test = $check->[$ind++];
-    die "no permission test specified" if !$test;
-
-    if ($test eq 'and') {
-       while (my $subcheck = $check->[$ind++]) {
-           $self->exec_api2_perm_check($subcheck, $username, $param);
-       }
-       return 1;
-    } elsif ($test eq 'or') {
-       while (my $subcheck = $check->[$ind++]) {
-           return 1 if $self->exec_api2_perm_check($subcheck, $username, $param, 1);
-       }
-       return 0 if $noerr;
-       raise_perm_exc();
-    } elsif ($test eq 'perm') {
-       my ($t, $tmplpath, $privs, %options) = @$check;
-       my $any = $options{any};
-       die "missing parameters" if !($tmplpath && $privs);
-       my $require_param = $options{require_param};
-       if ($require_param && !defined($param->{$require_param})) {
-           return 0 if $noerr;
-           raise_perm_exc();
-       }
-       my $path = PVE::Tools::template_replace($tmplpath, $param);
-       $path = PVE::AccessControl::normalize_path($path);
-       return $self->check_full($username, $path, $privs, $any, $noerr);
-    } elsif ($test eq 'userid-group') {
-       my $userid = $param->{userid};
-       my ($t, $privs, %options) = @$check;
-       return 0 if !$options{groups_param} && !$self->check_user_exist($userid, $noerr);
-       if (!$self->check_any($username, "/access/groups", $privs, 1)) {
-           my $groups = $self->filter_groups($username, $privs, 1);
-           if ($options{groups_param}) {
-               my @group_param = PVE::Tools::split_list($param->{groups});
-               raise_perm_exc("/access/groups, " . join("|", @$privs)) if !scalar(@group_param);
-               foreach my $pg (@group_param) {
-                   raise_perm_exc("/access/groups/$pg, " . join("|", @$privs))
-                       if !$groups->{$pg};
-               }
-           } else {
-               my $allowed_users = $self->group_member_join([keys %$groups]);
-               if (!$allowed_users->{$userid}) {
-                   return 0 if $noerr;
-                   raise_perm_exc();
-               }
-           }
-       }
-       return 1;
-    } elsif ($test eq 'userid-param') {
-       my ($userid, undef, $realm) = PVE::AccessControl::verify_username($param->{userid});
-       my ($t, $subtest) = @$check;
-       die "missing parameters" if !$subtest;
-       if ($subtest eq 'self') {
-           return 0 if !$self->check_user_exist($userid, $noerr);
-           return 1 if $username eq $userid;
-           return 0 if $noerr;
-           raise_perm_exc();
-       } elsif ($subtest eq 'Realm.AllocateUser') {
-           my $path =  "/access/realm/$realm";
-           return $self->check($username, $path, ['Realm.AllocateUser'], $noerr);
-       } else {
-           die "unknown userid-param test";
-       }
-     } elsif ($test eq 'perm-modify') {
-       my ($t, $tmplpath) = @$check;
-       my $path = PVE::Tools::template_replace($tmplpath, $param);
-       $path = PVE::AccessControl::normalize_path($path);
-       return $self->check_perm_modify($username, $path, $noerr);
-   } else {
-       die "unknown permission test";
-    }
-};
-
-sub check_api2_permissions {
-    my ($self, $perm, $username, $param) = @_;
-
-    return 1 if !$username && $perm->{user} && $perm->{user} eq 'world';
-
-    raise_perm_exc("user != null") if !$username;
-
-    return 1 if $username eq 'root@pam';
-
-    raise_perm_exc('user != root@pam') if !$perm;
-
-    return 1 if $perm->{user} && $perm->{user} eq 'all';
-
-    return $self->exec_api2_perm_check($perm->{check}, $username, $param)
-       if $perm->{check};
-
-    raise_perm_exc();
-}
-
-sub log_cluster_msg {
-    my ($self, $pri, $user, $msg) = @_;
-
-    PVE::Cluster::log_msg($pri, $user, $msg);
-}
-
-sub broadcast_tasklist {
-    my ($self, $tlist) = @_;
-
-    PVE::Cluster::broadcast_tasklist($tlist);
-}
-
-# initialize environment - must be called once at program startup
-sub init {
-    my ($class, $type, %params) = @_;
-
-    $class = ref($class) || $class;
-
-    my $self = $class->SUPER::init($type, %params);
-
-    $self->{user_cfg} = {};
-    $self->{aclcache} = {};
-    $self->{aclversion} = undef;
-
-    return $self;
-};
-
-
-# init_request - must be called before each RPC request
-sub init_request {
-    my ($self, %params) = @_;
-
-    PVE::Cluster::cfs_update();
-
-    $self->{result_attributes} = {};
-
-    my $userconfig; # we use this for regression tests
-    foreach my $p (keys %params) {
-       if ($p eq 'userconfig') {
-           $userconfig = $params{$p};
-       } else {
-           die "unknown parameter '$p'";
-       }
-    }
-
-    eval {
-       $self->{aclcache} = {};
-       if ($userconfig) {
-           my $ucdata = PVE::Tools::file_get_contents($userconfig);
-           my $cfg = PVE::AccessControl::parse_user_config($userconfig, $ucdata);
-           $self->{user_cfg} = $cfg;
-       } else {
-           my $ucvers = PVE::Cluster::cfs_file_version('user.cfg');
-           if (!$self->{aclcache} || !defined($self->{aclversion}) ||
-               !defined($ucvers) ||  ($ucvers ne $self->{aclversion})) {
-               $self->{aclversion} = $ucvers;
-               my $cfg = PVE::Cluster::cfs_read_file('user.cfg');
-               $self->{user_cfg} = $cfg;
-           }
-       }
-    };
-    if (my $err = $@) {
-       $self->{user_cfg} = {};
-       die "Unable to load access control list: $err";
-    }
-}
-
-# hacks: to provide better backwards compatibiliy
-
-# old code uses PVE::RPCEnvironment::get();
-# new code should use PVE::RPCEnvironment->get();
-sub get {
-    return PVE::RESTEnvironment->get();
-}
-
-# old code uses PVE::RPCEnvironment::is_worker();
-# new code should use PVE::RPCEnvironment->is_worker();
-sub is_worker {
-    return PVE::RESTEnvironment->is_worker();
-}
-
-1;
diff --git a/PVE/TokenConfig.pm b/PVE/TokenConfig.pm
deleted file mode 100644 (file)
index cfc60cc..0000000
+++ /dev/null
@@ -1,80 +0,0 @@
-package PVE::TokenConfig;
-
-use strict;
-use warnings;
-
-use UUID;
-
-use PVE::AccessControl;
-use PVE::Cluster;
-
-my $parse_token_cfg = sub {
-    my ($filename, $raw) = @_;
-
-    my $parsed = {};
-    return $parsed if !defined($raw);
-
-    my @lines = split(/\n/, $raw);
-    foreach my $line (@lines) {
-       next if $line =~ m/^\s*$/;
-
-       if ($line =~ m/^(\S+) (\S+)$/) {
-           if (PVE::AccessControl::pve_verify_tokenid($1, 1)) {
-               $parsed->{$1} = $2;
-               next;
-           }
-       }
-
-       warn "skipping invalid token.cfg entry\n";
-    }
-
-    return $parsed;
-};
-
-my $write_token_cfg = sub {
-    my ($filename, $data) = @_;
-
-    my $raw = '';
-    foreach my $tokenid (sort keys %$data) {
-       $raw .= "$tokenid $data->{$tokenid}\n";
-    }
-
-    return $raw;
-};
-
-PVE::Cluster::cfs_register_file('priv/token.cfg', $parse_token_cfg, $write_token_cfg);
-
-sub generate_token {
-    my ($tokenid) = @_;
-
-    PVE::AccessControl::pve_verify_tokenid($tokenid);
-
-    my $token_value = PVE::Cluster::cfs_lock_file('priv/token.cfg', 10, sub {
-       my $uuid = UUID::uuid();
-       my $token_cfg = PVE::Cluster::cfs_read_file('priv/token.cfg');
-
-       $token_cfg->{$tokenid} = $uuid;
-
-       PVE::Cluster::cfs_write_file('priv/token.cfg', $token_cfg);
-
-       return $uuid;
-    });
-
-    die "$@\n" if defined($@);
-
-    return $token_value;
-}
-
-sub delete_token {
-    my ($tokenid) = @_;
-
-    PVE::Cluster::cfs_lock_file('priv/token.cfg', 10, sub {
-       my $token_cfg = PVE::Cluster::cfs_read_file('priv/token.cfg');
-
-       delete $token_cfg->{$tokenid};
-
-       PVE::Cluster::cfs_write_file('priv/token.cfg', $token_cfg);
-    });
-
-    die "$@\n" if defined($@);
-}
diff --git a/oathkeygen b/oathkeygen
deleted file mode 100755 (executable)
index 82e4eec..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-#!/usr/bin/perl
-
-use strict;
-use warnings;
-use MIME::Base32; #libmime-base32-perl
-
-my $test;
-open(RND, "/dev/urandom");
-sysread(RND, $test, 10) == 10 || die "read random data failed\n";
-print MIME::Base32::encode_rfc3548($test) . "\n";
-
diff --git a/pveum b/pveum
deleted file mode 100755 (executable)
index 34ed82c..0000000
--- a/pveum
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/usr/bin/perl
-
-use strict;
-use warnings;
-
-use PVE::CLI::pveum;
-
-PVE::CLI::pveum->run_cli_handler();
diff --git a/src/Makefile b/src/Makefile
new file mode 100644 (file)
index 0000000..fefa157
--- /dev/null
@@ -0,0 +1,54 @@
+include /usr/share/dpkg/pkg-info.mk
+include /usr/share/dpkg/architecture.mk
+
+PACKAGE=libpve-access-control
+
+BUILDDIR ?= ${PACKAGE}-${DEB_VERSION_UPSTREAM}
+
+DESTDIR=
+PREFIX=/usr
+BINDIR=${PREFIX}/bin
+SBINDIR=${PREFIX}/sbin
+MANDIR=${PREFIX}/share/man
+DOCDIR=${PREFIX}/share/doc/${PACKAGE}
+MAN1DIR=${MANDIR}/man1/
+BASHCOMPLDIR=${PREFIX}/share/bash-completion/completions/
+ZSHCOMPLDIR=${PREFIX}/share/zsh/vendor-completions/
+
+export PERLDIR=${PREFIX}/share/perl5
+-include /usr/share/pve-doc-generator/pve-doc-generator.mk
+
+all:
+
+pveum.bash-completion: PVE/CLI/pveum.pm
+       perl -I. -T -e "use PVE::CLI::pveum; PVE::CLI::pveum->generate_bash_completions();" >$@.tmp
+       mv $@.tmp $@
+
+pveum.zsh-completion: PVE/CLI/pveum.pm
+       perl -I. -T -e "use PVE::CLI::pveum; PVE::CLI::pveum->generate_zsh_completions();" >$@.tmp
+       mv $@.tmp $@
+
+.PHONY: install
+install: pveum.1 oathkeygen pveum.bash-completion pveum.zsh-completion
+       install -d ${DESTDIR}${BINDIR}
+       install -d ${DESTDIR}${SBINDIR}
+       install -m 0755 pveum ${DESTDIR}${SBINDIR}
+       install -m 0755 oathkeygen ${DESTDIR}${BINDIR}
+       make -C PVE install
+       install -d ${DESTDIR}/${MAN1DIR}
+       install -d ${DESTDIR}/${DOCDIR}
+       install -m 0644 pveum.1 ${DESTDIR}/${MAN1DIR}
+       install -m 0644 -D pveum.bash-completion ${DESTDIR}${BASHCOMPLDIR}/pveum
+       install -m 0644 -D pveum.zsh-completion ${DESTDIR}${ZSHCOMPLDIR}/_pveum
+
+.PHONY: test
+test:
+       perl -I. ./pveum verifyapi
+       perl -I. -T -e "use PVE::CLI::pveum; PVE::CLI::pveum->verify_api();"
+       make -C test check
+
+.PHONY: clean distclean
+distclean: clean
+clean:
+       make cleanup-docgen
+       find . -name '*~' -exec rm {} ';'
diff --git a/src/PVE/API2/ACL.pm b/src/PVE/API2/ACL.pm
new file mode 100644 (file)
index 0000000..857c672
--- /dev/null
@@ -0,0 +1,206 @@
+package PVE::API2::ACL;
+
+use strict;
+use warnings;
+use PVE::Cluster qw (cfs_read_file cfs_write_file);
+use PVE::Tools qw(split_list);
+use PVE::AccessControl;
+use PVE::Exception qw(raise_param_exc);
+use PVE::JSONSchema qw(get_standard_option register_standard_option);
+
+use PVE::SafeSyslog;
+
+use PVE::RESTHandler;
+
+use base qw(PVE::RESTHandler);
+
+register_standard_option('acl-propagate', {
+    description => "Allow to propagate (inherit) permissions.",
+    type => 'boolean',
+    optional => 1,
+    default => 1,
+});
+register_standard_option('acl-path', {
+    description => "Access control path",
+    type => 'string',
+});
+
+__PACKAGE__->register_method ({
+    name => 'read_acl',
+    path => '',
+    method => 'GET',
+    description => "Get Access Control List (ACLs).",
+    permissions => {
+       description => "The returned list is restricted to objects where you have rights to modify permissions.",
+       user => 'all',
+    },
+    parameters => {
+       additionalProperties => 0,
+       properties => {},
+    },
+    returns => {
+       type => 'array',
+       items => {
+           type => "object",
+           additionalProperties => 0,
+           properties => {
+               propagate => get_standard_option('acl-propagate'),
+               path => get_standard_option('acl-path'),
+               type => { type => 'string', enum => ['user', 'group', 'token'] },
+               ugid => { type => 'string' },
+               roleid => { type => 'string' },
+           },
+       },
+    },
+    code => sub {
+       my ($param) = @_;
+
+       my $rpcenv = PVE::RPCEnvironment::get();
+       my $authuser = $rpcenv->get_user();
+       my $res = [];
+
+       my $usercfg = $rpcenv->{user_cfg};
+       if (!$usercfg || !$usercfg->{acl}) {
+           return $res;
+       }
+
+       my $audit = $rpcenv->check($authuser, '/access', ['Sys.Audit'], 1);
+
+       my $acl = $usercfg->{acl};
+       foreach my $path (keys %$acl) {
+           foreach my $type (qw(user group token)) {
+               my $d = $acl->{$path}->{"${type}s"};
+               next if !$d;
+               next if !($audit || $rpcenv->check_perm_modify($authuser, $path, 1));
+               foreach my $id (keys %$d) {
+                   foreach my $role (keys %{$d->{$id}}) {
+                       my $propagate = $d->{$id}->{$role};
+                       push @$res, {
+                           path => $path,
+                           type => $type,
+                           ugid => $id,
+                           roleid => $role,
+                           propagate => $propagate,
+                       };
+                   }
+               }
+           }
+       }
+
+       return $res;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'update_acl',
+    protected => 1,
+    path => '',
+    method => 'PUT',
+    permissions => {
+       check => ['perm-modify', '{path}'],
+    },
+    description => "Update Access Control List (add or remove permissions).",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           propagate => get_standard_option('acl-propagate'),
+           path => get_standard_option('acl-path'),
+           users => {
+               description => "List of users.",
+               type => 'string',  format => 'pve-userid-list',
+               optional => 1,
+           },
+           groups => {
+               description => "List of groups.",
+               type => 'string', format => 'pve-groupid-list',
+               optional => 1,
+           },
+           tokens => {
+               description => "List of API tokens.",
+               type => 'string', format => 'pve-tokenid-list',
+               optional => 1,
+           },
+           roles => {
+               description => "List of roles.",
+               type => 'string', format => 'pve-roleid-list',
+           },
+           delete => {
+               description => "Remove permissions (instead of adding it).",
+               type => 'boolean',
+               optional => 1,
+           },
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       if (!($param->{users} || $param->{groups} || $param->{tokens})) {
+           raise_param_exc({ map { $_ => "either 'users', 'groups' or 'tokens' is required." } qw(users groups tokens) });
+       }
+
+       my $path = PVE::AccessControl::normalize_path($param->{path});
+       raise_param_exc({ path => "invalid ACL path '$param->{path}'" }) if !$path;
+
+       if (!$param->{delete} && !PVE::AccessControl::check_path($path)) {
+           raise_param_exc({ path => "invalid ACL path '$param->{path}'" });
+       }
+
+       PVE::AccessControl::lock_user_config(
+           sub {
+
+               my $cfg = cfs_read_file("user.cfg");
+
+               my $propagate = 1;
+
+               if (defined($param->{propagate})) {
+                   $propagate = $param->{propagate} ? 1 : 0;
+               }
+
+               foreach my $role (split_list($param->{roles})) {
+                   die "role '$role' does not exist\n"
+                       if !$cfg->{roles}->{$role};
+
+                   foreach my $group (split_list($param->{groups})) {
+
+                       die "group '$group' does not exist\n"
+                           if !$cfg->{groups}->{$group};
+
+                       if ($param->{delete}) {
+                           delete($cfg->{acl}->{$path}->{groups}->{$group}->{$role});
+                       } else {
+                           $cfg->{acl}->{$path}->{groups}->{$group}->{$role} = $propagate;
+                       }
+                   }
+
+                   foreach my $userid (split_list($param->{users})) {
+                       my $username = PVE::AccessControl::verify_username($userid);
+
+                       die "user '$username' does not exist\n"
+                           if !$cfg->{users}->{$username};
+
+                       if ($param->{delete}) {
+                           delete($cfg->{acl}->{$path}->{users}->{$username}->{$role});
+                       } else {
+                           $cfg->{acl}->{$path}->{users}->{$username}->{$role} = $propagate;
+                       }
+                   }
+
+                   foreach my $tokenid (split_list($param->{tokens})) {
+                       my ($username, $token) = PVE::AccessControl::split_tokenid($tokenid);
+                       PVE::AccessControl::check_token_exist($cfg, $username, $token);
+
+                       if ($param->{delete}) {
+                           delete $cfg->{acl}->{$path}->{tokens}->{$tokenid}->{$role};
+                       } else {
+                           $cfg->{acl}->{$path}->{tokens}->{$tokenid}->{$role} = $propagate;
+                       }
+                   }
+               }
+
+               cfs_write_file("user.cfg", $cfg);
+           }, "ACL update failed");
+
+       return undef;
+    }});
+
+1;
diff --git a/src/PVE/API2/AccessControl.pm b/src/PVE/API2/AccessControl.pm
new file mode 100644 (file)
index 0000000..a77694b
--- /dev/null
@@ -0,0 +1,722 @@
+package PVE::API2::AccessControl;
+
+use strict;
+use warnings;
+
+use JSON;
+use MIME::Base64;
+
+use PVE::Exception qw(raise raise_perm_exc raise_param_exc);
+use PVE::SafeSyslog;
+use PVE::RPCEnvironment;
+use PVE::Cluster qw(cfs_read_file);
+use PVE::DataCenterConfig;
+use PVE::RESTHandler;
+use PVE::AccessControl;
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::API2::Domains;
+use PVE::API2::User;
+use PVE::API2::Group;
+use PVE::API2::Role;
+use PVE::API2::ACL;
+use PVE::Auth::Plugin;
+use PVE::OTP;
+use PVE::Tools;
+
+my $u2f_available = 0;
+eval {
+    require PVE::U2F;
+    $u2f_available = 1;
+};
+
+use base qw(PVE::RESTHandler);
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::User",
+    path => 'users',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Group",
+    path => 'groups',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Role",
+    path => 'roles',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::ACL",
+    path => 'acl',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Domains",
+    path => 'domains',
+});
+
+__PACKAGE__->register_method ({
+    name => 'index',
+    path => '',
+    method => 'GET',
+    description => "Directory index.",
+    permissions => {
+       user => 'all',
+    },
+    parameters => {
+       additionalProperties => 0,
+       properties => {},
+    },
+    returns => {
+       type => 'array',
+       items => {
+           type => "object",
+           properties => {
+               subdir => { type => 'string' },
+           },
+       },
+       links => [ { rel => 'child', href => "{subdir}" } ],
+    },
+    code => sub {
+       my ($param) = @_;
+
+       my $res = [];
+
+       my $ma = __PACKAGE__->method_attributes();
+
+       foreach my $info (@$ma) {
+           next if !$info->{subclass};
+
+           my $subpath = $info->{match_re}->[0];
+
+           push @$res, { subdir => $subpath };
+       }
+
+       push @$res, { subdir => 'ticket' };
+       push @$res, { subdir => 'password' };
+
+       return $res;
+    }});
+
+
+my $verify_auth = sub {
+    my ($rpcenv, $username, $pw_or_ticket, $otp, $path, $privs) = @_;
+
+    my $normpath = PVE::AccessControl::normalize_path($path);
+
+    my $ticketuser;
+    if (($ticketuser = PVE::AccessControl::verify_ticket($pw_or_ticket, 1)) &&
+       ($ticketuser eq $username)) {
+       # valid ticket
+    } elsif (PVE::AccessControl::verify_vnc_ticket($pw_or_ticket, $username, $normpath, 1)) {
+       # valid vnc ticket
+    } else {
+       $username = PVE::AccessControl::authenticate_user($username, $pw_or_ticket, $otp);
+    }
+
+    my $privlist = [ PVE::Tools::split_list($privs) ];
+    if (!($normpath && scalar(@$privlist) && $rpcenv->check($username, $normpath, $privlist))) {
+       die "no permission ($path, $privs)\n";
+    }
+
+    return { username => $username };
+};
+
+my $create_ticket = sub {
+    my ($rpcenv, $username, $pw_or_ticket, $otp) = @_;
+
+    my ($ticketuser, undef, $tfa_info) = PVE::AccessControl::verify_ticket($pw_or_ticket, 1);
+    if (defined($ticketuser) && ($ticketuser eq 'root@pam' || $ticketuser eq $username)) {
+       if (defined($tfa_info)) {
+           die "incomplete ticket\n";
+       }
+       # valid ticket. Note: root@pam can create tickets for other users
+    } else {
+       ($username, $tfa_info) = PVE::AccessControl::authenticate_user($username, $pw_or_ticket, $otp);
+    }
+
+    my %extra;
+    my $ticket_data = $username;
+    if (defined($tfa_info)) {
+       $extra{NeedTFA} = 1;
+       if ($tfa_info->{type} eq 'u2f') {
+           my $u2finfo = $tfa_info->{data};
+           my $u2f = get_u2f_instance($rpcenv, $u2finfo->@{qw(publicKey keyHandle)});
+           my $challenge = $u2f->auth_challenge()
+               or die "failed to get u2f challenge\n";
+           $challenge = decode_json($challenge);
+           $extra{U2FChallenge} = $challenge;
+           $ticket_data = "u2f!$username!$challenge->{challenge}";
+       } else {
+           # General half-login / 'missing 2nd factor' ticket:
+           $ticket_data = "tfa!$username";
+       }
+    }
+
+    my $ticket = PVE::AccessControl::assemble_ticket($ticket_data);
+    my $csrftoken = PVE::AccessControl::assemble_csrf_prevention_token($username);
+
+    return {
+       ticket => $ticket,
+       username => $username,
+       CSRFPreventionToken => $csrftoken,
+       %extra,
+    };
+};
+
+my $compute_api_permission = sub {
+    my ($rpcenv, $authuser) = @_;
+
+    my $usercfg = $rpcenv->{user_cfg};
+
+    my $res = {};
+    my $priv_re_map = {
+       vms => qr/VM\.|Permissions\.Modify/,
+       access => qr/(User|Group)\.|Permissions\.Modify/,
+       storage => qr/Datastore\.|Permissions\.Modify/,
+       nodes => qr/Sys\.|Permissions\.Modify/,
+       sdn => qr/SDN\.|Permissions\.Modify/,
+       dc => qr/Sys\.Audit|SDN\./,
+    };
+    map { $res->{$_} = {} } keys %$priv_re_map;
+
+    my $required_paths = ['/', '/nodes', '/access/groups', '/vms', '/storage', '/sdn'];
+
+    my $checked_paths = {};
+    foreach my $path (@$required_paths, keys %{$usercfg->{acl}}) {
+       next if $checked_paths->{$path};
+       $checked_paths->{$path} = 1;
+
+       my $path_perm = $rpcenv->permissions($authuser, $path);
+
+       my $toplevel = ($path =~ /^\/(\w+)/) ? $1 : 'dc';
+       if ($toplevel eq 'pool') {
+           foreach my $priv (keys %$path_perm) {
+               if ($priv =~ m/^VM\./) {
+                   $res->{vms}->{$priv} = 1;
+               } elsif ($priv =~ m/^Datastore\./) {
+                   $res->{storage}->{$priv} = 1;
+               } elsif ($priv eq 'Permissions.Modify') {
+                   $res->{storage}->{$priv} = 1;
+                   $res->{vms}->{$priv} = 1;
+               }
+           }
+       } else {
+           my $priv_regex = $priv_re_map->{$toplevel} // next;
+           foreach my $priv (keys %$path_perm) {
+               next if $priv !~ m/^($priv_regex)/;
+               $res->{$toplevel}->{$priv} = 1;
+           }
+       }
+    }
+
+    return $res;
+};
+
+__PACKAGE__->register_method ({
+    name => 'get_ticket',
+    path => 'ticket',
+    method => 'GET',
+    permissions => { user => 'world' },
+    description => "Dummy. Useful for formatters which want to provide a login page.",
+    parameters => {
+       additionalProperties => 0,
+    },
+    returns => { type => "null" },
+    code => sub { return undef; }});
+
+__PACKAGE__->register_method ({
+    name => 'create_ticket',
+    path => 'ticket',
+    method => 'POST',
+    permissions => {
+       description => "You need to pass valid credientials.",
+       user => 'world'
+    },
+    protected => 1, # else we can't access shadow files
+    allowtoken => 0, # we don't want tokens to create tickets
+    description => "Create or verify authentication ticket.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           username => {
+               description => "User name",
+               type => 'string',
+               maxLength => 64,
+               completion => \&PVE::AccessControl::complete_username,
+           },
+           realm =>  get_standard_option('realm', {
+               description => "You can optionally pass the realm using this parameter. Normally the realm is simply added to the username <username>\@<relam>.",
+               optional => 1,
+               completion => \&PVE::AccessControl::complete_realm,
+           }),
+           password => {
+               description => "The secret password. This can also be a valid ticket.",
+               type => 'string',
+           },
+           otp => {
+               description => "One-time password for Two-factor authentication.",
+               type => 'string',
+               optional => 1,
+           },
+           path => {
+               description => "Verify ticket, and check if user have access 'privs' on 'path'",
+               type => 'string',
+               requires => 'privs',
+               optional => 1,
+               maxLength => 64,
+           },
+           privs => {
+               description => "Verify ticket, and check if user have access 'privs' on 'path'",
+               type => 'string' , format => 'pve-priv-list',
+               requires => 'path',
+               optional => 1,
+               maxLength => 64,
+           },
+       }
+    },
+    returns => {
+       type => "object",
+       properties => {
+           username => { type => 'string' },
+           ticket => { type => 'string', optional => 1},
+           CSRFPreventionToken => { type => 'string', optional => 1 },
+           clustername => { type => 'string', optional => 1 },
+           # cap => computed api permissions, unless there's a u2f challenge
+       }
+    },
+    code => sub {
+       my ($param) = @_;
+
+       my $username = $param->{username};
+       $username .= "\@$param->{realm}" if $param->{realm};
+
+       $username = PVE::AccessControl::lookup_username($username);
+       my $rpcenv = PVE::RPCEnvironment::get();
+
+       my $res;
+       eval {
+           # test if user exists and is enabled
+           $rpcenv->check_user_enabled($username);
+
+           if ($param->{path} && $param->{privs}) {
+               $res = &$verify_auth($rpcenv, $username, $param->{password}, $param->{otp},
+                                    $param->{path}, $param->{privs});
+           } else {
+               $res = &$create_ticket($rpcenv, $username, $param->{password}, $param->{otp});
+           }
+       };
+       if (my $err = $@) {
+           my $clientip = $rpcenv->get_client_ip() || '';
+           syslog('err', "authentication failure; rhost=$clientip user=$username msg=$err");
+           # do not return any info to prevent user enumeration attacks
+           die PVE::Exception->new("authentication failure\n", code => 401);
+       }
+
+       $res->{cap} = &$compute_api_permission($rpcenv, $username)
+           if !defined($res->{NeedTFA});
+
+       my $clinfo = PVE::Cluster::get_clinfo();
+       if ($clinfo->{cluster}->{name} && $rpcenv->check($username, '/', ['Sys.Audit'], 1)) {
+           $res->{clustername} = $clinfo->{cluster}->{name};
+       }
+
+       PVE::Cluster::log_msg('info', 'root@pam', "successful auth for user '$username'");
+
+       return $res;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'change_password',
+    path => 'password',
+    method => 'PUT',
+    permissions => {
+       description => "Each user is allowed to change his own password. A user can change the password of another user if he has 'Realm.AllocateUser' (on the realm of user <userid>) and 'User.Modify' permission on /access/groups/<group> on a group where user <userid> is member of.",
+       check => [ 'or',
+                  ['userid-param', 'self'],
+                  [ 'and',
+                    [ 'userid-param', 'Realm.AllocateUser'],
+                    [ 'userid-group', ['User.Modify']]
+                  ]
+           ],
+    },
+    protected => 1, # else we can't access shadow files
+    allowtoken => 0, # we don't want tokens to change the regular user password
+    description => "Change user password.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid-completed'),
+           password => {
+               description => "The new password.",
+               type => 'string',
+               minLength => 5,
+               maxLength => 64,
+           },
+       }
+    },
+    returns => { type => "null" },
+    code => sub {
+       my ($param) = @_;
+
+       my $rpcenv = PVE::RPCEnvironment::get();
+       my $authuser = $rpcenv->get_user();
+
+       my ($userid, $ruid, $realm) = PVE::AccessControl::verify_username($param->{userid});
+
+       $rpcenv->check_user_exist($userid);
+
+       if ($authuser eq 'root@pam') {
+           # OK - root can change anything
+       } else {
+           if ($authuser eq $userid) {
+               $rpcenv->check_user_enabled($userid);
+               # OK - each user can change its own password
+           } else {
+               # only root may change root password
+               raise_perm_exc() if $userid eq 'root@pam';
+               # do not allow to change system user passwords
+               raise_perm_exc() if $realm eq 'pam';
+           }
+       }
+
+       PVE::AccessControl::domain_set_password($realm, $ruid, $param->{password});
+
+       PVE::Cluster::log_msg('info', 'root@pam', "changed password for user '$userid'");
+
+       return undef;
+    }});
+
+sub get_u2f_config() {
+    die "u2f support not available\n" if !$u2f_available;
+
+    my $dc = cfs_read_file('datacenter.cfg');
+    my $u2f = $dc->{u2f};
+    die "u2f not configured in datacenter.cfg\n" if !$u2f;
+    return $u2f;
+}
+
+sub get_u2f_instance {
+    my ($rpcenv, $publicKey, $keyHandle) = @_;
+
+    # We store the public key base64 encoded (as the api provides it in binary)
+    $publicKey = decode_base64($publicKey) if defined($publicKey);
+
+    my $u2fconfig = get_u2f_config();
+    my $u2f = PVE::U2F->new();
+
+    # via the 'Host' header (in case a node has multiple hosts available).
+    my $origin = $u2fconfig->{origin};
+    if (!defined($origin)) {
+       $origin = $rpcenv->get_request_host(1);
+       if ($origin) {
+           $origin = "https://$origin";
+       } else {
+           die "failed to figure out u2f origin\n";
+       }
+    }
+
+    my $appid = $u2fconfig->{appid} // $origin;
+    $u2f->set_appid($appid);
+    $u2f->set_origin($origin);
+    $u2f->set_publicKey($publicKey) if defined($publicKey);
+    $u2f->set_keyHandle($keyHandle) if defined($keyHandle);
+    return $u2f;
+}
+
+sub verify_user_tfa_config {
+    my ($type, $tfa_cfg, $value) = @_;
+
+    if (!defined($type)) {
+       die "missing tfa 'type'\n";
+    }
+
+    if ($type ne 'oath') {
+       die "invalid type for custom tfa authentication\n";
+    }
+
+    my $secret = $tfa_cfg->{keys}
+       or die "missing TOTP secret\n";
+    $tfa_cfg = $tfa_cfg->{config};
+    # Copy the hash to verify that we have no unexpected keys without modifying the original hash.
+    $tfa_cfg = {%$tfa_cfg};
+
+    # We can only verify 1 secret but oath_verify_otp allows multiple:
+    if (scalar(PVE::Tools::split_list($secret)) != 1) {
+       die "only exactly one secret key allowed\n";
+    }
+
+    my $digits = delete($tfa_cfg->{digits}) // 6;
+    my $step = delete($tfa_cfg->{step}) // 30;
+    # Maybe also this?
+    #     my $algorithm = delete($tfa_cfg->{algorithm}) // 'sha1';
+
+    if (length(my $more = join(', ', keys %$tfa_cfg))) {
+       die "unexpected tfa config keys: $more\n";
+    }
+
+    PVE::OTP::oath_verify_otp($value, $secret, $step, $digits);
+}
+
+__PACKAGE__->register_method ({
+    name => 'change_tfa',
+    path => 'tfa',
+    method => 'PUT',
+    permissions => {
+       description => 'A user can change their own u2f or totp token.',
+       check => [ 'or',
+                  ['userid-param', 'self'],
+                  [ 'and',
+                    [ 'userid-param', 'Realm.AllocateUser'],
+                    [ 'userid-group', ['User.Modify']]
+                  ]
+           ],
+    },
+    protected => 1, # else we can't access shadow files
+    allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
+    description => "Change user u2f authentication.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid', {
+               completion => \&PVE::AccessControl::complete_username,
+           }),
+           password => {
+               optional => 1, # Only required if not root@pam
+               description => "The current password.",
+               type => 'string',
+               minLength => 5,
+               maxLength => 64,
+           },
+           action => {
+               description => 'The action to perform',
+               type => 'string',
+               enum => [qw(delete new confirm)],
+           },
+           response => {
+               optional => 1,
+               description =>
+                   'Either the the response to the current u2f registration challenge,'
+                   .' or, when adding TOTP, the currently valid TOTP value.',
+               type => 'string',
+           },
+           key => {
+               optional => 1,
+               description => 'When adding TOTP, the shared secret value.',
+               type => 'string',
+               format => 'pve-tfa-secret',
+           },
+           config => {
+               optional => 1,
+               description => 'A TFA configuration. This must currently be of type TOTP of not set at all.',
+               type => 'string',
+               format => 'pve-tfa-config',
+               maxLength => 128,
+           },
+       }
+    },
+    returns => { type => 'object' },
+    code => sub {
+       my ($param) = @_;
+
+       my $rpcenv = PVE::RPCEnvironment::get();
+       my $authuser = $rpcenv->get_user();
+
+       my $action = delete $param->{action};
+       my $response = delete $param->{response};
+       my $password = delete($param->{password}) // '';
+       my $key = delete($param->{key});
+       my $config = delete($param->{config});
+
+       my ($userid, $ruid, $realm) = PVE::AccessControl::verify_username($param->{userid});
+       $rpcenv->check_user_exist($userid);
+
+       # Only root may modify root
+       raise_perm_exc() if $userid eq 'root@pam' && $authuser ne 'root@pam';
+
+       # Regular users need to confirm their password to change u2f settings.
+       if ($authuser ne 'root@pam') {
+           raise_param_exc({ 'password' => 'password is required to modify u2f data' })
+               if !defined($password);
+           my $domain_cfg = cfs_read_file('domains.cfg');
+           my $cfg = $domain_cfg->{ids}->{$realm};
+           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);
+       }
+
+       if ($action eq 'delete') {
+           PVE::AccessControl::user_set_tfa($userid, $realm, undef, undef);
+           PVE::Cluster::log_msg('info', $authuser, "deleted u2f data for user '$userid'");
+       } elsif ($action eq 'new') {
+           if (defined($config)) {
+               $config = PVE::Auth::Plugin::parse_tfa_config($config);
+               my $type = delete($config->{type});
+               my $tfa_cfg = {
+                   keys => $key,
+                   config => $config,
+               };
+               verify_user_tfa_config($type, $tfa_cfg, $response);
+               PVE::AccessControl::user_set_tfa($userid, $realm, $type, $tfa_cfg);
+           } else {
+               # The default is U2F:
+               my $u2f = get_u2f_instance($rpcenv);
+               my $challenge = $u2f->registration_challenge()
+                   or raise("failed to get u2f challenge");
+               $challenge = decode_json($challenge);
+               PVE::AccessControl::user_set_tfa($userid, $realm, 'u2f', $challenge);
+               return $challenge;
+           }
+       } elsif ($action eq 'confirm') {
+           raise_param_exc({ 'response' => "confirm action requires the 'response' parameter to be set" })
+               if !defined($response);
+
+           my ($type, $u2fdata) = PVE::AccessControl::user_get_tfa($userid, $realm);
+           raise("no u2f data available")
+               if (!defined($type) || $type ne 'u2f');
+
+           my $challenge = $u2fdata->{challenge}
+               or raise("no active challenge");
+
+           my $u2f = get_u2f_instance($rpcenv);
+           $u2f->set_challenge($challenge);
+           my ($keyHandle, $publicKey) = $u2f->registration_verify($response);
+           PVE::AccessControl::user_set_tfa($userid, $realm, 'u2f', {
+               keyHandle => $keyHandle,
+               publicKey => $publicKey, # already base64 encoded
+           });
+       } else {
+           die "invalid action: $action\n";
+       }
+
+       return {};
+    }});
+
+__PACKAGE__->register_method({
+    name => 'verify_tfa',
+    path => 'tfa',
+    method => 'POST',
+    permissions => { user => 'all' },
+    protected => 1, # else we can't access shadow files
+    allowtoken => 0, # we don't want tokens to access TFA information
+    description => 'Finish a u2f challenge.',
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           response => {
+               type => 'string',
+               description => 'The response to the current authentication challenge.',
+           },
+       }
+    },
+    returns => {
+       type => 'object',
+       properties => {
+           ticket => { type => 'string' },
+           # cap
+       }
+    },
+    code => sub {
+       my ($param) = @_;
+
+       my $rpcenv = PVE::RPCEnvironment::get();
+       my $authuser = $rpcenv->get_user();
+       my ($username, undef, $realm) = PVE::AccessControl::verify_username($authuser);
+
+       my ($tfa_type, $tfa_data) = PVE::AccessControl::user_get_tfa($username, $realm);
+       if (!defined($tfa_type)) {
+           raise('no u2f data available');
+       }
+
+       eval {
+           if ($tfa_type eq 'u2f') {
+               my $challenge = $rpcenv->get_u2f_challenge()
+                  or raise('no active challenge');
+
+               my $keyHandle = $tfa_data->{keyHandle};
+               my $publicKey = $tfa_data->{publicKey};
+               raise("incomplete u2f setup")
+                   if !defined($keyHandle) || !defined($publicKey);
+
+               my $u2f = get_u2f_instance($rpcenv, $publicKey, $keyHandle);
+               $u2f->set_challenge($challenge);
+
+               my ($counter, $present) = $u2f->auth_verify($param->{response});
+               # Do we want to do anything with these?
+           } else {
+               # sanity check before handing off to the verification code:
+               my $keys = $tfa_data->{keys} or die "missing tfa keys\n";
+               my $config = $tfa_data->{config} or die "bad tfa entry\n";
+               PVE::AccessControl::verify_one_time_pw($tfa_type, $authuser, $keys, $config, $param->{response});
+           }
+       };
+       if (my $err = $@) {
+           my $clientip = $rpcenv->get_client_ip() || '';
+           syslog('err', "authentication verification failure; rhost=$clientip user=$authuser msg=$err");
+           die PVE::Exception->new("authentication failure\n", code => 401);
+       }
+
+       return {
+           ticket => PVE::AccessControl::assemble_ticket($authuser),
+           cap => &$compute_api_permission($rpcenv, $authuser),
+       }
+    }});
+
+__PACKAGE__->register_method({
+    name => 'permissions',
+    path => 'permissions',
+    method => 'GET',
+    description => 'Retrieve effective permissions of given user/token.',
+    permissions => {
+       description => "Each user/token is allowed to dump their own permissions. A user can dump the permissions of another user if they have 'Sys.Audit' permission on /access.",
+       user => 'all',
+    },
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => {
+               type => 'string',
+               description => "User ID or full API token ID",
+               pattern => $PVE::AccessControl::userid_or_token_regex,
+               optional => 1,
+           },
+           path => get_standard_option('acl-path', {
+               description => "Only dump this specific path, not the whole tree.",
+               optional => 1,
+           }),
+       },
+    },
+    returns => {
+       type => 'object',
+       description => 'Map of "path" => (Map of "privilege" => "propagate boolean").',
+    },
+    code => sub {
+       my ($param) = @_;
+
+       my $rpcenv = PVE::RPCEnvironment::get();
+
+       my $userid = $param->{userid};
+       if (defined($userid)) {
+           $rpcenv->check($rpcenv->get_user(), '/access', ['Sys.Audit']);
+       } else {
+           $userid = $rpcenv->get_user();
+       }
+
+       my $res;
+
+       if (my $path = $param->{path}) {
+           my $perms = $rpcenv->permissions($userid, $path);
+           if ($perms) {
+               $res = { $path => $perms };
+           } else {
+               $res = {};
+           }
+       } else {
+           $res = $rpcenv->get_effective_permissions($userid);
+       }
+
+       return $res;
+    }});
+
+1;
diff --git a/src/PVE/API2/Domains.pm b/src/PVE/API2/Domains.pm
new file mode 100644 (file)
index 0000000..9c2b254
--- /dev/null
@@ -0,0 +1,484 @@
+package PVE::API2::Domains;
+
+use strict;
+use warnings;
+
+use PVE::Exception qw(raise_param_exc);
+use PVE::Tools qw(extract_param);
+use PVE::Cluster qw (cfs_read_file cfs_write_file);
+use PVE::AccessControl;
+use PVE::JSONSchema qw(get_standard_option);
+
+use PVE::SafeSyslog;
+use PVE::RESTHandler;
+use PVE::Auth::Plugin;
+
+my $domainconfigfile = "domains.cfg";
+
+use base qw(PVE::RESTHandler);
+
+__PACKAGE__->register_method ({
+    name => 'index',
+    path => '',
+    method => 'GET',
+    description => "Authentication domain index.",
+    permissions => {
+       description => "Anyone can access that, because we need that list for the login box (before the user is authenticated).",
+       user => 'world',
+    },
+    parameters => {
+       additionalProperties => 0,
+       properties => {},
+    },
+    returns => {
+       type => 'array',
+       items => {
+           type => "object",
+           properties => {
+               realm => { type => 'string' },
+               type => { type => 'string' },
+               tfa => {
+                   description => "Two-factor authentication provider.",
+                   type => 'string',
+                   enum => [ 'yubico', 'oath' ],
+                   optional => 1,
+               },
+               comment => {
+                   description => "A comment. The GUI use this text when you select a domain (Realm) on the login window.",
+                   type => 'string',
+                   optional => 1,
+               },
+           },
+       },
+       links => [ { rel => 'child', href => "{realm}" } ],
+    },
+    code => sub {
+       my ($param) = @_;
+
+       my $res = [];
+
+       my $cfg = cfs_read_file($domainconfigfile);
+       my $ids = $cfg->{ids};
+
+       foreach my $realm (keys %$ids) {
+           my $d = $ids->{$realm};
+           my $entry = { realm => $realm, type => $d->{type} };
+           $entry->{comment} = $d->{comment} if $d->{comment};
+           $entry->{default} = 1 if $d->{default};
+           if ($d->{tfa} && (my $tfa_cfg = PVE::Auth::Plugin::parse_tfa_config($d->{tfa}))) {
+               $entry->{tfa} = $tfa_cfg->{type};
+           }
+           push @$res, $entry;
+       }
+
+       return $res;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'create',
+    protected => 1,
+    path => '',
+    method => 'POST',
+    permissions => {
+       check => ['perm', '/access/realm', ['Realm.Allocate']],
+    },
+    description => "Add an authentication server.",
+    parameters => PVE::Auth::Plugin->createSchema(),
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       # always extract, add it with hook
+       my $password = extract_param($param, 'password');
+
+       PVE::Auth::Plugin::lock_domain_config(
+           sub {
+
+               my $cfg = cfs_read_file($domainconfigfile);
+               my $ids = $cfg->{ids};
+
+               my $realm = extract_param($param, 'realm');
+               my $type = $param->{type};
+
+               die "domain '$realm' already exists\n"
+                   if $ids->{$realm};
+
+               die "unable to use reserved name '$realm'\n"
+                   if ($realm eq 'pam' || $realm eq 'pve');
+
+               die "unable to create builtin type '$type'\n"
+                   if ($type eq 'pam' || $type eq 'pve');
+
+               my $plugin = PVE::Auth::Plugin->lookup($type);
+               my $config = $plugin->check_config($realm, $param, 1, 1);
+
+               if ($config->{default}) {
+                   foreach my $r (keys %$ids) {
+                       delete $ids->{$r}->{default};
+                   }
+               }
+
+               $ids->{$realm} = $config;
+
+               my $opts = $plugin->options();
+               if (defined($password) && !defined($opts->{password})) {
+                   $password = undef;
+                   warn "ignoring password parameter";
+               }
+               $plugin->on_add_hook($realm, $config, password => $password);
+
+               cfs_write_file($domainconfigfile, $cfg);
+           }, "add auth server failed");
+
+       return undef;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'update',
+    path => '{realm}',
+    method => 'PUT',
+    permissions => {
+       check => ['perm', '/access/realm', ['Realm.Allocate']],
+    },
+    description => "Update authentication server settings.",
+    protected => 1,
+    parameters => PVE::Auth::Plugin->updateSchema(),
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       # always extract, update in hook
+       my $password = extract_param($param, 'password');
+
+       PVE::Auth::Plugin::lock_domain_config(
+           sub {
+
+               my $cfg = cfs_read_file($domainconfigfile);
+               my $ids = $cfg->{ids};
+
+               my $digest = extract_param($param, 'digest');
+               PVE::SectionConfig::assert_if_modified($cfg, $digest);
+
+               my $realm = extract_param($param, 'realm');
+
+               die "domain '$realm' does not exist\n"
+                   if !$ids->{$realm};
+
+               my $delete_str = extract_param($param, 'delete');
+               die "no options specified\n" if !$delete_str && !scalar(keys %$param);
+
+               my $delete_pw = 0;
+               foreach my $opt (PVE::Tools::split_list($delete_str)) {
+                   delete $ids->{$realm}->{$opt};
+                   $delete_pw = 1 if $opt eq 'password';
+               }
+
+               my $plugin = PVE::Auth::Plugin->lookup($ids->{$realm}->{type});
+               my $config = $plugin->check_config($realm, $param, 0, 1);
+
+               if ($config->{default}) {
+                   foreach my $r (keys %$ids) {
+                       delete $ids->{$r}->{default};
+                   }
+               }
+
+               foreach my $p (keys %$config) {
+                   $ids->{$realm}->{$p} = $config->{$p};
+               }
+
+               my $opts = $plugin->options();
+               if ($delete_pw || defined($password)) {
+                   $plugin->on_update_hook($realm, $config, password => $password);
+               } else {
+                   $plugin->on_update_hook($realm, $config);
+               }
+
+               cfs_write_file($domainconfigfile, $cfg);
+           }, "update auth server failed");
+
+       return undef;
+    }});
+
+# fixme: return format!
+__PACKAGE__->register_method ({
+    name => 'read',
+    path => '{realm}',
+    method => 'GET',
+    description => "Get auth server configuration.",
+    permissions => {
+       check => ['perm', '/access/realm', ['Realm.Allocate', 'Sys.Audit'], any => 1],
+    },
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           realm =>  get_standard_option('realm'),
+       },
+    },
+    returns => {},
+    code => sub {
+       my ($param) = @_;
+
+       my $cfg = cfs_read_file($domainconfigfile);
+
+       my $realm = $param->{realm};
+
+       my $data = $cfg->{ids}->{$realm};
+       die "domain '$realm' does not exist\n" if !$data;
+
+       $data->{digest} = $cfg->{digest};
+
+       return $data;
+    }});
+
+
+__PACKAGE__->register_method ({
+    name => 'delete',
+    path => '{realm}',
+    method => 'DELETE',
+    permissions => {
+       check => ['perm', '/access/realm', ['Realm.Allocate']],
+    },
+    description => "Delete an authentication server.",
+    protected => 1,
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           realm =>  get_standard_option('realm'),
+       }
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       PVE::Auth::Plugin::lock_domain_config(
+           sub {
+
+               my $cfg = cfs_read_file($domainconfigfile);
+               my $ids = $cfg->{ids};
+               my $realm = $param->{realm};
+
+               die "authentication domain '$realm' does not exist\n" if !$ids->{$realm};
+
+               my $plugin = PVE::Auth::Plugin->lookup($ids->{$realm}->{type});
+
+               $plugin->on_delete_hook($realm, $ids->{$realm});
+
+               delete $ids->{$realm};
+
+               cfs_write_file($domainconfigfile, $cfg);
+           }, "delete auth server failed");
+
+       return undef;
+    }});
+
+my $update_users = sub {
+    my ($usercfg, $realm, $synced_users, $opts) = @_;
+
+    print "syncing users\n";
+    $usercfg->{users} = {} if !defined($usercfg->{users});
+    my $users = $usercfg->{users};
+
+    my $oldusers = {};
+    if ($opts->{'full'}) {
+       print "full sync, deleting outdated existing users first\n";
+       foreach my $userid (sort keys %$users) {
+           next if $userid !~ m/\@$realm$/;
+
+           $oldusers->{$userid} = delete $users->{$userid};
+           if ($opts->{'purge'} && !$synced_users->{$userid}) {
+               PVE::AccessControl::delete_user_acl($userid, $usercfg);
+               print "purged user '$userid' and all its ACL entries\n";
+           } elsif (!defined($synced_users->{$userid})) {
+               print "remove user '$userid'\n";
+           }
+       }
+    }
+
+    foreach my $userid (sort keys %$synced_users) {
+       my $synced_user = $synced_users->{$userid} // {};
+       if (!defined($users->{$userid})) {
+           my $user = $users->{$userid} = $synced_user;
+
+           my $olduser = $oldusers->{$userid} // {};
+           if (defined(my $enabled = $olduser->{enable})) {
+               $user->{enable} = $enabled;
+           } elsif ($opts->{'enable-new'}) {
+               $user->{enable} = 1;
+           }
+
+           if (defined($olduser->{tokens})) {
+               $user->{tokens} = $olduser->{tokens};
+           }
+           if (defined($oldusers->{$userid})) {
+               print "updated user '$userid'\n";
+           } else {
+               print "added user '$userid'\n";
+           }
+       } else {
+           my $olduser = $users->{$userid};
+           foreach my $attr (keys %$synced_user) {
+               $olduser->{$attr} = $synced_user->{$attr};
+           }
+           print "updated user '$userid'\n";
+       }
+    }
+};
+
+my $update_groups = sub {
+    my ($usercfg, $realm, $synced_groups, $opts) = @_;
+
+    print "syncing groups\n";
+    $usercfg->{groups} = {} if !defined($usercfg->{groups});
+    my $groups = $usercfg->{groups};
+    my $oldgroups = {};
+
+    if ($opts->{full}) {
+       print "full sync, deleting outdated existing groups first\n";
+       foreach my $groupid (sort keys %$groups) {
+           next if $groupid !~ m/\-$realm$/;
+
+           my $oldgroups->{$groupid} = delete $groups->{$groupid};
+           if ($opts->{purge} && !$synced_groups->{$groupid}) {
+               print "purged group '$groupid' and all its ACL entries\n";
+               PVE::AccessControl::delete_group_acl($groupid, $usercfg)
+           } elsif (!defined($synced_groups->{$groupid})) {
+               print "removed group '$groupid'\n";
+           }
+       }
+    }
+
+    foreach my $groupid (sort keys %$synced_groups) {
+       my $synced_group = $synced_groups->{$groupid};
+       if (!defined($groups->{$groupid})) {
+           $groups->{$groupid} = $synced_group;
+           if (defined($oldgroups->{$groupid})) {
+               print "updated group '$groupid'\n";
+           } else {
+               print "added group '$groupid'\n";
+           }
+       } else {
+           my $group = $groups->{$groupid};
+           foreach my $attr (keys %$synced_group) {
+               $group->{$attr} = $synced_group->{$attr};
+           }
+           print "updated group '$groupid'\n";
+       }
+    }
+};
+
+my $parse_sync_opts = sub {
+    my ($param, $realmconfig) = @_;
+
+    my $sync_opts_fmt = PVE::JSONSchema::get_format('realm-sync-options');
+
+    my $cfg_defaults = {};
+    if (defined(my $cfg_opts = $realmconfig->{'sync-defaults-options'})) {
+       $cfg_defaults = PVE::JSONSchema::parse_property_string($sync_opts_fmt, $cfg_opts);
+    }
+
+    my $res = {};
+    for my $opt (sort keys %$sync_opts_fmt) {
+       my $fmt = $sync_opts_fmt->{$opt};
+
+       $res->{$opt} = $param->{$opt} // $cfg_defaults->{$opt} // $fmt->{default};
+
+       raise_param_exc({
+           "$opt" => 'Not passed as parameter and not defined in realm default sync options.'
+       }) if !defined($res->{$opt});
+    }
+    return $res;
+};
+
+__PACKAGE__->register_method ({
+    name => 'sync',
+    path => '{realm}/sync',
+    method => 'POST',
+    permissions => {
+       description => "'Realm.AllocateUser' on '/access/realm/<realm>' and "
+           ." 'User.Modify' permissions to '/access/groups/'.",
+       check => [ 'and',
+           [ 'userid-param', 'Realm.AllocateUser' ],
+           [ 'userid-group', ['User.Modify'] ],
+       ],
+    },
+    description => "Syncs users and/or groups from the configured LDAP to user.cfg."
+       ." NOTE: Synced groups will have the name 'name-\$realm', so make sure"
+       ." those groups do not exist to prevent overwriting.",
+    protected => 1,
+    parameters => {
+       additionalProperties => 0,
+       properties => get_standard_option('realm-sync-options', {
+           realm => get_standard_option('realm'),
+           'dry-run' => {
+               description => "If set, does not write anything.",
+               type => 'boolean',
+               optional => 1,
+               default => 0,
+           },
+       }),
+    },
+    returns => {
+       description => 'Worker Task-UPID',
+       type => 'string'
+    },
+    code => sub {
+       my ($param) = @_;
+
+       my $rpcenv = PVE::RPCEnvironment::get();
+       my $authuser = $rpcenv->get_user();
+
+       my $dry_run = extract_param($param, 'dry-run');
+       my $realm = $param->{realm};
+       my $cfg = cfs_read_file($domainconfigfile);
+       my $realmconfig = $cfg->{ids}->{$realm};
+
+       raise_param_exc({ 'realm' => 'Realm does not exist.' }) if !defined($realmconfig);
+       my $type = $realmconfig->{type};
+
+       if ($type ne 'ldap' && $type ne 'ad') {
+           die "Cannot sync realm type '$type'! Only LDAP/AD realms can be synced.\n";
+       }
+
+       my $opts = $parse_sync_opts->($param, $realmconfig); # can throw up
+
+       my $scope = $opts->{scope};
+       my $whatstring = $scope eq 'both' ? "users and groups" : $scope;
+
+       my $plugin = PVE::Auth::Plugin->lookup($type);
+
+       my $worker = sub {
+           print "(dry test run) " if $dry_run;
+           print "starting sync for realm $realm\n";
+
+           my ($synced_users, $dnmap) = $plugin->get_users($realmconfig, $realm);
+           my $synced_groups = {};
+           if ($scope eq 'groups' || $scope eq 'both') {
+               $synced_groups = $plugin->get_groups($realmconfig, $realm, $dnmap);
+           }
+
+           PVE::AccessControl::lock_user_config(sub {
+               my $usercfg = cfs_read_file("user.cfg");
+               print "got data from server, updating $whatstring\n";
+
+               if ($scope eq 'users' || $scope eq 'both') {
+                   $update_users->($usercfg, $realm, $synced_users, $opts);
+               }
+
+               if ($scope eq 'groups' || $scope eq 'both') {
+                   $update_groups->($usercfg, $realm, $synced_groups, $opts);
+               }
+
+               if ($dry_run) {
+                   print "\nNOTE: Dry test run, changes were NOT written to the configuration.\n";
+                   return;
+               }
+               cfs_write_file("user.cfg", $usercfg);
+               print "successfully updated $whatstring configuration\n";
+           }, "syncing $whatstring failed");
+       };
+
+       my $workerid = !$dry_run ? 'auth-realm-sync' : 'auth-realm-sync-test';
+       return $rpcenv->fork_worker($workerid, $realm, $authuser, $worker);
+    }});
+
+1;
diff --git a/src/PVE/API2/Group.pm b/src/PVE/API2/Group.pm
new file mode 100644 (file)
index 0000000..c463bd6
--- /dev/null
@@ -0,0 +1,240 @@
+package PVE::API2::Group;
+
+use strict;
+use warnings;
+use PVE::Cluster qw (cfs_read_file cfs_write_file);
+use PVE::AccessControl;
+use PVE::SafeSyslog;
+use PVE::RESTHandler;
+use PVE::JSONSchema qw(get_standard_option register_standard_option);
+
+use base qw(PVE::RESTHandler);
+
+register_standard_option('group-id', {
+    type => 'string',
+    format => 'pve-groupid',
+    completion => \&PVE::AccessControl::complete_group,
+});
+
+register_standard_option('group-comment', { type => 'string', optional => 1 });
+
+__PACKAGE__->register_method ({
+    name => 'index', 
+    path => '', 
+    method => 'GET',
+    description => "Group index.",
+    permissions => { 
+       description => "The returned list is restricted to groups where you have 'User.Modify', 'Sys.Audit'  or 'Group.Allocate' permissions on /access/groups/<group>.",
+       user => 'all',
+    },
+    parameters => {
+       additionalProperties => 0,
+       properties => {},
+    },
+    returns => {
+       type => 'array',
+       items => {
+           type => "object",
+           properties => {
+               groupid => get_standard_option('group-id'),
+               comment => get_standard_option('group-comment'),
+               users => {
+                   type => 'string',
+                   format => 'pve-userid-list',
+                   optional => 1,
+                   description => 'list of users which form this group',
+               },
+           },
+       },
+       links => [ { rel => 'child', href => "{groupid}" } ],
+    },
+    code => sub {
+       my ($param) = @_;
+    
+       my $res = [];
+
+       my $rpcenv = PVE::RPCEnvironment::get();
+       my $usercfg = cfs_read_file("user.cfg");
+       my $authuser = $rpcenv->get_user();
+
+       my $privs = [ 'User.Modify', 'Sys.Audit', 'Group.Allocate'];
+
+       foreach my $group (keys %{$usercfg->{groups}}) {
+           next if !$rpcenv->check_any($authuser, "/access/groups/$group", $privs, 1);
+           my $data = $usercfg->{groups}->{$group};
+           my $entry = { groupid => $group };
+           $entry->{comment} = $data->{comment} if defined($data->{comment});
+           $entry->{users} = join (',', sort keys %{$data->{users}}) if defined($data->{users});
+           push @$res, $entry;
+       }
+
+       return $res;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'create_group', 
+    protected => 1,
+    path => '', 
+    method => 'POST',
+    permissions => { 
+       check => ['perm', '/access/groups', ['Group.Allocate']],
+    },
+    description => "Create new group.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           groupid => get_standard_option('group-id'),
+           comment => get_standard_option('group-comment'),
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       PVE::AccessControl::lock_user_config(
+           sub {
+                       
+               my $usercfg = cfs_read_file("user.cfg");
+
+               my $group = $param->{groupid};
+       
+               die "group '$group' already exists\n" 
+                   if $usercfg->{groups}->{$group};
+
+               $usercfg->{groups}->{$group} = { users => {} };
+
+               $usercfg->{groups}->{$group}->{comment} = $param->{comment} if $param->{comment};
+
+               
+               cfs_write_file("user.cfg", $usercfg);
+           }, "create group failed");
+
+       return undef;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'update_group', 
+    protected => 1,
+    path => '{groupid}', 
+    method => 'PUT',
+    permissions => { 
+       check => ['perm', '/access/groups', ['Group.Allocate']],
+    },
+    description => "Update group data.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           groupid => get_standard_option('group-id'),
+           comment => get_standard_option('group-comment'),
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       PVE::AccessControl::lock_user_config(
+           sub {
+                       
+               my $usercfg = cfs_read_file("user.cfg");
+
+               my $group = $param->{groupid};
+       
+               my $data = $usercfg->{groups}->{$group};
+
+               die "group '$group' does not exist\n" 
+                   if !$data;
+
+               $data->{comment} = $param->{comment} if defined($param->{comment});
+               
+               cfs_write_file("user.cfg", $usercfg);
+           }, "update group failed");
+
+       return undef;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'read_group', 
+    path => '{groupid}', 
+    method => 'GET',
+    permissions => { 
+       check => ['perm', '/access/groups', ['Sys.Audit', 'Group.Allocate'], any => 1],
+   },
+    description => "Get group configuration.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           groupid => get_standard_option('group-id'),
+       },
+    },
+    returns => {
+       type => "object",
+       additionalProperties => 0,
+       properties => {
+           comment => get_standard_option('group-comment'),
+           members => {
+               type => 'array',
+               items => get_standard_option('userid-completed')
+           },
+       },
+    },
+    code => sub {
+       my ($param) = @_;
+
+       my $group = $param->{groupid};
+
+       my $usercfg = cfs_read_file("user.cfg");
+       my $data = $usercfg->{groups}->{$group};
+
+       die "group '$group' does not exist\n" if !$data;
+
+       my $members = $data->{users} ? [ keys %{$data->{users}} ] : [];
+
+       my $res = { members => $members };
+
+       $res->{comment} = $data->{comment} if defined($data->{comment});
+
+       return $res;
+    }});
+
+
+__PACKAGE__->register_method ({
+    name => 'delete_group', 
+    protected => 1,
+    path => '{groupid}', 
+    method => 'DELETE',
+    permissions => { 
+       check => ['perm', '/access/groups', ['Group.Allocate']],
+    },
+    description => "Delete group.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           groupid => get_standard_option('group-id'),
+       }
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       PVE::AccessControl::lock_user_config(
+           sub {
+
+               my $usercfg = cfs_read_file("user.cfg");
+
+               my $group = $param->{groupid};
+
+               die "group '$group' does not exist\n" 
+                   if !$usercfg->{groups}->{$group};
+       
+               delete ($usercfg->{groups}->{$group});
+
+               PVE::AccessControl::delete_group_acl($group, $usercfg);
+
+               cfs_write_file("user.cfg", $usercfg);
+           }, "delete group failed");
+       
+       return undef;
+    }});
+
+1;
diff --git a/src/PVE/API2/Makefile b/src/PVE/API2/Makefile
new file mode 100644 (file)
index 0000000..1bf8c05
--- /dev/null
@@ -0,0 +1,12 @@
+
+API2_SOURCES=                  \
+       AccessControl.pm        \
+       Domains.pm              \
+       ACL.pm                  \
+       Role.pm                 \
+       Group.pm                \
+       User.pm
+
+.PHONY: install
+install:
+       for i in ${API2_SOURCES}; do install -D -m 0644 $$i ${DESTDIR}${PERLDIR}/PVE/API2/$$i; done
diff --git a/src/PVE/API2/Role.pm b/src/PVE/API2/Role.pm
new file mode 100644 (file)
index 0000000..70a92b6
--- /dev/null
@@ -0,0 +1,228 @@
+package PVE::API2::Role;
+
+use strict;
+use warnings;
+use PVE::Cluster qw (cfs_read_file cfs_write_file);
+use PVE::AccessControl;
+use PVE::JSONSchema qw(get_standard_option register_standard_option);
+
+use PVE::SafeSyslog;
+
+use PVE::RESTHandler;
+
+use base qw(PVE::RESTHandler);
+
+register_standard_option('role-id', {
+    type => 'string',
+    format => 'pve-roleid',
+});
+register_standard_option('role-privs', {
+    type => 'string' ,
+    format => 'pve-priv-list',
+    optional => 1,
+});
+
+__PACKAGE__->register_method ({
+    name => 'index',
+    path => '',
+    method => 'GET',
+    description => "Role index.",
+    permissions => {
+       user => 'all',
+    },
+    parameters => {
+       additionalProperties => 0,
+       properties => {},
+    },
+    returns => {
+       type => 'array',
+       items => {
+           type => "object",
+           properties => {
+               roleid => get_standard_option('role-id'),
+               privs =>  get_standard_option('role-privs'),
+               special => { type => 'boolean', optional => 1, default => 0 },
+           },
+       },
+       links => [ { rel => 'child', href => "{roleid}" } ],
+    },
+    code => sub {
+       my ($param) = @_;
+
+       my $res = [];
+
+       my $usercfg = cfs_read_file("user.cfg");
+
+       foreach my $role (keys %{$usercfg->{roles}}) {
+           my $privs = join(',', sort keys %{$usercfg->{roles}->{$role}});
+           push @$res, {
+               roleid => $role,
+               privs => $privs,
+               special => PVE::AccessControl::role_is_special($role),
+           };
+       }
+
+       return $res;
+}});
+
+__PACKAGE__->register_method ({
+    name => 'create_role',
+    protected => 1,
+    path => '',
+    method => 'POST',
+    permissions => {
+       check => ['perm', '/access', ['Sys.Modify']],
+    },
+    description => "Create new role.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           roleid => get_standard_option('role-id'),
+           privs =>  get_standard_option('role-privs'),
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       PVE::AccessControl::lock_user_config(
+           sub {
+
+               my $usercfg = cfs_read_file("user.cfg");
+
+               my $role = $param->{roleid};
+
+               die "role '$role' already exists\n"
+                   if $usercfg->{roles}->{$role};
+
+               $usercfg->{roles}->{$role} = {};
+
+               PVE::AccessControl::add_role_privs($role, $usercfg, $param->{privs});
+
+               cfs_write_file("user.cfg", $usercfg);
+           }, "create role failed");
+
+       return undef;
+}});
+
+__PACKAGE__->register_method ({
+    name => 'update_role',
+    protected => 1,
+    path => '{roleid}',
+    method => 'PUT',
+    permissions => {
+       check => ['perm', '/access', ['Sys.Modify']],
+    },
+    description => "Update an existing role.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           roleid => get_standard_option('role-id'),
+           privs =>  get_standard_option('role-privs'),
+           append => { type => 'boolean', optional => 1, requires => 'privs' },
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       my $role = $param->{roleid};
+
+       die "auto-generated role '$role' cannot be modified\n"
+           if PVE::AccessControl::role_is_special($role);
+
+       PVE::AccessControl::lock_user_config(
+           sub {
+
+               my $usercfg = cfs_read_file("user.cfg");
+
+               die "role '$role' does not exist\n"
+                   if !$usercfg->{roles}->{$role};
+
+               $usercfg->{roles}->{$role} = {} if !$param->{append};
+
+               PVE::AccessControl::add_role_privs($role, $usercfg, $param->{privs});
+
+               cfs_write_file("user.cfg", $usercfg);
+           }, "update role failed");
+
+       return undef;
+}});
+
+__PACKAGE__->register_method ({
+    name => 'read_role',
+    path => '{roleid}',
+    method => 'GET',
+    permissions => {
+       user => 'all',
+    },
+    description => "Get role configuration.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           roleid => get_standard_option('role-id'),
+       },
+    },
+    returns => {
+       type => "object",
+       additionalProperties => 0,
+       properties => PVE::AccessControl::create_priv_properties(),
+    },
+    code => sub {
+       my ($param) = @_;
+
+       my $usercfg = cfs_read_file("user.cfg");
+
+       my $role = $param->{roleid};
+
+       my $data = $usercfg->{roles}->{$role};
+
+       die "role '$role' does not exist\n" if !$data;
+
+       return $data;
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'delete_role',
+    protected => 1,
+    path => '{roleid}',
+    method => 'DELETE',
+    permissions => {
+       check => ['perm', '/access', ['Sys.Modify']],
+    },
+    description => "Delete role.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           roleid => get_standard_option('role-id'),
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       my $role = $param->{roleid};
+
+       die "auto-generated role '$role' cannot be deleted\n"
+           if PVE::AccessControl::role_is_special($role);
+
+       PVE::AccessControl::lock_user_config(
+           sub {
+               my $usercfg = cfs_read_file("user.cfg");
+
+               die "role '$role' does not exist\n"
+                   if !$usercfg->{roles}->{$role};
+
+               delete ($usercfg->{roles}->{$role});
+
+               # fixme: delete role from acl?
+
+               cfs_write_file("user.cfg", $usercfg);
+           }, "delete role failed");
+
+       return undef;
+    }
+});
+
+1;
diff --git a/src/PVE/API2/User.pm b/src/PVE/API2/User.pm
new file mode 100644 (file)
index 0000000..05de57f
--- /dev/null
@@ -0,0 +1,747 @@
+package PVE::API2::User;
+
+use strict;
+use warnings;
+use PVE::Exception qw(raise raise_perm_exc raise_param_exc);
+use PVE::Cluster qw (cfs_read_file cfs_write_file);
+use PVE::Tools qw(split_list extract_param);
+use PVE::AccessControl;
+use PVE::JSONSchema qw(get_standard_option register_standard_option);
+use PVE::TokenConfig;
+
+use PVE::SafeSyslog;
+
+use PVE::RESTHandler;
+
+use base qw(PVE::RESTHandler);
+
+register_standard_option('user-enable', {
+    description => "Enable the account (default). You can set this to '0' to disable the account",
+    type => 'boolean',
+    optional => 1,
+    default => 1,
+});
+register_standard_option('user-expire', {
+    description => "Account expiration date (seconds since epoch). '0' means no expiration date.",
+    type => 'integer',
+    minimum => 0,
+    optional => 1,
+});
+register_standard_option('user-firstname', { type => 'string', optional => 1 });
+register_standard_option('user-lastname', { type => 'string', optional => 1 });
+register_standard_option('user-email', { type => 'string', optional => 1, format => 'email-opt' });
+register_standard_option('user-comment', { type => 'string', optional => 1 });
+register_standard_option('user-keys', {
+    description => "Keys for two factor auth (yubico).",
+    type => 'string',
+    optional => 1,
+});
+register_standard_option('group-list', {
+    type => 'string', format => 'pve-groupid-list',
+    optional => 1,
+    completion => \&PVE::AccessControl::complete_group,
+});
+register_standard_option('token-subid', {
+    type => 'string',
+    pattern => $PVE::AccessControl::token_subid_regex,
+    description => 'User-specific token identifier.',
+});
+register_standard_option('token-expire', {
+    description => "API token expiration date (seconds since epoch). '0' means no expiration date.",
+    type => 'integer',
+    minimum => 0,
+    optional => 1,
+    default => 'same as user',
+});
+register_standard_option('token-privsep', {
+    description => "Restrict API token privileges with separate ACLs (default), or give full privileges of corresponding user.",
+    type => 'boolean',
+    optional => 1,
+    default => 1,
+});
+register_standard_option('token-comment', { type => 'string', optional => 1 });
+register_standard_option('token-info', {
+    type => 'object',
+    properties => {
+       expire => get_standard_option('token-expire'),
+       privsep => get_standard_option('token-privsep'),
+       comment => get_standard_option('token-comment'),
+    }
+});
+
+my $token_info_extend = sub {
+    my ($props) = @_;
+
+    my $obj = get_standard_option('token-info');
+    my $base_props = $obj->{properties};
+    $obj->{properties} = {};
+
+    foreach my $prop (keys %$base_props) {
+       $obj->{properties}->{$prop} = $base_props->{$prop};
+    }
+
+    foreach my $add_prop (keys %$props) {
+       $obj->{properties}->{$add_prop} = $props->{$add_prop};
+    }
+
+    return $obj;
+};
+
+my $extract_user_data = sub {
+    my ($data, $full) = @_;
+
+    my $res = {};
+
+    foreach my $prop (qw(enable expire firstname lastname email comment keys)) {
+       $res->{$prop} = $data->{$prop} if defined($data->{$prop});
+    }
+
+    return $res if !$full;
+
+    $res->{groups} = $data->{groups} ? [ keys %{$data->{groups}} ] : [];
+    $res->{tokens} = $data->{tokens};
+
+    return $res;
+};
+
+__PACKAGE__->register_method ({
+    name => 'index',
+    path => '',
+    method => 'GET',
+    description => "User index.",
+    permissions => {
+       description => "The returned list is restricted to users where you have 'User.Modify' or 'Sys.Audit' permissions on '/access/groups' or on a group the user belongs too. But it always includes the current (authenticated) user.",
+       user => 'all',
+    },
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           enabled => {
+               type => 'boolean',
+               description => "Optional filter for enable property.",
+               optional => 1,
+           },
+           full => {
+               type => 'boolean',
+               description => "Include group and token information.",
+               optional => 1,
+               default => 0,
+           }
+       },
+    },
+    returns => {
+       type => 'array',
+       items => {
+           type => "object",
+           properties => {
+               userid => get_standard_option('userid-completed'),
+               enable => get_standard_option('user-enable'),
+               expire => get_standard_option('user-expire'),
+               firstname => get_standard_option('user-firstname'),
+               lastname => get_standard_option('user-lastname'),
+               email => get_standard_option('user-email'),
+               comment => get_standard_option('user-comment'),
+               keys => get_standard_option('user-keys'),
+               groups => get_standard_option('group-list'),
+               tokens => {
+                   type => 'array',
+                   optional => 1,
+                   items => $token_info_extend->({
+                       tokenid => get_standard_option('token-subid'),
+                   }),
+               }
+           },
+       },
+       links => [ { rel => 'child', href => "{userid}" } ],
+    },
+    code => sub {
+       my ($param) = @_;
+
+       my $rpcenv = PVE::RPCEnvironment::get();
+       my $usercfg = $rpcenv->{user_cfg};
+       my $authuser = $rpcenv->get_user();
+
+       my $res = [];
+
+       my $privs = [ 'User.Modify', 'Sys.Audit' ];
+       my $canUserMod = $rpcenv->check_any($authuser, "/access/groups", $privs, 1);
+       my $groups = $rpcenv->filter_groups($authuser, $privs, 1);
+       my $allowed_users = $rpcenv->group_member_join([keys %$groups]);
+
+       foreach my $user (keys %{$usercfg->{users}}) {
+           if (!($canUserMod || $user eq $authuser)) {
+               next if !$allowed_users->{$user};
+           }
+
+           my $entry = &$extract_user_data($usercfg->{users}->{$user}, $param->{full});
+
+           if (defined($param->{enabled})) {
+               next if $entry->{enable} && !$param->{enabled};
+               next if !$entry->{enable} && $param->{enabled};
+           }
+
+           $entry->{groups} = join(',', @{$entry->{groups}}) if $entry->{groups};
+           $entry->{tokens} = [ map { { tokenid => $_, %{$entry->{tokens}->{$_}} } } sort keys %{$entry->{tokens}} ]
+               if defined($entry->{tokens});
+
+           $entry->{userid} = $user;
+           push @$res, $entry;
+       }
+
+       return $res;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'create_user',
+    protected => 1,
+    path => '',
+    method => 'POST',
+    permissions => {
+       description => "You need 'Realm.AllocateUser' on '/access/realm/<realm>' on the realm of user <userid>, and 'User.Modify' permissions to '/access/groups/<group>' for any group specified (or 'User.Modify' on '/access/groups' if you pass no groups.",
+       check => [ 'and',
+                  [ 'userid-param', 'Realm.AllocateUser'],
+                  [ 'userid-group', ['User.Modify'], groups_param => 1],
+           ],
+    },
+    description => "Create new user.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid-completed'),
+           enable => get_standard_option('user-enable'),
+           expire => get_standard_option('user-expire'),
+           firstname => get_standard_option('user-firstname'),
+           lastname => get_standard_option('user-lastname'),
+           email => get_standard_option('user-email'),
+           comment => get_standard_option('user-comment'),
+           keys => get_standard_option('user-keys'),
+           password => {
+               description => "Initial password.",
+               type => 'string',
+               optional => 1,
+               minLength => 5,
+               maxLength => 64
+           },
+           groups => get_standard_option('group-list'),
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       PVE::AccessControl::lock_user_config(sub {
+           my ($username, $ruid, $realm) = PVE::AccessControl::verify_username($param->{userid});
+
+           my $usercfg = cfs_read_file("user.cfg");
+
+           # ensure "user exists" check works for case insensitive realms
+           $username = PVE::AccessControl::lookup_username($username, 1);
+           die "user '$username' already exists\n" if $usercfg->{users}->{$username};
+
+           PVE::AccessControl::domain_set_password($realm, $ruid, $param->{password})
+               if defined($param->{password});
+
+           my $enable = defined($param->{enable}) ? $param->{enable} : 1;
+           $usercfg->{users}->{$username} = { enable => $enable };
+           $usercfg->{users}->{$username}->{expire} = $param->{expire} if $param->{expire};
+
+           if ($param->{groups}) {
+               foreach my $group (split_list($param->{groups})) {
+                   if ($usercfg->{groups}->{$group}) {
+                       PVE::AccessControl::add_user_group($username, $usercfg, $group);
+                   } else {
+                       die "no such group '$group'\n";
+                   }
+               }
+           }
+
+           $usercfg->{users}->{$username}->{firstname} = $param->{firstname} if $param->{firstname};
+           $usercfg->{users}->{$username}->{lastname} = $param->{lastname} if $param->{lastname};
+           $usercfg->{users}->{$username}->{email} = $param->{email} if $param->{email};
+           $usercfg->{users}->{$username}->{comment} = $param->{comment} if $param->{comment};
+           $usercfg->{users}->{$username}->{keys} = $param->{keys} if $param->{keys};
+
+           cfs_write_file("user.cfg", $usercfg);
+       }, "create user failed");
+
+       return undef;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'read_user',
+    path => '{userid}',
+    method => 'GET',
+    description => "Get user configuration.",
+    permissions => {
+       check => ['userid-group', ['User.Modify', 'Sys.Audit']],
+    },
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid-completed'),
+       },
+    },
+    returns => {
+       additionalProperties => 0,
+       properties => {
+           enable => get_standard_option('user-enable'),
+           expire => get_standard_option('user-expire'),
+           firstname => get_standard_option('user-firstname'),
+           lastname => get_standard_option('user-lastname'),
+           email => get_standard_option('user-email'),
+           comment => get_standard_option('user-comment'),
+           keys => get_standard_option('user-keys'),
+           groups => {
+               type => 'array',
+               optional => 1,
+               items => {
+                   type => 'string',
+                   format => 'pve-groupid',
+               },
+           },
+           tokens => {
+               optional => 1,
+               type => 'object',
+           },
+       },
+       type => "object"
+    },
+    code => sub {
+       my ($param) = @_;
+
+       my ($username, undef, $domain) =
+           PVE::AccessControl::verify_username($param->{userid});
+
+       my $usercfg = cfs_read_file("user.cfg");
+
+       my $data = PVE::AccessControl::check_user_exist($usercfg, $username);
+
+       return &$extract_user_data($data, 1);
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'update_user',
+    protected => 1,
+    path => '{userid}',
+    method => 'PUT',
+    permissions => {
+       check => ['userid-group', ['User.Modify'], groups_param => 1 ],
+    },
+    description => "Update user configuration.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid-completed'),
+           enable => get_standard_option('user-enable'),
+           expire => get_standard_option('user-expire'),
+           firstname => get_standard_option('user-firstname'),
+           lastname => get_standard_option('user-lastname'),
+           email => get_standard_option('user-email'),
+           comment => get_standard_option('user-comment'),
+           keys => get_standard_option('user-keys'),
+           groups => get_standard_option('group-list'),
+           append => {
+               type => 'boolean',
+               optional => 1,
+               requires => 'groups',
+           },
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       my ($username, $ruid, $realm) =
+           PVE::AccessControl::verify_username($param->{userid});
+
+       PVE::AccessControl::lock_user_config(
+           sub {
+
+               my $usercfg = cfs_read_file("user.cfg");
+
+               PVE::AccessControl::check_user_exist($usercfg, $username);
+
+               $usercfg->{users}->{$username}->{enable} = $param->{enable} if defined($param->{enable});
+
+               $usercfg->{users}->{$username}->{expire} = $param->{expire} if defined($param->{expire});
+
+               PVE::AccessControl::delete_user_group($username, $usercfg)
+                   if (!$param->{append} && defined($param->{groups}));
+
+               if ($param->{groups}) {
+                   foreach my $group (split_list($param->{groups})) {
+                       if ($usercfg->{groups}->{$group}) {
+                           PVE::AccessControl::add_user_group($username, $usercfg, $group);
+                       } else {
+                           die "no such group '$group'\n";
+                       }
+                   }
+               }
+
+               $usercfg->{users}->{$username}->{firstname} = $param->{firstname} if defined($param->{firstname});
+               $usercfg->{users}->{$username}->{lastname} = $param->{lastname} if defined($param->{lastname});
+               $usercfg->{users}->{$username}->{email} = $param->{email} if defined($param->{email});
+               $usercfg->{users}->{$username}->{comment} = $param->{comment} if defined($param->{comment});
+               $usercfg->{users}->{$username}->{keys} = $param->{keys} if defined($param->{keys});
+
+               cfs_write_file("user.cfg", $usercfg);
+           }, "update user failed");
+
+       return undef;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'delete_user',
+    protected => 1,
+    path => '{userid}',
+    method => 'DELETE',
+    description => "Delete user.",
+    permissions => {
+       check => [ 'and',
+                  [ 'userid-param', 'Realm.AllocateUser'],
+                  [ 'userid-group', ['User.Modify']],
+           ],
+    },
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid-completed'),
+       }
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       my $rpcenv = PVE::RPCEnvironment::get();
+       my $authuser = $rpcenv->get_user();
+
+       my ($userid, $ruid, $realm) =
+           PVE::AccessControl::verify_username($param->{userid});
+
+       PVE::AccessControl::lock_user_config(
+           sub {
+
+               my $usercfg = cfs_read_file("user.cfg");
+
+               my $domain_cfg = cfs_read_file('domains.cfg');
+               if (my $cfg = $domain_cfg->{ids}->{$realm}) {
+                   my $plugin = PVE::Auth::Plugin->lookup($cfg->{type});
+                   $plugin->delete_user($cfg, $realm, $ruid);
+               }
+
+               # Remove TFA data before removing the user entry as the user entry tells us whether
+               # we need ot update priv/tfa.cfg.
+               PVE::AccessControl::user_set_tfa($userid, $realm, undef, undef, $usercfg, $domain_cfg);
+
+               delete $usercfg->{users}->{$userid};
+
+               PVE::AccessControl::delete_user_group($userid, $usercfg);
+               PVE::AccessControl::delete_user_acl($userid, $usercfg);
+               cfs_write_file("user.cfg", $usercfg);
+           }, "delete user failed");
+
+       return undef;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'read_user_tfa_type',
+    path => '{userid}/tfa',
+    method => 'GET',
+    protected => 1,
+    description => "Get user TFA types (Personal and Realm).",
+    permissions => {
+       check => [ 'or',
+           ['userid-param', 'self'],
+           ['userid-group', ['User.Modify', 'Sys.Audit']],
+       ],
+    },
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid-completed'),
+       },
+    },
+    returns => {
+       additionalProperties => 0,
+       properties => {
+           realm => {
+               type => 'string',
+               enum => [qw(oath yubico)],
+               description => "The type of TFA the users realm has set, if any.",
+               optional => 1,
+           },
+           user => {
+               type => 'string',
+               enum => [qw(oath u2f)],
+               description => "The type of TFA the user has set, if any.",
+               optional => 1,
+           },
+       },
+       type => "object"
+    },
+    code => sub {
+       my ($param) = @_;
+
+       my ($username, undef, $realm) = PVE::AccessControl::verify_username($param->{userid});
+
+
+       my $domain_cfg = cfs_read_file('domains.cfg');
+       my $realm_cfg = $domain_cfg->{ids}->{$realm};
+       die "auth domain '$realm' does not exist\n" if !$realm_cfg;
+
+       my $realm_tfa = {};
+       $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_cfg->{tfa})
+           if $realm_cfg->{tfa};
+
+       my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+       my $tfa = $tfa_cfg->{users}->{$username};
+
+       my $res = {};
+       $res->{realm} = $realm_tfa->{type} if $realm_tfa->{type};
+       $res->{user} = $tfa->{type} if $tfa->{type};
+       return $res;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'token_index',
+    path => '{userid}/token',
+    method => 'GET',
+    description => "Get user API tokens.",
+    permissions => {
+       check => ['or',
+                   ['userid-param', 'self'],
+                   ['perm', '/access/users/{userid}', ['User.Modify']],
+       ],
+    },
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid-completed'),
+       },
+    },
+    returns => {
+       type => "array",
+       items => $token_info_extend->({
+                   tokenid => get_standard_option('token-subid'),
+       }),
+       links => [ { rel => 'child', href => "{tokenid}" } ],
+    },
+    code => sub {
+       my ($param) = @_;
+
+       my $userid = PVE::AccessControl::verify_username($param->{userid});
+       my $usercfg = cfs_read_file("user.cfg");
+
+       my $user = PVE::AccessControl::check_user_exist($usercfg, $userid);
+
+       my $tokens = $user->{tokens} // {};
+       return [ map { $tokens->{$_}->{tokenid} = $_; $tokens->{$_} } keys %$tokens];
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'read_token',
+    path => '{userid}/token/{tokenid}',
+    method => 'GET',
+    description => "Get specific API token information.",
+    permissions => {
+       check => ['or',
+                   ['userid-param', 'self'],
+                   ['perm', '/access/users/{userid}', ['User.Modify']],
+       ],
+    },
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid-completed'),
+           tokenid => get_standard_option('token-subid'),
+       },
+    },
+    returns => get_standard_option('token-info'),
+    code => sub {
+       my ($param) = @_;
+
+       my $userid = PVE::AccessControl::verify_username($param->{userid});
+       my $tokenid = $param->{tokenid};
+
+       my $usercfg = cfs_read_file("user.cfg");
+
+       return PVE::AccessControl::check_token_exist($usercfg, $userid, $tokenid);
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'generate_token',
+    path => '{userid}/token/{tokenid}',
+    method => 'POST',
+    description => "Generate a new API token for a specific user. NOTE: returns API token value, which needs to be stored as it cannot be retrieved afterwards!",
+    protected => 1,
+    permissions => {
+       check => ['or',
+                   ['userid-param', 'self'],
+                   ['perm', '/access/users/{userid}', ['User.Modify']],
+       ],
+    },
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid-completed'),
+           tokenid => get_standard_option('token-subid'),
+           expire => get_standard_option('token-expire'),
+           privsep => get_standard_option('token-privsep'),
+           comment => get_standard_option('token-comment'),
+       },
+    },
+    returns => {
+       additionalProperties => 0,
+       type => "object",
+       properties => {
+           info => get_standard_option('token-info'),
+           value => {
+               type => 'string',
+               description => 'API token value used for authentication.',
+           },
+           'full-tokenid' => {
+               type => 'string',
+               format_description => '<userid>!<tokenid>',
+               description => 'The full token id.',
+           },
+       },
+    },
+    code => sub {
+       my ($param) = @_;
+
+       my $userid = PVE::AccessControl::verify_username(extract_param($param, 'userid'));
+       my $tokenid = extract_param($param, 'tokenid');
+
+       my $usercfg = cfs_read_file("user.cfg");
+
+       my $token = PVE::AccessControl::check_token_exist($usercfg, $userid, $tokenid, 1);
+       my ($full_tokenid, $value);
+
+       PVE::AccessControl::check_user_exist($usercfg, $userid);
+       raise_param_exc({ 'tokenid' => 'Token already exists.' }) if defined($token);
+
+       my $generate_and_add_token = sub {
+           $usercfg = cfs_read_file("user.cfg");
+           PVE::AccessControl::check_user_exist($usercfg, $userid);
+           die "Token already exists.\n" if defined(PVE::AccessControl::check_token_exist($usercfg, $userid, $tokenid, 1));
+
+           $full_tokenid = PVE::AccessControl::join_tokenid($userid, $tokenid);
+           $value = PVE::TokenConfig::generate_token($full_tokenid);
+
+           $token = {};
+           $token->{privsep} = defined($param->{privsep}) ? $param->{privsep} : 1;
+           $token->{expire} = $param->{expire} if defined($param->{expire});
+           $token->{comment} = $param->{comment} if $param->{comment};
+
+           $usercfg->{users}->{$userid}->{tokens}->{$tokenid} = $token;
+           cfs_write_file("user.cfg", $usercfg);
+       };
+
+       PVE::AccessControl::lock_user_config($generate_and_add_token, 'generating token failed');
+
+       return {
+           info => $token,
+           value => $value,
+           'full-tokenid' => $full_tokenid,
+       };
+    }});
+
+
+__PACKAGE__->register_method ({
+    name => 'update_token_info',
+    path => '{userid}/token/{tokenid}',
+    method => 'PUT',
+    description => "Update API token for a specific user.",
+    protected => 1,
+    permissions => {
+       check => ['or',
+                   ['userid-param', 'self'],
+                   ['perm', '/access/users/{userid}', ['User.Modify']],
+       ],
+    },
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid-completed'),
+           tokenid => get_standard_option('token-subid'),
+           expire => get_standard_option('token-expire'),
+           privsep => get_standard_option('token-privsep'),
+           comment => get_standard_option('token-comment'),
+       },
+    },
+    returns => get_standard_option('token-info', { description => "Updated token information." }),
+    code => sub {
+       my ($param) = @_;
+
+       my $userid = PVE::AccessControl::verify_username(extract_param($param, 'userid'));
+       my $tokenid = extract_param($param, 'tokenid');
+
+       my $usercfg = cfs_read_file("user.cfg");
+       my $token = PVE::AccessControl::check_token_exist($usercfg, $userid, $tokenid);
+
+       my $update_token = sub {
+           $usercfg = cfs_read_file("user.cfg");
+           $token = PVE::AccessControl::check_token_exist($usercfg, $userid, $tokenid);
+
+           my $full_tokenid = PVE::AccessControl::join_tokenid($userid, $tokenid);
+
+           $token->{privsep} = $param->{privsep} if defined($param->{privsep});
+           $token->{expire} = $param->{expire} if defined($param->{expire});
+           $token->{comment} = $param->{comment} if $param->{comment};
+
+           $usercfg->{users}->{$userid}->{tokens}->{$tokenid} = $token;
+           cfs_write_file("user.cfg", $usercfg);
+       };
+
+       PVE::AccessControl::lock_user_config($update_token, 'updating token info failed');
+
+       return $token;
+    }});
+
+
+__PACKAGE__->register_method ({
+    name => 'remove_token',
+    path => '{userid}/token/{tokenid}',
+    method => 'DELETE',
+    description => "Remove API token for a specific user.",
+    protected => 1,
+    permissions => {
+       check => ['or',
+                   ['userid-param', 'self'],
+                   ['perm', '/access/users/{userid}', ['User.Modify']],
+       ],
+    },
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid-completed'),
+           tokenid => get_standard_option('token-subid'),
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       my $userid = PVE::AccessControl::verify_username(extract_param($param, 'userid'));
+       my $tokenid = extract_param($param, 'tokenid');
+
+       my $usercfg = cfs_read_file("user.cfg");
+       my $token = PVE::AccessControl::check_token_exist($usercfg, $userid, $tokenid);
+
+       my $update_token = sub {
+           $usercfg = cfs_read_file("user.cfg");
+
+           PVE::AccessControl::check_token_exist($usercfg, $userid, $tokenid);
+
+           my $full_tokenid = PVE::AccessControl::join_tokenid($userid, $tokenid);
+           PVE::TokenConfig::delete_token($full_tokenid);
+           delete $usercfg->{users}->{$userid}->{tokens}->{$tokenid};
+
+           cfs_write_file("user.cfg", $usercfg);
+       };
+
+       PVE::AccessControl::lock_user_config($update_token, 'deleting token failed');
+
+       return;
+    }});
+1;
diff --git a/src/PVE/AccessControl.pm b/src/PVE/AccessControl.pm
new file mode 100644 (file)
index 0000000..f7d4e78
--- /dev/null
@@ -0,0 +1,1727 @@
+package PVE::AccessControl;
+
+use strict;
+use warnings;
+use Encode;
+use Crypt::OpenSSL::Random;
+use Crypt::OpenSSL::RSA;
+use Net::SSLeay;
+use Net::IP;
+use MIME::Base64;
+use Digest::SHA;
+use IO::File;
+use File::stat;
+use JSON;
+
+use PVE::OTP;
+use PVE::Ticket;
+use PVE::Tools qw(run_command lock_file file_get_contents split_list safe_print);
+use PVE::Cluster qw(cfs_register_file cfs_read_file cfs_write_file cfs_lock_file);
+use PVE::JSONSchema qw(register_standard_option get_standard_option);
+
+use PVE::Auth::Plugin;
+use PVE::Auth::AD;
+use PVE::Auth::LDAP;
+use PVE::Auth::PVE;
+use PVE::Auth::PAM;
+
+# load and initialize all plugins
+
+PVE::Auth::AD->register();
+PVE::Auth::LDAP->register();
+PVE::Auth::PVE->register();
+PVE::Auth::PAM->register();
+PVE::Auth::Plugin->init();
+
+# $authdir must be writable by root only!
+my $confdir = "/etc/pve";
+my $authdir = "$confdir/priv";
+
+my $pve_www_key_fn = "$confdir/pve-www.key";
+
+my $pve_auth_key_files = {
+    priv => "$authdir/authkey.key",
+    pub =>  "$confdir/authkey.pub",
+    pubold => "$confdir/authkey.pub.old",
+};
+
+my $pve_auth_key_cache = {};
+
+my $ticket_lifetime = 3600 * 2; # 2 hours
+my $auth_graceperiod = 60 * 5; # 5 minutes
+my $authkey_lifetime = 3600 * 24; # rotate every 24 hours
+
+Crypt::OpenSSL::RSA->import_random_seed();
+
+cfs_register_file('user.cfg',
+                 \&parse_user_config,
+                 \&write_user_config);
+cfs_register_file('priv/tfa.cfg',
+                 \&parse_priv_tfa_config,
+                 \&write_priv_tfa_config);
+
+sub verify_username {
+    PVE::Auth::Plugin::verify_username(@_);
+}
+
+sub pve_verify_realm {
+    PVE::Auth::Plugin::pve_verify_realm(@_);
+}
+
+sub lock_user_config {
+    my ($code, $errmsg) = @_;
+
+    cfs_lock_file("user.cfg", undef, $code);
+    if (my $err = $@) {
+       $errmsg ? die "$errmsg: $err" : die $err;
+    }
+}
+
+my $cache_read_key = sub {
+    my ($type) = @_;
+
+    my $path = $pve_auth_key_files->{$type};
+
+    my $read_key_and_mtime = sub {
+       my $fh = IO::File->new($path, "r");
+
+       return undef if !defined($fh);
+
+       my $st = stat($fh);
+       my $pem = PVE::Tools::safe_read_from($fh, 0, 0, $path);
+
+       close $fh;
+
+       my $key;
+       if ($type eq 'pub' || $type eq 'pubold') {
+           $key = eval { Crypt::OpenSSL::RSA->new_public_key($pem); };
+       } elsif ($type eq 'priv') {
+           $key = eval { Crypt::OpenSSL::RSA->new_private_key($pem); };
+       } else {
+           die "Invalid authkey type '$type'\n";
+       }
+
+       return { key => $key, mtime => $st->mtime };
+    };
+
+    if (!defined($pve_auth_key_cache->{$type})) {
+       $pve_auth_key_cache->{$type} = $read_key_and_mtime->();
+    } else {
+       my $st = stat($path);
+       if (!$st || $st->mtime != $pve_auth_key_cache->{$type}->{mtime}) {
+           $pve_auth_key_cache->{$type} = $read_key_and_mtime->();
+       }
+    }
+
+    return $pve_auth_key_cache->{$type};
+};
+
+sub get_pubkey {
+    my ($old) = @_;
+
+    my $type = $old ? 'pubold' : 'pub';
+
+    my $res = $cache_read_key->($type);
+    return undef if !defined($res);
+
+    return wantarray ? ($res->{key}, $res->{mtime}) : $res->{key};
+}
+
+sub get_privkey {
+    my $res = $cache_read_key->('priv');
+
+    if (!defined($res) || !check_authkey(1)) {
+       rotate_authkey();
+       $res = $cache_read_key->('priv');
+    }
+
+    return wantarray ? ($res->{key}, $res->{mtime}) : $res->{key};
+}
+
+sub check_authkey {
+    my ($quiet) = @_;
+
+    # skip check if non-quorate, as rotation is not possible anyway
+    return 1 if !PVE::Cluster::check_cfs_quorum(1);
+
+    my ($pub_key, $mtime) = get_pubkey();
+    if (!$pub_key) {
+       warn "auth key pair missing, generating new one..\n"  if !$quiet;
+       return 0;
+    } else {
+       my $now = time();
+       if ($now - $mtime >= $authkey_lifetime) {
+           warn "auth key pair too old, rotating..\n" if !$quiet;;
+           return 0;
+       } elsif ($mtime > $now + $auth_graceperiod) {
+           # a nodes RTC had a time set in the future during key generation -> ticket
+           # validity is clamped to 0+5 min grace period until now >= mtime again
+           my (undef, $old_mtime) = get_pubkey(1);
+           if ($old_mtime && $mtime >= $old_mtime && $mtime - $old_mtime < $ticket_lifetime) {
+               warn "auth key pair generated in the future (key $mtime > host $now),"
+                   ." but old key still exists and in valid grace period so avoid automatic"
+                   ." fixup. Cluster time not in sync?\n" if !$quiet;
+               return 1;
+           }
+           warn "auth key pair generated in the future (key $mtime > host $now), rotating..\n" if !$quiet;
+           return 0;
+       } else {
+           warn "auth key new enough, skipping rotation\n" if !$quiet;;
+           return 1;
+       }
+    }
+}
+
+sub rotate_authkey {
+    return if $authkey_lifetime == 0;
+
+    PVE::Cluster::cfs_lock_authkey(undef, sub {
+       # re-check with lock to avoid double rotation in clusters
+       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 $@;
+       }
+
+       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 ($@) {
+           if ($old) {
+               warn "Failed to store new auth key - $@\n";
+               warn "Reverting to previous auth key\n";
+               eval {
+                   my $pem = $old->get_public_key_x509_string();
+                   PVE::Tools::file_set_contents($pve_auth_key_files->{pub}, $pem);
+               };
+               die "Failed to restore old auth key: $@\n" if $@;
+           } else {
+               die "Failed to store new auth key - $@\n";
+           }
+       }
+
+       eval {
+           my $pem = $new->get_private_key_string();
+           PVE::Tools::file_set_contents($pve_auth_key_files->{priv}, $pem);
+       };
+       if ($@) {
+           warn "Failed to store new auth key - $@\n";
+           warn "Deleting auth key to force regeneration\n";
+           unlink $pve_auth_key_files->{pub};
+           unlink $pve_auth_key_files->{priv};
+       }
+    });
+    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::hmac_sha256_base64($input);
+       $csrf_prevention_secret_legacy = Digest::SHA::sha1_base64($input);
+    }
+    return $csrf_prevention_secret;
+};
+
+sub assemble_csrf_prevention_token {
+    my ($username) = @_;
+
+    my $secret =  &$get_csrfr_secret();
+
+    return PVE::Ticket::assemble_csrf_prevention_token ($secret, $username);
+}
+
+sub verify_csrf_prevention_token {
+    my ($username, $token, $noerr) = @_;
+
+    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, -$auth_graceperiod, $ticket_lifetime, $noerr);
+}
+
+my $get_ticket_age_range = sub {
+    my ($now, $mtime, $rotated) = @_;
+
+    my $key_age = $now - $mtime;
+    $key_age = 0 if $key_age < 0;
+
+    my $min = -$auth_graceperiod;
+    my $max = $ticket_lifetime;
+
+    if ($rotated) {
+       # ticket creation after rotation is not allowed
+       $min = $key_age - $auth_graceperiod;
+    } else {
+       if ($key_age > $authkey_lifetime && $authkey_lifetime > 0) {
+           if (PVE::Cluster::check_cfs_quorum(1)) {
+               # key should have been rotated, clamp range accordingly
+               $min = $key_age - $authkey_lifetime;
+           } else {
+               warn "Cluster not quorate - extending auth key lifetime!\n";
+           }
+       }
+
+       $max = $key_age + $auth_graceperiod if $key_age < $ticket_lifetime;
+    }
+
+    return undef if $min > $ticket_lifetime;
+    return ($min, $max);
+};
+
+sub assemble_ticket {
+    my ($data) = @_;
+
+    my $rsa_priv = get_privkey();
+
+    return PVE::Ticket::assemble_rsa_ticket($rsa_priv, 'PVE', $data);
+}
+
+sub verify_ticket {
+    my ($ticket, $noerr) = @_;
+
+    my $now = time();
+
+    my $check = sub {
+       my ($old) = @_;
+
+       my ($rsa_pub, $rsa_mtime) = get_pubkey($old);
+       return undef if !$rsa_pub;
+
+       my ($min, $max) = $get_ticket_age_range->($now, $rsa_mtime, $old);
+       return undef if !defined($min);
+
+       return PVE::Ticket::verify_rsa_ticket(
+           $rsa_pub, 'PVE', $ticket, undef, $min, $max, 1);
+    };
+
+    my ($data, $age) = $check->();
+
+    # check with old, rotated key if current key failed
+    ($data, $age) = $check->(1) if !defined($data);
+
+    my $auth_failure = sub {
+       if ($noerr) {
+           return undef;
+       } else {
+           # raise error via undef ticket
+           PVE::Ticket::verify_rsa_ticket(undef, 'PVE');
+       }
+    };
+
+    if (!defined($data)) {
+       return $auth_failure->();
+    }
+
+    my ($username, $tfa_info);
+    if ($data =~ m{^u2f!([^!]+)!([0-9a-zA-Z/.=_\-+]+)$}) {
+       # Ticket for u2f-users:
+       ($username, my $challenge) = ($1, $2);
+       if ($challenge eq 'verified') {
+           # u2f challenge was completed
+           $challenge = undef;
+       } elsif (!wantarray) {
+           # The caller is not aware there could be an ongoing challenge,
+           # so we treat this ticket as invalid:
+           return $auth_failure->();
+       }
+       $tfa_info = {
+           type => 'u2f',
+           challenge => $challenge,
+       };
+    } elsif ($data =~ /^tfa!(.*)$/) {
+       # TOTP and Yubico don't require a challenge so this is the generic
+       # 'missing 2nd factor ticket'
+       $username = $1;
+       $tfa_info = { type => 'tfa' };
+    } else {
+       # Regular ticket (full access)
+       $username = $data;
+    }
+
+    return undef if !PVE::Auth::Plugin::verify_username($username, $noerr);
+
+    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')
+sub assemble_vnc_ticket {
+    my ($username, $path) = @_;
+
+    my $rsa_priv = get_privkey();
+
+    $path = normalize_path($path);
+
+    my $secret_data = "$username:$path";
+
+    return PVE::Ticket::assemble_rsa_ticket(
+       $rsa_priv, 'PVEVNC', undef, $secret_data);
+}
+
+sub verify_vnc_ticket {
+    my ($ticket, $username, $path, $noerr) = @_;
+
+    my $secret_data = "$username:$path";
+
+    my ($rsa_pub, $rsa_mtime) = get_pubkey();
+    if (!$rsa_pub || (time() - $rsa_mtime > $authkey_lifetime && $authkey_lifetime > 0)) {
+       if ($noerr) {
+           return undef;
+       } else {
+           # raise error via undef ticket
+           PVE::Ticket::verify_rsa_ticket($rsa_pub, 'PVEVNC');
+       }
+    }
+
+    return PVE::Ticket::verify_rsa_ticket(
+       $rsa_pub, 'PVEVNC', $ticket, $secret_data, -20, 40, $noerr);
+}
+
+sub assemble_spice_ticket {
+    my ($username, $vmid, $node) = @_;
+
+    my $secret = &$get_csrfr_secret();
+
+    return PVE::Ticket::assemble_spice_ticket(
+       $secret, $username, $vmid, $node);
+}
+
+sub verify_spice_connect_url {
+    my ($connect_str) = @_;
+
+    my $secret = &$get_csrfr_secret();
+
+    return PVE::Ticket::verify_spice_connect_url($secret, $connect_str);
+}
+
+sub read_x509_subject_spice {
+    my ($filename) = @_;
+
+    # read x509 subject
+    my $bio = Net::SSLeay::BIO_new_file($filename, 'r');
+    die "Could not open $filename using OpenSSL\n"
+       if !$bio;
+
+    my $x509 = Net::SSLeay::PEM_read_bio_X509($bio);
+    Net::SSLeay::BIO_free($bio);
+
+    die "Could not parse X509 certificate in $filename\n"
+       if !$x509;
+
+    my $nameobj = Net::SSLeay::X509_get_subject_name($x509);
+    my $subject = Net::SSLeay::X509_NAME_oneline($nameobj);
+    Net::SSLeay::X509_free($x509);
+
+    # remote-viewer wants comma as seperator (not '/')
+    $subject =~ s!^/!!;
+    $subject =~ s!/(\w+=)!,$1!g;
+
+    return $subject;
+}
+
+# helper to generate SPICE remote-viewer configuration
+sub remote_viewer_config {
+    my ($authuser, $vmid, $node, $proxy, $title, $port) = @_;
+
+    if (!$proxy) {
+       my $host = `hostname -f` || PVE::INotify::nodename();
+       chomp $host;
+       $proxy = $host;
+    }
+
+    my ($ticket, $proxyticket) = assemble_spice_ticket($authuser, $vmid, $node);
+
+    my $filename = "/etc/pve/local/pve-ssl.pem";
+    my $subject = read_x509_subject_spice($filename);
+
+    my $cacert = PVE::Tools::file_get_contents("/etc/pve/pve-root-ca.pem", 8192);
+    $cacert =~ s/\n/\\n/g;
+
+    $proxy = "[$proxy]" if Net::IP::ip_is_ipv6($proxy);
+    my $config = {
+       'secure-attention' => "Ctrl+Alt+Ins",
+       'toggle-fullscreen' => "Shift+F11",
+       'release-cursor' => "Ctrl+Alt+R",
+       type => 'spice',
+       title => $title,
+       host => $proxyticket, # this breaks tls hostname verification, so we need to use 'host-subject'
+       proxy => "http://$proxy:3128",
+       'tls-port' => $port,
+       'host-subject' => $subject,
+       ca => $cacert,
+       password => $ticket,
+       'delete-this-file' => 1,
+    };
+
+    return ($ticket, $proxyticket, $config);
+}
+
+sub check_user_exist {
+    my ($usercfg, $username, $noerr) = @_;
+
+    $username = PVE::Auth::Plugin::verify_username($username, $noerr);
+    return undef if !$username;
+
+    return $usercfg->{users}->{$username} if $usercfg && $usercfg->{users}->{$username};
+
+    die "no such user ('$username')\n" if !$noerr;
+
+    return undef;
+}
+
+sub check_user_enabled {
+    my ($usercfg, $username, $noerr) = @_;
+
+    my $data = check_user_exist($usercfg, $username, $noerr);
+    return undef if !$data;
+
+    return 1 if $data->{enable};
+
+    die "user '$username' is disabled\n" if !$noerr;
+
+    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) = @_;
+
+    die "missing one time password for two-factor authentication '$type'\n" if !$otp;
+
+    # fixme: proxy support?
+    my $proxy;
+
+    if ($type eq 'yubico') {
+       PVE::OTP::yubico_verify_otp($otp, $keys, $tfa_cfg->{url},
+                                   $tfa_cfg->{id}, $tfa_cfg->{key}, $proxy);
+    } elsif ($type eq 'oath') {
+       PVE::OTP::oath_verify_otp($otp, $keys, $tfa_cfg->{step}, $tfa_cfg->{digits});
+    } else {
+       die "unknown tfa type '$type'\n";
+    }
+}
+
+# password should be utf8 encoded
+# Note: some plugins delay/sleep if auth fails
+sub authenticate_user {
+    my ($username, $password, $otp) = @_;
+
+    die "no username specified\n" if !$username;
+
+    my ($ruid, $realm);
+
+    ($username, $ruid, $realm) = PVE::Auth::Plugin::verify_username($username);
+
+    my $usercfg = cfs_read_file('user.cfg');
+
+    check_user_enabled($usercfg, $username);
+
+    my $ctime = time();
+    my $expire = $usercfg->{users}->{$username}->{expire};
+
+    die "account expired\n" if $expire && ($expire < $ctime);
+
+    my $domain_cfg = cfs_read_file('domains.cfg');
+
+    my $cfg = $domain_cfg->{ids}->{$realm};
+    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);
+
+    my ($type, $tfa_data) = user_get_tfa($username, $realm);
+    if ($type) {
+       if ($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;
+}
+
+sub domain_set_password {
+    my ($realm, $username, $password) = @_;
+
+    die "no auth domain specified" if !$realm;
+
+    my $domain_cfg = cfs_read_file('domains.cfg');
+
+    my $cfg = $domain_cfg->{ids}->{$realm};
+    die "auth domain '$realm' does not exist\n" if !$cfg;
+    my $plugin = PVE::Auth::Plugin->lookup($cfg->{type});
+    $plugin->store_password($cfg, $realm, $username, $password);
+}
+
+sub add_user_group {
+    my ($username, $usercfg, $group) = @_;
+
+    $usercfg->{users}->{$username}->{groups}->{$group} = 1;
+    $usercfg->{groups}->{$group}->{users}->{$username} = 1;
+}
+
+sub delete_user_group {
+    my ($username, $usercfg) = @_;
+
+    foreach my $group (keys %{$usercfg->{groups}}) {
+
+       delete ($usercfg->{groups}->{$group}->{users}->{$username})
+           if $usercfg->{groups}->{$group}->{users}->{$username};
+    }
+}
+
+sub delete_user_acl {
+    my ($username, $usercfg) = @_;
+
+    foreach my $acl (keys %{$usercfg->{acl}}) {
+
+       delete ($usercfg->{acl}->{$acl}->{users}->{$username})
+           if $usercfg->{acl}->{$acl}->{users}->{$username};
+    }
+}
+
+sub delete_group_acl {
+    my ($group, $usercfg) = @_;
+
+    foreach my $acl (keys %{$usercfg->{acl}}) {
+
+       delete ($usercfg->{acl}->{$acl}->{groups}->{$group})
+           if $usercfg->{acl}->{$acl}->{groups}->{$group};
+    }
+}
+
+sub delete_pool_acl {
+    my ($pool, $usercfg) = @_;
+
+    my $path = "/pool/$pool";
+
+    delete ($usercfg->{acl}->{$path})
+}
+
+# we automatically create some predefined roles by splitting privs
+# into 3 groups (per category)
+# root: only root is allowed to do that
+# admin: an administrator can to that
+# user: a normal user/customer can to that
+my $privgroups = {
+    VM => {
+       root => [],
+       admin => [
+           'VM.Config.Disk',
+           'VM.Config.CPU',
+           'VM.Config.Memory',
+           'VM.Config.Network',
+           'VM.Config.HWType',
+           'VM.Config.Options', # covers all other things
+           'VM.Allocate',
+           'VM.Clone',
+           'VM.Migrate',
+           'VM.Monitor',
+           'VM.Snapshot',
+           'VM.Snapshot.Rollback',
+       ],
+       user => [
+           'VM.Config.CDROM', # change CDROM media
+           'VM.Config.Cloudinit',
+           'VM.Console',
+           'VM.Backup',
+           'VM.PowerMgmt',
+       ],
+       audit => [
+           'VM.Audit',
+       ],
+    },
+    Sys => {
+       root => [
+           'Sys.PowerMgmt',
+           'Sys.Modify', # edit/change node settings
+       ],
+       admin => [
+           'Permissions.Modify',
+           'Sys.Console',
+           'Sys.Syslog',
+       ],
+       user => [],
+       audit => [
+           'Sys.Audit',
+       ],
+    },
+    Datastore => {
+       root => [],
+       admin => [
+           'Datastore.Allocate',
+           'Datastore.AllocateTemplate',
+       ],
+       user => [
+           'Datastore.AllocateSpace',
+       ],
+       audit => [
+           'Datastore.Audit',
+       ],
+    },
+    SDN => {
+       root => [],
+       admin => [
+           'SDN.Allocate',
+           'SDN.Audit',
+       ],
+       audit => [
+           'SDN.Audit',
+       ],
+    },
+    User => {
+       root => [
+           'Realm.Allocate',
+       ],
+       admin => [
+           'User.Modify',
+           'Group.Allocate', # edit/change group settings
+           'Realm.AllocateUser',
+       ],
+       user => [],
+       audit => [],
+    },
+    Pool => {
+       root => [],
+       admin => [
+           'Pool.Allocate', # create/delete pools
+       ],
+       user => [],
+       audit => [],
+    },
+};
+
+my $valid_privs = {};
+
+my $special_roles = {
+    'NoAccess' => {}, # no privileges
+    'Administrator' => $valid_privs, # all privileges
+};
+
+sub create_roles {
+
+    foreach my $cat (keys %$privgroups) {
+       my $cd = $privgroups->{$cat};
+       foreach my $p (@{$cd->{root}}, @{$cd->{admin}},
+                      @{$cd->{user}}, @{$cd->{audit}}) {
+           $valid_privs->{$p} = 1;
+       }
+       foreach my $p (@{$cd->{admin}}, @{$cd->{user}}, @{$cd->{audit}}) {
+
+           $special_roles->{"PVE${cat}Admin"}->{$p} = 1;
+           $special_roles->{"PVEAdmin"}->{$p} = 1;
+       }
+       if (scalar(@{$cd->{user}})) {
+           foreach my $p (@{$cd->{user}}, @{$cd->{audit}}) {
+               $special_roles->{"PVE${cat}User"}->{$p} = 1;
+           }
+       }
+       foreach my $p (@{$cd->{audit}}) {
+           $special_roles->{"PVEAuditor"}->{$p} = 1;
+       }
+    }
+
+    $special_roles->{"PVETemplateUser"} = { 'VM.Clone' => 1, 'VM.Audit' => 1 };
+};
+
+create_roles();
+
+sub create_priv_properties {
+    my $properties = {};
+    foreach my $priv (keys %$valid_privs) {
+       $properties->{$priv} = {
+           type => 'boolean',
+           optional => 1,
+       };
+    }
+    return $properties;
+}
+
+sub role_is_special {
+    my ($role) = @_;
+    return (exists $special_roles->{$role}) ? 1 : 0;
+}
+
+sub add_role_privs {
+    my ($role, $usercfg, $privs) = @_;
+
+    return if !$privs;
+
+    die "role '$role' does not exist\n" if !$usercfg->{roles}->{$role};
+
+    foreach my $priv (split_list($privs)) {
+       if (defined ($valid_privs->{$priv})) {
+           $usercfg->{roles}->{$role}->{$priv} = 1;
+       } else {
+           die "invalid privilege '$priv'\n";
+       }
+    }
+}
+
+sub lookup_username {
+    my ($username, $noerr) = @_;
+
+    $username =~ m!^(${PVE::Auth::Plugin::user_regex})\@(${PVE::Auth::Plugin::realm_regex})$!;
+
+    my $realm = $2;
+    my $domain_cfg = cfs_read_file("domains.cfg");
+    my $casesensitive = $domain_cfg->{ids}->{$realm}->{'case-sensitive'} // 1;
+    my $usercfg = cfs_read_file('user.cfg');
+
+    if (!$casesensitive) {
+       my @matches = grep { lc $username eq lc $_ } (keys %{$usercfg->{users}});
+
+       die "ambiguous case insensitive match of username '$username', cannot safely grant access!\n"
+           if scalar @matches > 1 && !$noerr;
+
+       return $matches[0]
+    }
+
+    return $username;
+}
+
+sub normalize_path {
+    my $path = shift;
+
+    $path =~ s|/+|/|g;
+
+    $path =~ s|/$||;
+
+    $path = '/' if !$path;
+
+    $path = "/$path" if $path !~ m|^/|;
+
+    return undef if $path !~ m|^[[:alnum:]\.\-\_\/]+$|;
+
+    return $path;
+}
+
+sub check_path {
+    my ($path) = @_;
+    return $path =~ m!^(
+       /
+       |/access
+       |/access/groups
+       |/access/realm
+       |/nodes
+       |/nodes/[[:alnum:]\.\-\_]+
+       |/pool
+       |/pool/[[:alnum:]\.\-\_]+
+       |/sdn
+       |/storage
+       |/storage/[[:alnum:]\.\-\_]+
+       |/vms
+       |/vms/[1-9][0-9]{2,}
+    )$!xs;
+}
+
+PVE::JSONSchema::register_format('pve-groupid', \&verify_groupname);
+sub verify_groupname {
+    my ($groupname, $noerr) = @_;
+
+    if ($groupname !~ m/^[A-Za-z0-9\.\-_]+$/) {
+
+       die "group name '$groupname' contains invalid characters\n" if !$noerr;
+
+       return undef;
+    }
+
+    return $groupname;
+}
+
+PVE::JSONSchema::register_format('pve-roleid', \&verify_rolename);
+sub verify_rolename {
+    my ($rolename, $noerr) = @_;
+
+    if ($rolename !~ m/^[A-Za-z0-9\.\-_]+$/) {
+
+       die "role name '$rolename' contains invalid characters\n" if !$noerr;
+
+       return undef;
+    }
+
+    return $rolename;
+}
+
+PVE::JSONSchema::register_format('pve-poolid', \&verify_poolname);
+sub verify_poolname {
+    my ($poolname, $noerr) = @_;
+
+    if ($poolname !~ m/^[A-Za-z0-9\.\-_]+$/) {
+
+       die "pool name '$poolname' contains invalid characters\n" if !$noerr;
+
+       return undef;
+    }
+
+    return $poolname;
+}
+
+PVE::JSONSchema::register_format('pve-priv', \&verify_privname);
+sub verify_privname {
+    my ($priv, $noerr) = @_;
+
+    if (!$valid_privs->{$priv}) {
+       die "invalid privilege '$priv'\n" if !$noerr;
+
+       return undef;
+    }
+
+    return $priv;
+}
+
+sub userconfig_force_defaults {
+    my ($cfg) = @_;
+
+    foreach my $r (keys %$special_roles) {
+       $cfg->{roles}->{$r} = $special_roles->{$r};
+    }
+
+    # add root user if not exists
+    if (!$cfg->{users}->{'root@pam'}) {
+       $cfg->{users}->{'root@pam'}->{enable} = 1;
+    }
+}
+
+sub parse_user_config {
+    my ($filename, $raw) = @_;
+
+    my $cfg = {};
+
+    userconfig_force_defaults($cfg);
+
+    $raw = '' if !defined($raw);
+    while ($raw =~ /^\s*(.+?)\s*$/gm) {
+       my $line = $1;
+       my @data;
+
+       foreach my $d (split (/:/, $line)) {
+           $d =~ s/^\s+//;
+           $d =~ s/\s+$//;
+           push @data, $d
+       }
+
+       my $et = shift @data;
+
+       if ($et eq 'user') {
+           my ($user, $enable, $expire, $firstname, $lastname, $email, $comment, $keys) = @data;
+
+           my (undef, undef, $realm) = PVE::Auth::Plugin::verify_username($user, 1);
+           if (!$realm) {
+               warn "user config - ignore user '$user' - invalid user name\n";
+               next;
+           }
+
+           $enable = $enable ? 1 : 0;
+
+           $expire = 0 if !$expire;
+
+           if ($expire !~ m/^\d+$/) {
+               warn "user config - ignore user '$user' - (illegal characters in expire '$expire')\n";
+               next;
+           }
+           $expire = int($expire);
+
+           #if (!verify_groupname ($group, 1)) {
+           #    warn "user config - ignore user '$user' - invalid characters in group name\n";
+           #    next;
+           #}
+
+           $cfg->{users}->{$user} = {
+               enable => $enable,
+               # group => $group,
+           };
+           $cfg->{users}->{$user}->{firstname} = PVE::Tools::decode_text($firstname) if $firstname;
+           $cfg->{users}->{$user}->{lastname} = PVE::Tools::decode_text($lastname) if $lastname;
+           $cfg->{users}->{$user}->{email} = $email;
+           $cfg->{users}->{$user}->{comment} = PVE::Tools::decode_text($comment) if $comment;
+           $cfg->{users}->{$user}->{expire} = $expire;
+           # keys: allowed yubico key ids or oath secrets (base32 encoded)
+           $cfg->{users}->{$user}->{keys} = $keys if $keys;
+
+           #$cfg->{users}->{$user}->{groups}->{$group} = 1;
+           #$cfg->{groups}->{$group}->{$user} = 1;
+
+       } elsif ($et eq 'group') {
+           my ($group, $userlist, $comment) = @data;
+
+           if (!verify_groupname($group, 1)) {
+               warn "user config - ignore group '$group' - invalid characters in group name\n";
+               next;
+           }
+
+           # make sure to add the group (even if there are no members)
+           $cfg->{groups}->{$group} = { users => {} } if !$cfg->{groups}->{$group};
+
+           $cfg->{groups}->{$group}->{comment} = PVE::Tools::decode_text($comment) if $comment;
+
+           foreach my $user (split_list($userlist)) {
+
+               if (!PVE::Auth::Plugin::verify_username($user, 1)) {
+                   warn "user config - ignore invalid group member '$user'\n";
+                   next;
+               }
+
+               if ($cfg->{users}->{$user}) { # user exists
+                   $cfg->{users}->{$user}->{groups}->{$group} = 1;
+               } else {
+                   warn "user config - ignore invalid group member '$user'\n";
+               }
+               $cfg->{groups}->{$group}->{users}->{$user} = 1;
+           }
+
+       } elsif ($et eq 'role') {
+           my ($role, $privlist) = @data;
+
+           if (!verify_rolename($role, 1)) {
+               warn "user config - ignore role '$role' - invalid characters in role name\n";
+               next;
+           }
+
+           # make sure to add the role (even if there are no privileges)
+           $cfg->{roles}->{$role} = {} if !$cfg->{roles}->{$role};
+
+           foreach my $priv (split_list($privlist)) {
+               if (defined ($valid_privs->{$priv})) {
+                   $cfg->{roles}->{$role}->{$priv} = 1;
+               } else {
+                   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)) {
+
+                   if (!verify_rolename($role, 1)) {
+                       warn "user config - ignore invalid role name '$role' in acl\n";
+                       next;
+                   }
+
+                   if (!$cfg->{roles}->{$role}) {
+                       warn "user config - ignore invalid acl role '$role'\n";
+                       next;
+                   }
+
+                   foreach my $ug (split_list($uglist)) {
+                       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 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";
+                       }
+                   }
+               }
+           } else {
+               warn "user config - ignore invalid path in acl '$pathtxt'\n";
+           }
+       } elsif ($et eq 'pool') {
+           my ($pool, $comment, $vmlist, $storelist) = @data;
+
+           if (!verify_poolname($pool, 1)) {
+               warn "user config - ignore pool '$pool' - invalid characters in pool name\n";
+               next;
+           }
+
+           # make sure to add the pool (even if there are no members)
+           $cfg->{pools}->{$pool} = { vms => {}, storage => {} } if !$cfg->{pools}->{$pool};
+
+           $cfg->{pools}->{$pool}->{comment} = PVE::Tools::decode_text($comment) if $comment;
+
+           foreach my $vmid (split_list($vmlist)) {
+               if ($vmid !~ m/^\d+$/) {
+                   warn "user config - ignore invalid vmid '$vmid' in pool '$pool'\n";
+                   next;
+               }
+               $vmid = int($vmid);
+
+               if ($cfg->{vms}->{$vmid}) {
+                   warn "user config - ignore duplicate vmid '$vmid' in pool '$pool'\n";
+                   next;
+               }
+
+               $cfg->{pools}->{$pool}->{vms}->{$vmid} = 1;
+
+               # record vmid ==> pool relation
+               $cfg->{vms}->{$vmid} = $pool;
+           }
+
+           foreach my $storeid (split_list($storelist)) {
+               if ($storeid !~ m/^[a-z][a-z0-9\-\_\.]*[a-z0-9]$/i) {
+                   warn "user config - ignore invalid storage '$storeid' in pool '$pool'\n";
+                   next;
+               }
+               $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";
+       }
+    }
+
+    userconfig_force_defaults($cfg);
+
+    return $cfg;
+}
+
+sub write_user_config {
+    my ($filename, $cfg) = @_;
+
+    my $data = '';
+
+    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}) : '';
+       my $email = $d->{email} || '';
+       my $comment = $d->{comment} ? PVE::Tools::encode_text($d->{comment}) : '';
+       my $expire = int($d->{expire} || 0);
+       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 (sort keys %{$cfg->{groups}}) {
+       my $d = $cfg->{groups}->{$group};
+       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 (sort keys %{$cfg->{pools}}) {
+       my $d = $cfg->{pools}->{$pool};
+       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 (sort keys %{$cfg->{roles}}) {
+       next if $special_roles->{$role};
+
+       my $d = $cfg->{roles}->{$role};
+       my $list = join (',', sort keys %$d);
+       $data .= "role:$role:$list:\n";
+    }
+
+    $data .= "\n";
+
+    my $collect_rolelist_members = sub {
+       my ($acl_members, $result, $prefix, $exclude) = @_;
+
+       foreach my $member (keys %$acl_members) {
+           next if $exclude && $member eq $exclude;
+
+           my $l0 = '';
+           my $l1 = '';
+           foreach my $role (sort keys %{$acl_members->{$member}}) {
+               my $propagate = $acl_members->{$member}->{$role};
+               if ($propagate) {
+                   $l1 .= ',' if $l1;
+                   $l1 .= $role;
+               } else {
+                   $l0 .= ',' if $l0;
+                   $l0 .= $role;
+               }
+           }
+           $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};
+
+       my $rolelist_members = {};
+
+       $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";
+           }
+
+       }
+    }
+
+    return $data;
+}
+
+# The TFA configuration in priv/tfa.cfg format contains one line per user of
+# the form:
+#     USER:TYPE:DATA
+# DATA is a base64 encoded json string and its format depends on the type.
+sub parse_priv_tfa_config {
+    my ($filename, $raw) = @_;
+
+    my $users = {};
+    my $cfg = { users => $users };
+
+    $raw = '' if !defined($raw);
+    while ($raw =~ /^\s*(.+?)\s*$/gm) {
+       my $line = $1;
+       my ($user, $type, $data) = split(/:/, $line, 3);
+
+       my (undef, undef, $realm) = PVE::Auth::Plugin::verify_username($user, 1);
+       if (!$realm) {
+           warn "user tfa config - ignore user '$user' - invalid user name\n";
+           next;
+       }
+
+       $data = decode_json(decode_base64($data));
+
+       $users->{$user} = {
+           type => $type,
+           data => $data,
+       };
+    }
+
+    return $cfg;
+}
+
+sub write_priv_tfa_config {
+    my ($filename, $cfg) = @_;
+
+    my $output = '';
+
+    my $users = $cfg->{users};
+    foreach my $user (sort keys %$users) {
+       my $info = $users->{$user};
+       next if !%$info; # skip empty entries
+
+       $info = {%$info}; # copy to verify contents:
+
+       my $type = delete $info->{type};
+       my $data = delete $info->{data};
+
+       if (my @keys = keys %$info) {
+           die "invalid keys in TFA config for user $user: " . join(', ', @keys) . "\n";
+       }
+
+       $data = encode_base64(encode_json($data), '');
+       $output .= "${user}:${type}:${data}\n";
+    }
+
+    return $output;
+}
+
+sub roles {
+    my ($cfg, $user, $path) = @_;
+
+    # NOTE: we do not consider pools here.
+    # 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
+
+    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);
+
+       next if !(($p eq '/') || $final || ($path =~ m|^$p/|));
+
+       my $acl = $cfg->{acl}->{$p};
+
+       #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;
+           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; # user privs always override group privs
+           }
+       }
+
+       my $new;
+       foreach my $g (keys %{$acl->{groups}}) {
+           next if !$cfg->{groups}->{$g}->{users}->{$user};
+           if (my $ri = $acl->{groups}->{$g}) {
+               foreach my $role (keys %$ri) {
+                   my $propagate = $ri->{$role};
+                   if ($final || $propagate) {
+                       #print "APPLY ROLE $p \@$g $role\n";
+                       $new = {} if !$new;
+                       $new->{$role} = $propagate;
+                   }
+               }
+           }
+       }
+       if ($new) {
+           $roles = $new; # overwrite previous settings
+           next;
+       }
+    }
+
+    return { 'NoAccess' => $roles->{NoAccess} } if defined ($roles->{NoAccess});
+    #return () if defined ($roles->{NoAccess});
+
+    #print "permission $user $path = " . Dumper ($roles);
+
+    #print "roles $user $path = " . join (',', @ra) . "\n";
+
+    return $roles;
+}
+
+sub remove_vm_access {
+    my ($vmid) = @_;
+    my $delVMaccessFn = sub {
+        my $usercfg = cfs_read_file("user.cfg");
+       my $modified;
+
+        if (my $acl = $usercfg->{acl}->{"/vms/$vmid"}) {
+            delete $usercfg->{acl}->{"/vms/$vmid"};
+           $modified = 1;
+        }
+        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;
+    };
+
+    lock_user_config($delVMaccessFn, "access permissions cleanup for VM $vmid failed");
+}
+
+sub remove_storage_access {
+    my ($storeid) = @_;
+
+    my $deleteStorageAccessFn = sub {
+        my $usercfg = cfs_read_file("user.cfg");
+       my $modified;
+
+        if (my $storage = $usercfg->{acl}->{"/storage/$storeid"}) {
+            delete $usercfg->{acl}->{"/storage/$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;
+    };
+
+    lock_user_config($deleteStorageAccessFn,
+                    "access permissions cleanup for storage $storeid failed");
+}
+
+sub add_vm_to_pool {
+    my ($vmid, $pool) = @_;
+
+    my $addVMtoPoolFn = sub {
+       my $usercfg = cfs_read_file("user.cfg");
+       if (my $data = $usercfg->{pools}->{$pool}) {
+           $data->{vms}->{$vmid} = 1;
+           $usercfg->{vms}->{$vmid} = $pool;
+           cfs_write_file("user.cfg", $usercfg);
+       }
+    };
+
+    lock_user_config($addVMtoPoolFn, "can't add VM $vmid to pool '$pool'");
+}
+
+sub remove_vm_from_pool {
+    my ($vmid) = @_;
+
+    my $delVMfromPoolFn = sub {
+       my $usercfg = cfs_read_file("user.cfg");
+       if (my $pool = $usercfg->{vms}->{$vmid}) {
+           if (my $data = $usercfg->{pools}->{$pool}) {
+               delete $data->{vms}->{$vmid};
+               delete $usercfg->{vms}->{$vmid};
+               cfs_write_file("user.cfg", $usercfg);
+           }
+       }
+    };
+
+    lock_user_config($delVMfromPoolFn, "pool cleanup for VM $vmid failed");
+}
+
+my $USER_CONTROLLED_TFA_TYPES = {
+    u2f => 1,
+    oath => 1,
+};
+
+# Delete an entry by setting $data=undef in which case $type is ignored.
+# Otherwise both must be valid.
+sub user_set_tfa {
+    my ($userid, $realm, $type, $data, $cached_usercfg, $cached_domaincfg) = @_;
+
+    if (defined($data) && !defined($type)) {
+       # This is an internal usage error and should not happen
+       die "cannot set tfa data without a type\n";
+    }
+
+    my $user_cfg = $cached_usercfg || cfs_read_file('user.cfg');
+    my $user = $user_cfg->{users}->{$userid}
+       or die "user '$userid' not found\n";
+
+    my $domain_cfg = $cached_domaincfg || cfs_read_file('domains.cfg');
+    my $realm_cfg = $domain_cfg->{ids}->{$realm};
+    die "auth domain '$realm' does not exist\n" if !$realm_cfg;
+
+    my $realm_tfa = $realm_cfg->{tfa};
+    if (defined($realm_tfa)) {
+       $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_tfa);
+       # If the realm has a TFA setting, we're only allowed to use that.
+       if (defined($data)) {
+           my $required_type = $realm_tfa->{type};
+           if ($required_type ne $type) {
+               die "realm '$realm' only allows TFA of type '$required_type\n";
+           }
+
+           if (defined($data->{config})) {
+               # XXX: Is it enough if the type matches? Or should the configuration also match?
+           }
+
+           # realm-configured tfa always uses a simple key list, so use the user.cfg
+           $user->{keys} = $data->{keys};
+       } else {
+           die "realm '$realm' does not allow removing the 2nd factor\n";
+       }
+    } else {
+       # Without a realm-enforced TFA setting the user can add a u2f or totp entry by themselves.
+       # The 'yubico' type requires yubico server settings, which have to be configured on the
+       # realm, so this is not supported here:
+       die "domain '$realm' does not support TFA type '$type'\n"
+           if defined($data) && !$USER_CONTROLLED_TFA_TYPES->{$type};
+    }
+
+    # Custom TFA entries are stored in priv/tfa.cfg as they can be more complet: u2f uses a
+    # public key and a key handle, TOTP requires the usual totp settings...
+
+    my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+    my $tfa = ($tfa_cfg->{users}->{$userid} //= {});
+
+    if (defined($data)) {
+       $tfa->{type} = $type;
+       $tfa->{data} = $data;
+       cfs_write_file('priv/tfa.cfg', $tfa_cfg);
+
+       $user->{keys} = "x!$type";
+    } else {
+       delete $tfa_cfg->{users}->{$userid};
+       cfs_write_file('priv/tfa.cfg', $tfa_cfg);
+
+       delete $user->{keys};
+    }
+
+    cfs_write_file('user.cfg', $user_cfg);
+}
+
+sub user_get_tfa {
+    my ($username, $realm) = @_;
+
+    my $user_cfg = cfs_read_file('user.cfg');
+    my $user = $user_cfg->{users}->{$username}
+       or die "user '$username' not found\n";
+
+    my $keys = $user->{keys};
+
+    my $domain_cfg = cfs_read_file('domains.cfg');
+    my $realm_cfg = $domain_cfg->{ids}->{$realm};
+    die "auth domain '$realm' does not exist\n" if !$realm_cfg;
+
+    my $realm_tfa = $realm_cfg->{tfa};
+    $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";
+    }
+
+    # new style config starts with an 'x' and optionally contains a !<type> 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});
+    }
+}
+
+# bash completion helpers
+
+register_standard_option('userid-completed',
+    get_standard_option('userid', { completion => \&complete_username}),
+);
+
+sub complete_username {
+
+    my $user_cfg = cfs_read_file('user.cfg');
+
+    return [ keys %{$user_cfg->{users}} ];
+}
+
+sub complete_group {
+
+    my $user_cfg = cfs_read_file('user.cfg');
+
+    return [ keys %{$user_cfg->{groups}} ];
+}
+
+sub complete_realm {
+
+    my $domain_cfg = cfs_read_file('domains.cfg');
+
+    return [ keys %{$domain_cfg->{ids}} ];
+}
+
+1;
diff --git a/src/PVE/Auth/AD.pm b/src/PVE/Auth/AD.pm
new file mode 100755 (executable)
index 0000000..88b2098
--- /dev/null
@@ -0,0 +1,148 @@
+package PVE::Auth::AD;
+
+use strict;
+use warnings;
+use PVE::Auth::LDAP;
+use PVE::LDAP;
+
+use base qw(PVE::Auth::LDAP);
+
+sub type {
+    return 'ad';
+}
+
+sub properties {
+    return {
+       server1 => {
+           description => "Server IP address (or DNS name)",
+           type => 'string',
+           format => 'address',
+           maxLength => 256,
+       },
+       server2 => {
+           description => "Fallback Server IP address (or DNS name)",
+           type => 'string',
+           optional => 1,
+           format => 'address',
+           maxLength => 256,
+       },
+       secure => {
+           description => "Use secure LDAPS protocol. DEPRECATED: use 'mode' instead.",
+           type => 'boolean',
+           optional => 1,
+       },
+       sslversion => {
+           description => "LDAPS TLS/SSL version. It's not recommended to use version older than 1.2!",
+           type => 'string',
+           enum => [qw(tlsv1 tlsv1_1 tlsv1_2 tlsv1_3)],
+           optional => 1,
+       },
+       default => {
+           description => "Use this as default realm",
+           type => 'boolean',
+           optional => 1,
+       },
+       comment => {
+           description => "Description.",
+           type => 'string',
+           optional => 1,
+           maxLength => 4096,
+       },
+       port => {
+           description => "Server port.",
+           type => 'integer',
+           minimum => 1,
+           maximum => 65535,
+           optional => 1,
+       },
+       domain => {
+           description => "AD domain name",
+           type => 'string',
+           pattern => '\S+',
+           optional => 1,
+           maxLength => 256,
+       },
+       tfa => PVE::JSONSchema::get_standard_option('tfa'),
+    };
+}
+
+sub options {
+    return {
+       server1 => {},
+       server2 => { optional => 1 },
+       domain => {},
+       port => { optional => 1 },
+       secure => { optional => 1 },
+       sslversion => { optional => 1 },
+       default => { optional => 1 },,
+       comment => { optional => 1 },
+       tfa => { optional => 1 },
+       verify => { optional => 1 },
+       capath => { optional => 1 },
+       cert => { optional => 1 },
+       certkey => { optional => 1 },
+       base_dn => { optional => 1 },
+       bind_dn => { optional => 1 },
+       password => { optional => 1 },
+       user_attr => { optional => 1 },
+       filter => { optional => 1 },
+       sync_attributes => { optional => 1 },
+       user_classes => { optional => 1 },
+       group_dn => { optional => 1 },
+       group_name_attr => { optional => 1 },
+       group_filter => { optional => 1 },
+       group_classes => { optional => 1 },
+       'sync-defaults-options' => { optional => 1 },
+       mode => { optional => 1 },
+       'case-sensitive' => { optional => 1 },
+    };
+}
+
+sub get_users {
+    my ($class, $config, $realm) = @_;
+
+    $config->{user_attr} //= 'sAMAccountName';
+
+    return $class->SUPER::get_users($config, $realm);
+}
+
+sub authenticate_user {
+    my ($class, $config, $realm, $username, $password) = @_;
+
+    my $servers = [$config->{server1}];
+    push @$servers, $config->{server2} if $config->{server2};
+
+    my ($scheme, $port) = $class->get_scheme_and_port($config);
+
+    my %ad_args;
+    if ($config->{verify}) {
+       $ad_args{verify} = 'require';
+       $ad_args{clientcert} = $config->{cert} if $config->{cert};
+       $ad_args{clientkey} = $config->{certkey} if $config->{certkey};
+       if (defined(my $capath = $config->{capath})) {
+           if (-d $capath) {
+               $ad_args{capath} = $capath;
+           } else {
+               $ad_args{cafile} = $capath;
+           }
+       }
+    } elsif (defined($config->{verify})) {
+       $ad_args{verify} = 'none';
+    }
+
+    if ($scheme ne 'ldap') {
+       $ad_args{sslversion} = $config->{sslversion} // 'tlsv1_2';
+    }
+
+    my $ldap = PVE::LDAP::ldap_connect($servers, $scheme, $port, \%ad_args);
+
+    $username = "$username\@$config->{domain}"
+       if $username !~ m/@/ && $config->{domain};
+
+    PVE::LDAP::auth_user_dn($ldap, $username, $password);
+
+    $ldap->unbind();
+    return 1;
+}
+
+1;
diff --git a/src/PVE/Auth/LDAP.pm b/src/PVE/Auth/LDAP.pm
new file mode 100755 (executable)
index 0000000..97d0778
--- /dev/null
@@ -0,0 +1,454 @@
+package PVE::Auth::LDAP;
+
+use strict;
+use warnings;
+
+use PVE::Auth::Plugin;
+use PVE::JSONSchema;
+use PVE::LDAP;
+use PVE::Tools;
+
+use base qw(PVE::Auth::Plugin);
+
+sub type {
+    return 'ldap';
+}
+
+sub properties {
+    return {
+       base_dn => {
+           description => "LDAP base domain name",
+           type => 'string',
+           pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*',
+           optional => 1,
+           maxLength => 256,
+       },
+       user_attr => {
+           description => "LDAP user attribute name",
+           type => 'string',
+           pattern => '\S{2,}',
+           optional => 1,
+           maxLength => 256,
+       },
+       bind_dn => {
+           description => "LDAP bind domain name",
+           type => 'string',
+           pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*',
+           optional => 1,
+           maxLength => 256,
+       },
+       password => {
+           description => "LDAP bind password. Will be stored in '/etc/pve/priv/realm/<REALM>.pw'.",
+           type => 'string',
+           optional => 1,
+       },
+       verify => {
+           description => "Verify the server's SSL certificate",
+           type => 'boolean',
+           optional => 1,
+           default => 0,
+       },
+       capath => {
+           description => "Path to the CA certificate store",
+           type => 'string',
+           optional => 1,
+           default => '/etc/ssl/certs',
+       },
+       cert => {
+           description => "Path to the client certificate",
+           type => 'string',
+           optional => 1,
+       },
+       certkey => {
+           description => "Path to the client certificate key",
+           type => 'string',
+           optional => 1,
+       },
+       filter => {
+           description => "LDAP filter for user sync.",
+           type => 'string',
+           optional => 1,
+           maxLength => 2048,
+       },
+       sync_attributes => {
+           description => "Comma separated list of key=value pairs for specifying"
+              ." which LDAP attributes map to which PVE user field. For example,"
+              ." to map the LDAP attribute 'mail' to PVEs 'email', write "
+              ." 'email=mail'. By default, each PVE user field is represented "
+              ." by an LDAP attribute of the same name.",
+           optional => 1,
+           type => 'string',
+           pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*',
+       },
+       user_classes => {
+           description => "The objectclasses for users.",
+           type => 'string',
+           default => 'inetorgperson, posixaccount, person, user',
+           format => 'ldap-simple-attr-list',
+           optional => 1,
+       },
+       group_dn => {
+           description => "LDAP base domain name for group sync. If not set, the"
+               ." base_dn will be used.",
+           type => 'string',
+           pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*',
+           optional => 1,
+           maxLength => 256,
+       },
+       group_name_attr => {
+           description => "LDAP attribute representing a groups name. If not set"
+               ." or found, the first value of the DN will be used as name.",
+           type => 'string',
+           format => 'ldap-simple-attr',
+           optional => 1,
+           maxLength => 256,
+       },
+       group_filter => {
+           description => "LDAP filter for group sync.",
+           type => 'string',
+           optional => 1,
+           maxLength => 2048,
+       },
+       group_classes => {
+           description => "The objectclasses for groups.",
+           type => 'string',
+           default => 'groupOfNames, group, univentionGroup, ipausergroup',
+           format => 'ldap-simple-attr-list',
+           optional => 1,
+       },
+       'sync-defaults-options' => {
+           description => "The default options for behavior of synchronizations.",
+           type => 'string',
+           format => 'realm-sync-options',
+           optional => 1,
+       },
+       mode => {
+           description => "LDAP protocol mode.",
+           type => 'string',
+           enum => [ 'ldap', 'ldaps', 'ldap+starttls'],
+           optional => 1,
+           default => 'ldap',
+       },
+        'case-sensitive' => {
+           description => "username is case-sensitive",
+           type => 'boolean',
+           optional => 1,
+           default => 1,
+       }
+    };
+}
+
+sub options {
+    return {
+       server1 => {},
+       server2 => { optional => 1 },
+       base_dn => {},
+       bind_dn => { optional => 1 },
+       password => { optional => 1 },
+       user_attr => {},
+       port => { optional => 1 },
+       secure => { optional => 1 },
+       sslversion => { optional => 1 },
+       default => { optional => 1 },
+       comment => { optional => 1 },
+       tfa => { optional => 1 },
+       verify => { optional => 1 },
+       capath => { optional => 1 },
+       cert => { optional => 1 },
+       certkey => { optional => 1 },
+       filter => { optional => 1 },
+       sync_attributes => { optional => 1 },
+       user_classes => { optional => 1 },
+       group_dn => { optional => 1 },
+       group_name_attr => { optional => 1 },
+       group_filter => { optional => 1 },
+       group_classes => { optional => 1 },
+       'sync-defaults-options' => { optional => 1 },
+       mode => { optional => 1 },
+       'case-sensitive' => { optional => 1 },
+    };
+}
+
+sub get_scheme_and_port {
+    my ($class, $config) = @_;
+
+    my $scheme = $config->{mode} // ($config->{secure} ? 'ldaps' : 'ldap');
+
+    my $default_port = $scheme eq 'ldaps' ? 636 : 389;
+    my $port = $config->{port} // $default_port;
+
+    return ($scheme, $port);
+}
+
+sub connect_and_bind {
+    my ($class, $config, $realm) = @_;
+
+    my $servers = [$config->{server1}];
+    push @$servers, $config->{server2} if $config->{server2};
+
+    my ($scheme, $port) = $class->get_scheme_and_port($config);
+
+    my %ldap_args;
+    if ($config->{verify}) {
+       $ldap_args{verify} = 'require';
+       $ldap_args{clientcert} = $config->{cert} if $config->{cert};
+       $ldap_args{clientkey} = $config->{certkey} if $config->{certkey};
+       if (defined(my $capath = $config->{capath})) {
+           if (-d $capath) {
+               $ldap_args{capath} = $capath;
+           } else {
+               $ldap_args{cafile} = $capath;
+           }
+       }
+    } else {
+       $ldap_args{verify} = 'none';
+    }
+
+    if ($scheme ne 'ldap') {
+       $ldap_args{sslversion} = $config->{sslversion} || 'tlsv1_2';
+    }
+
+    my $ldap = PVE::LDAP::ldap_connect($servers, $scheme, $port, \%ldap_args);
+
+    if ($config->{bind_dn}) {
+       my $bind_dn = $config->{bind_dn};
+       my $bind_pass = ldap_get_credentials($realm);
+       die "missing password for realm $realm\n" if !defined($bind_pass);
+       PVE::LDAP::ldap_bind($ldap, $bind_dn, $bind_pass);
+    } elsif ($config->{cert} && $config->{certkey}) {
+       warn "skipping anonymous bind with clientcert\n";
+    } else {
+       PVE::LDAP::ldap_bind($ldap);
+    }
+
+    if (!$config->{base_dn}) {
+       my $root = $ldap->root_dse(attrs => [ 'defaultNamingContext' ]);
+       $config->{base_dn} = $root->get_value('defaultNamingContext');
+    }
+
+    return $ldap;
+}
+
+# returns:
+# {
+#     'username@realm' => {
+#      'attr1' => 'value1',
+#      'attr2' => 'value2',
+#      ...
+#     },
+#     ...
+# }
+#
+# or in list context:
+# (
+#     {
+#      'username@realm' => {
+#          'attr1' => 'value1',
+#          'attr2' => 'value2',
+#          ...
+#      },
+#      ...
+#     },
+#     {
+#      'uid=username,dc=....' => 'username@realm',
+#      ...
+#     }
+# )
+# the map of dn->username is needed for group membership sync
+sub get_users {
+    my ($class, $config, $realm) = @_;
+
+    my $ldap = $class->connect_and_bind($config, $realm);
+
+    my $user_name_attr = $config->{user_attr} // 'uid';
+    my $ldap_attribute_map = {
+       $user_name_attr => 'username',
+       enable => 'enable',
+       expire => 'expire',
+       firstname => 'firstname',
+       lastname => 'lastname',
+       email => 'email',
+       comment => 'comment',
+       keys => 'keys',
+    };
+
+    foreach my $attr (PVE::Tools::split_list($config->{sync_attributes})) {
+       my ($ours, $ldap) = ($attr =~ m/^\s*(\w+)=(.*)\s*$/);
+       $ldap_attribute_map->{$ldap} = $ours;
+    }
+
+    my $filter = $config->{filter};
+    my $basedn = $config->{base_dn};
+
+    $config->{user_classes} //= 'inetorgperson, posixaccount, person, user';
+    my $classes = [PVE::Tools::split_list($config->{user_classes})];
+
+    my $users = PVE::LDAP::query_users($ldap, $filter, [keys %$ldap_attribute_map], $basedn, $classes);
+
+    my $ret = {};
+    my $dnmap = {};
+
+    foreach my $user (@$users) {
+       my $user_attributes = $user->{attributes};
+       my $userid = $user_attributes->{$user_name_attr}->[0];
+       my $username = "$userid\@$realm";
+
+       # we cannot sync usernames that do not meet our criteria
+       eval { PVE::Auth::Plugin::verify_username($username) };
+       if (my $err = $@) {
+           warn "$err";
+           next;
+       }
+
+       $ret->{$username} = {};
+
+       foreach my $attr (keys %$user_attributes) {
+           if (my $ours = $ldap_attribute_map->{$attr}) {
+               $ret->{$username}->{$ours} = $user_attributes->{$attr}->[0];
+           }
+       }
+
+       if (wantarray) {
+           my $dn = $user->{dn};
+           $dnmap->{$dn} = $username;
+       }
+    }
+
+    return wantarray ? ($ret, $dnmap) : $ret;
+}
+
+# needs a map for dn -> username, we get this from the get_users call
+# otherwise we cannot determine the group membership
+sub get_groups {
+    my ($class, $config, $realm, $dnmap) = @_;
+
+    my $filter = $config->{group_filter};
+    my $basedn = $config->{group_dn} // $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);
+
+    my $groups = PVE::LDAP::query_groups($ldap, $basedn, $classes, $filter, $attr);
+
+    my $ret = {};
+
+    foreach my $group (@$groups) {
+       my $name = $group->{name};
+       if (!$name && $group->{dn} =~ m/^[^=]+=([^,]+),/){
+           $name = PVE::Tools::trim($1);
+       }
+       if ($name) {
+           $name .= "-$realm";
+
+           # we cannot sync groups that do not meet our criteria
+           eval { PVE::AccessControl::verify_groupname($name) };
+           if (my $err = $@) {
+               warn "$err";
+               next;
+           }
+
+           $ret->{$name} = { users => {} };
+           foreach my $member (@{$group->{members}}) {
+               if (my $user = $dnmap->{$member}) {
+                   $ret->{$name}->{users}->{$user} = 1;
+               }
+           }
+       }
+    }
+
+    return $ret;
+}
+
+sub authenticate_user {
+    my ($class, $config, $realm, $username, $password) = @_;
+
+    my $ldap = $class->connect_and_bind($config, $realm);
+
+    my $user_dn = PVE::LDAP::get_user_dn($ldap, $username, $config->{user_attr}, $config->{base_dn});
+    PVE::LDAP::auth_user_dn($ldap, $user_dn, $password);
+
+    $ldap->unbind();
+    return 1;
+}
+
+my $ldap_pw_dir = "/etc/pve/priv/realm";
+
+sub ldap_cred_file_name {
+    my ($realmid) = @_;
+    return "${ldap_pw_dir}/${realmid}.pw";
+}
+
+sub get_cred_file {
+    my ($realmid) = @_;
+
+    my $cred_file = ldap_cred_file_name($realmid);
+    if (-e $cred_file) {
+       return $cred_file;
+    } elsif (-e "/etc/pve/priv/ldap/${realmid}.pw") {
+       # FIXME: remove fallback with 7.0 by doing a rename on upgrade from 6.x
+       return "/etc/pve/priv/ldap/${realmid}.pw";
+    }
+
+    return $cred_file;
+}
+
+sub ldap_set_credentials {
+    my ($password, $realmid) = @_;
+
+    my $cred_file = ldap_cred_file_name($realmid);
+    mkdir $ldap_pw_dir;
+
+    PVE::Tools::file_set_contents($cred_file, $password);
+
+    return $cred_file;
+}
+
+sub ldap_get_credentials {
+    my ($realmid) = @_;
+
+    if (my $cred_file = get_cred_file($realmid)) {
+       return PVE::Tools::file_read_firstline($cred_file);
+    }
+    return undef;
+}
+
+sub ldap_delete_credentials {
+    my ($realmid) = @_;
+
+    if (my $cred_file = get_cred_file($realmid)) {
+       return if ! -e $cred_file; # nothing to do
+       unlink($cred_file) or warn "removing LDAP credentials '$cred_file' failed: $!\n";
+    }
+}
+
+sub on_add_hook {
+    my ($class, $realm, $config, %param) = @_;
+
+    if (defined($param{password})) {
+       ldap_set_credentials($param{password}, $realm);
+    } else {
+       ldap_delete_credentials($realm);
+    }
+}
+
+sub on_update_hook {
+    my ($class, $realm, $config, %param) = @_;
+
+    return if !exists($param{password});
+
+    if (defined($param{password})) {
+       ldap_set_credentials($param{password}, $realm);
+    } else {
+       ldap_delete_credentials($realm);
+    }
+}
+
+sub on_delete_hook {
+    my ($class, $realm, $config) = @_;
+
+    ldap_delete_credentials($realm);
+}
+
+1;
diff --git a/src/PVE/Auth/Makefile b/src/PVE/Auth/Makefile
new file mode 100644 (file)
index 0000000..58ae362
--- /dev/null
@@ -0,0 +1,11 @@
+
+AUTH_SOURCES=                  \
+       Plugin.pm               \
+       PVE.pm                  \
+       PAM.pm                  \
+       AD.pm                   \
+       LDAP.pm
+
+.PHONY: install
+install:
+       for i in ${AUTH_SOURCES}; do install -D -m 0644 $$i ${DESTDIR}${PERLDIR}/PVE/Auth/$$i; done
diff --git a/src/PVE/Auth/PAM.pm b/src/PVE/Auth/PAM.pm
new file mode 100755 (executable)
index 0000000..d016f83
--- /dev/null
@@ -0,0 +1,76 @@
+package PVE::Auth::PAM;
+
+use strict;
+use warnings;
+
+use PVE::Tools qw(run_command);
+use PVE::Auth::Plugin;
+use Authen::PAM qw(:constants);
+
+use base qw(PVE::Auth::Plugin);
+
+sub type {
+    return 'pam';
+}
+
+sub options {
+    return {
+       default => { optional => 1 },
+       comment => { optional => 1 },
+       tfa => { optional => 1 },
+    };
+}
+
+sub authenticate_user {
+    my ($class, $config, $realm, $username, $password) = @_;
+
+    # user (www-data) need to be able to read /etc/passwd /etc/shadow
+    die "no password\n" if !$password;
+
+    my $pamh = new Authen::PAM('proxmox-ve-auth', $username, sub {
+       my @res;
+       while(@_) {
+           my $msg_type = shift;
+           my $msg = shift;
+           push @res, (0, $password);
+       }
+       push @res, 0;
+       return @res;
+    });
+
+    if (!ref ($pamh)) {
+       my $err = $pamh->pam_strerror($pamh);
+       die "error during PAM init: $err";
+    }
+
+    my $res;
+
+    if (($res = $pamh->pam_authenticate(0)) != PAM_SUCCESS) {
+       my $err = $pamh->pam_strerror($res);
+       die "$err\n";
+    }
+
+    if (($res = $pamh->pam_acct_mgmt (0)) != PAM_SUCCESS) {
+       my $err = $pamh->pam_strerror($res);
+       die "$err\n";
+    }
+
+    $pamh = 0; # call destructor
+
+    return 1;
+}
+
+
+sub store_password {
+    my ($class, $config, $realm, $username, $password) = @_;
+
+    my $cmd = ['usermod'];
+
+    my $epw = PVE::Tools::encrypt_pw($password);
+
+    push @$cmd, '-p', $epw, $username;
+
+    run_command($cmd, errmsg => 'change password failed');
+}
+
+1;
diff --git a/src/PVE/Auth/PVE.pm b/src/PVE/Auth/PVE.pm
new file mode 100755 (executable)
index 0000000..de39d35
--- /dev/null
@@ -0,0 +1,116 @@
+package PVE::Auth::PVE;
+
+use strict;
+use warnings;
+use Encode;
+
+use PVE::Tools;
+use PVE::Auth::Plugin;
+use PVE::Cluster qw(cfs_register_file cfs_read_file cfs_write_file cfs_lock_file);
+
+use base qw(PVE::Auth::Plugin);
+
+my $shadowconfigfile = "priv/shadow.cfg";
+
+cfs_register_file($shadowconfigfile, 
+                 \&parse_shadow_passwd, 
+                 \&write_shadow_config);
+
+sub parse_shadow_passwd {
+    my ($filename, $raw) = @_;
+
+    my $shadow = {};
+
+    return $shadow if !defined($raw);
+
+    while ($raw =~ /^\s*(.+?)\s*$/gm) {
+       my $line = $1;
+
+       if ($line !~ m/^\S+:\S+:$/) {
+           warn "pve shadow password: ignore invalid line $.\n";
+           next;
+       }
+
+       my ($userid, $crypt_pass) = split (/:/, $line);
+       $shadow->{users}->{$userid}->{shadow} = $crypt_pass;
+    }
+
+    return $shadow;
+}
+
+sub write_shadow_config {
+    my ($filename, $cfg) = @_;
+
+    my $data = '';
+    foreach my $userid (keys %{$cfg->{users}}) {
+       my $crypt_pass = $cfg->{users}->{$userid}->{shadow};
+       $data .= "$userid:$crypt_pass:\n";
+    }
+
+    return $data
+}
+
+sub lock_shadow_config {
+    my ($code, $errmsg) = @_;
+
+    cfs_lock_file($shadowconfigfile, undef, $code);
+    my $err = $@;
+    if ($err) {
+       $errmsg ? die "$errmsg: $err" : die $err;
+    }
+}
+
+sub type {
+    return 'pve';
+}
+
+sub options {
+    return {
+       default => { optional => 1 },
+       comment => { optional => 1 },
+       tfa => { optional => 1 },
+    };
+}
+
+sub authenticate_user {
+    my ($class, $config, $realm, $username, $password) = @_;
+
+    die "no password\n" if !$password;
+
+    my $shadow_cfg = cfs_read_file($shadowconfigfile);
+    
+    if ($shadow_cfg->{users}->{$username}) {
+       my $encpw = crypt(Encode::encode('utf8', $password),
+                         $shadow_cfg->{users}->{$username}->{shadow});
+       die "invalid credentials\n" if ($encpw ne $shadow_cfg->{users}->{$username}->{shadow});
+    } else {
+       die "no password set\n";
+    }
+
+    return 1;
+}
+
+sub store_password {
+    my ($class, $config, $realm, $username, $password) = @_;
+
+    lock_shadow_config(sub {
+       my $shadow_cfg = cfs_read_file($shadowconfigfile);
+       my $epw = PVE::Tools::encrypt_pw($password);
+       $shadow_cfg->{users}->{$username}->{shadow} = $epw;
+       cfs_write_file($shadowconfigfile, $shadow_cfg);
+    });
+}
+
+sub delete_user {
+    my ($class, $config, $realm, $username) = @_;
+    lock_shadow_config(sub {
+       my $shadow_cfg = cfs_read_file($shadowconfigfile);
+
+       delete $shadow_cfg->{users}->{$username};
+
+       cfs_write_file($shadowconfigfile, $shadow_cfg);
+   });
+}
+
+1;
diff --git a/src/PVE/Auth/Plugin.pm b/src/PVE/Auth/Plugin.pm
new file mode 100755 (executable)
index 0000000..1413053
--- /dev/null
@@ -0,0 +1,299 @@
+package PVE::Auth::Plugin;
+
+use strict;
+use warnings;
+
+use Digest::SHA;
+use Encode;
+
+use PVE::Cluster qw(cfs_register_file cfs_read_file cfs_lock_file);
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::SectionConfig;
+use PVE::Tools;
+
+use base qw(PVE::SectionConfig);
+
+my $domainconfigfile = "domains.cfg";
+
+cfs_register_file($domainconfigfile,
+                 sub { __PACKAGE__->parse_config(@_); },
+                 sub { __PACKAGE__->write_config(@_); });
+
+sub lock_domain_config {
+    my ($code, $errmsg) = @_;
+
+    cfs_lock_file($domainconfigfile, undef, $code);
+    my $err = $@;
+    if ($err) {
+       $errmsg ? die "$errmsg: $err" : die $err;
+    }
+}
+
+our $realm_regex = qr/[A-Za-z][A-Za-z0-9\.\-_]+/;
+our $user_regex = qr![^\s:/]+!;
+
+PVE::JSONSchema::register_format('pve-realm', \&pve_verify_realm);
+sub pve_verify_realm {
+    my ($realm, $noerr) = @_;
+
+    if ($realm !~ m/^${realm_regex}$/) {
+       return undef if $noerr;
+       die "value does not look like a valid realm\n";
+    }
+    return $realm;
+}
+
+PVE::JSONSchema::register_standard_option('realm', {
+    description => "Authentication domain ID",
+    type => 'string', format => 'pve-realm',
+    maxLength => 32,
+});
+
+my $realm_sync_options_desc = {
+    scope => {
+       description => "Select what to sync.",
+       type => 'string',
+       enum => [qw(users groups both)],
+       optional => '1',
+    },
+    full => {
+       description => "If set, uses the LDAP Directory as source of truth,"
+           ." deleting users or groups not returned from the sync. Otherwise"
+           ." only syncs information which is not already present, and does not"
+           ." deletes or modifies anything else.",
+       type => 'boolean',
+       optional => '1',
+    },
+    'enable-new' => {
+       description => "Enable newly synced users immediately.",
+       type => 'boolean',
+       default => '1',
+       optional => '1',
+    },
+    purge => {
+       description => "Remove ACLs for users or groups which were removed from"
+           ." the config during a sync.",
+       type => 'boolean',
+       optional => '1',
+    },
+};
+PVE::JSONSchema::register_standard_option('realm-sync-options', $realm_sync_options_desc);
+PVE::JSONSchema::register_format('realm-sync-options', $realm_sync_options_desc);
+
+PVE::JSONSchema::register_format('pve-userid', \&verify_username);
+sub verify_username {
+    my ($username, $noerr) = @_;
+
+    $username = '' if !$username;
+    my $len = length($username);
+    if ($len < 3) {
+       die "user name '$username' is too short\n" if !$noerr;
+       return undef;
+    }
+    if ($len > 64) {
+       die "user name '$username' is too long ($len > 64)\n" if !$noerr;
+       return undef;
+    }
+
+    # we only allow a limited set of characters
+    # colon is not allowed, because we store usernames in
+    # colon separated lists)!
+    # slash is not allowed because it is used as pve API delimiter
+    # also see "man useradd"
+    if ($username =~ m!^(${user_regex})\@(${realm_regex})$!) {
+       return wantarray ? ($username, $1, $2) : $username;
+    }
+
+    die "value '$username' does not look like a valid user name\n" if !$noerr;
+
+    return undef;
+}
+
+PVE::JSONSchema::register_standard_option('userid', {
+    description => "User ID",
+    type => 'string', format => 'pve-userid',
+    maxLength => 64,
+});
+
+my $tfa_format = {
+    type => {
+        description => "The type of 2nd factor authentication.",
+        format_description => 'TFATYPE',
+        type => 'string',
+        enum => [qw(yubico oath)],
+    },
+    id => {
+        description => "Yubico API ID.",
+        format_description => 'ID',
+        type => 'string',
+        optional => 1,
+    },
+    key => {
+        description => "Yubico API Key.",
+        format_description => 'KEY',
+        type => 'string',
+        optional => 1,
+    },
+    url => {
+        description => "Yubico API URL.",
+        format_description => 'URL',
+        type => 'string',
+        optional => 1,
+    },
+    digits => {
+        description => "TOTP digits.",
+        format_description => 'COUNT',
+        type => 'integer',
+        minimum => 6, maximum => 8,
+        default => 6,
+        optional => 1,
+    },
+    step => {
+        description => "TOTP time period.",
+        format_description => 'SECONDS',
+        type => 'integer',
+        minimum => 10,
+        default => 30,
+        optional => 1,
+    },
+};
+
+PVE::JSONSchema::register_format('pve-tfa-config', $tfa_format);
+
+PVE::JSONSchema::register_standard_option('tfa', {
+    description => "Use Two-factor authentication.",
+    type => 'string', format => 'pve-tfa-config',
+    optional => 1,
+    maxLength => 128,
+});
+
+sub parse_tfa_config {
+    my ($data) = @_;
+
+    return PVE::JSONSchema::parse_property_string($tfa_format, $data);
+}
+
+my $defaultData = {
+    propertyList => {
+       type => { description => "Realm type." },
+       realm => get_standard_option('realm'),
+    },
+};
+
+sub private {
+    return $defaultData;
+}
+
+sub parse_section_header {
+    my ($class, $line) = @_;
+
+    if ($line =~ m/^(\S+):\s*(\S+)\s*$/) {
+       my ($type, $realm) = (lc($1), $2);
+       my $errmsg = undef; # set if you want to skip whole section
+       eval { pve_verify_realm($realm); };
+       $errmsg = $@ if $@;
+       my $config = {}; # to return additional attributes
+       return ($type, $realm, $errmsg, $config);
+    }
+    return undef;
+}
+
+sub parse_config {
+    my ($class, $filename, $raw) = @_;
+
+    my $cfg = $class->SUPER::parse_config($filename, $raw);
+
+    my $default;
+    foreach my $realm (keys %{$cfg->{ids}}) {
+       my $data = $cfg->{ids}->{$realm};
+       # make sure there is only one default marker
+       if ($data->{default}) {
+           if ($default) {
+               delete $data->{default};
+           } else {
+               $default = $realm;
+           }
+       }
+
+       if ($data->{comment}) {
+           $data->{comment} = PVE::Tools::decode_text($data->{comment});
+       }
+
+    }
+
+    # add default domains
+
+    $cfg->{ids}->{pve}->{type} = 'pve'; # force type
+    $cfg->{ids}->{pve}->{comment} = "Proxmox VE authentication server"
+       if !$cfg->{ids}->{pve}->{comment};
+
+    $cfg->{ids}->{pam}->{type} = 'pam'; # force type
+    $cfg->{ids}->{pam}->{plugin} =  'PVE::Auth::PAM';
+    $cfg->{ids}->{pam}->{comment} = "Linux PAM standard authentication"
+       if !$cfg->{ids}->{pam}->{comment};
+
+    return $cfg;
+};
+
+sub write_config {
+    my ($class, $filename, $cfg) = @_;
+
+    foreach my $realm (keys %{$cfg->{ids}}) {
+       my $data = $cfg->{ids}->{$realm};
+       if ($data->{comment}) {
+           $data->{comment} = PVE::Tools::encode_text($data->{comment});
+       }
+    }
+
+    $class->SUPER::write_config($filename, $cfg);
+}
+
+sub authenticate_user {
+    my ($class, $config, $realm, $username, $password) = @_;
+
+    die "overwrite me";
+}
+
+sub store_password {
+    my ($class, $config, $realm, $username, $password) = @_;
+
+    my $type = $class->type();
+
+    die "can't set password on auth type '$type'\n";
+}
+
+sub delete_user {
+    my ($class, $config, $realm, $username) = @_;
+
+    # do nothing by default
+}
+
+# called during addition of realm (before the new domain config got written)
+# `password` is moved to %param to avoid writing it out to the config
+# die to abort additon if there are (grave) problems
+# NOTE: runs in a domain config *locked* context
+sub on_add_hook {
+    my ($class, $realm, $config, %param) = @_;
+    # do nothing by default
+}
+
+# called during domain configuration update (before the updated domain config got
+# written). `password` is moved to %param to avoid writing it out to the config
+# die to abort the update if there are (grave) problems
+# NOTE: runs in a domain config *locked* context
+sub on_update_hook {
+    my ($class, $realm, $config, %param) = @_;
+    # do nothing by default
+}
+
+# called during deletion of realms (before the new domain config got written)
+# and if the activate check on addition fails, to cleanup all storage traces
+# which on_add_hook may have created.
+# die to abort deletion if there are (very grave) problems
+# NOTE: runs in a storage config *locked* context
+sub on_delete_hook {
+    my ($class, $realm, $config) = @_;
+    # do nothing by default
+}
+
+1;
diff --git a/src/PVE/CLI/Makefile b/src/PVE/CLI/Makefile
new file mode 100644 (file)
index 0000000..3054212
--- /dev/null
@@ -0,0 +1,9 @@
+SOURCES=pveum.pm
+
+.PHONY: install
+install: ${SOURCES}
+       install -d -m 0755 ${DESTDIR}${PERLDIR}/PVE/CLI
+       for i in ${SOURCES}; do install -D -m 0644 $$i ${DESTDIR}${PERLDIR}/PVE/CLI/$$i; done
+
+
+clean:
diff --git a/src/PVE/CLI/pveum.pm b/src/PVE/CLI/pveum.pm
new file mode 100755 (executable)
index 0000000..cbbdb7d
--- /dev/null
@@ -0,0 +1,187 @@
+package PVE::CLI::pveum;
+
+use strict;
+use warnings;
+
+use PVE::AccessControl;
+use PVE::RPCEnvironment;
+use PVE::API2::User;
+use PVE::API2::Group;
+use PVE::API2::Role;
+use PVE::API2::ACL;
+use PVE::API2::AccessControl;
+use PVE::API2::Pool;
+use PVE::API2::Domains;
+use PVE::CLIFormatter;
+use PVE::CLIHandler;
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::PTY;
+use PVE::RESTHandler;
+use PVE::Tools qw(extract_param);
+
+use base qw(PVE::CLIHandler);
+
+sub setup_environment {
+    PVE::RPCEnvironment->setup_default_cli_env();
+}
+
+sub param_mapping {
+    my ($name) = @_;
+
+    my $mapping = {
+       'change_password' => [
+           PVE::CLIHandler::get_standard_mapping('pve-password'),
+       ],
+       'create_ticket' => [
+           PVE::CLIHandler::get_standard_mapping('pve-password', {
+               func => sub {
+                   # do not accept values given on cmdline
+                   return PVE::PTY::read_password('Enter password: ');
+               },
+           }),
+       ]
+    };
+
+    return $mapping->{$name};
+}
+
+my $print_api_result = sub {
+    my ($data, $schema, $options) = @_;
+    PVE::CLIFormatter::print_api_result($data, $schema, undef, $options);
+};
+
+my $print_perm_result = sub {
+    my ($data, $schema, $options) = @_;
+
+    if (!defined($options->{'output-format'}) || $options->{'output-format'} eq 'text') {
+       my $table_schema = {
+           type => 'array',
+           items => {
+               type => 'object',
+               properties => {
+                   'path' => { type => 'string', title => 'ACL path' },
+                   'permissions' => { type => 'string', title => 'Permissions' },
+               },
+           },
+       };
+       my $table_data = [];
+       foreach my $path (sort keys %$data) {
+           my $value = '';
+           my $curr = $data->{$path};
+           foreach my $perm (sort keys %$curr) {
+               $value .= "\n" if $value;
+               $value .= $perm;
+               $value .= " (*)" if $curr->{$perm};
+           }
+           push @$table_data, { path => $path, permissions => $value };
+       }
+       PVE::CLIFormatter::print_api_result($table_data, $table_schema, undef, $options);
+       print "Permissions marked with '(*)' have the 'propagate' flag set.\n";
+    } else {
+       PVE::CLIFormatter::print_api_result($data, $schema, undef, $options);
+    }
+};
+
+__PACKAGE__->register_method({
+    name => 'token_permissions',
+    path => 'token_permissions',
+    method => 'GET',
+    description => 'Retrieve effective permissions of given token.',
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid'),
+           tokenid => get_standard_option('token-subid'),
+           path => get_standard_option('acl-path', {
+               description => "Only dump this specific path, not the whole tree.",
+               optional => 1,
+           }),
+       },
+    },
+    returns => {
+       type => 'object',
+       description => 'Hash of structure "path" => "privilege" => "propagate boolean".',
+    },
+    code => sub {
+       my ($param) = @_;
+
+       my $token_subid = extract_param($param, "tokenid");
+       $param->{userid} = PVE::AccessControl::join_tokenid($param->{userid}, $token_subid);
+
+       return PVE::API2::AccessControl->permissions($param);
+    }});
+
+our $cmddef = {
+    user => {
+       add    => [ 'PVE::API2::User', 'create_user', ['userid'] ],
+       modify => [ 'PVE::API2::User', 'update_user', ['userid'] ],
+       delete => [ 'PVE::API2::User', 'delete_user', ['userid'] ],
+       list   => [ 'PVE::API2::User', 'index', [], {}, $print_api_result, $PVE::RESTHandler::standard_output_options],
+       permissions => [ 'PVE::API2::AccessControl', 'permissions', ['userid'], {}, $print_perm_result, $PVE::RESTHandler::standard_output_options],
+       tfa => {
+           delete => [ 'PVE::API2::AccessControl', 'change_tfa', ['userid'], { action => 'delete', key => undef, config => undef, response => undef, }, ],
+       },
+       token => {
+           add    => [ 'PVE::API2::User', 'generate_token', ['userid', 'tokenid'], {}, $print_api_result, $PVE::RESTHandler::standard_output_options ],
+           modify    => [ 'PVE::API2::User', 'update_token_info', ['userid', 'tokenid'], {}, $print_api_result, $PVE::RESTHandler::standard_output_options ],
+           remove    => [ 'PVE::API2::User', 'remove_token', ['userid', 'tokenid'], {}, $print_api_result, $PVE::RESTHandler::standard_output_options ],
+           list   => [ 'PVE::API2::User', 'token_index', ['userid'], {}, $print_api_result, $PVE::RESTHandler::standard_output_options],
+           permissions => [ __PACKAGE__, 'token_permissions', ['userid', 'tokenid'], {}, $print_perm_result, $PVE::RESTHandler::standard_output_options],
+       }
+    },
+    group => {
+       add    => [ 'PVE::API2::Group', 'create_group', ['groupid'] ],
+       modify => [ 'PVE::API2::Group', 'update_group', ['groupid'] ],
+       delete => [ 'PVE::API2::Group', 'delete_group', ['groupid'] ],
+       list   => [ 'PVE::API2::Group', 'index', [], {}, $print_api_result, $PVE::RESTHandler::standard_output_options],
+    },
+    role => {
+       add    => [ 'PVE::API2::Role', 'create_role', ['roleid'] ],
+       modify => [ 'PVE::API2::Role', 'update_role', ['roleid'] ],
+       delete => [ 'PVE::API2::Role', 'delete_role', ['roleid'] ],
+       list   => [ 'PVE::API2::Role', 'index', [], {}, $print_api_result, $PVE::RESTHandler::standard_output_options],
+    },
+    acl => {
+       modify => [ 'PVE::API2::ACL', 'update_acl', ['path'], { delete => 0 }],
+       delete => [ 'PVE::API2::ACL', 'update_acl', ['path'], { delete => 1 }],
+       list   => [ 'PVE::API2::ACL', 'read_acl', [], {}, $print_api_result, $PVE::RESTHandler::standard_output_options],
+    },
+    pool => {
+       add => [ 'PVE::API2::Pool', 'create_pool', ['poolid'] ],
+       modify => [ 'PVE::API2::Pool', 'update_pool', ['poolid'] ],
+       delete => [ 'PVE::API2::Pool', 'delete_pool', ['poolid'] ],
+       list   => [ 'PVE::API2::Pool', 'index', [], {}, $print_api_result, $PVE::RESTHandler::standard_output_options],
+    },
+    realm => {
+       add    => [ 'PVE::API2::Domains', 'create', ['realm'] ],
+       modify => [ 'PVE::API2::Domains', 'update', ['realm'] ],
+       delete => [ 'PVE::API2::Domains', 'delete', ['realm'] ],
+       list   => [ 'PVE::API2::Domains', 'index', [], {}, $print_api_result, $PVE::RESTHandler::standard_output_options],
+       sync   => [ 'PVE::API2::Domains', 'sync', ['realm'], ],
+    },
+
+    ticket => [ 'PVE::API2::AccessControl', 'create_ticket', ['username'], undef,
+               sub {
+                   my ($res) = @_;
+                   print "$res->{ticket}\n";
+               }],
+
+    passwd => [ 'PVE::API2::AccessControl', 'change_password', ['userid'] ],
+
+    useradd => { alias => 'user add' },
+    usermod => { alias => 'user modify' },
+    userdel => { alias => 'user delete' },
+
+    groupadd => { alias => 'group add' },
+    groupmod => { alias => 'group modify' },
+    groupdel => { alias => 'group delete' },
+
+    roleadd => { alias => 'role add' },
+    rolemod => { alias => 'role modify' },
+    roledel => { alias => 'role delete' },
+
+    aclmod => { alias => 'acl modify' },
+    acldel => { alias => 'acl delete' },
+};
+
+1;
diff --git a/src/PVE/Makefile b/src/PVE/Makefile
new file mode 100644 (file)
index 0000000..c839d8f
--- /dev/null
@@ -0,0 +1,10 @@
+
+
+.PHONY: install
+install:
+       make -C Auth install
+       install -D -m 0644 AccessControl.pm ${DESTDIR}${PERLDIR}/PVE/AccessControl.pm
+       install -D -m 0644 RPCEnvironment.pm ${DESTDIR}${PERLDIR}/PVE/RPCEnvironment.pm
+       install -D -m 0644 TokenConfig.pm ${DESTDIR}${PERLDIR}/PVE/TokenConfig.pm
+       make -C API2 install
+       make -C CLI install
diff --git a/src/PVE/RPCEnvironment.pm b/src/PVE/RPCEnvironment.pm
new file mode 100644 (file)
index 0000000..e66107b
--- /dev/null
@@ -0,0 +1,493 @@
+package PVE::RPCEnvironment;
+
+use strict;
+use warnings;
+
+use PVE::RESTEnvironment;
+
+use PVE::Exception qw(raise raise_perm_exc);
+use PVE::SafeSyslog;
+use PVE::Tools;
+use PVE::INotify;
+use PVE::Cluster;
+use PVE::ProcFSTools;
+use PVE::AccessControl;
+
+use base qw(PVE::RESTEnvironment);
+
+# ACL cache
+
+my $compile_acl_path = sub {
+    my ($self, $user, $path) = @_;
+
+    my $cfg = $self->{user_cfg};
+
+    return undef if !$cfg->{roles};
+
+    die "internal error" if $user eq 'root@pam';
+
+    my $cache = $self->{aclcache};
+    $cache->{$user} = {} if !$cache->{$user};
+    my $data = $cache->{$user};
+
+    my ($username, undef) = PVE::AccessControl::split_tokenid($user, 1);
+    die "internal error" if $username && $username ne 'root@pam' && !defined($cache->{$username});
+
+    if (!$data->{poolroles}) {
+       $data->{poolroles} = {};
+
+       foreach my $pool (keys %{$cfg->{pools}}) {
+           my $d = $cfg->{pools}->{$pool};
+           my $pool_roles = PVE::AccessControl::roles($cfg, $user, "/pool/$pool"); # pool roles
+           next if !scalar(keys %$pool_roles);
+           foreach my $vmid (keys %{$d->{vms}}) {
+               for my $role (keys %$pool_roles) {
+                   $data->{poolroles}->{"/vms/$vmid"}->{$role} = 1;
+               }
+           }
+           foreach my $storeid (keys %{$d->{storage}}) {
+               for my $role (keys %$pool_roles) {
+                   $data->{poolroles}->{"/storage/$storeid"}->{$role} = 1;
+               }
+           }
+       }
+    }
+
+    my $roles = PVE::AccessControl::roles($cfg, $user, $path);
+
+    # apply roles inherited from pools
+    # Note: assume we do not want to propagate those privs
+    if ($data->{poolroles}->{$path}) {
+       if (!defined($roles->{NoAccess})) {
+           if ($data->{poolroles}->{$path}->{NoAccess}) {
+               $roles = { 'NoAccess' => 0 };
+           } else {
+               foreach my $role (keys %{$data->{poolroles}->{$path}}) {
+                   $roles->{$role} = 0 if !defined($roles->{$role});
+               }
+           }
+       }
+    }
+
+    $data->{roles}->{$path} = $roles;
+
+    my $privs = {};
+    foreach my $role (keys %$roles) {
+       if (my $privset = $cfg->{roles}->{$role}) {
+           foreach my $p (keys %$privset) {
+               $privs->{$p} = $roles->{$role};
+           }
+       }
+    }
+
+    if ($username && $username ne 'root@pam') {
+       # intersect user and token permissions
+       my $user_privs = $cache->{$username}->{privs}->{$path};
+       $privs = { map { $_ => $user_privs->{$_} && $privs->{$_} } keys %$privs };
+    }
+
+    $data->{privs}->{$path} = $privs;
+
+    return $privs;
+};
+
+sub permissions {
+    my ($self, $user, $path) = @_;
+
+    if ($user eq 'root@pam') { # root can do anything
+       my $cfg = $self->{user_cfg};
+       return { map { $_ => 1 } keys %{$cfg->{roles}->{'Administrator'}} };
+    }
+
+    if (PVE::AccessControl::pve_verify_tokenid($user, 1)) {
+       my ($username, $token) = PVE::AccessControl::split_tokenid($user);
+       my $cfg = $self->{user_cfg};
+       my $token_info = $cfg->{users}->{$username}->{tokens}->{$token};
+
+       return {} if !$token_info;
+
+       # ensure cache for user is populated
+       my $user_perms = $self->permissions($username, $path);
+
+       # return user privs for non-privsep tokens
+       return $user_perms if !$token_info->{privsep};
+    } else {
+       $user = PVE::AccessControl::verify_username($user, 1);
+       return {} if !$user;
+    }
+
+    my $cache = $self->{aclcache};
+    $cache->{$user} = {} if !$cache->{$user};
+
+    my $acl = $cache->{$user};
+
+    my $perm = $acl->{privs}->{$path};
+    return $perm if $perm;
+
+    return &$compile_acl_path($self, $user, $path);
+}
+
+sub get_effective_permissions {
+    my ($self, $user) = @_;
+
+    # default / top level paths
+    my $paths = {
+       '/' => 1,
+       '/access' => 1,
+       '/access/groups' => 1,
+       '/nodes' => 1,
+       '/pools' => 1,
+       '/storage' => 1,
+       '/vms' => 1,
+    };
+
+    my $cfg = $self->{user_cfg};
+
+    # paths explicitly listed in ACLs
+    foreach my $acl_path (keys %{$cfg->{acl}}) {
+       $paths->{$acl_path} = 1;
+    }
+
+    # paths referenced by pool definitions
+    foreach my $pool (keys %{$cfg->{pools}}) {
+       my $d = $cfg->{pools}->{$pool};
+       foreach my $vmid (keys %{$d->{vms}}) {
+           $paths->{"/vms/$vmid"} = 1;
+       }
+       foreach my $storeid (keys %{$d->{storage}}) {
+           $paths->{"/storage/$storeid"} = 1;
+       }
+    }
+
+    my $perms = {};
+    foreach my $path (keys %$paths) {
+       my $path_perms = $self->permissions($user, $path);
+       # filter paths where user has NO permissions
+       $perms->{$path} = $path_perms if %$path_perms;
+    }
+    return $perms;
+}
+
+sub check {
+    my ($self, $user, $path, $privs, $noerr) = @_;
+
+    my $perm = $self->permissions($user, $path);
+
+    foreach my $priv (@$privs) {
+       PVE::AccessControl::verify_privname($priv);
+       if (!defined($perm->{$priv})) {
+           return undef if $noerr;
+           raise_perm_exc("$path, $priv");
+       }
+    };
+
+    return 1;
+};
+
+sub check_any {
+    my ($self, $user, $path, $privs, $noerr) = @_;
+
+    my $perm = $self->permissions($user, $path);
+
+    my $found = 0;
+    foreach my $priv (@$privs) {
+       PVE::AccessControl::verify_privname($priv);
+       if (defined($perm->{$priv})) {
+           $found = 1;
+           last;
+       }
+    };
+
+    return 1 if $found;
+
+    return undef if $noerr;
+
+    raise_perm_exc("$path, " . join("|", @$privs));
+};
+
+sub check_full {
+    my ($self, $username, $path, $privs, $any, $noerr) = @_;
+    if ($any) {
+       return $self->check_any($username, $path, $privs, $noerr);
+    } else {
+       return $self->check($username, $path, $privs, $noerr);
+    }
+}
+
+sub check_user_enabled {
+    my ($self, $user, $noerr) = @_;
+
+    my $cfg = $self->{user_cfg};
+    return PVE::AccessControl::check_user_enabled($cfg, $user, $noerr);
+}
+
+sub check_user_exist {
+    my ($self, $user, $noerr) = @_;
+
+    my $cfg = $self->{user_cfg};
+    return PVE::AccessControl::check_user_exist($cfg, $user, $noerr);
+}
+
+sub check_pool_exist {
+    my ($self, $pool, $noerr) = @_;
+
+    my $cfg = $self->{user_cfg};
+
+    return 1 if $cfg->{pools}->{$pool};
+
+    return undef if $noerr;
+
+    raise_perm_exc("pool '$pool' does not exist");
+}
+
+sub check_vm_perm {
+    my ($self, $user, $vmid, $pool, $privs, $any, $noerr) = @_;
+
+    my $cfg = $self->{user_cfg};
+
+    if ($pool) {
+       return if $self->check_full($user, "/pool/$pool", $privs, $any, 1);
+    }
+    return $self->check_full($user, "/vms/$vmid", $privs, $any, $noerr);
+};
+
+sub is_group_member {
+    my ($self, $group, $user) = @_;
+
+    my $cfg = $self->{user_cfg};
+
+    return 0 if !$cfg->{groups}->{$group};
+
+    return defined($cfg->{groups}->{$group}->{users}->{$user});
+}
+
+sub filter_groups {
+    my ($self, $user, $privs, $any) = @_;
+
+    my $cfg = $self->{user_cfg};
+
+    my $groups = {};
+    foreach my $group (keys %{$cfg->{groups}}) {
+       my $path = "/access/groups/$group";
+       if ($self->check_full($user, $path, $privs, $any, 1)) {
+           $groups->{$group} = $cfg->{groups}->{$group};
+       }
+    }
+
+    return $groups;
+}
+
+sub group_member_join {
+    my ($self, $grouplist) = @_;
+
+    my $users = {};
+
+    my $cfg = $self->{user_cfg};
+    foreach my $group (@$grouplist) {
+       my $data = $cfg->{groups}->{$group};
+       next if !$data;
+       foreach my $user (keys %{$data->{users}}) {
+           $users->{$user} = 1;
+       }
+    }
+
+    return $users;
+}
+
+sub check_perm_modify {
+    my ($self, $username, $path, $noerr) = @_;
+
+    return $self->check($username, '/access', [ 'Permissions.Modify' ], $noerr) if !$path;
+
+    my $testperms = [ 'Permissions.Modify' ];
+    if ($path =~ m|^/storage/.+$|) {
+       push @$testperms, 'Datastore.Allocate';
+    } elsif ($path =~ m|^/vms/.+$|) {
+       push @$testperms, 'VM.Allocate';
+    } elsif ($path =~ m|^/pool/.+$|) {
+       push @$testperms, 'Pool.Allocate';
+    }
+
+    return $self->check_any($username, $path, $testperms, $noerr);
+}
+
+sub exec_api2_perm_check {
+    my ($self, $check, $username, $param, $noerr) = @_;
+
+    # syslog("info", "CHECK " . join(', ', @$check));
+
+    my $ind = 0;
+    my $test = $check->[$ind++];
+    die "no permission test specified" if !$test;
+
+    if ($test eq 'and') {
+       while (my $subcheck = $check->[$ind++]) {
+           $self->exec_api2_perm_check($subcheck, $username, $param);
+       }
+       return 1;
+    } elsif ($test eq 'or') {
+       while (my $subcheck = $check->[$ind++]) {
+           return 1 if $self->exec_api2_perm_check($subcheck, $username, $param, 1);
+       }
+       return 0 if $noerr;
+       raise_perm_exc();
+    } elsif ($test eq 'perm') {
+       my ($t, $tmplpath, $privs, %options) = @$check;
+       my $any = $options{any};
+       die "missing parameters" if !($tmplpath && $privs);
+       my $require_param = $options{require_param};
+       if ($require_param && !defined($param->{$require_param})) {
+           return 0 if $noerr;
+           raise_perm_exc();
+       }
+       my $path = PVE::Tools::template_replace($tmplpath, $param);
+       $path = PVE::AccessControl::normalize_path($path);
+       return $self->check_full($username, $path, $privs, $any, $noerr);
+    } elsif ($test eq 'userid-group') {
+       my $userid = $param->{userid};
+       my ($t, $privs, %options) = @$check;
+       return 0 if !$options{groups_param} && !$self->check_user_exist($userid, $noerr);
+       if (!$self->check_any($username, "/access/groups", $privs, 1)) {
+           my $groups = $self->filter_groups($username, $privs, 1);
+           if ($options{groups_param}) {
+               my @group_param = PVE::Tools::split_list($param->{groups});
+               raise_perm_exc("/access/groups, " . join("|", @$privs)) if !scalar(@group_param);
+               foreach my $pg (@group_param) {
+                   raise_perm_exc("/access/groups/$pg, " . join("|", @$privs))
+                       if !$groups->{$pg};
+               }
+           } else {
+               my $allowed_users = $self->group_member_join([keys %$groups]);
+               if (!$allowed_users->{$userid}) {
+                   return 0 if $noerr;
+                   raise_perm_exc();
+               }
+           }
+       }
+       return 1;
+    } elsif ($test eq 'userid-param') {
+       my ($userid, undef, $realm) = PVE::AccessControl::verify_username($param->{userid});
+       my ($t, $subtest) = @$check;
+       die "missing parameters" if !$subtest;
+       if ($subtest eq 'self') {
+           return 0 if !$self->check_user_exist($userid, $noerr);
+           return 1 if $username eq $userid;
+           return 0 if $noerr;
+           raise_perm_exc();
+       } elsif ($subtest eq 'Realm.AllocateUser') {
+           my $path =  "/access/realm/$realm";
+           return $self->check($username, $path, ['Realm.AllocateUser'], $noerr);
+       } else {
+           die "unknown userid-param test";
+       }
+     } elsif ($test eq 'perm-modify') {
+       my ($t, $tmplpath) = @$check;
+       my $path = PVE::Tools::template_replace($tmplpath, $param);
+       $path = PVE::AccessControl::normalize_path($path);
+       return $self->check_perm_modify($username, $path, $noerr);
+   } else {
+       die "unknown permission test";
+    }
+};
+
+sub check_api2_permissions {
+    my ($self, $perm, $username, $param) = @_;
+
+    return 1 if !$username && $perm->{user} && $perm->{user} eq 'world';
+
+    raise_perm_exc("user != null") if !$username;
+
+    return 1 if $username eq 'root@pam';
+
+    raise_perm_exc('user != root@pam') if !$perm;
+
+    return 1 if $perm->{user} && $perm->{user} eq 'all';
+
+    return $self->exec_api2_perm_check($perm->{check}, $username, $param)
+       if $perm->{check};
+
+    raise_perm_exc();
+}
+
+sub log_cluster_msg {
+    my ($self, $pri, $user, $msg) = @_;
+
+    PVE::Cluster::log_msg($pri, $user, $msg);
+}
+
+sub broadcast_tasklist {
+    my ($self, $tlist) = @_;
+
+    PVE::Cluster::broadcast_tasklist($tlist);
+}
+
+# initialize environment - must be called once at program startup
+sub init {
+    my ($class, $type, %params) = @_;
+
+    $class = ref($class) || $class;
+
+    my $self = $class->SUPER::init($type, %params);
+
+    $self->{user_cfg} = {};
+    $self->{aclcache} = {};
+    $self->{aclversion} = undef;
+
+    return $self;
+};
+
+
+# init_request - must be called before each RPC request
+sub init_request {
+    my ($self, %params) = @_;
+
+    PVE::Cluster::cfs_update();
+
+    $self->{result_attributes} = {};
+
+    my $userconfig; # we use this for regression tests
+    foreach my $p (keys %params) {
+       if ($p eq 'userconfig') {
+           $userconfig = $params{$p};
+       } else {
+           die "unknown parameter '$p'";
+       }
+    }
+
+    eval {
+       $self->{aclcache} = {};
+       if ($userconfig) {
+           my $ucdata = PVE::Tools::file_get_contents($userconfig);
+           my $cfg = PVE::AccessControl::parse_user_config($userconfig, $ucdata);
+           $self->{user_cfg} = $cfg;
+       } else {
+           my $ucvers = PVE::Cluster::cfs_file_version('user.cfg');
+           if (!$self->{aclcache} || !defined($self->{aclversion}) ||
+               !defined($ucvers) ||  ($ucvers ne $self->{aclversion})) {
+               $self->{aclversion} = $ucvers;
+               my $cfg = PVE::Cluster::cfs_read_file('user.cfg');
+               $self->{user_cfg} = $cfg;
+           }
+       }
+    };
+    if (my $err = $@) {
+       $self->{user_cfg} = {};
+       die "Unable to load access control list: $err";
+    }
+}
+
+# hacks: to provide better backwards compatibiliy
+
+# old code uses PVE::RPCEnvironment::get();
+# new code should use PVE::RPCEnvironment->get();
+sub get {
+    return PVE::RESTEnvironment->get();
+}
+
+# old code uses PVE::RPCEnvironment::is_worker();
+# new code should use PVE::RPCEnvironment->is_worker();
+sub is_worker {
+    return PVE::RESTEnvironment->is_worker();
+}
+
+1;
diff --git a/src/PVE/TokenConfig.pm b/src/PVE/TokenConfig.pm
new file mode 100644 (file)
index 0000000..cfc60cc
--- /dev/null
@@ -0,0 +1,80 @@
+package PVE::TokenConfig;
+
+use strict;
+use warnings;
+
+use UUID;
+
+use PVE::AccessControl;
+use PVE::Cluster;
+
+my $parse_token_cfg = sub {
+    my ($filename, $raw) = @_;
+
+    my $parsed = {};
+    return $parsed if !defined($raw);
+
+    my @lines = split(/\n/, $raw);
+    foreach my $line (@lines) {
+       next if $line =~ m/^\s*$/;
+
+       if ($line =~ m/^(\S+) (\S+)$/) {
+           if (PVE::AccessControl::pve_verify_tokenid($1, 1)) {
+               $parsed->{$1} = $2;
+               next;
+           }
+       }
+
+       warn "skipping invalid token.cfg entry\n";
+    }
+
+    return $parsed;
+};
+
+my $write_token_cfg = sub {
+    my ($filename, $data) = @_;
+
+    my $raw = '';
+    foreach my $tokenid (sort keys %$data) {
+       $raw .= "$tokenid $data->{$tokenid}\n";
+    }
+
+    return $raw;
+};
+
+PVE::Cluster::cfs_register_file('priv/token.cfg', $parse_token_cfg, $write_token_cfg);
+
+sub generate_token {
+    my ($tokenid) = @_;
+
+    PVE::AccessControl::pve_verify_tokenid($tokenid);
+
+    my $token_value = PVE::Cluster::cfs_lock_file('priv/token.cfg', 10, sub {
+       my $uuid = UUID::uuid();
+       my $token_cfg = PVE::Cluster::cfs_read_file('priv/token.cfg');
+
+       $token_cfg->{$tokenid} = $uuid;
+
+       PVE::Cluster::cfs_write_file('priv/token.cfg', $token_cfg);
+
+       return $uuid;
+    });
+
+    die "$@\n" if defined($@);
+
+    return $token_value;
+}
+
+sub delete_token {
+    my ($tokenid) = @_;
+
+    PVE::Cluster::cfs_lock_file('priv/token.cfg', 10, sub {
+       my $token_cfg = PVE::Cluster::cfs_read_file('priv/token.cfg');
+
+       delete $token_cfg->{$tokenid};
+
+       PVE::Cluster::cfs_write_file('priv/token.cfg', $token_cfg);
+    });
+
+    die "$@\n" if defined($@);
+}
diff --git a/src/oathkeygen b/src/oathkeygen
new file mode 100755 (executable)
index 0000000..82e4eec
--- /dev/null
@@ -0,0 +1,11 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+use MIME::Base32; #libmime-base32-perl
+
+my $test;
+open(RND, "/dev/urandom");
+sysread(RND, $test, 10) == 10 || die "read random data failed\n";
+print MIME::Base32::encode_rfc3548($test) . "\n";
+
diff --git a/src/pveum b/src/pveum
new file mode 100755 (executable)
index 0000000..34ed82c
--- /dev/null
+++ b/src/pveum
@@ -0,0 +1,8 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+
+use PVE::CLI::pveum;
+
+PVE::CLI::pveum->run_cli_handler();
diff --git a/src/test/Makefile b/src/test/Makefile
new file mode 100644 (file)
index 0000000..adaacb9
--- /dev/null
@@ -0,0 +1,14 @@
+
+all:
+
+.PHONY: check
+check:
+       perl -I.. parser_writer.pl
+       perl -I.. perm-test1.pl
+       perl -I.. perm-test2.pl
+       perl -I.. perm-test3.pl
+       perl -I.. perm-test4.pl
+       perl -I.. perm-test5.pl
+       perl -I.. perm-test6.pl
+       perl -I.. perm-test7.pl
+       perl -I.. perm-test8.pl
diff --git a/src/test/auth-test.pl b/src/test/auth-test.pl
new file mode 100644 (file)
index 0000000..60429a9
--- /dev/null
@@ -0,0 +1,15 @@
+#!/usr/bin/perl -w
+
+use strict;
+use PVE::PTY;
+use PVE::AccessControl;
+
+my $username = shift;
+die "Username missing" if !$username;
+
+my $password = PVE::PTY::read_password('password: ');
+PVE::AccessControl::authenticate_user($username,$password);
+
+print "Authentication Successful!!\n";
+
+exit (0);
diff --git a/src/test/dump-perm.pl b/src/test/dump-perm.pl
new file mode 100755 (executable)
index 0000000..cb2a2ee
--- /dev/null
@@ -0,0 +1,38 @@
+#!/usr/bin/perl -w
+
+use strict;
+use PVE::RPCEnvironment;
+use Getopt::Long;
+use Data::Dumper;
+
+# example: 
+# dump-perm.pl -f myuser.cfg root /
+
+my $opt_file;
+if (!GetOptions ("file=s"   => \$opt_file)) {
+    exit (-1);
+}
+
+my $username = shift;
+my $path = shift;
+if (!($username && $path)) {
+    print "usage: $0 <username> <path>\n";
+    exit (-1);
+}
+
+my $cfg;
+
+my $rpcenv = PVE::RPCEnvironment->init('cli');
+if ($opt_file) {
+    $rpcenv->init_request(userconfig => $opt_file);
+} else {
+    $rpcenv->init_request();
+}
+
+my $perm = $rpcenv->permissions($username, $path);
+
+print "permission for user '$username' on '$path':\n";
+print join(',', keys %$perm) . "\n";
+
+exit (0);
diff --git a/src/test/dump-users.pl b/src/test/dump-users.pl
new file mode 100755 (executable)
index 0000000..f08d30b
--- /dev/null
@@ -0,0 +1,13 @@
+#!/usr/bin/perl -w
+
+use strict;
+use PVE::AccessControl;
+use Data::Dumper;
+
+my $cfg;
+
+$cfg = PVE::AccessControl::load_user_config();
+
+print Dumper($cfg) . "\n";
+
+exit (0);
diff --git a/src/test/parser_writer.pl b/src/test/parser_writer.pl
new file mode 100755 (executable)
index 0000000..2fef7db
--- /dev/null
@@ -0,0 +1,1025 @@
+#!/usr/bin/perl -w
+
+use strict;
+
+use Test::More;
+use PVE::AccessControl;
+
+use Storable qw(dclone);
+
+PVE::AccessControl::create_roles();
+my $default_user_cfg = {};
+PVE::AccessControl::userconfig_force_defaults($default_user_cfg);
+
+my $add_default_user_properties = sub {
+    my ($user) = @_;
+
+    $user->{enable} = 1 if !defined($user->{enable});
+    $user->{expire} = 0 if !defined($user->{expire});
+    $user->{email} = undef if !defined($user->{email});
+
+    return $user;
+};
+
+sub default_roles {
+    my $roles = dclone($default_user_cfg->{roles});
+    return $roles;
+}
+
+sub default_roles_with {
+    my ($extra_roles) = @_;
+
+    my $roles = default_roles();
+
+    foreach my $r (@$extra_roles) {
+       my $role = dclone($r);
+       my $roleid = delete $role->{id};
+       $roles->{$roleid} = $role;
+    }
+
+    return $roles;
+}
+
+sub default_users {
+    my $users = dclone($default_user_cfg->{users});
+    return { map { $_ => $add_default_user_properties->($users->{$_}); } keys %$users};
+}
+
+sub default_users_with {
+    my ($extra_users) = @_;
+
+    my $users = default_users();
+
+    foreach my $u (@$extra_users) {
+       my $user = dclone($u);
+       my $userid = delete $user->{id};
+       $users->{$userid} = $add_default_user_properties->($user);
+    }
+
+    return $users;
+}
+
+sub default_groups {
+    return {};
+}
+
+sub default_groups_with {
+    my ($extra_groups) = @_;
+
+    my $groups = default_groups();
+
+    foreach my $g (@$extra_groups) {
+       my $group = dclone($g);
+       my $groupid = delete $group->{id};
+       $groups->{$groupid} = $group;
+    }
+
+    return $groups;
+}
+
+sub default_pools {
+    return {};
+}
+
+sub default_pools_with {
+    my ($extra_pools) = @_;
+
+    my $pools = default_pools();
+
+    foreach my $p (@$extra_pools) {
+       my $pool = dclone($p);
+       my $poolid = delete $pool->{id};
+       $pools->{$poolid} = $pool;
+    }
+
+    return $pools;
+}
+
+sub default_pool_vms_with {
+    my ($extra_pools) = @_;
+
+    my $vms = {};
+    foreach my $pool (@$extra_pools) {
+       foreach my $vmid (keys %{$pool->{vms}}) {
+           $vms->{$vmid} = $pool->{id};
+       }
+    }
+    return $vms;
+}
+
+sub default_acls {
+    return {};
+}
+
+# note: does not support merging paths!
+sub default_acls_with {
+    my ($extra_acls) = @_;
+
+    my $acls = default_acls();
+
+    foreach my $a (@$extra_acls) {
+       my $acl = dclone($a);
+       my $path = delete $acl->{path};
+       $acls->{$path} = $acl;
+    }
+
+    return $acls;
+}
+
+my $default_cfg = {
+    test_pam => {
+       'id' => 'test@pam',
+       'enable' => 1,
+       'expire' => 0,
+       'email' => undef,
+    },
+    test2_pam => {
+       'id' => 'test2@pam',
+       'enable' => 1,
+       'expire' => 0,
+       'email' => undef,
+    },
+    test_pam_with_group => {
+       'id' => 'test@pam',
+       'enable' => 1,
+       'expire' => 0,
+       'email' => undef,
+       'groups' => { 'testgroup' => 1 },
+    },
+    test2_pam_with_group => {
+       'id' => 'test2@pam',
+       'enable' => 1,
+       'expire' => 0,
+       'email' => undef,
+       'groups' => { 'testgroup' => 1 },
+    },
+    test3_pam => {
+       'id' => 'test3@pam',
+       'enable' => 1,
+       'expire' => 0,
+       'email' => undef,
+       'groups' => { 'another' => 1 },
+    },
+    test_pam_with_token => {
+       'id' => 'test@pam',
+       'enable' => 1,
+       'expire' => 0,
+       'email' => undef,
+       'tokens' => {
+           'full' => {
+               'privsep' => 0,
+               'expire' => 0,
+           },
+       },
+    },
+    test_pam2_with_token => {
+       'id' => 'test2@pam',
+       'enable' => 1,
+       'expire' => 0,
+       'email' => undef,
+       'tokens' => {
+           'full' => {
+               'privsep' => 0,
+               'expire' => 0,
+           },
+           'privsep' => {
+               'privsep' => 1,
+               'expire' => 0,
+           },
+           'expired' => {
+               'privsep' => 0,
+               'expire' => 1,
+           },
+       },
+    },
+    test_group_empty => {
+       'id' => 'testgroup',
+       users => {},
+    },
+    test_group_single_member => {
+       'id' => 'testgroup',
+       'users' => {
+           'test@pam' => 1,
+       },
+    },
+    test_group_members => {
+       'id' => 'testgroup',
+       'users' => {
+           'test@pam' => 1,
+           'test2@pam' => 1,
+       },
+    },
+    test_group_second => {
+       'id' => 'another',
+       users => {
+           'test3@pam' => 1,
+       },
+    },
+    test_role_single_priv => {
+       'id' => 'testrolesingle',
+       'VM.Allocate' => 1,
+    },
+    test_role_privs => {
+       'id' => 'testrole',
+       'VM.Allocate' => 1,
+       'Datastore.Audit' => 1,
+    },
+    test_pool_empty => {
+       'id' => 'testpool',
+       vms => {},
+       storage => {},
+    },
+    test_pool_members => {
+       'id' => 'testpool',
+       vms => { 123 => 1, 1234 => 1},
+       storage => { 'local' => 1, 'local-zfs' => 1},
+    },
+    test_pool_duplicate_vms => {
+       'id' => 'test_duplicate_vms',
+       vms => {},
+       storage => {},
+    },
+    test_pool_duplicate_storages => {
+       'id' => 'test_duplicate_storages',
+       vms => {},
+       storage => { 'local' => 1, 'local-zfs' => 1},
+    },
+    acl_simple_user => {
+       'path' => '/',
+       users => {
+           'test@pam' => {
+               'PVEVMAdmin' => 1,
+           },
+       },
+    },
+    acl_complex_users => {
+       'path' => '/storage',
+       users => {
+           'test2@pam' => {
+               'PVEDatastoreUser' => 1,
+           },
+           'test@pam' => {
+               'PVEDatastoreAdmin' => 1,
+           },
+       },
+    },
+    acl_complex_missing_user => {
+       'path' => '/storage',
+       users => {
+           'test2@pam' => {
+               'PVEDatastoreUser' => 1,
+           },
+           'test@pam' => {
+               'PVEDatastoreAdmin' => 1,
+           },
+       },
+    },
+    acl_simple_token => {
+       'path' => '/',
+       tokens => {
+           'test@pam!full' => {
+               'PVEVMAdmin' => 1,
+           },
+       },
+    },
+    acl_complex_tokens => {
+       'path' => '/storage',
+       tokens => {
+           'test2@pam!privsep' => {
+               'PVEDatastoreUser' => 1,
+           },
+           'test2@pam!expired' => {
+               'PVEDatastoreAdmin' => 1,
+           },
+           'test@pam!full' => {
+               'PVEDatastoreAdmin' => 1,
+           },
+       },
+    },
+    acl_complex_missing_token => {
+       'path' => '/storage',
+       tokens => {
+           'test2@pam!expired' => {
+               'PVEDatastoreAdmin' => 1,
+           },
+           'test2@pam!privsep' => {
+               'PVEDatastoreUser' => 1,
+           },
+       },
+    },
+    acl_simple_group => {
+       'path' => '/',
+       groups => {
+           'testgroup' => {
+               'PVEVMAdmin' => 1,
+           },
+       },
+    },
+    acl_complex_groups => {
+       'path' => '/storage',
+       groups => {
+           'testgroup' => {
+               'PVEDatastoreAdmin' => 1,
+           },
+           'another' => {
+               'PVEDatastoreUser' => 1,
+           },
+       },
+    },
+    acl_simple_group_noprop => {
+       'path' => '/',
+       groups => {
+           'testgroup' => {
+               'PVEVMAdmin' => 0,
+           },
+       },
+    },
+    acl_complex_groups_noprop => {
+       'path' => '/storage',
+       groups => {
+           'testgroup' => {
+               'PVEDatastoreAdmin' => 0,
+           },
+           'another' => {
+               'PVEDatastoreUser' => 0,
+           },
+       },
+    },
+    acl_complex_missing_group => {
+       'path' => '/storage',
+       groups => {
+           'testgroup' => {
+               'PVEDatastoreAdmin' => 1,
+           },
+           'another' => {
+               'PVEDatastoreUser' => 1,
+           },
+       },
+    },
+    acl_missing_role => {
+       'path' => '/storage',
+       users => {
+           'test@pam' => {
+               'MissingRole' => 1,
+           },
+       },
+    },
+};
+
+$default_cfg->{'acl_complex_mixed_root'} = {
+       'path' => '/',
+       users => $default_cfg->{'acl_simple_user'}->{users},
+       groups => $default_cfg->{'acl_simple_group'}->{groups},
+};
+
+$default_cfg->{'acl_complex_mixed_storage'} = {
+       'path' => '/storage',
+       users => $default_cfg->{'acl_complex_users'}->{users},
+       groups => $default_cfg->{'acl_complex_groups'}->{groups},
+};
+
+$default_cfg->{'acl_complex_mixed_root_noprop'} = {
+       'path' => '/',
+       users => $default_cfg->{'acl_simple_user'}->{users},
+       groups => $default_cfg->{'acl_simple_group_noprop'}->{groups},
+};
+
+$default_cfg->{'acl_complex_mixed_storage_noprop'} = {
+       'path' => '/storage',
+       users => $default_cfg->{'acl_complex_users'}->{users},
+       groups => $default_cfg->{'acl_complex_groups_noprop'}->{groups},
+};
+
+my $default_raw = {
+    users => {
+       'root@pam' => 'user:root@pam:1:0::::::',
+       'test_pam' => 'user:test@pam:1:0::::::',
+       'test2_pam' => 'user:test2@pam:1:0::::::',
+       'test3_pam' => 'user:test3@pam:1:0::::::',
+    },
+    groups => {
+       'test_group_empty' => 'group:testgroup:::',
+       'test_group_single_member' => 'group:testgroup:test@pam::',
+       'test_group_members' => 'group:testgroup:test2@pam,test@pam::',
+       'test_group_members_out_of_order' => 'group:testgroup:test@pam,test2@pam::',
+       'test_group_second' => 'group:another:test3@pam::',
+    },
+    tokens => {
+       'test_token_simple' => 'token:test@pam!full:0:0::',
+       'test_token_multi_full' => 'token:test2@pam!full:0:0::',
+       'test_token_multi_privsep' => 'token:test2@pam!privsep:0:1::',
+       'test_token_multi_expired' => 'token:test2@pam!expired:1:0::',
+    },
+    roles => {
+       'test_role_single_priv' => 'role:testrolesingle:VM.Allocate:',
+       'test_role_privs' => 'role:testrole:Datastore.Audit,VM.Allocate:',
+       'test_role_privs_out_of_order' => 'role:testrole:VM.Allocate,Datastore.Audit:',
+       'test_role_privs_duplicate' => 'role:testrole:VM.Allocate,Datastore.Audit,VM.Allocate:',
+       'test_role_privs_invalid' => 'role:testrole:VM.Invalid,Datastore.Audit,VM.Allocate:',
+    },
+    pools => {
+       'test_pool_empty' => 'pool:testpool::::',
+       'test_pool_invalid' => 'pool:testpool::non-numeric:inval!d:',
+       'test_pool_members' => 'pool:testpool::123,1234:local,local-zfs:',
+       'test_pool_duplicate_vms' => 'pool:test_duplicate_vms::123,1234::',
+       'test_pool_duplicate_vms_expected' => 'pool:test_duplicate_vms::::',
+       'test_pool_duplicate_storages' => 'pool:test_duplicate_storages:::local,local-zfs:',
+    },
+    acl => {
+       'acl_simple_user' => 'acl:1:/:test@pam:PVEVMAdmin:',
+       'acl_complex_users_1' => 'acl:1:/storage:test@pam:PVEDatastoreAdmin:',
+       'acl_complex_users_2' => 'acl:1:/storage:test2@pam:PVEDatastoreUser:',
+       'acl_simple_token' => 'acl:1:/:test@pam!full:PVEVMAdmin:',
+       'acl_complex_tokens_1' => 'acl:1:/storage:test2@pam!expired,test@pam!full:PVEDatastoreAdmin:',
+       'acl_complex_tokens_2' => 'acl:1:/storage:test2@pam!privsep:PVEDatastoreUser:',
+       'acl_complex_tokens_1_missing' => 'acl:1:/storage:test2@pam!expired:PVEDatastoreAdmin:',
+       'acl_simple_group' => 'acl:1:/:@testgroup:PVEVMAdmin:',
+       'acl_complex_groups_1' => 'acl:1:/storage:@testgroup:PVEDatastoreAdmin:',
+       'acl_complex_groups_2' => 'acl:1:/storage:@another:PVEDatastoreUser:',
+       'acl_simple_group_noprop' => 'acl:0:/:@testgroup:PVEVMAdmin:',
+       'acl_complex_groups_1_noprop' => 'acl:0:/storage:@testgroup:PVEDatastoreAdmin:',
+       'acl_complex_groups_2_noprop' => 'acl:0:/storage:@another:PVEDatastoreUser:',
+       'acl_complex_mixed_1' => 'acl:1:/:@testgroup,test@pam:PVEVMAdmin:',
+       'acl_complex_mixed_2' => 'acl:1:/storage:@testgroup,test@pam:PVEDatastoreAdmin:',
+       'acl_complex_mixed_3' => 'acl:1:/storage:@another,test2@pam:PVEDatastoreUser:',
+       'acl_missing_role' => 'acl:1:/storage:test@pam:MissingRole:',
+    },
+};
+
+my $tests = [
+    {
+       name => "empty_config",
+       config => {},
+       expected_config => {
+           users => { 'root@pam' => { enable => 1 } },
+           roles => default_roles(),
+       },
+       raw => "",
+       expected_raw => "\n\n\n\n",
+    },
+    {
+       name => "default_config",
+       config => {
+           users => default_users(),
+           roles => default_roles(),
+       },
+       raw => $default_raw->{users}->{'root@pam'}."\n\n\n\n\n",
+    },
+    {
+       name => "group_empty",
+       config => {
+           users => default_users(),
+           roles => default_roles(),
+           groups => default_groups_with([$default_cfg->{'test_group_empty'}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n\n".
+              $default_raw->{groups}->{'test_group_empty'}."\n\n".
+              "\n\n",
+    },
+    {
+       name => "group_inexisting_member",
+       config => {
+           users => default_users(),
+           roles => default_roles(),
+           groups => default_groups_with([$default_cfg->{'test_group_empty'}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n\n".
+              "group:testgroup:does_not_exist::".
+              "\n\n\n\n",
+       expected_raw => "".
+              $default_raw->{users}->{'root@pam'}."\n\n".
+              $default_raw->{groups}->{'test_group_empty'}."\n\n".
+              "\n\n",
+    },
+    {
+       name => "group_invalid_member",
+       expected_config => {
+           users => default_users(),
+           roles => default_roles(),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n\n".
+              'group:inval!d:root@pam:'.
+              "\n\n",
+    },
+    {
+       name => "group_with_one_member",
+       config => {
+           users => default_users_with([$default_cfg->{test_pam_with_group}]),
+           roles => default_roles(),
+           groups => default_groups_with([$default_cfg->{'test_group_single_member'}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n\n".
+              $default_raw->{groups}->{'test_group_single_member'}."\n\n".
+              "\n\n",
+    },
+    {
+       name => "group_with_members",
+       config => {
+           users => default_users_with([$default_cfg->{test_pam_with_group}, $default_cfg->{test2_pam_with_group}]),
+           roles => default_roles(),
+           groups => default_groups_with([$default_cfg->{'test_group_members'}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test2_pam'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n\n".
+              $default_raw->{groups}->{'test_group_members'}."\n\n".
+              "\n\n",
+    },
+    {
+       name => "token_simple",
+       config => {
+           users => default_users_with([$default_cfg->{test_pam_with_token}]),
+           roles => default_roles(),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n".
+              $default_raw->{tokens}->{'test_token_simple'}."\n\n\n\n\n",
+    },
+    {
+       name => "token_multi",
+       config => {
+           users => default_users_with([$default_cfg->{test_pam_with_token}, $default_cfg->{test_pam2_with_token}]),
+           roles => default_roles(),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test2_pam'}."\n".
+              $default_raw->{tokens}->{'test_token_multi_expired'}."\n".
+              $default_raw->{tokens}->{'test_token_multi_full'}."\n".
+              $default_raw->{tokens}->{'test_token_multi_privsep'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n".
+              $default_raw->{tokens}->{'test_token_simple'}."\n".
+              "\n\n\n\n",
+    },
+    {
+       name => "custom_role_with_single_priv",
+       config => {
+           users => default_users(),
+           roles => default_roles_with([$default_cfg->{test_role_single_priv}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n\n\n\n".
+              $default_raw->{roles}->{'test_role_single_priv'}."\n\n",
+    },
+    {
+       name => "custom_role_with_privs",
+       config => {
+           users => default_users(),
+           roles => default_roles_with([$default_cfg->{test_role_privs}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n\n\n\n".
+              $default_raw->{roles}->{'test_role_privs'}."\n\n",
+    },
+    {
+       name => "custom_role_with_duplicate_privs",
+       config => {
+           users => default_users(),
+           roles => default_roles_with([$default_cfg->{test_role_privs}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n\n\n\n".
+              $default_raw->{roles}->{'test_role_privs_duplicate'}."\n\n",
+       expected_raw => "".
+              $default_raw->{users}->{'root@pam'}."\n\n\n\n".
+              $default_raw->{roles}->{'test_role_privs'}."\n\n",
+    },
+    {
+       name => "custom_role_with_invalid_priv",
+       config => {
+           users => default_users(),
+           roles => default_roles_with([$default_cfg->{test_role_privs}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n\n\n\n".
+              $default_raw->{roles}->{'test_role_privs_invalid'}."\n\n",
+       expected_raw => "".
+              $default_raw->{users}->{'root@pam'}."\n\n\n\n".
+              $default_raw->{roles}->{'test_role_privs'}."\n\n",
+    },
+    {
+       name => "pool_empty",
+       config => {
+           users => default_users(),
+           roles => default_roles(),
+           pools => default_pools_with([$default_cfg->{test_pool_empty}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n\n\n".
+              $default_raw->{pools}->{'test_pool_empty'}."\n\n\n",
+    },
+    {
+       name => "pool_invalid",
+       config => {
+           users => default_users(),
+           roles => default_roles(),
+           pools => default_pools_with([$default_cfg->{test_pool_empty}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n\n\n".
+              $default_raw->{pools}->{'test_pool_invalid'}."\n\n\n",
+       expected_raw => "".
+              $default_raw->{users}->{'root@pam'}."\n\n\n".
+              $default_raw->{pools}->{'test_pool_empty'}."\n\n\n",
+    },
+    {
+       name => "pool_members",
+       config => {
+           users => default_users(),
+           roles => default_roles(),
+           pools => default_pools_with([$default_cfg->{test_pool_members}]),
+           vms => default_pool_vms_with([$default_cfg->{test_pool_members}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n\n\n".
+              $default_raw->{pools}->{'test_pool_members'}."\n\n\n",
+    },
+    {
+       name => "pool_duplicate_members",
+       config => {
+           users => default_users(),
+           roles => default_roles(),
+           pools => default_pools_with([$default_cfg->{test_pool_members}, $default_cfg->{test_pool_duplicate_vms}, $default_cfg->{test_pool_duplicate_storages}]),
+           vms => default_pool_vms_with([$default_cfg->{test_pool_members}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n\n\n".
+              $default_raw->{pools}->{'test_pool_members'}."\n".
+              $default_raw->{pools}->{'test_pool_duplicate_vms'}."\n".
+              $default_raw->{pools}->{'test_pool_duplicate_storages'}."\n",
+       expected_raw => "".
+              $default_raw->{users}->{'root@pam'}."\n\n\n".
+              $default_raw->{pools}->{'test_pool_duplicate_storages'}."\n".
+              $default_raw->{pools}->{'test_pool_duplicate_vms_expected'}."\n".
+              $default_raw->{pools}->{'test_pool_members'}."\n\n\n",
+    },
+    {
+       name => "acl_simple_user",
+       config => {
+           users => default_users_with([$default_cfg->{test_pam}]),
+           roles => default_roles(),
+           acl => default_acls_with([$default_cfg->{acl_simple_user}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n\n\n\n\n".
+              $default_raw->{acl}->{'acl_simple_user'}."\n",
+    },
+    {
+       name => "acl_complex_users",
+       config => {
+           users => default_users_with([$default_cfg->{test_pam}, $default_cfg->{'test2_pam'}]),
+           roles => default_roles(),
+           acl => default_acls_with([$default_cfg->{acl_simple_user}, $default_cfg->{acl_complex_users}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test2_pam'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n\n\n\n\n".
+              $default_raw->{acl}->{'acl_simple_user'}."\n".
+              $default_raw->{acl}->{'acl_complex_users_1'}."\n".
+              $default_raw->{acl}->{'acl_complex_users_2'}."\n",
+    },
+    {
+       name => "acl_complex_missing_user",
+       config => {
+           users => default_users_with([$default_cfg->{test2_pam}]),
+           roles => default_roles(),
+           acl => default_acls_with([$default_cfg->{acl_simple_user}, $default_cfg->{acl_complex_missing_user}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test2_pam'}."\n\n\n\n\n".
+              $default_raw->{acl}->{'acl_simple_user'}."\n".
+              $default_raw->{acl}->{'acl_complex_users_1'}."\n".
+              $default_raw->{acl}->{'acl_complex_users_2'}."\n",
+    },
+    {
+       name => "acl_simple_group",
+       config => {
+           users => default_users_with([$default_cfg->{test_pam_with_group}]),
+           groups => default_groups_with([$default_cfg->{'test_group_single_member'}]),
+           roles => default_roles(),
+           acl => default_acls_with([$default_cfg->{acl_simple_group}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n\n".
+              $default_raw->{groups}->{'test_group_single_member'}."\n\n\n\n".
+              $default_raw->{acl}->{'acl_simple_group'}."\n",
+    },
+    {
+       name => "acl_complex_groups",
+       config => {
+           users => default_users_with([$default_cfg->{test_pam_with_group}, $default_cfg->{'test2_pam_with_group'}, $default_cfg->{'test3_pam'}]),
+           groups => default_groups_with([$default_cfg->{'test_group_members'}, $default_cfg->{'test_group_second'}]),
+           roles => default_roles(),
+           acl => default_acls_with([$default_cfg->{acl_simple_group}, $default_cfg->{acl_complex_groups}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test2_pam'}."\n".
+              $default_raw->{users}->{'test3_pam'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n\n".
+              $default_raw->{groups}->{'test_group_second'}."\n".
+              $default_raw->{groups}->{'test_group_members'}."\n\n\n\n".
+              $default_raw->{acl}->{'acl_simple_group'}."\n".
+              $default_raw->{acl}->{'acl_complex_groups_1'}."\n".
+              $default_raw->{acl}->{'acl_complex_groups_2'}."\n",
+    },
+    {
+       name => "acl_complex_missing_group",
+       config => {
+           users => default_users_with([$default_cfg->{test_pam}, $default_cfg->{'test2_pam'}, $default_cfg->{'test3_pam'}]),
+           groups => default_groups_with([$default_cfg->{'test_group_second'}]),
+           roles => default_roles(),
+           acl => default_acls_with([$default_cfg->{acl_simple_group}, $default_cfg->{acl_complex_missing_group}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test2_pam'}."\n".
+              $default_raw->{users}->{'test3_pam'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n\n".
+              $default_raw->{groups}->{'test_group_second'}."\n".
+              $default_raw->{acl}->{'acl_simple_group'}."\n".
+              $default_raw->{acl}->{'acl_complex_groups_1'}."\n".
+              $default_raw->{acl}->{'acl_complex_groups_2'}."\n",
+       expected_raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test2_pam'}."\n".
+              $default_raw->{users}->{'test3_pam'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n\n".
+              $default_raw->{groups}->{'test_group_second'}."\n\n\n\n".
+              $default_raw->{acl}->{'acl_simple_group'}."\n".
+              $default_raw->{acl}->{'acl_complex_groups_1'}."\n".
+              $default_raw->{acl}->{'acl_complex_groups_2'}."\n",
+    },
+    {
+       name => "acl_simple_token",
+       config => {
+           users => default_users_with([$default_cfg->{test_pam_with_token}]),
+           roles => default_roles(),
+           acl => default_acls_with([$default_cfg->{acl_simple_token}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n".
+              $default_raw->{tokens}->{'test_token_simple'}."\n\n\n\n\n".
+              $default_raw->{acl}->{'acl_simple_token'}."\n",
+    },
+    {
+       name => "acl_complex_tokens",
+       config => {
+           users => default_users_with([$default_cfg->{test_pam_with_token}, $default_cfg->{'test_pam2_with_token'}]),
+           roles => default_roles(),
+           acl => default_acls_with([$default_cfg->{acl_simple_token}, $default_cfg->{acl_complex_tokens}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test2_pam'}."\n".
+              $default_raw->{tokens}->{'test_token_multi_expired'}."\n".
+              $default_raw->{tokens}->{'test_token_multi_full'}."\n".
+              $default_raw->{tokens}->{'test_token_multi_privsep'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n".
+              $default_raw->{tokens}->{'test_token_simple'}."\n\n\n\n\n".
+              $default_raw->{acl}->{'acl_simple_token'}."\n".
+              $default_raw->{acl}->{'acl_complex_tokens_1'}."\n".
+              $default_raw->{acl}->{'acl_complex_tokens_2'}."\n",
+    },
+    {
+       name => "acl_complex_missing_token",
+       config => {
+           users => default_users_with([$default_cfg->{test_pam}, $default_cfg->{test_pam2_with_token}]),
+           roles => default_roles(),
+           acl => default_acls_with([$default_cfg->{acl_complex_missing_token}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test2_pam'}."\n".
+              $default_raw->{tokens}->{'test_token_multi_expired'}."\n".
+              $default_raw->{tokens}->{'test_token_multi_full'}."\n".
+              $default_raw->{tokens}->{'test_token_multi_privsep'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n".
+              $default_raw->{acl}->{'acl_simple_token'}."\n".
+              $default_raw->{acl}->{'acl_complex_tokens_1'}."\n".
+              $default_raw->{acl}->{'acl_complex_tokens_2'}."\n",
+       expected_raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test2_pam'}."\n".
+              $default_raw->{tokens}->{'test_token_multi_expired'}."\n".
+              $default_raw->{tokens}->{'test_token_multi_full'}."\n".
+              $default_raw->{tokens}->{'test_token_multi_privsep'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n\n\n\n\n".
+              $default_raw->{acl}->{'acl_complex_tokens_1_missing'}."\n".
+              $default_raw->{acl}->{'acl_complex_tokens_2'}."\n",
+    },
+    {
+       name => "acl_missing_role",
+       config => {
+           users => default_users_with([$default_cfg->{test_pam}]),
+           roles => default_roles(),
+           acl => default_acls_with([$default_cfg->{acl_simple_user}]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n\n\n\n\n".
+              $default_raw->{acl}->{'acl_simple_user'}."\n".
+              $default_raw->{acl}->{'acl_missing_role'}."\n",
+       expected_raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n\n\n\n\n".
+              $default_raw->{acl}->{'acl_simple_user'}."\n",
+    },
+    {
+       name => "acl_complex_mixed",
+       config => {
+           users => default_users_with([$default_cfg->{test_pam_with_group}, $default_cfg->{'test2_pam_with_group'}, $default_cfg->{'test3_pam'}]),
+           groups => default_groups_with([$default_cfg->{'test_group_members'}, $default_cfg->{'test_group_second'}]),
+           roles => default_roles(),
+           acl => default_acls_with([
+               $default_cfg->{acl_complex_mixed_root},
+               $default_cfg->{acl_complex_mixed_storage},
+           ]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test2_pam'}."\n".
+              $default_raw->{users}->{'test3_pam'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n\n".
+              $default_raw->{groups}->{'test_group_second'}."\n".
+              $default_raw->{groups}->{'test_group_members'}."\n\n\n\n".
+              $default_raw->{acl}->{'acl_simple_group'}."\n".
+              $default_raw->{acl}->{'acl_complex_groups_1'}."\n".
+              $default_raw->{acl}->{'acl_complex_groups_2'}."\n".
+              $default_raw->{acl}->{'acl_simple_user'}."\n".
+              $default_raw->{acl}->{'acl_complex_users_1'}."\n".
+              $default_raw->{acl}->{'acl_complex_users_2'}."\n",
+       expected_raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test2_pam'}."\n".
+              $default_raw->{users}->{'test3_pam'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n\n".
+              $default_raw->{groups}->{'test_group_second'}."\n".
+              $default_raw->{groups}->{'test_group_members'}."\n\n\n\n".
+              $default_raw->{acl}->{'acl_complex_mixed_1'}."\n".
+              $default_raw->{acl}->{'acl_complex_mixed_2'}."\n".
+              $default_raw->{acl}->{'acl_complex_mixed_3'}."\n",
+    },
+    {
+       name => "acl_complex_mixed_prop_noprop_no_merge_sort_by_path",
+       config => {
+           users => default_users_with([$default_cfg->{test_pam_with_group}, $default_cfg->{'test2_pam_with_group'}, $default_cfg->{'test3_pam'}]),
+           groups => default_groups_with([$default_cfg->{'test_group_members'}, $default_cfg->{'test_group_second'}]),
+           roles => default_roles(),
+           acl => default_acls_with([
+               $default_cfg->{acl_complex_mixed_root_noprop},
+               $default_cfg->{acl_complex_mixed_storage_noprop},
+           ]),
+       },
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test2_pam'}."\n".
+              $default_raw->{users}->{'test3_pam'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n\n".
+              $default_raw->{groups}->{'test_group_second'}."\n".
+              $default_raw->{groups}->{'test_group_members'}."\n\n\n\n".
+              $default_raw->{acl}->{'acl_simple_group_noprop'}."\n".
+              $default_raw->{acl}->{'acl_simple_user'}."\n".
+              $default_raw->{acl}->{'acl_complex_groups_1_noprop'}."\n".
+              $default_raw->{acl}->{'acl_complex_groups_2_noprop'}."\n".
+              $default_raw->{acl}->{'acl_complex_users_1'}."\n".
+              $default_raw->{acl}->{'acl_complex_users_2'}."\n",
+    },
+    {
+       name => "sort_roles_and_privs",
+       raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{roles}->{'test_role_single_priv'}."\n\n".
+              $default_raw->{roles}->{'test_role_privs_out_of_order'}."\n\n",
+       expected_raw => "".
+              $default_raw->{users}->{'root@pam'}."\n\n\n\n".
+              $default_raw->{roles}->{'test_role_privs'}."\n".
+              $default_raw->{roles}->{'test_role_single_priv'}."\n\n",
+    },
+    {
+       name => "sort_users_and_group_members",
+       raw => "".
+              $default_raw->{users}->{'test2_pam'}."\n".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n\n".
+              $default_raw->{groups}->{'test_group_members_out_of_order'}."\n\n".
+           "\n\n",
+       expected_raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test2_pam'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n\n".
+              $default_raw->{groups}->{'test_group_members'}."\n\n".
+           "\n\n",
+    },
+    {
+       name => "sort_user_groups_and_acls",
+       raw => "".
+              $default_raw->{users}->{'test2_pam'}."\n".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n\n".
+              $default_raw->{users}->{'test3_pam'}."\n".
+              $default_raw->{groups}->{'test_group_members_out_of_order'}."\n\n\n\n".
+              $default_raw->{groups}->{'test_group_second'}."\n".
+              $default_raw->{acl}->{'acl_simple_user'}."\n".
+              $default_raw->{acl}->{'acl_simple_group'}."\n".
+              $default_raw->{acl}->{'acl_complex_users_1'}."\n".
+              $default_raw->{acl}->{'acl_complex_users_2'}."\n".
+              $default_raw->{acl}->{'acl_complex_groups_1'}."\n".
+              $default_raw->{acl}->{'acl_complex_groups_2'}."\n",
+       expected_raw => "".
+              $default_raw->{users}->{'root@pam'}."\n".
+              $default_raw->{users}->{'test2_pam'}."\n".
+              $default_raw->{users}->{'test3_pam'}."\n".
+              $default_raw->{users}->{'test_pam'}."\n\n".
+              $default_raw->{groups}->{'test_group_second'}."\n".
+              $default_raw->{groups}->{'test_group_members'}."\n\n\n\n".
+              $default_raw->{acl}->{'acl_complex_mixed_1'}."\n".
+              $default_raw->{acl}->{'acl_complex_mixed_2'}."\n".
+              $default_raw->{acl}->{'acl_complex_mixed_3'}."\n",
+    },
+    {
+       name => 'default_values',
+       config => {
+           users => {
+               'root@pam' => {
+                   enable => 0,
+                   expire => 0,
+                   email => undef,
+               },
+               'test@pam' => {
+                   enable => 0,
+                   expire => 0,
+                   email => undef,
+                   tokens => {
+                       'test' => {
+                           expire => 0,
+                           privsep => 0,
+                       },
+                   },
+               },
+           },
+           roles => default_roles_with([{ id => 'testrole' }]),
+           groups => default_groups_with([$default_cfg->{test_group_empty}]),
+           pools => default_pools_with([$default_cfg->{test_pool_empty}]),
+       },
+       raw => "".
+              'user:root@pam'."\n".
+              'user:test@pam'."\n".
+              'token:test@pam!test'."\n\n".
+              'group:testgroup'."\n\n".
+              'pool:testpool'."\n\n".
+              'role:testrole'."\n\n".
+              'acl::/:',
+       expected_raw => "".
+              'user:root@pam:0:0::::::'."\n".
+              'user:test@pam:0:0::::::'."\n".
+              'token:test@pam!test:0:0::'."\n\n".
+              'group:testgroup:::'."\n\n".
+              'pool:testpool::::'."\n\n".
+              'role:testrole::'."\n\n",
+    },
+];
+
+
+my $number_of_tests_run = 0;
+foreach my $t (@$tests) {
+    my $expected_config = $t->{expected_config} // $t->{config};
+    my $expected_raw = $t->{expected_raw} // $t->{raw};
+    if (defined($t->{raw})) {
+       my $parsed = PVE::AccessControl::parse_user_config($t->{name}, $t->{raw});
+       if (defined($expected_config)) {
+           is_deeply($parsed, $expected_config, "$t->{name}_parse");
+           $number_of_tests_run++;
+       }
+       if (defined($t->{expected_raw}) && !defined($t->{config})) {
+           is(PVE::AccessControl::write_user_config($t->{name}, $parsed), $t->{expected_raw}, "$t->{name}_rewrite");
+           $number_of_tests_run++;
+       }
+
+    }
+    if (defined($t->{config})) {
+       my $written = PVE::AccessControl::write_user_config($t->{name}, $t->{config});
+       if (defined($expected_raw)) {
+           is($written, $expected_raw, "$t->{name}_write");
+           $number_of_tests_run++;
+       }
+       if (defined($t->{expected_config}) && !defined($t->{raw})) {
+           is_deeply(PVE::AccessControl::parse_user_config($t->{name}, $t->{written}), $t->{expected_config}, "$t->{name}_reparse");
+           $number_of_tests_run++;
+       }
+    }
+};
+
+done_testing( $number_of_tests_run);
diff --git a/src/test/perm-test1.pl b/src/test/perm-test1.pl
new file mode 100755 (executable)
index 0000000..12c95db
--- /dev/null
@@ -0,0 +1,64 @@
+#!/usr/bin/perl -w
+
+use strict;
+use PVE::Tools;
+use PVE::AccessControl;
+use PVE::RPCEnvironment;
+use Getopt::Long;
+
+my $rpcenv = PVE::RPCEnvironment->init('cli');
+
+my $cfgfn = "test1.cfg";
+$rpcenv->init_request(userconfig => $cfgfn);
+
+sub check_roles {
+    my ($user, $path, $expected_result) = @_;
+
+    my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path);
+    my $res = join(',', sort keys %$roles);
+
+    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    print "ROLES:$path:$user:$res\n";
+}
+
+sub check_permission {
+    my ($user, $path, $expected_result) = @_;
+
+    my $perm = $rpcenv->permissions($user, $path);
+    my $res = join(',', sort keys %$perm);
+
+    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    $perm = $rpcenv->permissions($user, $path);
+    $res = join(',', sort keys %$perm);
+    die "unexpected result (compiled)\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    print "PERM:$path:$user:$res\n";
+}
+
+check_roles('max@pve', '/', '');
+check_roles('max@pve', '/vms', 'vm_admin');
+
+#user permissions overrides group permissions
+check_roles('max@pve', '/vms/100', 'customer');
+check_roles('max@pve', '/vms/101', 'vm_admin');
+
+check_permission('max@pve', '/', '');
+check_permission('max@pve', '/vms', 'Permissions.Modify,VM.Allocate,VM.Audit,VM.Console');
+check_permission('max@pve', '/vms/100', 'VM.Audit,VM.PowerMgmt');
+
+check_permission('alex@pve', '/vms', '');
+check_permission('alex@pve', '/vms/100', 'VM.Audit,VM.PowerMgmt');
+
+
+check_roles('max@pve', '/vms/200', 'storage_manager');
+check_roles('joe@pve', '/vms/200', 'vm_admin');
+check_roles('sue@pve', '/vms/200', 'NoAccess');
+
+print "all tests passed\n";
+
+exit (0);
diff --git a/src/test/perm-test2.pl b/src/test/perm-test2.pl
new file mode 100755 (executable)
index 0000000..1317051
--- /dev/null
@@ -0,0 +1,39 @@
+#!/usr/bin/perl -w
+
+use strict;
+use PVE::Tools;
+use PVE::AccessControl;
+use PVE::RPCEnvironment;
+use Getopt::Long;
+
+my $rpcenv = PVE::RPCEnvironment->init('cli');
+
+my $cfgfn = "test2.cfg";
+$rpcenv->init_request(userconfig => $cfgfn);
+
+sub check_roles {
+    my ($user, $path, $expected_result) = @_;
+
+    my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path);
+    my $res = join(',', sort keys %$roles);
+
+    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    print "ROLES:$path:$user:$res\n";
+}
+
+# inherit multiple group permissions
+
+check_roles('User1@pve', '/', '');
+check_roles('User2@pve', '/', '');
+
+check_roles('User1@pve', '/vms', 'Role1,Role2');
+check_roles('User2@pve', '/vms', '');
+
+check_roles('User1@pve', '/vms/100', 'Role1,Role2');
+check_roles('User2@pve', '/vms', '');
+
+print "all tests passed\n";
+
+exit (0);
diff --git a/src/test/perm-test3.pl b/src/test/perm-test3.pl
new file mode 100755 (executable)
index 0000000..b7b5480
--- /dev/null
@@ -0,0 +1,34 @@
+#!/usr/bin/perl -w
+
+use strict;
+use PVE::Tools;
+use PVE::AccessControl;
+use PVE::RPCEnvironment;
+use Getopt::Long;
+
+my $rpcenv = PVE::RPCEnvironment->init('cli');
+
+my $cfgfn = "test3.cfg";
+$rpcenv->init_request(userconfig => $cfgfn);
+
+sub check_roles {
+    my ($user, $path, $expected_result) = @_;
+
+    my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path);
+    my $res = join(',', sort keys %$roles);
+
+    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    print "ROLES:$path:$user:$res\n";
+}
+
+check_roles('User1@pve', '', '');
+check_roles('User2@pve', '', '');
+
+check_roles('User1@pve', '/vms/300', 'Role1');
+check_roles('User1@pve', '/vms/200', 'Role2');
+
+print "all tests passed\n";
+
+exit (0);
diff --git a/src/test/perm-test4.pl b/src/test/perm-test4.pl
new file mode 100755 (executable)
index 0000000..718963e
--- /dev/null
@@ -0,0 +1,32 @@
+#!/usr/bin/perl -w
+
+use strict;
+use PVE::Tools;
+use PVE::AccessControl;
+use PVE::RPCEnvironment;
+use Getopt::Long;
+
+my $rpcenv = PVE::RPCEnvironment->init('cli');
+
+my $cfgfn = "test4.cfg";
+$rpcenv->init_request(userconfig => $cfgfn);
+
+sub check_roles {
+    my ($user, $path, $expected_result) = @_;
+
+    my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path);
+    my $res = join(',', sort keys %$roles);
+
+    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    print "ROLES:$path:$user:$res\n";
+}
+
+
+check_roles('User1@pve', '/vms/300', 'Role1');
+check_roles('User2@pve', '/vms/300', 'NoAccess');
+
+print "all tests passed\n";
+
+exit (0);
diff --git a/src/test/perm-test5.pl b/src/test/perm-test5.pl
new file mode 100755 (executable)
index 0000000..ebb40e3
--- /dev/null
@@ -0,0 +1,42 @@
+#!/usr/bin/perl -w
+
+use strict;
+use PVE::Tools;
+use PVE::AccessControl;
+use PVE::RPCEnvironment;
+use Getopt::Long;
+
+my $rpcenv = PVE::RPCEnvironment->init('cli');
+
+my $cfgfn = "test5.cfg";
+$rpcenv->init_request(userconfig => $cfgfn);
+
+sub check_roles {
+    my ($user, $path, $expected_result) = @_;
+
+    my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path);
+    my $res = join(',', sort keys %$roles);
+
+    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    print "ROLES:$path:$user:$res\n";
+}
+
+
+check_roles('User1@pve', '/vms', 'Role1');
+check_roles('User1@pve', '/vms/100', 'Role1');
+check_roles('User1@pve', '/vms/100/a', 'Role1');
+check_roles('User1@pve', '/vms/100/a/b', 'Role2');
+check_roles('User1@pve', '/vms/100/a/b/c', 'Role2');
+check_roles('User1@pve', '/vms/200', 'Role1');
+
+check_roles('User2@pve', '/kvm', 'Role2');
+check_roles('User2@pve', '/kvm/vms', 'Role1');
+check_roles('User2@pve', '/kvm/vms/100', '');
+check_roles('User2@pve', '/kvm/vms/100/a', 'Role3');
+check_roles('User2@pve', '/kvm/vms/100/a/b', '');
+
+print "all tests passed\n";
+
+exit (0);
diff --git a/src/test/perm-test6.pl b/src/test/perm-test6.pl
new file mode 100755 (executable)
index 0000000..dd433dd
--- /dev/null
@@ -0,0 +1,92 @@
+#!/usr/bin/perl -w
+
+use strict;
+use PVE::Tools;
+use PVE::AccessControl;
+use PVE::RPCEnvironment;
+use Getopt::Long;
+
+my $rpcenv = PVE::RPCEnvironment->init('cli');
+
+my $cfgfn = "test6.cfg";
+$rpcenv->init_request(userconfig => $cfgfn);
+
+sub check_roles {
+    my ($user, $path, $expected_result) = @_;
+
+    my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path);
+    my $res = join(',', sort keys %$roles);
+
+    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    print "ROLES:$path:$user:$res\n";
+}
+
+sub check_permissions {
+    my ($user, $path, $expected_result) = @_;
+
+    my $perm = $rpcenv->permissions($user, $path);
+    my $res = join(',', sort keys %$perm);
+
+    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    $perm = $rpcenv->permissions($user, $path);
+    $res = join(',', sort keys %$perm);
+    die "unexpected result (compiled)\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    print "PERM:$path:$user:$res\n";
+}
+
+check_roles('User1@pve', '', '');
+check_roles('User2@pve', '', '');
+check_roles('User3@pve', '', '');
+check_roles('User4@pve', '', '');
+
+check_roles('User1@pve', '/vms', 'RoleTEST1');
+check_roles('User2@pve', '/vms', 'RoleTEST1');
+check_roles('User3@pve', '/vms', 'NoAccess');
+check_roles('User4@pve', '/vms', '');
+
+check_roles('User1@pve', '/vms/100', 'RoleTEST1');
+check_roles('User2@pve', '/vms/100', 'RoleTEST1');
+check_roles('User3@pve', '/vms/100', 'NoAccess');
+check_roles('User4@pve', '/vms/100', '');
+
+check_roles('User1@pve', '/vms/300', 'RoleTEST1');
+check_roles('User2@pve', '/vms/300', 'RoleTEST1');
+check_roles('User3@pve', '/vms/300', 'NoAccess');
+check_roles('User4@pve', '/vms/300', 'RoleTEST1');
+
+check_permissions('User1@pve', '/vms/500', 'VM.Console,VM.PowerMgmt');
+check_permissions('User2@pve', '/vms/500', 'VM.Console,VM.PowerMgmt');
+# without pool
+check_roles('User3@pve', '/vms/500', 'NoAccess');
+# with pool
+check_permissions('User3@pve', '/vms/500', '');
+# without pool
+check_roles('User4@pve', '/vms/500', '');
+# with pool
+check_permissions('User4@pve', '/vms/500', '');
+
+
+check_permissions('User1@pve', '/vms/600', 'VM.Console');
+check_permissions('User2@pve', '/vms/600', 'VM.Console');
+check_permissions('User3@pve', '/vms/600', '');
+check_permissions('User4@pve', '/vms/600', 'VM.Console');
+
+check_permissions('User1@pve', '/storage/store1', 'VM.Console,VM.PowerMgmt');
+check_permissions('User2@pve', '/storage/store1', 'VM.PowerMgmt');
+check_permissions('User3@pve', '/storage/store1', 'VM.PowerMgmt');
+check_permissions('User4@pve', '/storage/store1', 'VM.Console');
+
+check_permissions('User1@pve', '/storage/store2', 'VM.PowerMgmt');
+check_permissions('User2@pve', '/storage/store2', 'VM.PowerMgmt');
+check_permissions('User3@pve', '/storage/store2', 'VM.PowerMgmt');
+check_permissions('User4@pve', '/storage/store2', '');
+
+print "all tests passed\n";
+
+exit (0);
diff --git a/src/test/perm-test7.pl b/src/test/perm-test7.pl
new file mode 100755 (executable)
index 0000000..57ece07
--- /dev/null
@@ -0,0 +1,53 @@
+#!/usr/bin/perl -w
+
+use strict;
+use PVE::Tools;
+use PVE::AccessControl;
+use PVE::RPCEnvironment;
+use Getopt::Long;
+
+my $rpcenv = PVE::RPCEnvironment->init('cli');
+
+my $cfgfn = "test7.cfg";
+$rpcenv->init_request(userconfig => $cfgfn);
+
+sub check_roles {
+    my ($user, $path, $expected_result) = @_;
+
+    my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path);
+    my $res = join(',', sort keys %$roles);
+
+    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    print "ROLES:$path:$user:$res\n";
+}
+
+sub check_permissions {
+    my ($user, $path, $expected_result) = @_;
+
+    my $perm = $rpcenv->permissions($user, $path);
+    my $res = join(',', sort keys %$perm);
+
+    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    $perm = $rpcenv->permissions($user, $path);
+    $res = join(',', sort keys %$perm);
+    die "unexpected result (compiled)\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    print "PERM:$path:$user:$res\n";
+}
+
+check_roles('User1@pve', '/vms', 'Role1');
+check_roles('User1@pve', '/vms/200', 'Role1');
+
+# no pool
+check_roles('User1@pve', '/vms/100', 'Role1');
+# with pool
+check_permissions('User1@pve', '/vms/100', '');
+
+print "all tests passed\n";
+
+exit (0);
diff --git a/src/test/perm-test8.pl b/src/test/perm-test8.pl
new file mode 100644 (file)
index 0000000..67e0cb1
--- /dev/null
@@ -0,0 +1,68 @@
+#!/usr/bin/perl -w
+
+use strict;
+use PVE::Tools;
+use PVE::AccessControl;
+use PVE::RPCEnvironment;
+
+my $rpcenv = PVE::RPCEnvironment->init('cli');
+
+my $cfgfn = "test8.cfg";
+$rpcenv->init_request(userconfig => $cfgfn);
+
+sub check_roles {
+    my ($user, $path, $expected_result) = @_;
+
+    my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path);
+    my $res = join(',', sort keys %$roles);
+
+    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    print "ROLES:$path:$user:$res\n";
+}
+
+sub check_permission {
+    my ($user, $path, $expected_result) = @_;
+
+    my $perm = $rpcenv->permissions($user, $path);
+    my $res = join(',', sort keys %$perm);
+
+    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    $perm = $rpcenv->permissions($user, $path);
+    $res = join(',', sort keys %$perm);
+    die "unexpected result (compiled)\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    print "PERM:$path:$user:$res\n";
+}
+
+check_roles('max@pve', '/', '');
+check_roles('max@pve', '/vms', 'vm_admin');
+
+#user permissions overrides group permissions
+check_roles('max@pve', '/vms/100', 'customer');
+check_roles('max@pve', '/vms/101', 'vm_admin');
+
+check_permission('max@pve', '/', '');
+check_permission('max@pve', '/vms', 'Permissions.Modify,VM.Allocate,VM.Audit,VM.Console');
+check_permission('max@pve', '/vms/100', 'VM.Audit,VM.PowerMgmt');
+
+check_permission('alex@pve', '/vms', '');
+check_permission('alex@pve', '/vms/100', 'VM.Audit,VM.PowerMgmt');
+
+check_roles('max@pve', '/vms/200', 'storage_manager');
+check_roles('joe@pve', '/vms/200', 'vm_admin');
+check_roles('sue@pve', '/vms/200', 'NoAccess');
+
+check_roles('carol@pam', '/vms/200', 'NoAccess');
+check_roles('carol@pam!token', '/vms/200', 'NoAccess');
+check_roles('max@pve!token', '/vms/200', 'storage_manager');
+check_roles('max@pve!token2', '/vms/200', 'customer');
+
+print "all tests passed\n";
+
+exit (0);
+
diff --git a/src/test/test1.cfg b/src/test/test1.cfg
new file mode 100644 (file)
index 0000000..d27c5d6
--- /dev/null
@@ -0,0 +1,22 @@
+user:joe@pve:1:
+user:max@pve:1:
+user:alex@pve:1:
+user:sue@pve:1:
+user:carol@pam:1:
+
+group:testgroup1:joe@pve,max@pve,sue@pve:
+group:testgroup2:alex@pve,carol@pam,sue@pve:
+group:testgroup3:max@pve:
+
+role:storage_manager:Datastore.AllocateSpace,Datastore.Audit:
+role:customer:VM.Audit,VM.PowerMgmt:
+role:vm_admin:VM.Audit,VM.Allocate,Permissions.Modify,VM.Console:
+
+acl:1:/vms:@testgroup1:vm_admin:
+acl:1:/vms/100/:alex@pve,max@pve:customer:
+acl:1:/storage/nfs1:@testgroup2:storage_manager:
+acl:1:/users:max@pve:Administrator:
+
+acl:1:/vms/200:@testgroup3:storage_manager:
+acl:1:/vms/200:@testgroup2:NoAccess:
+
diff --git a/src/test/test2.cfg b/src/test/test2.cfg
new file mode 100644 (file)
index 0000000..efad292
--- /dev/null
@@ -0,0 +1,11 @@
+user:User1@pve:1:
+user:User2@pve:1:
+
+group:GroupA:User1@pve:
+group:GroupB:User1@pve:
+
+role:Role1:VM.PowerMgmt:
+role:Role2:VM.Console:
+
+acl:1:/vms:@GroupA:Role1:
+acl:1:/vms:@GroupB:Role2:
diff --git a/src/test/test3.cfg b/src/test/test3.cfg
new file mode 100644 (file)
index 0000000..75a9732
--- /dev/null
@@ -0,0 +1,11 @@
+user:User1@pve:1:
+user:User2@pve:1:
+
+group:GroupA:User1@pve:
+group:GroupB:User1@pve:
+
+role:Role1:VM.PowerMgmt:
+role:Role2:VM.Console:
+
+acl:1:/vms:@GroupA:Role1:
+acl:1:/vms/200:@GroupB:Role2:
diff --git a/src/test/test4.cfg b/src/test/test4.cfg
new file mode 100644 (file)
index 0000000..c6daccb
--- /dev/null
@@ -0,0 +1,11 @@
+user:User1@pve:1:
+user:User2@pve:1:
+
+group:GroupA:User1@pve,User2@pve:
+group:GroupB:User1@pve,User2@pve:
+
+role:Role1:VM.PowerMgmt:
+role:Role2:VM.Console:
+
+acl:1:/vms:@GroupA:Role1:
+acl:1:/vms:User2@pve:NoAccess:
diff --git a/src/test/test5.cfg b/src/test/test5.cfg
new file mode 100644 (file)
index 0000000..13948cf
--- /dev/null
@@ -0,0 +1,16 @@
+user:User1@pve:1:
+user:User2@pve:1:
+
+group:GroupA:User1@pve,User2@pve:
+group:GroupB:User1@pve,User2@pve:
+
+role:Role1:VM.PowerMgmt:
+role:Role2:VM.Console:
+role:Role3:VM.Console:
+
+acl:1:/vms:User1@pve:Role1:
+acl:1:/vms/100/a/b:User1@pve:Role2:
+
+acl:0:/kvm:User2@pve:Role2:
+acl:0:/kvm/vms:User2@pve:Role1:
+acl:0:/kvm/vms/100/a:User2@pve:Role3:
diff --git a/src/test/test6.cfg b/src/test/test6.cfg
new file mode 100644 (file)
index 0000000..4986910
--- /dev/null
@@ -0,0 +1,21 @@
+user:User1@pve:1:
+user:User2@pve:1:
+user:User3@pve:1:
+user:User4@pve:1:
+
+group:DEVEL:User1@pve,User2@pve,User3@pve:
+group:MARKETING:User1@pve,User4@pve:
+
+role:RoleDEVEL:VM.PowerMgmt:
+role:RoleMARKETING:VM.Console:
+role:RoleTEST1:VM.Console:
+
+acl:1:/pool/devel:@DEVEL:RoleDEVEL:
+acl:1:/pool/marketing:@MARKETING:RoleMARKETING:
+
+acl:1:/vms:@DEVEL:RoleTEST1:
+acl:1:/vms:User3@pve:NoAccess:
+acl:1:/vms/300:@MARKETING:RoleTEST1:
+
+pool:devel:MITS development:500,501,502:store1 store2:
+pool:marketing:MITS marketing:600:store1:
diff --git a/src/test/test7.cfg b/src/test/test7.cfg
new file mode 100644 (file)
index 0000000..a17d668
--- /dev/null
@@ -0,0 +1,15 @@
+user:User1@pve:1:
+user:User2@pve:1:
+
+group:GroupA:User1@pve,User2@pve:
+group:GroupB:User1@pve,User2@pve:
+
+role:Role1:VM.PowerMgmt:
+role:Role2:VM.Console:
+role:Role3:VM.Console:
+
+acl:1:/pool/devel:User1@pve:NoAccess:
+
+acl:1:/vms:User1@pve:Role1:
+
+pool:devel:Development:100:store1:
diff --git a/src/test/test8.cfg b/src/test/test8.cfg
new file mode 100644 (file)
index 0000000..6b0eac6
--- /dev/null
@@ -0,0 +1,28 @@
+user:joe@pve:1:
+user:max@pve:1:
+token:max@pve!token::0:
+token:max@pve!token2::1:
+user:alex@pve:1:
+user:sue@pve:1:
+user:carol@pam:1:
+token:carol@pam!token:
+
+group:testgroup1:joe@pve,max@pve,sue@pve:
+group:testgroup2:alex@pve,carol@pam,sue@pve:
+group:testgroup3:max@pve:
+
+role:storage_manager:Datastore.AllocateSpace,Datastore.Audit:
+role:customer:VM.Audit,VM.PowerMgmt:
+role:vm_admin:VM.Audit,VM.Allocate,Permissions.Modify,VM.Console:
+
+acl:1:/vms:@testgroup1:vm_admin:
+acl:1:/vms/100/:alex@pve,max@pve:customer:
+acl:1:/storage/nfs1:@testgroup2:storage_manager:
+acl:1:/users:max@pve:Administrator:
+
+acl:1:/vms/200:@testgroup3:storage_manager:
+acl:1:/vms/200:@testgroup2:NoAccess:
+
+acl:1:/vms/200:carol@pam!token:vm_admin
+acl:1:/vms/200:max@pve!token:storage_manager
+acl:1:/vms/200:max@pve!token2:customer
diff --git a/test/Makefile b/test/Makefile
deleted file mode 100644 (file)
index adaacb9..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-
-all:
-
-.PHONY: check
-check:
-       perl -I.. parser_writer.pl
-       perl -I.. perm-test1.pl
-       perl -I.. perm-test2.pl
-       perl -I.. perm-test3.pl
-       perl -I.. perm-test4.pl
-       perl -I.. perm-test5.pl
-       perl -I.. perm-test6.pl
-       perl -I.. perm-test7.pl
-       perl -I.. perm-test8.pl
diff --git a/test/auth-test.pl b/test/auth-test.pl
deleted file mode 100644 (file)
index 60429a9..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-#!/usr/bin/perl -w
-
-use strict;
-use PVE::PTY;
-use PVE::AccessControl;
-
-my $username = shift;
-die "Username missing" if !$username;
-
-my $password = PVE::PTY::read_password('password: ');
-PVE::AccessControl::authenticate_user($username,$password);
-
-print "Authentication Successful!!\n";
-
-exit (0);
diff --git a/test/dump-perm.pl b/test/dump-perm.pl
deleted file mode 100755 (executable)
index cb2a2ee..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-#!/usr/bin/perl -w
-
-use strict;
-use PVE::RPCEnvironment;
-use Getopt::Long;
-use Data::Dumper;
-
-# example: 
-# dump-perm.pl -f myuser.cfg root /
-
-my $opt_file;
-if (!GetOptions ("file=s"   => \$opt_file)) {
-    exit (-1);
-}
-
-my $username = shift;
-my $path = shift;
-if (!($username && $path)) {
-    print "usage: $0 <username> <path>\n";
-    exit (-1);
-}
-
-my $cfg;
-
-my $rpcenv = PVE::RPCEnvironment->init('cli');
-if ($opt_file) {
-    $rpcenv->init_request(userconfig => $opt_file);
-} else {
-    $rpcenv->init_request();
-}
-
-my $perm = $rpcenv->permissions($username, $path);
-
-print "permission for user '$username' on '$path':\n";
-print join(',', keys %$perm) . "\n";
-
-exit (0);
diff --git a/test/dump-users.pl b/test/dump-users.pl
deleted file mode 100755 (executable)
index f08d30b..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-#!/usr/bin/perl -w
-
-use strict;
-use PVE::AccessControl;
-use Data::Dumper;
-
-my $cfg;
-
-$cfg = PVE::AccessControl::load_user_config();
-
-print Dumper($cfg) . "\n";
-
-exit (0);
diff --git a/test/parser_writer.pl b/test/parser_writer.pl
deleted file mode 100755 (executable)
index 2fef7db..0000000
+++ /dev/null
@@ -1,1025 +0,0 @@
-#!/usr/bin/perl -w
-
-use strict;
-
-use Test::More;
-use PVE::AccessControl;
-
-use Storable qw(dclone);
-
-PVE::AccessControl::create_roles();
-my $default_user_cfg = {};
-PVE::AccessControl::userconfig_force_defaults($default_user_cfg);
-
-my $add_default_user_properties = sub {
-    my ($user) = @_;
-
-    $user->{enable} = 1 if !defined($user->{enable});
-    $user->{expire} = 0 if !defined($user->{expire});
-    $user->{email} = undef if !defined($user->{email});
-
-    return $user;
-};
-
-sub default_roles {
-    my $roles = dclone($default_user_cfg->{roles});
-    return $roles;
-}
-
-sub default_roles_with {
-    my ($extra_roles) = @_;
-
-    my $roles = default_roles();
-
-    foreach my $r (@$extra_roles) {
-       my $role = dclone($r);
-       my $roleid = delete $role->{id};
-       $roles->{$roleid} = $role;
-    }
-
-    return $roles;
-}
-
-sub default_users {
-    my $users = dclone($default_user_cfg->{users});
-    return { map { $_ => $add_default_user_properties->($users->{$_}); } keys %$users};
-}
-
-sub default_users_with {
-    my ($extra_users) = @_;
-
-    my $users = default_users();
-
-    foreach my $u (@$extra_users) {
-       my $user = dclone($u);
-       my $userid = delete $user->{id};
-       $users->{$userid} = $add_default_user_properties->($user);
-    }
-
-    return $users;
-}
-
-sub default_groups {
-    return {};
-}
-
-sub default_groups_with {
-    my ($extra_groups) = @_;
-
-    my $groups = default_groups();
-
-    foreach my $g (@$extra_groups) {
-       my $group = dclone($g);
-       my $groupid = delete $group->{id};
-       $groups->{$groupid} = $group;
-    }
-
-    return $groups;
-}
-
-sub default_pools {
-    return {};
-}
-
-sub default_pools_with {
-    my ($extra_pools) = @_;
-
-    my $pools = default_pools();
-
-    foreach my $p (@$extra_pools) {
-       my $pool = dclone($p);
-       my $poolid = delete $pool->{id};
-       $pools->{$poolid} = $pool;
-    }
-
-    return $pools;
-}
-
-sub default_pool_vms_with {
-    my ($extra_pools) = @_;
-
-    my $vms = {};
-    foreach my $pool (@$extra_pools) {
-       foreach my $vmid (keys %{$pool->{vms}}) {
-           $vms->{$vmid} = $pool->{id};
-       }
-    }
-    return $vms;
-}
-
-sub default_acls {
-    return {};
-}
-
-# note: does not support merging paths!
-sub default_acls_with {
-    my ($extra_acls) = @_;
-
-    my $acls = default_acls();
-
-    foreach my $a (@$extra_acls) {
-       my $acl = dclone($a);
-       my $path = delete $acl->{path};
-       $acls->{$path} = $acl;
-    }
-
-    return $acls;
-}
-
-my $default_cfg = {
-    test_pam => {
-       'id' => 'test@pam',
-       'enable' => 1,
-       'expire' => 0,
-       'email' => undef,
-    },
-    test2_pam => {
-       'id' => 'test2@pam',
-       'enable' => 1,
-       'expire' => 0,
-       'email' => undef,
-    },
-    test_pam_with_group => {
-       'id' => 'test@pam',
-       'enable' => 1,
-       'expire' => 0,
-       'email' => undef,
-       'groups' => { 'testgroup' => 1 },
-    },
-    test2_pam_with_group => {
-       'id' => 'test2@pam',
-       'enable' => 1,
-       'expire' => 0,
-       'email' => undef,
-       'groups' => { 'testgroup' => 1 },
-    },
-    test3_pam => {
-       'id' => 'test3@pam',
-       'enable' => 1,
-       'expire' => 0,
-       'email' => undef,
-       'groups' => { 'another' => 1 },
-    },
-    test_pam_with_token => {
-       'id' => 'test@pam',
-       'enable' => 1,
-       'expire' => 0,
-       'email' => undef,
-       'tokens' => {
-           'full' => {
-               'privsep' => 0,
-               'expire' => 0,
-           },
-       },
-    },
-    test_pam2_with_token => {
-       'id' => 'test2@pam',
-       'enable' => 1,
-       'expire' => 0,
-       'email' => undef,
-       'tokens' => {
-           'full' => {
-               'privsep' => 0,
-               'expire' => 0,
-           },
-           'privsep' => {
-               'privsep' => 1,
-               'expire' => 0,
-           },
-           'expired' => {
-               'privsep' => 0,
-               'expire' => 1,
-           },
-       },
-    },
-    test_group_empty => {
-       'id' => 'testgroup',
-       users => {},
-    },
-    test_group_single_member => {
-       'id' => 'testgroup',
-       'users' => {
-           'test@pam' => 1,
-       },
-    },
-    test_group_members => {
-       'id' => 'testgroup',
-       'users' => {
-           'test@pam' => 1,
-           'test2@pam' => 1,
-       },
-    },
-    test_group_second => {
-       'id' => 'another',
-       users => {
-           'test3@pam' => 1,
-       },
-    },
-    test_role_single_priv => {
-       'id' => 'testrolesingle',
-       'VM.Allocate' => 1,
-    },
-    test_role_privs => {
-       'id' => 'testrole',
-       'VM.Allocate' => 1,
-       'Datastore.Audit' => 1,
-    },
-    test_pool_empty => {
-       'id' => 'testpool',
-       vms => {},
-       storage => {},
-    },
-    test_pool_members => {
-       'id' => 'testpool',
-       vms => { 123 => 1, 1234 => 1},
-       storage => { 'local' => 1, 'local-zfs' => 1},
-    },
-    test_pool_duplicate_vms => {
-       'id' => 'test_duplicate_vms',
-       vms => {},
-       storage => {},
-    },
-    test_pool_duplicate_storages => {
-       'id' => 'test_duplicate_storages',
-       vms => {},
-       storage => { 'local' => 1, 'local-zfs' => 1},
-    },
-    acl_simple_user => {
-       'path' => '/',
-       users => {
-           'test@pam' => {
-               'PVEVMAdmin' => 1,
-           },
-       },
-    },
-    acl_complex_users => {
-       'path' => '/storage',
-       users => {
-           'test2@pam' => {
-               'PVEDatastoreUser' => 1,
-           },
-           'test@pam' => {
-               'PVEDatastoreAdmin' => 1,
-           },
-       },
-    },
-    acl_complex_missing_user => {
-       'path' => '/storage',
-       users => {
-           'test2@pam' => {
-               'PVEDatastoreUser' => 1,
-           },
-           'test@pam' => {
-               'PVEDatastoreAdmin' => 1,
-           },
-       },
-    },
-    acl_simple_token => {
-       'path' => '/',
-       tokens => {
-           'test@pam!full' => {
-               'PVEVMAdmin' => 1,
-           },
-       },
-    },
-    acl_complex_tokens => {
-       'path' => '/storage',
-       tokens => {
-           'test2@pam!privsep' => {
-               'PVEDatastoreUser' => 1,
-           },
-           'test2@pam!expired' => {
-               'PVEDatastoreAdmin' => 1,
-           },
-           'test@pam!full' => {
-               'PVEDatastoreAdmin' => 1,
-           },
-       },
-    },
-    acl_complex_missing_token => {
-       'path' => '/storage',
-       tokens => {
-           'test2@pam!expired' => {
-               'PVEDatastoreAdmin' => 1,
-           },
-           'test2@pam!privsep' => {
-               'PVEDatastoreUser' => 1,
-           },
-       },
-    },
-    acl_simple_group => {
-       'path' => '/',
-       groups => {
-           'testgroup' => {
-               'PVEVMAdmin' => 1,
-           },
-       },
-    },
-    acl_complex_groups => {
-       'path' => '/storage',
-       groups => {
-           'testgroup' => {
-               'PVEDatastoreAdmin' => 1,
-           },
-           'another' => {
-               'PVEDatastoreUser' => 1,
-           },
-       },
-    },
-    acl_simple_group_noprop => {
-       'path' => '/',
-       groups => {
-           'testgroup' => {
-               'PVEVMAdmin' => 0,
-           },
-       },
-    },
-    acl_complex_groups_noprop => {
-       'path' => '/storage',
-       groups => {
-           'testgroup' => {
-               'PVEDatastoreAdmin' => 0,
-           },
-           'another' => {
-               'PVEDatastoreUser' => 0,
-           },
-       },
-    },
-    acl_complex_missing_group => {
-       'path' => '/storage',
-       groups => {
-           'testgroup' => {
-               'PVEDatastoreAdmin' => 1,
-           },
-           'another' => {
-               'PVEDatastoreUser' => 1,
-           },
-       },
-    },
-    acl_missing_role => {
-       'path' => '/storage',
-       users => {
-           'test@pam' => {
-               'MissingRole' => 1,
-           },
-       },
-    },
-};
-
-$default_cfg->{'acl_complex_mixed_root'} = {
-       'path' => '/',
-       users => $default_cfg->{'acl_simple_user'}->{users},
-       groups => $default_cfg->{'acl_simple_group'}->{groups},
-};
-
-$default_cfg->{'acl_complex_mixed_storage'} = {
-       'path' => '/storage',
-       users => $default_cfg->{'acl_complex_users'}->{users},
-       groups => $default_cfg->{'acl_complex_groups'}->{groups},
-};
-
-$default_cfg->{'acl_complex_mixed_root_noprop'} = {
-       'path' => '/',
-       users => $default_cfg->{'acl_simple_user'}->{users},
-       groups => $default_cfg->{'acl_simple_group_noprop'}->{groups},
-};
-
-$default_cfg->{'acl_complex_mixed_storage_noprop'} = {
-       'path' => '/storage',
-       users => $default_cfg->{'acl_complex_users'}->{users},
-       groups => $default_cfg->{'acl_complex_groups_noprop'}->{groups},
-};
-
-my $default_raw = {
-    users => {
-       'root@pam' => 'user:root@pam:1:0::::::',
-       'test_pam' => 'user:test@pam:1:0::::::',
-       'test2_pam' => 'user:test2@pam:1:0::::::',
-       'test3_pam' => 'user:test3@pam:1:0::::::',
-    },
-    groups => {
-       'test_group_empty' => 'group:testgroup:::',
-       'test_group_single_member' => 'group:testgroup:test@pam::',
-       'test_group_members' => 'group:testgroup:test2@pam,test@pam::',
-       'test_group_members_out_of_order' => 'group:testgroup:test@pam,test2@pam::',
-       'test_group_second' => 'group:another:test3@pam::',
-    },
-    tokens => {
-       'test_token_simple' => 'token:test@pam!full:0:0::',
-       'test_token_multi_full' => 'token:test2@pam!full:0:0::',
-       'test_token_multi_privsep' => 'token:test2@pam!privsep:0:1::',
-       'test_token_multi_expired' => 'token:test2@pam!expired:1:0::',
-    },
-    roles => {
-       'test_role_single_priv' => 'role:testrolesingle:VM.Allocate:',
-       'test_role_privs' => 'role:testrole:Datastore.Audit,VM.Allocate:',
-       'test_role_privs_out_of_order' => 'role:testrole:VM.Allocate,Datastore.Audit:',
-       'test_role_privs_duplicate' => 'role:testrole:VM.Allocate,Datastore.Audit,VM.Allocate:',
-       'test_role_privs_invalid' => 'role:testrole:VM.Invalid,Datastore.Audit,VM.Allocate:',
-    },
-    pools => {
-       'test_pool_empty' => 'pool:testpool::::',
-       'test_pool_invalid' => 'pool:testpool::non-numeric:inval!d:',
-       'test_pool_members' => 'pool:testpool::123,1234:local,local-zfs:',
-       'test_pool_duplicate_vms' => 'pool:test_duplicate_vms::123,1234::',
-       'test_pool_duplicate_vms_expected' => 'pool:test_duplicate_vms::::',
-       'test_pool_duplicate_storages' => 'pool:test_duplicate_storages:::local,local-zfs:',
-    },
-    acl => {
-       'acl_simple_user' => 'acl:1:/:test@pam:PVEVMAdmin:',
-       'acl_complex_users_1' => 'acl:1:/storage:test@pam:PVEDatastoreAdmin:',
-       'acl_complex_users_2' => 'acl:1:/storage:test2@pam:PVEDatastoreUser:',
-       'acl_simple_token' => 'acl:1:/:test@pam!full:PVEVMAdmin:',
-       'acl_complex_tokens_1' => 'acl:1:/storage:test2@pam!expired,test@pam!full:PVEDatastoreAdmin:',
-       'acl_complex_tokens_2' => 'acl:1:/storage:test2@pam!privsep:PVEDatastoreUser:',
-       'acl_complex_tokens_1_missing' => 'acl:1:/storage:test2@pam!expired:PVEDatastoreAdmin:',
-       'acl_simple_group' => 'acl:1:/:@testgroup:PVEVMAdmin:',
-       'acl_complex_groups_1' => 'acl:1:/storage:@testgroup:PVEDatastoreAdmin:',
-       'acl_complex_groups_2' => 'acl:1:/storage:@another:PVEDatastoreUser:',
-       'acl_simple_group_noprop' => 'acl:0:/:@testgroup:PVEVMAdmin:',
-       'acl_complex_groups_1_noprop' => 'acl:0:/storage:@testgroup:PVEDatastoreAdmin:',
-       'acl_complex_groups_2_noprop' => 'acl:0:/storage:@another:PVEDatastoreUser:',
-       'acl_complex_mixed_1' => 'acl:1:/:@testgroup,test@pam:PVEVMAdmin:',
-       'acl_complex_mixed_2' => 'acl:1:/storage:@testgroup,test@pam:PVEDatastoreAdmin:',
-       'acl_complex_mixed_3' => 'acl:1:/storage:@another,test2@pam:PVEDatastoreUser:',
-       'acl_missing_role' => 'acl:1:/storage:test@pam:MissingRole:',
-    },
-};
-
-my $tests = [
-    {
-       name => "empty_config",
-       config => {},
-       expected_config => {
-           users => { 'root@pam' => { enable => 1 } },
-           roles => default_roles(),
-       },
-       raw => "",
-       expected_raw => "\n\n\n\n",
-    },
-    {
-       name => "default_config",
-       config => {
-           users => default_users(),
-           roles => default_roles(),
-       },
-       raw => $default_raw->{users}->{'root@pam'}."\n\n\n\n\n",
-    },
-    {
-       name => "group_empty",
-       config => {
-           users => default_users(),
-           roles => default_roles(),
-           groups => default_groups_with([$default_cfg->{'test_group_empty'}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n\n".
-              $default_raw->{groups}->{'test_group_empty'}."\n\n".
-              "\n\n",
-    },
-    {
-       name => "group_inexisting_member",
-       config => {
-           users => default_users(),
-           roles => default_roles(),
-           groups => default_groups_with([$default_cfg->{'test_group_empty'}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n\n".
-              "group:testgroup:does_not_exist::".
-              "\n\n\n\n",
-       expected_raw => "".
-              $default_raw->{users}->{'root@pam'}."\n\n".
-              $default_raw->{groups}->{'test_group_empty'}."\n\n".
-              "\n\n",
-    },
-    {
-       name => "group_invalid_member",
-       expected_config => {
-           users => default_users(),
-           roles => default_roles(),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n\n".
-              'group:inval!d:root@pam:'.
-              "\n\n",
-    },
-    {
-       name => "group_with_one_member",
-       config => {
-           users => default_users_with([$default_cfg->{test_pam_with_group}]),
-           roles => default_roles(),
-           groups => default_groups_with([$default_cfg->{'test_group_single_member'}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n\n".
-              $default_raw->{groups}->{'test_group_single_member'}."\n\n".
-              "\n\n",
-    },
-    {
-       name => "group_with_members",
-       config => {
-           users => default_users_with([$default_cfg->{test_pam_with_group}, $default_cfg->{test2_pam_with_group}]),
-           roles => default_roles(),
-           groups => default_groups_with([$default_cfg->{'test_group_members'}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test2_pam'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n\n".
-              $default_raw->{groups}->{'test_group_members'}."\n\n".
-              "\n\n",
-    },
-    {
-       name => "token_simple",
-       config => {
-           users => default_users_with([$default_cfg->{test_pam_with_token}]),
-           roles => default_roles(),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n".
-              $default_raw->{tokens}->{'test_token_simple'}."\n\n\n\n\n",
-    },
-    {
-       name => "token_multi",
-       config => {
-           users => default_users_with([$default_cfg->{test_pam_with_token}, $default_cfg->{test_pam2_with_token}]),
-           roles => default_roles(),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test2_pam'}."\n".
-              $default_raw->{tokens}->{'test_token_multi_expired'}."\n".
-              $default_raw->{tokens}->{'test_token_multi_full'}."\n".
-              $default_raw->{tokens}->{'test_token_multi_privsep'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n".
-              $default_raw->{tokens}->{'test_token_simple'}."\n".
-              "\n\n\n\n",
-    },
-    {
-       name => "custom_role_with_single_priv",
-       config => {
-           users => default_users(),
-           roles => default_roles_with([$default_cfg->{test_role_single_priv}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n\n\n\n".
-              $default_raw->{roles}->{'test_role_single_priv'}."\n\n",
-    },
-    {
-       name => "custom_role_with_privs",
-       config => {
-           users => default_users(),
-           roles => default_roles_with([$default_cfg->{test_role_privs}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n\n\n\n".
-              $default_raw->{roles}->{'test_role_privs'}."\n\n",
-    },
-    {
-       name => "custom_role_with_duplicate_privs",
-       config => {
-           users => default_users(),
-           roles => default_roles_with([$default_cfg->{test_role_privs}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n\n\n\n".
-              $default_raw->{roles}->{'test_role_privs_duplicate'}."\n\n",
-       expected_raw => "".
-              $default_raw->{users}->{'root@pam'}."\n\n\n\n".
-              $default_raw->{roles}->{'test_role_privs'}."\n\n",
-    },
-    {
-       name => "custom_role_with_invalid_priv",
-       config => {
-           users => default_users(),
-           roles => default_roles_with([$default_cfg->{test_role_privs}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n\n\n\n".
-              $default_raw->{roles}->{'test_role_privs_invalid'}."\n\n",
-       expected_raw => "".
-              $default_raw->{users}->{'root@pam'}."\n\n\n\n".
-              $default_raw->{roles}->{'test_role_privs'}."\n\n",
-    },
-    {
-       name => "pool_empty",
-       config => {
-           users => default_users(),
-           roles => default_roles(),
-           pools => default_pools_with([$default_cfg->{test_pool_empty}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n\n\n".
-              $default_raw->{pools}->{'test_pool_empty'}."\n\n\n",
-    },
-    {
-       name => "pool_invalid",
-       config => {
-           users => default_users(),
-           roles => default_roles(),
-           pools => default_pools_with([$default_cfg->{test_pool_empty}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n\n\n".
-              $default_raw->{pools}->{'test_pool_invalid'}."\n\n\n",
-       expected_raw => "".
-              $default_raw->{users}->{'root@pam'}."\n\n\n".
-              $default_raw->{pools}->{'test_pool_empty'}."\n\n\n",
-    },
-    {
-       name => "pool_members",
-       config => {
-           users => default_users(),
-           roles => default_roles(),
-           pools => default_pools_with([$default_cfg->{test_pool_members}]),
-           vms => default_pool_vms_with([$default_cfg->{test_pool_members}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n\n\n".
-              $default_raw->{pools}->{'test_pool_members'}."\n\n\n",
-    },
-    {
-       name => "pool_duplicate_members",
-       config => {
-           users => default_users(),
-           roles => default_roles(),
-           pools => default_pools_with([$default_cfg->{test_pool_members}, $default_cfg->{test_pool_duplicate_vms}, $default_cfg->{test_pool_duplicate_storages}]),
-           vms => default_pool_vms_with([$default_cfg->{test_pool_members}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n\n\n".
-              $default_raw->{pools}->{'test_pool_members'}."\n".
-              $default_raw->{pools}->{'test_pool_duplicate_vms'}."\n".
-              $default_raw->{pools}->{'test_pool_duplicate_storages'}."\n",
-       expected_raw => "".
-              $default_raw->{users}->{'root@pam'}."\n\n\n".
-              $default_raw->{pools}->{'test_pool_duplicate_storages'}."\n".
-              $default_raw->{pools}->{'test_pool_duplicate_vms_expected'}."\n".
-              $default_raw->{pools}->{'test_pool_members'}."\n\n\n",
-    },
-    {
-       name => "acl_simple_user",
-       config => {
-           users => default_users_with([$default_cfg->{test_pam}]),
-           roles => default_roles(),
-           acl => default_acls_with([$default_cfg->{acl_simple_user}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n\n\n\n\n".
-              $default_raw->{acl}->{'acl_simple_user'}."\n",
-    },
-    {
-       name => "acl_complex_users",
-       config => {
-           users => default_users_with([$default_cfg->{test_pam}, $default_cfg->{'test2_pam'}]),
-           roles => default_roles(),
-           acl => default_acls_with([$default_cfg->{acl_simple_user}, $default_cfg->{acl_complex_users}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test2_pam'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n\n\n\n\n".
-              $default_raw->{acl}->{'acl_simple_user'}."\n".
-              $default_raw->{acl}->{'acl_complex_users_1'}."\n".
-              $default_raw->{acl}->{'acl_complex_users_2'}."\n",
-    },
-    {
-       name => "acl_complex_missing_user",
-       config => {
-           users => default_users_with([$default_cfg->{test2_pam}]),
-           roles => default_roles(),
-           acl => default_acls_with([$default_cfg->{acl_simple_user}, $default_cfg->{acl_complex_missing_user}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test2_pam'}."\n\n\n\n\n".
-              $default_raw->{acl}->{'acl_simple_user'}."\n".
-              $default_raw->{acl}->{'acl_complex_users_1'}."\n".
-              $default_raw->{acl}->{'acl_complex_users_2'}."\n",
-    },
-    {
-       name => "acl_simple_group",
-       config => {
-           users => default_users_with([$default_cfg->{test_pam_with_group}]),
-           groups => default_groups_with([$default_cfg->{'test_group_single_member'}]),
-           roles => default_roles(),
-           acl => default_acls_with([$default_cfg->{acl_simple_group}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n\n".
-              $default_raw->{groups}->{'test_group_single_member'}."\n\n\n\n".
-              $default_raw->{acl}->{'acl_simple_group'}."\n",
-    },
-    {
-       name => "acl_complex_groups",
-       config => {
-           users => default_users_with([$default_cfg->{test_pam_with_group}, $default_cfg->{'test2_pam_with_group'}, $default_cfg->{'test3_pam'}]),
-           groups => default_groups_with([$default_cfg->{'test_group_members'}, $default_cfg->{'test_group_second'}]),
-           roles => default_roles(),
-           acl => default_acls_with([$default_cfg->{acl_simple_group}, $default_cfg->{acl_complex_groups}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test2_pam'}."\n".
-              $default_raw->{users}->{'test3_pam'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n\n".
-              $default_raw->{groups}->{'test_group_second'}."\n".
-              $default_raw->{groups}->{'test_group_members'}."\n\n\n\n".
-              $default_raw->{acl}->{'acl_simple_group'}."\n".
-              $default_raw->{acl}->{'acl_complex_groups_1'}."\n".
-              $default_raw->{acl}->{'acl_complex_groups_2'}."\n",
-    },
-    {
-       name => "acl_complex_missing_group",
-       config => {
-           users => default_users_with([$default_cfg->{test_pam}, $default_cfg->{'test2_pam'}, $default_cfg->{'test3_pam'}]),
-           groups => default_groups_with([$default_cfg->{'test_group_second'}]),
-           roles => default_roles(),
-           acl => default_acls_with([$default_cfg->{acl_simple_group}, $default_cfg->{acl_complex_missing_group}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test2_pam'}."\n".
-              $default_raw->{users}->{'test3_pam'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n\n".
-              $default_raw->{groups}->{'test_group_second'}."\n".
-              $default_raw->{acl}->{'acl_simple_group'}."\n".
-              $default_raw->{acl}->{'acl_complex_groups_1'}."\n".
-              $default_raw->{acl}->{'acl_complex_groups_2'}."\n",
-       expected_raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test2_pam'}."\n".
-              $default_raw->{users}->{'test3_pam'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n\n".
-              $default_raw->{groups}->{'test_group_second'}."\n\n\n\n".
-              $default_raw->{acl}->{'acl_simple_group'}."\n".
-              $default_raw->{acl}->{'acl_complex_groups_1'}."\n".
-              $default_raw->{acl}->{'acl_complex_groups_2'}."\n",
-    },
-    {
-       name => "acl_simple_token",
-       config => {
-           users => default_users_with([$default_cfg->{test_pam_with_token}]),
-           roles => default_roles(),
-           acl => default_acls_with([$default_cfg->{acl_simple_token}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n".
-              $default_raw->{tokens}->{'test_token_simple'}."\n\n\n\n\n".
-              $default_raw->{acl}->{'acl_simple_token'}."\n",
-    },
-    {
-       name => "acl_complex_tokens",
-       config => {
-           users => default_users_with([$default_cfg->{test_pam_with_token}, $default_cfg->{'test_pam2_with_token'}]),
-           roles => default_roles(),
-           acl => default_acls_with([$default_cfg->{acl_simple_token}, $default_cfg->{acl_complex_tokens}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test2_pam'}."\n".
-              $default_raw->{tokens}->{'test_token_multi_expired'}."\n".
-              $default_raw->{tokens}->{'test_token_multi_full'}."\n".
-              $default_raw->{tokens}->{'test_token_multi_privsep'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n".
-              $default_raw->{tokens}->{'test_token_simple'}."\n\n\n\n\n".
-              $default_raw->{acl}->{'acl_simple_token'}."\n".
-              $default_raw->{acl}->{'acl_complex_tokens_1'}."\n".
-              $default_raw->{acl}->{'acl_complex_tokens_2'}."\n",
-    },
-    {
-       name => "acl_complex_missing_token",
-       config => {
-           users => default_users_with([$default_cfg->{test_pam}, $default_cfg->{test_pam2_with_token}]),
-           roles => default_roles(),
-           acl => default_acls_with([$default_cfg->{acl_complex_missing_token}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test2_pam'}."\n".
-              $default_raw->{tokens}->{'test_token_multi_expired'}."\n".
-              $default_raw->{tokens}->{'test_token_multi_full'}."\n".
-              $default_raw->{tokens}->{'test_token_multi_privsep'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n".
-              $default_raw->{acl}->{'acl_simple_token'}."\n".
-              $default_raw->{acl}->{'acl_complex_tokens_1'}."\n".
-              $default_raw->{acl}->{'acl_complex_tokens_2'}."\n",
-       expected_raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test2_pam'}."\n".
-              $default_raw->{tokens}->{'test_token_multi_expired'}."\n".
-              $default_raw->{tokens}->{'test_token_multi_full'}."\n".
-              $default_raw->{tokens}->{'test_token_multi_privsep'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n\n\n\n\n".
-              $default_raw->{acl}->{'acl_complex_tokens_1_missing'}."\n".
-              $default_raw->{acl}->{'acl_complex_tokens_2'}."\n",
-    },
-    {
-       name => "acl_missing_role",
-       config => {
-           users => default_users_with([$default_cfg->{test_pam}]),
-           roles => default_roles(),
-           acl => default_acls_with([$default_cfg->{acl_simple_user}]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n\n\n\n\n".
-              $default_raw->{acl}->{'acl_simple_user'}."\n".
-              $default_raw->{acl}->{'acl_missing_role'}."\n",
-       expected_raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n\n\n\n\n".
-              $default_raw->{acl}->{'acl_simple_user'}."\n",
-    },
-    {
-       name => "acl_complex_mixed",
-       config => {
-           users => default_users_with([$default_cfg->{test_pam_with_group}, $default_cfg->{'test2_pam_with_group'}, $default_cfg->{'test3_pam'}]),
-           groups => default_groups_with([$default_cfg->{'test_group_members'}, $default_cfg->{'test_group_second'}]),
-           roles => default_roles(),
-           acl => default_acls_with([
-               $default_cfg->{acl_complex_mixed_root},
-               $default_cfg->{acl_complex_mixed_storage},
-           ]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test2_pam'}."\n".
-              $default_raw->{users}->{'test3_pam'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n\n".
-              $default_raw->{groups}->{'test_group_second'}."\n".
-              $default_raw->{groups}->{'test_group_members'}."\n\n\n\n".
-              $default_raw->{acl}->{'acl_simple_group'}."\n".
-              $default_raw->{acl}->{'acl_complex_groups_1'}."\n".
-              $default_raw->{acl}->{'acl_complex_groups_2'}."\n".
-              $default_raw->{acl}->{'acl_simple_user'}."\n".
-              $default_raw->{acl}->{'acl_complex_users_1'}."\n".
-              $default_raw->{acl}->{'acl_complex_users_2'}."\n",
-       expected_raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test2_pam'}."\n".
-              $default_raw->{users}->{'test3_pam'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n\n".
-              $default_raw->{groups}->{'test_group_second'}."\n".
-              $default_raw->{groups}->{'test_group_members'}."\n\n\n\n".
-              $default_raw->{acl}->{'acl_complex_mixed_1'}."\n".
-              $default_raw->{acl}->{'acl_complex_mixed_2'}."\n".
-              $default_raw->{acl}->{'acl_complex_mixed_3'}."\n",
-    },
-    {
-       name => "acl_complex_mixed_prop_noprop_no_merge_sort_by_path",
-       config => {
-           users => default_users_with([$default_cfg->{test_pam_with_group}, $default_cfg->{'test2_pam_with_group'}, $default_cfg->{'test3_pam'}]),
-           groups => default_groups_with([$default_cfg->{'test_group_members'}, $default_cfg->{'test_group_second'}]),
-           roles => default_roles(),
-           acl => default_acls_with([
-               $default_cfg->{acl_complex_mixed_root_noprop},
-               $default_cfg->{acl_complex_mixed_storage_noprop},
-           ]),
-       },
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test2_pam'}."\n".
-              $default_raw->{users}->{'test3_pam'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n\n".
-              $default_raw->{groups}->{'test_group_second'}."\n".
-              $default_raw->{groups}->{'test_group_members'}."\n\n\n\n".
-              $default_raw->{acl}->{'acl_simple_group_noprop'}."\n".
-              $default_raw->{acl}->{'acl_simple_user'}."\n".
-              $default_raw->{acl}->{'acl_complex_groups_1_noprop'}."\n".
-              $default_raw->{acl}->{'acl_complex_groups_2_noprop'}."\n".
-              $default_raw->{acl}->{'acl_complex_users_1'}."\n".
-              $default_raw->{acl}->{'acl_complex_users_2'}."\n",
-    },
-    {
-       name => "sort_roles_and_privs",
-       raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{roles}->{'test_role_single_priv'}."\n\n".
-              $default_raw->{roles}->{'test_role_privs_out_of_order'}."\n\n",
-       expected_raw => "".
-              $default_raw->{users}->{'root@pam'}."\n\n\n\n".
-              $default_raw->{roles}->{'test_role_privs'}."\n".
-              $default_raw->{roles}->{'test_role_single_priv'}."\n\n",
-    },
-    {
-       name => "sort_users_and_group_members",
-       raw => "".
-              $default_raw->{users}->{'test2_pam'}."\n".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n\n".
-              $default_raw->{groups}->{'test_group_members_out_of_order'}."\n\n".
-           "\n\n",
-       expected_raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test2_pam'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n\n".
-              $default_raw->{groups}->{'test_group_members'}."\n\n".
-           "\n\n",
-    },
-    {
-       name => "sort_user_groups_and_acls",
-       raw => "".
-              $default_raw->{users}->{'test2_pam'}."\n".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n\n".
-              $default_raw->{users}->{'test3_pam'}."\n".
-              $default_raw->{groups}->{'test_group_members_out_of_order'}."\n\n\n\n".
-              $default_raw->{groups}->{'test_group_second'}."\n".
-              $default_raw->{acl}->{'acl_simple_user'}."\n".
-              $default_raw->{acl}->{'acl_simple_group'}."\n".
-              $default_raw->{acl}->{'acl_complex_users_1'}."\n".
-              $default_raw->{acl}->{'acl_complex_users_2'}."\n".
-              $default_raw->{acl}->{'acl_complex_groups_1'}."\n".
-              $default_raw->{acl}->{'acl_complex_groups_2'}."\n",
-       expected_raw => "".
-              $default_raw->{users}->{'root@pam'}."\n".
-              $default_raw->{users}->{'test2_pam'}."\n".
-              $default_raw->{users}->{'test3_pam'}."\n".
-              $default_raw->{users}->{'test_pam'}."\n\n".
-              $default_raw->{groups}->{'test_group_second'}."\n".
-              $default_raw->{groups}->{'test_group_members'}."\n\n\n\n".
-              $default_raw->{acl}->{'acl_complex_mixed_1'}."\n".
-              $default_raw->{acl}->{'acl_complex_mixed_2'}."\n".
-              $default_raw->{acl}->{'acl_complex_mixed_3'}."\n",
-    },
-    {
-       name => 'default_values',
-       config => {
-           users => {
-               'root@pam' => {
-                   enable => 0,
-                   expire => 0,
-                   email => undef,
-               },
-               'test@pam' => {
-                   enable => 0,
-                   expire => 0,
-                   email => undef,
-                   tokens => {
-                       'test' => {
-                           expire => 0,
-                           privsep => 0,
-                       },
-                   },
-               },
-           },
-           roles => default_roles_with([{ id => 'testrole' }]),
-           groups => default_groups_with([$default_cfg->{test_group_empty}]),
-           pools => default_pools_with([$default_cfg->{test_pool_empty}]),
-       },
-       raw => "".
-              'user:root@pam'."\n".
-              'user:test@pam'."\n".
-              'token:test@pam!test'."\n\n".
-              'group:testgroup'."\n\n".
-              'pool:testpool'."\n\n".
-              'role:testrole'."\n\n".
-              'acl::/:',
-       expected_raw => "".
-              'user:root@pam:0:0::::::'."\n".
-              'user:test@pam:0:0::::::'."\n".
-              'token:test@pam!test:0:0::'."\n\n".
-              'group:testgroup:::'."\n\n".
-              'pool:testpool::::'."\n\n".
-              'role:testrole::'."\n\n",
-    },
-];
-
-
-my $number_of_tests_run = 0;
-foreach my $t (@$tests) {
-    my $expected_config = $t->{expected_config} // $t->{config};
-    my $expected_raw = $t->{expected_raw} // $t->{raw};
-    if (defined($t->{raw})) {
-       my $parsed = PVE::AccessControl::parse_user_config($t->{name}, $t->{raw});
-       if (defined($expected_config)) {
-           is_deeply($parsed, $expected_config, "$t->{name}_parse");
-           $number_of_tests_run++;
-       }
-       if (defined($t->{expected_raw}) && !defined($t->{config})) {
-           is(PVE::AccessControl::write_user_config($t->{name}, $parsed), $t->{expected_raw}, "$t->{name}_rewrite");
-           $number_of_tests_run++;
-       }
-
-    }
-    if (defined($t->{config})) {
-       my $written = PVE::AccessControl::write_user_config($t->{name}, $t->{config});
-       if (defined($expected_raw)) {
-           is($written, $expected_raw, "$t->{name}_write");
-           $number_of_tests_run++;
-       }
-       if (defined($t->{expected_config}) && !defined($t->{raw})) {
-           is_deeply(PVE::AccessControl::parse_user_config($t->{name}, $t->{written}), $t->{expected_config}, "$t->{name}_reparse");
-           $number_of_tests_run++;
-       }
-    }
-};
-
-done_testing( $number_of_tests_run);
diff --git a/test/perm-test1.pl b/test/perm-test1.pl
deleted file mode 100755 (executable)
index 12c95db..0000000
+++ /dev/null
@@ -1,64 +0,0 @@
-#!/usr/bin/perl -w
-
-use strict;
-use PVE::Tools;
-use PVE::AccessControl;
-use PVE::RPCEnvironment;
-use Getopt::Long;
-
-my $rpcenv = PVE::RPCEnvironment->init('cli');
-
-my $cfgfn = "test1.cfg";
-$rpcenv->init_request(userconfig => $cfgfn);
-
-sub check_roles {
-    my ($user, $path, $expected_result) = @_;
-
-    my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path);
-    my $res = join(',', sort keys %$roles);
-
-    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
-       if $res ne $expected_result;
-
-    print "ROLES:$path:$user:$res\n";
-}
-
-sub check_permission {
-    my ($user, $path, $expected_result) = @_;
-
-    my $perm = $rpcenv->permissions($user, $path);
-    my $res = join(',', sort keys %$perm);
-
-    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
-       if $res ne $expected_result;
-
-    $perm = $rpcenv->permissions($user, $path);
-    $res = join(',', sort keys %$perm);
-    die "unexpected result (compiled)\nneed '${expected_result}'\ngot '$res'\n"
-       if $res ne $expected_result;
-
-    print "PERM:$path:$user:$res\n";
-}
-
-check_roles('max@pve', '/', '');
-check_roles('max@pve', '/vms', 'vm_admin');
-
-#user permissions overrides group permissions
-check_roles('max@pve', '/vms/100', 'customer');
-check_roles('max@pve', '/vms/101', 'vm_admin');
-
-check_permission('max@pve', '/', '');
-check_permission('max@pve', '/vms', 'Permissions.Modify,VM.Allocate,VM.Audit,VM.Console');
-check_permission('max@pve', '/vms/100', 'VM.Audit,VM.PowerMgmt');
-
-check_permission('alex@pve', '/vms', '');
-check_permission('alex@pve', '/vms/100', 'VM.Audit,VM.PowerMgmt');
-
-
-check_roles('max@pve', '/vms/200', 'storage_manager');
-check_roles('joe@pve', '/vms/200', 'vm_admin');
-check_roles('sue@pve', '/vms/200', 'NoAccess');
-
-print "all tests passed\n";
-
-exit (0);
diff --git a/test/perm-test2.pl b/test/perm-test2.pl
deleted file mode 100755 (executable)
index 1317051..0000000
+++ /dev/null
@@ -1,39 +0,0 @@
-#!/usr/bin/perl -w
-
-use strict;
-use PVE::Tools;
-use PVE::AccessControl;
-use PVE::RPCEnvironment;
-use Getopt::Long;
-
-my $rpcenv = PVE::RPCEnvironment->init('cli');
-
-my $cfgfn = "test2.cfg";
-$rpcenv->init_request(userconfig => $cfgfn);
-
-sub check_roles {
-    my ($user, $path, $expected_result) = @_;
-
-    my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path);
-    my $res = join(',', sort keys %$roles);
-
-    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
-       if $res ne $expected_result;
-
-    print "ROLES:$path:$user:$res\n";
-}
-
-# inherit multiple group permissions
-
-check_roles('User1@pve', '/', '');
-check_roles('User2@pve', '/', '');
-
-check_roles('User1@pve', '/vms', 'Role1,Role2');
-check_roles('User2@pve', '/vms', '');
-
-check_roles('User1@pve', '/vms/100', 'Role1,Role2');
-check_roles('User2@pve', '/vms', '');
-
-print "all tests passed\n";
-
-exit (0);
diff --git a/test/perm-test3.pl b/test/perm-test3.pl
deleted file mode 100755 (executable)
index b7b5480..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-#!/usr/bin/perl -w
-
-use strict;
-use PVE::Tools;
-use PVE::AccessControl;
-use PVE::RPCEnvironment;
-use Getopt::Long;
-
-my $rpcenv = PVE::RPCEnvironment->init('cli');
-
-my $cfgfn = "test3.cfg";
-$rpcenv->init_request(userconfig => $cfgfn);
-
-sub check_roles {
-    my ($user, $path, $expected_result) = @_;
-
-    my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path);
-    my $res = join(',', sort keys %$roles);
-
-    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
-       if $res ne $expected_result;
-
-    print "ROLES:$path:$user:$res\n";
-}
-
-check_roles('User1@pve', '', '');
-check_roles('User2@pve', '', '');
-
-check_roles('User1@pve', '/vms/300', 'Role1');
-check_roles('User1@pve', '/vms/200', 'Role2');
-
-print "all tests passed\n";
-
-exit (0);
diff --git a/test/perm-test4.pl b/test/perm-test4.pl
deleted file mode 100755 (executable)
index 718963e..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-#!/usr/bin/perl -w
-
-use strict;
-use PVE::Tools;
-use PVE::AccessControl;
-use PVE::RPCEnvironment;
-use Getopt::Long;
-
-my $rpcenv = PVE::RPCEnvironment->init('cli');
-
-my $cfgfn = "test4.cfg";
-$rpcenv->init_request(userconfig => $cfgfn);
-
-sub check_roles {
-    my ($user, $path, $expected_result) = @_;
-
-    my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path);
-    my $res = join(',', sort keys %$roles);
-
-    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
-       if $res ne $expected_result;
-
-    print "ROLES:$path:$user:$res\n";
-}
-
-
-check_roles('User1@pve', '/vms/300', 'Role1');
-check_roles('User2@pve', '/vms/300', 'NoAccess');
-
-print "all tests passed\n";
-
-exit (0);
diff --git a/test/perm-test5.pl b/test/perm-test5.pl
deleted file mode 100755 (executable)
index ebb40e3..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-#!/usr/bin/perl -w
-
-use strict;
-use PVE::Tools;
-use PVE::AccessControl;
-use PVE::RPCEnvironment;
-use Getopt::Long;
-
-my $rpcenv = PVE::RPCEnvironment->init('cli');
-
-my $cfgfn = "test5.cfg";
-$rpcenv->init_request(userconfig => $cfgfn);
-
-sub check_roles {
-    my ($user, $path, $expected_result) = @_;
-
-    my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path);
-    my $res = join(',', sort keys %$roles);
-
-    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
-       if $res ne $expected_result;
-
-    print "ROLES:$path:$user:$res\n";
-}
-
-
-check_roles('User1@pve', '/vms', 'Role1');
-check_roles('User1@pve', '/vms/100', 'Role1');
-check_roles('User1@pve', '/vms/100/a', 'Role1');
-check_roles('User1@pve', '/vms/100/a/b', 'Role2');
-check_roles('User1@pve', '/vms/100/a/b/c', 'Role2');
-check_roles('User1@pve', '/vms/200', 'Role1');
-
-check_roles('User2@pve', '/kvm', 'Role2');
-check_roles('User2@pve', '/kvm/vms', 'Role1');
-check_roles('User2@pve', '/kvm/vms/100', '');
-check_roles('User2@pve', '/kvm/vms/100/a', 'Role3');
-check_roles('User2@pve', '/kvm/vms/100/a/b', '');
-
-print "all tests passed\n";
-
-exit (0);
diff --git a/test/perm-test6.pl b/test/perm-test6.pl
deleted file mode 100755 (executable)
index dd433dd..0000000
+++ /dev/null
@@ -1,92 +0,0 @@
-#!/usr/bin/perl -w
-
-use strict;
-use PVE::Tools;
-use PVE::AccessControl;
-use PVE::RPCEnvironment;
-use Getopt::Long;
-
-my $rpcenv = PVE::RPCEnvironment->init('cli');
-
-my $cfgfn = "test6.cfg";
-$rpcenv->init_request(userconfig => $cfgfn);
-
-sub check_roles {
-    my ($user, $path, $expected_result) = @_;
-
-    my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path);
-    my $res = join(',', sort keys %$roles);
-
-    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
-       if $res ne $expected_result;
-
-    print "ROLES:$path:$user:$res\n";
-}
-
-sub check_permissions {
-    my ($user, $path, $expected_result) = @_;
-
-    my $perm = $rpcenv->permissions($user, $path);
-    my $res = join(',', sort keys %$perm);
-
-    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
-       if $res ne $expected_result;
-
-    $perm = $rpcenv->permissions($user, $path);
-    $res = join(',', sort keys %$perm);
-    die "unexpected result (compiled)\nneed '${expected_result}'\ngot '$res'\n"
-       if $res ne $expected_result;
-
-    print "PERM:$path:$user:$res\n";
-}
-
-check_roles('User1@pve', '', '');
-check_roles('User2@pve', '', '');
-check_roles('User3@pve', '', '');
-check_roles('User4@pve', '', '');
-
-check_roles('User1@pve', '/vms', 'RoleTEST1');
-check_roles('User2@pve', '/vms', 'RoleTEST1');
-check_roles('User3@pve', '/vms', 'NoAccess');
-check_roles('User4@pve', '/vms', '');
-
-check_roles('User1@pve', '/vms/100', 'RoleTEST1');
-check_roles('User2@pve', '/vms/100', 'RoleTEST1');
-check_roles('User3@pve', '/vms/100', 'NoAccess');
-check_roles('User4@pve', '/vms/100', '');
-
-check_roles('User1@pve', '/vms/300', 'RoleTEST1');
-check_roles('User2@pve', '/vms/300', 'RoleTEST1');
-check_roles('User3@pve', '/vms/300', 'NoAccess');
-check_roles('User4@pve', '/vms/300', 'RoleTEST1');
-
-check_permissions('User1@pve', '/vms/500', 'VM.Console,VM.PowerMgmt');
-check_permissions('User2@pve', '/vms/500', 'VM.Console,VM.PowerMgmt');
-# without pool
-check_roles('User3@pve', '/vms/500', 'NoAccess');
-# with pool
-check_permissions('User3@pve', '/vms/500', '');
-# without pool
-check_roles('User4@pve', '/vms/500', '');
-# with pool
-check_permissions('User4@pve', '/vms/500', '');
-
-
-check_permissions('User1@pve', '/vms/600', 'VM.Console');
-check_permissions('User2@pve', '/vms/600', 'VM.Console');
-check_permissions('User3@pve', '/vms/600', '');
-check_permissions('User4@pve', '/vms/600', 'VM.Console');
-
-check_permissions('User1@pve', '/storage/store1', 'VM.Console,VM.PowerMgmt');
-check_permissions('User2@pve', '/storage/store1', 'VM.PowerMgmt');
-check_permissions('User3@pve', '/storage/store1', 'VM.PowerMgmt');
-check_permissions('User4@pve', '/storage/store1', 'VM.Console');
-
-check_permissions('User1@pve', '/storage/store2', 'VM.PowerMgmt');
-check_permissions('User2@pve', '/storage/store2', 'VM.PowerMgmt');
-check_permissions('User3@pve', '/storage/store2', 'VM.PowerMgmt');
-check_permissions('User4@pve', '/storage/store2', '');
-
-print "all tests passed\n";
-
-exit (0);
diff --git a/test/perm-test7.pl b/test/perm-test7.pl
deleted file mode 100755 (executable)
index 57ece07..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-#!/usr/bin/perl -w
-
-use strict;
-use PVE::Tools;
-use PVE::AccessControl;
-use PVE::RPCEnvironment;
-use Getopt::Long;
-
-my $rpcenv = PVE::RPCEnvironment->init('cli');
-
-my $cfgfn = "test7.cfg";
-$rpcenv->init_request(userconfig => $cfgfn);
-
-sub check_roles {
-    my ($user, $path, $expected_result) = @_;
-
-    my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path);
-    my $res = join(',', sort keys %$roles);
-
-    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
-       if $res ne $expected_result;
-
-    print "ROLES:$path:$user:$res\n";
-}
-
-sub check_permissions {
-    my ($user, $path, $expected_result) = @_;
-
-    my $perm = $rpcenv->permissions($user, $path);
-    my $res = join(',', sort keys %$perm);
-
-    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
-       if $res ne $expected_result;
-
-    $perm = $rpcenv->permissions($user, $path);
-    $res = join(',', sort keys %$perm);
-    die "unexpected result (compiled)\nneed '${expected_result}'\ngot '$res'\n"
-       if $res ne $expected_result;
-
-    print "PERM:$path:$user:$res\n";
-}
-
-check_roles('User1@pve', '/vms', 'Role1');
-check_roles('User1@pve', '/vms/200', 'Role1');
-
-# no pool
-check_roles('User1@pve', '/vms/100', 'Role1');
-# with pool
-check_permissions('User1@pve', '/vms/100', '');
-
-print "all tests passed\n";
-
-exit (0);
diff --git a/test/perm-test8.pl b/test/perm-test8.pl
deleted file mode 100644 (file)
index 67e0cb1..0000000
+++ /dev/null
@@ -1,68 +0,0 @@
-#!/usr/bin/perl -w
-
-use strict;
-use PVE::Tools;
-use PVE::AccessControl;
-use PVE::RPCEnvironment;
-
-my $rpcenv = PVE::RPCEnvironment->init('cli');
-
-my $cfgfn = "test8.cfg";
-$rpcenv->init_request(userconfig => $cfgfn);
-
-sub check_roles {
-    my ($user, $path, $expected_result) = @_;
-
-    my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path);
-    my $res = join(',', sort keys %$roles);
-
-    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
-       if $res ne $expected_result;
-
-    print "ROLES:$path:$user:$res\n";
-}
-
-sub check_permission {
-    my ($user, $path, $expected_result) = @_;
-
-    my $perm = $rpcenv->permissions($user, $path);
-    my $res = join(',', sort keys %$perm);
-
-    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
-       if $res ne $expected_result;
-
-    $perm = $rpcenv->permissions($user, $path);
-    $res = join(',', sort keys %$perm);
-    die "unexpected result (compiled)\nneed '${expected_result}'\ngot '$res'\n"
-       if $res ne $expected_result;
-
-    print "PERM:$path:$user:$res\n";
-}
-
-check_roles('max@pve', '/', '');
-check_roles('max@pve', '/vms', 'vm_admin');
-
-#user permissions overrides group permissions
-check_roles('max@pve', '/vms/100', 'customer');
-check_roles('max@pve', '/vms/101', 'vm_admin');
-
-check_permission('max@pve', '/', '');
-check_permission('max@pve', '/vms', 'Permissions.Modify,VM.Allocate,VM.Audit,VM.Console');
-check_permission('max@pve', '/vms/100', 'VM.Audit,VM.PowerMgmt');
-
-check_permission('alex@pve', '/vms', '');
-check_permission('alex@pve', '/vms/100', 'VM.Audit,VM.PowerMgmt');
-
-check_roles('max@pve', '/vms/200', 'storage_manager');
-check_roles('joe@pve', '/vms/200', 'vm_admin');
-check_roles('sue@pve', '/vms/200', 'NoAccess');
-
-check_roles('carol@pam', '/vms/200', 'NoAccess');
-check_roles('carol@pam!token', '/vms/200', 'NoAccess');
-check_roles('max@pve!token', '/vms/200', 'storage_manager');
-check_roles('max@pve!token2', '/vms/200', 'customer');
-
-print "all tests passed\n";
-
-exit (0);
-
diff --git a/test/test1.cfg b/test/test1.cfg
deleted file mode 100644 (file)
index d27c5d6..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-user:joe@pve:1:
-user:max@pve:1:
-user:alex@pve:1:
-user:sue@pve:1:
-user:carol@pam:1:
-
-group:testgroup1:joe@pve,max@pve,sue@pve:
-group:testgroup2:alex@pve,carol@pam,sue@pve:
-group:testgroup3:max@pve:
-
-role:storage_manager:Datastore.AllocateSpace,Datastore.Audit:
-role:customer:VM.Audit,VM.PowerMgmt:
-role:vm_admin:VM.Audit,VM.Allocate,Permissions.Modify,VM.Console:
-
-acl:1:/vms:@testgroup1:vm_admin:
-acl:1:/vms/100/:alex@pve,max@pve:customer:
-acl:1:/storage/nfs1:@testgroup2:storage_manager:
-acl:1:/users:max@pve:Administrator:
-
-acl:1:/vms/200:@testgroup3:storage_manager:
-acl:1:/vms/200:@testgroup2:NoAccess:
-
diff --git a/test/test2.cfg b/test/test2.cfg
deleted file mode 100644 (file)
index efad292..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-user:User1@pve:1:
-user:User2@pve:1:
-
-group:GroupA:User1@pve:
-group:GroupB:User1@pve:
-
-role:Role1:VM.PowerMgmt:
-role:Role2:VM.Console:
-
-acl:1:/vms:@GroupA:Role1:
-acl:1:/vms:@GroupB:Role2:
diff --git a/test/test3.cfg b/test/test3.cfg
deleted file mode 100644 (file)
index 75a9732..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-user:User1@pve:1:
-user:User2@pve:1:
-
-group:GroupA:User1@pve:
-group:GroupB:User1@pve:
-
-role:Role1:VM.PowerMgmt:
-role:Role2:VM.Console:
-
-acl:1:/vms:@GroupA:Role1:
-acl:1:/vms/200:@GroupB:Role2:
diff --git a/test/test4.cfg b/test/test4.cfg
deleted file mode 100644 (file)
index c6daccb..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-user:User1@pve:1:
-user:User2@pve:1:
-
-group:GroupA:User1@pve,User2@pve:
-group:GroupB:User1@pve,User2@pve:
-
-role:Role1:VM.PowerMgmt:
-role:Role2:VM.Console:
-
-acl:1:/vms:@GroupA:Role1:
-acl:1:/vms:User2@pve:NoAccess:
diff --git a/test/test5.cfg b/test/test5.cfg
deleted file mode 100644 (file)
index 13948cf..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-user:User1@pve:1:
-user:User2@pve:1:
-
-group:GroupA:User1@pve,User2@pve:
-group:GroupB:User1@pve,User2@pve:
-
-role:Role1:VM.PowerMgmt:
-role:Role2:VM.Console:
-role:Role3:VM.Console:
-
-acl:1:/vms:User1@pve:Role1:
-acl:1:/vms/100/a/b:User1@pve:Role2:
-
-acl:0:/kvm:User2@pve:Role2:
-acl:0:/kvm/vms:User2@pve:Role1:
-acl:0:/kvm/vms/100/a:User2@pve:Role3:
diff --git a/test/test6.cfg b/test/test6.cfg
deleted file mode 100644 (file)
index 4986910..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-user:User1@pve:1:
-user:User2@pve:1:
-user:User3@pve:1:
-user:User4@pve:1:
-
-group:DEVEL:User1@pve,User2@pve,User3@pve:
-group:MARKETING:User1@pve,User4@pve:
-
-role:RoleDEVEL:VM.PowerMgmt:
-role:RoleMARKETING:VM.Console:
-role:RoleTEST1:VM.Console:
-
-acl:1:/pool/devel:@DEVEL:RoleDEVEL:
-acl:1:/pool/marketing:@MARKETING:RoleMARKETING:
-
-acl:1:/vms:@DEVEL:RoleTEST1:
-acl:1:/vms:User3@pve:NoAccess:
-acl:1:/vms/300:@MARKETING:RoleTEST1:
-
-pool:devel:MITS development:500,501,502:store1 store2:
-pool:marketing:MITS marketing:600:store1:
diff --git a/test/test7.cfg b/test/test7.cfg
deleted file mode 100644 (file)
index a17d668..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-user:User1@pve:1:
-user:User2@pve:1:
-
-group:GroupA:User1@pve,User2@pve:
-group:GroupB:User1@pve,User2@pve:
-
-role:Role1:VM.PowerMgmt:
-role:Role2:VM.Console:
-role:Role3:VM.Console:
-
-acl:1:/pool/devel:User1@pve:NoAccess:
-
-acl:1:/vms:User1@pve:Role1:
-
-pool:devel:Development:100:store1:
diff --git a/test/test8.cfg b/test/test8.cfg
deleted file mode 100644 (file)
index 6b0eac6..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-user:joe@pve:1:
-user:max@pve:1:
-token:max@pve!token::0:
-token:max@pve!token2::1:
-user:alex@pve:1:
-user:sue@pve:1:
-user:carol@pam:1:
-token:carol@pam!token:
-
-group:testgroup1:joe@pve,max@pve,sue@pve:
-group:testgroup2:alex@pve,carol@pam,sue@pve:
-group:testgroup3:max@pve:
-
-role:storage_manager:Datastore.AllocateSpace,Datastore.Audit:
-role:customer:VM.Audit,VM.PowerMgmt:
-role:vm_admin:VM.Audit,VM.Allocate,Permissions.Modify,VM.Console:
-
-acl:1:/vms:@testgroup1:vm_admin:
-acl:1:/vms/100/:alex@pve,max@pve:customer:
-acl:1:/storage/nfs1:@testgroup2:storage_manager:
-acl:1:/users:max@pve:Administrator:
-
-acl:1:/vms/200:@testgroup3:storage_manager:
-acl:1:/vms/200:@testgroup2:NoAccess:
-
-acl:1:/vms/200:carol@pam!token:vm_admin
-acl:1:/vms/200:max@pve!token:storage_manager
-acl:1:/vms/200:max@pve!token2:customer