-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)
.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.*
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'
role:
- defines a sets of priviledges
+ defines a sets of privileges
predefined roles:
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
+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
* 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
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
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
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
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
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
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.
-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:
.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:
.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
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) {
}
}
}
- }
+ });
return $res;
}});
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;
}
}
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;
}
}
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;
}
}
}
use PVE::API2::Role;
use PVE::API2::ACL;
use PVE::API2::OpenId;
+use PVE::API2::TFA;
use PVE::Auth::Plugin;
use PVE::OTP;
path => 'openid',
});
+__PACKAGE__->register_method ({
+ subclass => "PVE::API2::TFA",
+ path => 'tfa',
+});
+
__PACKAGE__->register_method ({
name => 'index',
path => '',
}});
-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)) &&
} 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) ];
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 {
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 => {
$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 = $@) {
minLength => 5,
maxLength => 64,
},
+ 'confirmation-password' => $PVE::API2::TFA::OPTIONAL_PASSWORD_SCHEMA,
}
},
returns => { type => "null" },
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
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',
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 => '',
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) = @_;
my $realm = extract_param($param, 'realm');
my $type = $param->{type};
+ my $check_connection = extract_param($param, 'check-connection');
die "domain '$realm' already exists\n"
if $ids->{$realm};
die "unable to 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);
}
$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");
},
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) = @_;
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)) {
$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}) {
$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");
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;
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";
}
}
};
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";
}
}
};
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;
};
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."
--- /dev/null
+SOURCES = \
+ RealmSync.pm \
+
+.PHONY: install
+install:
+ for i in $(SOURCES); do install -D -m 0644 $$i $(DESTDIR)$(PERLDIR)/PVE/API2/Jobs/$$i; done
--- /dev/null
+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;
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
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);
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');
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).",
my $res;
eval {
+ my $dcconf = PVE::Cluster::cfs_read_file('datacenter.cfg');
+ local $ENV{all_proxy} = $dcconf->{http_proxy} if exists $dcconf->{http_proxy};
+
my ($realm, $private_auth_state) = PVE::RS::OpenId::verify_public_auth_state(
$openid_state_path, $param->{'state'});
my $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}";
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);
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;
}});
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;
}});
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;
}
--- /dev/null
+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;
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', {
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;
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 => {
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}" } ],
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};
$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;
}
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 => {
tokens => {
optional => 1,
type => 'object',
+ additionalProperties => get_standard_option('token-info'),
},
},
type => "object"
code => sub {
my ($param) = @_;
- my ($username, undef, $domain) =
- PVE::AccessControl::verify_username($param->{userid});
+ my ($username, undef, $domain) = PVE::AccessControl::verify_username($param->{userid});
my $usercfg = cfs_read_file("user.cfg");
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 => {
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;
}});
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,
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;
}});
additionalProperties => 0,
properties => {
userid => get_standard_option('userid-completed'),
+ multiple => {
+ type => 'boolean',
+ description => 'Request all entries as an array.',
+ optional => 1,
+ default => 0,
+ },
},
},
returns => {
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"
},
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 => {
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 => {
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 => {
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 => {
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);
$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;
}});
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 => {
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);
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;
}});
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;
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) = @_;
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();
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();
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->();
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);
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";
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 {
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;
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 {
return undef;
}
+# deprecated
sub verify_one_time_pw {
my ($type, $username, $keys, $tfa_cfg, $otp) = @_;
# 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;
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 {
$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) = @_;
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
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',
],
'SDN.Allocate',
'SDN.Audit',
],
+ user => [
+ 'SDN.Use',
+ ],
audit => [
'SDN.Audit',
],
'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
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 };
};
sub normalize_path {
my $path = shift;
+ return undef if !$path;
+
$path =~ s|/+|/|g;
$path =~ s|/$||;
|/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;
}
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;
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 {
$propagate = $propagate ? 1 : 0;
if (my $path = normalize_path($pathtxt)) {
+ my $acl_node;
foreach my $role (split_list($rolelist)) {
if (!verify_rolename($role, 1)) {
if (!$cfg->{groups}->{$group}) { # group does not exist
warn "user config - ignore invalid acl group '$group'\n";
}
- $cfg->{acl}->{$path}->{groups}->{$group}->{$role} = $propagate;
+ $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";
}
}
# 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;
}
};
- 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 = {};
}
}
- }
+ });
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;
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 {
return 'Administrator' if $user eq 'root@pam'; # root can do anything
+ if (!defined($path)) {
+ # this shouldn't happen!
+ warn "internal error: ACL check called for undefined ACL path!\n";
+ return {};
+ }
+
if (pve_verify_tokenid($user, 1)) {
my $tokenid = $user;
my ($username, $token) = split_tokenid($tokenid);
my $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);
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;
};
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,
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');
$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
base_dn => {
description => "LDAP base domain name",
type => 'string',
- pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*',
optional => 1,
maxLength => 256,
},
bind_dn => {
description => "LDAP bind domain name",
type => 'string',
- pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*',
optional => 1,
maxLength => 256,
},
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,
},
type => 'boolean',
optional => 1,
default => 1,
- }
+ },
};
}
};
}
+my sub verify_sync_attribute_value {
+ my ($attr, $value) = @_;
+
+ # The attribute does not include the realm, so can't use PVE::Auth::Plugin::verify_username
+ if ($attr eq 'username') {
+ die "value '$value' does not look like a valid user name\n"
+ if $value !~ m/${PVE::Auth::Plugin::user_regex}/;
+ return;
+ }
+
+ return if $attr eq 'enable'; # for backwards compat, don't parse/validate
+
+ if (my $schema = PVE::JSONSchema::get_standard_option("user-$attr")) {
+ PVE::JSONSchema::validate($value, $schema, "invalid value '$value'\n");
+ } else {
+ die "internal error: no schema for attribute '$attr' with value '$value' available!\n";
+ }
+}
+
sub get_scheme_and_port {
my ($class, $config) = @_;
}
sub connect_and_bind {
- my ($class, $config, $realm) = @_;
+ my ($class, $config, $realm, $param) = @_;
my $servers = [$config->{server1}];
push @$servers, $config->{server2} if $config->{server2};
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}) {
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;
}
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;
}
}
$ret->{$name} = { users => {} };
foreach my $member (@{$group->{members}}) {
- if (my $user = $dnmap->{$member}) {
+ if (my $user = $dnmap->{lc($member)}) {
$ret->{$name}->{users}->{$user} = 1;
}
}
ldap_delete_credentials($realm);
}
+sub check_connection {
+ my ($class, $realm, $config, %param) = @_;
+
+ $class->connect_and_bind($config, $realm, \%param);
+}
+
1;
.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
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 },
};
}
# 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;
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) {
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',
},
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',
},
}
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,
});
# 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) = @_;
# 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;
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:
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);
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'] ],
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 ],
--- /dev/null
+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
--- /dev/null
+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;
.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
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;
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} = {};
}
}
+ # 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) = @_;
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};
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;
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\./) {
} 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;
}
'/access' => 1,
'/access/groups' => 1,
'/nodes' => 1,
- '/pools' => 1,
+ '/pool' => 1,
+ '/sdn' => 1,
'/storage' => 1,
'/vms' => 1,
};
my $cfg = $self->{user_cfg};
# paths explicitly listed in ACLs
- foreach my $acl_path (keys %{$cfg->{acl}}) {
- $paths->{$acl_path} = 1;
- }
+ PVE::AccessControl::iterate_acl_tree("/", $cfg->{acl_root}, sub {
+ my ($path, $node) = @_;
+ $paths->{$path} = 1;
+ });
# paths referenced by pool definitions
foreach my $pool (keys %{$cfg->{pools}}) {
my $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;
}
}
}
+# 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) = @_;
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;
} 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";
}
};
}
}
-# 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();
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;
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);
perl -I.. perm-test6.pl
perl -I.. perm-test7.pl
perl -I.. perm-test8.pl
+ perl -I.. realm_sync_test.pl
#!/usr/bin/perl -w
use strict;
+use warnings;
+
use PVE::PTY;
+
use PVE::AccessControl;
my $username = shift;
#!/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 /
#!/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();
#!/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 = {};
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;
'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' => '/',
name => "empty_config",
config => {},
expected_config => {
+ acl_root => default_acls(),
users => { 'root@pam' => { enable => 1 } },
roles => default_roles(),
},
{
name => "default_config",
config => {
+ acl_root => default_acls(),
users => default_users(),
roles => default_roles(),
},
{
name => "group_empty",
config => {
+ acl_root => default_acls(),
users => default_users(),
roles => default_roles(),
groups => default_groups_with([$default_cfg->{'test_group_empty'}]),
{
name => "group_inexisting_member",
config => {
+ acl_root => default_acls(),
users => default_users(),
roles => default_roles(),
groups => default_groups_with([$default_cfg->{'test_group_empty'}]),
{
name => "group_invalid_member",
expected_config => {
+ acl_root => default_acls(),
users => default_users(),
roles => default_roles(),
},
{
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'}]),
{
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'}]),
{
name => "token_simple",
config => {
+ acl_root => default_acls(),
users => default_users_with([$default_cfg->{test_pam_with_token}]),
roles => default_roles(),
},
{
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(),
},
{
name => "custom_role_with_single_priv",
config => {
+ acl_root => default_acls(),
users => default_users(),
roles => default_roles_with([$default_cfg->{test_role_single_priv}]),
},
{
name => "custom_role_with_privs",
config => {
+ acl_root => default_acls(),
users => default_users(),
roles => default_roles_with([$default_cfg->{test_role_privs}]),
},
{
name => "custom_role_with_duplicate_privs",
config => {
+ acl_root => default_acls(),
users => default_users(),
roles => default_roles_with([$default_cfg->{test_role_privs}]),
},
{
name => "custom_role_with_invalid_priv",
config => {
+ acl_root => default_acls(),
users => default_users(),
roles => default_roles_with([$default_cfg->{test_role_privs}]),
},
{
name => "pool_empty",
config => {
+ acl_root => default_acls(),
users => default_users(),
roles => default_roles(),
pools => default_pools_with([$default_cfg->{test_pool_empty}]),
{
name => "pool_invalid",
config => {
+ acl_root => default_acls(),
users => default_users(),
roles => default_roles(),
pools => default_pools_with([$default_cfg->{test_pool_empty}]),
{
name => "pool_members",
config => {
+ acl_root => default_acls(),
users => default_users(),
roles => default_roles(),
pools => default_pools_with([$default_cfg->{test_pool_members}]),
{
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}]),
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".
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".
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".
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".
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".
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".
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".
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".
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".
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".
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},
]),
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},
]),
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".
#!/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');
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');
#!/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');
#!/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');
#!/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');
#!/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');
#!/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');
# 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');
#!/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');
#!/usr/bin/perl -w
use strict;
+use warnings;
+
use PVE::Tools;
+
use PVE::AccessControl;
use PVE::RPCEnvironment;
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', '');
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);
--- /dev/null
+#!/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();
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:
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:
pool:devel:MITS development:500,501,502:store1 store2:
pool:marketing:MITS marketing:600:store1:
+pool:marketing/interns:MITS marketing intern:700:store3:
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:
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