]> git.proxmox.com Git - pve-access-control.git/commitdiff
bump version to 8.1.4 master
authorThomas Lamprecht <t.lamprecht@proxmox.com>
Mon, 22 Apr 2024 11:45:27 +0000 (13:45 +0200)
committerThomas Lamprecht <t.lamprecht@proxmox.com>
Mon, 22 Apr 2024 11:45:27 +0000 (13:45 +0200)
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
47 files changed:
Makefile
README
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/OpenId.pm
src/PVE/API2/Role.pm
src/PVE/API2/TFA.pm [new file with mode: 0644]
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/Makefile
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 [new file with mode: 0755]
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.*
diff --git a/README b/README
index c706e5b214b648cb8e4ec316dde5913e6061cabb..3ce3eae3074ef5fdb554f773eb744746236785d5 100644 (file)
--- a/README
+++ b/README
@@ -37,7 +37,7 @@ LDAP: example.com
 
 There are 2 special authentication domains name 'pve' and 'pam':
 
- * pve: stores paswords to "/etc/pve/priv/shadow.cfg" (SHA256 crypt); 
+ * pve: stores passwords to "/etc/pve/priv/shadow.cfg" (SHA256 crypt); 
 
  * pam: use unix 'pam'
 
@@ -132,7 +132,7 @@ privileges:
 
 role:
 
-       defines a sets of priviledges
+       defines a sets of privileges
 
        predefined roles:
 
@@ -154,11 +154,19 @@ permission:
 ACL and Objects:
 ================
  
-An access control list (ACL) is a list of permissions attached to an object. The list specifies who or what is allowed to access the object and what operations are allowed to be performed on the object.
-
-Object: A Virtual machine, Network (bridge, venet), Hosts, Host Memory, Storage, ...
-
-We can identify our objects by an unique (file system like) path, which also defines a tree like hierarchy relation. ACL can be inherited. Permissions are inherited if the propagate flag is set on the parent. Child permissions always overwrite inherited permissions. User permission takes precedence over all group permissions. If multiple group permission apply the resulting role is the union of all those group priviledges.
+An access control list (ACL) is a list of permissions attached to an object.
+The list specifies who or what is allowed to access the object and what
+operations are allowed to be performed on the object.
+
+Object: A Virtual machine, Network (bridge, venet), Hosts, Host Memory,
+Storage, ...
+
+We can identify our objects by an unique (file system like) path, which also
+defines a tree like hierarchy relation. ACL can be inherited. Permissions are
+inherited if the propagate flag is set on the parent. Child permissions always
+overwrite inherited permissions. User permission takes precedence over all
+group permissions. If multiple group permission apply the resulting role is the
+union of all those group privileges.
 
 There is at most one object permission per user or group
 
index e47c3122952bfbde9b2b6c429869aa89ec5c817b..cb0e71cfe043b899ce98f58a22d68def8157946c 100644 (file)
@@ -1,3 +1,401 @@
+libpve-access-control (8.1.4) bookworm; urgency=medium
+
+  * fix #5335: sort ACL entries in user.cfg to make it easier to track changes
+
+ -- Proxmox Support Team <support@proxmox.com>  Mon, 22 Apr 2024 13:45:22 +0200
+
+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
+
+  * auth key: protect against rare chance of a double rotation in clusters,
+    leaving the potential that some set of nodes have the earlier key cached,
+    that then got rotated out due to the race, resulting in a possible other
+    set of nodes having the newer key cached. This is a split view of the auth
+    key and may resulting in spurious failures if API requests are made to a
+    different node than the ticket was generated on.
+    In addition to that, the "keep validity of old tickets if signed in the
+    last two hours before rotation" logic was disabled too in such a case,
+    making such tickets invalid too early.
+    Note that both are cases where Proxmox VE was too strict, so while this
+    had no security implications it can be a nuisance, especially for
+    environments that use the API through an automated or scripted way
+
+ -- Proxmox Support Team <support@proxmox.com>  Thu, 14 Jul 2022 08:36:51 +0200
+
+libpve-access-control (7.2-3) bullseye; urgency=medium
+
+  * api: token: use userid-group as API perm check to avoid being overly
+    strict through a misguided use of user id for non-root users.
+
+  * perm check: forbid undefined/empty ACL path for future proofing of against
+    above issue
+
+ -- Proxmox Support Team <support@proxmox.com>  Mon, 20 Jun 2022 15:51:14 +0200
+
+libpve-access-control (7.2-2) bullseye; urgency=medium
+
+  * permissions: merge propagation flag for multiple roles on a path that
+    share privilege  in a deterministic way, to avoid that it gets lost
+    depending on perl's random sort, which would result in returing less
+    privileges than an auth-id actually had.
+
+  * permissions: avoid that token and user privilege intersection is to strict
+    for user permissions that have propagation disabled.
+
+ -- Proxmox Support Team <support@proxmox.com>  Fri, 03 Jun 2022 14:02:30 +0200
+
+libpve-access-control (7.2-1) bullseye; urgency=medium
+
+  * user check: fix expiration/enable order
+
+ -- Proxmox Support Team <support@proxmox.com>  Tue, 31 May 2022 13:43:37 +0200
+
+libpve-access-control (7.1-8) bullseye; urgency=medium
+
+  * fix #3668: realm-sync: replace 'full' & 'purge' with 'remove-
+    vanished'
+
+ -- Proxmox Support Team <support@proxmox.com>  Thu, 28 Apr 2022 17:02:46 +0200
+
+libpve-access-control (7.1-7) bullseye; urgency=medium
+
+  * userid-group check: distinguish create and update
+
+  * api: get user: declare token schema
+
+ -- Proxmox Support Team <support@proxmox.com>  Mon, 21 Mar 2022 16:15:23 +0100
+
+libpve-access-control (7.1-6) bullseye; urgency=medium
+
+  * fix #3768: warn on bad u2f or webauthn settings
+
+  * tfa: when modifying others, verify the current user's password
+
+  * tfa list: account for admin permissions
+
+  * fix realm sync permissions
+
+  * fix token permission display bug
+
+  * include SDN permissions in permission tree
+
+ -- Proxmox Support Team <support@proxmox.com>  Fri, 21 Jan 2022 14:20:42 +0100
+
+libpve-access-control (7.1-5) bullseye; urgency=medium
+
+  * openid: fix username-claim fallback
+
+ -- Proxmox Support Team <support@proxmox.com>  Thu, 25 Nov 2021 07:57:38 +0100
+
+libpve-access-control (7.1-4) bullseye; urgency=medium
+
+  * set current origin in the webauthn config if no fixed origin was
+    configured, to support webauthn via subdomains
+
+ -- Proxmox Support Team <support@proxmox.com>  Mon, 22 Nov 2021 14:04:06 +0100
+
+libpve-access-control (7.1-3) bullseye; urgency=medium
+
+  * openid: allow arbitrary username-claims
+
+  * openid: support configuring the prompt, scopes and ACR values
+
+ -- Proxmox Support Team <support@proxmox.com>  Fri, 19 Nov 2021 08:11:52 +0100
+
+libpve-access-control (7.1-2) bullseye; urgency=medium
+
+  * catch incompatible tfa entries with a nice error
+
+ -- Proxmox Support Team <support@proxmox.com>  Wed, 17 Nov 2021 13:44:45 +0100
+
+libpve-access-control (7.1-1) bullseye; urgency=medium
+
+  * tfa: map HTTP 404 error in get_tfa_entry correctly
+
+ -- Proxmox Support Team <support@proxmox.com>  Mon, 15 Nov 2021 15:33:22 +0100
+
+libpve-access-control (7.0-7) bullseye; urgency=medium
+
+  * fix #3513: pass configured proxy to OpenID
+
+  * use rust based parser for TFA config
+
+  * use PBS-like auth api call flow,
+
+  * merge old user.cfg keys to tfa config when adding entries
+
+  * implement version checks for new tfa config writer to ensure all
+    cluster nodes are ready to avoid login issues
+
+  * tickets: add tunnel ticket
+
+ -- Proxmox Support Team <support@proxmox.com>  Thu, 11 Nov 2021 18:17:49 +0100
+
+libpve-access-control (7.0-6) bullseye; urgency=medium
+
+  * fix regression in user deletion when realm does not enforce TFA
+
+ -- Proxmox Support Team <support@proxmox.com>  Thu, 21 Oct 2021 12:28:52 +0200
+
+libpve-access-control (7.0-5) bullseye; urgency=medium
+
+  * acl: check path: add /sdn/vnets/* path
+
+  * fix #2302: allow deletion of users when realm enforces TFA
+
+  * api: delete user: disable user first to avoid surprise on error during the
+    various cleanup action required for user deletion (e.g., TFA, ACL, group)
+
+ -- Proxmox Support Team <support@proxmox.com>  Mon, 27 Sep 2021 15:50:47 +0200
+
 libpve-access-control (7.0-4) bullseye; urgency=medium
 
   * realm: add OpenID configuration
@@ -77,7 +475,7 @@ libpve-access-control (6.0-7) pve; urgency=medium
 
   * add realm sub commands to pveum CLI tool
 
-  * api: domains: add user group sync API enpoint
+  * api: domains: add user group sync API endpoint
 
   * allow one to sync and import users and groups from LDAP/AD based realms
 
@@ -162,7 +560,7 @@ libpve-access-control (5.1-10) unstable; urgency=medium
 libpve-access-control (5.1-9) unstable; urgency=medium
 
   * store the tfa type in user.cfg allowing to get it without proxying the call
-    to a higher priviledged daemon.
+    to a higher privileged daemon.
 
   * tfa: realm required TFA should lock out users without TFA configured, as it
     was done before Proxmox VE 5.4
@@ -548,7 +946,7 @@ libpve-access-control (3.0-4) unstable; urgency=low
 
 libpve-access-control (3.0-3) unstable; urgency=low
 
-  * Add new role PVETemplateUser (and VM.Clone priviledge)
+  * Add new role PVETemplateUser (and VM.Clone privilege)
 
  -- Proxmox Support Team <support@proxmox.com>  Mon, 29 Apr 2013 11:42:15 +0200
 
@@ -615,7 +1013,7 @@ libpve-access-control (1.0-19) unstable; urgency=low
 
 libpve-access-control (1.0-18) unstable; urgency=low
 
-  * fix bug #151: corretly parse username inside ticket
+  * fix bug #151: correctly parse username inside ticket
   
   * fix bug #152: allow user to change his own password
 
@@ -710,7 +1108,7 @@ libpve-access-control (1.0-4) unstable; urgency=low
 libpve-access-control (1.0-3) unstable; urgency=low
 
   * add support for delayed parameter parsing - We need that to disable
-    file upload for normal API request (avoid DOS attacs)
+    file upload for normal API request (avoid DOS attacks)
 
  -- Proxmox Support Team <support@proxmox.com>  Fri, 02 Dec 2011 09:56:10 +0100
 
diff --git a/debian/compat b/debian/compat
deleted file mode 100644 (file)
index 48082f7..0000000
+++ /dev/null
@@ -1 +0,0 @@
-12
index 3ef748b91d2e43012d7e9a918d5b88e09602f953..580e1c11ca563f90d810f57041a0d13fcd19fe96 100644 (file)
@@ -2,18 +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
@@ -26,15 +29,16 @@ Depends: libauthen-pam-perl,
          libmime-base32-perl,
          libnet-ldap-perl,
          libnet-ssleay-perl,
-         libpve-common-perl (>= 6.0-18),
          libpve-cluster-perl,
-        libpve-rs-perl,
+         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 (<= 6.0-9),
+         ${perl:Depends}
+Breaks: pve-manager (<< 7.0-15)
 Description: Proxmox VE access control library
  This package contains the role based user management and access
  control function used by Proxmox VE.
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 6dec66cea4294be283347b1cb5d0155b0e5d003a..c55a7b347be7a9466cdad328c2d6ce029644b8cd 100644 (file)
@@ -20,6 +20,7 @@ use PVE::API2::Group;
 use PVE::API2::Role;
 use PVE::API2::ACL;
 use PVE::API2::OpenId;
+use PVE::API2::TFA;
 use PVE::Auth::Plugin;
 use PVE::OTP;
 
@@ -61,6 +62,11 @@ __PACKAGE__->register_method ({
     path => 'openid',
 });
 
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::TFA",
+    path => 'tfa',
+});
+
 __PACKAGE__->register_method ({
     name => 'index',
     path => '',
@@ -105,10 +111,11 @@ __PACKAGE__->register_method ({
     }});
 
 
-my $verify_auth = sub {
+my sub verify_auth : prototype($$$$$$) {
     my ($rpcenv, $username, $pw_or_ticket, $otp, $path, $privs) = @_;
 
     my $normpath = PVE::AccessControl::normalize_path($path);
+    die "invalid path - $path\n" if defined($path) && !defined($normpath);
 
     my $ticketuser;
     if (($ticketuser = PVE::AccessControl::verify_ticket($pw_or_ticket, 1)) &&
@@ -117,7 +124,11 @@ my $verify_auth = sub {
     } elsif (PVE::AccessControl::verify_vnc_ticket($pw_or_ticket, $username, $normpath, 1)) {
        # valid vnc ticket
     } else {
-       $username = PVE::AccessControl::authenticate_user($username, $pw_or_ticket, $otp);
+       $username = PVE::AccessControl::authenticate_user(
+           $username,
+           $pw_or_ticket,
+           $otp,
+       );
     }
 
     my $privlist = [ PVE::Tools::split_list($privs) ];
@@ -128,38 +139,44 @@ my $verify_auth = sub {
     return { username => $username };
 };
 
-my $create_ticket = sub {
-    my ($rpcenv, $username, $pw_or_ticket, $otp) = @_;
+my sub create_ticket_do : prototype($$$$$) {
+    my ($rpcenv, $username, $pw_or_ticket, $otp, $tfa_challenge) = @_;
+
+    die "TFA response should be in 'password', not 'otp' when 'tfa-challenge' is set\n"
+       if defined($otp) && defined($tfa_challenge);
+
+    my ($ticketuser, undef, $tfa_info);
+    if (!defined($tfa_challenge)) {
+       # We only verify this ticket if we're not responding to a TFA challenge, as in that case
+       # it is a TFA-data ticket and will be verified by `authenticate_user`.
+
+       ($ticketuser, undef, $tfa_info) = PVE::AccessControl::verify_ticket($pw_or_ticket, 1);
+    }
 
-    my ($ticketuser, undef, $tfa_info) = PVE::AccessControl::verify_ticket($pw_or_ticket, 1);
     if (defined($ticketuser) && ($ticketuser eq 'root@pam' || $ticketuser eq $username)) {
        if (defined($tfa_info)) {
            die "incomplete ticket\n";
        }
        # valid ticket. Note: root@pam can create tickets for other users
     } else {
-       ($username, $tfa_info) = PVE::AccessControl::authenticate_user($username, $pw_or_ticket, $otp);
+       ($username, $tfa_info) = PVE::AccessControl::authenticate_user(
+           $username,
+           $pw_or_ticket,
+           $otp,
+           $tfa_challenge,
+       );
     }
 
     my %extra;
     my $ticket_data = $username;
+    my $aad;
     if (defined($tfa_info)) {
        $extra{NeedTFA} = 1;
-       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);
+    my $ticket = PVE::AccessControl::assemble_ticket($ticket_data, $aad);
     my $csrftoken = PVE::AccessControl::assemble_csrf_prevention_token($username);
 
     return {
@@ -230,6 +247,17 @@ __PACKAGE__->register_method ({
                optional => 1,
                maxLength => 64,
            },
+           'new-format' => {
+               type => 'boolean',
+               description => 'This parameter is now ignored and assumed to be 1.',
+               optional => 1,
+               default => 1,
+           },
+           'tfa-challenge' => {
+               type => 'string',
+                description => "The signed TFA challenge string the user wants to respond to.",
+               optional => 1,
+           },
        }
     },
     returns => {
@@ -257,10 +285,16 @@ __PACKAGE__->register_method ({
            $rpcenv->check_user_enabled($username);
 
            if ($param->{path} && $param->{privs}) {
-               $res = &$verify_auth($rpcenv, $username, $param->{password}, $param->{otp},
-                                    $param->{path}, $param->{privs});
+               $res = verify_auth($rpcenv, $username, $param->{password}, $param->{otp},
+                                  $param->{path}, $param->{privs});
            } else {
-               $res = &$create_ticket($rpcenv, $username, $param->{password}, $param->{otp});
+               $res = create_ticket_do(
+                   $rpcenv,
+                   $username,
+                   $param->{password},
+                   $param->{otp},
+                   $param->{'tfa-challenge'},
+               );
            }
        };
        if (my $err = $@) {
@@ -310,6 +344,7 @@ __PACKAGE__->register_method ({
                minLength => 5,
                maxLength => 64,
            },
+           'confirmation-password' => $PVE::API2::TFA::OPTIONAL_PASSWORD_SCHEMA,
        }
     },
     returns => { type => "null" },
@@ -319,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
@@ -415,209 +453,6 @@ sub verify_user_tfa_config {
     PVE::OTP::oath_verify_otp($value, $secret, $step, $digits);
 }
 
-__PACKAGE__->register_method ({
-    name => 'change_tfa',
-    path => 'tfa',
-    method => 'PUT',
-    permissions => {
-       description => 'A user can change their own u2f or totp token.',
-       check => [ 'or',
-                  ['userid-param', 'self'],
-                  [ 'and',
-                    [ 'userid-param', 'Realm.AllocateUser'],
-                    [ 'userid-group', ['User.Modify']]
-                  ]
-           ],
-    },
-    protected => 1, # else we can't access shadow files
-    allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
-    description => "Change user u2f authentication.",
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           userid => get_standard_option('userid', {
-               completion => \&PVE::AccessControl::complete_username,
-           }),
-           password => {
-               optional => 1, # Only required if not root@pam
-               description => "The current password.",
-               type => 'string',
-               minLength => 5,
-               maxLength => 64,
-           },
-           action => {
-               description => 'The action to perform',
-               type => 'string',
-               enum => [qw(delete new confirm)],
-           },
-           response => {
-               optional => 1,
-               description =>
-                   'Either the the response to the current u2f registration challenge,'
-                   .' or, when adding TOTP, the currently valid TOTP value.',
-               type => 'string',
-           },
-           key => {
-               optional => 1,
-               description => 'When adding TOTP, the shared secret value.',
-               type => 'string',
-               format => 'pve-tfa-secret',
-           },
-           config => {
-               optional => 1,
-               description => 'A TFA configuration. This must currently be of type TOTP of not set at all.',
-               type => 'string',
-               format => 'pve-tfa-config',
-               maxLength => 128,
-           },
-       }
-    },
-    returns => { type => 'object' },
-    code => sub {
-       my ($param) = @_;
-
-       my $rpcenv = PVE::RPCEnvironment::get();
-       my $authuser = $rpcenv->get_user();
-
-       my $action = delete $param->{action};
-       my $response = delete $param->{response};
-       my $password = delete($param->{password}) // '';
-       my $key = delete($param->{key});
-       my $config = delete($param->{config});
-
-       my ($userid, $ruid, $realm) = PVE::AccessControl::verify_username($param->{userid});
-       $rpcenv->check_user_exist($userid);
-
-       # Only root may modify root
-       raise_perm_exc() if $userid eq 'root@pam' && $authuser ne 'root@pam';
-
-       # Regular users need to confirm their password to change u2f settings.
-       if ($authuser ne 'root@pam') {
-           raise_param_exc({ 'password' => 'password is required to modify u2f data' })
-               if !defined($password);
-           my $domain_cfg = cfs_read_file('domains.cfg');
-           my $cfg = $domain_cfg->{ids}->{$realm};
-           die "auth domain '$realm' does not exist\n" if !$cfg;
-           my $plugin = PVE::Auth::Plugin->lookup($cfg->{type});
-           $plugin->authenticate_user($cfg, $realm, $ruid, $password);
-       }
-
-       if ($action eq 'delete') {
-           PVE::AccessControl::user_set_tfa($userid, $realm, undef, undef);
-           PVE::Cluster::log_msg('info', $authuser, "deleted u2f data for user '$userid'");
-       } elsif ($action eq 'new') {
-           if (defined($config)) {
-               $config = PVE::Auth::Plugin::parse_tfa_config($config);
-               my $type = delete($config->{type});
-               my $tfa_cfg = {
-                   keys => $key,
-                   config => $config,
-               };
-               verify_user_tfa_config($type, $tfa_cfg, $response);
-               PVE::AccessControl::user_set_tfa($userid, $realm, $type, $tfa_cfg);
-           } else {
-               # The default is U2F:
-               my $u2f = get_u2f_instance($rpcenv);
-               my $challenge = $u2f->registration_challenge()
-                   or raise("failed to get u2f challenge");
-               $challenge = decode_json($challenge);
-               PVE::AccessControl::user_set_tfa($userid, $realm, 'u2f', $challenge);
-               return $challenge;
-           }
-       } elsif ($action eq 'confirm') {
-           raise_param_exc({ 'response' => "confirm action requires the 'response' parameter to be set" })
-               if !defined($response);
-
-           my ($type, $u2fdata) = PVE::AccessControl::user_get_tfa($userid, $realm);
-           raise("no u2f data available")
-               if (!defined($type) || $type ne 'u2f');
-
-           my $challenge = $u2fdata->{challenge}
-               or raise("no active challenge");
-
-           my $u2f = get_u2f_instance($rpcenv);
-           $u2f->set_challenge($challenge);
-           my ($keyHandle, $publicKey) = $u2f->registration_verify($response);
-           PVE::AccessControl::user_set_tfa($userid, $realm, 'u2f', {
-               keyHandle => $keyHandle,
-               publicKey => $publicKey, # already base64 encoded
-           });
-       } else {
-           die "invalid action: $action\n";
-       }
-
-       return {};
-    }});
-
-__PACKAGE__->register_method({
-    name => 'verify_tfa',
-    path => 'tfa',
-    method => 'POST',
-    permissions => { user => 'all' },
-    protected => 1, # else we can't access shadow files
-    allowtoken => 0, # we don't want tokens to access TFA information
-    description => 'Finish a u2f challenge.',
-    parameters => {
-       additionalProperties => 0,
-       properties => {
-           response => {
-               type => 'string',
-               description => 'The response to the current authentication challenge.',
-           },
-       }
-    },
-    returns => {
-       type => 'object',
-       properties => {
-           ticket => { type => 'string' },
-           # cap
-       }
-    },
-    code => sub {
-       my ($param) = @_;
-
-       my $rpcenv = PVE::RPCEnvironment::get();
-       my $authuser = $rpcenv->get_user();
-       my ($username, undef, $realm) = PVE::AccessControl::verify_username($authuser);
-
-       my ($tfa_type, $tfa_data) = PVE::AccessControl::user_get_tfa($username, $realm);
-       if (!defined($tfa_type)) {
-           raise('no u2f data available');
-       }
-
-       eval {
-           if ($tfa_type eq 'u2f') {
-               my $challenge = $rpcenv->get_u2f_challenge()
-                  or raise('no active challenge');
-
-               my $keyHandle = $tfa_data->{keyHandle};
-               my $publicKey = $tfa_data->{publicKey};
-               raise("incomplete u2f setup")
-                   if !defined($keyHandle) || !defined($publicKey);
-
-               my $u2f = get_u2f_instance($rpcenv, $publicKey, $keyHandle);
-               $u2f->set_challenge($challenge);
-
-               my ($counter, $present) = $u2f->auth_verify($param->{response});
-               # Do we want to do anything with these?
-           } else {
-               # sanity check before handing off to the verification code:
-               my $keys = $tfa_data->{keys} or die "missing tfa keys\n";
-               my $config = $tfa_data->{config} or die "bad tfa entry\n";
-               PVE::AccessControl::verify_one_time_pw($tfa_type, $authuser, $keys, $config, $param->{response});
-           }
-       };
-       if (my $err = $@) {
-           my $clientip = $rpcenv->get_client_ip() || '';
-           syslog('err', "authentication verification failure; rhost=$clientip user=$authuser msg=$err");
-           die PVE::Exception->new("authentication failure\n", code => 401);
-       }
-
-       return {
-           ticket => PVE::AccessControl::assemble_ticket($authuser),
-           cap => $rpcenv->compute_api_permission($authuser),
-       }
-    }});
 
 __PACKAGE__->register_method({
     name => 'permissions',
index 9c2b2548028790f19baaa81bde4cf0f071bb7719..e7b7d39e62521e4a9ecc4e634a0ca279f59ab212 100644 (file)
@@ -17,6 +17,40 @@ my $domainconfigfile = "domains.cfg";
 
 use base qw(PVE::RESTHandler);
 
+# maps old 'full'/'purge' parameters to new 'remove-vanished'
+# TODO remove when we delete the 'full'/'purge' parameters
+my $map_remove_vanished = sub {
+    my ($opt, $delete_deprecated) = @_;
+
+    if (!defined($opt->{'remove-vanished'}) && ($opt->{full} || $opt->{purge})) {
+       my $props = [];
+       push @$props, 'entry', 'properties' if $opt->{full};
+       push @$props, 'acl' if $opt->{purge};
+       $opt->{'remove-vanished'} = join(';', @$props);
+    }
+
+    if ($delete_deprecated) {
+       delete $opt->{full};
+       delete $opt->{purge};
+    }
+
+    return $opt;
+};
+
+my $map_sync_default_options = sub {
+    my ($cfg, $delete_deprecated) = @_;
+
+    my $opt = $cfg->{'sync-defaults-options'};
+    return if !defined($opt);
+    my $sync_opts_fmt = PVE::JSONSchema::get_format('realm-sync-options');
+
+    my $old_opt = PVE::JSONSchema::parse_property_string($sync_opts_fmt, $opt);
+
+    my $new_opt = $map_remove_vanished->($old_opt, $delete_deprecated);
+
+    $cfg->{'sync-defaults-options'} = PVE::JSONSchema::print_property_string($new_opt, $sync_opts_fmt);
+};
+
 __PACKAGE__->register_method ({
     name => 'index',
     path => '',
@@ -83,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) = @_;
@@ -99,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};
@@ -109,6 +151,13 @@ __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);
+               }
+
                my $plugin = PVE::Auth::Plugin->lookup($type);
                my $config = $plugin->check_config($realm, $param, 1, 1);
 
@@ -127,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");
 
@@ -142,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) = @_;
@@ -160,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)) {
@@ -173,7 +239,11 @@ __PACKAGE__->register_method ({
                    $delete_pw = 1 if $opt eq 'password';
                }
 
-               my $plugin = PVE::Auth::Plugin->lookup($ids->{$realm}->{type});
+               if ($type eq 'ad' || $type eq 'ldap') {
+                   $map_sync_default_options->($param, 1);
+               }
+
+               my $plugin = PVE::Auth::Plugin->lookup($type);
                my $config = $plugin->check_config($realm, $param, 0, 1);
 
                if ($config->{default}) {
@@ -193,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");
 
@@ -225,6 +299,11 @@ __PACKAGE__->register_method ({
        my $data = $cfg->{ids}->{$realm};
        die "domain '$realm' does not exist\n" if !$data;
 
+       my $type = $data->{type};
+       if ($type eq 'ad' || $type eq 'ldap') {
+           $map_sync_default_options->($data);
+       }
+
        $data->{digest} = $cfg->{digest};
 
        return $data;
@@ -274,52 +353,54 @@ __PACKAGE__->register_method ({
 my $update_users = sub {
     my ($usercfg, $realm, $synced_users, $opts) = @_;
 
-    print "syncing users\n";
+    if (defined(my $vanished = $opts->{'remove-vanished'})) {
+       print "syncing users (remove-vanished opts: $vanished)\n";
+    } else {
+       print "syncing users\n";
+    }
+
     $usercfg->{users} = {} if !defined($usercfg->{users});
     my $users = $usercfg->{users};
+    my $to_remove = { map { $_ => 1 } split(';', $opts->{'remove-vanished'} // '') };
 
-    my $oldusers = {};
-    if ($opts->{'full'}) {
-       print "full sync, deleting outdated existing users first\n";
-       foreach my $userid (sort keys %$users) {
-           next if $userid !~ m/\@$realm$/;
-
-           $oldusers->{$userid} = delete $users->{$userid};
-           if ($opts->{'purge'} && !$synced_users->{$userid}) {
-               PVE::AccessControl::delete_user_acl($userid, $usercfg);
-               print "purged user '$userid' and all its ACL entries\n";
-           } elsif (!defined($synced_users->{$userid})) {
-               print "remove user '$userid'\n";
-           }
+    print "deleting outdated existing users first\n" if $to_remove->{entry};
+    foreach my $userid (sort keys %$users) {
+       next if $userid !~ m/\@$realm$/;
+       next if defined($synced_users->{$userid});
+
+       if ($to_remove->{entry}) {
+           print "remove user '$userid'\n";
+           delete $users->{$userid};
+       }
+
+       if ($to_remove->{acl}) {
+           print "purge users '$userid' ACL entries\n";
+           PVE::AccessControl::delete_user_acl($userid, $usercfg);
        }
     }
 
     foreach my $userid (sort keys %$synced_users) {
        my $synced_user = $synced_users->{$userid} // {};
-       if (!defined($users->{$userid})) {
+       my $olduser = $users->{$userid};
+       if ($to_remove->{properties} || !defined($olduser)) {
+           # we use the synced user, but want to keep some properties on update
+           if (defined($olduser)) {
+               print "overwriting user '$userid'\n";
+           } else {
+               $olduser = {};
+               print "adding user '$userid'\n";
+           }
            my $user = $users->{$userid} = $synced_user;
 
-           my $olduser = $oldusers->{$userid} // {};
-           if (defined(my $enabled = $olduser->{enable})) {
-               $user->{enable} = $enabled;
-           } elsif ($opts->{'enable-new'}) {
-               $user->{enable} = 1;
-           }
+           my $enabled = $olduser->{enable} // $opts->{'enable-new'};
+           $user->{enable} = $enabled if defined($enabled);
+           $user->{tokens} = $olduser->{tokens} if defined($olduser->{tokens});
 
-           if (defined($olduser->{tokens})) {
-               $user->{tokens} = $olduser->{tokens};
-           }
-           if (defined($oldusers->{$userid})) {
-               print "updated user '$userid'\n";
-           } else {
-               print "added user '$userid'\n";
-           }
        } else {
-           my $olduser = $users->{$userid};
            foreach my $attr (keys %$synced_user) {
                $olduser->{$attr} = $synced_user->{$attr};
            }
-           print "updated user '$userid'\n";
+           print "updating user '$userid'\n";
        }
     }
 };
@@ -327,41 +408,47 @@ my $update_users = sub {
 my $update_groups = sub {
     my ($usercfg, $realm, $synced_groups, $opts) = @_;
 
-    print "syncing groups\n";
+    if (defined(my $vanished = $opts->{'remove-vanished'})) {
+       print "syncing groups (remove-vanished opts: $vanished)\n";
+    } else {
+       print "syncing groups\n";
+    }
+
     $usercfg->{groups} = {} if !defined($usercfg->{groups});
     my $groups = $usercfg->{groups};
-    my $oldgroups = {};
-
-    if ($opts->{full}) {
-       print "full sync, deleting outdated existing groups first\n";
-       foreach my $groupid (sort keys %$groups) {
-           next if $groupid !~ m/\-$realm$/;
-
-           my $oldgroups->{$groupid} = delete $groups->{$groupid};
-           if ($opts->{purge} && !$synced_groups->{$groupid}) {
-               print "purged group '$groupid' and all its ACL entries\n";
-               PVE::AccessControl::delete_group_acl($groupid, $usercfg)
-           } elsif (!defined($synced_groups->{$groupid})) {
-               print "removed group '$groupid'\n";
-           }
+    my $to_remove = { map { $_ => 1 } split(';', $opts->{'remove-vanished'} // '') };
+
+    print "deleting outdated existing groups first\n" if $to_remove->{entry};
+    foreach my $groupid (sort keys %$groups) {
+       next if $groupid !~ m/\-$realm$/;
+       next if defined($synced_groups->{$groupid});
+
+       if ($to_remove->{entry}) {
+           print "remove group '$groupid'\n";
+           delete $groups->{$groupid};
+       }
+
+       if ($to_remove->{acl}) {
+           print "purge groups '$groupid' ACL entries\n";
+           PVE::AccessControl::delete_group_acl($groupid, $usercfg);
        }
     }
 
     foreach my $groupid (sort keys %$synced_groups) {
        my $synced_group = $synced_groups->{$groupid};
-       if (!defined($groups->{$groupid})) {
-           $groups->{$groupid} = $synced_group;
-           if (defined($oldgroups->{$groupid})) {
-               print "updated group '$groupid'\n";
+       my $oldgroup = $groups->{$groupid};
+       if ($to_remove->{properties} || !defined($oldgroup)) {
+           if (defined($oldgroup)) {
+               print "overwriting group '$groupid'\n";
            } else {
-               print "added group '$groupid'\n";
+               print "adding group '$groupid'\n";
            }
+           $groups->{$groupid} = $synced_group;
        } else {
-           my $group = $groups->{$groupid};
            foreach my $attr (keys %$synced_group) {
-               $group->{$attr} = $synced_group->{$attr};
+               $oldgroup->{$attr} = $synced_group->{$attr};
            }
-           print "updated group '$groupid'\n";
+           print "updating group '$groupid'\n";
        }
     }
 };
@@ -381,11 +468,15 @@ my $parse_sync_opts = sub {
        my $fmt = $sync_opts_fmt->{$opt};
 
        $res->{$opt} = $param->{$opt} // $cfg_defaults->{$opt} // $fmt->{default};
-
-       raise_param_exc({
-           "$opt" => 'Not passed as parameter and not defined in realm default sync options.'
-       }) if !defined($res->{$opt});
     }
+
+    $map_remove_vanished->($res, 1);
+
+    # only scope has no implicit value
+    raise_param_exc({
+       "scope" => 'Not passed as parameter and not defined in realm default sync options.'
+    }) if !defined($res->{scope});
+
     return $res;
 };
 
@@ -397,8 +488,8 @@ __PACKAGE__->register_method ({
        description => "'Realm.AllocateUser' on '/access/realm/<realm>' and "
            ." 'User.Modify' permissions to '/access/groups/'.",
        check => [ 'and',
-           [ 'userid-param', 'Realm.AllocateUser' ],
-           [ 'userid-group', ['User.Modify'] ],
+           ['perm', '/access/realm/{realm}', ['Realm.AllocateUser']],
+           ['perm', '/access/groups', ['User.Modify']],
        ],
     },
     description => "Syncs users and/or groups from the configured LDAP to user.cfg."
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 4e49037b93309d16d24d20b30515a50c837caef2..7991c4c7b9ae771121bc95ffcd4f39b02cea3940 100644 (file)
@@ -6,8 +6,12 @@ API2_SOURCES=                  \
        Role.pm                 \
        Group.pm                \
        User.pm                 \
+       TFA.pm                  \
        OpenId.pm
 
+SUBDIRS = Jobs
+
 .PHONY: install
 install:
-       for i in ${API2_SOURCES}; do install -D -m 0644 $$i ${DESTDIR}${PERLDIR}/PVE/API2/$$i; done
+       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 22423ba695dc8bee337f024b8e2b943df0611397..77410e6d880402230603307122e793c219883d8c 100644 (file)
@@ -36,6 +36,14 @@ my $lookup_openid_auth = sub {
        client_id => $config->{'client-id'},
        client_key => $config->{'client-key'},
     };
+    $openid_config->{prompt} = $config->{'prompt'} if defined($config->{'prompt'});
+
+    my $scopes = $config->{'scopes'} // 'email profile';
+    $openid_config->{scopes} = [ PVE::Tools::split_list($scopes) ];
+
+    if (defined(my $acr = $config->{'acr-values'})) {
+       $openid_config->{acr_values} = [ PVE::Tools::split_list($acr) ];
+    }
 
     my $openid = PVE::RS::OpenId->discover($openid_config, $redirect_url);
     return ($config, $openid);
@@ -97,6 +105,9 @@ __PACKAGE__->register_method ({
     code => sub {
        my ($param) = @_;
 
+       my $dcconf = PVE::Cluster::cfs_read_file('datacenter.cfg');
+       local $ENV{all_proxy} = $dcconf->{http_proxy} if exists $dcconf->{http_proxy};
+
        my $realm = extract_param($param, 'realm');
        my $redirect_url = extract_param($param, 'redirect-url');
 
@@ -123,7 +134,7 @@ __PACKAGE__->register_method ({
            code => {
                description => "OpenId authorization code.",
                type => 'string',
-               maxLength => 1024,
+               maxLength => 4096,
             },
            'redirect-url' => {
                description => "Redirection Url. The client should set this to the used server url (location.origin).",
@@ -149,6 +160,9 @@ __PACKAGE__->register_method ({
 
        my $res;
        eval {
+           my $dcconf = PVE::Cluster::cfs_read_file('datacenter.cfg');
+           local $ENV{all_proxy} = $dcconf->{http_proxy} if exists $dcconf->{http_proxy};
+
            my ($realm, $private_auth_state) = PVE::RS::OpenId::verify_public_auth_state(
                $openid_state_path, $param->{'state'});
 
@@ -159,23 +173,20 @@ __PACKAGE__->register_method ({
            my $info = $openid->verify_authorization_code($param->{code}, $private_auth_state);
            my $subject = $info->{'sub'};
 
-           die "missing openid claim 'sub'\n" if !defined($subject);
-
-           my $unique_name = $subject; # default
-           if (defined(my $user_attr = $config->{'username-claim'})) {
-               if ($user_attr eq 'subject') {
-                   $unique_name = $subject;
-               } elsif ($user_attr eq 'username') {
-                   my $username = $info->{'preferred_username'};
-                   die "missing claim 'preferred_username'\n" if !defined($username);
-                   $unique_name =  $username;
-               } elsif ($user_attr eq 'email') {
-                   my $email = $info->{'email'};
-                   die "missing claim 'email'\n" if !defined($email);
-                   $unique_name = $email;
-               } else {
-                   die "got unexpected value for 'username-claim': '${user_attr}'\n";
-               }
+           my $unique_name;
+
+           my $user_attr = $config->{'username-claim'} // 'sub';
+           if (defined($info->{$user_attr})) {
+               $unique_name = $info->{$user_attr};
+           } elsif ($user_attr eq 'subject') { # stay compat with old versions
+               $unique_name = $subject;
+           } elsif ($user_attr eq 'username') { # stay compat with old versions
+               my $username = $info->{'preferred_username'};
+               die "missing claim 'preferred_username'\n" if !defined($username);
+               $unique_name =  $username;
+           } else {
+               # neither the attr nor fallback are defined in info..
+               die "missing configured claim '$user_attr' in returned info object\n";
            }
 
            my $username = "${unique_name}\@${realm}";
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;
     }
diff --git a/src/PVE/API2/TFA.pm b/src/PVE/API2/TFA.pm
new file mode 100644 (file)
index 0000000..62ddd95
--- /dev/null
@@ -0,0 +1,470 @@
+package PVE::API2::TFA;
+
+use strict;
+use warnings;
+
+use HTTP::Status qw(:constants);
+
+use PVE::AccessControl;
+use PVE::Cluster qw(cfs_read_file cfs_write_file);
+use PVE::Exception qw(raise raise_perm_exc raise_param_exc);
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::RPCEnvironment;
+use PVE::SafeSyslog;
+
+use PVE::API2::AccessControl; # for old login api get_u2f_instance method
+
+use PVE::RESTHandler;
+
+use base qw(PVE::RESTHandler);
+
+our $OPTIONAL_PASSWORD_SCHEMA = {
+    description => "The current password of the user performing the change.",
+    type => 'string',
+    optional => 1, # Only required if not root@pam
+    minLength => 5,
+    maxLength => 64
+};
+
+my $TFA_TYPE_SCHEMA = {
+    type => 'string',
+    description => 'TFA Entry Type.',
+    enum => [qw(totp u2f webauthn recovery yubico)],
+};
+
+my %TFA_INFO_PROPERTIES = (
+    id => {
+       type => 'string',
+       description => 'The id used to reference this entry.',
+    },
+    description => {
+       type => 'string',
+       description => 'User chosen description for this entry.',
+    },
+    created => {
+       type => 'integer',
+       description => 'Creation time of this entry as unix epoch.',
+    },
+    enable => {
+       type => 'boolean',
+       description => 'Whether this TFA entry is currently enabled.',
+       optional => 1,
+       default => 1,
+    },
+);
+
+my $TYPED_TFA_ENTRY_SCHEMA = {
+    type => 'object',
+    description => 'TFA Entry.',
+    properties => {
+       type => $TFA_TYPE_SCHEMA,
+       %TFA_INFO_PROPERTIES,
+    },
+};
+
+my $TFA_ID_SCHEMA = {
+    type => 'string',
+    description => 'A TFA entry id.',
+};
+
+my $TFA_UPDATE_INFO_SCHEMA = {
+    type => 'object',
+    properties => {
+       id => {
+           type => 'string',
+           description => 'The id of a newly added TFA entry.',
+       },
+       challenge => {
+           type => 'string',
+           optional => 1,
+           description =>
+               'When adding u2f entries, this contains a challenge the user must respond to in order'
+               .' to finish the registration.'
+       },
+       recovery => {
+           type => 'array',
+           optional => 1,
+           description =>
+               'When adding recovery codes, this contains the list of codes to be displayed to'
+               .' the user',
+           items => {
+               type => 'string',
+               description => 'A recovery entry.'
+           },
+       },
+    },
+};
+
+# Set TFA to enabled if $tfa_cfg is passed, or to disabled if $tfa_cfg is undef,
+# When enabling we also merge the old user.cfg keys into the $tfa_cfg.
+my sub set_user_tfa_enabled : prototype($$$) {
+    my ($userid, $realm, $tfa_cfg) = @_;
+
+    PVE::AccessControl::lock_user_config(sub {
+       my $user_cfg = cfs_read_file('user.cfg');
+       my $user = $user_cfg->{users}->{$userid};
+       my $keys = $user->{keys};
+       # When enabling, we convert old-old keys,
+       # When disabling, we shouldn't actually have old keys anymore, so if they are there,
+       # they'll be removed.
+       if ($tfa_cfg && $keys && $keys !~ /^x(?:!.*)?$/) {
+           my $domain_cfg = cfs_read_file('domains.cfg');
+           my $realm_cfg = $domain_cfg->{ids}->{$realm};
+           die "auth domain '$realm' does not exist\n" if !$realm_cfg;
+
+           my $realm_tfa = $realm_cfg->{tfa};
+           $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_tfa) if $realm_tfa;
+
+           PVE::AccessControl::add_old_keys_to_realm_tfa($userid, $tfa_cfg, $realm_tfa, $keys);
+       }
+       $user->{keys} = $tfa_cfg ? 'x' : undef;
+       cfs_write_file("user.cfg", $user_cfg);
+    }, "enabling TFA for the user failed");
+}
+
+__PACKAGE__->register_method ({
+    name => 'list_user_tfa',
+    path => '{userid}',
+    method => 'GET',
+    permissions => {
+       check => [ 'or',
+           ['userid-param', 'self'],
+           ['userid-group', ['User.Modify', 'Sys.Audit']],
+       ],
+    },
+    protected => 1, # else we can't access shadow files
+    description => 'List TFA configurations of users.',
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid', {
+               completion => \&PVE::AccessControl::complete_username,
+           }),
+       }
+    },
+    returns => {
+       description => "A list of the user's TFA entries.",
+       type => 'array',
+       items => $TYPED_TFA_ENTRY_SCHEMA,
+       links => [ { rel => 'child', href => "{id}" } ],
+    },
+    code => sub {
+       my ($param) = @_;
+       my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+       return $tfa_cfg->api_list_user_tfa($param->{userid});
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'get_tfa_entry',
+    path => '{userid}/{id}',
+    method => 'GET',
+    permissions => {
+       check => [ 'or',
+           ['userid-param', 'self'],
+           ['userid-group', ['User.Modify', 'Sys.Audit']],
+       ],
+    },
+    protected => 1, # else we can't access shadow files
+    description => 'Fetch a requested TFA entry if present.',
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid', {
+               completion => \&PVE::AccessControl::complete_username,
+           }),
+           id => $TFA_ID_SCHEMA,
+       }
+    },
+    returns => $TYPED_TFA_ENTRY_SCHEMA,
+    code => sub {
+       my ($param) = @_;
+       my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+       my $id = $param->{id};
+       my $entry = $tfa_cfg->api_get_tfa_entry($param->{userid}, $id);
+       raise("No such tfa entry '$id'", code => HTTP::Status::HTTP_NOT_FOUND) if !$entry;
+       return $entry;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'delete_tfa',
+    path => '{userid}/{id}',
+    method => 'DELETE',
+    permissions => {
+       check => [ 'or',
+           ['userid-param', 'self'],
+           ['userid-group', ['User.Modify']],
+       ],
+    },
+    protected => 1, # else we can't access shadow files
+    allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
+    description => 'Delete a TFA entry by ID.',
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid', {
+               completion => \&PVE::AccessControl::complete_username,
+           }),
+           id => $TFA_ID_SCHEMA,
+           password => $OPTIONAL_PASSWORD_SCHEMA,
+       }
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       my $rpcenv = PVE::RPCEnvironment::get();
+       my $authuser = $rpcenv->get_user();
+       my $userid = $rpcenv->reauth_user_for_user_modification(
+           $authuser,
+           $param->{userid},
+           $param->{password},
+       );
+
+       my $has_entries_left = PVE::AccessControl::lock_tfa_config(sub {
+           my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+           my $has_entries_left = $tfa_cfg->api_delete_tfa($userid, $param->{id});
+           cfs_write_file('priv/tfa.cfg', $tfa_cfg);
+           return $has_entries_left;
+       });
+       if (!$has_entries_left) {
+           set_user_tfa_enabled($userid, undef, undef);
+       }
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'list_tfa',
+    path => '',
+    method => 'GET',
+    permissions => {
+       description => "Returns all or just the logged-in user, depending on privileges.",
+       user => 'all',
+    },
+    protected => 1, # else we can't access shadow files
+    description => 'List TFA configurations of users.',
+    parameters => {
+       additionalProperties => 0,
+       properties => {}
+    },
+    returns => {
+       description => "The list tuples of user and TFA entries.",
+       type => 'array',
+       items => {
+           type => 'object',
+           properties => {
+               userid => {
+                   type => 'string',
+                   description => 'User this entry belongs to.',
+               },
+               entries => {
+                   type => 'array',
+                   items => $TYPED_TFA_ENTRY_SCHEMA,
+               },
+               'totp-locked' => {
+                   type => 'boolean',
+                   optional => 1,
+                   description => 'True if the user is currently locked out of TOTP factors.',
+               },
+               'tfa-locked-until' => {
+                   type => 'integer',
+                   optional => 1,
+                   description =>
+                       'Contains a timestamp until when a user is locked out of 2nd factors.',
+               },
+           },
+       },
+       links => [ { rel => 'child', href => "{userid}" } ],
+    },
+    code => sub {
+       my ($param) = @_;
+
+       my $rpcenv = PVE::RPCEnvironment::get();
+       my $authuser = $rpcenv->get_user();
+
+       my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+       my $entries = $tfa_cfg->api_list_tfa($authuser, 1);
+
+       my $privs = [ 'User.Modify', 'Sys.Audit' ];
+       if ($rpcenv->check_any($authuser, "/access/groups", $privs, 1)) {
+           # can modify all
+           return $entries;
+       }
+
+       my $groups = $rpcenv->filter_groups($authuser, $privs, 1);
+       my $allowed_users = $rpcenv->group_member_join([keys %$groups]);
+       return [
+           grep {
+               my $userid = $_->{userid};
+               $userid eq $authuser || $allowed_users->{$userid}
+           } $entries->@*
+       ];
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'add_tfa_entry',
+    path => '{userid}',
+    method => 'POST',
+    permissions => {
+       check => [ 'or',
+           ['userid-param', 'self'],
+           ['userid-group', ['User.Modify']],
+       ],
+    },
+    protected => 1, # else we can't access shadow files
+    allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
+    description => 'Add a TFA entry for a user.',
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid', {
+               completion => \&PVE::AccessControl::complete_username,
+           }),
+            type => $TFA_TYPE_SCHEMA,
+           description => {
+               type => 'string',
+               description => 'A description to distinguish multiple entries from one another',
+               maxLength => 255,
+               optional => 1,
+           },
+           totp => {
+               type => 'string',
+               description => "A totp URI.",
+               optional => 1,
+           },
+           value => {
+               type => 'string',
+               description =>
+                   'The current value for the provided totp URI, or a Webauthn/U2F'
+                   .' challenge response',
+               optional => 1,
+           },
+           challenge => {
+               type => 'string',
+               description => 'When responding to a u2f challenge: the original challenge string',
+               optional => 1,
+           },
+           password => $OPTIONAL_PASSWORD_SCHEMA,
+       },
+    },
+    returns => $TFA_UPDATE_INFO_SCHEMA,
+    code => sub {
+       my ($param) = @_;
+
+       my $rpcenv = PVE::RPCEnvironment::get();
+       my $authuser = $rpcenv->get_user();
+       my ($userid, undef, $realm) = $rpcenv->reauth_user_for_user_modification(
+           $authuser,
+           $param->{userid},
+           $param->{password},
+       );
+
+       my $type = delete $param->{type};
+       my $value = delete $param->{value};
+       if ($type eq 'yubico') {
+           $value = validate_yubico_otp($userid, $realm, $value);
+       }
+
+       return PVE::AccessControl::lock_tfa_config(sub {
+           my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+
+           set_user_tfa_enabled($userid, $realm, $tfa_cfg);
+
+           PVE::AccessControl::configure_u2f_and_wa($tfa_cfg);
+
+           my $response = $tfa_cfg->api_add_tfa_entry(
+               $userid,
+               $param->{description},
+               $param->{totp},
+               $value,
+               $param->{challenge},
+               $type,
+           );
+
+           cfs_write_file('priv/tfa.cfg', $tfa_cfg);
+
+           return $response;
+       });
+    }});
+
+sub validate_yubico_otp : prototype($$$) {
+    my ($userid, $realm, $value) = @_;
+
+    my $domain_cfg = cfs_read_file('domains.cfg');
+    my $realm_cfg = $domain_cfg->{ids}->{$realm};
+    die "auth domain '$realm' does not exist\n" if !$realm_cfg;
+
+    my $realm_tfa = $realm_cfg->{tfa};
+    die "no yubico otp configuration available for realm $realm\n"
+       if !$realm_tfa;
+
+    $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_tfa);
+    die "realm is not setup for Yubico OTP\n"
+       if !$realm_tfa || $realm_tfa->{type} ne 'yubico';
+
+    my $public_key = substr($value, 0, 12);
+
+    PVE::AccessControl::authenticate_yubico_do($value, $public_key, $realm_tfa);
+
+    return $public_key;
+}
+
+__PACKAGE__->register_method ({
+    name => 'update_tfa_entry',
+    path => '{userid}/{id}',
+    method => 'PUT',
+    permissions => {
+       check => [ 'or',
+           ['userid-param', 'self'],
+           ['userid-group', ['User.Modify']],
+       ],
+    },
+    protected => 1, # else we can't access shadow files
+    allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
+    description => 'Add a TFA entry for a user.',
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid', {
+               completion => \&PVE::AccessControl::complete_username,
+           }),
+           id => $TFA_ID_SCHEMA,
+           description => {
+               type => 'string',
+               description => 'A description to distinguish multiple entries from one another',
+               maxLength => 255,
+               optional => 1,
+           },
+           enable => {
+               type => 'boolean',
+               description => 'Whether the entry should be enabled for login.',
+               optional => 1,
+           },
+           password => $OPTIONAL_PASSWORD_SCHEMA,
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       my $rpcenv = PVE::RPCEnvironment::get();
+       my $authuser = $rpcenv->get_user();
+       my $userid = $rpcenv->reauth_user_for_user_modification(
+           $authuser,
+           $param->{userid},
+           $param->{password},
+       );
+
+       PVE::AccessControl::lock_tfa_config(sub {
+           my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+
+           $tfa_cfg->api_update_tfa_entry(
+               $userid,
+               $param->{id},
+               $param->{description},
+               $param->{enable},
+           );
+
+           cfs_write_file('priv/tfa.cfg', $tfa_cfg);
+       });
+    }});
+
+1;
index 3fa1b65c4a156642bbce5769d61c71db95757799..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;
        }
 
@@ -218,10 +250,11 @@ __PACKAGE__->register_method ({
     method => 'POST',
     permissions => {
        description => "You need 'Realm.AllocateUser' on '/access/realm/<realm>' on the realm of user <userid>, and 'User.Modify' permissions to '/access/groups/<group>' for any group specified (or 'User.Modify' on '/access/groups' if you pass no groups.",
-       check => [ 'and',
-                  [ 'userid-param', 'Realm.AllocateUser'],
-                  [ 'userid-group', ['User.Modify'], groups_param => 1],
-           ],
+       check => [
+           'and',
+           [ 'userid-param', 'Realm.AllocateUser'],
+           [ 'userid-group', ['User.Modify'], groups_param => 'create'],
+       ],
     },
     description => "Create new user.",
     parameters => {
@@ -322,6 +355,7 @@ __PACKAGE__->register_method ({
            tokens => {
                optional => 1,
                type => 'object',
+               additionalProperties => get_standard_option('token-info'),
            },
        },
        type => "object"
@@ -329,8 +363,7 @@ __PACKAGE__->register_method ({
     code => sub {
        my ($param) = @_;
 
-       my ($username, undef, $domain) =
-           PVE::AccessControl::verify_username($param->{userid});
+       my ($username, undef, $domain) = PVE::AccessControl::verify_username($param->{userid});
 
        my $usercfg = cfs_read_file("user.cfg");
 
@@ -345,7 +378,7 @@ __PACKAGE__->register_method ({
     path => '{userid}',
     method => 'PUT',
     permissions => {
-       check => ['userid-group', ['User.Modify'], groups_param => 1 ],
+       check => ['userid-group', ['User.Modify'], groups_param => 'update' ],
     },
     description => "Update user configuration.",
     parameters => {
@@ -371,41 +404,37 @@ __PACKAGE__->register_method ({
     code => sub {
        my ($param) = @_;
 
-       my ($username, $ruid, $realm) =
-           PVE::AccessControl::verify_username($param->{userid});
-
-       PVE::AccessControl::lock_user_config(
-           sub {
-
-               my $usercfg = cfs_read_file("user.cfg");
+       my ($username, $ruid, $realm) = PVE::AccessControl::verify_username($param->{userid});
 
-               PVE::AccessControl::check_user_exist($usercfg, $username);
+       PVE::AccessControl::lock_user_config(sub {
+           my $usercfg = cfs_read_file("user.cfg");
 
-               $usercfg->{users}->{$username}->{enable} = $param->{enable} if defined($param->{enable});
+           PVE::AccessControl::check_user_exist($usercfg, $username);
 
-               $usercfg->{users}->{$username}->{expire} = $param->{expire} if defined($param->{expire});
+           $usercfg->{users}->{$username}->{enable} = $param->{enable} if defined($param->{enable});
+           $usercfg->{users}->{$username}->{expire} = $param->{expire} if defined($param->{expire});
 
-               PVE::AccessControl::delete_user_group($username, $usercfg)
-                   if (!$param->{append} && defined($param->{groups}));
+           PVE::AccessControl::delete_user_group($username, $usercfg)
+               if (!$param->{append} && defined($param->{groups}));
 
-               if ($param->{groups}) {
-                   foreach my $group (split_list($param->{groups})) {
-                       if ($usercfg->{groups}->{$group}) {
-                           PVE::AccessControl::add_user_group($username, $usercfg, $group);
-                       } else {
-                           die "no such group '$group'\n";
-                       }
+           if ($param->{groups}) {
+               foreach my $group (split_list($param->{groups})) {
+                   if ($usercfg->{groups}->{$group}) {
+                       PVE::AccessControl::add_user_group($username, $usercfg, $group);
+                   } else {
+                       die "no such group '$group'\n";
                    }
                }
+           }
 
-               $usercfg->{users}->{$username}->{firstname} = $param->{firstname} if defined($param->{firstname});
-               $usercfg->{users}->{$username}->{lastname} = $param->{lastname} if defined($param->{lastname});
-               $usercfg->{users}->{$username}->{email} = $param->{email} if defined($param->{email});
-               $usercfg->{users}->{$username}->{comment} = $param->{comment} if defined($param->{comment});
-               $usercfg->{users}->{$username}->{keys} = $param->{keys} if defined($param->{keys});
+           $usercfg->{users}->{$username}->{firstname} = $param->{firstname} if defined($param->{firstname});
+           $usercfg->{users}->{$username}->{lastname} = $param->{lastname} if defined($param->{lastname});
+           $usercfg->{users}->{$username}->{email} = $param->{email} if defined($param->{email});
+           $usercfg->{users}->{$username}->{comment} = $param->{comment} if defined($param->{comment});
+           $usercfg->{users}->{$username}->{keys} = $param->{keys} if defined($param->{keys});
 
-               cfs_write_file("user.cfg", $usercfg);
-           }, "update user failed");
+           cfs_write_file("user.cfg", $usercfg);
+       }, "update user failed");
 
        return undef;
     }});
@@ -418,9 +447,9 @@ __PACKAGE__->register_method ({
     description => "Delete user.",
     permissions => {
        check => [ 'and',
-                  [ 'userid-param', 'Realm.AllocateUser'],
-                  [ 'userid-group', ['User.Modify']],
-           ],
+           [ 'userid-param', 'Realm.AllocateUser'],
+           [ 'userid-group', ['User.Modify']],
+       ],
     },
     parameters => {
        additionalProperties => 0,
@@ -435,30 +464,40 @@ __PACKAGE__->register_method ({
        my $rpcenv = PVE::RPCEnvironment::get();
        my $authuser = $rpcenv->get_user();
 
-       my ($userid, $ruid, $realm) =
-           PVE::AccessControl::verify_username($param->{userid});
+       my ($userid, $ruid, $realm) = PVE::AccessControl::verify_username($param->{userid});
 
-       PVE::AccessControl::lock_user_config(
-           sub {
+       PVE::AccessControl::lock_user_config(sub {
+           my $usercfg = cfs_read_file("user.cfg");
 
-               my $usercfg = cfs_read_file("user.cfg");
+           # NOTE: disable the user first (transaction like), so if (e.g.) we fail in the middle of
+           # TFA deletion the user will be still disabled and not just without TFA protection.
+           $usercfg->{users}->{$userid}->{enable} = 0;
+           cfs_write_file("user.cfg", $usercfg);
 
-               my $domain_cfg = cfs_read_file('domains.cfg');
-               if (my $cfg = $domain_cfg->{ids}->{$realm}) {
-                   my $plugin = PVE::Auth::Plugin->lookup($cfg->{type});
-                   $plugin->delete_user($cfg, $realm, $ruid);
-               }
+           my $domain_cfg = cfs_read_file('domains.cfg');
+           if (my $cfg = $domain_cfg->{ids}->{$realm}) {
+               my $plugin = PVE::Auth::Plugin->lookup($cfg->{type});
+               $plugin->delete_user($cfg, $realm, $ruid);
+           }
 
-               # Remove TFA data before removing the user entry as the user entry tells us whether
-               # we need ot update priv/tfa.cfg.
-               PVE::AccessControl::user_set_tfa($userid, $realm, undef, undef, $usercfg, $domain_cfg);
+           # Remove user from cache before removing the TFA entry so realms with TFA-enforcement
+           # know that it's OK to drop any TFA entry in that case.
+           delete $usercfg->{users}->{$userid};
 
-               delete $usercfg->{users}->{$userid};
+           my $partial_deletion = '';
+           eval {
+               PVE::AccessControl::user_remove_tfa($userid);
+               $partial_deletion = ' - but deleted related TFA';
 
                PVE::AccessControl::delete_user_group($userid, $usercfg);
+               $partial_deletion .= ', Groups';
                PVE::AccessControl::delete_user_acl($userid, $usercfg);
+               $partial_deletion .= ', ACLs';
+
                cfs_write_file("user.cfg", $usercfg);
-           }, "delete user failed");
+           };
+           die "$@$partial_deletion\n" if $@;
+       }, "delete user failed");
 
        return undef;
     }});
@@ -479,6 +518,12 @@ __PACKAGE__->register_method ({
        additionalProperties => 0,
        properties => {
            userid => get_standard_option('userid-completed'),
+           multiple => {
+               type => 'boolean',
+               description => 'Request all entries as an array.',
+               optional => 1,
+               default => 0,
+           },
        },
     },
     returns => {
@@ -493,9 +538,23 @@ __PACKAGE__->register_method ({
            user => {
                type => 'string',
                enum => [qw(oath u2f)],
-               description => "The type of TFA the user has set, if any.",
+               description =>
+                   "The type of TFA the user has set, if any."
+                   . " Only set if 'multiple' was not passed.",
                optional => 1,
            },
+           types => {
+               type => 'array',
+               description =>
+                   "Array of the user configured TFA types, if any."
+                   . " Only available if 'multiple' was not passed.",
+               optional => 1,
+               items => {
+                   type => 'string',
+                   enum => [qw(totp u2f yubico webauthn recovedry)],
+                   description => 'A TFA type.',
+               },
+           },
        },
        type => "object"
     },
@@ -504,33 +563,73 @@ __PACKAGE__->register_method ({
 
        my ($username, undef, $realm) = PVE::AccessControl::verify_username($param->{userid});
 
-
        my $domain_cfg = cfs_read_file('domains.cfg');
        my $realm_cfg = $domain_cfg->{ids}->{$realm};
        die "auth domain '$realm' does not exist\n" if !$realm_cfg;
 
+       my $res = {};
        my $realm_tfa = {};
-       $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_cfg->{tfa})
-           if $realm_cfg->{tfa};
+       $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_cfg->{tfa}) if $realm_cfg->{tfa};
+       $res->{realm} = $realm_tfa->{type} if $realm_tfa->{type};
 
        my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
-       my $tfa = $tfa_cfg->{users}->{$username};
-
-       my $res = {};
-       $res->{realm} = $realm_tfa->{type} if $realm_tfa->{type};
-       $res->{user} = $tfa->{type} if $tfa->{type};
+       if ($param->{multiple}) {
+           my $tfa = $tfa_cfg->get_user($username);
+           my $user = [];
+           foreach my $type (keys %$tfa) {
+               next if !scalar($tfa->{$type}->@*);
+               push @$user, $type;
+           }
+           $res->{user} = $user;
+       } else {
+           my $tfa = $tfa_cfg->{users}->{$username};
+           $res->{user} = $tfa->{type} if $tfa->{type};
+       }
        return $res;
     }});
 
+__PACKAGE__->register_method ({
+    name => 'unlock_tfa',
+    path => '{userid}/unlock-tfa',
+    method => 'PUT',
+    protected => 1,
+    description => "Unlock a user's TFA authentication.",
+    permissions => {
+       check => [ 'userid-group', ['User.Modify']],
+    },
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid-completed'),
+       },
+    },
+    returns => { type => 'boolean' },
+    code => sub {
+       my ($param) = @_;
+
+       my $userid = extract_param($param, "userid");
+
+       my $user_was_locked = PVE::AccessControl::lock_tfa_config(sub {
+           my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+           my $was_locked = $tfa_cfg->api_unlock_tfa($userid);
+           cfs_write_file('priv/tfa.cfg', $tfa_cfg)
+               if $was_locked;
+           return $was_locked;
+       });
+
+       return $user_was_locked;
+    }});
+
 __PACKAGE__->register_method ({
     name => 'token_index',
     path => '{userid}/token',
     method => 'GET',
     description => "Get user API tokens.",
     permissions => {
-       check => ['or',
-                   ['userid-param', 'self'],
-                   ['perm', '/access/users/{userid}', ['User.Modify']],
+       check => [
+           'or',
+           ['userid-param', 'self'],
+           ['userid-group', ['User.Modify']],
        ],
     },
     parameters => {
@@ -564,9 +663,10 @@ __PACKAGE__->register_method ({
     method => 'GET',
     description => "Get specific API token information.",
     permissions => {
-       check => ['or',
-                   ['userid-param', 'self'],
-                   ['perm', '/access/users/{userid}', ['User.Modify']],
+       check => [
+           'or',
+           ['userid-param', 'self'],
+           ['userid-group', ['User.Modify']],
        ],
     },
     parameters => {
@@ -595,9 +695,10 @@ __PACKAGE__->register_method ({
     description => "Generate a new API token for a specific user. NOTE: returns API token value, which needs to be stored as it cannot be retrieved afterwards!",
     protected => 1,
     permissions => {
-       check => ['or',
-                   ['userid-param', 'self'],
-                   ['perm', '/access/users/{userid}', ['User.Modify']],
+       check => [
+           'or',
+           ['userid-param', 'self'],
+           ['userid-group', ['User.Modify']],
        ],
     },
     parameters => {
@@ -674,9 +775,10 @@ __PACKAGE__->register_method ({
     description => "Update API token for a specific user.",
     protected => 1,
     permissions => {
-       check => ['or',
-                   ['userid-param', 'self'],
-                   ['perm', '/access/users/{userid}', ['User.Modify']],
+       check => [
+           'or',
+           ['userid-param', 'self'],
+           ['userid-group', ['User.Modify']],
        ],
     },
     parameters => {
@@ -699,7 +801,7 @@ __PACKAGE__->register_method ({
        my $usercfg = cfs_read_file("user.cfg");
        my $token = PVE::AccessControl::check_token_exist($usercfg, $userid, $tokenid);
 
-       my $update_token = sub {
+       PVE::AccessControl::lock_user_config(sub {
            $usercfg = cfs_read_file("user.cfg");
            $token = PVE::AccessControl::check_token_exist($usercfg, $userid, $tokenid);
 
@@ -711,9 +813,7 @@ __PACKAGE__->register_method ({
 
            $usercfg->{users}->{$userid}->{tokens}->{$tokenid} = $token;
            cfs_write_file("user.cfg", $usercfg);
-       };
-
-       PVE::AccessControl::lock_user_config($update_token, 'updating token info failed');
+       }, 'updating token info failed');
 
        return $token;
     }});
@@ -726,9 +826,10 @@ __PACKAGE__->register_method ({
     description => "Remove API token for a specific user.",
     protected => 1,
     permissions => {
-       check => ['or',
-                   ['userid-param', 'self'],
-                   ['perm', '/access/users/{userid}', ['User.Modify']],
+       check => [
+           'or',
+           ['userid-param', 'self'],
+           ['userid-group', ['User.Modify']],
        ],
     },
     parameters => {
@@ -748,7 +849,7 @@ __PACKAGE__->register_method ({
        my $usercfg = cfs_read_file("user.cfg");
        my $token = PVE::AccessControl::check_token_exist($usercfg, $userid, $tokenid);
 
-       my $update_token = sub {
+       PVE::AccessControl::lock_user_config(sub {
            $usercfg = cfs_read_file("user.cfg");
 
            PVE::AccessControl::check_token_exist($usercfg, $userid, $tokenid);
@@ -758,9 +859,7 @@ __PACKAGE__->register_method ({
            delete $usercfg->{users}->{$userid}->{tokens}->{$tokenid};
 
            cfs_write_file("user.cfg", $usercfg);
-       };
-
-       PVE::AccessControl::lock_user_config($update_token, 'deleting token failed');
+       }, 'deleting token failed');
 
        return;
     }});
index 3d8d01c0dfb99d1331b3f57a23f2724cfbeb2b0f..47f2d38b09c7f267e74978de506cb47cbdf8ae41 100644 (file)
@@ -7,18 +7,24 @@ use Crypt::OpenSSL::Random;
 use Crypt::OpenSSL::RSA;
 use Net::SSLeay;
 use Net::IP;
+use MIME::Base32;
 use MIME::Base64;
 use Digest::SHA;
 use IO::File;
 use File::stat;
 use JSON;
+use Scalar::Util 'weaken';
+use URI::Escape;
 
+use PVE::Exception qw(raise_perm_exc raise_param_exc);
 use PVE::OTP;
 use PVE::Ticket;
 use PVE::Tools qw(run_command lock_file file_get_contents split_list safe_print);
 use PVE::Cluster qw(cfs_register_file cfs_read_file cfs_write_file cfs_lock_file);
 use PVE::JSONSchema qw(register_standard_option get_standard_option);
 
+use PVE::RS::TFA;
+
 use PVE::Auth::Plugin;
 use PVE::Auth::AD;
 use PVE::Auth::LDAP;
@@ -66,15 +72,39 @@ sub pve_verify_realm {
     PVE::Auth::Plugin::pve_verify_realm(@_);
 }
 
+# Locking both config files together is only ever allowed in one order:
+#  1) tfa config
+#  2) user config
+# If we permit the other way round, too, we might end up deadlocking!
+my $user_config_locked;
 sub lock_user_config {
     my ($code, $errmsg) = @_;
 
+    my $locked = 1;
+    $user_config_locked = \$locked;
+    weaken $user_config_locked; # make this scope guard signal safe...
+
     cfs_lock_file("user.cfg", undef, $code);
+    $user_config_locked = undef;
     if (my $err = $@) {
        $errmsg ? die "$errmsg: $err" : die $err;
     }
 }
 
+sub lock_tfa_config {
+    my ($code, $errmsg) = @_;
+
+    die "tfa config lock cannot be acquired while holding user config lock\n"
+       if ($user_config_locked && $$user_config_locked);
+
+    my $res = cfs_lock_file("priv/tfa.cfg", undef, $code);
+    if (my $err = $@) {
+       $errmsg ? die "$errmsg: $err" : die $err;
+    }
+
+    return $res;
+}
+
 my $cache_read_key = sub {
     my ($type) = @_;
 
@@ -174,7 +204,16 @@ sub rotate_authkey {
     return if $authkey_lifetime == 0;
 
     PVE::Cluster::cfs_lock_authkey(undef, sub {
-       # re-check with lock to avoid double rotation in clusters
+       # stat() calls might be answered from the kernel page cache for up to
+       # 1s, so this special dance is needed to avoid a double rotation in
+       # clusters *despite* the cfs_lock context..
+
+       # drop in-process cache hash
+       $pve_auth_key_cache = {};
+       # force open/close of file to invalidate page cache entry
+       get_pubkey();
+       # now re-check with lock held and page cache invalidated so that stat()
+       # does the right thing, and any key updates by other nodes are visible.
        return if check_authkey();
 
        my $old = get_pubkey();
@@ -336,16 +375,23 @@ my $get_ticket_age_range = sub {
     return ($min, $max);
 };
 
-sub assemble_ticket {
-    my ($data) = @_;
+sub assemble_ticket : prototype($;$) {
+    my ($data, $aad) = @_;
 
     my $rsa_priv = get_privkey();
 
-    return PVE::Ticket::assemble_rsa_ticket($rsa_priv, 'PVE', $data);
+    return PVE::Ticket::assemble_rsa_ticket($rsa_priv, 'PVE', $data, $aad);
 }
 
-sub verify_ticket {
-    my ($ticket, $noerr) = @_;
+# Returns the username, "age" and tfa info.
+#
+# Note that for the new-style outh, tfa info is never set, as it only uses the `/ticket` api call
+# via the new 'tfa-challenge' parameter, so this part can go with PVE-8.
+#
+# New-style auth still uses this function, but sets `$tfa_ticket` to true when validating the tfa
+# ticket.
+sub verify_ticket : prototype($;$$) {
+    my ($ticket, $noerr, $tfa_ticket_aad) = @_;
 
     my $now = time();
 
@@ -359,7 +405,7 @@ sub verify_ticket {
        return undef if !defined($min);
 
        return PVE::Ticket::verify_rsa_ticket(
-           $rsa_pub, 'PVE', $ticket, undef, $min, $max, 1);
+           $rsa_pub, 'PVE', $ticket, $tfa_ticket_aad, $min, $max, 1);
     };
 
     my ($data, $age) = $check->();
@@ -380,7 +426,21 @@ sub verify_ticket {
        return $auth_failure->();
     }
 
+    if ($tfa_ticket_aad) {
+       # We're validating a ticket-call's 'tfa-challenge' parameter, so just return its data.
+       if ($data =~ /^!tfa!(.*)$/) {
+           return $1;
+       }
+       die "bad ticket\n";
+    }
+
     my ($username, $tfa_info);
+    if ($data =~ /^!tfa!(.*)$/) {
+       # PBS style half-authenticated ticket, contains a json string form of a `TfaChallenge`
+       # object.
+       # This type of ticket does not contain the user name.
+       return { type => 'new', data => $1 };
+    }
     if ($data =~ m{^u2f!([^!]+)!([0-9a-zA-Z/.=_\-+]+)$}) {
        # Ticket for u2f-users:
        ($username, my $challenge) = ($1, $2);
@@ -434,32 +494,34 @@ sub verify_token {
     my $token_info = $user->{tokens}->{$token};
 
     my $ctime = time();
-    die "token expired\n" if $token_info->{expire} && ($token_info->{expire} < $ctime);
+    die "token '$token' access expired\n" if $token_info->{expire} && ($token_info->{expire} < $ctime);
 
     die "invalid token value!\n" if !PVE::Cluster::verify_token($tokenid, $value);
 
     return wantarray ? ($tokenid) : $tokenid;
 }
 
-
-# VNC tickets
-# - they do not contain the username in plain text
-# - they are restricted to a specific resource path (example: '/vms/100')
-sub assemble_vnc_ticket {
-    my ($username, $path) = @_;
+my $assemble_short_lived_ticket = sub {
+    my ($prefix, $username, $path) = @_;
 
     my $rsa_priv = get_privkey();
 
     $path = normalize_path($path);
 
+    die "invalid ticket path\n" if !defined($path);
+
     my $secret_data = "$username:$path";
 
     return PVE::Ticket::assemble_rsa_ticket(
-       $rsa_priv, 'PVEVNC', undef, $secret_data);
-}
+       $rsa_priv, $prefix, undef, $secret_data);
+};
 
-sub verify_vnc_ticket {
-    my ($ticket, $username, $path, $noerr) = @_;
+my $verify_short_lived_ticket = sub {
+    my ($ticket, $prefix, $username, $path, $noerr) = @_;
+
+    $path = normalize_path($path);
+
+    die "invalid ticket path\n" if !defined($path);
 
     my $secret_data = "$username:$path";
 
@@ -469,12 +531,42 @@ sub verify_vnc_ticket {
            return undef;
        } else {
            # raise error via undef ticket
-           PVE::Ticket::verify_rsa_ticket($rsa_pub, 'PVEVNC');
+           PVE::Ticket::verify_rsa_ticket($rsa_pub, $prefix);
        }
     }
 
     return PVE::Ticket::verify_rsa_ticket(
-       $rsa_pub, 'PVEVNC', $ticket, $secret_data, -20, 40, $noerr);
+       $rsa_pub, $prefix, $ticket, $secret_data, -20, 40, $noerr);
+};
+
+# VNC tickets
+# - they do not contain the username in plain text
+# - they are restricted to a specific resource path (example: '/vms/100')
+sub assemble_vnc_ticket {
+    my ($username, $path) = @_;
+
+    return $assemble_short_lived_ticket->('PVEVNC', $username, $path);
+}
+
+sub verify_vnc_ticket {
+    my ($ticket, $username, $path, $noerr) = @_;
+
+    return $verify_short_lived_ticket->($ticket, 'PVEVNC', $username, $path, $noerr);
+}
+
+# Tunnel tickets
+# - they do not contain the username in plain text
+# - they are restricted to a specific resource path (example: '/vms/100', '/socket/run/qemu-server/123.storage')
+sub assemble_tunnel_ticket {
+    my ($username, $path) = @_;
+
+    return $assemble_short_lived_ticket->('PVETUNNEL', $username, $path);
+}
+
+sub verify_tunnel_ticket {
+    my ($ticket, $username, $path, $noerr) = @_;
+
+    return $verify_short_lived_ticket->($ticket, 'PVETUNNEL', $username, $path, $noerr);
 }
 
 sub assemble_spice_ticket {
@@ -512,7 +604,7 @@ sub read_x509_subject_spice {
     my $subject = Net::SSLeay::X509_NAME_oneline($nameobj);
     Net::SSLeay::X509_free($x509);
 
-    # remote-viewer wants comma as seperator (not '/')
+    # remote-viewer wants comma as separator (not '/')
     $subject =~ s!^/!!;
     $subject =~ s!/(\w+=)!,$1!g;
 
@@ -575,16 +667,20 @@ sub check_user_enabled {
     my $data = check_user_exist($usercfg, $username, $noerr);
     return undef if !$data;
 
-    return 1 if $data->{enable};
-
-    die "user '$username' is disabled\n" if !$noerr;
+    if (!$data->{enable}) {
+       die "user '$username' is disabled\n" if !$noerr;
+       return undef;
+    }
 
     my $ctime = time();
     my $expire = $usercfg->{users}->{$username}->{expire};
 
-    die "account expired\n" if $expire && ($expire < $ctime);
+    if ($expire && $expire < $ctime) {
+       die "user '$username' access expired\n" if !$noerr;
+       return undef;
+    }
 
-    return undef;
+    return 1; # enabled and not expired
 }
 
 sub check_token_exist {
@@ -601,6 +697,7 @@ sub check_token_exist {
     return undef;
 }
 
+# deprecated
 sub verify_one_time_pw {
     my ($type, $username, $keys, $tfa_cfg, $otp) = @_;
 
@@ -621,8 +718,8 @@ sub verify_one_time_pw {
 
 # password should be utf8 encoded
 # Note: some plugins delay/sleep if auth fails
-sub authenticate_user {
-    my ($username, $password, $otp) = @_;
+sub authenticate_user : prototype($$$;$) {
+    my ($username, $password, $otp, $tfa_challenge) = @_;
 
     die "no username specified\n" if !$username;
 
@@ -639,34 +736,197 @@ sub authenticate_user {
     my $cfg = $domain_cfg->{ids}->{$realm};
     die "auth domain '$realm' does not exist\n" if !$cfg;
     my $plugin = PVE::Auth::Plugin->lookup($cfg->{type});
+
+    if ($tfa_challenge) {
+       # This is the 2nd factor, use the password for the OTP response.
+       my $tfa_challenge = authenticate_2nd_new($username, $realm, $password, $tfa_challenge);
+       return wantarray ? ($username, $tfa_challenge) : $username;
+    }
+
     $plugin->authenticate_user($cfg, $realm, $ruid, $password);
 
-    my ($type, $tfa_data) = user_get_tfa($username, $realm);
-    if ($type) {
-       if ($type eq 'u2f') {
-           # Note that if the user did not manage to complete the initial u2f registration
-           # challenge we have a hash containing a 'challenge' entry in the user's tfa.cfg entry:
-           $tfa_data = undef if exists $tfa_data->{challenge};
-       } elsif (!defined($otp)) {
-           # The user requires a 2nd factor but has not provided one. Return success but
-           # don't clear $tfa_data.
-       } else {
-           my $keys = $tfa_data->{keys};
-           my $tfa_cfg = $tfa_data->{config};
-           verify_one_time_pw($type, $username, $keys, $tfa_cfg, $otp);
-           $tfa_data = undef;
+    # This is the first factor with an optional immediate 2nd factor for TOTP:
+    $tfa_challenge = authenticate_2nd_new($username, $realm, $otp, undef);
+    return wantarray ? ($username, $tfa_challenge) : $username;
+}
+
+sub authenticate_2nd_new_do : prototype($$$$) {
+    my ($username, $realm, $tfa_response, $tfa_challenge) = @_;
+    my ($tfa_cfg, $realm_tfa) = user_get_tfa($username, $realm);
+
+    # FIXME: `$tfa_cfg` is now usually never undef - use cheap check for
+    # whether the user has *any* entries here instead whe it is available in
+    # pve-rs
+    if (!defined($tfa_cfg)) {
+       return undef;
+    }
+
+    my $realm_type = $realm_tfa && $realm_tfa->{type};
+    # verify realm type unless using recovery keys:
+    if (defined($realm_type)) {
+       $realm_type = 'totp' if $realm_type eq 'oath'; # we used to call it that
+       if ($realm_type eq 'yubico') {
+           # Yubico auth will not be supported in rust for now...
+           if (!defined($tfa_challenge)) {
+               my $challenge = { yubico => JSON::true };
+               # Even with yubico auth we do allow recovery keys to be used:
+               if (my $recovery = $tfa_cfg->recovery_state($username)) {
+                   $challenge->{recovery} = $recovery;
+               }
+               return to_json($challenge);
+           }
+
+           if ($tfa_response =~ /^yubico:(.*)$/) {
+               $tfa_response = $1;
+               # Defer to after unlocking the TFA config:
+               return sub {
+                   authenticate_yubico_new(
+                       $tfa_cfg, $username, $realm_tfa, $tfa_challenge, $tfa_response,
+                   );
+               };
+           }
        }
 
-       # Return the type along with the rest:
-       if ($tfa_data) {
-           $tfa_data = {
-               type => $type,
-               data => $tfa_data,
-           };
+       my $response_type;
+       if (defined($tfa_response)) {
+           if ($tfa_response !~ /^([^:]+):/) {
+               die "bad otp response\n";
+           }
+           $response_type = $1;
+       }
+
+       die "realm requires $realm_type authentication\n"
+           if $response_type && $response_type ne 'recovery' && $response_type ne $realm_type;
+    }
+
+    configure_u2f_and_wa($tfa_cfg);
+
+    my ($result, $tfa_done);
+    if (defined($tfa_challenge)) {
+       $tfa_done = 1;
+       $tfa_challenge = verify_ticket($tfa_challenge, 0, $username);
+       $result = $tfa_cfg->authentication_verify2($username, $tfa_challenge, $tfa_response);
+       $tfa_challenge = undef;
+    } else {
+       $tfa_challenge = $tfa_cfg->authentication_challenge($username);
+
+       die "missing required 2nd keys\n"
+           if $realm_tfa && !defined($tfa_challenge);
+
+       if (defined($tfa_response)) {
+           if (defined($tfa_challenge)) {
+               $tfa_done = 1;
+               $result = $tfa_cfg->authentication_verify2($username, $tfa_challenge, $tfa_response);
+           } else {
+               die "no such challenge\n";
+           }
+       }
+    }
+
+    if ($tfa_done) {
+       if (!$result) {
+           # authentication_verify2 somehow returned undef - should be unreachable
+           die "2nd factor failed\n";
        }
+
+       if ($result->{'needs-saving'}) {
+           cfs_write_file('priv/tfa.cfg', $tfa_cfg);
+       }
+       if ($result->{'totp-limit-reached'}) {
+           # FIXME: send mail to the user (or admin/root if no email configured)
+           die "failed 2nd factor: TOTP limit reached, locked\n";
+       }
+       if ($result->{'tfa-limit-reached'}) {
+           # FIXME: send mail to the user (or admin/root if no email configured)
+           die "failed 1nd factor: TFA limit reached, user locked out\n";
+       }
+       if (!$result->{result}) {
+           die "failed 2nd factor\n";
+       }
+    }
+
+    return $tfa_challenge;
+}
+
+# Returns a tfa challenge or undef.
+sub authenticate_2nd_new : prototype($$$$) {
+    my ($username, $realm, $tfa_response, $tfa_challenge) = @_;
+
+    my $result;
+
+    if (defined($tfa_response) && $tfa_response =~ m/^recovery:/) {
+       $result = lock_tfa_config(sub {
+           authenticate_2nd_new_do($username, $realm, $tfa_response, $tfa_challenge);
+       });
+    } else {
+       $result = authenticate_2nd_new_do($username, $realm, $tfa_response, $tfa_challenge);
+    }
+
+    # Yubico auth returns the authentication sub:
+    if (ref($result) eq 'CODE') {
+       $result = $result->();
     }
 
-    return wantarray ? ($username, $tfa_data) : $username;
+    return $result;
+}
+
+sub authenticate_yubico_new : prototype($$$) {
+    my ($tfa_cfg, $username, $realm, $tfa_challenge, $otp) = @_;
+
+    $tfa_challenge = verify_ticket($tfa_challenge, 0, $username);
+    $tfa_challenge = from_json($tfa_challenge);
+
+    if (!$tfa_challenge->{yubico}) {
+       die "no such challenge\n";
+    }
+
+    my $keys = $tfa_cfg->get_yubico_keys($username);
+    die "no keys configured\n" if !defined($keys) || !length($keys);
+
+    authenticate_yubico_do($otp, $keys, $realm);
+
+    # return `undef` to clear the tfa challenge.
+    return undef;
+}
+
+sub authenticate_yubico_do : prototype($$$) {
+    my ($value, $keys, $realm) = @_;
+
+    # fixme: proxy support?
+    my $proxy = undef;
+
+    PVE::OTP::yubico_verify_otp($value, $keys, $realm->{url}, $realm->{id}, $realm->{key}, $proxy);
+}
+
+sub configure_u2f_and_wa : prototype($) {
+    my ($tfa_cfg) = @_;
+
+    my $rpc_origin;
+    my $get_origin = sub {
+       return $rpc_origin if defined($rpc_origin);
+       my $rpcenv = PVE::RPCEnvironment::get();
+       if (my $origin = $rpcenv->get_request_host(1)) {
+           $rpc_origin = "https://$origin";
+           return $rpc_origin;
+       }
+       die "failed to figure out origin\n";
+    };
+
+    my $dc = cfs_read_file('datacenter.cfg');
+    if (my $u2f = $dc->{u2f}) {
+       eval {
+           $tfa_cfg->set_u2f_config({
+               origin => $u2f->{origin} // $get_origin->(),
+               appid => $u2f->{appid},
+           });
+       };
+       warn "u2f unavailable, configuration error: $@\n" if $@;
+    }
+    if (my $wa = $dc->{webauthn}) {
+       $wa->{origin} //= $get_origin->();
+       eval { $tfa_cfg->set_webauthn_config({%$wa}) };
+       warn "webauthn unavailable, configuration error: $@\n" if $@;
+    }
 }
 
 sub domain_set_password {
@@ -682,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) = @_;
 
@@ -702,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
@@ -764,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',
        ],
@@ -794,6 +1096,9 @@ my $privgroups = {
            'SDN.Allocate',
            'SDN.Audit',
        ],
+       user => [
+           'SDN.Use',
+       ],
        audit => [
            'SDN.Audit',
        ],
@@ -822,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
@@ -833,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 };
 };
 
@@ -916,6 +1240,8 @@ sub lookup_username {
 sub normalize_path {
     my $path = shift;
 
+    return undef if !$path;
+
     $path =~ s|/+|/|g;
 
     $path =~ s|/$||;
@@ -941,13 +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/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;
 }
 
@@ -983,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;
@@ -1017,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 {
@@ -1131,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)) {
@@ -1150,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";
                            }
@@ -1179,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;
 
@@ -1327,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 = {};
 
@@ -1347,38 +1714,26 @@ sub write_user_config {
            }
 
        }
-    }
+    });
 
     return $data;
 }
 
-# The TFA configuration in priv/tfa.cfg format contains one line per user of
-# the form:
-#     USER:TYPE:DATA
-# DATA is a base64 encoded json string and its format depends on the type.
+# Creates a `PVE::RS::TFA` instance from the raw config data.
+# Its contained hash will also support the legacy functionality.
 sub parse_priv_tfa_config {
     my ($filename, $raw) = @_;
 
-    my $users = {};
-    my $cfg = { users => $users };
-
     $raw = '' if !defined($raw);
-    while ($raw =~ /^\s*(.+?)\s*$/gm) {
-       my $line = $1;
-       my ($user, $type, $data) = split(/:/, $line, 3);
+    my $cfg = PVE::RS::TFA->new($raw);
 
+    # Purge invalid users:
+    foreach my $user ($cfg->users()->@*) {
        my (undef, undef, $realm) = PVE::Auth::Plugin::verify_username($user, 1);
        if (!$realm) {
            warn "user tfa config - ignore user '$user' - invalid user name\n";
-           next;
+           $cfg->remove_user($user);
        }
-
-       $data = decode_json(decode_base64($data));
-
-       $users->{$user} = {
-           type => $type,
-           data => $data,
-       };
     }
 
     return $cfg;
@@ -1387,27 +1742,7 @@ sub parse_priv_tfa_config {
 sub write_priv_tfa_config {
     my ($filename, $cfg) = @_;
 
-    my $output = '';
-
-    my $users = $cfg->{users};
-    foreach my $user (sort keys %$users) {
-       my $info = $users->{$user};
-       next if !%$info; # skip empty entries
-
-       $info = {%$info}; # copy to verify contents:
-
-       my $type = delete $info->{type};
-       my $data = delete $info->{data};
-
-       if (my @keys = keys %$info) {
-           die "invalid keys in TFA config for user $user: " . join(', ', @keys) . "\n";
-       }
-
-       $data = encode_base64(encode_json($data), '');
-       $output .= "${user}:${type}:${data}\n";
-    }
-
-    return $output;
+    return $cfg->write();
 }
 
 sub roles {
@@ -1420,6 +1755,12 @@ sub roles {
 
     return 'Administrator' if $user eq 'root@pam'; # root can do anything
 
+    if (!defined($path)) {
+       # this shouldn't happen!
+       warn "internal error: ACL check called for undefined ACL path!\n";
+       return {};
+    }
+
     if (pve_verify_tokenid($user, 1)) {
        my $tokenid = $user;
        my ($username, $token) = split_tokenid($tokenid);
@@ -1435,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);
@@ -1509,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;
     };
 
@@ -1533,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,
@@ -1588,74 +1937,87 @@ my $USER_CONTROLLED_TFA_TYPES = {
     oath => 1,
 };
 
-# Delete an entry by setting $data=undef in which case $type is ignored.
-# Otherwise both must be valid.
-sub user_set_tfa {
-    my ($userid, $realm, $type, $data, $cached_usercfg, $cached_domaincfg) = @_;
-
-    if (defined($data) && !defined($type)) {
-       # This is an internal usage error and should not happen
-       die "cannot set tfa data without a type\n";
-    }
+sub user_remove_tfa : prototype($) {
+    my ($userid) = @_;
 
-    my $user_cfg = $cached_usercfg || cfs_read_file('user.cfg');
-    my $user = $user_cfg->{users}->{$userid}
-       or die "user '$userid' not found\n";
+    my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+    $tfa_cfg->remove_user($userid);
+    cfs_write_file('priv/tfa.cfg', $tfa_cfg);
+}
 
-    my $domain_cfg = $cached_domaincfg || cfs_read_file('domains.cfg');
-    my $realm_cfg = $domain_cfg->{ids}->{$realm};
-    die "auth domain '$realm' does not exist\n" if !$realm_cfg;
+my sub add_old_yubico_keys : prototype($$$) {
+    my ($userid, $tfa_cfg, $keys) = @_;
 
-    my $realm_tfa = $realm_cfg->{tfa};
-    if (defined($realm_tfa)) {
-       $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_tfa);
-       # If the realm has a TFA setting, we're only allowed to use that.
-       if (defined($data)) {
-           my $required_type = $realm_tfa->{type};
-           if ($required_type ne $type) {
-               die "realm '$realm' only allows TFA of type '$required_type\n";
-           }
-
-           if (defined($data->{config})) {
-               # XXX: Is it enough if the type matches? Or should the configuration also match?
-           }
+    my $count = 0;
+    foreach my $key (split_list($keys)) {
+       my $description = "<old userconfig key $count>";
+       ++$count;
+       $tfa_cfg->add_yubico_entry($userid, $description, $key);
+    }
+}
 
-           # realm-configured tfa always uses a simple key list, so use the user.cfg
-           $user->{keys} = $data->{keys};
-       } else {
-           die "realm '$realm' does not allow removing the 2nd factor\n";
-       }
+my sub normalize_totp_secret : prototype($) {
+    my ($key) = @_;
+
+    my $binkey;
+    # See PVE::OTP::oath_verify_otp:
+    if ($key =~ /^v2-0x([0-9a-fA-F]+)$/) {
+       # v2, hex
+       $binkey = pack('H*', $1);
+    } elsif ($key =~ /^v2-([A-Z2-7=]+)$/) {
+       # v2, base32
+       $binkey = MIME::Base32::decode_rfc3548($1);
+    } elsif ($key =~ /^[A-Z2-7=]{16}$/) {
+       $binkey = MIME::Base32::decode_rfc3548($key);
+    } elsif ($key =~ /^[A-Fa-f0-9]{40}$/) {
+       $binkey = pack('H*', $key);
     } else {
-       # Without a realm-enforced TFA setting the user can add a u2f or totp entry by themselves.
-       # The 'yubico' type requires yubico server settings, which have to be configured on the
-       # realm, so this is not supported here:
-       die "domain '$realm' does not support TFA type '$type'\n"
-           if defined($data) && !$USER_CONTROLLED_TFA_TYPES->{$type};
+       return undef;
     }
 
-    # Custom TFA entries are stored in priv/tfa.cfg as they can be more complet: u2f uses a
-    # public key and a key handle, TOTP requires the usual totp settings...
+    return MIME::Base32::encode_rfc3548($binkey);
+}
 
-    my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
-    my $tfa = ($tfa_cfg->{users}->{$userid} //= {});
+my sub add_old_totp_keys : prototype($$$$) {
+    my ($userid, $tfa_cfg, $realm_tfa, $keys) = @_;
+
+    my $issuer = 'Proxmox%20VE';
+    my $account = uri_escape("Old key for $userid");
+    my $digits = $realm_tfa->{digits} || 6;
+    my $step = $realm_tfa->{step} || 30;
+    my $uri = "otpauth://totp/$issuer:$account?digits=$digits&period=$step&algorithm=SHA1&secret=";
+
+    my $count = 0;
+    foreach my $key (split_list($keys)) {
+       $key = normalize_totp_secret($key);
+       # and just skip invalid keys:
+       next if !defined($key);
+
+       my $description = "<old userconfig key $count>";
+       ++$count;
+       eval { $tfa_cfg->add_totp_entry($userid, $description, $uri . $key) };
+       warn $@ if $@;
+    }
+}
 
-    if (defined($data)) {
-       $tfa->{type} = $type;
-       $tfa->{data} = $data;
-       cfs_write_file('priv/tfa.cfg', $tfa_cfg);
+sub add_old_keys_to_realm_tfa : prototype($$$$) {
+    my ($userid, $tfa_cfg, $realm_tfa, $keys) = @_;
 
-       $user->{keys} = "x!$type";
-    } else {
-       delete $tfa_cfg->{users}->{$userid};
-       cfs_write_file('priv/tfa.cfg', $tfa_cfg);
+    # if there's no realm tfa configured, we don't know what the keys mean, so we just ignore
+    # them...
+    return if !$realm_tfa;
 
-       delete $user->{keys};
+    my $type = $realm_tfa->{type};
+    if ($type eq 'oath') {
+       add_old_totp_keys($userid, $tfa_cfg, $realm_tfa, $keys);
+    } elsif ($type eq 'yubico') {
+       add_old_yubico_keys($userid, $tfa_cfg, $keys);
+    } else {
+       # invalid keys, we'll just drop them now...
     }
-
-    cfs_write_file('user.cfg', $user_cfg);
 }
 
-sub user_get_tfa {
+sub user_get_tfa : prototype($$$) {
     my ($username, $realm) = @_;
 
     my $user_cfg = cfs_read_file('user.cfg');
@@ -1672,32 +2034,12 @@ sub user_get_tfa {
     $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_tfa)
        if $realm_tfa;
 
-    if (!$keys) {
-       return if !$realm_tfa;
-       die "missing required 2nd keys\n";
+    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 515d2f44b278ac38cc08139b2ef0c2a28e7aaba1..c8e4db930c148f147841f56b0bcdc53a449d1c88 100755 (executable)
@@ -21,40 +21,63 @@ sub properties {
            maxLength => 256,
        },
        "client-id" => {
-            description => "OpenID Client ID",
-            type => 'string',
+           description => "OpenID Client ID",
+           type => 'string',
            maxLength => 256,
-       },
-       "client-key" => {
+       },
+       "client-key" => {
            description => "OpenID Client Key",
            type => 'string',
            optional => 1,
            maxLength => 256,
-       },
-       autocreate => {
-          description => "Automatically create users if they do not exist.",
-          optional => 1,
-          type => 'boolean',
-          default => 0,
-       },
-       "username-claim" => {
-          description => "OpenID claim used to generate the unique username.",
-          type => 'string',
-          enum => ['subject', 'username', 'email'],
-          optional => 1,
-       },
+       },
+       autocreate => {
+           description => "Automatically create users if they do not exist.",
+           optional => 1,
+           type => 'boolean',
+           default => 0,
+       },
+       "username-claim" => {
+           description => "OpenID claim used to generate the unique username.",
+           type => 'string',
+           optional => 1,
+       },
+       prompt => {
+           description => "Specifies whether the Authorization Server prompts the End-User for"
+               ." reauthentication and consent.",
+           type => 'string',
+           pattern => '(?:none|login|consent|select_account|\S+)', # \S+ is the extension variant
+           optional => 1,
+       },
+       scopes => {
+           description => "Specifies the scopes (user details) that should be authorized and"
+               ." returned, for example 'email' or 'profile'.",
+           type => 'string', # format => 'some-safe-id-list', # FIXME: TODO
+           default => "email profile",
+           optional => 1,
+       },
+       'acr-values' => {
+           description => "Specifies the Authentication Context Class Reference values that the"
+               ."Authorization Server is being requested to use for the Auth Request.",
+           type => 'string',
+           pattern => '^[^\x00-\x1F\x7F <>#"]*$', # Prohibit characters not allowed in URI RFC 2396.
+           optional => 1,
+       },
    };
 }
 
 sub options {
     return {
        "issuer-url" => {},
-        "client-id" => {},
-        "client-key" => { optional => 1 },
-        autocreate => { optional => 1 },
-        "username-claim" => { optional => 1, fixed => 1 },
-        default => { optional => 1 },
-        comment => { optional => 1 },
+       "client-id" => {},
+       "client-key" => { optional => 1 },
+       autocreate => { optional => 1 },
+       "username-claim" => { optional => 1, fixed => 1 },
+       prompt => { optional => 1 },
+       scopes => { optional => 1 },
+       "acr-values" => { optional => 1 },
+       default => { optional => 1 },
+       comment => { optional => 1 },
     };
 }
 
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 141305336c8640dd40879eb6e084b95c51ad0fa7..aa3706fd3d59d391cc0358b7d84d0e5ee230a9cb 100755 (executable)
@@ -49,18 +49,39 @@ PVE::JSONSchema::register_standard_option('realm', {
     maxLength => 32,
 });
 
+my $remove_options = "(?:acl|properties|entry)";
+
+PVE::JSONSchema::register_standard_option('sync-scope', {
+    description => "Select what to sync.",
+    type => 'string',
+    enum => [qw(users groups both)],
+    optional => '1',
+});
+
+PVE::JSONSchema::register_standard_option('sync-remove-vanished', {
+    description => "A semicolon-seperated list of things to remove when they or the user"
+       ." vanishes during a sync. The following values are possible: 'entry' removes the"
+       ." user/group when not returned from the sync. 'properties' removes the set"
+       ." properties on existing user/group that do not appear in the source (even custom ones)."
+       ." 'acl' removes acls when the user/group is not returned from the sync."
+       ." Instead of a list it also can be 'none' (the default).",
+    type => 'string',
+    default => 'none',
+    typetext => "([acl];[properties];[entry])|none",
+    pattern => "(?:(?:$remove_options\;)*$remove_options)|none",
+    optional => '1',
+});
+
 my $realm_sync_options_desc = {
-    scope => {
-       description => "Select what to sync.",
-       type => 'string',
-       enum => [qw(users groups both)],
-       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 => "If set, uses the LDAP Directory as source of truth,"
-           ." deleting users or groups not returned from the sync. Otherwise"
-           ." only syncs information which is not already present, and does not"
-           ." deletes or modifies anything else.",
+       description => "DEPRECATED: use 'remove-vanished' instead. If set, uses the LDAP Directory as source of truth,"
+           ." deleting users or groups not returned from the sync and removing"
+           ." all locally modified properties of synced users. If not set,"
+           ." only syncs information which is present in the synced data, and does not"
+           ." delete or modify anything else.",
        type => 'boolean',
        optional => '1',
     },
@@ -71,8 +92,8 @@ my $realm_sync_options_desc = {
        optional => '1',
     },
     purge => {
-       description => "Remove ACLs for users or groups which were removed from"
-           ." the config during a sync.",
+       description => "DEPRECATED: use 'remove-vanished' instead. Remove ACLs for users or"
+           ." groups which were removed from the config during a sync.",
        type => 'boolean',
        optional => '1',
     },
@@ -110,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,
 });
@@ -270,7 +291,7 @@ sub delete_user {
 
 # called during addition of realm (before the new domain config got written)
 # `password` is moved to %param to avoid writing it out to the config
-# die to abort additon if there are (grave) problems
+# die to abort addition if there are (grave) problems
 # NOTE: runs in a domain config *locked* context
 sub on_add_hook {
     my ($class, $realm, $config, %param) = @_;
@@ -290,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 59297070066458cfd8da7e7250c5a27377ea8ee1..d6351622f8e06a1d24d62abf5baff95056ab46b2 100755 (executable)
@@ -11,6 +11,8 @@ use PVE::API2::Role;
 use PVE::API2::ACL;
 use PVE::API2::AccessControl;
 use PVE::API2::Domains;
+use PVE::API2::TFA;
+use PVE::Cluster qw(cfs_read_file cfs_write_file);
 use PVE::CLIFormatter;
 use PVE::CLIHandler;
 use PVE::JSONSchema qw(get_standard_option);
@@ -110,6 +112,86 @@ __PACKAGE__->register_method({
        return PVE::API2::AccessControl->permissions($param);
     }});
 
+__PACKAGE__->register_method({
+    name => 'delete_tfa',
+    path => 'delete_tfa',
+    method => 'PUT',
+    description => 'Delete TFA entries from a user.',
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid'),
+           id => {
+               description => "The TFA ID, if none provided, all TFA entries will be deleted.",
+               type => 'string',
+               optional => 1,
+           },
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       my $userid = extract_param($param, "userid");
+       my $tfa_id = extract_param($param, "id");
+
+       PVE::AccessControl::lock_tfa_config(sub {
+           my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+           if (defined($tfa_id)) {
+               $tfa_cfg->api_delete_tfa($userid, $tfa_id);
+           } else {
+               $tfa_cfg->remove_user($userid);
+           }
+           cfs_write_file('priv/tfa.cfg', $tfa_cfg);
+       });
+       return;
+    }});
+
+__PACKAGE__->register_method({
+    name => 'list_tfa',
+    path => 'list_tfa',
+    method => 'GET',
+    description => "List TFA entries.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid', { optional => 1 }),
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       my $userid = extract_param($param, "userid");
+
+       my sub format_tfa_entries : prototype($;$) {
+           my ($entries, $indent) = @_;
+
+           $indent //= '';
+
+           my $nl = '';
+           for my $entry (@$entries) {
+               my ($id, $ty, $desc) = ($entry->@{qw/id type description/});
+               printf("${nl}${indent}%-9s %s\n${indent}    %s\n", "$ty:", $id, $desc // '');
+               $nl = "\n";
+           }
+       };
+
+       my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+       if (defined($userid)) {
+           format_tfa_entries($tfa_cfg->api_list_user_tfa($userid));
+       } else {
+           my $result = $tfa_cfg->api_list_tfa('', 1);
+           my $nl = '';
+           for my $entry (sort { $a->{userid} cmp $b->{userid} } @$result) {
+               print "${nl}$entry->{userid}:\n";
+               format_tfa_entries($entry->{entries}, '    ');
+               $nl = "\n";
+           }
+       }
+       return;
+    }});
+
 our $cmddef = {
     user => {
        add    => [ 'PVE::API2::User', 'create_user', ['userid'] ],
@@ -118,7 +200,9 @@ our $cmddef = {
        list   => [ 'PVE::API2::User', 'index', [], {}, $print_api_result, $PVE::RESTHandler::standard_output_options],
        permissions => [ 'PVE::API2::AccessControl', 'permissions', ['userid'], {}, $print_perm_result, $PVE::RESTHandler::standard_output_options],
        tfa => {
-           delete => [ 'PVE::API2::AccessControl', 'change_tfa', ['userid'], { action => 'delete', key => undef, config => undef, response => undef, }, ],
+           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 8aae0940e5157cb7380c29fa801aaeb1517b4891..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;
@@ -23,15 +23,18 @@ my $compile_acl_path = sub {
 
     return undef if !$cfg->{roles};
 
+    # permissions() has an early return for this case
     die "internal error" if $user eq 'root@pam';
 
     my $cache = $self->{aclcache};
     $cache->{$user} = {} if !$cache->{$user};
     my $data = $cache->{$user};
 
+    # permissions() will always prime the cache for the owning user
     my ($username, undef) = PVE::AccessControl::split_tokenid($user, 1);
     die "internal error" if $username && $username ne 'root@pam' && !defined($cache->{$username});
 
+    # resolve and cache roles of the current user/token for all pool ACL paths
     if (!$data->{poolroles}) {
        $data->{poolroles} = {};
 
@@ -52,44 +55,83 @@ my $compile_acl_path = sub {
        }
     }
 
+    # get roles of current user/token on checked path - this already handles
+    # propagation and NoAccess along the path
+    #
+    # hash mapping role name to propagation flag value, a key being defined
+    # means the role is set
     my $roles = PVE::AccessControl::roles($cfg, $user, $path);
 
     # apply roles inherited from pools
-    # Note: assume we do not want to propagate those privs
     if ($data->{poolroles}->{$path}) {
+       # NoAccess must not be trumped by pool ACLs
        if (!defined($roles->{NoAccess})) {
            if ($data->{poolroles}->{$path}->{NoAccess}) {
+               # but pool ACL NoAccess trumps regular ACL
                $roles = { 'NoAccess' => 0 };
            } else {
                foreach my $role (keys %{$data->{poolroles}->{$path}}) {
+                   # only use role from pool ACL if regular ACL didn't already
+                   # set it, and never set propagation for pool-derived ACLs
                    $roles->{$role} = 0 if !defined($roles->{$role});
                }
            }
        }
     }
 
+    # cache roles
     $data->{roles}->{$path} = $roles;
 
+    # derive privs from set roles - hash mapping privilege name to propagation
+    # flag value, a key being defined means the priv is set
     my $privs = {};
     foreach my $role (keys %$roles) {
        if (my $privset = $cfg->{roles}->{$role}) {
            foreach my $p (keys %$privset) {
-               $privs->{$p} = $roles->{$role};
+               # set priv '$p' to propagated iff any of the set roles
+               # containing it have the propagated flag set
+               $privs->{$p} ||= $roles->{$role};
            }
        }
     }
 
+    # intersect user and token permissions
     if ($username && $username ne 'root@pam') {
-       # intersect user and token permissions
+       # map of set privs to their propagation flag value, for the owning user
        my $user_privs = $cache->{$username}->{privs}->{$path};
-       $privs = { map { $_ => $user_privs->{$_} && $privs->{$_} } keys %$privs };
+       # list of privs set both for token and owning user
+       my $filtered_privs = [ grep { defined($user_privs->{$_}) } keys %$privs ];
+       # intersection of privs using filtered list, combining both propagation
+       # flags
+       $privs = { map { $_ => $user_privs->{$_} && $privs->{$_} } @$filtered_privs };
     }
 
+    foreach my $priv (keys %$privs) {
+       # safeguard, this should never happen anyway
+       delete $privs->{$priv} if !defined($privs->{$priv});
+    }
+
+    # cache privs
     $data->{privs}->{$path} = $privs;
 
     return $privs;
 };
 
+# this is the method used by permission check helpers below
+#
+# returned value is a hash mapping all set privileges on $path to their
+# respective propagation flag. the propagation flag is informational only -
+# actual propagation is handled in PVE::AccessControl::roles(). to determine
+# whether a privilege is set, check for definedness in the returned hash.
+#
+# compiled ACLs are cached, so repeated checks for the same path and user are
+# almost free.
+#
+# if $user is a tokenid, permissions are calculated depending on the
+# privilege-separation flag value:
+# - non-priv-separated: permissions for owning user are returned
+# - priv-separated: permissions for owning user are calculated and intersected
+#   with those of token
 sub permissions {
     my ($self, $user, $path) = @_;
 
@@ -98,6 +140,12 @@ sub permissions {
        return { map { $_ => 1 } keys %{$cfg->{roles}->{'Administrator'}} };
     }
 
+    if (!defined($path)) {
+       # this shouldn't happen!
+       warn "internal error: ACL check called for undefined ACL path!\n";
+       return {};
+    }
+
     if (PVE::AccessControl::pve_verify_tokenid($user, 1)) {
        my ($username, $token) = PVE::AccessControl::split_tokenid($user);
        my $cfg = $self->{user_cfg};
@@ -138,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;
 
@@ -154,6 +208,8 @@ sub compute_api_permission {
        my $toplevel = ($path =~ /^\/(\w+)/) ? $1 : 'dc';
        if ($toplevel eq 'pool') {
            foreach my $priv (keys %$path_perm) {
+               next if !defined($path_perm->{$priv});
+
                if ($priv =~ m/^VM\./) {
                    $res->{vms}->{$priv} = 1;
                } elsif ($priv =~ m/^Datastore\./) {
@@ -166,6 +222,8 @@ sub compute_api_permission {
        } else {
            my $priv_regex = $priv_re_map->{$toplevel} // next;
            foreach my $priv (keys %$path_perm) {
+               next if !defined($path_perm->{$priv});
+
                next if $priv !~ m/^($priv_regex)/;
                $res->{$toplevel}->{$priv} = 1;
            }
@@ -184,7 +242,8 @@ sub get_effective_permissions {
        '/access' => 1,
        '/access/groups' => 1,
        '/nodes' => 1,
-       '/pools' => 1,
+       '/pool' => 1,
+       '/sdn' => 1,
        '/storage' => 1,
        '/vms' => 1,
     };
@@ -192,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}}) {
@@ -210,6 +270,9 @@ sub get_effective_permissions {
     my $perms = {};
     foreach my $path (keys %$paths) {
        my $path_perms = $self->permissions($user, $path);
+       foreach my $priv (keys %$path_perms) {
+           delete $path_perms->{$priv} if !defined($path_perms->{$priv});
+       }
        # filter paths where user has NO permissions
        $perms->{$path} = $path_perms if %$path_perms;
     }
@@ -262,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) = @_;
 
@@ -389,22 +477,32 @@ sub exec_api2_perm_check {
            raise_perm_exc();
        }
        my $path = PVE::Tools::template_replace($tmplpath, $param);
-       $path = PVE::AccessControl::normalize_path($path);
-       return $self->check_full($username, $path, $privs, $any, $noerr);
+       my $normpath = PVE::AccessControl::normalize_path($path);
+       warn "Failed to normalize '$path'\n" if !defined($normpath) && defined($path);
+
+       return $self->check_full($username, $normpath, $privs, $any, $noerr);
     } elsif ($test eq 'userid-group') {
        my $userid = $param->{userid};
        my ($t, $privs, %options) = @$check;
-       return 0 if !$options{groups_param} && !$self->check_user_exist($userid, $noerr);
+
+       my $check_existing_user = !$options{groups_param} || $options{groups_param} ne 'create';
+       return 0 if $check_existing_user && !$self->check_user_exist($userid, $noerr);
+
+       # check permission for ALL groups (and thus ALL users)
        if (!$self->check_any($username, "/access/groups", $privs, 1)) {
+           # list of groups $username has any of $privs on
            my $groups = $self->filter_groups($username, $privs, 1);
            if ($options{groups_param}) {
+               # does $username have any of $privs on all new/updated/.. groups?
                my @group_param = PVE::Tools::split_list($param->{groups});
                raise_perm_exc("/access/groups, " . join("|", @$privs)) if !scalar(@group_param);
                foreach my $pg (@group_param) {
                    raise_perm_exc("/access/groups/$pg, " . join("|", @$privs))
                        if !$groups->{$pg};
                }
-           } else {
+           }
+           if ($check_existing_user) {
+               # does $username have any of $privs on any existing group of $userid
                my $allowed_users = $self->group_member_join([keys %$groups]);
                if (!$allowed_users->{$userid}) {
                    return 0 if $noerr;
@@ -428,12 +526,13 @@ sub exec_api2_perm_check {
        } else {
            die "unknown userid-param test";
        }
-     } elsif ($test eq 'perm-modify') {
+    } elsif ($test eq 'perm-modify') {
        my ($t, $tmplpath) = @$check;
        my $path = PVE::Tools::template_replace($tmplpath, $param);
        $path = PVE::AccessControl::normalize_path($path);
+       return 0 if !defined($path); # should already die in API2::ACL
        return $self->check_perm_modify($username, $path, $noerr);
-   } else {
+    } else {
        die "unknown permission test";
     }
 };
@@ -524,7 +623,7 @@ sub init_request {
     }
 }
 
-# hacks: to provide better backwards compatibiliy
+# hacks: to provide better backwards compatibility
 
 # old code uses PVE::RPCEnvironment::get();
 # new code should use PVE::RPCEnvironment->get();
@@ -538,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 adaacb9389d1f7ed9019edfade138f84d0149f85..859a84bcba51237f5d91fa6bbe518becc6c56c39 100644 (file)
@@ -12,3 +12,4 @@ check:
        perl -I.. perm-test6.pl
        perl -I.. perm-test7.pl
        perl -I.. perm-test8.pl
+       perl -I.. realm_sync_test.pl
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 67e0cb14955136e110b14e030328eeda544063f9..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', '');
@@ -62,6 +65,9 @@ check_roles('carol@pam!token', '/vms/200', 'NoAccess');
 check_roles('max@pve!token', '/vms/200', 'storage_manager');
 check_roles('max@pve!token2', '/vms/200', 'customer');
 
+# check intersection -> token has Administrator, but user only vm_admin
+check_permission('max@pve!token2', '/vms/300', 'VM.Allocate,VM.Audit,VM.Console,VM.PowerMgmt');
+
 print "all tests passed\n";
 
 exit (0);
diff --git a/src/test/realm_sync_test.pl b/src/test/realm_sync_test.pl
new file mode 100755 (executable)
index 0000000..3281315
--- /dev/null
@@ -0,0 +1,359 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+
+use Test::MockModule;
+use Test::More;
+use Storable qw(dclone);
+
+use PVE::AccessControl;
+use PVE::API2::Domains;
+
+my $domainscfg = {
+    ids => {
+       "pam" => { type => 'pam' },
+       "pve" => { type => 'pve' },
+       "syncedrealm" => { type => 'ldap' }
+    },
+};
+
+my $initialusercfg = {
+    users => {
+       'root@pam' => { username => 'root', },
+       'user1@syncedrealm' => {
+           username => 'user1',
+           enable => 1,
+           'keys' => 'some',
+       },
+       'user2@syncedrealm' => {
+           username => 'user2',
+           enable => 1,
+       },
+       'user3@syncedrealm' => {
+           username => 'user3',
+           enable => 1,
+       },
+    },
+    groups => {
+       'group1-syncedrealm' => { users => {}, },
+       'group2-syncedrealm' => { users => {}, },
+    },
+    acl_root => {
+       users => {
+           'user3@syncedrealm' => {},
+       },
+       groups => {},
+    },
+};
+
+my $sync_response = {
+    user => [
+       {
+           attributes => { 'uid' => ['user1'], },
+           dn => 'uid=user1,dc=syncedrealm',
+       },
+       {
+           attributes => { 'uid' => ['user2'], },
+           dn => 'uid=user2,dc=syncedrealm',
+       },
+       {
+           attributes => { 'uid' => ['user4'], },
+           dn => 'uid=user4,dc=syncedrealm',
+       },
+    ],
+    groups => [
+       {
+           dn => 'dc=group1,dc=syncedrealm',
+           members => [
+               'uid=user1,dc=syncedrealm',
+           ],
+       },
+       {
+           dn => 'dc=group3,dc=syncedrealm',
+           members => [
+               'uid=nonexisting,dc=syncedrealm',
+           ],
+       }
+    ],
+};
+
+my $returned_user_cfg = {};
+
+# mocking all cluster and ldap operations
+my $pve_cluster_module = Test::MockModule->new('PVE::Cluster');
+$pve_cluster_module->mock(
+    cfs_update => sub {},
+    cfs_read_file => sub {
+       my ($filename) = @_;
+       if ($filename eq 'domains.cfg') { return dclone($domainscfg); }
+       if ($filename eq 'user.cfg') { return dclone($initialusercfg); }
+       die "unexpected cfs_read_file";
+    },
+    cfs_write_file => sub {
+       my ($filename, $data) = @_;
+       if ($filename eq 'user.cfg') {
+           $returned_user_cfg = $data;
+           return;
+       }
+       die "unexpected cfs_read_file";
+    },
+    cfs_lock_file => sub {
+       my ($filename, $timeout, $code) = @_;
+       return $code->();
+    },
+);
+
+my $pve_api_domains = Test::MockModule->new('PVE::API2::Domains');
+$pve_api_domains->mock(
+    cfs_read_file => sub { PVE::Cluster::cfs_read_file(@_); },
+    cfs_write_file => sub { PVE::Cluster::cfs_write_file(@_); },
+);
+
+my $pve_accesscontrol = Test::MockModule->new('PVE::AccessControl');
+$pve_accesscontrol->mock(
+    cfs_lock_file => sub { PVE::Cluster::cfs_lock_file(@_); },
+);
+
+my $pve_rpcenvironment = Test::MockModule->new('PVE::RPCEnvironment');
+$pve_rpcenvironment->mock(
+    get => sub { return bless {}, 'PVE::RPCEnvironment'; },
+    get_user => sub { return 'root@pam'; },
+    fork_worker => sub {
+       my ($class, $workertype, $id, $user, $code) = @_;
+
+       return $code->();
+    },
+);
+
+my $pve_ldap_module = Test::MockModule->new('PVE::LDAP');
+$pve_ldap_module->mock(
+    ldap_connect => sub { return {}; },
+    ldap_bind => sub {},
+    query_users => sub {
+       return $sync_response->{user};
+    },
+    query_groups => sub {
+       return $sync_response->{groups};
+    },
+);
+
+my $pve_auth_ldap = Test::MockModule->new('PVE::Auth::LDAP');
+$pve_auth_ldap->mock(
+    connect_and_bind => sub { return {}; },
+);
+
+my $tests = [
+    [
+       "non-full without purge",
+       {
+           realm => 'syncedrealm',
+           scope => 'both',
+       },
+       {
+           users => {
+               'root@pam' => { username => 'root', },
+               'user1@syncedrealm' => {
+                   username => 'user1',
+                   enable => 1,
+                   'keys' => 'some',
+               },
+               'user2@syncedrealm' => {
+                   username => 'user2',
+                   enable => 1,
+               },
+               'user3@syncedrealm' => {
+                   username => 'user3',
+                   enable => 1,
+               },
+               'user4@syncedrealm' => {
+                   username => 'user4',
+                   enable => 1,
+               },
+           },
+           groups => {
+               'group1-syncedrealm' => {
+                   users => {
+                       'user1@syncedrealm' => 1,
+                   },
+               },
+               'group2-syncedrealm' => { users => {}, },
+               'group3-syncedrealm' => { users => {}, },
+           },
+           acl_root => {
+               users => {
+                   'user3@syncedrealm' => {},
+               },
+               groups => {},
+           },
+       },
+    ],
+    [
+       "full without purge",
+       {
+           realm => 'syncedrealm',
+           'remove-vanished' => 'entry;properties',
+           scope => 'both',
+       },
+       {
+           users => {
+               'root@pam' => { username => 'root', },
+               'user1@syncedrealm' => {
+                   username => 'user1',
+                   enable => 1,
+               },
+               'user2@syncedrealm' => {
+                   username => 'user2',
+                   enable => 1,
+               },
+               'user4@syncedrealm' => {
+                   username => 'user4',
+                   enable => 1,
+               },
+           },
+           groups => {
+               'group1-syncedrealm' => {
+                   users => {
+                       'user1@syncedrealm' => 1,
+                   },
+               },
+               'group3-syncedrealm' => { users => {}, }
+           },
+           acl_root => {
+               users => {
+                   'user3@syncedrealm' => {},
+               },
+               groups => {},
+           },
+       },
+    ],
+    [
+       "non-full with purge",
+       {
+           realm => 'syncedrealm',
+           'remove-vanished' => 'acl',
+           scope => 'both',
+       },
+       {
+           users => {
+               'root@pam' => { username => 'root', },
+               'user1@syncedrealm' => {
+                   username => 'user1',
+                   enable => 1,
+                   'keys' => 'some',
+               },
+               'user2@syncedrealm' => {
+                   username => 'user2',
+                   enable => 1,
+               },
+               'user3@syncedrealm' => {
+                   username => 'user3',
+                   enable => 1,
+               },
+               'user4@syncedrealm' => {
+                   username => 'user4',
+                   enable => 1,
+               },
+           },
+           groups => {
+               'group1-syncedrealm' => {
+                   users => {
+                       'user1@syncedrealm' => 1,
+                   },
+               },
+               'group2-syncedrealm' => { users => {}, },
+               'group3-syncedrealm' => { users => {}, },
+           },
+           acl_root => {
+               users => {},
+               groups => {},
+           },
+       },
+    ],
+    [
+       "full with purge",
+       {
+           realm => 'syncedrealm',
+           'remove-vanished' => 'acl;entry;properties',
+           scope => 'both',
+       },
+       {
+           users => {
+               'root@pam' => { username => 'root', },
+               'user1@syncedrealm' => {
+                   username => 'user1',
+                   enable => 1,
+               },
+               'user2@syncedrealm' => {
+                   username => 'user2',
+                   enable => 1,
+               },
+               'user4@syncedrealm' => {
+                   username => 'user4',
+                   enable => 1,
+               },
+           },
+           groups => {
+               'group1-syncedrealm' => {
+                   users => {
+                       'user1@syncedrealm' => 1,
+                   },
+               },
+               'group3-syncedrealm' => { users => {}, },
+           },
+           acl_root => {
+               users => {},
+               groups => {},
+           },
+       },
+    ],
+    [
+       "don't delete properties, but users and acls",
+       {
+           realm => 'syncedrealm',
+           'remove-vanished' => 'acl;entry',
+           scope => 'both',
+       },
+       {
+           users => {
+               'root@pam' => { username => 'root', },
+               'user1@syncedrealm' => {
+                   username => 'user1',
+                   enable => 1,
+                   'keys' => 'some',
+               },
+               'user2@syncedrealm' => {
+                   username => 'user2',
+                   enable => 1,
+               },
+               'user4@syncedrealm' => {
+                   username => 'user4',
+                   enable => 1,
+               },
+           },
+           groups => {
+               'group1-syncedrealm' => {
+                   users => {
+                       'user1@syncedrealm' => 1,
+                   },
+               },
+               'group3-syncedrealm' => { users => {}, },
+           },
+           acl_root => {
+               users => {},
+               groups => {},
+           },
+       },
+    ],
+];
+
+for my $test (@$tests) {
+    my $name = $test->[0];
+    my $parameters = $test->[1];
+    my $expected = $test->[2];
+    $returned_user_cfg = {};
+    PVE::API2::Domains->sync($parameters);
+    is_deeply($returned_user_cfg, $expected, $name);
+}
+
+done_testing();
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 6b0eac6a933c80dcde0578853131e4abf5727098..ce704ef66c6a564ee905ecf481e22a7dbc67489f 100644 (file)
@@ -13,9 +13,11 @@ 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:
+acl:1:/vms/300:max@pve:vm_admin:
 acl:1:/vms/100/:alex@pve,max@pve:customer:
 acl:1:/storage/nfs1:@testgroup2:storage_manager:
 acl:1:/users:max@pve:Administrator:
@@ -26,3 +28,5 @@ acl:1:/vms/200:@testgroup2:NoAccess:
 acl:1:/vms/200:carol@pam!token:vm_admin
 acl:1:/vms/200:max@pve!token:storage_manager
 acl:1:/vms/200:max@pve!token2:customer
+
+acl:1:/vms/300:max@pve!token2:Administrator