From: Daniel Krambrock via pve-devel Date: Thu, 11 Apr 2024 08:09:09 +0000 (+0200) Subject: fix #5335: sort ACL entries in user.cfg X-Git-Url: https://git.proxmox.com/?p=pve-access-control.git;a=commitdiff_plain;h=HEAD;hp=54d312f350a90cf2cb6c62cdc43009881e9c7cf3 fix #5335: sort ACL entries in user.cfg Stable sorting in user.cfg config file allows tracking changes by checking into git or when using automation like ansible. Signed-off-by: Daniel Krambrock Tested-by: Folge Gleumes --- diff --git a/Makefile b/Makefile index c9e3c17..ed09ea8 100644 --- a/Makefile +++ b/Makefile @@ -1,92 +1,48 @@ -include /usr/share/dpkg/pkg-info.mk -include /usr/share/dpkg/architecture.mk +include /usr/share/dpkg/default.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 +BUILDDIR ?= $(PACKAGE)-$(DEB_VERSION_UPSTREAM) 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 $@ + dpkg -i $(DEB) -.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} - echo "git clone git://git.proxmox.com/git/pve-access-control.git\\ngit checkout ${GITVERSION}" > ${BUILDDIR}/debian/SOURCE +$(BUILDDIR): + rm -rf $(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 -deb: ${DEB} -${DEB}: ${BUILDDIR} - cd ${BUILDDIR}; dpkg-buildpackage -b -us -uc - lintian ${DEB} +deb: $(DEB) +$(DEB): $(BUILDDIR) + cd $(BUILDDIR); dpkg-buildpackage -b -us -uc + lintian $(DEB) .PHONY: dsc -dsc: ${DSC} -${DSC}: ${BUILDDIR} - cd ${BUILDDIR}; dpkg-buildpackage -S -us -uc -d - lintian ${DSC} +dsc: $(DSC) +$(DSC): $(BUILDDIR) + cd $(BUILDDIR); dpkg-buildpackage -S -us -uc -d + lintian $(DSC) -.PHONY: upload -upload: ${DEB} - tar cf - ${DEB} | ssh repoman@repo.proxmox.com -- upload --product pve --dist buster --arch ${DEB_BUILD_ARCH} +sbuild: $(DSC) + sbuild $(DSC) -.PHONY: clean -clean: - rm -rf ${BUILDDIR} - make cleanup-docgen - rm -rf *.deb *.buildinfo *.changes ${PACKAGE}*.tar.gz *.dsc - find . -name '*~' -exec rm {} ';' +.PHONY: upload +upload: UPLOAD_DIST ?= $(DEB_DISTRIBUTION) +upload: $(DEB) + tar cf - $(DEB) | ssh repoman@repo.proxmox.com -- upload --product pve --dist $(UPLOAD_DIST) -.PHONY: distclean +.PHONY: clean distclean distclean: clean +clean: + rm -rf $(PACKAGE)-[0-9]*/ + rm -f *.dsc *.deb *.buildinfo *.build *.changes $(PACKAGE)*.tar.* diff --git a/PVE/API2/ACL.pm b/PVE/API2/ACL.pm deleted file mode 100644 index c340267..0000000 --- a/PVE/API2/ACL.pm +++ /dev/null @@ -1,202 +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; - - 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 8b5be1e..0000000 --- a/PVE/AccessControl.pm +++ /dev/null @@ -1,1708 +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; -} - -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 42feba8..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('common-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 c98ddde..0000000 --- a/PVE/CLI/pveum.pm +++ /dev/null @@ -1,181 +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::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], - }, - - 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/README b/README index 33643a6..3ce3eae 100644 --- a/README +++ b/README @@ -37,7 +37,7 @@ LDAP: example.com There are 2 special authentication domains name 'pve' and 'pam': - * pve: stores paswords to "/etc/pve/priv/shadow.cfg" (SHA256 crypt); + * pve: stores passwords to "/etc/pve/priv/shadow.cfg" (SHA256 crypt); * pam: use unix 'pam' @@ -96,6 +96,7 @@ privileges: VM.Config.Options: modify any other VM configuration Pool.Allocate: create/remove/modify a pool. + Pool.Audit: view a pool Datastore.Allocate: create/remove/modify a data store. Datastore.AllocateSpace: allocate space on a datastore @@ -131,7 +132,7 @@ privileges: role: - defines a sets of priviledges + defines a sets of privileges predefined roles: @@ -153,11 +154,19 @@ permission: ACL and Objects: ================ -An access control list (ACL) is a list of permissions attached to an object. The list specifies who or what is allowed to access the object and what operations are allowed to be performed on the object. - -Object: A Virtual machine, Network (bridge, venet), Hosts, Host Memory, Storage, ... - -We can identify our objects by an unique (file system like) path, which also defines a tree like hierarchy relation. ACL can be inherited. Permissions are inherited if the propagate flag is set on the parent. Child permissions always overwrite inherited permissions. User permission takes precedence over all group permissions. If multiple group permission apply the resulting role is the union of all those group priviledges. +An access control list (ACL) is a list of permissions attached to an object. +The list specifies who or what is allowed to access the object and what +operations are allowed to be performed on the object. + +Object: A Virtual machine, Network (bridge, venet), Hosts, Host Memory, +Storage, ... + +We can identify our objects by an unique (file system like) path, which also +defines a tree like hierarchy relation. ACL can be inherited. Permissions are +inherited if the propagate flag is set on the parent. Child permissions always +overwrite inherited permissions. User permission takes precedence over all +group permissions. If multiple group permission apply the resulting role is the +union of all those group privileges. There is at most one object permission per user or group diff --git a/debian/changelog b/debian/changelog index c2a4f99..ad18bb7 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,437 @@ +libpve-access-control (8.1.3) bookworm; urgency=medium + + * user: password change: require confirmation-password parameter so that + anybody gaining local or physical access to a device where a user is + logged in on a Proxmox VE web-interface cannot give them more permanent + access or deny the actual user accessing their account by changing the + password. Note that such an attack scenario means that the attacker + already has high privileges and can already control the resource + completely through another attack. + Such initial attacks (like stealing an unlocked device) are almost always + are outside of the control of our projects. Still, hardening the API a bit + by requiring a confirmation of the original password is to cheap to + implement to not do so. + + * jobs: realm sync: fix scheduled LDAP syncs not applying all attributes, + like comments, correctly + + -- Proxmox Support Team Fri, 22 Mar 2024 14:14:36 +0100 + +libpve-access-control (8.1.2) bookworm; urgency=medium + + * add Sys.AccessNetwork privilege + + -- Proxmox Support Team Wed, 28 Feb 2024 15:42:12 +0100 + +libpve-access-control (8.1.1) bookworm; urgency=medium + + * LDAP sync: fix-up assembling valid attribute set + + -- Proxmox Support Team Thu, 08 Feb 2024 19:03:26 +0100 + +libpve-access-control (8.1.0) bookworm; urgency=medium + + * api: user: limit the legacy user-keys option to the depreacated values + that could be set in the first limited TFA system, like e.g., 'x!yubico' + or base32 encoded secrets. + + * oidc: enforce generic URI regex for the ACR value to align with OIDC + specifications and with Proxmox Backup Server, which was recently changed + to actually be less strict. + + * LDAP sync: improve validation of synced attributes, closely limit the + mapped attributes names and their values to avoid glitches through odd + LDIF entries. + + * api: user: limit maximum length for first & last name to 1024 characters, + email to 254 characters (the maximum actually useable in practice) and + comment properties to 2048 characters. This avoid that a few single users + bloat the user.cfg to much by mistake, reducing the total amount of users + and ACLs that can be set up. Note that only users with User.Modify and + realm syncs (setup by admins) can change these in the first place, so this + is mostly to avoid mishaps and just to be sure. + + -- Proxmox Support Team Thu, 08 Feb 2024 17:50:59 +0100 + +libpve-access-control (8.0.7) bookworm; urgency=medium + + * fix #1148: allow up to three levels of pool nesting + + * pools: record parent/subpool information + + -- Proxmox Support Team Mon, 20 Nov 2023 12:24:13 +0100 + +libpve-access-control (8.0.6) bookworm; urgency=medium + + * perms: fix wrong /pools entry in default set of ACL paths + + * acl: add missing SDN ACL paths to allowed list + + -- Proxmox Support Team Fri, 17 Nov 2023 08:27:11 +0100 + +libpve-access-control (8.0.5) bookworm; urgency=medium + + * fix an issue where setting ldap passwords would refuse to work unless + at least one additional property was changed as well + + * add 'check-connection' parameter to create and update endpoints for ldap + based realms + + -- Proxmox Support Team Fri, 11 Aug 2023 13:35:23 +0200 + +libpve-access-control (8.0.4) bookworm; urgency=medium + + * Lookup of second factors is no longer tied to the 'keys' field in the + user.cfg. This fixes an issue where certain LDAP/AD sync job settings + could disable user-configured 2nd factors. + + * Existing-but-disabled TFA factors can no longer circumvent realm-mandated + TFA. + + -- Proxmox Support Team Thu, 20 Jul 2023 10:59:21 +0200 + +libpve-access-control (8.0.3) bookworm; urgency=medium + + * pveum: list tfa: recovery keys have no descriptions + + * pveum: list tfa: sort by user ID + + * drop assert_new_tfa_config_available for Proxmox VE 8, as the new format + is understood since pve-manager 7.0-15, and users must upgrade to Proxmox + VE 7.4 before upgrading to Proxmox VE 8 in addition to that. + + -- Proxmox Support Team Wed, 21 Jun 2023 19:45:29 +0200 + +libpve-access-control (8.0.2) bookworm; urgency=medium + + * api: users: sort groups to avoid "flapping" text + + * api: tfa: don't block tokens from viewing and list TFA entries, both are + safe to do for anybody with enough permissions to view a user. + + * api: tfa: add missing links for child-routes + + -- Proxmox Support Team Wed, 21 Jun 2023 18:13:54 +0200 + +libpve-access-control (8.0.1) bookworm; urgency=medium + + * tfa: cope with native versions in cluster version check + + -- Proxmox Support Team Fri, 09 Jun 2023 16:12:01 +0200 + +libpve-access-control (8.0.0) bookworm; urgency=medium + + * api: roles: forbid creating new roles starting with "PVE" namespace + + -- Proxmox Support Team Fri, 09 Jun 2023 10:14:28 +0200 + +libpve-access-control (8.0.0~3) bookworm; urgency=medium + + * rpcenv: api permission heuristic: query Sys.Modify for root ACL-path + + * access control: add /sdn/zones/// ACL object path + + * add helper for checking bridge access + + * add new SDN.Use privilege in PVESDNUser role, allowing one to specify + which user are allowed to use a bridge (or vnet, if SDN is installed) + + * add privileges and paths for cluster resource mapping + + -- Proxmox Support Team Wed, 07 Jun 2023 19:06:54 +0200 + +libpve-access-control (8.0.0~2) bookworm; urgency=medium + + * api: user index: only include existing tfa lock flags + + * add realm-sync plugin for jobs and CRUD api for realm-sync-jobs + + * roles: only include Permissions.Modify in Administrator built-in role. + As, depending on the ACL object path, this privilege might allow one to + change their own permissions, which was making the distinction between + Admin and PVEAdmin irrelevant. + + * acls: restrict less-privileged ACL modifications. Through allocate + permissions in pools, storages and virtual guests one can do some ACL + modifications without having the Permissions.Modify privilege, lock those + better down to ensure that one can only hand out only the subset of their + own privileges, never more. Note that this is mostly future proofing, as + the ACL object paths one could give out more permissions where already + limiting the scope. + + -- Proxmox Support Team Wed, 07 Jun 2023 11:34:30 +0200 + +libpve-access-control (8.0.0~1) bookworm; urgency=medium + + * bump pve-rs dependency to 0.8.3 + + * drop old verify_tfa api call (POST /access/tfa) + + * drop support for old login API: + - 'new-format' is now considured to be 1 and ignored by the API + + * pam auth: set PAM_RHOST to allow pam configs to log/restrict/... by remote + address + + * cli: add 'pveum tfa list' + + * cli: add 'pveum tfa unlock' + + * enable lockout of TFA: + - too many TOTP attempts will lock out of TOTP + - using a recovery key will unlock TOTP + - too many TFA attempts will lock a user's TFA auth for an hour + + * api: add /access/users//unlock-tfa to unlock a user's TFA + authentication if it was locked by too many wrong 2nd factor login attempts + + * api: /access/tfa and /access/users now include the tfa lockout status + + -- Proxmox Support Team Mon, 05 Jun 2023 14:52:29 +0200 + +libpve-access-control (7.99.0) bookworm; urgency=medium + + * initial re-build for Proxmox VE 8.x series + + * switch to native versioning + + -- Proxmox Support Team Sun, 21 May 2023 10:34:19 +0200 + +libpve-access-control (7.4-3) bullseye; urgency=medium + + * use new 2nd factor verification from pve-rs + + -- Proxmox Support Team Tue, 16 May 2023 13:31:28 +0200 + +libpve-access-control (7.4-2) bullseye; urgency=medium + + * fix #4609: fix regression where a valid DN in the ldap/ad realm config + wasn't accepted anymore + + -- Proxmox Support Team Thu, 23 Mar 2023 15:44:21 +0100 + +libpve-access-control (7.4-1) bullseye; urgency=medium + + * realm sync: refactor scope/remove-vanished into a standard option + + * ldap: Allow quoted values for DN attribute values + + -- Proxmox Support Team Mon, 20 Mar 2023 17:16:11 +0100 + +libpve-access-control (7.3-2) bullseye; urgency=medium + + * fix #4518: dramatically improve ACL computation performance + + * userid format: clarify that this is the full name@realm in description + + -- Proxmox Support Team Mon, 06 Mar 2023 11:40:11 +0100 + +libpve-access-control (7.3-1) bullseye; urgency=medium + + * realm: sync: allow explicit 'none' for 'remove-vanished' option + + -- Proxmox Support Team Fri, 16 Dec 2022 13:11:04 +0100 + +libpve-access-control (7.2-5) bullseye; urgency=medium + + * api: realm sync: avoid separate log line for "remove-vanished" opt + + * auth ldap/ad: compare group member dn case-insensitively + + * two factor auth: only lock tfa config for recovery keys + + * privs: add Sys.Incoming for guarding cross-cluster data streams like guest + migrations and storage migrations + + -- Proxmox Support Team Thu, 17 Nov 2022 13:09:17 +0100 + +libpve-access-control (7.2-4) bullseye; urgency=medium + + * fix #4074: increase API OpenID code size limit to 2048 + + * auth key: protect against rare chance of a double rotation in clusters, + leaving the potential that some set of nodes have the earlier key cached, + that then got rotated out due to the race, resulting in a possible other + set of nodes having the newer key cached. This is a split view of the auth + key and may resulting in spurious failures if API requests are made to a + different node than the ticket was generated on. + In addition to that, the "keep validity of old tickets if signed in the + last two hours before rotation" logic was disabled too in such a case, + making such tickets invalid too early. + Note that both are cases where Proxmox VE was too strict, so while this + had no security implications it can be a nuisance, especially for + environments that use the API through an automated or scripted way + + -- Proxmox Support Team Thu, 14 Jul 2022 08:36:51 +0200 + +libpve-access-control (7.2-3) bullseye; urgency=medium + + * api: token: use userid-group as API perm check to avoid being overly + strict through a misguided use of user id for non-root users. + + * perm check: forbid undefined/empty ACL path for future proofing of against + above issue + + -- Proxmox Support Team Mon, 20 Jun 2022 15:51:14 +0200 + +libpve-access-control (7.2-2) bullseye; urgency=medium + + * permissions: merge propagation flag for multiple roles on a path that + share privilege in a deterministic way, to avoid that it gets lost + depending on perl's random sort, which would result in returing less + privileges than an auth-id actually had. + + * permissions: avoid that token and user privilege intersection is to strict + for user permissions that have propagation disabled. + + -- Proxmox Support Team Fri, 03 Jun 2022 14:02:30 +0200 + +libpve-access-control (7.2-1) bullseye; urgency=medium + + * user check: fix expiration/enable order + + -- Proxmox Support Team Tue, 31 May 2022 13:43:37 +0200 + +libpve-access-control (7.1-8) bullseye; urgency=medium + + * fix #3668: realm-sync: replace 'full' & 'purge' with 'remove- + vanished' + + -- Proxmox Support Team Thu, 28 Apr 2022 17:02:46 +0200 + +libpve-access-control (7.1-7) bullseye; urgency=medium + + * userid-group check: distinguish create and update + + * api: get user: declare token schema + + -- Proxmox Support Team Mon, 21 Mar 2022 16:15:23 +0100 + +libpve-access-control (7.1-6) bullseye; urgency=medium + + * fix #3768: warn on bad u2f or webauthn settings + + * tfa: when modifying others, verify the current user's password + + * tfa list: account for admin permissions + + * fix realm sync permissions + + * fix token permission display bug + + * include SDN permissions in permission tree + + -- Proxmox Support Team Fri, 21 Jan 2022 14:20:42 +0100 + +libpve-access-control (7.1-5) bullseye; urgency=medium + + * openid: fix username-claim fallback + + -- Proxmox Support Team Thu, 25 Nov 2021 07:57:38 +0100 + +libpve-access-control (7.1-4) bullseye; urgency=medium + + * set current origin in the webauthn config if no fixed origin was + configured, to support webauthn via subdomains + + -- Proxmox Support Team Mon, 22 Nov 2021 14:04:06 +0100 + +libpve-access-control (7.1-3) bullseye; urgency=medium + + * openid: allow arbitrary username-claims + + * openid: support configuring the prompt, scopes and ACR values + + -- Proxmox Support Team Fri, 19 Nov 2021 08:11:52 +0100 + +libpve-access-control (7.1-2) bullseye; urgency=medium + + * catch incompatible tfa entries with a nice error + + -- Proxmox Support Team Wed, 17 Nov 2021 13:44:45 +0100 + +libpve-access-control (7.1-1) bullseye; urgency=medium + + * tfa: map HTTP 404 error in get_tfa_entry correctly + + -- Proxmox Support Team Mon, 15 Nov 2021 15:33:22 +0100 + +libpve-access-control (7.0-7) bullseye; urgency=medium + + * fix #3513: pass configured proxy to OpenID + + * use rust based parser for TFA config + + * use PBS-like auth api call flow, + + * merge old user.cfg keys to tfa config when adding entries + + * implement version checks for new tfa config writer to ensure all + cluster nodes are ready to avoid login issues + + * tickets: add tunnel ticket + + -- Proxmox Support Team Thu, 11 Nov 2021 18:17:49 +0100 + +libpve-access-control (7.0-6) bullseye; urgency=medium + + * fix regression in user deletion when realm does not enforce TFA + + -- Proxmox Support Team Thu, 21 Oct 2021 12:28:52 +0200 + +libpve-access-control (7.0-5) bullseye; urgency=medium + + * acl: check path: add /sdn/vnets/* path + + * fix #2302: allow deletion of users when realm enforces TFA + + * api: delete user: disable user first to avoid surprise on error during the + various cleanup action required for user deletion (e.g., TFA, ACL, group) + + -- Proxmox Support Team Mon, 27 Sep 2021 15:50:47 +0200 + +libpve-access-control (7.0-4) bullseye; urgency=medium + + * realm: add OpenID configuration + + * api: implement OpenID related endpoints + + * implement opt-in OpenID autocreate user feature + + * api: user: add 'realm-type' to user list response + + -- Proxmox Support Team Fri, 02 Jul 2021 13:45:46 +0200 + +libpve-access-control (7.0-3) bullseye; urgency=medium + + * api: acl: add missing `/access/realm/`, `/access/group/` and + `/sdn/zones/` to allowed ACL paths + + -- Proxmox Support Team Mon, 21 Jun 2021 10:31:19 +0200 + +libpve-access-control (7.0-2) bullseye; urgency=medium + + * fix #3402: add Pool.Audit privilege - custom roles containing + Pool.Allocate must be updated to include the new privilege. + + -- Proxmox Support Team Tue, 1 Jun 2021 11:28:38 +0200 + +libpve-access-control (7.0-1) bullseye; urgency=medium + + * re-build for Debian 11 Bullseye based releases + + -- Proxmox Support Team Sun, 09 May 2021 18:18:23 +0200 + +libpve-access-control (6.4-1) pve; urgency=medium + + * fix #1670: change PAM service name to project specific name + + * fix #1500: permission path syntax check for access control + + * pveum: add resource pool CLI commands + + -- Proxmox Support Team Sat, 24 Apr 2021 19:48:21 +0200 + libpve-access-control (6.1-3) pve; urgency=medium * partially fix #2825: authkey: rotate if it was generated in the @@ -35,7 +469,7 @@ libpve-access-control (6.0-7) pve; urgency=medium * add realm sub commands to pveum CLI tool - * api: domains: add user group sync API enpoint + * api: domains: add user group sync API endpoint * allow one to sync and import users and groups from LDAP/AD based realms @@ -120,7 +554,7 @@ libpve-access-control (5.1-10) unstable; urgency=medium libpve-access-control (5.1-9) unstable; urgency=medium * store the tfa type in user.cfg allowing to get it without proxying the call - to a higher priviledged daemon. + to a higher privileged daemon. * tfa: realm required TFA should lock out users without TFA configured, as it was done before Proxmox VE 5.4 @@ -506,7 +940,7 @@ libpve-access-control (3.0-4) unstable; urgency=low libpve-access-control (3.0-3) unstable; urgency=low - * Add new role PVETemplateUser (and VM.Clone priviledge) + * Add new role PVETemplateUser (and VM.Clone privilege) -- Proxmox Support Team Mon, 29 Apr 2013 11:42:15 +0200 @@ -573,7 +1007,7 @@ libpve-access-control (1.0-19) unstable; urgency=low libpve-access-control (1.0-18) unstable; urgency=low - * fix bug #151: corretly parse username inside ticket + * fix bug #151: correctly parse username inside ticket * fix bug #152: allow user to change his own password @@ -668,7 +1102,7 @@ libpve-access-control (1.0-4) unstable; urgency=low libpve-access-control (1.0-3) unstable; urgency=low * add support for delayed parameter parsing - We need that to disable - file upload for normal API request (avoid DOS attacs) + file upload for normal API request (avoid DOS attacks) -- Proxmox Support Team Fri, 02 Dec 2011 09:56:10 +0100 diff --git a/debian/compat b/debian/compat deleted file mode 100644 index f599e28..0000000 --- a/debian/compat +++ /dev/null @@ -1 +0,0 @@ -10 diff --git a/debian/control b/debian/control index 9727e24..580e1c1 100644 --- a/debian/control +++ b/debian/control @@ -2,16 +2,21 @@ Source: libpve-access-control Section: perl Priority: optional Maintainer: Proxmox Support Team -Build-Depends: debhelper (>= 10.0.0~), +Build-Depends: debhelper-compat (= 13), + libanyevent-perl, libauthen-pam-perl, libnet-ldap-perl, - libpve-common-perl (>= 6.0-11), - lintian, - perl (>= 5.10.0-19), libpve-cluster-perl, + libpve-common-perl (>= 8.0.8), + libpve-rs-perl, + libtest-mockmodule-perl, + liburi-perl, + libuuid-perl, + lintian, + perl, pve-cluster (>= 6.1-4), - pve-doc-generator (>= 5.3-3), -Standards-Version: 3.9.8 + pve-doc-generator (>= 5.3-3) +Standards-Version: 4.6.2 Homepage: https://www.proxmox.com Package: libpve-access-control @@ -24,15 +29,16 @@ Depends: libauthen-pam-perl, libmime-base32-perl, libnet-ldap-perl, libnet-ssleay-perl, - libpve-common-perl (>= 6.0-18), libpve-cluster-perl, + libpve-common-perl (>= 8.0.8), + libpve-rs-perl (>= 0.8.3), libpve-u2f-server-perl (>= 1.0-2), + liburi-perl, libuuid-perl, - perl (>= 5.6.0-16), pve-cluster (>= 6.1-4), ${misc:Depends}, - ${perl:Depends}, -Breaks: pve-manager (<= 6.0-9), + ${perl:Depends} +Breaks: pve-manager (<< 7.0-15) Description: Proxmox VE access control library This package contains the role based user management and access control function used by Proxmox VE. diff --git a/debian/rules b/debian/rules index b6ca118..98fcd1b 100755 --- a/debian/rules +++ b/debian/rules @@ -12,5 +12,5 @@ %: dh $@ -override-dh_missing: +override_dh_missing: dh_missing --fail-missing diff --git a/debian/source/format b/debian/source/format index d3827e7..89ae9db 100644 --- a/debian/source/format +++ b/debian/source/format @@ -1 +1 @@ -1.0 +3.0 (native) diff --git a/oathkeygen b/oathkeygen deleted file mode 100755 index 89e385a..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 randon 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..5e1ffd7 --- /dev/null +++ b/src/Makefile @@ -0,0 +1,48 @@ +PACKAGE ?= libpve-access-control + +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: + rm -f *.xml.tmp *.1 *.5 *.8 *{synopsis,opts}.adoc docinfo.xml diff --git a/src/PVE/API2/ACL.pm b/src/PVE/API2/ACL.pm new file mode 100644 index 0000000..93adb78 --- /dev/null +++ b/src/PVE/API2/ACL.pm @@ -0,0 +1,231 @@ +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_root}) { + return $res; + } + + my $audit = $rpcenv->check($authuser, '/access', ['Sys.Audit'], 1); + + my $root = $usercfg->{acl_root}; + PVE::AccessControl::iterate_acl_tree("/", $root, sub { + my ($path, $node) = @_; + foreach my $type (qw(user group token)) { + my $d = $node->{"${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 $rpcenv = PVE::RPCEnvironment::get(); + my $authuser = $rpcenv->get_user(); + my $auth_user_privs = $rpcenv->permissions($authuser, $path); + + my $propagate = 1; + + if (defined($param->{propagate})) { + $propagate = $param->{propagate} ? 1 : 0; + } + + my $node = PVE::AccessControl::find_acl_tree_node($cfg->{acl_root}, $path); + + foreach my $role (split_list($param->{roles})) { + die "role '$role' does not exist\n" + if !$cfg->{roles}->{$role}; + + if (!$auth_user_privs->{'Permissions.Modify'}) { + # 'perm-modify' allows /vms/* with VM.Allocate and similar restricted use cases + # filter those to only allow handing out a subset of currently active privs + my $role_privs = $cfg->{roles}->{$role}; + my $verb = $param->{delete} ? 'remove' : 'add'; + foreach my $priv (keys $role_privs->%*) { + raise_param_exc({ role => "Cannot $verb role '$role' - requires 'Permissions.Modify' or superset of privileges." }) + if !defined($auth_user_privs->{$priv}); + + # propagation is only potentially problematic for adding ACLs, not removing.. + raise_param_exc({ role => "Cannot $verb role '$role' with propagation - requires 'Permissions.Modify' or propagated superset of privileges." }) + if $propagate && $auth_user_privs->{$priv} != $propagate && !$param->{delete}; + } + + # NoAccess has no privs, needs an explicit check + raise_param_exc({ role => "Cannot $verb role '$role' - requires 'Permissions.Modify'"}) + if $role eq 'NoAccess'; + } + + foreach my $group (split_list($param->{groups})) { + + die "group '$group' does not exist\n" + if !$cfg->{groups}->{$group}; + + if ($param->{delete}) { + delete($node->{groups}->{$group}->{$role}); + } else { + $node->{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 ($node->{users}->{$username}->{$role}); + } else { + $node->{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 $node->{tokens}->{$tokenid}->{$role}; + } else { + $node->{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..c55a7b3 --- /dev/null +++ b/src/PVE/API2/AccessControl.pm @@ -0,0 +1,513 @@ +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::API2::OpenId; +use PVE::API2::TFA; +use PVE::Auth::Plugin; +use PVE::OTP; + +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 ({ + subclass => "PVE::API2::OpenId", + path => 'openid', +}); + +__PACKAGE__->register_method ({ + subclass => "PVE::API2::TFA", + path => 'tfa', +}); + +__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 sub verify_auth : prototype($$$$$$) { + my ($rpcenv, $username, $pw_or_ticket, $otp, $path, $privs) = @_; + + my $normpath = PVE::AccessControl::normalize_path($path); + die "invalid path - $path\n" if defined($path) && !defined($normpath); + + 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 sub create_ticket_do : prototype($$$$$) { + my ($rpcenv, $username, $pw_or_ticket, $otp, $tfa_challenge) = @_; + + die "TFA response should be in 'password', not 'otp' when 'tfa-challenge' is set\n" + if defined($otp) && defined($tfa_challenge); + + my ($ticketuser, undef, $tfa_info); + if (!defined($tfa_challenge)) { + # We only verify this ticket if we're not responding to a TFA challenge, as in that case + # it is a TFA-data ticket and will be verified by `authenticate_user`. + + ($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, + $tfa_challenge, + ); + } + + my %extra; + my $ticket_data = $username; + my $aad; + if (defined($tfa_info)) { + $extra{NeedTFA} = 1; + $ticket_data = "!tfa!$tfa_info"; + $aad = $username; + } + + my $ticket = PVE::AccessControl::assemble_ticket($ticket_data, $aad); + my $csrftoken = PVE::AccessControl::assemble_csrf_prevention_token($username); + + return { + ticket => $ticket, + username => $username, + CSRFPreventionToken => $csrftoken, + %extra, + }; +}; + +__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, + }, + 'new-format' => { + type => 'boolean', + description => 'This parameter is now ignored and assumed to be 1.', + optional => 1, + default => 1, + }, + 'tfa-challenge' => { + type => 'string', + description => "The signed TFA challenge string the user wants to respond to.", + optional => 1, + }, + } + }, + 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_do( + $rpcenv, + $username, + $param->{password}, + $param->{otp}, + $param->{'tfa-challenge'}, + ); + } + }; + 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} = $rpcenv->compute_api_permission($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, + }, + 'confirmation-password' => $PVE::API2::TFA::OPTIONAL_PASSWORD_SCHEMA, + } + }, + returns => { type => "null" }, + code => sub { + my ($param) = @_; + + my $rpcenv = PVE::RPCEnvironment::get(); + my $authuser = $rpcenv->get_user(); + + my ($userid, $ruid, $realm) = $rpcenv->reauth_user_for_user_modification( + $authuser, + $param->{userid}, + $param->{'confirmation-password'}, + 'confirmation-password', + ); + + 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 => '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..e7b7d39 --- /dev/null +++ b/src/PVE/API2/Domains.pm @@ -0,0 +1,575 @@ +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); + +# maps old 'full'/'purge' parameters to new 'remove-vanished' +# TODO remove when we delete the 'full'/'purge' parameters +my $map_remove_vanished = sub { + my ($opt, $delete_deprecated) = @_; + + if (!defined($opt->{'remove-vanished'}) && ($opt->{full} || $opt->{purge})) { + my $props = []; + push @$props, 'entry', 'properties' if $opt->{full}; + push @$props, 'acl' if $opt->{purge}; + $opt->{'remove-vanished'} = join(';', @$props); + } + + if ($delete_deprecated) { + delete $opt->{full}; + delete $opt->{purge}; + } + + return $opt; +}; + +my $map_sync_default_options = sub { + my ($cfg, $delete_deprecated) = @_; + + my $opt = $cfg->{'sync-defaults-options'}; + return if !defined($opt); + my $sync_opts_fmt = PVE::JSONSchema::get_format('realm-sync-options'); + + my $old_opt = PVE::JSONSchema::parse_property_string($sync_opts_fmt, $opt); + + my $new_opt = $map_remove_vanished->($old_opt, $delete_deprecated); + + $cfg->{'sync-defaults-options'} = PVE::JSONSchema::print_property_string($new_opt, $sync_opts_fmt); +}; + +__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(0, { + 'check-connection' => { + description => 'Check bind connection to the server.', + type => 'boolean', + optional => 1, + default => 0, + }, + }), + 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}; + my $check_connection = extract_param($param, 'check-connection'); + + 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'); + + die "'check-connection' parameter can only be set for realms of type 'ldap' or 'ad'\n" + if defined($check_connection) && !($type eq 'ldap' || $type eq 'ad'); + + if ($type eq 'ad' || $type eq 'ldap') { + $map_sync_default_options->($param, 1); + } + + 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); + + # Only for LDAP/AD, implied through the existence of the 'check-connection' param + $plugin->check_connection($realm, $config, password => $password) + if $check_connection; + + 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(0, { + 'check-connection' => { + description => 'Check bind connection to the server.', + type => 'boolean', + optional => 1, + default => 0, + }, + }), + 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'); + my $type = $ids->{$realm}->{type}; + my $check_connection = extract_param($param, 'check-connection'); + + die "domain '$realm' does not exist\n" + if !$ids->{$realm}; + + die "'check-connection' parameter can only be set for realms of type 'ldap' or 'ad'\n" + if defined($check_connection) && !($type eq 'ldap' || $type eq 'ad'); + + my $delete_str = extract_param($param, 'delete'); + die "no options specified\n" + if !$delete_str && !scalar(keys %$param) && !defined($password); + + my $delete_pw = 0; + foreach my $opt (PVE::Tools::split_list($delete_str)) { + delete $ids->{$realm}->{$opt}; + $delete_pw = 1 if $opt eq 'password'; + } + + if ($type eq 'ad' || $type eq 'ldap') { + $map_sync_default_options->($param, 1); + } + + my $plugin = PVE::Auth::Plugin->lookup($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); + } + + # Only for LDAP/AD, implied through the existence of the 'check-connection' param + $plugin->check_connection($realm, $ids->{$realm}, password => $password) + if $check_connection; + + 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; + + my $type = $data->{type}; + if ($type eq 'ad' || $type eq 'ldap') { + $map_sync_default_options->($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) = @_; + + if (defined(my $vanished = $opts->{'remove-vanished'})) { + print "syncing users (remove-vanished opts: $vanished)\n"; + } else { + print "syncing users\n"; + } + + $usercfg->{users} = {} if !defined($usercfg->{users}); + my $users = $usercfg->{users}; + my $to_remove = { map { $_ => 1 } split(';', $opts->{'remove-vanished'} // '') }; + + print "deleting outdated existing users first\n" if $to_remove->{entry}; + foreach my $userid (sort keys %$users) { + next if $userid !~ m/\@$realm$/; + next if defined($synced_users->{$userid}); + + if ($to_remove->{entry}) { + print "remove user '$userid'\n"; + delete $users->{$userid}; + } + + if ($to_remove->{acl}) { + print "purge users '$userid' ACL entries\n"; + PVE::AccessControl::delete_user_acl($userid, $usercfg); + } + } + + foreach my $userid (sort keys %$synced_users) { + my $synced_user = $synced_users->{$userid} // {}; + my $olduser = $users->{$userid}; + if ($to_remove->{properties} || !defined($olduser)) { + # we use the synced user, but want to keep some properties on update + if (defined($olduser)) { + print "overwriting user '$userid'\n"; + } else { + $olduser = {}; + print "adding user '$userid'\n"; + } + my $user = $users->{$userid} = $synced_user; + + my $enabled = $olduser->{enable} // $opts->{'enable-new'}; + $user->{enable} = $enabled if defined($enabled); + $user->{tokens} = $olduser->{tokens} if defined($olduser->{tokens}); + + } else { + foreach my $attr (keys %$synced_user) { + $olduser->{$attr} = $synced_user->{$attr}; + } + print "updating user '$userid'\n"; + } + } +}; + +my $update_groups = sub { + my ($usercfg, $realm, $synced_groups, $opts) = @_; + + if (defined(my $vanished = $opts->{'remove-vanished'})) { + print "syncing groups (remove-vanished opts: $vanished)\n"; + } else { + print "syncing groups\n"; + } + + $usercfg->{groups} = {} if !defined($usercfg->{groups}); + my $groups = $usercfg->{groups}; + my $to_remove = { map { $_ => 1 } split(';', $opts->{'remove-vanished'} // '') }; + + print "deleting outdated existing groups first\n" if $to_remove->{entry}; + foreach my $groupid (sort keys %$groups) { + next if $groupid !~ m/\-$realm$/; + next if defined($synced_groups->{$groupid}); + + if ($to_remove->{entry}) { + print "remove group '$groupid'\n"; + delete $groups->{$groupid}; + } + + if ($to_remove->{acl}) { + print "purge groups '$groupid' ACL entries\n"; + PVE::AccessControl::delete_group_acl($groupid, $usercfg); + } + } + + foreach my $groupid (sort keys %$synced_groups) { + my $synced_group = $synced_groups->{$groupid}; + my $oldgroup = $groups->{$groupid}; + if ($to_remove->{properties} || !defined($oldgroup)) { + if (defined($oldgroup)) { + print "overwriting group '$groupid'\n"; + } else { + print "adding group '$groupid'\n"; + } + $groups->{$groupid} = $synced_group; + } else { + foreach my $attr (keys %$synced_group) { + $oldgroup->{$attr} = $synced_group->{$attr}; + } + print "updating 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}; + } + + $map_remove_vanished->($res, 1); + + # only scope has no implicit value + raise_param_exc({ + "scope" => 'Not passed as parameter and not defined in realm default sync options.' + }) if !defined($res->{scope}); + + 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', + ['perm', '/access/realm/{realm}', ['Realm.AllocateUser']], + ['perm', '/access/groups', ['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/Jobs/Makefile b/src/PVE/API2/Jobs/Makefile new file mode 100644 index 0000000..fd79607 --- /dev/null +++ b/src/PVE/API2/Jobs/Makefile @@ -0,0 +1,6 @@ +SOURCES = \ + RealmSync.pm \ + +.PHONY: install +install: + for i in $(SOURCES); do install -D -m 0644 $$i $(DESTDIR)$(PERLDIR)/PVE/API2/Jobs/$$i; done diff --git a/src/PVE/API2/Jobs/RealmSync.pm b/src/PVE/API2/Jobs/RealmSync.pm new file mode 100644 index 0000000..111ffc3 --- /dev/null +++ b/src/PVE/API2/Jobs/RealmSync.pm @@ -0,0 +1,284 @@ +package PVE::API2::Jobs::RealmSync; + +use strict; +use warnings; + +use PVE::Cluster qw(cfs_lock_file cfs_read_file cfs_write_file); +use PVE::Exception qw(raise_param_exc); +use PVE::JSONSchema qw(get_standard_option); +use PVE::Job::Registry (); +use PVE::SectionConfig (); +use PVE::Tools qw(extract_param); + +use PVE::Jobs::RealmSync (); + +use base qw(PVE::RESTHandler); + +my $get_cluster_last_run = sub { + my ($jobid) = @_; + + my $state = eval { PVE::Jobs::RealmSync::get_state($jobid) }; + die "error on getting state for '$jobid': $@\n" if $@; + + if (my $upid = $state->{upid}) { + if (my $decoded = PVE::Tools::upid_decode($upid)) { + return $decoded->{starttime}; + } + } else { + return $state->{time}; + } + + return undef; +}; + +__PACKAGE__->register_method ({ + name => 'syncjob_index', + path => '', + method => 'GET', + description => "List configured realm-sync-jobs.", + permissions => { + check => ['perm', '/', ['Sys.Audit']], + }, + parameters => { + additionalProperties => 0, + properties => {}, + }, + returns => { + type => 'array', + items => { + type => "object", + properties => { + id => { + description => "The ID of the entry.", + type => 'string' + }, + enabled => { + description => "If the job is enabled or not.", + type => 'boolean', + }, + comment => { + description => "A comment for the job.", + type => 'string', + optional => 1, + }, + schedule => { + description => "The configured sync schedule.", + type => 'string', + }, + realm => get_standard_option('realm'), + scope => get_standard_option('sync-scope'), + 'remove-vanished' => get_standard_option('sync-remove-vanished'), + 'last-run' => { + description => "Last execution time of the job in seconds since the beginning of the UNIX epoch", + type => 'integer', + optional => 1, + }, + 'next-run' => { + description => "Next planned execution time of the job in seconds since the beginning of the UNIX epoch.", + type => 'integer', + optional => 1, + }, + }, + }, + links => [ { rel => 'child', href => "{id}" } ], + }, + code => sub { + my ($param) = @_; + + my $rpcenv = PVE::RPCEnvironment::get(); + my $user = $rpcenv->get_user(); + + my $jobs_data = cfs_read_file('jobs.cfg'); + my $order = $jobs_data->{order}; + my $jobs = $jobs_data->{ids}; + + my $res = []; + for my $jobid (sort { $order->{$a} <=> $order->{$b} } keys %$jobs) { + my $job = $jobs->{$jobid}; + next if $job->{type} ne 'realm-sync'; + + $job->{id} = $jobid; + if (my $schedule = $job->{schedule}) { + $job->{'last-run'} = eval { $get_cluster_last_run->($jobid) }; + my $last_run = $job->{'last-run'} // time(); # current time as fallback + + my $calendar_event = Proxmox::RS::CalendarEvent->new($schedule); + my $next_run = $calendar_event->compute_next_event($last_run); + $job->{'next-run'} = $next_run if defined($next_run); + } + + push @$res, $job; + } + + return $res; + }}); + +__PACKAGE__->register_method({ + name => 'read_job', + path => '{id}', + method => 'GET', + description => "Read realm-sync job definition.", + permissions => { + check => ['perm', '/', ['Sys.Audit']], + }, + parameters => { + additionalProperties => 0, + properties => { + id => { + type => 'string', + format => 'pve-configid', + }, + }, + }, + returns => { + type => 'object', + }, + code => sub { + my ($param) = @_; + + my $jobs = cfs_read_file('jobs.cfg'); + my $id = $param->{id}; + my $job = $jobs->{ids}->{$id}; + return $job if $job && $job->{type} eq 'realm-sync'; + + raise_param_exc({ id => "No such job '$id'" }); + + }}); + +__PACKAGE__->register_method({ + name => 'create_job', + path => '{id}', + method => 'POST', + protected => 1, + description => "Create new realm-sync job.", + permissions => { + description => "'Realm.AllocateUser' on '/access/realm/' and " + ."'User.Modify' permissions to '/access/groups/'.", + check => [ 'and', + ['perm', '/access/realm/{realm}', ['Realm.AllocateUser']], + ['perm', '/access/groups', ['User.Modify']], + ], + }, + parameters => PVE::Jobs::RealmSync->createSchema(), + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $id = extract_param($param, 'id'); + + cfs_lock_file('jobs.cfg', undef, sub { + my $data = cfs_read_file('jobs.cfg'); + + die "Job '$id' already exists\n" + if $data->{ids}->{$id}; + + my $plugin = PVE::Job::Registry->lookup('realm-sync'); + my $opts = $plugin->check_config($id, $param, 1, 1); + + my $realm = $opts->{realm}; + my $cfg = cfs_read_file('domains.cfg'); + + raise_param_exc({ realm => "No such realm '$realm'" }) + if !defined($cfg->{ids}->{$realm}); + + my $realm_type = $cfg->{ids}->{$realm}->{type}; + raise_param_exc({ realm => "Only LDAP/AD realms can be synced." }) + if $realm_type ne 'ldap' && $realm_type ne 'ad'; + + $data->{ids}->{$id} = $opts; + + cfs_write_file('jobs.cfg', $data); + }); + die "$@" if ($@); + + return undef; + }}); + +__PACKAGE__->register_method({ + name => 'update_job', + path => '{id}', + method => 'PUT', + protected => 1, + description => "Update realm-sync job definition.", + permissions => { + description => "'Realm.AllocateUser' on '/access/realm/' and 'User.Modify'" + ." permissions to '/access/groups/'.", + check => [ 'and', + ['perm', '/access/realm/{realm}', ['Realm.AllocateUser']], + ['perm', '/access/groups', ['User.Modify']], + ], + }, + parameters => PVE::Jobs::RealmSync->updateSchema(), + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $id = extract_param($param, 'id'); + my $delete = extract_param($param, 'delete'); + $delete = [PVE::Tools::split_list($delete)] if $delete; + + die "no job options specified\n" if !scalar(keys %$param); + + cfs_lock_file('jobs.cfg', undef, sub { + my $jobs = cfs_read_file('jobs.cfg'); + + my $plugin = PVE::Job::Registry->lookup('realm-sync'); + my $opts = $plugin->check_config($id, $param, 0, 1); + + my $job = $jobs->{ids}->{$id}; + die "no such realm-sync job\n" if !$job || $job->{type} ne 'realm-sync'; + + my $options = $plugin->options(); + PVE::SectionConfig::delete_from_config($job, $options, $opts, $delete); + + $job->{$_} = $param->{$_} for keys $param->%*; + + cfs_write_file('jobs.cfg', $jobs); + + return; + }); + die "$@" if ($@); + }}); + + +__PACKAGE__->register_method({ + name => 'delete_job', + path => '{id}', + method => 'DELETE', + description => "Delete realm-sync job definition.", + permissions => { + check => ['perm', '/', ['Sys.Modify']], + }, + protected => 1, + parameters => { + additionalProperties => 0, + properties => { + id => { + type => 'string', + format => 'pve-configid', + }, + }, + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $id = $param->{id}; + + cfs_lock_file('jobs.cfg', undef, sub { + my $jobs = cfs_read_file('jobs.cfg'); + + if (!defined($jobs->{ids}->{$id}) || $jobs->{ids}->{$id}->{type} ne 'realm-sync') { + raise_param_exc({ id => "No such job '$id'" }); + } + delete $jobs->{ids}->{$id}; + + cfs_write_file('jobs.cfg', $jobs); + PVE::Jobs::RealmSync::save_state($id, undef); + }); + die "$@" if $@; + + return undef; + }}); + +1; diff --git a/src/PVE/API2/Makefile b/src/PVE/API2/Makefile new file mode 100644 index 0000000..7991c4c --- /dev/null +++ b/src/PVE/API2/Makefile @@ -0,0 +1,17 @@ + +API2_SOURCES= \ + AccessControl.pm \ + Domains.pm \ + ACL.pm \ + Role.pm \ + Group.pm \ + User.pm \ + TFA.pm \ + OpenId.pm + +SUBDIRS = Jobs + +.PHONY: install +install: + for i in $(API2_SOURCES); do install -D -m 0644 $$i $(DESTDIR)$(PERLDIR)/PVE/API2/$$i; done + set -e && for i in $(SUBDIRS); do $(MAKE) -C $$i $@; done diff --git a/src/PVE/API2/OpenId.pm b/src/PVE/API2/OpenId.pm new file mode 100644 index 0000000..77410e6 --- /dev/null +++ b/src/PVE/API2/OpenId.pm @@ -0,0 +1,249 @@ +package PVE::API2::OpenId; + +use strict; +use warnings; + +use PVE::Tools qw(extract_param); +use PVE::RS::OpenId; + +use PVE::Exception qw(raise raise_perm_exc raise_param_exc); +use PVE::SafeSyslog; +use PVE::RPCEnvironment; +use PVE::Cluster qw(cfs_read_file cfs_write_file); +use PVE::AccessControl; +use PVE::JSONSchema qw(get_standard_option); +use PVE::Auth::Plugin; + +use PVE::RESTHandler; + +use base qw(PVE::RESTHandler); + +my $openid_state_path = "/var/lib/pve-manager"; + +my $lookup_openid_auth = sub { + my ($realm, $redirect_url) = @_; + + my $cfg = cfs_read_file('domains.cfg'); + my $ids = $cfg->{ids}; + + die "authentication domain '$realm' does not exist\n" if !$ids->{$realm}; + + my $config = $ids->{$realm}; + die "wrong realm type ($config->{type} != openid)\n" if $config->{type} ne "openid"; + + my $openid_config = { + issuer_url => $config->{'issuer-url'}, + client_id => $config->{'client-id'}, + client_key => $config->{'client-key'}, + }; + $openid_config->{prompt} = $config->{'prompt'} if defined($config->{'prompt'}); + + my $scopes = $config->{'scopes'} // 'email profile'; + $openid_config->{scopes} = [ PVE::Tools::split_list($scopes) ]; + + if (defined(my $acr = $config->{'acr-values'})) { + $openid_config->{acr_values} = [ PVE::Tools::split_list($acr) ]; + } + + my $openid = PVE::RS::OpenId->discover($openid_config, $redirect_url); + return ($config, $openid); +}; + +__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) = @_; + + return [ + { subdir => 'auth-url' }, + { subdir => 'login' }, + ]; + }}); + +__PACKAGE__->register_method ({ + name => 'auth_url', + path => 'auth-url', + method => 'POST', + protected => 1, + description => "Get the OpenId Authorization Url for the specified realm.", + parameters => { + additionalProperties => 0, + properties => { + realm => get_standard_option('realm'), + 'redirect-url' => { + description => "Redirection Url. The client should set this to the used server url (location.origin).", + type => 'string', + maxLength => 255, + }, + }, + }, + returns => { + type => "string", + description => "Redirection URL.", + }, + permissions => { user => 'world' }, + code => sub { + my ($param) = @_; + + my $dcconf = PVE::Cluster::cfs_read_file('datacenter.cfg'); + local $ENV{all_proxy} = $dcconf->{http_proxy} if exists $dcconf->{http_proxy}; + + my $realm = extract_param($param, 'realm'); + my $redirect_url = extract_param($param, 'redirect-url'); + + my ($config, $openid) = $lookup_openid_auth->($realm, $redirect_url); + my $url = $openid->authorize_url($openid_state_path , $realm); + + return $url; + }}); + +__PACKAGE__->register_method ({ + name => 'login', + path => 'login', + method => 'POST', + protected => 1, + description => " Verify OpenID authorization code and create a ticket.", + parameters => { + additionalProperties => 0, + properties => { + 'state' => { + description => "OpenId state.", + type => 'string', + maxLength => 1024, + }, + code => { + description => "OpenId authorization code.", + type => 'string', + maxLength => 4096, + }, + 'redirect-url' => { + description => "Redirection Url. The client should set this to the used server url (location.origin).", + type => 'string', + maxLength => 255, + }, + }, + }, + returns => { + properties => { + username => { type => 'string' }, + ticket => { type => 'string' }, + CSRFPreventionToken => { type => 'string' }, + cap => { type => 'object' }, # computed api permissions + clustername => { type => 'string', optional => 1 }, + }, + }, + permissions => { user => 'world' }, + code => sub { + my ($param) = @_; + + my $rpcenv = PVE::RPCEnvironment::get(); + + my $res; + eval { + my $dcconf = PVE::Cluster::cfs_read_file('datacenter.cfg'); + local $ENV{all_proxy} = $dcconf->{http_proxy} if exists $dcconf->{http_proxy}; + + my ($realm, $private_auth_state) = PVE::RS::OpenId::verify_public_auth_state( + $openid_state_path, $param->{'state'}); + + my $redirect_url = extract_param($param, 'redirect-url'); + + my ($config, $openid) = $lookup_openid_auth->($realm, $redirect_url); + + my $info = $openid->verify_authorization_code($param->{code}, $private_auth_state); + my $subject = $info->{'sub'}; + + my $unique_name; + + my $user_attr = $config->{'username-claim'} // 'sub'; + if (defined($info->{$user_attr})) { + $unique_name = $info->{$user_attr}; + } elsif ($user_attr eq 'subject') { # stay compat with old versions + $unique_name = $subject; + } elsif ($user_attr eq 'username') { # stay compat with old versions + my $username = $info->{'preferred_username'}; + die "missing claim 'preferred_username'\n" if !defined($username); + $unique_name = $username; + } else { + # neither the attr nor fallback are defined in info.. + die "missing configured claim '$user_attr' in returned info object\n"; + } + + my $username = "${unique_name}\@${realm}"; + + # first, check if $username respects our naming conventions + PVE::Auth::Plugin::verify_username($username); + + if ($config->{'autocreate'} && !$rpcenv->check_user_exist($username, 1)) { + PVE::AccessControl::lock_user_config(sub { + my $usercfg = cfs_read_file("user.cfg"); + + die "user '$username' already exists\n" if $usercfg->{users}->{$username}; + + my $entry = { enable => 1 }; + if (defined(my $email = $info->{'email'})) { + $entry->{email} = $email; + } + if (defined(my $given_name = $info->{'given_name'})) { + $entry->{firstname} = $given_name; + } + if (defined(my $family_name = $info->{'family_name'})) { + $entry->{lastname} = $family_name; + } + + $usercfg->{users}->{$username} = $entry; + + cfs_write_file("user.cfg", $usercfg); + }, "autocreate openid user failed"); + } else { + # test if user exists and is enabled + $rpcenv->check_user_enabled($username); + } + + my $ticket = PVE::AccessControl::assemble_ticket($username); + my $csrftoken = PVE::AccessControl::assemble_csrf_prevention_token($username); + my $cap = $rpcenv->compute_api_permission($username); + + $res = { + ticket => $ticket, + username => $username, + CSRFPreventionToken => $csrftoken, + cap => $cap, + }; + + my $clinfo = PVE::Cluster::get_clinfo(); + if ($clinfo->{cluster}->{name} && $rpcenv->check($username, '/', ['Sys.Audit'], 1)) { + $res->{clustername} = $clinfo->{cluster}->{name}; + } + }; + if (my $err = $@) { + my $clientip = $rpcenv->get_client_ip() || ''; + syslog('err', "openid authentication failure; rhost=$clientip msg=$err"); + # do not return any info to prevent user enumeration attacks + die PVE::Exception->new("authentication failure\n", code => 401); + } + + PVE::Cluster::log_msg('info', 'root@pam', "successful openid auth for user '$res->{username}'"); + + return $res; + }}); diff --git a/src/PVE/API2/Role.pm b/src/PVE/API2/Role.pm new file mode 100644 index 0000000..a924018 --- /dev/null +++ b/src/PVE/API2/Role.pm @@ -0,0 +1,224 @@ +package PVE::API2::Role; + +use strict; +use warnings; + +use PVE::AccessControl (); +use PVE::Cluster qw(cfs_read_file cfs_write_file); +use PVE::Exception qw(raise_param_exc); +use PVE::JSONSchema qw(get_standard_option register_standard_option); + +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) = @_; + + my $role = $param->{roleid}; + + if ($role =~ /^PVE/i) { + raise_param_exc({ + roleid => "cannot use role ID starting with the (case-insensitive) 'PVE' namespace", + }); + } + + PVE::AccessControl::lock_user_config(sub { + my $usercfg = cfs_read_file("user.cfg"); + + 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/TFA.pm b/src/PVE/API2/TFA.pm new file mode 100644 index 0000000..62ddd95 --- /dev/null +++ b/src/PVE/API2/TFA.pm @@ -0,0 +1,470 @@ +package PVE::API2::TFA; + +use strict; +use warnings; + +use HTTP::Status qw(:constants); + +use PVE::AccessControl; +use PVE::Cluster qw(cfs_read_file cfs_write_file); +use PVE::Exception qw(raise raise_perm_exc raise_param_exc); +use PVE::JSONSchema qw(get_standard_option); +use PVE::RPCEnvironment; +use PVE::SafeSyslog; + +use PVE::API2::AccessControl; # for old login api get_u2f_instance method + +use PVE::RESTHandler; + +use base qw(PVE::RESTHandler); + +our $OPTIONAL_PASSWORD_SCHEMA = { + description => "The current password of the user performing the change.", + type => 'string', + optional => 1, # Only required if not root@pam + minLength => 5, + maxLength => 64 +}; + +my $TFA_TYPE_SCHEMA = { + type => 'string', + description => 'TFA Entry Type.', + enum => [qw(totp u2f webauthn recovery yubico)], +}; + +my %TFA_INFO_PROPERTIES = ( + id => { + type => 'string', + description => 'The id used to reference this entry.', + }, + description => { + type => 'string', + description => 'User chosen description for this entry.', + }, + created => { + type => 'integer', + description => 'Creation time of this entry as unix epoch.', + }, + enable => { + type => 'boolean', + description => 'Whether this TFA entry is currently enabled.', + optional => 1, + default => 1, + }, +); + +my $TYPED_TFA_ENTRY_SCHEMA = { + type => 'object', + description => 'TFA Entry.', + properties => { + type => $TFA_TYPE_SCHEMA, + %TFA_INFO_PROPERTIES, + }, +}; + +my $TFA_ID_SCHEMA = { + type => 'string', + description => 'A TFA entry id.', +}; + +my $TFA_UPDATE_INFO_SCHEMA = { + type => 'object', + properties => { + id => { + type => 'string', + description => 'The id of a newly added TFA entry.', + }, + challenge => { + type => 'string', + optional => 1, + description => + 'When adding u2f entries, this contains a challenge the user must respond to in order' + .' to finish the registration.' + }, + recovery => { + type => 'array', + optional => 1, + description => + 'When adding recovery codes, this contains the list of codes to be displayed to' + .' the user', + items => { + type => 'string', + description => 'A recovery entry.' + }, + }, + }, +}; + +# Set TFA to enabled if $tfa_cfg is passed, or to disabled if $tfa_cfg is undef, +# When enabling we also merge the old user.cfg keys into the $tfa_cfg. +my sub set_user_tfa_enabled : prototype($$$) { + my ($userid, $realm, $tfa_cfg) = @_; + + PVE::AccessControl::lock_user_config(sub { + my $user_cfg = cfs_read_file('user.cfg'); + my $user = $user_cfg->{users}->{$userid}; + my $keys = $user->{keys}; + # When enabling, we convert old-old keys, + # When disabling, we shouldn't actually have old keys anymore, so if they are there, + # they'll be removed. + if ($tfa_cfg && $keys && $keys !~ /^x(?:!.*)?$/) { + 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; + + PVE::AccessControl::add_old_keys_to_realm_tfa($userid, $tfa_cfg, $realm_tfa, $keys); + } + $user->{keys} = $tfa_cfg ? 'x' : undef; + cfs_write_file("user.cfg", $user_cfg); + }, "enabling TFA for the user failed"); +} + +__PACKAGE__->register_method ({ + name => 'list_user_tfa', + path => '{userid}', + method => 'GET', + permissions => { + check => [ 'or', + ['userid-param', 'self'], + ['userid-group', ['User.Modify', 'Sys.Audit']], + ], + }, + protected => 1, # else we can't access shadow files + description => 'List TFA configurations of users.', + parameters => { + additionalProperties => 0, + properties => { + userid => get_standard_option('userid', { + completion => \&PVE::AccessControl::complete_username, + }), + } + }, + returns => { + description => "A list of the user's TFA entries.", + type => 'array', + items => $TYPED_TFA_ENTRY_SCHEMA, + links => [ { rel => 'child', href => "{id}" } ], + }, + code => sub { + my ($param) = @_; + my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); + return $tfa_cfg->api_list_user_tfa($param->{userid}); + }}); + +__PACKAGE__->register_method ({ + name => 'get_tfa_entry', + path => '{userid}/{id}', + method => 'GET', + permissions => { + check => [ 'or', + ['userid-param', 'self'], + ['userid-group', ['User.Modify', 'Sys.Audit']], + ], + }, + protected => 1, # else we can't access shadow files + description => 'Fetch a requested TFA entry if present.', + parameters => { + additionalProperties => 0, + properties => { + userid => get_standard_option('userid', { + completion => \&PVE::AccessControl::complete_username, + }), + id => $TFA_ID_SCHEMA, + } + }, + returns => $TYPED_TFA_ENTRY_SCHEMA, + code => sub { + my ($param) = @_; + my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); + my $id = $param->{id}; + my $entry = $tfa_cfg->api_get_tfa_entry($param->{userid}, $id); + raise("No such tfa entry '$id'", code => HTTP::Status::HTTP_NOT_FOUND) if !$entry; + return $entry; + }}); + +__PACKAGE__->register_method ({ + name => 'delete_tfa', + path => '{userid}/{id}', + method => 'DELETE', + permissions => { + check => [ 'or', + ['userid-param', 'self'], + ['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 => 'Delete a TFA entry by ID.', + parameters => { + additionalProperties => 0, + properties => { + userid => get_standard_option('userid', { + completion => \&PVE::AccessControl::complete_username, + }), + id => $TFA_ID_SCHEMA, + password => $OPTIONAL_PASSWORD_SCHEMA, + } + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $rpcenv = PVE::RPCEnvironment::get(); + my $authuser = $rpcenv->get_user(); + my $userid = $rpcenv->reauth_user_for_user_modification( + $authuser, + $param->{userid}, + $param->{password}, + ); + + my $has_entries_left = PVE::AccessControl::lock_tfa_config(sub { + my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); + my $has_entries_left = $tfa_cfg->api_delete_tfa($userid, $param->{id}); + cfs_write_file('priv/tfa.cfg', $tfa_cfg); + return $has_entries_left; + }); + if (!$has_entries_left) { + set_user_tfa_enabled($userid, undef, undef); + } + }}); + +__PACKAGE__->register_method ({ + name => 'list_tfa', + path => '', + method => 'GET', + permissions => { + description => "Returns all or just the logged-in user, depending on privileges.", + user => 'all', + }, + protected => 1, # else we can't access shadow files + description => 'List TFA configurations of users.', + parameters => { + additionalProperties => 0, + properties => {} + }, + returns => { + description => "The list tuples of user and TFA entries.", + type => 'array', + items => { + type => 'object', + properties => { + userid => { + type => 'string', + description => 'User this entry belongs to.', + }, + entries => { + type => 'array', + items => $TYPED_TFA_ENTRY_SCHEMA, + }, + 'totp-locked' => { + type => 'boolean', + optional => 1, + description => 'True if the user is currently locked out of TOTP factors.', + }, + 'tfa-locked-until' => { + type => 'integer', + optional => 1, + description => + 'Contains a timestamp until when a user is locked out of 2nd factors.', + }, + }, + }, + links => [ { rel => 'child', href => "{userid}" } ], + }, + code => sub { + my ($param) = @_; + + my $rpcenv = PVE::RPCEnvironment::get(); + my $authuser = $rpcenv->get_user(); + + my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); + my $entries = $tfa_cfg->api_list_tfa($authuser, 1); + + my $privs = [ 'User.Modify', 'Sys.Audit' ]; + if ($rpcenv->check_any($authuser, "/access/groups", $privs, 1)) { + # can modify all + return $entries; + } + + my $groups = $rpcenv->filter_groups($authuser, $privs, 1); + my $allowed_users = $rpcenv->group_member_join([keys %$groups]); + return [ + grep { + my $userid = $_->{userid}; + $userid eq $authuser || $allowed_users->{$userid} + } $entries->@* + ]; + }}); + +__PACKAGE__->register_method ({ + name => 'add_tfa_entry', + path => '{userid}', + method => 'POST', + permissions => { + check => [ 'or', + ['userid-param', 'self'], + ['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 => 'Add a TFA entry for a user.', + parameters => { + additionalProperties => 0, + properties => { + userid => get_standard_option('userid', { + completion => \&PVE::AccessControl::complete_username, + }), + type => $TFA_TYPE_SCHEMA, + description => { + type => 'string', + description => 'A description to distinguish multiple entries from one another', + maxLength => 255, + optional => 1, + }, + totp => { + type => 'string', + description => "A totp URI.", + optional => 1, + }, + value => { + type => 'string', + description => + 'The current value for the provided totp URI, or a Webauthn/U2F' + .' challenge response', + optional => 1, + }, + challenge => { + type => 'string', + description => 'When responding to a u2f challenge: the original challenge string', + optional => 1, + }, + password => $OPTIONAL_PASSWORD_SCHEMA, + }, + }, + returns => $TFA_UPDATE_INFO_SCHEMA, + code => sub { + my ($param) = @_; + + my $rpcenv = PVE::RPCEnvironment::get(); + my $authuser = $rpcenv->get_user(); + my ($userid, undef, $realm) = $rpcenv->reauth_user_for_user_modification( + $authuser, + $param->{userid}, + $param->{password}, + ); + + my $type = delete $param->{type}; + my $value = delete $param->{value}; + if ($type eq 'yubico') { + $value = validate_yubico_otp($userid, $realm, $value); + } + + return PVE::AccessControl::lock_tfa_config(sub { + my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); + + set_user_tfa_enabled($userid, $realm, $tfa_cfg); + + PVE::AccessControl::configure_u2f_and_wa($tfa_cfg); + + my $response = $tfa_cfg->api_add_tfa_entry( + $userid, + $param->{description}, + $param->{totp}, + $value, + $param->{challenge}, + $type, + ); + + cfs_write_file('priv/tfa.cfg', $tfa_cfg); + + return $response; + }); + }}); + +sub validate_yubico_otp : prototype($$$) { + my ($userid, $realm, $value) = @_; + + 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}; + die "no yubico otp configuration available for realm $realm\n" + if !$realm_tfa; + + $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_tfa); + die "realm is not setup for Yubico OTP\n" + if !$realm_tfa || $realm_tfa->{type} ne 'yubico'; + + my $public_key = substr($value, 0, 12); + + PVE::AccessControl::authenticate_yubico_do($value, $public_key, $realm_tfa); + + return $public_key; +} + +__PACKAGE__->register_method ({ + name => 'update_tfa_entry', + path => '{userid}/{id}', + method => 'PUT', + permissions => { + check => [ 'or', + ['userid-param', 'self'], + ['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 => 'Add a TFA entry for a user.', + parameters => { + additionalProperties => 0, + properties => { + userid => get_standard_option('userid', { + completion => \&PVE::AccessControl::complete_username, + }), + id => $TFA_ID_SCHEMA, + description => { + type => 'string', + description => 'A description to distinguish multiple entries from one another', + maxLength => 255, + optional => 1, + }, + enable => { + type => 'boolean', + description => 'Whether the entry should be enabled for login.', + optional => 1, + }, + password => $OPTIONAL_PASSWORD_SCHEMA, + }, + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $rpcenv = PVE::RPCEnvironment::get(); + my $authuser = $rpcenv->get_user(); + my $userid = $rpcenv->reauth_user_for_user_modification( + $authuser, + $param->{userid}, + $param->{password}, + ); + + PVE::AccessControl::lock_tfa_config(sub { + my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); + + $tfa_cfg->api_update_tfa_entry( + $userid, + $param->{id}, + $param->{description}, + $param->{enable}, + ); + + cfs_write_file('priv/tfa.cfg', $tfa_cfg); + }); + }}); + +1; diff --git a/src/PVE/API2/User.pm b/src/PVE/API2/User.pm new file mode 100644 index 0000000..489d34f --- /dev/null +++ b/src/PVE/API2/User.pm @@ -0,0 +1,866 @@ +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::JSONSchema qw(get_standard_option register_standard_option); +use PVE::SafeSyslog; + +use PVE::AccessControl; +use PVE::Auth::Plugin; +use PVE::TokenConfig; + +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, maxLength => 1024, }); +register_standard_option('user-lastname', { type => 'string', optional => 1, maxLength => 1024, }); +register_standard_option('user-email', { + type => 'string', + optional => 1, + format => 'email-opt', + maxLength => 254, # 256 including punctuation and separator is the max path as per RFC 5321 +}); +register_standard_option('user-comment', { + type => 'string', + optional => 1, + maxLength => 2048, +}); +register_standard_option('user-keys', { + description => "Keys for two factor auth (yubico).", + type => 'string', + pattern => '[0-9a-zA-Z!=]{0,4096}', + 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} ? [ sort 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', + }, + protected => 1, # to access priv/tfa.cfg + 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'), + }), + }, + 'realm-type' => { + type => 'string', format => 'pve-realm', + description => 'The type of the users realm', + optional => 1, # it should always be there, but we use conditional code below, so.. + }, + 'totp-locked' => { + type => 'boolean', + optional => 1, + description => 'True if the user is currently locked out of TOTP factors.', + }, + 'tfa-locked-until' => { + type => 'integer', + optional => 1, + description => + 'Contains a timestamp until when a user is locked out of 2nd factors.', + }, + }, + }, + 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 $domainscfg = cfs_read_file('domains.cfg'); + my $domainids = $domainscfg->{ids}; + + 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]); + + my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); + + foreach my $user (sort 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}; + + if (defined(my $tokens = $entry->{tokens})) { + $entry->{tokens} = [ + map { { tokenid => $_, %{$tokens->{$_}} } } sort keys %$tokens + ]; + } + + if ($user =~ /($PVE::Auth::Plugin::realm_regex)$/) { + my $realm = $1; + $entry->{'realm-type'} = $domainids->{$realm}->{type} if exists $domainids->{$realm}; + } + + $entry->{userid} = $user; + + if (defined($tfa_cfg)) { + if (my $data = $tfa_cfg->tfa_lock_status($user)) { + for (qw(totp-locked tfa-locked-until)) { + $entry->{$_} = $data->{$_} if exists($data->{$_}); + } + } + } + + 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 => 'create'], + ], + }, + 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', + additionalProperties => get_standard_option('token-info'), + }, + }, + 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 => 'update' ], + }, + 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"); + + # NOTE: disable the user first (transaction like), so if (e.g.) we fail in the middle of + # TFA deletion the user will be still disabled and not just without TFA protection. + $usercfg->{users}->{$userid}->{enable} = 0; + cfs_write_file("user.cfg", $usercfg); + + 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 user from cache before removing the TFA entry so realms with TFA-enforcement + # know that it's OK to drop any TFA entry in that case. + delete $usercfg->{users}->{$userid}; + + my $partial_deletion = ''; + eval { + PVE::AccessControl::user_remove_tfa($userid); + $partial_deletion = ' - but deleted related TFA'; + + PVE::AccessControl::delete_user_group($userid, $usercfg); + $partial_deletion .= ', Groups'; + PVE::AccessControl::delete_user_acl($userid, $usercfg); + $partial_deletion .= ', ACLs'; + + cfs_write_file("user.cfg", $usercfg); + }; + die "$@$partial_deletion\n" if $@; + }, "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'), + multiple => { + type => 'boolean', + description => 'Request all entries as an array.', + optional => 1, + default => 0, + }, + }, + }, + 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." + . " Only set if 'multiple' was not passed.", + optional => 1, + }, + types => { + type => 'array', + description => + "Array of the user configured TFA types, if any." + . " Only available if 'multiple' was not passed.", + optional => 1, + items => { + type => 'string', + enum => [qw(totp u2f yubico webauthn recovedry)], + description => 'A TFA type.', + }, + }, + }, + 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 $res = {}; + my $realm_tfa = {}; + $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_cfg->{tfa}) if $realm_cfg->{tfa}; + $res->{realm} = $realm_tfa->{type} if $realm_tfa->{type}; + + my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); + if ($param->{multiple}) { + my $tfa = $tfa_cfg->get_user($username); + my $user = []; + foreach my $type (keys %$tfa) { + next if !scalar($tfa->{$type}->@*); + push @$user, $type; + } + $res->{user} = $user; + } else { + my $tfa = $tfa_cfg->{users}->{$username}; + $res->{user} = $tfa->{type} if $tfa->{type}; + } + return $res; + }}); + +__PACKAGE__->register_method ({ + name => 'unlock_tfa', + path => '{userid}/unlock-tfa', + method => 'PUT', + protected => 1, + description => "Unlock a user's TFA authentication.", + permissions => { + check => [ 'userid-group', ['User.Modify']], + }, + parameters => { + additionalProperties => 0, + properties => { + userid => get_standard_option('userid-completed'), + }, + }, + returns => { type => 'boolean' }, + code => sub { + my ($param) = @_; + + my $userid = extract_param($param, "userid"); + + my $user_was_locked = PVE::AccessControl::lock_tfa_config(sub { + my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); + my $was_locked = $tfa_cfg->api_unlock_tfa($userid); + cfs_write_file('priv/tfa.cfg', $tfa_cfg) + if $was_locked; + return $was_locked; + }); + + return $user_was_locked; + }}); + +__PACKAGE__->register_method ({ + name => 'token_index', + path => '{userid}/token', + method => 'GET', + description => "Get user API tokens.", + permissions => { + check => [ + 'or', + ['userid-param', 'self'], + ['userid-group', ['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'], + ['userid-group', ['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'], + ['userid-group', ['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'], + ['userid-group', ['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); + + PVE::AccessControl::lock_user_config(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); + }, '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'], + ['userid-group', ['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); + + PVE::AccessControl::lock_user_config(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); + }, 'deleting token failed'); + + return; + }}); +1; diff --git a/src/PVE/AccessControl.pm b/src/PVE/AccessControl.pm new file mode 100644 index 0000000..47f2d38 --- /dev/null +++ b/src/PVE/AccessControl.pm @@ -0,0 +1,2072 @@ +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::Base32; +use MIME::Base64; +use Digest::SHA; +use IO::File; +use File::stat; +use JSON; +use Scalar::Util 'weaken'; +use URI::Escape; + +use PVE::Exception qw(raise_perm_exc raise_param_exc); +use PVE::OTP; +use PVE::Ticket; +use PVE::Tools qw(run_command lock_file file_get_contents split_list safe_print); +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::RS::TFA; + +use PVE::Auth::Plugin; +use PVE::Auth::AD; +use PVE::Auth::LDAP; +use PVE::Auth::PVE; +use PVE::Auth::PAM; +use PVE::Auth::OpenId; + +# load and initialize all plugins + +PVE::Auth::AD->register(); +PVE::Auth::LDAP->register(); +PVE::Auth::PVE->register(); +PVE::Auth::PAM->register(); +PVE::Auth::OpenId->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(@_); +} + +# Locking both config files together is only ever allowed in one order: +# 1) tfa config +# 2) user config +# If we permit the other way round, too, we might end up deadlocking! +my $user_config_locked; +sub lock_user_config { + my ($code, $errmsg) = @_; + + my $locked = 1; + $user_config_locked = \$locked; + weaken $user_config_locked; # make this scope guard signal safe... + + cfs_lock_file("user.cfg", undef, $code); + $user_config_locked = undef; + if (my $err = $@) { + $errmsg ? die "$errmsg: $err" : die $err; + } +} + +sub lock_tfa_config { + my ($code, $errmsg) = @_; + + die "tfa config lock cannot be acquired while holding user config lock\n" + if ($user_config_locked && $$user_config_locked); + + my $res = cfs_lock_file("priv/tfa.cfg", undef, $code); + if (my $err = $@) { + $errmsg ? die "$errmsg: $err" : die $err; + } + + return $res; +} + +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 { + # stat() calls might be answered from the kernel page cache for up to + # 1s, so this special dance is needed to avoid a double rotation in + # clusters *despite* the cfs_lock context.. + + # drop in-process cache hash + $pve_auth_key_cache = {}; + # force open/close of file to invalidate page cache entry + get_pubkey(); + # now re-check with lock held and page cache invalidated so that stat() + # does the right thing, and any key updates by other nodes are visible. + 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 : prototype($;$) { + my ($data, $aad) = @_; + + my $rsa_priv = get_privkey(); + + return PVE::Ticket::assemble_rsa_ticket($rsa_priv, 'PVE', $data, $aad); +} + +# Returns the username, "age" and tfa info. +# +# Note that for the new-style outh, tfa info is never set, as it only uses the `/ticket` api call +# via the new 'tfa-challenge' parameter, so this part can go with PVE-8. +# +# New-style auth still uses this function, but sets `$tfa_ticket` to true when validating the tfa +# ticket. +sub verify_ticket : prototype($;$$) { + my ($ticket, $noerr, $tfa_ticket_aad) = @_; + + 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, $tfa_ticket_aad, $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->(); + } + + if ($tfa_ticket_aad) { + # We're validating a ticket-call's 'tfa-challenge' parameter, so just return its data. + if ($data =~ /^!tfa!(.*)$/) { + return $1; + } + die "bad ticket\n"; + } + + my ($username, $tfa_info); + if ($data =~ /^!tfa!(.*)$/) { + # PBS style half-authenticated ticket, contains a json string form of a `TfaChallenge` + # object. + # This type of ticket does not contain the user name. + return { type => 'new', data => $1 }; + } + 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 $user = $usercfg->{users}->{$username}; + my $token_info = $user->{tokens}->{$token}; + + my $ctime = time(); + die "token '$token' access 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; +} + +my $assemble_short_lived_ticket = sub { + my ($prefix, $username, $path) = @_; + + my $rsa_priv = get_privkey(); + + $path = normalize_path($path); + + die "invalid ticket path\n" if !defined($path); + + my $secret_data = "$username:$path"; + + return PVE::Ticket::assemble_rsa_ticket( + $rsa_priv, $prefix, undef, $secret_data); +}; + +my $verify_short_lived_ticket = sub { + my ($ticket, $prefix, $username, $path, $noerr) = @_; + + $path = normalize_path($path); + + die "invalid ticket path\n" if !defined($path); + + 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, $prefix); + } + } + + return PVE::Ticket::verify_rsa_ticket( + $rsa_pub, $prefix, $ticket, $secret_data, -20, 40, $noerr); +}; + +# 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) = @_; + + return $assemble_short_lived_ticket->('PVEVNC', $username, $path); +} + +sub verify_vnc_ticket { + my ($ticket, $username, $path, $noerr) = @_; + + return $verify_short_lived_ticket->($ticket, 'PVEVNC', $username, $path, $noerr); +} + +# Tunnel tickets +# - they do not contain the username in plain text +# - they are restricted to a specific resource path (example: '/vms/100', '/socket/run/qemu-server/123.storage') +sub assemble_tunnel_ticket { + my ($username, $path) = @_; + + return $assemble_short_lived_ticket->('PVETUNNEL', $username, $path); +} + +sub verify_tunnel_ticket { + my ($ticket, $username, $path, $noerr) = @_; + + return $verify_short_lived_ticket->($ticket, 'PVETUNNEL', $username, $path, $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 separator (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; + + if (!$data->{enable}) { + die "user '$username' is disabled\n" if !$noerr; + return undef; + } + + my $ctime = time(); + my $expire = $usercfg->{users}->{$username}->{expire}; + + if ($expire && $expire < $ctime) { + die "user '$username' access expired\n" if !$noerr; + return undef; + } + + return 1; # enabled and not expired +} + +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; +} + +# deprecated +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 : prototype($$$;$) { + my ($username, $password, $otp, $tfa_challenge) = @_; + + 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 $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}); + + if ($tfa_challenge) { + # This is the 2nd factor, use the password for the OTP response. + my $tfa_challenge = authenticate_2nd_new($username, $realm, $password, $tfa_challenge); + return wantarray ? ($username, $tfa_challenge) : $username; + } + + $plugin->authenticate_user($cfg, $realm, $ruid, $password); + + # This is the first factor with an optional immediate 2nd factor for TOTP: + $tfa_challenge = authenticate_2nd_new($username, $realm, $otp, undef); + return wantarray ? ($username, $tfa_challenge) : $username; +} + +sub authenticate_2nd_new_do : prototype($$$$) { + my ($username, $realm, $tfa_response, $tfa_challenge) = @_; + my ($tfa_cfg, $realm_tfa) = user_get_tfa($username, $realm); + + # FIXME: `$tfa_cfg` is now usually never undef - use cheap check for + # whether the user has *any* entries here instead whe it is available in + # pve-rs + if (!defined($tfa_cfg)) { + return undef; + } + + my $realm_type = $realm_tfa && $realm_tfa->{type}; + # verify realm type unless using recovery keys: + if (defined($realm_type)) { + $realm_type = 'totp' if $realm_type eq 'oath'; # we used to call it that + if ($realm_type eq 'yubico') { + # Yubico auth will not be supported in rust for now... + if (!defined($tfa_challenge)) { + my $challenge = { yubico => JSON::true }; + # Even with yubico auth we do allow recovery keys to be used: + if (my $recovery = $tfa_cfg->recovery_state($username)) { + $challenge->{recovery} = $recovery; + } + return to_json($challenge); + } + + if ($tfa_response =~ /^yubico:(.*)$/) { + $tfa_response = $1; + # Defer to after unlocking the TFA config: + return sub { + authenticate_yubico_new( + $tfa_cfg, $username, $realm_tfa, $tfa_challenge, $tfa_response, + ); + }; + } + } + + my $response_type; + if (defined($tfa_response)) { + if ($tfa_response !~ /^([^:]+):/) { + die "bad otp response\n"; + } + $response_type = $1; + } + + die "realm requires $realm_type authentication\n" + if $response_type && $response_type ne 'recovery' && $response_type ne $realm_type; + } + + configure_u2f_and_wa($tfa_cfg); + + my ($result, $tfa_done); + if (defined($tfa_challenge)) { + $tfa_done = 1; + $tfa_challenge = verify_ticket($tfa_challenge, 0, $username); + $result = $tfa_cfg->authentication_verify2($username, $tfa_challenge, $tfa_response); + $tfa_challenge = undef; + } else { + $tfa_challenge = $tfa_cfg->authentication_challenge($username); + + die "missing required 2nd keys\n" + if $realm_tfa && !defined($tfa_challenge); + + if (defined($tfa_response)) { + if (defined($tfa_challenge)) { + $tfa_done = 1; + $result = $tfa_cfg->authentication_verify2($username, $tfa_challenge, $tfa_response); + } else { + die "no such challenge\n"; + } + } + } + + if ($tfa_done) { + if (!$result) { + # authentication_verify2 somehow returned undef - should be unreachable + die "2nd factor failed\n"; + } + + if ($result->{'needs-saving'}) { + cfs_write_file('priv/tfa.cfg', $tfa_cfg); + } + if ($result->{'totp-limit-reached'}) { + # FIXME: send mail to the user (or admin/root if no email configured) + die "failed 2nd factor: TOTP limit reached, locked\n"; + } + if ($result->{'tfa-limit-reached'}) { + # FIXME: send mail to the user (or admin/root if no email configured) + die "failed 1nd factor: TFA limit reached, user locked out\n"; + } + if (!$result->{result}) { + die "failed 2nd factor\n"; + } + } + + return $tfa_challenge; +} + +# Returns a tfa challenge or undef. +sub authenticate_2nd_new : prototype($$$$) { + my ($username, $realm, $tfa_response, $tfa_challenge) = @_; + + my $result; + + if (defined($tfa_response) && $tfa_response =~ m/^recovery:/) { + $result = lock_tfa_config(sub { + authenticate_2nd_new_do($username, $realm, $tfa_response, $tfa_challenge); + }); + } else { + $result = authenticate_2nd_new_do($username, $realm, $tfa_response, $tfa_challenge); + } + + # Yubico auth returns the authentication sub: + if (ref($result) eq 'CODE') { + $result = $result->(); + } + + return $result; +} + +sub authenticate_yubico_new : prototype($$$) { + my ($tfa_cfg, $username, $realm, $tfa_challenge, $otp) = @_; + + $tfa_challenge = verify_ticket($tfa_challenge, 0, $username); + $tfa_challenge = from_json($tfa_challenge); + + if (!$tfa_challenge->{yubico}) { + die "no such challenge\n"; + } + + my $keys = $tfa_cfg->get_yubico_keys($username); + die "no keys configured\n" if !defined($keys) || !length($keys); + + authenticate_yubico_do($otp, $keys, $realm); + + # return `undef` to clear the tfa challenge. + return undef; +} + +sub authenticate_yubico_do : prototype($$$) { + my ($value, $keys, $realm) = @_; + + # fixme: proxy support? + my $proxy = undef; + + PVE::OTP::yubico_verify_otp($value, $keys, $realm->{url}, $realm->{id}, $realm->{key}, $proxy); +} + +sub configure_u2f_and_wa : prototype($) { + my ($tfa_cfg) = @_; + + my $rpc_origin; + my $get_origin = sub { + return $rpc_origin if defined($rpc_origin); + my $rpcenv = PVE::RPCEnvironment::get(); + if (my $origin = $rpcenv->get_request_host(1)) { + $rpc_origin = "https://$origin"; + return $rpc_origin; + } + die "failed to figure out origin\n"; + }; + + my $dc = cfs_read_file('datacenter.cfg'); + if (my $u2f = $dc->{u2f}) { + eval { + $tfa_cfg->set_u2f_config({ + origin => $u2f->{origin} // $get_origin->(), + appid => $u2f->{appid}, + }); + }; + warn "u2f unavailable, configuration error: $@\n" if $@; + } + if (my $wa = $dc->{webauthn}) { + $wa->{origin} //= $get_origin->(); + eval { $tfa_cfg->set_webauthn_config({%$wa}) }; + warn "webauthn unavailable, configuration error: $@\n" if $@; + } +} + +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 iterate_acl_tree { + my ($path, $node, $code) = @_; + + $code->($path, $node); + + $path = '' if $path eq '/'; # avoid leading '//' + + my $children = $node->{children}; + + foreach my $child (sort keys %$children) { + iterate_acl_tree("$path/$child", $children->{$child}, $code); + } +} + +# find ACL node corresponding to normalized $path under $root +sub find_acl_tree_node { + my ($root, $path) = @_; + + my $split_path = [ split("/", $path) ]; + + if (!$split_path) { + return $root; + } + + my $node = $root; + for my $p (@$split_path) { + next if !$p; + + $node->{children} = {} if !$node->{children}; + $node->{children}->{$p} = {} if !$node->{children}->{$p}; + + $node = $node->{children}->{$p}; + } + + return $node; +} + +sub add_user_group { + my ($username, $usercfg, $group) = @_; + + $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) = @_; + + my $code = sub { + my ($path, $acl_node) = @_; + + delete ($acl_node->{users}->{$username}) + if $acl_node->{users}->{$username}; + }; + + iterate_acl_tree("/", $usercfg->{acl_root}, $code); +} + +sub delete_group_acl { + my ($group, $usercfg) = @_; + + my $code = sub { + my ($path, $acl_node) = @_; + + delete ($acl_node->{groups}->{$group}) + if $acl_node->{groups}->{$group}; + }; + + iterate_acl_tree("/", $usercfg->{acl_root}, $code); +} + +sub delete_pool_acl { + my ($pool, $usercfg) = @_; + + delete ($usercfg->{acl_root}->{children}->{pool}->{children}->{$pool}); +} + +# 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 + 'Sys.Incoming', # incoming storage/guest migrations + 'Sys.AccessNetwork', # for, e.g., downloading ISOs from any URL + ], + admin => [ + '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', + ], + user => [ + 'SDN.Use', + ], + 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 => [ + 'Pool.Audit', + ], + audit => [ + 'Pool.Audit', + ], + }, + Mapping => { + root => [], + admin => [ + 'Mapping.Modify', + ], + user => [ + 'Mapping.Use', + ], + audit => [ + 'Mapping.Audit', + ], + }, +}; + +my $valid_privs = { + 'Permissions.Modify' => 1, # not contained in a group +}; + +my $special_roles = { + 'NoAccess' => {}, # no privileges + 'Administrator' => $valid_privs, # all privileges +}; + +sub create_roles { + + for my $cat (keys %$privgroups) { + my $cd = $privgroups->{$cat}; + # create map to easily check if a privilege is valid + for my $priv (@{$cd->{root}}, @{$cd->{admin}}, @{$cd->{user}}, @{$cd->{audit}}) { + $valid_privs->{$priv} = 1; + } + # create grouped admin roles and PVEAdmin + for my $priv (@{$cd->{admin}}, @{$cd->{user}}, @{$cd->{audit}}) { + $special_roles->{"PVE${cat}Admin"}->{$priv} = 1; + $special_roles->{"PVEAdmin"}->{$priv} = 1; + } + # create grouped user and audit roles + if (scalar(@{$cd->{user}})) { + for my $priv (@{$cd->{user}}, @{$cd->{audit}}) { + $special_roles->{"PVE${cat}User"}->{$priv} = 1; + } + } + for my $priv (@{$cd->{audit}}) { + $special_roles->{"PVEAuditor"}->{$priv} = 1; + } + } + + # remove Mapping.Modify from PVEAdmin, only Administrator, root@pam and + # PVEMappingAdmin should be able to use that for now + delete $special_roles->{"PVEAdmin"}->{"Mapping.Modify"}; + + $special_roles->{"PVETemplateUser"} = { 'VM.Clone' => 1, 'VM.Audit' => 1 }; +}; + +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; + + return undef if !$path; + + $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/groups/[[:alnum:]\.\-\_]+ + |/access/realm + |/access/realm/[[:alnum:]\.\-\_]+ + |/nodes + |/nodes/[[:alnum:]\.\-\_]+ + |/pool + |/pool/[A-Za-z0-9\.\-_]+(?:/[A-Za-z0-9\.\-_]+){0,2} + |/sdn + |/sdn/controllers + |/sdn/controllers/[[:alnum:]\_\-]+ + |/sdn/dns + |/sdn/dns/[[:alnum:]]+ + |/sdn/ipams + |/sdn/ipams/[[:alnum:]]+ + |/sdn/zones + |/sdn/zones/[[:alnum:]\.\-\_]+ + |/sdn/zones/[[:alnum:]\.\-\_]+/[[:alnum:]\.\-\_]+ + |/sdn/zones/[[:alnum:]\.\-\_]+/[[:alnum:]\.\-\_]+/[1-9][0-9]{0,3} + |/storage + |/storage/[[:alnum:]\.\-\_]+ + |/vms + |/vms/[1-9][0-9]{2,} + |/mapping + |/mapping/[[:alnum:]\.\-\_]+ + |/mapping/[[:alnum:]\.\-\_]+/[[:alnum:]\.\-\_]+ + )$!xs; +} + +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 (split("/", $poolname) > 3) { + die "pool name '$poolname' nested too deeply (max levels = 3)\n" if !$noerr; + + return undef; + } + + # also adapt check_path above if changed! + if ($poolname !~ m!^[A-Za-z0-9\.\-_]+(?:/[A-Za-z0-9\.\-_]+){0,2}$!) { + die "pool name '$poolname' contains invalid characters\n" if !$noerr; + + return undef; + } + + 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; + } + + # add (empty) ACL tree root node + if (!$cfg->{acl_root}) { + $cfg->{acl_root} = {}; + } +} + +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)) { + my $acl_node; + 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"; + } + $acl_node = find_acl_tree_node($cfg->{acl_root}, $path) if !$acl_node; + $acl_node->{groups}->{$group}->{$role} = $propagate; + } elsif (PVE::Auth::Plugin::verify_username($ug, 1)) { + if (!$cfg->{users}->{$ug}) { # user does not exist + warn "user config - ignore invalid acl member '$ug'\n"; + } + $acl_node = find_acl_tree_node($cfg->{acl_root}, $path) if !$acl_node; + $acl_node->{users}->{$ug}->{$role} = $propagate; + } elsif (my ($user, $token) = split_tokenid($ug, 1)) { + if (check_token_exist($cfg, $user, $token, 1)) { + $acl_node = find_acl_tree_node($cfg->{acl_root}, $path) if !$acl_node; + $acl_node->{tokens}->{$ug}->{$role} = $propagate; + } else { + warn "user config - ignore invalid acl token '$ug'\n"; + } + } 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 => {}, pools => {} } + if !$cfg->{pools}->{$pool}; + + if ($pool =~ m!/!) { + my $curr = $pool; + while ($curr =~ m!^(.+)/[^/]+$!) { + # ensure nested pool info is correctly recorded + my $parent = $1; + $cfg->{pools}->{$curr}->{parent} = $parent; + $cfg->{pools}->{$parent} = { vms => {}, storage => {}, pools => {} } + if !$cfg->{pools}->{$parent}; + $cfg->{pools}->{$parent}->{pools}->{$curr} = 1; + $curr = $parent; + } + } + + $cfg->{pools}->{$pool}->{comment} = PVE::Tools::decode_text($comment) if $comment; + + 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; + } + }; + + iterate_acl_tree("/", $cfg->{acl_root}, sub { + my ($path, $d) = @_; + + 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; +} + +# Creates a `PVE::RS::TFA` instance from the raw config data. +# Its contained hash will also support the legacy functionality. +sub parse_priv_tfa_config { + my ($filename, $raw) = @_; + + $raw = '' if !defined($raw); + my $cfg = PVE::RS::TFA->new($raw); + + # Purge invalid users: + foreach my $user ($cfg->users()->@*) { + my (undef, undef, $realm) = PVE::Auth::Plugin::verify_username($user, 1); + if (!$realm) { + warn "user tfa config - ignore user '$user' - invalid user name\n"; + $cfg->remove_user($user); + } + } + + return $cfg; +} + +sub write_priv_tfa_config { + my ($filename, $cfg) = @_; + + return $cfg->write(); +} + +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 (!defined($path)) { + # this shouldn't happen! + warn "internal error: ACL check called for undefined ACL path!\n"; + return {}; + } + + 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 = {}; + + my $split = [ split("/", $path) ]; + if ($path eq '/') { + $split = [ '' ]; + } + + my $acl = $cfg->{acl_root}; + my $i = 0; + + while (@$split) { + my $p = shift @$split; + my $final = !@$split; + if ($p ne '') { + $acl = $acl->{children}->{$p}; + } + + #print "CHECKACL $path $p\n"; + #print "ACL $path = " . Dumper ($acl); + 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_root}->{children}->{vms}->{children}->{$vmid}) { + delete $usercfg->{acl_root}->{children}->{vms}->{children}->{$vmid}; + $modified = 1; + } + if (my $pool = $usercfg->{vms}->{$vmid}) { + if (my $data = $usercfg->{pools}->{$pool}) { + delete $data->{vms}->{$vmid}; + delete $usercfg->{vms}->{$vmid}; + $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 $acl = $usercfg->{acl_root}->{children}->{storage}->{children}->{$storeid}) { + delete $usercfg->{acl_root}->{children}->{storage}->{children}->{$storeid}; + $modified = 1; + } + foreach my $pool (keys %{$usercfg->{pools}}) { + delete $usercfg->{pools}->{$pool}->{storage}->{$storeid}; + $modified = 1; + } + cfs_write_file("user.cfg", $usercfg) if $modified; + }; + + 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, +}; + +sub user_remove_tfa : prototype($) { + my ($userid) = @_; + + my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); + $tfa_cfg->remove_user($userid); + cfs_write_file('priv/tfa.cfg', $tfa_cfg); +} + +my sub add_old_yubico_keys : prototype($$$) { + my ($userid, $tfa_cfg, $keys) = @_; + + my $count = 0; + foreach my $key (split_list($keys)) { + my $description = ""; + ++$count; + $tfa_cfg->add_yubico_entry($userid, $description, $key); + } +} + +my sub normalize_totp_secret : prototype($) { + my ($key) = @_; + + my $binkey; + # See PVE::OTP::oath_verify_otp: + if ($key =~ /^v2-0x([0-9a-fA-F]+)$/) { + # v2, hex + $binkey = pack('H*', $1); + } elsif ($key =~ /^v2-([A-Z2-7=]+)$/) { + # v2, base32 + $binkey = MIME::Base32::decode_rfc3548($1); + } elsif ($key =~ /^[A-Z2-7=]{16}$/) { + $binkey = MIME::Base32::decode_rfc3548($key); + } elsif ($key =~ /^[A-Fa-f0-9]{40}$/) { + $binkey = pack('H*', $key); + } else { + return undef; + } + + return MIME::Base32::encode_rfc3548($binkey); +} + +my sub add_old_totp_keys : prototype($$$$) { + my ($userid, $tfa_cfg, $realm_tfa, $keys) = @_; + + my $issuer = 'Proxmox%20VE'; + my $account = uri_escape("Old key for $userid"); + my $digits = $realm_tfa->{digits} || 6; + my $step = $realm_tfa->{step} || 30; + my $uri = "otpauth://totp/$issuer:$account?digits=$digits&period=$step&algorithm=SHA1&secret="; + + my $count = 0; + foreach my $key (split_list($keys)) { + $key = normalize_totp_secret($key); + # and just skip invalid keys: + next if !defined($key); + + my $description = ""; + ++$count; + eval { $tfa_cfg->add_totp_entry($userid, $description, $uri . $key) }; + warn $@ if $@; + } +} + +sub add_old_keys_to_realm_tfa : prototype($$$$) { + my ($userid, $tfa_cfg, $realm_tfa, $keys) = @_; + + # if there's no realm tfa configured, we don't know what the keys mean, so we just ignore + # them... + return if !$realm_tfa; + + my $type = $realm_tfa->{type}; + if ($type eq 'oath') { + add_old_totp_keys($userid, $tfa_cfg, $realm_tfa, $keys); + } elsif ($type eq 'yubico') { + add_old_yubico_keys($userid, $tfa_cfg, $keys); + } else { + # invalid keys, we'll just drop them now... + } +} + +sub user_get_tfa : prototype($$$) { + 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; + + my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); + if (defined($keys) && $keys !~ /^x(?:!.*)$/) { + add_old_keys_to_realm_tfa($username, $tfa_cfg, $realm_tfa, $keys); + } + + return ($tfa_cfg, $realm_tfa); +} + +# 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..bf7e968 --- /dev/null +++ b/src/PVE/Auth/LDAP.pm @@ -0,0 +1,489 @@ +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', + 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', + 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', + 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 }, + }; +} + +my sub verify_sync_attribute_value { + my ($attr, $value) = @_; + + # The attribute does not include the realm, so can't use PVE::Auth::Plugin::verify_username + if ($attr eq 'username') { + die "value '$value' does not look like a valid user name\n" + if $value !~ m/${PVE::Auth::Plugin::user_regex}/; + return; + } + + return if $attr eq 'enable'; # for backwards compat, don't parse/validate + + if (my $schema = PVE::JSONSchema::get_standard_option("user-$attr")) { + PVE::JSONSchema::validate($value, $schema, "invalid value '$value'\n"); + } else { + die "internal error: no schema for attribute '$attr' with value '$value' available!\n"; + } +} + +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, $param) = @_; + + 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 = $param->{password} || 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', + # NOTE: also ensure verify_sync_attribute_value can handle any new/changed attribute name + }; + # build on the fly as this is small and only called once per realm in a ldap-sync anyway + my $valid_sync_attributes = { map { $_ => 1 } values $ldap_attribute_map->%* }; + + foreach my $attr (PVE::Tools::split_list($config->{sync_attributes})) { + my ($ours, $ldap) = ($attr =~ m/^\s*(\w+)=(.*)\s*$/); + if (!$valid_sync_attributes->{$ours}) { + warn "skipping bad 'sync_attributes' entry – '$ours' is not a valid target attribute\n"; + next; + } + $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}) { + my $value = $user_attributes->{$attr}->[0]; + eval { verify_sync_attribute_value($ours, $value) }; + if (my $err = $@) { + warn "skipping attribute mapping '$attr'->'$ours' for user '$username' - $err"; + next; + } + $ret->{$username}->{$ours} = $value; + } + } + + if (wantarray) { + my $dn = $user->{dn}; + $dnmap->{lc($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->{lc($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); +} + +sub check_connection { + my ($class, $realm, $config, %param) = @_; + + $class->connect_and_bind($config, $realm, \%param); +} + +1; diff --git a/src/PVE/Auth/Makefile b/src/PVE/Auth/Makefile new file mode 100644 index 0000000..a5c4cde --- /dev/null +++ b/src/PVE/Auth/Makefile @@ -0,0 +1,12 @@ + +AUTH_SOURCES= \ + Plugin.pm \ + PVE.pm \ + PAM.pm \ + AD.pm \ + LDAP.pm \ + OpenId.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/OpenId.pm b/src/PVE/Auth/OpenId.pm new file mode 100755 index 0000000..c8e4db9 --- /dev/null +++ b/src/PVE/Auth/OpenId.pm @@ -0,0 +1,91 @@ +package PVE::Auth::OpenId; + +use strict; +use warnings; + +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); + +sub type { + return 'openid'; +} + +sub properties { + return { + "issuer-url" => { + description => "OpenID Issuer Url", + type => 'string', + maxLength => 256, + }, + "client-id" => { + description => "OpenID Client ID", + type => 'string', + maxLength => 256, + }, + "client-key" => { + description => "OpenID Client Key", + type => 'string', + optional => 1, + maxLength => 256, + }, + autocreate => { + description => "Automatically create users if they do not exist.", + optional => 1, + type => 'boolean', + default => 0, + }, + "username-claim" => { + description => "OpenID claim used to generate the unique username.", + type => 'string', + optional => 1, + }, + prompt => { + description => "Specifies whether the Authorization Server prompts the End-User for" + ." reauthentication and consent.", + type => 'string', + pattern => '(?:none|login|consent|select_account|\S+)', # \S+ is the extension variant + optional => 1, + }, + scopes => { + description => "Specifies the scopes (user details) that should be authorized and" + ." returned, for example 'email' or 'profile'.", + type => 'string', # format => 'some-safe-id-list', # FIXME: TODO + default => "email profile", + optional => 1, + }, + 'acr-values' => { + description => "Specifies the Authentication Context Class Reference values that the" + ."Authorization Server is being requested to use for the Auth Request.", + type => 'string', + pattern => '^[^\x00-\x1F\x7F <>#"]*$', # Prohibit characters not allowed in URI RFC 2396. + optional => 1, + }, + }; +} + +sub options { + return { + "issuer-url" => {}, + "client-id" => {}, + "client-key" => { optional => 1 }, + autocreate => { optional => 1 }, + "username-claim" => { optional => 1, fixed => 1 }, + prompt => { optional => 1 }, + scopes => { optional => 1 }, + "acr-values" => { optional => 1 }, + default => { optional => 1 }, + comment => { optional => 1 }, + }; +} + +sub authenticate_user { + my ($class, $config, $realm, $username, $password) = @_; + + die "OpenID realm does not allow password verification.\n"; +} + + +1; diff --git a/src/PVE/Auth/PAM.pm b/src/PVE/Auth/PAM.pm new file mode 100755 index 0000000..feabc0b --- /dev/null +++ b/src/PVE/Auth/PAM.pm @@ -0,0 +1,82 @@ +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 = Authen::PAM->new('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"; + } + + if (my $rpcenv = PVE::RPCEnvironment::get()) { + if (my $ip = $rpcenv->get_client_ip()) { + $pamh->pam_set_item(PAM_RHOST(), $ip); + } + } + + 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..aa3706f --- /dev/null +++ b/src/PVE/Auth/Plugin.pm @@ -0,0 +1,328 @@ +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 $remove_options = "(?:acl|properties|entry)"; + +PVE::JSONSchema::register_standard_option('sync-scope', { + description => "Select what to sync.", + type => 'string', + enum => [qw(users groups both)], + optional => '1', +}); + +PVE::JSONSchema::register_standard_option('sync-remove-vanished', { + description => "A semicolon-seperated list of things to remove when they or the user" + ." vanishes during a sync. The following values are possible: 'entry' removes the" + ." user/group when not returned from the sync. 'properties' removes the set" + ." properties on existing user/group that do not appear in the source (even custom ones)." + ." 'acl' removes acls when the user/group is not returned from the sync." + ." Instead of a list it also can be 'none' (the default).", + type => 'string', + default => 'none', + typetext => "([acl];[properties];[entry])|none", + pattern => "(?:(?:$remove_options\;)*$remove_options)|none", + optional => '1', +}); + +my $realm_sync_options_desc = { + scope => get_standard_option('sync-scope'), + 'remove-vanished' => get_standard_option('sync-remove-vanished'), + # TODO check/rewrite in pve7to8, and remove with 8.0 + full => { + description => "DEPRECATED: use 'remove-vanished' instead. If set, uses the LDAP Directory as source of truth," + ." deleting users or groups not returned from the sync and removing" + ." all locally modified properties of synced users. If not set," + ." only syncs information which is present in the synced data, and does not" + ." delete or modify anything else.", + type => 'boolean', + optional => '1', + }, + 'enable-new' => { + description => "Enable newly synced users immediately.", + type => 'boolean', + default => '1', + optional => '1', + }, + purge => { + description => "DEPRECATED: use 'remove-vanished' instead. 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 => "Full User ID, in the `name\@realm` format.", + 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 addition 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 domain config *locked* context +sub on_delete_hook { + my ($class, $realm, $config) = @_; + # do nothing by default +} + +# called during addition and updates of realms (before the new domain config gets written) +# die to abort addition/update in case the connection/bind fails +# NOTE: runs in a domain config *locked* context +sub check_connection { + my ($class, $realm, $config, %param) = @_; + # do nothing by default +} + +1; diff --git a/src/PVE/CLI/Makefile b/src/PVE/CLI/Makefile new file mode 100644 index 0000000..582814b --- /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..d635162 --- /dev/null +++ b/src/PVE/CLI/pveum.pm @@ -0,0 +1,282 @@ +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::Domains; +use PVE::API2::TFA; +use PVE::Cluster qw(cfs_read_file cfs_write_file); +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); + }}); + +__PACKAGE__->register_method({ + name => 'delete_tfa', + path => 'delete_tfa', + method => 'PUT', + description => 'Delete TFA entries from a user.', + parameters => { + additionalProperties => 0, + properties => { + userid => get_standard_option('userid'), + id => { + description => "The TFA ID, if none provided, all TFA entries will be deleted.", + type => 'string', + optional => 1, + }, + }, + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $userid = extract_param($param, "userid"); + my $tfa_id = extract_param($param, "id"); + + PVE::AccessControl::lock_tfa_config(sub { + my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); + if (defined($tfa_id)) { + $tfa_cfg->api_delete_tfa($userid, $tfa_id); + } else { + $tfa_cfg->remove_user($userid); + } + cfs_write_file('priv/tfa.cfg', $tfa_cfg); + }); + return; + }}); + +__PACKAGE__->register_method({ + name => 'list_tfa', + path => 'list_tfa', + method => 'GET', + description => "List TFA entries.", + parameters => { + additionalProperties => 0, + properties => { + userid => get_standard_option('userid', { optional => 1 }), + }, + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $userid = extract_param($param, "userid"); + + my sub format_tfa_entries : prototype($;$) { + my ($entries, $indent) = @_; + + $indent //= ''; + + my $nl = ''; + for my $entry (@$entries) { + my ($id, $ty, $desc) = ($entry->@{qw/id type description/}); + printf("${nl}${indent}%-9s %s\n${indent} %s\n", "$ty:", $id, $desc // ''); + $nl = "\n"; + } + }; + + my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); + if (defined($userid)) { + format_tfa_entries($tfa_cfg->api_list_user_tfa($userid)); + } else { + my $result = $tfa_cfg->api_list_tfa('', 1); + my $nl = ''; + for my $entry (sort { $a->{userid} cmp $b->{userid} } @$result) { + print "${nl}$entry->{userid}:\n"; + format_tfa_entries($entry->{entries}, ' '); + $nl = "\n"; + } + } + return; + }}); + +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 => [ __PACKAGE__, 'delete_tfa', ['userid'] ], + list => [ __PACKAGE__, 'list_tfa', ['userid'] ], + unlock => [ 'PVE::API2::User', 'unlock_tfa', ['userid'] ], + }, + 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], + }, + 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' }, +}; + +# FIXME: HACK! The pool API is in pve-manager as it needs access to storage guest and RRD stats, +# so we only add the pool commands if the API module is available (required for boots-trapping) +my $have_pool_api; +eval { + require PVE::API2::Pool; + PVE::API2::Pool->import(); + $have_pool_api = 1; +}; + +if ($have_pool_api) { + $cmddef->{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], + }; +} + +1; diff --git a/src/PVE/Jobs/Makefile b/src/PVE/Jobs/Makefile new file mode 100644 index 0000000..9eed1b2 --- /dev/null +++ b/src/PVE/Jobs/Makefile @@ -0,0 +1,6 @@ +SOURCES=RealmSync.pm + +.PHONY: install +install: ${SOURCES} + install -d -m 0755 ${DESTDIR}${PERLDIR}/PVE/Jobs + for i in ${SOURCES}; do install -D -m 0644 $$i ${DESTDIR}${PERLDIR}/PVE/Jobs/$$i; done diff --git a/src/PVE/Jobs/RealmSync.pm b/src/PVE/Jobs/RealmSync.pm new file mode 100644 index 0000000..4c77e55 --- /dev/null +++ b/src/PVE/Jobs/RealmSync.pm @@ -0,0 +1,204 @@ +package PVE::Jobs::RealmSync; + +use strict; +use warnings; + +use JSON qw(decode_json encode_json); +use POSIX qw(ENOENT); + +use PVE::JSONSchema qw(get_standard_option); +use PVE::Cluster (); +use PVE::CalendarEvent (); +use PVE::Tools (); + +use PVE::API2::Domains (); + +# load user-* standard options +use PVE::API2::User (); + +use base qw(PVE::Job::Registry); + +sub type { + return 'realm-sync'; +} + +my $props = get_standard_option('realm-sync-options', { + realm => get_standard_option('realm'), +}); + +sub properties { + return $props; +} + +sub options { + my $options = { + enabled => { optional => 1 }, + schedule => {}, + comment => { optional => 1 }, + scope => {}, + }; + for my $opt (keys %$props) { + next if defined($options->{$opt}); + # ignore legacy props from realm-sync schema + next if $opt eq 'full' || $opt eq 'purge'; + if ($props->{$opt}->{optional}) { + $options->{$opt} = { optional => 1 }; + } else { + $options->{$opt} = {}; + } + } + $options->{realm}->{fixed} = 1; + + return $options; +} + +sub decode_value { + my ($class, $type, $key, $value) = @_; + return $value; +} + +sub encode_value { + my ($class, $type, $key, $value) = @_; + return $value; +} + +sub createSchema { + my ($class, $skip_type) = @_; + + my $schema = $class->SUPER::createSchema($skip_type); + + my $opts = $class->options(); + for my $opt (keys $schema->{properties}->%*) { + next if defined($opts->{$opt}) || $opt eq 'id'; + delete $schema->{properties}->{$opt}; + } + + return $schema; +} + +sub updateSchema { + my ($class, $skip_type) = @_; + my $schema = $class->SUPER::updateSchema($skip_type); + + my $opts = $class->options(); + for my $opt (keys $schema->{properties}->%*) { + next if defined($opts->{$opt}); + next if $opt eq 'id' || $opt eq 'delete'; + delete $schema->{properties}->{$opt}; + } + + return $schema; +} + +my $statedir = "/etc/pve/priv/jobs"; + +sub get_state { + my ($id) = @_; + + mkdir $statedir; + my $statefile = "$statedir/realm-sync-$id.json"; + my $raw = eval { PVE::Tools::file_get_contents($statefile) } // ''; + + my $state = ($raw =~ m/^(\{.*\})$/) ? decode_json($1) : {}; + + return $state; +} + +sub save_state { + my ($id, $state) = @_; + + mkdir $statedir; + my $statefile = "$statedir/realm-sync-$id.json"; + + if (defined($state)) { + PVE::Tools::file_set_contents($statefile, encode_json($state)); + } else { + unlink $statefile or $! == ENOENT or die "could not delete state for $id - $!\n"; + } + + return undef; +} + +sub run { + my ($class, $conf, $id, $schedule) = @_; + + for my $opt (keys %$conf) { + delete $conf->{$opt} if !defined($props->{$opt}); + } + + my $realm = $conf->{realm}; + + # cluster synced + my $now = time(); + my $nodename = PVE::INotify::nodename(); + + # check statefile in pmxcfs if we should start + my $shouldrun = PVE::Cluster::cfs_lock_domain('realm-sync', undef, sub { + my $members = PVE::Cluster::get_members(); + + my $state = get_state($id); + my $last_node = $state->{node} // $nodename; + my $last_upid = $state->{upid}; + my $last_time = $state->{time}; + + my $last_node_online = $last_node eq $nodename || ($members->{$last_node} // {})->{online}; + + if (defined($last_upid)) { + # first check if the next run is scheduled + if (my $parsed = PVE::Tools::upid_decode($last_upid, 1)) { + my $cal_spec = PVE::CalendarEvent::parse_calendar_event($schedule); + my $next_sync = PVE::CalendarEvent::compute_next_event($cal_spec, $parsed->{starttime}); + return 0 if !defined($next_sync) || $now < $next_sync; # not yet its (next) turn + } + # check if still running and node is online + my $tasks = PVE::Cluster::get_tasklist(); + for my $task (@$tasks) { + next if $task->{upid} ne $last_upid; + last if defined($task->{endtime}); # it's already finished + last if !$last_node_online; # it's not finished and the node is offline + return 0; # not finished and online + } + } elsif (defined($last_time) && ($last_time+60) > $now && $last_node_online) { + # another node started this job in the last 60 seconds and is still online + return 0; + } + + # any of the following conditions should be true here: + # * it was started on another node but that node is offline now + # * it was started but either too long ago, or with an error + # * the started task finished + + save_state($id, { + node => $nodename, + time => $now, + }); + return 1; + }); + die $@ if $@; + + if ($shouldrun) { + my $upid = eval { PVE::API2::Domains->sync($conf) }; + my $err = $@; + PVE::Cluster::cfs_lock_domain('realm-sync', undef, sub { + if ($err && !$upid) { + save_state($id, { + node => $nodename, + time => $now, + error => $err, + }); + die "$err\n"; + } + + save_state($id, { + node => $nodename, + upid => $upid, + }); + }); + die $@ if $@; + return $upid; + } + + return "OK"; # all other cases should not run the sync on this node +} + +1; diff --git a/src/PVE/Makefile b/src/PVE/Makefile new file mode 100644 index 0000000..a032b19 --- /dev/null +++ b/src/PVE/Makefile @@ -0,0 +1,11 @@ + + +.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 + make -C Jobs install diff --git a/src/PVE/RPCEnvironment.pm b/src/PVE/RPCEnvironment.pm new file mode 100644 index 0000000..e668353 --- /dev/null +++ b/src/PVE/RPCEnvironment.pm @@ -0,0 +1,673 @@ +package PVE::RPCEnvironment; + +use strict; +use warnings; + +use PVE::AccessControl; +use PVE::Cluster; +use PVE::Exception qw(raise raise_param_exc raise_perm_exc); +use PVE::INotify; +use PVE::ProcFSTools; +use PVE::RESTEnvironment; +use PVE::SafeSyslog; +use PVE::Tools; + +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}; + + # permissions() has an early return for this case + die "internal error" if $user eq 'root@pam'; + + my $cache = $self->{aclcache}; + $cache->{$user} = {} if !$cache->{$user}; + my $data = $cache->{$user}; + + # permissions() will always prime the cache for the owning user + my ($username, undef) = PVE::AccessControl::split_tokenid($user, 1); + die "internal error" if $username && $username ne 'root@pam' && !defined($cache->{$username}); + + # resolve and cache roles of the current user/token for all pool ACL paths + 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; + } + } + } + } + + # get roles of current user/token on checked path - this already handles + # propagation and NoAccess along the path + # + # hash mapping role name to propagation flag value, a key being defined + # means the role is set + my $roles = PVE::AccessControl::roles($cfg, $user, $path); + + # apply roles inherited from pools + if ($data->{poolroles}->{$path}) { + # NoAccess must not be trumped by pool ACLs + if (!defined($roles->{NoAccess})) { + if ($data->{poolroles}->{$path}->{NoAccess}) { + # but pool ACL NoAccess trumps regular ACL + $roles = { 'NoAccess' => 0 }; + } else { + foreach my $role (keys %{$data->{poolroles}->{$path}}) { + # only use role from pool ACL if regular ACL didn't already + # set it, and never set propagation for pool-derived ACLs + $roles->{$role} = 0 if !defined($roles->{$role}); + } + } + } + } + + # cache roles + $data->{roles}->{$path} = $roles; + + # derive privs from set roles - hash mapping privilege name to propagation + # flag value, a key being defined means the priv is set + my $privs = {}; + foreach my $role (keys %$roles) { + if (my $privset = $cfg->{roles}->{$role}) { + foreach my $p (keys %$privset) { + # set priv '$p' to propagated iff any of the set roles + # containing it have the propagated flag set + $privs->{$p} ||= $roles->{$role}; + } + } + } + + # intersect user and token permissions + if ($username && $username ne 'root@pam') { + # map of set privs to their propagation flag value, for the owning user + my $user_privs = $cache->{$username}->{privs}->{$path}; + # list of privs set both for token and owning user + my $filtered_privs = [ grep { defined($user_privs->{$_}) } keys %$privs ]; + # intersection of privs using filtered list, combining both propagation + # flags + $privs = { map { $_ => $user_privs->{$_} && $privs->{$_} } @$filtered_privs }; + } + + foreach my $priv (keys %$privs) { + # safeguard, this should never happen anyway + delete $privs->{$priv} if !defined($privs->{$priv}); + } + + # cache privs + $data->{privs}->{$path} = $privs; + + return $privs; +}; + +# this is the method used by permission check helpers below +# +# returned value is a hash mapping all set privileges on $path to their +# respective propagation flag. the propagation flag is informational only - +# actual propagation is handled in PVE::AccessControl::roles(). to determine +# whether a privilege is set, check for definedness in the returned hash. +# +# compiled ACLs are cached, so repeated checks for the same path and user are +# almost free. +# +# if $user is a tokenid, permissions are calculated depending on the +# privilege-separation flag value: +# - non-priv-separated: permissions for owning user are returned +# - priv-separated: permissions for owning user are calculated and intersected +# with those of token +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 (!defined($path)) { + # this shouldn't happen! + warn "internal error: ACL check called for undefined ACL path!\n"; + return {}; + } + + 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 compute_api_permission { + my ($self, $authuser) = @_; + + my $usercfg = $self->{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|Sys\.Modify|SDN\./, + mapping => qr/Mapping\.|Permissions.Modify/, + }; + map { $res->{$_} = {} } keys %$priv_re_map; + + my $required_paths = ['/', '/nodes', '/access/groups', '/vms', '/storage', '/sdn', '/mapping']; + my $defined_paths = []; + PVE::AccessControl::iterate_acl_tree("/", $usercfg->{acl_root}, sub { + my ($path, $node) = @_; + push @$defined_paths, $path; + }); + + my $checked_paths = {}; + foreach my $path (@$required_paths, @$defined_paths) { + next if $checked_paths->{$path}; + $checked_paths->{$path} = 1; + + my $path_perm = $self->permissions($authuser, $path); + + my $toplevel = ($path =~ /^\/(\w+)/) ? $1 : 'dc'; + if ($toplevel eq 'pool') { + foreach my $priv (keys %$path_perm) { + next if !defined($path_perm->{$priv}); + + 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 !defined($path_perm->{$priv}); + + next if $priv !~ m/^($priv_regex)/; + $res->{$toplevel}->{$priv} = 1; + } + } + } + + return $res; +} + +sub get_effective_permissions { + my ($self, $user) = @_; + + # default / top level paths + my $paths = { + '/' => 1, + '/access' => 1, + '/access/groups' => 1, + '/nodes' => 1, + '/pool' => 1, + '/sdn' => 1, + '/storage' => 1, + '/vms' => 1, + }; + + my $cfg = $self->{user_cfg}; + + # paths explicitly listed in ACLs + PVE::AccessControl::iterate_acl_tree("/", $cfg->{acl_root}, sub { + my ($path, $node) = @_; + $paths->{$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); + foreach my $priv (keys %$path_perms) { + delete $path_perms->{$priv} if !defined($path_perms->{$priv}); + } + # 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); + } +} + +# check for any fashion of access to vnet/bridge +sub check_sdn_bridge { + my ($self, $username, $zone, $bridge, $privs, $noerr) = @_; + + my $path = "/sdn/zones/$zone/$bridge"; + # check access to bridge itself + return 1 if $self->check_any($username, $path, $privs, 1); + + my $cfg = $self->{user_cfg}; + my $bridge_acl = PVE::AccessControl::find_acl_tree_node($cfg->{acl_root}, $path); + if ($bridge_acl) { + # check access to VLANs + my $vlans = $bridge_acl->{children}; + for my $vlan (keys %$vlans) { + my $vlanpath = "$path/$vlan"; + return 1 if $self->check_any($username, $vlanpath, $privs, 1); + } + } + + # repeat check, but fatal + $self->check_any($username, $path, $privs, 0) if !$noerr; + + return; +} + +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); + my $normpath = PVE::AccessControl::normalize_path($path); + warn "Failed to normalize '$path'\n" if !defined($normpath) && defined($path); + + return $self->check_full($username, $normpath, $privs, $any, $noerr); + } elsif ($test eq 'userid-group') { + my $userid = $param->{userid}; + my ($t, $privs, %options) = @$check; + + my $check_existing_user = !$options{groups_param} || $options{groups_param} ne 'create'; + return 0 if $check_existing_user && !$self->check_user_exist($userid, $noerr); + + # check permission for ALL groups (and thus ALL users) + if (!$self->check_any($username, "/access/groups", $privs, 1)) { + # list of groups $username has any of $privs on + my $groups = $self->filter_groups($username, $privs, 1); + if ($options{groups_param}) { + # does $username have any of $privs on all new/updated/.. groups? + 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}; + } + } + if ($check_existing_user) { + # does $username have any of $privs on any existing group of $userid + 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 0 if !defined($path); # should already die in API2::ACL + 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 compatibility + +# 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(); +} + +# Permission helper for TFA and password API endpoints modifying users. +# Only root may modify root, regular users need to specify their password. +# +# Returns the same as `verify_username` in list context (userid, ruid, realm), +# or just the userid in scalar context. +sub reauth_user_for_user_modification : prototype($$$$;$) { + my ($rpcenv, $authuser, $userid, $password, $param_name) = @_; + + $param_name //= 'password'; + + ($userid, my $ruid, my $realm) = PVE::AccessControl::verify_username($userid); + $rpcenv->check_user_exist($userid); + + raise_perm_exc() if $userid eq 'root@pam' && $authuser ne 'root@pam'; + + # Regular users need to confirm their password to change TFA settings. + if ($authuser ne 'root@pam') { + raise_param_exc({ $param_name => 'password is required to modify user' }) + if !defined($password); + + ($authuser, my $auth_username, my $auth_realm) = + PVE::AccessControl::verify_username($authuser); + + my $domain_cfg = PVE::Cluster::cfs_read_file('domains.cfg'); + my $cfg = $domain_cfg->{ids}->{$auth_realm}; + die "auth domain '$auth_realm' does not exist\n" if !$cfg; + my $plugin = PVE::Auth::Plugin->lookup($cfg->{type}); + $plugin->authenticate_user($cfg, $auth_realm, $auth_username, $password); + } + + return wantarray ? ($userid, $ruid, $realm) : $userid; +} + +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..fa73f05 --- /dev/null +++ b/src/oathkeygen @@ -0,0 +1,14 @@ +#!/usr/bin/perl + +use strict; +use warnings; + +use MIME::Base32; # libmime-base32-perl + +open(my $RND_FH, '<', "/dev/urandom") or die "Unable to open '/dev/urandom' - $!"; +sysread($RND_FH, my $random_data, 10) == 10 or die "read random data failed - $!\n"; +close $RND_FH or warn "Unable to close '/dev/urandom' - $!"; + +print MIME::Base32::encode_rfc3548($random_data) . "\n"; + +exit(0); 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..859a84b --- /dev/null +++ b/src/test/Makefile @@ -0,0 +1,15 @@ + +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 + perl -I.. realm_sync_test.pl diff --git a/src/test/auth-test.pl b/src/test/auth-test.pl new file mode 100644 index 0000000..1a5addd --- /dev/null +++ b/src/test/auth-test.pl @@ -0,0 +1,18 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; + +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..16bf6c8 --- /dev/null +++ b/src/test/dump-perm.pl @@ -0,0 +1,41 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; + +use Data::Dumper; +use Getopt::Long; + +use PVE::RPCEnvironment; + +# 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..ebbb001 --- /dev/null +++ b/src/test/dump-users.pl @@ -0,0 +1,16 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; + +use Data::Dumper; + +use PVE::AccessControl; + +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..80c346b --- /dev/null +++ b/src/test/parser_writer.pl @@ -0,0 +1,1056 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; + +use Storable qw(dclone); +use Test::More; + +use PVE::AccessControl; + +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}; + my $split_path = [ split("/", $path) ]; + my $node = $acls; + for my $p (@$split_path) { + next if !$p; + $node->{children} = {} if !$node->{children}; + $node->{children}->{$p} = {} if !$node->{children}->{$p}; + $node = $node->{children}->{$p}; + } + %$node = ( %$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 => {}, + pools => {}, + }, + test_pool_members => { + 'id' => 'testpool', + vms => { 123 => 1, 1234 => 1}, + storage => { 'local' => 1, 'local-zfs' => 1}, + pools => {}, + }, + test_pool_duplicate_vms => { + 'id' => 'test_duplicate_vms', + vms => {}, + storage => {}, + pools => {}, + }, + test_pool_duplicate_storages => { + 'id' => 'test_duplicate_storages', + vms => {}, + storage => { 'local' => 1, 'local-zfs' => 1}, + pools => {}, + }, + 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 => { + acl_root => default_acls(), + users => { 'root@pam' => { enable => 1 } }, + roles => default_roles(), + }, + raw => "", + expected_raw => "\n\n\n\n", + }, + { + name => "default_config", + config => { + acl_root => default_acls(), + users => default_users(), + roles => default_roles(), + }, + raw => $default_raw->{users}->{'root@pam'}."\n\n\n\n\n", + }, + { + name => "group_empty", + config => { + acl_root => default_acls(), + 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 => { + acl_root => default_acls(), + 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 => { + acl_root => default_acls(), + 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 => { + acl_root => default_acls(), + 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 => { + acl_root => default_acls(), + 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 => { + acl_root => default_acls(), + 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 => { + acl_root => default_acls(), + 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 => { + acl_root => default_acls(), + 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 => { + acl_root => default_acls(), + 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 => { + acl_root => default_acls(), + 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 => { + acl_root => default_acls(), + 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 => { + acl_root => default_acls(), + 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 => { + acl_root => default_acls(), + 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 => { + acl_root => default_acls(), + 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 => { + acl_root => default_acls(), + 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_root => 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_root => 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_root => 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_root => 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_root => 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_root => 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_root => 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_root => 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_root => 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_root => 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_root => 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_root => 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}]), + acl_root => {}, + }, + 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..df9fe90 --- /dev/null +++ b/src/test/perm-test1.pl @@ -0,0 +1,95 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; + +use Getopt::Long; + +use PVE::Tools; + +use PVE::AccessControl; +use PVE::RPCEnvironment; + +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'); + +# PVEVMAdmin -> no Permissions.Modify! +check_permission( + 'alex@pve', + '/vms/300', + '' # sorted, comma-separated expected privilege string + . 'VM.Allocate,VM.Audit,VM.Backup,VM.Clone,VM.Config.CDROM,VM.Config.CPU,VM.Config.Cloudinit,' + . 'VM.Config.Disk,VM.Config.HWType,VM.Config.Memory,VM.Config.Network,VM.Config.Options,' + . 'VM.Console,VM.Migrate,VM.Monitor,VM.PowerMgmt,VM.Snapshot,VM.Snapshot.Rollback' +); +# Administrator -> Permissions.Modify! +check_permission( + 'alex@pve', + '/vms/400', + '' # sorted, comma-separated expected privilege string, loosely grouped by prefix + . 'Datastore.Allocate,Datastore.AllocateSpace,Datastore.AllocateTemplate,Datastore.Audit,' + . 'Group.Allocate,' + . 'Mapping.Audit,Mapping.Modify,Mapping.Use,' + . 'Permissions.Modify,' + . 'Pool.Allocate,Pool.Audit,' + . 'Realm.Allocate,Realm.AllocateUser,' + . 'SDN.Allocate,SDN.Audit,SDN.Use,' + . 'Sys.AccessNetwork,Sys.Audit,Sys.Console,Sys.Incoming,Sys.Modify,Sys.PowerMgmt,Sys.Syslog,' + . 'User.Modify,' + . 'VM.Allocate,VM.Audit,VM.Backup,VM.Clone,VM.Config.CDROM,VM.Config.CPU,VM.Config.Cloudinit,' + . 'VM.Config.Disk,VM.Config.HWType,VM.Config.Memory,VM.Config.Network,VM.Config.Options,' + . 'VM.Console,VM.Migrate,VM.Monitor,VM.PowerMgmt,VM.Snapshot,VM.Snapshot.Rollback', +); + +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..fe76eff --- /dev/null +++ b/src/test/perm-test2.pl @@ -0,0 +1,43 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; + +use Getopt::Long; + +use PVE::Tools; + +use PVE::AccessControl; +use PVE::RPCEnvironment; + +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..7acf577 --- /dev/null +++ b/src/test/perm-test3.pl @@ -0,0 +1,38 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; + +use Getopt::Long; + +use PVE::Tools; + +use PVE::AccessControl; +use PVE::RPCEnvironment; + +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..bef4ffb --- /dev/null +++ b/src/test/perm-test4.pl @@ -0,0 +1,36 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; + +use Getopt::Long; + +use PVE::Tools; + +use PVE::AccessControl; +use PVE::RPCEnvironment; + +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..44c12b2 --- /dev/null +++ b/src/test/perm-test5.pl @@ -0,0 +1,46 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; + +use Getopt::Long; + +use PVE::Tools; + +use PVE::AccessControl; +use PVE::RPCEnvironment; + +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..c2d40fc --- /dev/null +++ b/src/test/perm-test6.pl @@ -0,0 +1,112 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; + +use Getopt::Long; + +use PVE::Tools; + +use PVE::AccessControl; +use PVE::RPCEnvironment; + +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', ''); + +# without pool, checking no access on parent pool +check_roles('intern@pve', '/vms/600', ''); +# once more, with VM in nested pool +check_roles('intern@pve', '/vms/700', ''); +# with propagated ACL +check_roles('User4@pve', '/vms/700', ''); +# with pool, checking no access on parent pool +check_permissions('intern@pve', '/vms/600', ''); +# once more, with VM in nested pool +check_permissions('intern@pve', '/vms/700', 'VM.Audit'); +# with propagated ACL +check_permissions('User4@pve', '/vms/700', 'VM.Console'); + +# check nested pool permissions +check_roles('intern@pve', '/pool/marketing/interns', 'RoleINTERN'); +check_roles('User4@pve', '/pool/marketing/interns', 'RoleMARKETING'); + +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..da8535e --- /dev/null +++ b/src/test/perm-test7.pl @@ -0,0 +1,57 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; + +use Getopt::Long; + +use PVE::Tools; + +use PVE::AccessControl; +use PVE::RPCEnvironment; + +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..21bf1d3 --- /dev/null +++ b/src/test/perm-test8.pl @@ -0,0 +1,74 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; + +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', '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'); + +# check intersection -> token has Administrator, but user only vm_admin +check_permission('max@pve!token2', '/vms/300', 'VM.Allocate,VM.Audit,VM.Console,VM.PowerMgmt'); + +print "all tests passed\n"; + +exit (0); + diff --git a/src/test/realm_sync_test.pl b/src/test/realm_sync_test.pl new file mode 100755 index 0000000..3281315 --- /dev/null +++ b/src/test/realm_sync_test.pl @@ -0,0 +1,359 @@ +#!/usr/bin/perl + +use strict; +use warnings; + +use Test::MockModule; +use Test::More; +use Storable qw(dclone); + +use PVE::AccessControl; +use PVE::API2::Domains; + +my $domainscfg = { + ids => { + "pam" => { type => 'pam' }, + "pve" => { type => 'pve' }, + "syncedrealm" => { type => 'ldap' } + }, +}; + +my $initialusercfg = { + users => { + 'root@pam' => { username => 'root', }, + 'user1@syncedrealm' => { + username => 'user1', + enable => 1, + 'keys' => 'some', + }, + 'user2@syncedrealm' => { + username => 'user2', + enable => 1, + }, + 'user3@syncedrealm' => { + username => 'user3', + enable => 1, + }, + }, + groups => { + 'group1-syncedrealm' => { users => {}, }, + 'group2-syncedrealm' => { users => {}, }, + }, + acl_root => { + users => { + 'user3@syncedrealm' => {}, + }, + groups => {}, + }, +}; + +my $sync_response = { + user => [ + { + attributes => { 'uid' => ['user1'], }, + dn => 'uid=user1,dc=syncedrealm', + }, + { + attributes => { 'uid' => ['user2'], }, + dn => 'uid=user2,dc=syncedrealm', + }, + { + attributes => { 'uid' => ['user4'], }, + dn => 'uid=user4,dc=syncedrealm', + }, + ], + groups => [ + { + dn => 'dc=group1,dc=syncedrealm', + members => [ + 'uid=user1,dc=syncedrealm', + ], + }, + { + dn => 'dc=group3,dc=syncedrealm', + members => [ + 'uid=nonexisting,dc=syncedrealm', + ], + } + ], +}; + +my $returned_user_cfg = {}; + +# mocking all cluster and ldap operations +my $pve_cluster_module = Test::MockModule->new('PVE::Cluster'); +$pve_cluster_module->mock( + cfs_update => sub {}, + cfs_read_file => sub { + my ($filename) = @_; + if ($filename eq 'domains.cfg') { return dclone($domainscfg); } + if ($filename eq 'user.cfg') { return dclone($initialusercfg); } + die "unexpected cfs_read_file"; + }, + cfs_write_file => sub { + my ($filename, $data) = @_; + if ($filename eq 'user.cfg') { + $returned_user_cfg = $data; + return; + } + die "unexpected cfs_read_file"; + }, + cfs_lock_file => sub { + my ($filename, $timeout, $code) = @_; + return $code->(); + }, +); + +my $pve_api_domains = Test::MockModule->new('PVE::API2::Domains'); +$pve_api_domains->mock( + cfs_read_file => sub { PVE::Cluster::cfs_read_file(@_); }, + cfs_write_file => sub { PVE::Cluster::cfs_write_file(@_); }, +); + +my $pve_accesscontrol = Test::MockModule->new('PVE::AccessControl'); +$pve_accesscontrol->mock( + cfs_lock_file => sub { PVE::Cluster::cfs_lock_file(@_); }, +); + +my $pve_rpcenvironment = Test::MockModule->new('PVE::RPCEnvironment'); +$pve_rpcenvironment->mock( + get => sub { return bless {}, 'PVE::RPCEnvironment'; }, + get_user => sub { return 'root@pam'; }, + fork_worker => sub { + my ($class, $workertype, $id, $user, $code) = @_; + + return $code->(); + }, +); + +my $pve_ldap_module = Test::MockModule->new('PVE::LDAP'); +$pve_ldap_module->mock( + ldap_connect => sub { return {}; }, + ldap_bind => sub {}, + query_users => sub { + return $sync_response->{user}; + }, + query_groups => sub { + return $sync_response->{groups}; + }, +); + +my $pve_auth_ldap = Test::MockModule->new('PVE::Auth::LDAP'); +$pve_auth_ldap->mock( + connect_and_bind => sub { return {}; }, +); + +my $tests = [ + [ + "non-full without purge", + { + realm => 'syncedrealm', + scope => 'both', + }, + { + users => { + 'root@pam' => { username => 'root', }, + 'user1@syncedrealm' => { + username => 'user1', + enable => 1, + 'keys' => 'some', + }, + 'user2@syncedrealm' => { + username => 'user2', + enable => 1, + }, + 'user3@syncedrealm' => { + username => 'user3', + enable => 1, + }, + 'user4@syncedrealm' => { + username => 'user4', + enable => 1, + }, + }, + groups => { + 'group1-syncedrealm' => { + users => { + 'user1@syncedrealm' => 1, + }, + }, + 'group2-syncedrealm' => { users => {}, }, + 'group3-syncedrealm' => { users => {}, }, + }, + acl_root => { + users => { + 'user3@syncedrealm' => {}, + }, + groups => {}, + }, + }, + ], + [ + "full without purge", + { + realm => 'syncedrealm', + 'remove-vanished' => 'entry;properties', + scope => 'both', + }, + { + users => { + 'root@pam' => { username => 'root', }, + 'user1@syncedrealm' => { + username => 'user1', + enable => 1, + }, + 'user2@syncedrealm' => { + username => 'user2', + enable => 1, + }, + 'user4@syncedrealm' => { + username => 'user4', + enable => 1, + }, + }, + groups => { + 'group1-syncedrealm' => { + users => { + 'user1@syncedrealm' => 1, + }, + }, + 'group3-syncedrealm' => { users => {}, } + }, + acl_root => { + users => { + 'user3@syncedrealm' => {}, + }, + groups => {}, + }, + }, + ], + [ + "non-full with purge", + { + realm => 'syncedrealm', + 'remove-vanished' => 'acl', + scope => 'both', + }, + { + users => { + 'root@pam' => { username => 'root', }, + 'user1@syncedrealm' => { + username => 'user1', + enable => 1, + 'keys' => 'some', + }, + 'user2@syncedrealm' => { + username => 'user2', + enable => 1, + }, + 'user3@syncedrealm' => { + username => 'user3', + enable => 1, + }, + 'user4@syncedrealm' => { + username => 'user4', + enable => 1, + }, + }, + groups => { + 'group1-syncedrealm' => { + users => { + 'user1@syncedrealm' => 1, + }, + }, + 'group2-syncedrealm' => { users => {}, }, + 'group3-syncedrealm' => { users => {}, }, + }, + acl_root => { + users => {}, + groups => {}, + }, + }, + ], + [ + "full with purge", + { + realm => 'syncedrealm', + 'remove-vanished' => 'acl;entry;properties', + scope => 'both', + }, + { + users => { + 'root@pam' => { username => 'root', }, + 'user1@syncedrealm' => { + username => 'user1', + enable => 1, + }, + 'user2@syncedrealm' => { + username => 'user2', + enable => 1, + }, + 'user4@syncedrealm' => { + username => 'user4', + enable => 1, + }, + }, + groups => { + 'group1-syncedrealm' => { + users => { + 'user1@syncedrealm' => 1, + }, + }, + 'group3-syncedrealm' => { users => {}, }, + }, + acl_root => { + users => {}, + groups => {}, + }, + }, + ], + [ + "don't delete properties, but users and acls", + { + realm => 'syncedrealm', + 'remove-vanished' => 'acl;entry', + scope => 'both', + }, + { + users => { + 'root@pam' => { username => 'root', }, + 'user1@syncedrealm' => { + username => 'user1', + enable => 1, + 'keys' => 'some', + }, + 'user2@syncedrealm' => { + username => 'user2', + enable => 1, + }, + 'user4@syncedrealm' => { + username => 'user4', + enable => 1, + }, + }, + groups => { + 'group1-syncedrealm' => { + users => { + 'user1@syncedrealm' => 1, + }, + }, + 'group3-syncedrealm' => { users => {}, }, + }, + acl_root => { + users => {}, + groups => {}, + }, + }, + ], +]; + +for my $test (@$tests) { + my $name = $test->[0]; + my $parameters = $test->[1]; + my $expected = $test->[2]; + $returned_user_cfg = {}; + PVE::API2::Domains->sync($parameters); + is_deeply($returned_user_cfg, $expected, $name); +} + +done_testing(); diff --git a/src/test/test1.cfg b/src/test/test1.cfg new file mode 100644 index 0000000..0b1b587 --- /dev/null +++ b/src/test/test1.cfg @@ -0,0 +1,24 @@ +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: +acl:1:/vms/300:alex@pve:PVEVMAdmin: +acl:1:/vms/400:alex@pve:Administrator: + 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..661f56a --- /dev/null +++ b/src/test/test6.cfg @@ -0,0 +1,26 @@ +user:User1@pve:1: +user:User2@pve:1: +user:User3@pve:1: +user:User4@pve:1: +user:intern@pve:1: + +group:DEVEL:User1@pve,User2@pve,User3@pve: +group:MARKETING:User1@pve,User4@pve: +group:INTERNS:intern@pve: + +role:RoleDEVEL:VM.PowerMgmt: +role:RoleMARKETING:VM.Console: +role:RoleINTERN:VM.Audit: +role:RoleTEST1:VM.Console: + +acl:1:/pool/devel:@DEVEL:RoleDEVEL: +acl:1:/pool/marketing:@MARKETING:RoleMARKETING: +acl:1:/pool/marketing/interns:@INTERNS:RoleINTERN: + +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: +pool:marketing/interns:MITS marketing intern:700:store3: 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..ce704ef --- /dev/null +++ b/src/test/test8.cfg @@ -0,0 +1,32 @@ +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,VM.Console: + +acl:1:/vms:@testgroup1:vm_admin: +acl:0:/vms/300:max@pve:customer: +acl:1:/vms/300:max@pve: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 + +acl:1:/vms/300:max@pve!token2:Administrator 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