From: Thomas Lamprecht Date: Sun, 9 May 2021 17:37:47 +0000 (+0200) Subject: buildsys: split packaging and source build-systems X-Git-Url: https://git.proxmox.com/?p=pve-access-control.git;a=commitdiff_plain;h=197d1016fd2f75aa4654d2fd77b4142fc450e2ba buildsys: split packaging and source build-systems Much nicer to handle and work with than entangling all together in a single spaghetti pile. Signed-off-by: Thomas Lamprecht --- diff --git a/Makefile b/Makefile index c9e3c17..4db6f8f 100644 --- 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 index 857c672..0000000 --- a/PVE/API2/ACL.pm +++ /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 index a77694b..0000000 --- a/PVE/API2/AccessControl.pm +++ /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 \@.", - 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 ) and 'User.Modify' permission on /access/groups/ on a group where user 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 index 9c2b254..0000000 --- a/PVE/API2/Domains.pm +++ /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/' 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 index c463bd6..0000000 --- a/PVE/API2/Group.pm +++ /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/.", - 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 index 1bf8c05..0000000 --- a/PVE/API2/Makefile +++ /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 index 70a92b6..0000000 --- a/PVE/API2/Role.pm +++ /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 index 05de57f..0000000 --- a/PVE/API2/User.pm +++ /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/' on the realm of user , and 'User.Modify' permissions to '/access/groups/' 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 => '!', - 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 index f7d4e78..0000000 --- a/PVE/AccessControl.pm +++ /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 ! 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 index 88b2098..0000000 --- a/PVE/Auth/AD.pm +++ /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 index 97d0778..0000000 --- a/PVE/Auth/LDAP.pm +++ /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/.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 index 58ae362..0000000 --- a/PVE/Auth/Makefile +++ /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 index d016f83..0000000 --- a/PVE/Auth/PAM.pm +++ /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 index de39d35..0000000 --- a/PVE/Auth/PVE.pm +++ /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 index 1413053..0000000 --- a/PVE/Auth/Plugin.pm +++ /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 index 3054212..0000000 --- a/PVE/CLI/Makefile +++ /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 index cbbdb7d..0000000 --- a/PVE/CLI/pveum.pm +++ /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 index c839d8f..0000000 --- a/PVE/Makefile +++ /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 index e66107b..0000000 --- a/PVE/RPCEnvironment.pm +++ /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 index cfc60cc..0000000 --- a/PVE/TokenConfig.pm +++ /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 index 82e4eec..0000000 --- a/oathkeygen +++ /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 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 index 0000000..fefa157 --- /dev/null +++ b/src/Makefile @@ -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 index 0000000..857c672 --- /dev/null +++ b/src/PVE/API2/ACL.pm @@ -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 index 0000000..a77694b --- /dev/null +++ b/src/PVE/API2/AccessControl.pm @@ -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 \@.", + 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 ) and 'User.Modify' permission on /access/groups/ on a group where user 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 index 0000000..9c2b254 --- /dev/null +++ b/src/PVE/API2/Domains.pm @@ -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/' 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 index 0000000..c463bd6 --- /dev/null +++ b/src/PVE/API2/Group.pm @@ -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/.", + 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 index 0000000..1bf8c05 --- /dev/null +++ b/src/PVE/API2/Makefile @@ -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 index 0000000..70a92b6 --- /dev/null +++ b/src/PVE/API2/Role.pm @@ -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 index 0000000..05de57f --- /dev/null +++ b/src/PVE/API2/User.pm @@ -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/' on the realm of user , and 'User.Modify' permissions to '/access/groups/' 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 => '!', + 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 index 0000000..f7d4e78 --- /dev/null +++ b/src/PVE/AccessControl.pm @@ -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 ! 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 index 0000000..88b2098 --- /dev/null +++ b/src/PVE/Auth/AD.pm @@ -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 index 0000000..97d0778 --- /dev/null +++ b/src/PVE/Auth/LDAP.pm @@ -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/.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 index 0000000..58ae362 --- /dev/null +++ b/src/PVE/Auth/Makefile @@ -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 index 0000000..d016f83 --- /dev/null +++ b/src/PVE/Auth/PAM.pm @@ -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 index 0000000..de39d35 --- /dev/null +++ b/src/PVE/Auth/PVE.pm @@ -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 index 0000000..1413053 --- /dev/null +++ b/src/PVE/Auth/Plugin.pm @@ -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 index 0000000..3054212 --- /dev/null +++ b/src/PVE/CLI/Makefile @@ -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 index 0000000..cbbdb7d --- /dev/null +++ b/src/PVE/CLI/pveum.pm @@ -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 index 0000000..c839d8f --- /dev/null +++ b/src/PVE/Makefile @@ -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 index 0000000..e66107b --- /dev/null +++ b/src/PVE/RPCEnvironment.pm @@ -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 index 0000000..cfc60cc --- /dev/null +++ b/src/PVE/TokenConfig.pm @@ -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 index 0000000..82e4eec --- /dev/null +++ b/src/oathkeygen @@ -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 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 index 0000000..adaacb9 --- /dev/null +++ b/src/test/Makefile @@ -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 index 0000000..60429a9 --- /dev/null +++ b/src/test/auth-test.pl @@ -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 index 0000000..cb2a2ee --- /dev/null +++ b/src/test/dump-perm.pl @@ -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 \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 index 0000000..f08d30b --- /dev/null +++ b/src/test/dump-users.pl @@ -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 index 0000000..2fef7db --- /dev/null +++ b/src/test/parser_writer.pl @@ -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 index 0000000..12c95db --- /dev/null +++ b/src/test/perm-test1.pl @@ -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 index 0000000..1317051 --- /dev/null +++ b/src/test/perm-test2.pl @@ -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 index 0000000..b7b5480 --- /dev/null +++ b/src/test/perm-test3.pl @@ -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 index 0000000..718963e --- /dev/null +++ b/src/test/perm-test4.pl @@ -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 index 0000000..ebb40e3 --- /dev/null +++ b/src/test/perm-test5.pl @@ -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 index 0000000..dd433dd --- /dev/null +++ b/src/test/perm-test6.pl @@ -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 index 0000000..57ece07 --- /dev/null +++ b/src/test/perm-test7.pl @@ -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 index 0000000..67e0cb1 --- /dev/null +++ b/src/test/perm-test8.pl @@ -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 index 0000000..d27c5d6 --- /dev/null +++ b/src/test/test1.cfg @@ -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 index 0000000..efad292 --- /dev/null +++ b/src/test/test2.cfg @@ -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 index 0000000..75a9732 --- /dev/null +++ b/src/test/test3.cfg @@ -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 index 0000000..c6daccb --- /dev/null +++ b/src/test/test4.cfg @@ -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 index 0000000..13948cf --- /dev/null +++ b/src/test/test5.cfg @@ -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 index 0000000..4986910 --- /dev/null +++ b/src/test/test6.cfg @@ -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 index 0000000..a17d668 --- /dev/null +++ b/src/test/test7.cfg @@ -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 index 0000000..6b0eac6 --- /dev/null +++ b/src/test/test8.cfg @@ -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 index adaacb9..0000000 --- a/test/Makefile +++ /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 index 60429a9..0000000 --- a/test/auth-test.pl +++ /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 index cb2a2ee..0000000 --- a/test/dump-perm.pl +++ /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 \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 index f08d30b..0000000 --- a/test/dump-users.pl +++ /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 index 2fef7db..0000000 --- a/test/parser_writer.pl +++ /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 index 12c95db..0000000 --- a/test/perm-test1.pl +++ /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 index 1317051..0000000 --- a/test/perm-test2.pl +++ /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 index b7b5480..0000000 --- a/test/perm-test3.pl +++ /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 index 718963e..0000000 --- a/test/perm-test4.pl +++ /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 index ebb40e3..0000000 --- a/test/perm-test5.pl +++ /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 index dd433dd..0000000 --- a/test/perm-test6.pl +++ /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 index 57ece07..0000000 --- a/test/perm-test7.pl +++ /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 index 67e0cb1..0000000 --- a/test/perm-test8.pl +++ /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 index d27c5d6..0000000 --- a/test/test1.cfg +++ /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 index efad292..0000000 --- a/test/test2.cfg +++ /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 index 75a9732..0000000 --- a/test/test3.cfg +++ /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 index c6daccb..0000000 --- a/test/test4.cfg +++ /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 index 13948cf..0000000 --- a/test/test5.cfg +++ /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 index 4986910..0000000 --- a/test/test6.cfg +++ /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 index a17d668..0000000 --- a/test/test7.cfg +++ /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 index 6b0eac6..0000000 --- a/test/test8.cfg +++ /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