-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.*
+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
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 (>= 0.4.3),
+ libpve-common-perl (>= 8.0.8),
+ libpve-rs-perl (>= 0.8.3),
libpve-u2f-server-perl (>= 1.0-2),
liburi-perl,
libuuid-perl,
pve-cluster (>= 6.1-4),
${misc:Depends},
- ${perl:Depends},
-Breaks: pve-manager (<< 7.0-15),
+ ${perl:Depends}
+Breaks: pve-manager (<< 7.0-15)
Description: Proxmox VE access control library
This package contains the role based user management and access
control function used by Proxmox VE.
-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;
}
}
}
}});
-my sub verify_auth : prototype($$$$$$$) {
- my ($rpcenv, $username, $pw_or_ticket, $otp, $path, $privs, $new_format) = @_;
+my sub verify_auth : prototype($$$$$$) {
+ my ($rpcenv, $username, $pw_or_ticket, $otp, $path, $privs) = @_;
my $normpath = PVE::AccessControl::normalize_path($path);
die "invalid path - $path\n" if defined($path) && !defined($normpath);
$username,
$pw_or_ticket,
$otp,
- $new_format,
);
}
return { username => $username };
};
-my sub create_ticket_do : prototype($$$$$$) {
- my ($rpcenv, $username, $pw_or_ticket, $otp, $new_format, $tfa_challenge) = @_;
+my sub create_ticket_do : prototype($$$$$) {
+ my ($rpcenv, $username, $pw_or_ticket, $otp, $tfa_challenge) = @_;
die "TFA response should be in 'password', not 'otp' when 'tfa-challenge' is set\n"
if defined($otp) && defined($tfa_challenge);
$username,
$pw_or_ticket,
$otp,
- $new_format,
$tfa_challenge,
);
}
my %extra;
my $ticket_data = $username;
my $aad;
- if ($new_format) {
- if (defined($tfa_info)) {
- $extra{NeedTFA} = 1;
- $ticket_data = "!tfa!$tfa_info";
- $aad = $username;
- }
- } elsif (defined($tfa_info)) {
+ if (defined($tfa_info)) {
$extra{NeedTFA} = 1;
- if ($tfa_info->{type} eq 'u2f') {
- my $u2finfo = $tfa_info->{data};
- my $u2f = get_u2f_instance($rpcenv, $u2finfo->@{qw(publicKey keyHandle)});
- my $challenge = $u2f->auth_challenge()
- or die "failed to get u2f challenge\n";
- $challenge = decode_json($challenge);
- $extra{U2FChallenge} = $challenge;
- $ticket_data = "u2f!$username!$challenge->{challenge}";
- } else {
- # General half-login / 'missing 2nd factor' ticket:
- $ticket_data = "tfa!$username";
- }
+ $ticket_data = "!tfa!$tfa_info";
+ $aad = $username;
}
my $ticket = PVE::AccessControl::assemble_ticket($ticket_data, $aad);
},
'new-format' => {
type => 'boolean',
- description =>
- 'With webauthn the format of half-authenticated tickts changed.'
- .' New clients should pass 1 here and not worry about the old format.'
- .' The old format is deprecated and will be retired with PVE-8.0',
+ description => 'This parameter is now ignored and assumed to be 1.',
optional => 1,
- default => 0,
+ default => 1,
},
'tfa-challenge' => {
type => 'string',
if ($param->{path} && $param->{privs}) {
$res = verify_auth($rpcenv, $username, $param->{password}, $param->{otp},
- $param->{path}, $param->{privs}, $param->{'new-format'});
+ $param->{path}, $param->{privs});
} else {
$res = create_ticket_do(
$rpcenv,
$username,
$param->{password},
$param->{otp},
- $param->{'new-format'},
$param->{'tfa-challenge'},
);
}
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
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);
}
}
$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 $type = $ids->{$realm}->{type};
if ($type eq 'ad' || $type eq 'ldap') {
$map_sync_default_options->($param, 1);
}
$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");
--- /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;
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
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;
}
use base qw(PVE::RESTHandler);
-my $OPTIONAL_PASSWORD_SCHEMA = {
- description => "The current password.",
+our $OPTIONAL_PASSWORD_SCHEMA = {
+ description => "The current password of the user performing the change.",
type => 'string',
optional => 1, # Only required if not root@pam
minLength => 5,
},
};
-# Only root may modify root, regular users need to specify their password.
-#
-# Returns the userid returned from `verify_username`.
-# Or ($userid, $realm) in list context.
-my sub root_permission_check : prototype($$$$) {
- my ($rpcenv, $authuser, $userid, $password) = @_;
-
- ($userid, undef, my $realm) = PVE::AccessControl::verify_username($userid);
- $rpcenv->check_user_exist($userid);
-
- raise_perm_exc() if $userid eq 'root@pam' && $authuser ne 'root@pam';
-
- # Regular users need to confirm their password to change TFA settings.
- if ($authuser ne 'root@pam') {
- raise_param_exc({ 'password' => 'password is required to modify TFA data' })
- if !defined($password);
-
- ($authuser, my $auth_username, my $auth_realm) =
- PVE::AccessControl::verify_username($authuser);
-
- my $domain_cfg = cfs_read_file('domains.cfg');
- my $cfg = $domain_cfg->{ids}->{$auth_realm};
- die "auth domain '$auth_realm' does not exist\n" if !$cfg;
- my $plugin = PVE::Auth::Plugin->lookup($cfg->{type});
- $plugin->authenticate_user($cfg, $auth_realm, $auth_username, $password);
- }
-
- return wantarray ? ($userid, $realm) : $userid;
-}
-
# Set TFA to enabled if $tfa_cfg is passed, or to disabled if $tfa_cfg is undef,
# When enabling we also merge the old user.cfg keys into the $tfa_cfg.
my sub set_user_tfa_enabled : prototype($$$) {
}, "enabling TFA for the user failed");
}
-### OLD API
-
-__PACKAGE__->register_method({
- name => 'verify_tfa',
- path => '',
- method => 'POST',
- permissions => { user => 'all' },
- protected => 1, # else we can't access shadow files
- allowtoken => 0, # we don't want tokens to access TFA information
- description => 'Finish a u2f challenge.',
- parameters => {
- additionalProperties => 0,
- properties => {
- response => {
- type => 'string',
- description => 'The response to the current authentication challenge.',
- },
- }
- },
- returns => {
- type => 'object',
- properties => {
- ticket => { type => 'string' },
- # cap
- }
- },
- code => sub {
- my ($param) = @_;
-
- my $rpcenv = PVE::RPCEnvironment::get();
- my $authuser = $rpcenv->get_user();
- my ($username, undef, $realm) = PVE::AccessControl::verify_username($authuser);
-
- my ($tfa_type, $tfa_data) = PVE::AccessControl::user_get_tfa($username, $realm, 0);
- if (!defined($tfa_type)) {
- raise('no u2f data available');
- }
- if ($tfa_type eq 'incompatible') {
- raise('tfa entries incompatible with old login api');
- }
-
- eval {
- if ($tfa_type eq 'u2f') {
- my $challenge = $rpcenv->get_u2f_challenge()
- or raise('no active challenge');
-
- my $keyHandle = $tfa_data->{keyHandle};
- my $publicKey = $tfa_data->{publicKey};
- raise("incomplete u2f setup")
- if !defined($keyHandle) || !defined($publicKey);
-
- my $u2f = PVE::API2::AccessControl::get_u2f_instance($rpcenv, $publicKey, $keyHandle);
- $u2f->set_challenge($challenge);
-
- my ($counter, $present) = $u2f->auth_verify($param->{response});
- # Do we want to do anything with these?
- } else {
- # sanity check before handing off to the verification code:
- my $keys = $tfa_data->{keys} or die "missing tfa keys\n";
- my $config = $tfa_data->{config} or die "bad tfa entry\n";
- PVE::AccessControl::verify_one_time_pw($tfa_type, $authuser, $keys, $config, $param->{response});
- }
- };
- if (my $err = $@) {
- my $clientip = $rpcenv->get_client_ip() || '';
- syslog('err', "authentication verification failure; rhost=$clientip user=$authuser msg=$err");
- die PVE::Exception->new("authentication failure\n", code => 401);
- }
-
- return {
- ticket => PVE::AccessControl::assemble_ticket($authuser),
- cap => $rpcenv->compute_api_permission($authuser),
- }
- }});
-
-### END OLD API
-
__PACKAGE__->register_method ({
name => 'list_user_tfa',
path => '{userid}',
],
},
protected => 1, # else we can't access shadow files
- allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
description => 'List TFA configurations of users.',
parameters => {
additionalProperties => 0,
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) = @_;
],
},
protected => 1, # else we can't access shadow files
- allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
description => 'Fetch a requested TFA entry if present.',
parameters => {
additionalProperties => 0,
code => sub {
my ($param) = @_;
- PVE::AccessControl::assert_new_tfa_config_available();
-
my $rpcenv = PVE::RPCEnvironment::get();
my $authuser = $rpcenv->get_user();
- my $userid =
- root_permission_check($rpcenv, $authuser, $param->{userid}, $param->{password});
+ my $userid = $rpcenv->reauth_user_for_user_modification(
+ $authuser,
+ $param->{userid},
+ $param->{password},
+ );
my $has_entries_left = PVE::AccessControl::lock_tfa_config(sub {
my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
user => 'all',
},
protected => 1, # else we can't access shadow files
- allowtoken => 0, # we don't want tokens to change the regular user's TFA settings
description => 'List TFA configurations of users.',
parameters => {
additionalProperties => 0,
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) = @_;
code => sub {
my ($param) = @_;
- PVE::AccessControl::assert_new_tfa_config_available();
-
my $rpcenv = PVE::RPCEnvironment::get();
my $authuser = $rpcenv->get_user();
- my ($userid, $realm) =
- root_permission_check($rpcenv, $authuser, $param->{userid}, $param->{password});
+ my ($userid, undef, $realm) = $rpcenv->reauth_user_for_user_modification(
+ $authuser,
+ $param->{userid},
+ $param->{password},
+ );
my $type = delete $param->{type};
my $value = delete $param->{value};
});
}});
-sub validate_yubico_otp : prototype($$) {
+sub validate_yubico_otp : prototype($$$) {
my ($userid, $realm, $value) = @_;
my $domain_cfg = cfs_read_file('domains.cfg');
code => sub {
my ($param) = @_;
- PVE::AccessControl::assert_new_tfa_config_available();
-
my $rpcenv = PVE::RPCEnvironment::get();
my $authuser = $rpcenv->get_user();
- my $userid =
- root_permission_check($rpcenv, $authuser, $param->{userid}, $param->{password});
+ my $userid = $rpcenv->reauth_user_for_user_modification(
+ $authuser,
+ $param->{userid},
+ $param->{password},
+ );
PVE::AccessControl::lock_tfa_config(sub {
my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
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;
}
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',
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);
# password should be utf8 encoded
# Note: some plugins delay/sleep if auth fails
-sub authenticate_user : prototype($$$$;$) {
- my ($username, $password, $otp, $new_format, $tfa_challenge) = @_;
+sub authenticate_user : prototype($$$;$) {
+ my ($username, $password, $otp, $tfa_challenge) = @_;
die "no username specified\n" if !$username;
$plugin->authenticate_user($cfg, $realm, $ruid, $password);
- if ($new_format) {
- # This is the first factor with an optional immediate 2nd factor for TOTP:
- my $tfa_challenge = authenticate_2nd_new($username, $realm, $otp, undef);
- return wantarray ? ($username, $tfa_challenge) : $username;
- } else {
- return authenticate_2nd_old($username, $realm, $otp);
- }
-}
-
-sub authenticate_2nd_old : prototype($$$) {
- my ($username, $realm, $otp) = @_;
-
- my ($type, $tfa_data) = user_get_tfa($username, $realm, 0);
- if ($type) {
- if ($type eq 'incompatible') {
- die "old login api disabled, user has incompatible TFA entries\n";
- } elsif ($type eq 'u2f') {
- # Note that if the user did not manage to complete the initial u2f registration
- # challenge we have a hash containing a 'challenge' entry in the user's tfa.cfg entry:
- $tfa_data = undef if exists $tfa_data->{challenge};
- } elsif (!defined($otp)) {
- # The user requires a 2nd factor but has not provided one. Return success but
- # don't clear $tfa_data.
- } else {
- my $keys = $tfa_data->{keys};
- my $tfa_cfg = $tfa_data->{config};
- verify_one_time_pw($type, $username, $keys, $tfa_cfg, $otp);
- $tfa_data = undef;
- }
-
- # Return the type along with the rest:
- if ($tfa_data) {
- $tfa_data = {
- type => $type,
- data => $tfa_data,
- };
- }
- }
-
- return wantarray ? ($username, $tfa_data) : $username;
+ # 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, 1);
+ 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;
}
configure_u2f_and_wa($tfa_cfg);
- my $must_save = 0;
+ my ($result, $tfa_done);
if (defined($tfa_challenge)) {
+ $tfa_done = 1;
$tfa_challenge = verify_ticket($tfa_challenge, 0, $username);
- $must_save = $tfa_cfg->authentication_verify($username, $tfa_challenge, $tfa_response);
+ $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)) {
- $must_save = $tfa_cfg->authentication_verify($username, $tfa_challenge, $tfa_response);
+ $tfa_done = 1;
+ $result = $tfa_cfg->authentication_verify2($username, $tfa_challenge, $tfa_response);
} else {
die "no such challenge\n";
}
}
}
- if ($must_save) {
- cfs_write_file('priv/tfa.cfg', $tfa_cfg);
+ 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;
$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
'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 };
};
|/nodes
|/nodes/[[:alnum:]\.\-\_]+
|/pool
- |/pool/[[:alnum:]\.\-\_]+
+ |/pool/[A-Za-z0-9\.\-_]+(?:/[A-Za-z0-9\.\-_]+){0,2}
|/sdn
+ |/sdn/controllers
+ |/sdn/controllers/[[:alnum:]\_\-]+
+ |/sdn/dns
+ |/sdn/dns/[[:alnum:]]+
+ |/sdn/ipams
+ |/sdn/ipams/[[:alnum:]]+
+ |/sdn/zones
|/sdn/zones/[[:alnum:]\.\-\_]+
- |/sdn/vnets/[[:alnum:]\.\-\_]+
+ |/sdn/zones/[[:alnum:]\.\-\_]+/[[:alnum:]\.\-\_]+
+ |/sdn/zones/[[:alnum:]\.\-\_]+/[[:alnum:]\.\-\_]+/[1-9][0-9]{0,3}
|/storage
|/storage/[[:alnum:]\.\-\_]+
|/vms
|/vms/[1-9][0-9]{2,}
+ |/mapping
+ |/mapping/[[:alnum:]\.\-\_]+
+ |/mapping/[[:alnum:]\.\-\_]+/[[:alnum:]\.\-\_]+
)$!xs;
}
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;
}
sub write_priv_tfa_config {
my ($filename, $cfg) = @_;
- assert_new_tfa_config_available();
-
return $cfg->write();
}
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,
};
-sub assert_new_tfa_config_available() {
- PVE::Cluster::cfs_update();
- my $version_info = PVE::Cluster::get_node_kv('version-info');
- die "cannot update tfa config, please make sure all cluster nodes are up to date\n"
- if !$version_info;
- my $members = PVE::Cluster::get_members() or return; # get_members returns undef on no cluster
- my $old = '';
- foreach my $node (keys $members->%*) {
- my $info = $version_info->{$node};
- if (!$info) {
- $old .= " cluster node '$node' is too old, did not broadcast its version info\n";
- next;
- }
- $info = from_json($info);
- my $ver = $info->{version};
- if ($ver !~ /^(\d+\.\d+)-(\d+)/) {
- $old .= " cluster node '$node' provided an invalid version string: '$ver'\n";
- next;
- }
- my ($maj, $rel) = ($1, $2);
- if (!($maj > 7.0 || ($maj == 7.0 && $rel >= 15))) {
- $old .= " cluster node '$node' is too old ($ver < 7.0-15)\n";
- next;
- }
- }
- die "cannot update tfa config, following nodes are not up to date:\n$old" if length($old);
-}
-
sub user_remove_tfa : prototype($) {
my ($userid) = @_;
- assert_new_tfa_config_available();
-
my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
$tfa_cfg->remove_user($userid);
cfs_write_file('priv/tfa.cfg', $tfa_cfg);
}
sub user_get_tfa : prototype($$$) {
- my ($username, $realm, $new_format) = @_;
+ my ($username, $realm) = @_;
my $user_cfg = cfs_read_file('user.cfg');
my $user = $user_cfg->{users}->{$username}
$realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_tfa)
if $realm_tfa;
- if (!$keys) {
- return if !$realm_tfa;
- die "missing required 2nd keys\n";
- }
-
- if ($new_format) {
- my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
- if (defined($keys) && $keys !~ /^x(?:!.*)$/) {
- add_old_keys_to_realm_tfa($username, $tfa_cfg, $realm_tfa, $keys);
- }
- return ($tfa_cfg, $realm_tfa);
+ my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
+ if (defined($keys) && $keys !~ /^x(?:!.*)$/) {
+ add_old_keys_to_realm_tfa($username, $tfa_cfg, $realm_tfa, $keys);
}
- # new style config starts with an 'x' and optionally contains a !<type> suffix
- if ($keys !~ /^x(?:!.*)?$/) {
- # old style config, find the type via the realm
- return if !$realm_tfa;
- return ($realm_tfa->{type}, {
- keys => $keys,
- config => $realm_tfa,
- });
- } else {
- my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
- my $tfa = $tfa_cfg->{users}->{$username};
- return if !$tfa; # should not happen (user.cfg wasn't cleaned up?)
-
- if ($realm_tfa) {
- # if the realm has a tfa setting we need to verify the type:
- die "auth domain '$realm' and user have mismatching TFA settings\n"
- if $realm_tfa && $realm_tfa->{type} ne $tfa->{type};
- }
-
- return ($tfa->{type}, $tfa->{data});
- }
+ return ($tfa_cfg, $realm_tfa);
}
# bash completion helpers
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;
}
}
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
'acr-values' => {
description => "Specifies the Authentication Context Class Reference values that the"
."Authorization Server is being requested to use for the Auth Request.",
- type => 'string', # format => 'some-safe-id-list', # FIXME: TODO
+ type => 'string',
+ pattern => '^[^\x00-\x1F\x7F <>#"]*$', # Prohibit characters not allowed in URI RFC 2396.
optional => 1,
},
};
# 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) {
my $remove_options = "(?:acl|properties|entry)";
+PVE::JSONSchema::register_standard_option('sync-scope', {
+ description => "Select what to sync.",
+ type => 'string',
+ enum => [qw(users groups both)],
+ optional => '1',
+});
+
+PVE::JSONSchema::register_standard_option('sync-remove-vanished', {
+ description => "A semicolon-seperated list of things to remove when they or the user"
+ ." vanishes during a sync. The following values are possible: 'entry' removes the"
+ ." user/group when not returned from the sync. 'properties' removes the set"
+ ." properties on existing user/group that do not appear in the source (even custom ones)."
+ ." 'acl' removes acls when the user/group is not returned from the sync."
+ ." Instead of a list it also can be 'none' (the default).",
+ type => 'string',
+ default => 'none',
+ typetext => "([acl];[properties];[entry])|none",
+ pattern => "(?:(?:$remove_options\;)*$remove_options)|none",
+ optional => '1',
+});
+
my $realm_sync_options_desc = {
- scope => {
- description => "Select what to sync.",
- type => 'string',
- enum => [qw(users groups both)],
- optional => '1',
- },
- 'remove-vanished' => {
- description => "A semicolon-seperated list of things to remove when they or the user"
- ." vanishes during a sync. The following values are possible: 'entry' removes the"
- ." user/group when not returned from the sync. 'properties' removes the set"
- ." properties on existing user/group that do not appear in the source (even custom ones)."
- ." 'acl' removes acls when the user/group is not returned from the sync.",
- type => 'string',
- typetext => "[acl];[properties];[entry]",
- pattern => "(?:$remove_options\;)*$remove_options",
- optional => '1',
- },
+ scope => get_standard_option('sync-scope'),
+ 'remove-vanished' => get_standard_option('sync-remove-vanished'),
# TODO check/rewrite in pve7to8, and remove with 8.0
full => {
description => "DEPRECATED: use 'remove-vanished' instead. If set, uses the LDAP Directory as source of truth,"
}
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,
});
# 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:
my $userid = extract_param($param, "userid");
my $tfa_id = extract_param($param, "id");
- PVE::AccessControl::assert_new_tfa_config_available();
-
PVE::AccessControl::lock_tfa_config(sub {
my $tfa_cfg = cfs_read_file('priv/tfa.cfg');
if (defined($tfa_id)) {
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'] ],
permissions => [ 'PVE::API2::AccessControl', 'permissions', ['userid'], {}, $print_perm_result, $PVE::RESTHandler::standard_output_options],
tfa => {
delete => [ __PACKAGE__, 'delete_tfa', ['userid'] ],
+ list => [ __PACKAGE__, 'list_tfa', ['userid'] ],
+ unlock => [ 'PVE::API2::User', 'unlock_tfa', ['userid'] ],
},
token => {
add => [ 'PVE::API2::User', 'generate_token', ['userid', 'tokenid'], {}, $print_api_result, $PVE::RESTHandler::standard_output_options ],
--- /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;
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;
'/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}}) {
}
}
+# 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) = @_;
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);
#!/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!token2', '/vms/200', 'customer');
# check intersection -> token has Administrator, but user only vm_admin
-check_permission('max@pve!token2', '/vms/300', 'Permissions.Modify,VM.Allocate,VM.Audit,VM.Console,VM.PowerMgmt');
+check_permission('max@pve!token2', '/vms/300', 'VM.Allocate,VM.Audit,VM.Console,VM.PowerMgmt');
print "all tests passed\n";
'group1-syncedrealm' => { users => {}, },
'group2-syncedrealm' => { users => {}, },
},
- acl => {
- '/' => {
- users => {
- 'user3@syncedrealm' => {},
- },
- groups => {},
+ acl_root => {
+ users => {
+ 'user3@syncedrealm' => {},
},
+ groups => {},
},
};
'group2-syncedrealm' => { users => {}, },
'group3-syncedrealm' => { users => {}, },
},
- acl => {
- '/' => {
- users => {
- 'user3@syncedrealm' => {},
- },
- groups => {},
+ acl_root => {
+ users => {
+ 'user3@syncedrealm' => {},
},
+ groups => {},
},
},
],
},
'group3-syncedrealm' => { users => {}, }
},
- acl => {
- '/' => {
- users => {
- 'user3@syncedrealm' => {},
- },
- groups => {},
+ acl_root => {
+ users => {
+ 'user3@syncedrealm' => {},
},
+ groups => {},
},
},
],
'group2-syncedrealm' => { users => {}, },
'group3-syncedrealm' => { users => {}, },
},
- acl => {
- '/' => {
- users => {},
- groups => {},
- },
+ acl_root => {
+ users => {},
+ groups => {},
},
},
],
},
'group3-syncedrealm' => { users => {}, },
},
- acl => {
- '/' => {
- users => {},
- groups => {},
- },
+ acl_root => {
+ users => {},
+ groups => {},
},
},
],
},
'group3-syncedrealm' => { users => {}, },
},
- acl => {
- '/' => {
- users => {},
- groups => {},
- },
+ acl_root => {
+ users => {},
+ groups => {},
},
},
],
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: