]> git.proxmox.com Git - pve-access-control.git/commitdiff
fix #5335: sort ACL entries in user.cfg master
authorDaniel Krambrock via pve-devel <pve-devel@lists.proxmox.com>
Thu, 11 Apr 2024 08:09:09 +0000 (10:09 +0200)
committerFabian Grünbichler <f.gruenbichler@proxmox.com>
Tue, 16 Apr 2024 12:21:58 +0000 (14:21 +0200)
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 <krambrock@hrz.uni-marburg.de>
Tested-by: Folge Gleumes <f.gleumes@proxmox.com>
44 files changed:
Makefile
debian/changelog
debian/compat [deleted file]
debian/control
debian/source/format
src/Makefile
src/PVE/API2/ACL.pm
src/PVE/API2/AccessControl.pm
src/PVE/API2/Domains.pm
src/PVE/API2/Jobs/Makefile [new file with mode: 0644]
src/PVE/API2/Jobs/RealmSync.pm [new file with mode: 0644]
src/PVE/API2/Makefile
src/PVE/API2/Role.pm
src/PVE/API2/TFA.pm
src/PVE/API2/User.pm
src/PVE/AccessControl.pm
src/PVE/Auth/LDAP.pm
src/PVE/Auth/Makefile
src/PVE/Auth/OpenId.pm
src/PVE/Auth/PAM.pm
src/PVE/Auth/Plugin.pm
src/PVE/CLI/Makefile
src/PVE/CLI/pveum.pm
src/PVE/Jobs/Makefile [new file with mode: 0644]
src/PVE/Jobs/RealmSync.pm [new file with mode: 0644]
src/PVE/Makefile
src/PVE/RPCEnvironment.pm
src/oathkeygen
src/test/auth-test.pl
src/test/dump-perm.pl
src/test/dump-users.pl
src/test/parser_writer.pl
src/test/perm-test1.pl
src/test/perm-test2.pl
src/test/perm-test3.pl
src/test/perm-test4.pl
src/test/perm-test5.pl
src/test/perm-test6.pl
src/test/perm-test7.pl
src/test/perm-test8.pl
src/test/realm_sync_test.pl
src/test/test1.cfg
src/test/test6.cfg
src/test/test8.cfg

index 77ae3f19a825cbf3b55527e592fb629b86ee03af..ed09ea812bf5f56218a6a761c674caa88b3eedea 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -1,12 +1,11 @@
-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
+DEB=$(PACKAGE)_$(DEB_VERSION_UPSTREAM_REVISION)_all.deb
+DSC=$(PACKAGE)_$(DEB_VERSION_UPSTREAM_REVISION).dsc
 
-BUILDDIR ?= ${PACKAGE}-${DEB_VERSION_UPSTREAM}
+BUILDDIR ?= $(PACKAGE)-$(DEB_VERSION_UPSTREAM)
 
 GITVERSION:=$(shell git rev-parse HEAD)
 
@@ -14,34 +13,36 @@ all:
 
 .PHONY: dinstall
 dinstall: deb
-       dpkg -i ${DEB}
+       dpkg -i $(DEB)
 
-${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
+$(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 bullseye --arch ${DEB_BUILD_ARCH}
+sbuild: $(DSC)
+       sbuild $(DSC)
 
-.PHONY: clean
-clean:
-       rm -rf ${PACKAGE}-*/ *.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.*
index 705d059e4cb5b9678433a31ba6e55892f433dd8b..ad18bb79d6f081592ad6579bd72a9dae69cdbe1a 100644 (file)
@@ -1,3 +1,250 @@
+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 <support@proxmox.com>  Fri, 22 Mar 2024 14:14:36 +0100
+
+libpve-access-control (8.1.2) bookworm; urgency=medium
+
+  * add Sys.AccessNetwork privilege
+
+ -- Proxmox Support Team <support@proxmox.com>  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 <support@proxmox.com>  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 <support@proxmox.com>  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 <support@proxmox.com>  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 <support@proxmox.com>  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 <support@proxmox.com>  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 <support@proxmox.com>  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 <support@proxmox.com>  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 <support@proxmox.com>  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 <support@proxmox.com>  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 <support@proxmox.com>  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/<zone>/<vnet>/<vlan> 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 <support@proxmox.com>  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 <support@proxmox.com>  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/<userid>/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 <support@proxmox.com>  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 <support@proxmox.com>  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 <support@proxmox.com>  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 <support@proxmox.com>  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 <support@proxmox.com>  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 <support@proxmox.com>  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 <support@proxmox.com>  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 <support@proxmox.com>  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
diff --git a/debian/compat b/debian/compat
deleted file mode 100644 (file)
index 48082f7..0000000
+++ /dev/null
@@ -1 +0,0 @@
-12
index 137791f4c03d5ce1896ba96ef298c4394bea6a27..580e1c11ca563f90d810f57041a0d13fcd19fe96 100644 (file)
@@ -2,19 +2,21 @@ Source: libpve-access-control
 Section: perl
 Priority: optional
 Maintainer: Proxmox Support Team <support@proxmox.com>
-Build-Depends: debhelper (>= 12~),
+Build-Depends: debhelper-compat (= 13),
+               libanyevent-perl,
                libauthen-pam-perl,
                libnet-ldap-perl,
-               libpve-common-perl (>= 6.0-11),
+               libpve-cluster-perl,
+               libpve-common-perl (>= 8.0.8),
+               libpve-rs-perl,
+               libtest-mockmodule-perl,
                liburi-perl,
                libuuid-perl,
                lintian,
                perl,
-               libpve-cluster-perl,
-               libpve-rs-perl,
                pve-cluster (>= 6.1-4),
-               pve-doc-generator (>= 5.3-3),
-Standards-Version: 4.5.1
+               pve-doc-generator (>= 5.3-3)
+Standards-Version: 4.6.2
 Homepage: https://www.proxmox.com
 
 Package: libpve-access-control
@@ -27,16 +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-rs-perl (>= 0.4.3),
+         libpve-common-perl (>= 8.0.8),
+         libpve-rs-perl (>= 0.8.3),
          libpve-u2f-server-perl (>= 1.0-2),
          liburi-perl,
          libuuid-perl,
          pve-cluster (>= 6.1-4),
          ${misc:Depends},
-         ${perl:Depends},
-Breaks: pve-manager (<< 7.0-15),
+         ${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.
index d3827e75a5cadb9fe4a27e1cb9b6d192e7323120..89ae9db8f88b823b6a7eabf55e203658739da122 100644 (file)
@@ -1 +1 @@
-1.0
+3.0 (native)
index fefa157ef782e09e68b9bc7d8a44621ab9495374..5e1ffd7623bec68d62653fa62c397ab554f31ad3 100644 (file)
@@ -1,21 +1,16 @@
-include /usr/share/dpkg/pkg-info.mk
-include /usr/share/dpkg/architecture.mk
-
-PACKAGE=libpve-access-control
-
-BUILDDIR ?= ${PACKAGE}-${DEB_VERSION_UPSTREAM}
+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
+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:
@@ -30,16 +25,16 @@ pveum.zsh-completion: PVE/CLI/pveum.pm
 
 .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}
+       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
+       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:
@@ -50,5 +45,4 @@ test:
 .PHONY: clean distclean
 distclean: clean
 clean:
-       make cleanup-docgen
-       find . -name '*~' -exec rm {} ';'
+       rm -f *.xml.tmp *.1 *.5 *.8 *{synopsis,opts}.adoc docinfo.xml
index 857c6727d225285b06fd8d97fe4a1d1df413b2e8..93adb78248c6de1f076f03581838d93a87983eb2 100644 (file)
@@ -60,16 +60,17 @@ __PACKAGE__->register_method ({
        my $res = [];
 
        my $usercfg = $rpcenv->{user_cfg};
-       if (!$usercfg || !$usercfg->{acl}) {
+       if (!$usercfg || !$usercfg->{acl_root}) {
            return $res;
        }
 
        my $audit = $rpcenv->check($authuser, '/access', ['Sys.Audit'], 1);
 
-       my $acl = $usercfg->{acl};
-       foreach my $path (keys %$acl) {
+       my $root = $usercfg->{acl_root};
+       PVE::AccessControl::iterate_acl_tree("/", $root, sub {
+           my ($path, $node) = @_;
            foreach my $type (qw(user group token)) {
-               my $d = $acl->{$path}->{"${type}s"};
+               my $d = $node->{"${type}s"};
                next if !$d;
                next if !($audit || $rpcenv->check_perm_modify($authuser, $path, 1));
                foreach my $id (keys %$d) {
@@ -85,7 +86,7 @@ __PACKAGE__->register_method ({
                    }
                }
            }
-       }
+       });
 
        return $res;
     }});
@@ -147,28 +148,52 @@ __PACKAGE__->register_method ({
 
        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($cfg->{acl}->{$path}->{groups}->{$group}->{$role});
+                           delete($node->{groups}->{$group}->{$role});
                        } else {
-                           $cfg->{acl}->{$path}->{groups}->{$group}->{$role} = $propagate;
+                           $node->{groups}->{$group}->{$role} = $propagate;
                        }
                    }
 
@@ -179,9 +204,9 @@ __PACKAGE__->register_method ({
                            if !$cfg->{users}->{$username};
 
                        if ($param->{delete}) {
-                           delete($cfg->{acl}->{$path}->{users}->{$username}->{$role});
+                           delete ($node->{users}->{$username}->{$role});
                        } else {
-                           $cfg->{acl}->{$path}->{users}->{$username}->{$role} = $propagate;
+                           $node->{users}->{$username}->{$role} = $propagate;
                        }
                    }
 
@@ -190,9 +215,9 @@ __PACKAGE__->register_method ({
                        PVE::AccessControl::check_token_exist($cfg, $username, $token);
 
                        if ($param->{delete}) {
-                           delete $cfg->{acl}->{$path}->{tokens}->{$tokenid}->{$role};
+                           delete $node->{tokens}->{$tokenid}->{$role};
                        } else {
-                           $cfg->{acl}->{$path}->{tokens}->{$tokenid}->{$role} = $propagate;
+                           $node->{tokens}->{$tokenid}->{$role} = $propagate;
                        }
                    }
                }
index 104e7e3036c7b0889c7038ba3101dc7c27614307..c55a7b347be7a9466cdad328c2d6ce029644b8cd 100644 (file)
@@ -111,8 +111,8 @@ __PACKAGE__->register_method ({
     }});
 
 
-my sub verify_auth : prototype($$$$$$$) {
-    my ($rpcenv, $username, $pw_or_ticket, $otp, $path, $privs, $new_format) = @_;
+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);
@@ -128,7 +128,6 @@ my sub verify_auth : prototype($$$$$$$) {
            $username,
            $pw_or_ticket,
            $otp,
-           $new_format,
        );
     }
 
@@ -140,8 +139,8 @@ my sub verify_auth : prototype($$$$$$$) {
     return { username => $username };
 };
 
-my sub create_ticket_do : prototype($$$$$$) {
-    my ($rpcenv, $username, $pw_or_ticket, $otp, $new_format, $tfa_challenge) = @_;
+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);
@@ -164,7 +163,6 @@ my sub create_ticket_do : prototype($$$$$$) {
            $username,
            $pw_or_ticket,
            $otp,
-           $new_format,
            $tfa_challenge,
        );
     }
@@ -172,26 +170,10 @@ my sub create_ticket_do : prototype($$$$$$) {
     my %extra;
     my $ticket_data = $username;
     my $aad;
-    if ($new_format) {
-       if (defined($tfa_info)) {
-           $extra{NeedTFA} = 1;
-           $ticket_data = "!tfa!$tfa_info";
-           $aad = $username;
-       }
-    } elsif (defined($tfa_info)) {
+    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";
-       }
+       $ticket_data = "!tfa!$tfa_info";
+       $aad = $username;
     }
 
     my $ticket = PVE::AccessControl::assemble_ticket($ticket_data, $aad);
@@ -267,12 +249,9 @@ __PACKAGE__->register_method ({
            },
            'new-format' => {
                type => 'boolean',
-               description =>
-                   'With webauthn the format of half-authenticated tickts changed.'
-                   .' New clients should pass 1 here and not worry about the old format.'
-                   .' The old format is deprecated and will be retired with PVE-8.0',
+               description => 'This parameter is now ignored and assumed to be 1.',
                optional => 1,
-               default => 0,
+               default => 1,
            },
            'tfa-challenge' => {
                type => 'string',
@@ -307,14 +286,13 @@ __PACKAGE__->register_method ({
 
            if ($param->{path} && $param->{privs}) {
                $res = verify_auth($rpcenv, $username, $param->{password}, $param->{otp},
-                                  $param->{path}, $param->{privs}, $param->{'new-format'});
+                                  $param->{path}, $param->{privs});
            } else {
                $res = create_ticket_do(
                    $rpcenv,
                    $username,
                    $param->{password},
                    $param->{otp},
-                   $param->{'new-format'},
                    $param->{'tfa-challenge'},
                );
            }
@@ -366,6 +344,7 @@ __PACKAGE__->register_method ({
                minLength => 5,
                maxLength => 64,
            },
+           'confirmation-password' => $PVE::API2::TFA::OPTIONAL_PASSWORD_SCHEMA,
        }
     },
     returns => { type => "null" },
@@ -375,9 +354,12 @@ __PACKAGE__->register_method ({
        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);
+       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
index aa8e716a70d38d7fbe2e133144d417f1ec4333ef..e7b7d39e62521e4a9ecc4e634a0ca279f59ab212 100644 (file)
@@ -117,7 +117,14 @@ __PACKAGE__->register_method ({
        check => ['perm', '/access/realm', ['Realm.Allocate']],
     },
     description => "Add an authentication server.",
-    parameters => PVE::Auth::Plugin->createSchema(),
+    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) = @_;
@@ -133,6 +140,7 @@ __PACKAGE__->register_method ({
 
                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};
@@ -143,6 +151,9 @@ __PACKAGE__->register_method ({
                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);
                }
@@ -165,6 +176,10 @@ __PACKAGE__->register_method ({
                }
                $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");
 
@@ -180,7 +195,14 @@ __PACKAGE__->register_method ({
     },
     description => "Update authentication server settings.",
     protected => 1,
-    parameters => PVE::Auth::Plugin->updateSchema(),
+    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) = @_;
@@ -198,12 +220,18 @@ __PACKAGE__->register_method ({
                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);
+               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)) {
@@ -211,7 +239,6 @@ __PACKAGE__->register_method ({
                    $delete_pw = 1 if $opt eq 'password';
                }
 
-               my $type = $ids->{$realm}->{type};
                if ($type eq 'ad' || $type eq 'ldap') {
                    $map_sync_default_options->($param, 1);
                }
@@ -236,6 +263,10 @@ __PACKAGE__->register_method ({
                    $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");
 
diff --git a/src/PVE/API2/Jobs/Makefile b/src/PVE/API2/Jobs/Makefile
new file mode 100644 (file)
index 0000000..fd79607
--- /dev/null
@@ -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 (file)
index 0000000..111ffc3
--- /dev/null
@@ -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/<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/<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;
index 2817f4887d91a3fe5e0e6cfb17660af2b057eead..7991c4c7b9ae771121bc95ffcd4f39b02cea3940 100644 (file)
@@ -9,6 +9,9 @@ API2_SOURCES=                   \
        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
+       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
index 70a92b67d300d29ad559f0f83a592d4b72347978..a924018fd87c448d0b6316d985f82213cd884655 100644 (file)
@@ -2,13 +2,11 @@ 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 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);
 
@@ -85,22 +83,25 @@ __PACKAGE__->register_method ({
     code => sub {
        my ($param) = @_;
 
-       PVE::AccessControl::lock_user_config(
-           sub {
+       my $role = $param->{roleid};
 
-               my $usercfg = cfs_read_file("user.cfg");
+       if ($role =~ /^PVE/i) {
+           raise_param_exc({
+               roleid => "cannot use role ID starting with the (case-insensitive) 'PVE' namespace",
+           });
+       }
 
-               my $role = $param->{roleid};
+       PVE::AccessControl::lock_user_config(sub {
+           my $usercfg = cfs_read_file("user.cfg");
 
-               die "role '$role' already exists\n"
-                   if $usercfg->{roles}->{$role};
+           die "role '$role' already exists\n" if $usercfg->{roles}->{$role};
 
-               $usercfg->{roles}->{$role} = {};
+           $usercfg->{roles}->{$role} = {};
 
-               PVE::AccessControl::add_role_privs($role, $usercfg, $param->{privs});
+           PVE::AccessControl::add_role_privs($role, $usercfg, $param->{privs});
 
-               cfs_write_file("user.cfg", $usercfg);
-           }, "create role failed");
+           cfs_write_file("user.cfg", $usercfg);
+       }, "create role failed");
 
        return undef;
 }});
@@ -131,20 +132,17 @@ __PACKAGE__->register_method ({
        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");
+       PVE::AccessControl::lock_user_config(sub {
+           my $usercfg = cfs_read_file("user.cfg");
 
-               die "role '$role' does not exist\n"
-                   if !$usercfg->{roles}->{$role};
+           die "role '$role' does not exist\n" if !$usercfg->{roles}->{$role};
 
-               $usercfg->{roles}->{$role} = {} if !$param->{append};
+           $usercfg->{roles}->{$role} = {} if !$param->{append};
 
-               PVE::AccessControl::add_role_privs($role, $usercfg, $param->{privs});
+           PVE::AccessControl::add_role_privs($role, $usercfg, $param->{privs});
 
-               cfs_write_file("user.cfg", $usercfg);
-           }, "update role failed");
+           cfs_write_file("user.cfg", $usercfg);
+       }, "update role failed");
 
        return undef;
 }});
@@ -207,19 +205,17 @@ __PACKAGE__->register_method ({
        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");
+       PVE::AccessControl::lock_user_config(sub {
+           my $usercfg = cfs_read_file("user.cfg");
 
-               die "role '$role' does not exist\n"
-                   if !$usercfg->{roles}->{$role};
+           die "role '$role' does not exist\n" if !$usercfg->{roles}->{$role};
 
-               delete ($usercfg->{roles}->{$role});
+           delete ($usercfg->{roles}->{$role});
 
-               # fixme: delete role from acl?
+           # fixme: delete role from acl?
 
-               cfs_write_file("user.cfg", $usercfg);
-           }, "delete role failed");
+           cfs_write_file("user.cfg", $usercfg);
+       }, "delete role failed");
 
        return undef;
     }
index bee4dee7deba40b2dd825debe3189e42acfd3f94..62ddd959378008ae3caf6ea92e04d43b489fc358 100644 (file)
@@ -18,8 +18,8 @@ use PVE::RESTHandler;
 
 use base qw(PVE::RESTHandler);
 
-my $OPTIONAL_PASSWORD_SCHEMA = {
-    description => "The current password.",
+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,
@@ -95,36 +95,6 @@ my $TFA_UPDATE_INFO_SCHEMA = {
     },
 };
 
-# Only root may modify root, regular users need to specify their password.
-#
-# Returns the userid returned from `verify_username`.
-# Or ($userid, $realm) in list context.
-my sub root_permission_check : prototype($$$$) {
-    my ($rpcenv, $authuser, $userid, $password) = @_;
-
-    ($userid, undef, 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({ 'password' => 'password is required to modify TFA data' })
-           if !defined($password);
-
-       ($authuser, my $auth_username, my $auth_realm) =
-           PVE::AccessControl::verify_username($authuser);
-
-       my $domain_cfg = 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, $realm) : $userid;
-}
-
 # 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($$$) {
@@ -152,83 +122,6 @@ my sub set_user_tfa_enabled : prototype($$$) {
     }, "enabling TFA for the user failed");
 }
 
-### OLD API
-
-__PACKAGE__->register_method({
-    name => 'verify_tfa',
-    path => '',
-    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, 0);
-       if (!defined($tfa_type)) {
-           raise('no u2f data available');
-       }
-       if ($tfa_type eq 'incompatible') {
-           raise('tfa entries incompatible with old login api');
-       }
-
-       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 = PVE::API2::AccessControl::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 => $rpcenv->compute_api_permission($authuser),
-       }
-    }});
-
-### END OLD API
-
 __PACKAGE__->register_method ({
     name => 'list_user_tfa',
     path => '{userid}',
@@ -240,7 +133,6 @@ __PACKAGE__->register_method ({
        ],
     },
     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 => 'List TFA configurations of users.',
     parameters => {
        additionalProperties => 0,
@@ -254,6 +146,7 @@ __PACKAGE__->register_method ({
        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) = @_;
@@ -272,7 +165,6 @@ __PACKAGE__->register_method ({
        ],
     },
     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 => 'Fetch a requested TFA entry if present.',
     parameters => {
        additionalProperties => 0,
@@ -320,12 +212,13 @@ __PACKAGE__->register_method ({
     code => sub {
        my ($param) = @_;
 
-       PVE::AccessControl::assert_new_tfa_config_available();
-       
        my $rpcenv = PVE::RPCEnvironment::get();
        my $authuser = $rpcenv->get_user();
-       my $userid =
-           root_permission_check($rpcenv, $authuser, $param->{userid}, $param->{password});
+       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');
@@ -347,7 +240,6 @@ __PACKAGE__->register_method ({
        user => 'all',
     },
     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 => 'List TFA configurations of users.',
     parameters => {
        additionalProperties => 0,
@@ -367,8 +259,20 @@ __PACKAGE__->register_method ({
                    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) = @_;
@@ -445,12 +349,13 @@ __PACKAGE__->register_method ({
     code => sub {
        my ($param) = @_;
 
-       PVE::AccessControl::assert_new_tfa_config_available();
-
        my $rpcenv = PVE::RPCEnvironment::get();
        my $authuser = $rpcenv->get_user();
-       my ($userid, $realm) =
-           root_permission_check($rpcenv, $authuser, $param->{userid}, $param->{password});
+       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};
@@ -480,7 +385,7 @@ __PACKAGE__->register_method ({
        });
     }});
 
-sub validate_yubico_otp : prototype($$) {
+sub validate_yubico_otp : prototype($$$) {
     my ($userid, $realm, $value) = @_;
 
     my $domain_cfg = cfs_read_file('domains.cfg');
@@ -540,12 +445,13 @@ __PACKAGE__->register_method ({
     code => sub {
        my ($param) = @_;
 
-       PVE::AccessControl::assert_new_tfa_config_available();
-
        my $rpcenv = PVE::RPCEnvironment::get();
        my $authuser = $rpcenv->get_user();
-       my $userid =
-           root_permission_check($rpcenv, $authuser, $param->{userid}, $param->{password});
+       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');
index ed4cb707809663b67e57464a225c1654382b07b0..489d34f3577540abcb5f75a01d7706266ec6cb07 100644 (file)
@@ -29,13 +29,23 @@ register_standard_option('user-expire', {
     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-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', {
@@ -100,7 +110,7 @@ my $extract_user_data = sub {
 
     return $res if !$full;
 
-    $res->{groups} = $data->{groups} ? [ keys %{$data->{groups}} ] : [];
+    $res->{groups} = $data->{groups} ? [ sort keys %{$data->{groups}} ] : [];
     $res->{tokens} = $data->{tokens};
 
     return $res;
@@ -115,6 +125,7 @@ __PACKAGE__->register_method ({
        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 => {
@@ -157,6 +168,17 @@ __PACKAGE__->register_method ({
                    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}" } ],
@@ -178,6 +200,8 @@ __PACKAGE__->register_method ({
        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};
@@ -205,6 +229,14 @@ __PACKAGE__->register_method ({
 
            $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;
        }
 
@@ -556,6 +588,38 @@ __PACKAGE__->register_method ({
        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',
index c32dcc303fabc2498ae0cdfc16c4759962213690..47f2d38b09c7f267e74978de506cb47cbdf8ae41 100644 (file)
@@ -16,6 +16,7 @@ use JSON;
 use Scalar::Util 'weaken';
 use URI::Escape;
 
+use PVE::Exception qw(raise_perm_exc raise_param_exc);
 use PVE::OTP;
 use PVE::Ticket;
 use PVE::Tools qw(run_command lock_file file_get_contents split_list safe_print);
@@ -717,8 +718,8 @@ sub verify_one_time_pw {
 
 # password should be utf8 encoded
 # Note: some plugins delay/sleep if auth fails
-sub authenticate_user : prototype($$$$;$) {
-    my ($username, $password, $otp, $new_format, $tfa_challenge) = @_;
+sub authenticate_user : prototype($$$;$) {
+    my ($username, $password, $otp, $tfa_challenge) = @_;
 
     die "no username specified\n" if !$username;
 
@@ -744,121 +745,122 @@ sub authenticate_user : prototype($$$$;$) {
 
     $plugin->authenticate_user($cfg, $realm, $ruid, $password);
 
-    if ($new_format) {
-       # This is the first factor with an optional immediate 2nd factor for TOTP:
-       my $tfa_challenge = authenticate_2nd_new($username, $realm, $otp, $tfa_challenge);
-       return wantarray ? ($username, $tfa_challenge) : $username;
-    } else {
-       return authenticate_2nd_old($username, $realm, $otp);
-    }
+    # 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_old : prototype($$$) {
-    my ($username, $realm, $otp) = @_;
-
-    my ($type, $tfa_data) = user_get_tfa($username, $realm, 0);
-    if ($type) {
-       if ($type eq 'incompatible') {
-           die "old login api disabled, user has incompatible TFA entries\n";
-       } elsif ($type eq 'u2f') {
-           # Note that if the user did not manage to complete the initial u2f registration
-           # challenge we have a hash containing a 'challenge' entry in the user's tfa.cfg entry:
-           $tfa_data = undef if exists $tfa_data->{challenge};
-       } elsif (!defined($otp)) {
-           # The user requires a 2nd factor but has not provided one. Return success but
-           # don't clear $tfa_data.
-       } else {
-           my $keys = $tfa_data->{keys};
-           my $tfa_cfg = $tfa_data->{config};
-           verify_one_time_pw($type, $username, $keys, $tfa_cfg, $otp);
-           $tfa_data = undef;
-       }
+sub authenticate_2nd_new_do : prototype($$$$) {
+    my ($username, $realm, $tfa_response, $tfa_challenge) = @_;
+    my ($tfa_cfg, $realm_tfa) = user_get_tfa($username, $realm);
 
-       # Return the type along with the rest:
-       if ($tfa_data) {
-           $tfa_data = {
-               type => $type,
-               data => $tfa_data,
-           };
-       }
+    # 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;
     }
 
-    return wantarray ? ($username, $tfa_data) : $username;
-}
-
-# Returns a tfa challenge or undef.
-sub authenticate_2nd_new : prototype($$$$) {
-    my ($username, $realm, $otp, $tfa_challenge) = @_;
-
-    my $result = lock_tfa_config(sub {
-       my ($tfa_cfg, $realm_tfa) = user_get_tfa($username, $realm, 1);
-
-       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 ($otp =~ /^yubico:(.*)$/) {
-                   $otp = $1;
-                   # Defer to after unlocking the TFA config:
-                   return sub {
-                       authenticate_yubico_new(
-                           $tfa_cfg, $username, $realm_tfa, $tfa_challenge, $otp,
-                       );
-                   };
+    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);
            }
 
-           my $response_type;
-           if (defined($otp)) {
-               if ($otp !~ /^([^:]+):/) {
-                   die "bad otp response\n";
-               }
-               $response_type = $1;
+           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,
+                   );
+               };
            }
+       }
 
-           die "realm requires $realm_type authentication\n"
-               if $response_type && $response_type ne 'recovery' && $response_type ne $realm_type;
+       my $response_type;
+       if (defined($tfa_response)) {
+           if ($tfa_response !~ /^([^:]+):/) {
+               die "bad otp response\n";
+           }
+           $response_type = $1;
        }
 
-       configure_u2f_and_wa($tfa_cfg);
+       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 $must_save = 0;
-       if (defined($tfa_challenge)) {
-           $tfa_challenge = verify_ticket($tfa_challenge, 0, $username);
-           $must_save = $tfa_cfg->authentication_verify($username, $tfa_challenge, $otp);
-           $tfa_challenge = undef;
-       } else {
-           $tfa_challenge = $tfa_cfg->authentication_challenge($username);
-           if (defined($otp)) {
-               if (defined($tfa_challenge)) {
-                   $must_save = $tfa_cfg->authentication_verify($username, $tfa_challenge, $otp);
-               } else {
-                   die "no such challenge\n";
-               }
+    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 ($must_save) {
+       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;
-    });
+    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') {
@@ -940,6 +942,43 @@ sub domain_set_password {
     $plugin->store_password($cfg, $realm, $username, $password);
 }
 
+sub iterate_acl_tree {
+    my ($path, $node, $code) = @_;
+
+    $code->($path, $node);
+
+    $path = '' if $path eq '/'; # avoid leading '//'
+
+    my $children = $node->{children};
+
+    foreach my $child (sort keys %$children) {
+       iterate_acl_tree("$path/$child", $children->{$child}, $code);
+    }
+}
+
+# find ACL node corresponding to normalized $path under $root
+sub find_acl_tree_node {
+    my ($root, $path) = @_;
+
+    my $split_path = [ split("/", $path) ];
+
+    if (!$split_path) {
+       return $root;
+    }
+
+    my $node = $root;
+    for my $p (@$split_path) {
+       next if !$p;
+
+       $node->{children} = {} if !$node->{children};
+       $node->{children}->{$p} = {} if !$node->{children}->{$p};
+
+       $node = $node->{children}->{$p};
+    }
+
+    return $node;
+}
+
 sub add_user_group {
     my ($username, $usercfg, $group) = @_;
 
@@ -960,29 +999,33 @@ sub delete_user_group {
 sub delete_user_acl {
     my ($username, $usercfg) = @_;
 
-    foreach my $acl (keys %{$usercfg->{acl}}) {
+    my $code = sub {
+       my ($path, $acl_node) = @_;
 
-       delete ($usercfg->{acl}->{$acl}->{users}->{$username})
-           if $usercfg->{acl}->{$acl}->{users}->{$username};
-    }
+       delete ($acl_node->{users}->{$username})
+           if $acl_node->{users}->{$username};
+    };
+
+    iterate_acl_tree("/", $usercfg->{acl_root}, $code);
 }
 
 sub delete_group_acl {
     my ($group, $usercfg) = @_;
 
-    foreach my $acl (keys %{$usercfg->{acl}}) {
+    my $code = sub {
+       my ($path, $acl_node) = @_;
 
-       delete ($usercfg->{acl}->{$acl}->{groups}->{$group})
-           if $usercfg->{acl}->{$acl}->{groups}->{$group};
-    }
+       delete ($acl_node->{groups}->{$group})
+           if $acl_node->{groups}->{$group};
+    };
+
+    iterate_acl_tree("/", $usercfg->{acl_root}, $code);
 }
 
 sub delete_pool_acl {
     my ($pool, $usercfg) = @_;
 
-    my $path = "/pool/$pool";
-
-    delete ($usercfg->{acl}->{$path})
+    delete ($usercfg->{acl_root}->{children}->{pool}->{children}->{$pool});
 }
 
 # we automatically create some predefined roles by splitting privs
@@ -1022,9 +1065,10 @@ my $privgroups = {
        root => [
            'Sys.PowerMgmt',
            'Sys.Modify', # edit/change node settings
+           'Sys.Incoming', # incoming storage/guest migrations
+           'Sys.AccessNetwork', # for, e.g., downloading ISOs from any URL
        ],
        admin => [
-           'Permissions.Modify',
            'Sys.Console',
            'Sys.Syslog',
        ],
@@ -1052,6 +1096,9 @@ my $privgroups = {
            'SDN.Allocate',
            'SDN.Audit',
        ],
+       user => [
+           'SDN.Use',
+       ],
        audit => [
            'SDN.Audit',
        ],
@@ -1080,9 +1127,23 @@ my $privgroups = {
            'Pool.Audit',
        ],
     },
+    Mapping => {
+       root => [],
+       admin => [
+           'Mapping.Modify',
+       ],
+       user => [
+           'Mapping.Use',
+       ],
+       audit => [
+           'Mapping.Audit',
+       ],
+    },
 };
 
-my $valid_privs = {};
+my $valid_privs = {
+    'Permissions.Modify' => 1, # not contained in a group
+};
 
 my $special_roles = {
     'NoAccess' => {}, # no privileges
@@ -1091,27 +1152,32 @@ my $special_roles = {
 
 sub create_roles {
 
-    foreach my $cat (keys %$privgroups) {
+    for my $cat (keys %$privgroups) {
        my $cd = $privgroups->{$cat};
-       foreach my $p (@{$cd->{root}}, @{$cd->{admin}},
-                      @{$cd->{user}}, @{$cd->{audit}}) {
-           $valid_privs->{$p} = 1;
+       # create map to easily check if a privilege is valid
+       for my $priv (@{$cd->{root}}, @{$cd->{admin}}, @{$cd->{user}}, @{$cd->{audit}}) {
+           $valid_privs->{$priv} = 1;
        }
-       foreach my $p (@{$cd->{admin}}, @{$cd->{user}}, @{$cd->{audit}}) {
-
-           $special_roles->{"PVE${cat}Admin"}->{$p} = 1;
-           $special_roles->{"PVEAdmin"}->{$p} = 1;
+       # create grouped admin roles and PVEAdmin
+       for my $priv (@{$cd->{admin}}, @{$cd->{user}}, @{$cd->{audit}}) {
+           $special_roles->{"PVE${cat}Admin"}->{$priv} = 1;
+           $special_roles->{"PVEAdmin"}->{$priv} = 1;
        }
+       # create grouped user and audit roles
        if (scalar(@{$cd->{user}})) {
-           foreach my $p (@{$cd->{user}}, @{$cd->{audit}}) {
-               $special_roles->{"PVE${cat}User"}->{$p} = 1;
+           for my $priv (@{$cd->{user}}, @{$cd->{audit}}) {
+               $special_roles->{"PVE${cat}User"}->{$priv} = 1;
            }
        }
-       foreach my $p (@{$cd->{audit}}) {
-           $special_roles->{"PVEAuditor"}->{$p} = 1;
+       for my $priv (@{$cd->{audit}}) {
+           $special_roles->{"PVEAuditor"}->{$priv} = 1;
        }
     }
 
+    # remove Mapping.Modify from PVEAdmin, only Administrator, root@pam and
+    # PVEMappingAdmin should be able to use that for now
+    delete $special_roles->{"PVEAdmin"}->{"Mapping.Modify"};
+
     $special_roles->{"PVETemplateUser"} = { 'VM.Clone' => 1, 'VM.Audit' => 1 };
 };
 
@@ -1201,14 +1267,25 @@ sub check_path {
        |/nodes
        |/nodes/[[:alnum:]\.\-\_]+
        |/pool
-       |/pool/[[:alnum:]\.\-\_]+
+       |/pool/[A-Za-z0-9\.\-_]+(?:/[A-Za-z0-9\.\-_]+){0,2}
        |/sdn
+       |/sdn/controllers
+       |/sdn/controllers/[[:alnum:]\_\-]+
+       |/sdn/dns
+       |/sdn/dns/[[:alnum:]]+
+       |/sdn/ipams
+       |/sdn/ipams/[[:alnum:]]+
+       |/sdn/zones
        |/sdn/zones/[[:alnum:]\.\-\_]+
-       |/sdn/vnets/[[:alnum:]\.\-\_]+
+       |/sdn/zones/[[:alnum:]\.\-\_]+/[[:alnum:]\.\-\_]+
+       |/sdn/zones/[[:alnum:]\.\-\_]+/[[:alnum:]\.\-\_]+/[1-9][0-9]{0,3}
        |/storage
        |/storage/[[:alnum:]\.\-\_]+
        |/vms
        |/vms/[1-9][0-9]{2,}
+       |/mapping
+       |/mapping/[[:alnum:]\.\-\_]+
+       |/mapping/[[:alnum:]\.\-\_]+/[[:alnum:]\.\-\_]+
     )$!xs;
 }
 
@@ -1244,8 +1321,14 @@ PVE::JSONSchema::register_format('pve-poolid', \&verify_poolname);
 sub verify_poolname {
     my ($poolname, $noerr) = @_;
 
-    if ($poolname !~ m/^[A-Za-z0-9\.\-_]+$/) {
+    if (split("/", $poolname) > 3) {
+       die "pool name '$poolname' nested too deeply (max levels = 3)\n" if !$noerr;
+
+       return undef;
+    }
 
+    # also adapt check_path above if changed!
+    if ($poolname !~ m!^[A-Za-z0-9\.\-_]+(?:/[A-Za-z0-9\.\-_]+){0,2}$!) {
        die "pool name '$poolname' contains invalid characters\n" if !$noerr;
 
        return undef;
@@ -1278,6 +1361,11 @@ sub userconfig_force_defaults {
     if (!$cfg->{users}->{'root@pam'}) {
        $cfg->{users}->{'root@pam'}->{enable} = 1;
     }
+
+    # add (empty) ACL tree root node
+    if (!$cfg->{acl_root}) {
+       $cfg->{acl_root} = {};
+    }
 }
 
 sub parse_user_config {
@@ -1392,6 +1480,7 @@ sub parse_user_config {
            $propagate = $propagate ? 1 : 0;
 
            if (my $path = normalize_path($pathtxt)) {
+               my $acl_node;
                foreach my $role (split_list($rolelist)) {
 
                    if (!verify_rolename($role, 1)) {
@@ -1411,15 +1500,18 @@ sub parse_user_config {
                            if (!$cfg->{groups}->{$group}) { # group does not exist
                                warn "user config - ignore invalid acl group '$group'\n";
                            }
-                           $cfg->{acl}->{$path}->{groups}->{$group}->{$role} = $propagate;
+                           $acl_node = find_acl_tree_node($cfg->{acl_root}, $path) if !$acl_node;
+                           $acl_node->{groups}->{$group}->{$role} = $propagate;
                        } elsif (PVE::Auth::Plugin::verify_username($ug, 1)) {
                            if (!$cfg->{users}->{$ug}) { # user does not exist
                                warn "user config - ignore invalid acl member '$ug'\n";
                            }
-                           $cfg->{acl}->{$path}->{users}->{$ug}->{$role} = $propagate;
+                           $acl_node = find_acl_tree_node($cfg->{acl_root}, $path) if !$acl_node;
+                           $acl_node->{users}->{$ug}->{$role} = $propagate;
                        } elsif (my ($user, $token) = split_tokenid($ug, 1)) {
                            if (check_token_exist($cfg, $user, $token, 1)) {
-                               $cfg->{acl}->{$path}->{tokens}->{$ug}->{$role} = $propagate;
+                               $acl_node = find_acl_tree_node($cfg->{acl_root}, $path) if !$acl_node;
+                               $acl_node->{tokens}->{$ug}->{$role} = $propagate;
                            } else {
                                warn "user config - ignore invalid acl token '$ug'\n";
                            }
@@ -1440,7 +1532,21 @@ sub parse_user_config {
            }
 
            # make sure to add the pool (even if there are no members)
-           $cfg->{pools}->{$pool} = { vms => {}, storage => {} } if !$cfg->{pools}->{$pool};
+           $cfg->{pools}->{$pool} = { vms => {}, storage => {}, pools => {} }
+               if !$cfg->{pools}->{$pool};
+
+           if ($pool =~ m!/!) {
+               my $curr = $pool;
+               while ($curr =~ m!^(.+)/[^/]+$!) {
+                   # ensure nested pool info is correctly recorded
+                   my $parent = $1;
+                   $cfg->{pools}->{$curr}->{parent} = $parent;
+                   $cfg->{pools}->{$parent} = { vms => {}, storage => {}, pools => {} }
+                       if !$cfg->{pools}->{$parent};
+                   $cfg->{pools}->{$parent}->{pools}->{$curr} = 1;
+                   $curr = $parent;
+               }
+           }
 
            $cfg->{pools}->{$pool}->{comment} = PVE::Tools::decode_text($comment) if $comment;
 
@@ -1588,8 +1694,8 @@ sub write_user_config {
        }
     };
 
-    foreach my $path (sort keys %{$cfg->{acl}}) {
-       my $d = $cfg->{acl}->{$path};
+    iterate_acl_tree("/", $cfg->{acl_root}, sub {
+       my ($path, $d) = @_;
 
        my $rolelist_members = {};
 
@@ -1608,7 +1714,7 @@ sub write_user_config {
            }
 
        }
-    }
+    });
 
     return $data;
 }
@@ -1636,8 +1742,6 @@ sub parse_priv_tfa_config {
 sub write_priv_tfa_config {
     my ($filename, $cfg) = @_;
 
-    assert_new_tfa_config_available();
-
     return $cfg->write();
 }
 
@@ -1672,12 +1776,20 @@ sub roles {
 
     my $roles = {};
 
-    foreach my $p (sort keys %{$cfg->{acl}}) {
-       my $final = ($path eq $p);
+    my $split = [ split("/", $path) ];
+    if ($path eq '/') {
+       $split = [ '' ];
+    }
 
-       next if !(($p eq '/') || $final || ($path =~ m|^$p/|));
+    my $acl = $cfg->{acl_root};
+    my $i = 0;
 
-       my $acl = $cfg->{acl}->{$p};
+    while (@$split) {
+       my $p = shift @$split;
+       my $final = !@$split;
+       if ($p ne '') {
+           $acl = $acl->{children}->{$p};
+       }
 
        #print "CHECKACL $path $p\n";
        #print "ACL $path = " . Dumper ($acl);
@@ -1746,20 +1858,20 @@ sub roles {
 sub remove_vm_access {
     my ($vmid) = @_;
     my $delVMaccessFn = sub {
-        my $usercfg = cfs_read_file("user.cfg");
+       my $usercfg = cfs_read_file("user.cfg");
        my $modified;
 
-        if (my $acl = $usercfg->{acl}->{"/vms/$vmid"}) {
-            delete $usercfg->{acl}->{"/vms/$vmid"};
+       if (my $acl = $usercfg->{acl_root}->{children}->{vms}->{children}->{$vmid}) {
+           delete $usercfg->{acl_root}->{children}->{vms}->{children}->{$vmid};
            $modified = 1;
-        }
-        if (my $pool = $usercfg->{vms}->{$vmid}) {
-            if (my $data = $usercfg->{pools}->{$pool}) {
-                delete $data->{vms}->{$vmid};
-                delete $usercfg->{vms}->{$vmid};
+       }
+       if (my $pool = $usercfg->{vms}->{$vmid}) {
+           if (my $data = $usercfg->{pools}->{$pool}) {
+               delete $data->{vms}->{$vmid};
+               delete $usercfg->{vms}->{$vmid};
                $modified = 1;
-            }
-        }
+           }
+       }
        cfs_write_file("user.cfg", $usercfg) if $modified;
     };
 
@@ -1770,18 +1882,18 @@ sub remove_storage_access {
     my ($storeid) = @_;
 
     my $deleteStorageAccessFn = sub {
-        my $usercfg = cfs_read_file("user.cfg");
+       my $usercfg = cfs_read_file("user.cfg");
        my $modified;
 
-        if (my $storage = $usercfg->{acl}->{"/storage/$storeid"}) {
-            delete $usercfg->{acl}->{"/storage/$storeid"};
-            $modified = 1;
-        }
+       if (my $acl = $usercfg->{acl_root}->{children}->{storage}->{children}->{$storeid}) {
+           delete $usercfg->{acl_root}->{children}->{storage}->{children}->{$storeid};
+           $modified = 1;
+       }
        foreach my $pool (keys %{$usercfg->{pools}}) {
            delete $usercfg->{pools}->{$pool}->{storage}->{$storeid};
            $modified = 1;
        }
-        cfs_write_file("user.cfg", $usercfg) if $modified;
+       cfs_write_file("user.cfg", $usercfg) if $modified;
     };
 
     lock_user_config($deleteStorageAccessFn,
@@ -1825,39 +1937,9 @@ my $USER_CONTROLLED_TFA_TYPES = {
     oath => 1,
 };
 
-sub assert_new_tfa_config_available() {
-    PVE::Cluster::cfs_update();
-    my $version_info = PVE::Cluster::get_node_kv('version-info');
-    die "cannot update tfa config, please make sure all cluster nodes are up to date\n"
-       if !$version_info;
-    my $members = PVE::Cluster::get_members() or return; # get_members returns undef on no cluster
-    my $old = '';
-    foreach my $node (keys $members->%*) {
-       my $info = $version_info->{$node};
-       if (!$info) {
-           $old .= "  cluster node '$node' is too old, did not broadcast its version info\n";
-           next;
-       }
-       $info = from_json($info);
-       my $ver = $info->{version};
-       if ($ver !~ /^(\d+\.\d+)-(\d+)/) {
-           $old .= "  cluster node '$node' provided an invalid version string: '$ver'\n";
-           next;
-       }
-       my ($maj, $rel) = ($1, $2);
-       if (!($maj > 7.0 || ($maj == 7.0 && $rel >= 15))) {
-           $old .= "  cluster node '$node' is too old ($ver < 7.0-15)\n";
-           next;
-       }
-    }
-    die "cannot update tfa config, following nodes are not up to date:\n$old" if length($old);
-}
-
 sub user_remove_tfa : prototype($) {
     my ($userid) = @_;
 
-    assert_new_tfa_config_available();
-
     my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
     $tfa_cfg->remove_user($userid);
     cfs_write_file('priv/tfa.cfg', $tfa_cfg);
@@ -1936,7 +2018,7 @@ sub add_old_keys_to_realm_tfa : prototype($$$$) {
 }
 
 sub user_get_tfa : prototype($$$) {
-    my ($username, $realm, $new_format) = @_;
+    my ($username, $realm) = @_;
 
     my $user_cfg = cfs_read_file('user.cfg');
     my $user = $user_cfg->{users}->{$username}
@@ -1952,40 +2034,12 @@ sub user_get_tfa : prototype($$$) {
     $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_tfa)
        if $realm_tfa;
 
-    if (!$keys) {
-       return if !$realm_tfa;
-       die "missing required 2nd keys\n";
-    }
-
-    if ($new_format) {
-       my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
-       if (defined($keys) && $keys !~ /^x(?:!.*)$/) {
-           add_old_keys_to_realm_tfa($username, $tfa_cfg, $realm_tfa, $keys);
-       }
-       return ($tfa_cfg, $realm_tfa);
+    my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+    if (defined($keys) && $keys !~ /^x(?:!.*)$/) {
+       add_old_keys_to_realm_tfa($username, $tfa_cfg, $realm_tfa, $keys);
     }
 
-    # new style config starts with an 'x' and optionally contains a !<type> suffix
-    if ($keys !~ /^x(?:!.*)?$/) {
-       # old style config, find the type via the realm
-       return if !$realm_tfa;
-       return ($realm_tfa->{type}, {
-           keys => $keys,
-           config => $realm_tfa,
-       });
-    } else {
-       my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
-       my $tfa = $tfa_cfg->{users}->{$username};
-       return if !$tfa; # should not happen (user.cfg wasn't cleaned up?)
-
-       if ($realm_tfa) {
-           # if the realm has a tfa setting we need to verify the type:
-           die "auth domain '$realm' and user have mismatching TFA settings\n"
-               if $realm_tfa && $realm_tfa->{type} ne $tfa->{type};
-       }
-
-       return ($tfa->{type}, $tfa->{data});
-    }
+    return ($tfa_cfg, $realm_tfa);
 }
 
 # bash completion helpers
index 97d077886cd904863b65270580da8f46fb62f376..bf7e968cb1824065bd5225ce1fe53df50e8e0fb7 100755 (executable)
@@ -19,7 +19,6 @@ sub properties {
        base_dn => {
            description => "LDAP base domain name",
            type => 'string',
-           pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*',
            optional => 1,
            maxLength => 256,
        },
@@ -33,7 +32,6 @@ sub properties {
        bind_dn => {
            description => "LDAP bind domain name",
            type => 'string',
-           pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*',
            optional => 1,
            maxLength => 256,
        },
@@ -91,7 +89,6 @@ sub properties {
            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,
        },
@@ -134,7 +131,7 @@ sub properties {
            type => 'boolean',
            optional => 1,
            default => 1,
-       }
+       },
     };
 }
 
@@ -169,6 +166,25 @@ sub options {
     };
 }
 
+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) = @_;
 
@@ -181,7 +197,7 @@ sub get_scheme_and_port {
 }
 
 sub connect_and_bind {
-    my ($class, $config, $realm) = @_;
+    my ($class, $config, $realm, $param) = @_;
 
     my $servers = [$config->{server1}];
     push @$servers, $config->{server2} if $config->{server2};
@@ -212,7 +228,7 @@ sub connect_and_bind {
 
     if ($config->{bind_dn}) {
        my $bind_dn = $config->{bind_dn};
-       my $bind_pass = ldap_get_credentials($realm);
+       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}) {
@@ -270,10 +286,17 @@ sub get_users {
        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;
     }
 
@@ -304,13 +327,19 @@ sub get_users {
 
        foreach my $attr (keys %$user_attributes) {
            if (my $ours = $ldap_attribute_map->{$attr}) {
-               $ret->{$username}->{$ours} = $user_attributes->{$attr}->[0];
+               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->{$dn} = $username;
+           $dnmap->{lc($dn)} = $username;
        }
     }
 
@@ -351,7 +380,7 @@ sub get_groups {
 
            $ret->{$name} = { users => {} };
            foreach my $member (@{$group->{members}}) {
-               if (my $user = $dnmap->{$member}) {
+               if (my $user = $dnmap->{lc($member)}) {
                    $ret->{$name}->{users}->{$user} = 1;
                }
            }
@@ -451,4 +480,10 @@ sub on_delete_hook {
     ldap_delete_credentials($realm);
 }
 
+sub check_connection {
+    my ($class, $realm, $config, %param) = @_;
+
+    $class->connect_and_bind($config, $realm, \%param);
+}
+
 1;
index be7bde3cbb48ea4d276a55c44116883f86745ef8..a5c4cde9baf803256d6d0601270adf52a9031e69 100644 (file)
@@ -9,4 +9,4 @@ AUTH_SOURCES=                   \
 
 .PHONY: install
 install:
-       for i in ${AUTH_SOURCES}; do install -D -m 0644 $$i ${DESTDIR}${PERLDIR}/PVE/Auth/$$i; done
+       for i in $(AUTH_SOURCES); do install -D -m 0644 $$i $(DESTDIR)$(PERLDIR)/PVE/Auth/$$i; done
index 56904e64e76d34230a20b5ea529d299bbdc47937..c8e4db930c148f147841f56b0bcdc53a449d1c88 100755 (executable)
@@ -59,7 +59,8 @@ sub properties {
        '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', # format => 'some-safe-id-list', # FIXME: TODO
+           type => 'string',
+           pattern => '^[^\x00-\x1F\x7F <>#"]*$', # Prohibit characters not allowed in URI RFC 2396.
            optional => 1,
        },
    };
index d016f834f9e3ec92b87e86ffc36a6ea42d45f15c..feabc0b18bb564eacd484752fa4b9d065580756a 100755 (executable)
@@ -27,7 +27,7 @@ sub authenticate_user {
     # user (www-data) need to be able to read /etc/passwd /etc/shadow
     die "no password\n" if !$password;
 
-    my $pamh = new Authen::PAM('proxmox-ve-auth', $username, sub {
+    my $pamh = Authen::PAM->new('proxmox-ve-auth', $username, sub {
        my @res;
        while(@_) {
            my $msg_type = shift;
@@ -43,6 +43,12 @@ sub authenticate_user {
        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) {
index 03d3342e48c7004ec7047e8be3bd009f232bd0ad..aa3706fd3d59d391cc0358b7d84d0e5ee230a9cb 100755 (executable)
@@ -51,24 +51,30 @@ PVE::JSONSchema::register_standard_option('realm', {
 
 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 => {
-       description => "Select what to sync.",
-       type => 'string',
-       enum => [qw(users groups both)],
-       optional => '1',
-    },
-    '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.",
-       type => 'string',
-       typetext => "[acl];[properties];[entry]",
-       pattern => "(?:$remove_options\;)*$remove_options",
-       optional => '1',
-    },
+    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,"
@@ -125,7 +131,7 @@ sub verify_username {
 }
 
 PVE::JSONSchema::register_standard_option('userid', {
-    description => "User ID",
+    description => "Full User ID, in the `name\@realm` format.",
     type => 'string', format => 'pve-userid',
     maxLength => 64,
 });
@@ -305,10 +311,18 @@ sub on_update_hook {
 # 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
+# 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;
index 30542124e1ef8fa68db60897c2d1cfb13e7f7df8..582814b237e66b3322064ba8da331cccc5701afc 100644 (file)
@@ -1,9 +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
+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:
index 44399b6f3ce6b4543c946c04427e659e72ddf672..d6351622f8e06a1d24d62abf5baff95056ab46b2 100755 (executable)
@@ -135,8 +135,6 @@ __PACKAGE__->register_method({
        my $userid = extract_param($param, "userid");
        my $tfa_id = extract_param($param, "id");
 
-       PVE::AccessControl::assert_new_tfa_config_available();
-
        PVE::AccessControl::lock_tfa_config(sub {
            my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
            if (defined($tfa_id)) {
@@ -149,6 +147,51 @@ __PACKAGE__->register_method({
        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'] ],
@@ -158,6 +201,8 @@ our $cmddef = {
        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 ],
diff --git a/src/PVE/Jobs/Makefile b/src/PVE/Jobs/Makefile
new file mode 100644 (file)
index 0000000..9eed1b2
--- /dev/null
@@ -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 (file)
index 0000000..4c77e55
--- /dev/null
@@ -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;
index c839d8f3f225dff14da76f324284dbba3bc0e073..a032b19aee82bf7e55f11856303197f7e94dfb9d 100644 (file)
@@ -3,8 +3,9 @@
 .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
+       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
index 0ee23460bd4ed153e271a32f4cbc401e087647fb..e6683532c1f506aaaf688665fcdef50ba80e319a 100644 (file)
@@ -5,7 +5,7 @@ use warnings;
 
 use PVE::AccessControl;
 use PVE::Cluster;
-use PVE::Exception qw(raise raise_perm_exc);
+use PVE::Exception qw(raise raise_param_exc raise_perm_exc);
 use PVE::INotify;
 use PVE::ProcFSTools;
 use PVE::RESTEnvironment;
@@ -186,14 +186,20 @@ sub compute_api_permission {
        storage => qr/Datastore\.|Permissions\.Modify/,
        nodes => qr/Sys\.|Permissions\.Modify/,
        sdn => qr/SDN\.|Permissions\.Modify/,
-       dc => qr/Sys\.Audit|SDN\./,
+       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'];
+    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, keys %{$usercfg->{acl}}) {
+    foreach my $path (@$required_paths, @$defined_paths) {
        next if $checked_paths->{$path};
        $checked_paths->{$path} = 1;
 
@@ -236,7 +242,7 @@ sub get_effective_permissions {
        '/access' => 1,
        '/access/groups' => 1,
        '/nodes' => 1,
-       '/pools' => 1,
+       '/pool' => 1,
        '/sdn' => 1,
        '/storage' => 1,
        '/vms' => 1,
@@ -245,9 +251,10 @@ sub get_effective_permissions {
     my $cfg = $self->{user_cfg};
 
     # paths explicitly listed in ACLs
-    foreach my $acl_path (keys %{$cfg->{acl}}) {
-       $paths->{$acl_path} = 1;
-    }
+    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}}) {
@@ -318,6 +325,31 @@ sub check_full {
     }
 }
 
+# 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) = @_;
 
@@ -605,4 +637,37 @@ 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;
index 82e4eec48df159e364ef9aef3df58f73cbfed17a..fa73f05c72dd05a1cafc74edba2b7a6925e0df50 100755 (executable)
@@ -2,10 +2,13 @@
 
 use strict;
 use warnings;
-use MIME::Base32; #libmime-base32-perl
 
-my $test;
-open(RND, "/dev/urandom");
-sysread(RND, $test, 10) == 10 || die "read random data failed\n";
-print MIME::Base32::encode_rfc3548($test) . "\n";
+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);
index 60429a98c36daf348a046c8a84f637fbbd366c97..1a5addd57823e527fffc8012ed2030c73f3b014d 100644 (file)
@@ -1,7 +1,10 @@
 #!/usr/bin/perl -w
 
 use strict;
+use warnings;
+
 use PVE::PTY;
+
 use PVE::AccessControl;
 
 my $username = shift;
index cb2a2eebde5c89921fe4c4be10a671d1e1b04342..16bf6c89bdbed67010c9af97c810667f2ddff851 100755 (executable)
@@ -1,9 +1,12 @@
 #!/usr/bin/perl -w
 
 use strict;
-use PVE::RPCEnvironment;
-use Getopt::Long;
+use warnings;
+
 use Data::Dumper;
+use Getopt::Long;
+
+use PVE::RPCEnvironment;
 
 # example: 
 # dump-perm.pl -f myuser.cfg root /
index f08d30bac709e668361d8b7671c7ad44a0d23acf..ebbb0017dc1b8455e93ff8d7b0c2998be691f00e 100755 (executable)
@@ -1,9 +1,12 @@
 #!/usr/bin/perl -w
 
 use strict;
-use PVE::AccessControl;
+use warnings;
+
 use Data::Dumper;
 
+use PVE::AccessControl;
+
 my $cfg;
 
 $cfg = PVE::AccessControl::load_user_config();
index 2fef7db7711f70c2bfd556d943248dcb555b54f5..80c346b4cc883a0dc2afca149db0cf88a1a84f68 100755 (executable)
@@ -1,11 +1,12 @@
 #!/usr/bin/perl -w
 
 use strict;
+use warnings;
 
+use Storable qw(dclone);
 use Test::More;
-use PVE::AccessControl;
 
-use Storable qw(dclone);
+use PVE::AccessControl;
 
 PVE::AccessControl::create_roles();
 my $default_user_cfg = {};
@@ -120,7 +121,15 @@ sub default_acls_with {
     foreach my $a (@$extra_acls) {
        my $acl = dclone($a);
        my $path = delete $acl->{path};
-       $acls->{$path} = $acl;
+       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;
@@ -228,21 +237,25 @@ my $default_cfg = {
        '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' => '/',
@@ -451,6 +464,7 @@ my $tests = [
        name => "empty_config",
        config => {},
        expected_config => {
+           acl_root => default_acls(),
            users => { 'root@pam' => { enable => 1 } },
            roles => default_roles(),
        },
@@ -460,6 +474,7 @@ my $tests = [
     {
        name => "default_config",
        config => {
+           acl_root => default_acls(),
            users => default_users(),
            roles => default_roles(),
        },
@@ -468,6 +483,7 @@ my $tests = [
     {
        name => "group_empty",
        config => {
+           acl_root => default_acls(),
            users => default_users(),
            roles => default_roles(),
            groups => default_groups_with([$default_cfg->{'test_group_empty'}]),
@@ -480,6 +496,7 @@ my $tests = [
     {
        name => "group_inexisting_member",
        config => {
+           acl_root => default_acls(),
            users => default_users(),
            roles => default_roles(),
            groups => default_groups_with([$default_cfg->{'test_group_empty'}]),
@@ -496,6 +513,7 @@ my $tests = [
     {
        name => "group_invalid_member",
        expected_config => {
+           acl_root => default_acls(),
            users => default_users(),
            roles => default_roles(),
        },
@@ -507,6 +525,7 @@ my $tests = [
     {
        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'}]),
@@ -520,6 +539,7 @@ my $tests = [
     {
        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'}]),
@@ -534,6 +554,7 @@ my $tests = [
     {
        name => "token_simple",
        config => {
+           acl_root => default_acls(),
            users => default_users_with([$default_cfg->{test_pam_with_token}]),
            roles => default_roles(),
        },
@@ -545,6 +566,7 @@ my $tests = [
     {
        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(),
        },
@@ -561,6 +583,7 @@ my $tests = [
     {
        name => "custom_role_with_single_priv",
        config => {
+           acl_root => default_acls(),
            users => default_users(),
            roles => default_roles_with([$default_cfg->{test_role_single_priv}]),
        },
@@ -571,6 +594,7 @@ my $tests = [
     {
        name => "custom_role_with_privs",
        config => {
+           acl_root => default_acls(),
            users => default_users(),
            roles => default_roles_with([$default_cfg->{test_role_privs}]),
        },
@@ -581,6 +605,7 @@ my $tests = [
     {
        name => "custom_role_with_duplicate_privs",
        config => {
+           acl_root => default_acls(),
            users => default_users(),
            roles => default_roles_with([$default_cfg->{test_role_privs}]),
        },
@@ -594,6 +619,7 @@ my $tests = [
     {
        name => "custom_role_with_invalid_priv",
        config => {
+           acl_root => default_acls(),
            users => default_users(),
            roles => default_roles_with([$default_cfg->{test_role_privs}]),
        },
@@ -607,6 +633,7 @@ my $tests = [
     {
        name => "pool_empty",
        config => {
+           acl_root => default_acls(),
            users => default_users(),
            roles => default_roles(),
            pools => default_pools_with([$default_cfg->{test_pool_empty}]),
@@ -618,6 +645,7 @@ my $tests = [
     {
        name => "pool_invalid",
        config => {
+           acl_root => default_acls(),
            users => default_users(),
            roles => default_roles(),
            pools => default_pools_with([$default_cfg->{test_pool_empty}]),
@@ -632,6 +660,7 @@ my $tests = [
     {
        name => "pool_members",
        config => {
+           acl_root => default_acls(),
            users => default_users(),
            roles => default_roles(),
            pools => default_pools_with([$default_cfg->{test_pool_members}]),
@@ -644,6 +673,7 @@ my $tests = [
     {
        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}]),
@@ -665,7 +695,7 @@ my $tests = [
        config => {
            users => default_users_with([$default_cfg->{test_pam}]),
            roles => default_roles(),
-           acl => default_acls_with([$default_cfg->{acl_simple_user}]),
+           acl_root => default_acls_with([$default_cfg->{acl_simple_user}]),
        },
        raw => "".
               $default_raw->{users}->{'root@pam'}."\n".
@@ -677,7 +707,7 @@ my $tests = [
        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}]),
+           acl_root => default_acls_with([$default_cfg->{acl_simple_user}, $default_cfg->{acl_complex_users}]),
        },
        raw => "".
               $default_raw->{users}->{'root@pam'}."\n".
@@ -692,7 +722,7 @@ my $tests = [
        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}]),
+           acl_root => default_acls_with([$default_cfg->{acl_simple_user}, $default_cfg->{acl_complex_missing_user}]),
        },
        raw => "".
               $default_raw->{users}->{'root@pam'}."\n".
@@ -707,7 +737,7 @@ my $tests = [
            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}]),
+           acl_root => default_acls_with([$default_cfg->{acl_simple_group}]),
        },
        raw => "".
               $default_raw->{users}->{'root@pam'}."\n".
@@ -721,7 +751,7 @@ my $tests = [
            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}]),
+           acl_root => default_acls_with([$default_cfg->{acl_simple_group}, $default_cfg->{acl_complex_groups}]),
        },
        raw => "".
               $default_raw->{users}->{'root@pam'}."\n".
@@ -740,7 +770,7 @@ my $tests = [
            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}]),
+           acl_root => default_acls_with([$default_cfg->{acl_simple_group}, $default_cfg->{acl_complex_missing_group}]),
        },
        raw => "".
               $default_raw->{users}->{'root@pam'}."\n".
@@ -766,7 +796,7 @@ my $tests = [
        config => {
            users => default_users_with([$default_cfg->{test_pam_with_token}]),
            roles => default_roles(),
-           acl => default_acls_with([$default_cfg->{acl_simple_token}]),
+           acl_root => default_acls_with([$default_cfg->{acl_simple_token}]),
        },
        raw => "".
               $default_raw->{users}->{'root@pam'}."\n".
@@ -779,7 +809,7 @@ my $tests = [
        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}]),
+           acl_root => default_acls_with([$default_cfg->{acl_simple_token}, $default_cfg->{acl_complex_tokens}]),
        },
        raw => "".
               $default_raw->{users}->{'root@pam'}."\n".
@@ -798,7 +828,7 @@ my $tests = [
        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}]),
+           acl_root => default_acls_with([$default_cfg->{acl_complex_missing_token}]),
        },
        raw => "".
               $default_raw->{users}->{'root@pam'}."\n".
@@ -825,7 +855,7 @@ my $tests = [
        config => {
            users => default_users_with([$default_cfg->{test_pam}]),
            roles => default_roles(),
-           acl => default_acls_with([$default_cfg->{acl_simple_user}]),
+           acl_root => default_acls_with([$default_cfg->{acl_simple_user}]),
        },
        raw => "".
               $default_raw->{users}->{'root@pam'}."\n".
@@ -843,7 +873,7 @@ my $tests = [
            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([
+           acl_root => default_acls_with([
                $default_cfg->{acl_complex_mixed_root},
                $default_cfg->{acl_complex_mixed_storage},
            ]),
@@ -878,7 +908,7 @@ my $tests = [
            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([
+           acl_root => default_acls_with([
                $default_cfg->{acl_complex_mixed_root_noprop},
                $default_cfg->{acl_complex_mixed_storage_noprop},
            ]),
@@ -973,6 +1003,7 @@ my $tests = [
            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".
index 12c95dbace5695b5d159025f88e077224fe80849..df9fe901d18c169206114f72a0523d3098379423 100755 (executable)
@@ -1,10 +1,14 @@
 #!/usr/bin/perl -w
 
 use strict;
+use warnings;
+
+use Getopt::Long;
+
 use PVE::Tools;
+
 use PVE::AccessControl;
 use PVE::RPCEnvironment;
-use Getopt::Long;
 
 my $rpcenv = PVE::RPCEnvironment->init('cli');
 
@@ -54,6 +58,33 @@ 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');
index 1317051c082b5c69886bf77cb8aca20470782017..fe76eff314d16fe689fe4075ca0f61405bc46cd3 100755 (executable)
@@ -1,10 +1,14 @@
 #!/usr/bin/perl -w
 
 use strict;
+use warnings;
+
+use Getopt::Long;
+
 use PVE::Tools;
+
 use PVE::AccessControl;
 use PVE::RPCEnvironment;
-use Getopt::Long;
 
 my $rpcenv = PVE::RPCEnvironment->init('cli');
 
index b7b54807ef44f758a15a415822c4f41bf184e465..7acf577e76bd5420d124ca49e7dbe79b69a45376 100755 (executable)
@@ -1,10 +1,14 @@
 #!/usr/bin/perl -w
 
 use strict;
+use warnings;
+
+use Getopt::Long;
+
 use PVE::Tools;
+
 use PVE::AccessControl;
 use PVE::RPCEnvironment;
-use Getopt::Long;
 
 my $rpcenv = PVE::RPCEnvironment->init('cli');
 
index 718963eeec2eb999b5d93a9baf35c40bfb5544de..bef4ffbe084e7b252f83590a93b7e516d3801607 100755 (executable)
@@ -1,10 +1,14 @@
 #!/usr/bin/perl -w
 
 use strict;
+use warnings;
+
+use Getopt::Long;
+
 use PVE::Tools;
+
 use PVE::AccessControl;
 use PVE::RPCEnvironment;
-use Getopt::Long;
 
 my $rpcenv = PVE::RPCEnvironment->init('cli');
 
index ebb40e369e85343d2cf382defdbf7b6f3a0cdc52..44c12b23fb9a56531e75932f4f677b58dcf9950e 100755 (executable)
@@ -1,10 +1,14 @@
 #!/usr/bin/perl -w
 
 use strict;
+use warnings;
+
+use Getopt::Long;
+
 use PVE::Tools;
+
 use PVE::AccessControl;
 use PVE::RPCEnvironment;
-use Getopt::Long;
 
 my $rpcenv = PVE::RPCEnvironment->init('cli');
 
index dd433ddd2133fa5f0e9f291cddbbbb2c279b1214..c2d40fca4a44bc17878f7092b2ab7829cbdee3d5 100755 (executable)
@@ -1,10 +1,14 @@
 #!/usr/bin/perl -w
 
 use strict;
+use warnings;
+
+use Getopt::Long;
+
 use PVE::Tools;
+
 use PVE::AccessControl;
 use PVE::RPCEnvironment;
-use Getopt::Long;
 
 my $rpcenv = PVE::RPCEnvironment->init('cli');
 
@@ -71,6 +75,22 @@ 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');
index 57ece07e39315ad94b28a0180f904ebeff1a8cc5..da8535e1ea3c66308260a4be3e93d2dc1c802992 100755 (executable)
@@ -1,10 +1,14 @@
 #!/usr/bin/perl -w
 
 use strict;
+use warnings;
+
+use Getopt::Long;
+
 use PVE::Tools;
+
 use PVE::AccessControl;
 use PVE::RPCEnvironment;
-use Getopt::Long;
 
 my $rpcenv = PVE::RPCEnvironment->init('cli');
 
index 5dab6c68d1a2ac6e64054d47859785ccf5ccd12f..21bf1d32e80c4a7032268ec03dc4bd703fb73d68 100644 (file)
@@ -1,7 +1,10 @@
 #!/usr/bin/perl -w
 
 use strict;
+use warnings;
+
 use PVE::Tools;
+
 use PVE::AccessControl;
 use PVE::RPCEnvironment;
 
@@ -47,7 +50,7 @@ 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', 'VM.Allocate,VM.Audit,VM.Console');
 check_permission('max@pve', '/vms/100', 'VM.Audit,VM.PowerMgmt');
 
 check_permission('alex@pve', '/vms', '');
@@ -63,7 +66,7 @@ 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', 'Permissions.Modify,VM.Allocate,VM.Audit,VM.Console,VM.PowerMgmt');
+check_permission('max@pve!token2', '/vms/300', 'VM.Allocate,VM.Audit,VM.Console,VM.PowerMgmt');
 
 print "all tests passed\n";
 
index ea083f3c3dbb0acf79cc5a397c1291fa6bc3ee6e..3281315700a80f0e2b5e883d64c10833a741628f 100755 (executable)
@@ -39,13 +39,11 @@ my $initialusercfg = {
        'group1-syncedrealm' => { users => {}, },
        'group2-syncedrealm' => { users => {}, },
     },
-    acl => {
-       '/' => {
-           users => {
-               'user3@syncedrealm' => {},
-           },
-           groups => {},
+    acl_root => {
+       users => {
+           'user3@syncedrealm' => {},
        },
+       groups => {},
     },
 };
 
@@ -182,13 +180,11 @@ my $tests = [
                'group2-syncedrealm' => { users => {}, },
                'group3-syncedrealm' => { users => {}, },
            },
-           acl => {
-               '/' => {
-                   users => {
-                       'user3@syncedrealm' => {},
-                   },
-                   groups => {},
+           acl_root => {
+               users => {
+                   'user3@syncedrealm' => {},
                },
+               groups => {},
            },
        },
     ],
@@ -223,13 +219,11 @@ my $tests = [
                },
                'group3-syncedrealm' => { users => {}, }
            },
-           acl => {
-               '/' => {
-                   users => {
-                       'user3@syncedrealm' => {},
-                   },
-                   groups => {},
+           acl_root => {
+               users => {
+                   'user3@syncedrealm' => {},
                },
+               groups => {},
            },
        },
     ],
@@ -270,11 +264,9 @@ my $tests = [
                'group2-syncedrealm' => { users => {}, },
                'group3-syncedrealm' => { users => {}, },
            },
-           acl => {
-               '/' => {
-                   users => {},
-                   groups => {},
-               },
+           acl_root => {
+               users => {},
+               groups => {},
            },
        },
     ],
@@ -309,11 +301,9 @@ my $tests = [
                },
                'group3-syncedrealm' => { users => {}, },
            },
-           acl => {
-               '/' => {
-                   users => {},
-                   groups => {},
-               },
+           acl_root => {
+               users => {},
+               groups => {},
            },
        },
     ],
@@ -349,11 +339,9 @@ my $tests = [
                },
                'group3-syncedrealm' => { users => {}, },
            },
-           acl => {
-               '/' => {
-                   users => {},
-                   groups => {},
-               },
+           acl_root => {
+               users => {},
+               groups => {},
            },
        },
     ],
index d27c5d6dd18a5038262dee163ac82255079ac4f2..0b1b587858795f5e642ca87912a8651f80718de5 100644 (file)
@@ -19,4 +19,6 @@ 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:
 
index 49869106f2f1f2d62d836a1709be115851489b23..661f56ab1f8ab3be2dc47f132c399c7141eb2476 100644 (file)
@@ -2,16 +2,20 @@ 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:
@@ -19,3 +23,4 @@ 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:
index d5c7e86b0f15a6b978cbb6cacd1af5cd5ac3c416..ce704ef66c6a564ee905ecf481e22a7dbc67489f 100644 (file)
@@ -13,7 +13,7 @@ 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:
+role:vm_admin:VM.Audit,VM.Allocate,VM.Console:
 
 acl:1:/vms:@testgroup1:vm_admin:
 acl:0:/vms/300:max@pve:customer: