├── debian ├── docs ├── source │ └── format ├── triggers ├── rules ├── copyright └── control ├── src ├── pveum ├── PVE │ ├── API2 │ │ ├── Jobs │ │ │ ├── Makefile │ │ │ └── RealmSync.pm │ │ ├── Makefile │ │ ├── Role.pm │ │ ├── Group.pm │ │ ├── ACL.pm │ │ ├── OpenId.pm │ │ ├── TFA.pm │ │ └── AccessControl.pm │ ├── Jobs │ │ ├── Makefile │ │ └── RealmSync.pm │ ├── CLI │ │ ├── Makefile │ │ └── pveum.pm │ ├── Auth │ │ ├── Makefile │ │ ├── PAM.pm │ │ ├── PVE.pm │ │ ├── OpenId.pm │ │ ├── AD.pm │ │ ├── Plugin.pm │ │ └── LDAP.pm │ ├── Makefile │ └── TokenConfig.pm ├── test │ ├── test2.cfg │ ├── test3.cfg │ ├── test4.cfg │ ├── dump-users.pl │ ├── api-tests.pl │ ├── test7.cfg │ ├── Makefile │ ├── auth-test.pl │ ├── test5.cfg │ ├── api-get-permissions-test.cfg │ ├── test1.cfg │ ├── test6.cfg │ ├── dump-perm.pl │ ├── perm-test4.pl │ ├── perm-test3.pl │ ├── test8.cfg │ ├── perm-test2.pl │ ├── perm-test5.pl │ ├── perm-test7.pl │ ├── perm-test8.pl │ ├── perm-test1.pl │ ├── perm-test6.pl │ ├── api-get-permissions-test.pl │ └── realm_sync_test.pl ├── oathkeygen └── Makefile ├── .gitignore ├── user.cfg.ex ├── Makefile └── README /debian/docs: -------------------------------------------------------------------------------- 1 | debian/SOURCE 2 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /debian/triggers: -------------------------------------------------------------------------------- 1 | activate-noawait pve-api-updates 2 | -------------------------------------------------------------------------------- /src/pveum: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use PVE::CLI::pveum; 7 | 8 | PVE::CLI::pveum->run_cli_handler(); 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | *.deb 3 | *.1.pod 4 | *.1.gz 5 | /libpve-access-control-[0-9]*/ 6 | /libpve-access-control_[0-9] 7 | *.buildinfo 8 | *.build 9 | *.changes 10 | *.dsc 11 | *.tar.xz 12 | -------------------------------------------------------------------------------- /src/PVE/API2/Jobs/Makefile: -------------------------------------------------------------------------------- 1 | SOURCES = \ 2 | RealmSync.pm \ 3 | 4 | .PHONY: install 5 | install: 6 | for i in $(SOURCES); do install -D -m 0644 $$i $(DESTDIR)$(PERLDIR)/PVE/API2/Jobs/$$i; done 7 | -------------------------------------------------------------------------------- /user.cfg.ex: -------------------------------------------------------------------------------- 1 | user:joe@localhost:1: 2 | 3 | group:testgroup:joe@localhost: 4 | 5 | role:admin:VM.ConfigureCD,VM.Create,Permissions.Modify,VM.Console: 6 | 7 | acl:0:/users:@testgroup,joe@localhost:Administrator: 8 | 9 | -------------------------------------------------------------------------------- /src/PVE/Jobs/Makefile: -------------------------------------------------------------------------------- 1 | SOURCES=RealmSync.pm 2 | 3 | .PHONY: install 4 | install: ${SOURCES} 5 | install -d -m 0755 ${DESTDIR}${PERLDIR}/PVE/Jobs 6 | for i in ${SOURCES}; do install -D -m 0644 $$i ${DESTDIR}${PERLDIR}/PVE/Jobs/$$i; done 7 | -------------------------------------------------------------------------------- /src/test/test2.cfg: -------------------------------------------------------------------------------- 1 | user:User1@pve:1: 2 | user:User2@pve:1: 3 | 4 | group:GroupA:User1@pve: 5 | group:GroupB:User1@pve: 6 | 7 | role:Role1:VM.PowerMgmt: 8 | role:Role2:VM.Console: 9 | 10 | acl:1:/vms:@GroupA:Role1: 11 | acl:1:/vms:@GroupB:Role2: 12 | -------------------------------------------------------------------------------- /src/PVE/CLI/Makefile: -------------------------------------------------------------------------------- 1 | SOURCES=pveum.pm 2 | 3 | .PHONY: install 4 | install: $(SOURCES) 5 | install -d -m 0755 $(DESTDIR)$(PERLDIR)/PVE/CLI 6 | for i in $(SOURCES); do install -D -m 0644 $$i $(DESTDIR)$(PERLDIR)/PVE/CLI/$$i; done 7 | 8 | 9 | clean: 10 | -------------------------------------------------------------------------------- /src/test/test3.cfg: -------------------------------------------------------------------------------- 1 | user:User1@pve:1: 2 | user:User2@pve:1: 3 | 4 | group:GroupA:User1@pve: 5 | group:GroupB:User1@pve: 6 | 7 | role:Role1:VM.PowerMgmt: 8 | role:Role2:VM.Console: 9 | 10 | acl:1:/vms:@GroupA:Role1: 11 | acl:1:/vms/200:@GroupB:Role2: 12 | -------------------------------------------------------------------------------- /src/test/test4.cfg: -------------------------------------------------------------------------------- 1 | user:User1@pve:1: 2 | user:User2@pve:1: 3 | 4 | group:GroupA:User1@pve,User2@pve: 5 | group:GroupB:User1@pve,User2@pve: 6 | 7 | role:Role1:VM.PowerMgmt: 8 | role:Role2:VM.Console: 9 | 10 | acl:1:/vms:@GroupA:Role1: 11 | acl:1:/vms:User2@pve:NoAccess: 12 | -------------------------------------------------------------------------------- /src/test/dump-users.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Data::Dumper; 7 | 8 | use PVE::AccessControl; 9 | 10 | my $cfg; 11 | 12 | $cfg = PVE::AccessControl::load_user_config(); 13 | 14 | print Dumper($cfg) . "\n"; 15 | 16 | exit(0); 17 | -------------------------------------------------------------------------------- /src/PVE/Auth/Makefile: -------------------------------------------------------------------------------- 1 | 2 | AUTH_SOURCES= \ 3 | Plugin.pm \ 4 | PVE.pm \ 5 | PAM.pm \ 6 | AD.pm \ 7 | LDAP.pm \ 8 | OpenId.pm 9 | 10 | .PHONY: install 11 | install: 12 | for i in $(AUTH_SOURCES); do install -D -m 0644 $$i $(DESTDIR)$(PERLDIR)/PVE/Auth/$$i; done 13 | -------------------------------------------------------------------------------- /src/test/api-tests.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use TAP::Harness; 7 | 8 | my $harness = TAP::Harness->new({ verbosity => -1 }); 9 | 10 | my $result = $harness->runtests('api-get-permissions-test.pl'); 11 | 12 | exit -1 if $result->{failed}; 13 | -------------------------------------------------------------------------------- /src/test/test7.cfg: -------------------------------------------------------------------------------- 1 | user:User1@pve:1: 2 | user:User2@pve:1: 3 | 4 | group:GroupA:User1@pve,User2@pve: 5 | group:GroupB:User1@pve,User2@pve: 6 | 7 | role:Role1:VM.PowerMgmt: 8 | role:Role2:VM.Console: 9 | role:Role3:VM.Console: 10 | 11 | acl:1:/pool/devel:User1@pve:NoAccess: 12 | 13 | acl:1:/vms:User1@pve:Role1: 14 | 15 | pool:devel:Development:100:store1: 16 | -------------------------------------------------------------------------------- /src/test/Makefile: -------------------------------------------------------------------------------- 1 | 2 | all: 3 | 4 | .PHONY: check 5 | check: 6 | perl -I.. parser_writer.pl 7 | perl -I.. perm-test1.pl 8 | perl -I.. perm-test2.pl 9 | perl -I.. perm-test3.pl 10 | perl -I.. perm-test4.pl 11 | perl -I.. perm-test5.pl 12 | perl -I.. perm-test6.pl 13 | perl -I.. perm-test7.pl 14 | perl -I.. perm-test8.pl 15 | perl -I.. realm_sync_test.pl 16 | perl -I.. api-tests.pl 17 | -------------------------------------------------------------------------------- /src/PVE/Makefile: -------------------------------------------------------------------------------- 1 | 2 | 3 | .PHONY: install 4 | install: 5 | make -C Auth install 6 | install -D -m 0644 AccessControl.pm $(DESTDIR)$(PERLDIR)/PVE/AccessControl.pm 7 | install -D -m 0644 RPCEnvironment.pm $(DESTDIR)$(PERLDIR)/PVE/RPCEnvironment.pm 8 | install -D -m 0644 TokenConfig.pm $(DESTDIR)$(PERLDIR)/PVE/TokenConfig.pm 9 | make -C API2 install 10 | make -C CLI install 11 | make -C Jobs install 12 | -------------------------------------------------------------------------------- /src/test/auth-test.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use PVE::PTY; 7 | 8 | use PVE::AccessControl; 9 | 10 | my $username = shift; 11 | die "Username missing" if !$username; 12 | 13 | my $password = PVE::PTY::read_password('password: '); 14 | PVE::AccessControl::authenticate_user($username, $password); 15 | 16 | print "Authentication Successful!!\n"; 17 | 18 | exit(0); 19 | -------------------------------------------------------------------------------- /src/PVE/API2/Makefile: -------------------------------------------------------------------------------- 1 | 2 | API2_SOURCES= \ 3 | AccessControl.pm \ 4 | Domains.pm \ 5 | ACL.pm \ 6 | Role.pm \ 7 | Group.pm \ 8 | User.pm \ 9 | TFA.pm \ 10 | OpenId.pm 11 | 12 | SUBDIRS = Jobs 13 | 14 | .PHONY: install 15 | install: 16 | for i in $(API2_SOURCES); do install -D -m 0644 $$i $(DESTDIR)$(PERLDIR)/PVE/API2/$$i; done 17 | set -e && for i in $(SUBDIRS); do $(MAKE) -C $$i $@; done 18 | -------------------------------------------------------------------------------- /src/test/test5.cfg: -------------------------------------------------------------------------------- 1 | user:User1@pve:1: 2 | user:User2@pve:1: 3 | 4 | group:GroupA:User1@pve,User2@pve: 5 | group:GroupB:User1@pve,User2@pve: 6 | 7 | role:Role1:VM.PowerMgmt: 8 | role:Role2:VM.Console: 9 | role:Role3:VM.Console: 10 | 11 | acl:1:/vms:User1@pve:Role1: 12 | acl:1:/vms/100/a/b:User1@pve:Role2: 13 | 14 | acl:0:/kvm:User2@pve:Role2: 15 | acl:0:/kvm/vms:User2@pve:Role1: 16 | acl:0:/kvm/vms/100/a:User2@pve:Role3: 17 | -------------------------------------------------------------------------------- /src/oathkeygen: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use MIME::Base32; # libmime-base32-perl 7 | 8 | open(my $RND_FH, '<', "/dev/urandom") or die "Unable to open '/dev/urandom' - $!"; 9 | sysread($RND_FH, my $random_data, 10) == 10 or die "read random data failed - $!\n"; 10 | close $RND_FH or warn "Unable to close '/dev/urandom' - $!"; 11 | 12 | print MIME::Base32::encode_rfc3548($random_data) . "\n"; 13 | 14 | exit(0); 15 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | # -*- makefile -*- 3 | # Sample debian/rules that uses debhelper. 4 | # This file was originally written by Joey Hess and Craig Small. 5 | # As a special exception, when this file is copied by dh-make into a 6 | # dh-make output file, you may use that output file without restriction. 7 | # This special exception was added by Craig Small in version 0.37 of dh-make. 8 | 9 | # Uncomment this to turn on verbose mode. 10 | #export DH_VERBOSE=1 11 | 12 | %: 13 | dh $@ 14 | 15 | override_dh_missing: 16 | dh_missing --fail-missing 17 | -------------------------------------------------------------------------------- /src/test/api-get-permissions-test.cfg: -------------------------------------------------------------------------------- 1 | user:auditor@pam:1: 2 | token:auditor@pam!noprivsep::0: 3 | token:auditor@pam!privsep::1: 4 | user:stranger@pve:1: 5 | token:stranger@pve!noprivsep::0: 6 | token:stranger@pve!privsep::1: 7 | 8 | role:sys_auditor:Sys.Audit: 9 | role:vm_lurker:VM.Audit,VM.Console: 10 | role:vm_manager:VM.PowerMgmt: 11 | 12 | acl:1:/access:auditor@pam:sys_auditor: 13 | acl:1:/vms:auditor@pam:vm_lurker: 14 | acl:1:/vms:auditor@pam!privsep:vm_lurker: 15 | acl:1:/vms:stranger@pve:vm_lurker: 16 | acl:1:/vms:stranger@pve:vm_manager: 17 | acl:1:/vms:stranger@pve!privsep:vm_lurker: 18 | -------------------------------------------------------------------------------- /src/test/test1.cfg: -------------------------------------------------------------------------------- 1 | user:joe@pve:1: 2 | user:max@pve:1: 3 | user:alex@pve:1: 4 | user:sue@pve:1: 5 | user:carol@pam:1: 6 | 7 | group:testgroup1:joe@pve,max@pve,sue@pve: 8 | group:testgroup2:alex@pve,carol@pam,sue@pve: 9 | group:testgroup3:max@pve: 10 | 11 | role:storage_manager:Datastore.AllocateSpace,Datastore.Audit: 12 | role:customer:VM.Audit,VM.PowerMgmt: 13 | role:vm_admin:VM.Audit,VM.Allocate,Permissions.Modify,VM.Console: 14 | 15 | acl:1:/vms:@testgroup1:vm_admin: 16 | acl:1:/vms/100/:alex@pve,max@pve:customer: 17 | acl:1:/storage/nfs1:@testgroup2:storage_manager: 18 | acl:1:/users:max@pve:Administrator: 19 | 20 | acl:1:/vms/200:@testgroup3:storage_manager: 21 | acl:1:/vms/200:@testgroup2:NoAccess: 22 | acl:1:/vms/300:alex@pve:PVEVMAdmin: 23 | acl:1:/vms/400:alex@pve:Administrator: 24 | 25 | -------------------------------------------------------------------------------- /src/test/test6.cfg: -------------------------------------------------------------------------------- 1 | user:User1@pve:1: 2 | user:User2@pve:1: 3 | user:User3@pve:1: 4 | user:User4@pve:1: 5 | user:intern@pve:1: 6 | 7 | group:DEVEL:User1@pve,User2@pve,User3@pve: 8 | group:MARKETING:User1@pve,User4@pve: 9 | group:INTERNS:intern@pve: 10 | 11 | role:RoleDEVEL:VM.PowerMgmt: 12 | role:RoleMARKETING:VM.Console: 13 | role:RoleINTERN:VM.Audit: 14 | role:RoleTEST1:VM.Console: 15 | 16 | acl:1:/pool/devel:@DEVEL:RoleDEVEL: 17 | acl:1:/pool/marketing:@MARKETING:RoleMARKETING: 18 | acl:1:/pool/marketing/interns:@INTERNS:RoleINTERN: 19 | 20 | acl:1:/vms:@DEVEL:RoleTEST1: 21 | acl:1:/vms:User3@pve:NoAccess: 22 | acl:1:/vms/300:@MARKETING:RoleTEST1: 23 | 24 | pool:devel:MITS development:500,501,502:store1 store2: 25 | pool:marketing:MITS marketing:600:store1: 26 | pool:marketing/interns:MITS marketing intern:700:store3: 27 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Copyright (C) 2010 Proxmox Server Solutions GmbH 2 | 3 | This software is written by Proxmox Server Solutions GmbH 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU Affero General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU Affero General Public License for more details. 14 | 15 | You should have received a copy of the GNU Affero General Public License 16 | along with this program. If not, see . 17 | -------------------------------------------------------------------------------- /src/test/dump-perm.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Data::Dumper; 7 | use Getopt::Long; 8 | 9 | use PVE::RPCEnvironment; 10 | 11 | # example: 12 | # dump-perm.pl -f myuser.cfg root / 13 | 14 | my $opt_file; 15 | if (!GetOptions("file=s" => \$opt_file)) { 16 | exit(-1); 17 | } 18 | 19 | my $username = shift; 20 | my $path = shift; 21 | 22 | if (!($username && $path)) { 23 | print "usage: $0 \n"; 24 | exit(-1); 25 | } 26 | 27 | my $cfg; 28 | 29 | my $rpcenv = PVE::RPCEnvironment->init('cli'); 30 | if ($opt_file) { 31 | $rpcenv->init_request(userconfig => $opt_file); 32 | } else { 33 | $rpcenv->init_request(); 34 | } 35 | 36 | my $perm = $rpcenv->permissions($username, $path); 37 | 38 | print "permission for user '$username' on '$path':\n"; 39 | print join(',', keys %$perm) . "\n"; 40 | 41 | exit(0); 42 | -------------------------------------------------------------------------------- /src/test/perm-test4.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Getopt::Long; 7 | 8 | use PVE::Tools; 9 | 10 | use PVE::AccessControl; 11 | use PVE::RPCEnvironment; 12 | 13 | my $rpcenv = PVE::RPCEnvironment->init('cli'); 14 | 15 | my $cfgfn = "test4.cfg"; 16 | $rpcenv->init_request(userconfig => $cfgfn); 17 | 18 | sub check_roles { 19 | my ($user, $path, $expected_result) = @_; 20 | 21 | my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path); 22 | my $res = join(',', sort keys %$roles); 23 | 24 | die "unexpected result\nneed '${expected_result}'\ngot '$res'\n" 25 | if $res ne $expected_result; 26 | 27 | print "ROLES:$path:$user:$res\n"; 28 | } 29 | 30 | check_roles('User1@pve', '/vms/300', 'Role1'); 31 | check_roles('User2@pve', '/vms/300', 'NoAccess'); 32 | 33 | print "all tests passed\n"; 34 | 35 | exit(0); 36 | -------------------------------------------------------------------------------- /src/test/perm-test3.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Getopt::Long; 7 | 8 | use PVE::Tools; 9 | 10 | use PVE::AccessControl; 11 | use PVE::RPCEnvironment; 12 | 13 | my $rpcenv = PVE::RPCEnvironment->init('cli'); 14 | 15 | my $cfgfn = "test3.cfg"; 16 | $rpcenv->init_request(userconfig => $cfgfn); 17 | 18 | sub check_roles { 19 | my ($user, $path, $expected_result) = @_; 20 | 21 | my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path); 22 | my $res = join(',', sort keys %$roles); 23 | 24 | die "unexpected result\nneed '${expected_result}'\ngot '$res'\n" 25 | if $res ne $expected_result; 26 | 27 | print "ROLES:$path:$user:$res\n"; 28 | } 29 | 30 | check_roles('User1@pve', '', ''); 31 | check_roles('User2@pve', '', ''); 32 | 33 | check_roles('User1@pve', '/vms/300', 'Role1'); 34 | check_roles('User1@pve', '/vms/200', 'Role2'); 35 | 36 | print "all tests passed\n"; 37 | 38 | exit(0); 39 | -------------------------------------------------------------------------------- /src/test/test8.cfg: -------------------------------------------------------------------------------- 1 | user:joe@pve:1: 2 | user:max@pve:1: 3 | token:max@pve!token::0: 4 | token:max@pve!token2::1: 5 | user:alex@pve:1: 6 | user:sue@pve:1: 7 | user:carol@pam:1: 8 | token:carol@pam!token: 9 | 10 | group:testgroup1:joe@pve,max@pve,sue@pve: 11 | group:testgroup2:alex@pve,carol@pam,sue@pve: 12 | group:testgroup3:max@pve: 13 | 14 | role:storage_manager:Datastore.AllocateSpace,Datastore.Audit: 15 | role:customer:VM.Audit,VM.PowerMgmt: 16 | role:vm_admin:VM.Audit,VM.Allocate,VM.Console: 17 | 18 | acl:1:/vms:@testgroup1:vm_admin: 19 | acl:0:/vms/300:max@pve:customer: 20 | acl:1:/vms/300:max@pve:vm_admin: 21 | acl:1:/vms/100/:alex@pve,max@pve:customer: 22 | acl:1:/storage/nfs1:@testgroup2:storage_manager: 23 | acl:1:/users:max@pve:Administrator: 24 | 25 | acl:1:/vms/200:@testgroup3:storage_manager: 26 | acl:1:/vms/200:@testgroup2:NoAccess: 27 | 28 | acl:1:/vms/200:carol@pam!token:vm_admin 29 | acl:1:/vms/200:max@pve!token:storage_manager 30 | acl:1:/vms/200:max@pve!token2:customer 31 | 32 | acl:1:/vms/300:max@pve!token2:Administrator 33 | -------------------------------------------------------------------------------- /src/test/perm-test2.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Getopt::Long; 7 | 8 | use PVE::Tools; 9 | 10 | use PVE::AccessControl; 11 | use PVE::RPCEnvironment; 12 | 13 | my $rpcenv = PVE::RPCEnvironment->init('cli'); 14 | 15 | my $cfgfn = "test2.cfg"; 16 | $rpcenv->init_request(userconfig => $cfgfn); 17 | 18 | sub check_roles { 19 | my ($user, $path, $expected_result) = @_; 20 | 21 | my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path); 22 | my $res = join(',', sort keys %$roles); 23 | 24 | die "unexpected result\nneed '${expected_result}'\ngot '$res'\n" 25 | if $res ne $expected_result; 26 | 27 | print "ROLES:$path:$user:$res\n"; 28 | } 29 | 30 | # inherit multiple group permissions 31 | 32 | check_roles('User1@pve', '/', ''); 33 | check_roles('User2@pve', '/', ''); 34 | 35 | check_roles('User1@pve', '/vms', 'Role1,Role2'); 36 | check_roles('User2@pve', '/vms', ''); 37 | 38 | check_roles('User1@pve', '/vms/100', 'Role1,Role2'); 39 | check_roles('User2@pve', '/vms', ''); 40 | 41 | print "all tests passed\n"; 42 | 43 | exit(0); 44 | -------------------------------------------------------------------------------- /src/test/perm-test5.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Getopt::Long; 7 | 8 | use PVE::Tools; 9 | 10 | use PVE::AccessControl; 11 | use PVE::RPCEnvironment; 12 | 13 | my $rpcenv = PVE::RPCEnvironment->init('cli'); 14 | 15 | my $cfgfn = "test5.cfg"; 16 | $rpcenv->init_request(userconfig => $cfgfn); 17 | 18 | sub check_roles { 19 | my ($user, $path, $expected_result) = @_; 20 | 21 | my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path); 22 | my $res = join(',', sort keys %$roles); 23 | 24 | die "unexpected result\nneed '${expected_result}'\ngot '$res'\n" 25 | if $res ne $expected_result; 26 | 27 | print "ROLES:$path:$user:$res\n"; 28 | } 29 | 30 | check_roles('User1@pve', '/vms', 'Role1'); 31 | check_roles('User1@pve', '/vms/100', 'Role1'); 32 | check_roles('User1@pve', '/vms/100/a', 'Role1'); 33 | check_roles('User1@pve', '/vms/100/a/b', 'Role2'); 34 | check_roles('User1@pve', '/vms/100/a/b/c', 'Role2'); 35 | check_roles('User1@pve', '/vms/200', 'Role1'); 36 | 37 | check_roles('User2@pve', '/kvm', 'Role2'); 38 | check_roles('User2@pve', '/kvm/vms', 'Role1'); 39 | check_roles('User2@pve', '/kvm/vms/100', ''); 40 | check_roles('User2@pve', '/kvm/vms/100/a', 'Role3'); 41 | check_roles('User2@pve', '/kvm/vms/100/a/b', ''); 42 | 43 | print "all tests passed\n"; 44 | 45 | exit(0); 46 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | include /usr/share/dpkg/default.mk 2 | 3 | PACKAGE=libpve-access-control 4 | 5 | DEB=$(PACKAGE)_$(DEB_VERSION_UPSTREAM_REVISION)_all.deb 6 | DSC=$(PACKAGE)_$(DEB_VERSION_UPSTREAM_REVISION).dsc 7 | 8 | BUILDDIR ?= $(PACKAGE)-$(DEB_VERSION_UPSTREAM) 9 | 10 | GITVERSION:=$(shell git rev-parse HEAD) 11 | 12 | all: 13 | 14 | .PHONY: dinstall 15 | dinstall: deb 16 | dpkg -i $(DEB) 17 | 18 | .PHONY: tidy 19 | tidy: 20 | git ls-files ':*.p[ml]'| xargs -n4 -P0 proxmox-perltidy 21 | 22 | $(BUILDDIR): 23 | rm -rf $(BUILDDIR) 24 | cp -a src $(BUILDDIR) 25 | cp -a debian $(BUILDDIR)/ 26 | echo "git clone git://git.proxmox.com/git/pve-access-control.git\\ngit checkout $(GITVERSION)" > $(BUILDDIR)/debian/SOURCE 27 | 28 | .PHONY: deb 29 | deb: $(DEB) 30 | $(DEB): $(BUILDDIR) 31 | cd $(BUILDDIR); dpkg-buildpackage -b -us -uc 32 | lintian $(DEB) 33 | 34 | .PHONY: dsc 35 | dsc: $(DSC) 36 | $(DSC): $(BUILDDIR) 37 | cd $(BUILDDIR); dpkg-buildpackage -S -us -uc -d 38 | lintian $(DSC) 39 | 40 | sbuild: $(DSC) 41 | sbuild $(DSC) 42 | 43 | .PHONY: upload 44 | upload: UPLOAD_DIST ?= $(DEB_DISTRIBUTION) 45 | upload: $(DEB) 46 | tar cf - $(DEB) | ssh repoman@repo.proxmox.com -- upload --product pve --dist $(UPLOAD_DIST) 47 | 48 | .PHONY: clean distclean 49 | distclean: clean 50 | clean: 51 | rm -rf $(PACKAGE)-[0-9]*/ 52 | rm -f *.dsc *.deb *.buildinfo *.build *.changes $(PACKAGE)*.tar.* 53 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: libpve-access-control 2 | Section: perl 3 | Priority: optional 4 | Maintainer: Proxmox Support Team 5 | Build-Depends: debhelper-compat (= 13), 6 | libanyevent-perl, 7 | libauthen-pam-perl, 8 | libnet-ldap-perl, 9 | libpve-cluster-perl, 10 | libpve-common-perl (>= 8.0.8), 11 | libpve-rs-perl, 12 | libtest-mockmodule-perl, 13 | liburi-perl, 14 | libuuid-perl, 15 | lintian, 16 | perl, 17 | pve-cluster (>= 6.1-4), 18 | pve-doc-generator (>= 5.3-3) 19 | Standards-Version: 4.6.2 20 | Homepage: https://www.proxmox.com 21 | 22 | Package: libpve-access-control 23 | Architecture: all 24 | Depends: libauthen-pam-perl, 25 | libcrypt-openssl-random-perl, 26 | libcrypt-openssl-rsa-perl, 27 | libjson-perl, 28 | libjson-xs-perl, 29 | libmime-base32-perl, 30 | libnet-ldap-perl, 31 | libnet-ssleay-perl, 32 | libpve-cluster-perl, 33 | libpve-common-perl (>= 8.0.8), 34 | libpve-rs-perl (>= 0.9.3), 35 | liburi-perl, 36 | libuuid-perl, 37 | pve-cluster (>= 6.1-4), 38 | ${misc:Depends}, 39 | ${perl:Depends} 40 | Breaks: pve-manager (<< 7.0-15) 41 | Description: Proxmox VE access control library 42 | This package contains the role based user management and access 43 | control function used by Proxmox VE. 44 | -------------------------------------------------------------------------------- /src/test/perm-test7.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Getopt::Long; 7 | 8 | use PVE::Tools; 9 | 10 | use PVE::AccessControl; 11 | use PVE::RPCEnvironment; 12 | 13 | my $rpcenv = PVE::RPCEnvironment->init('cli'); 14 | 15 | my $cfgfn = "test7.cfg"; 16 | $rpcenv->init_request(userconfig => $cfgfn); 17 | 18 | sub check_roles { 19 | my ($user, $path, $expected_result) = @_; 20 | 21 | my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path); 22 | my $res = join(',', sort keys %$roles); 23 | 24 | die "unexpected result\nneed '${expected_result}'\ngot '$res'\n" 25 | if $res ne $expected_result; 26 | 27 | print "ROLES:$path:$user:$res\n"; 28 | } 29 | 30 | sub check_permissions { 31 | my ($user, $path, $expected_result) = @_; 32 | 33 | my $perm = $rpcenv->permissions($user, $path); 34 | my $res = join(',', sort keys %$perm); 35 | 36 | die "unexpected result\nneed '${expected_result}'\ngot '$res'\n" 37 | if $res ne $expected_result; 38 | 39 | $perm = $rpcenv->permissions($user, $path); 40 | $res = join(',', sort keys %$perm); 41 | die "unexpected result (compiled)\nneed '${expected_result}'\ngot '$res'\n" 42 | if $res ne $expected_result; 43 | 44 | print "PERM:$path:$user:$res\n"; 45 | } 46 | 47 | check_roles('User1@pve', '/vms', 'Role1'); 48 | check_roles('User1@pve', '/vms/200', 'Role1'); 49 | 50 | # no pool 51 | check_roles('User1@pve', '/vms/100', 'Role1'); 52 | # with pool 53 | check_permissions('User1@pve', '/vms/100', ''); 54 | 55 | print "all tests passed\n"; 56 | 57 | exit(0); 58 | -------------------------------------------------------------------------------- /src/Makefile: -------------------------------------------------------------------------------- 1 | PACKAGE ?= libpve-access-control 2 | 3 | DESTDIR= 4 | PREFIX=/usr 5 | BINDIR=$(PREFIX)/bin 6 | SBINDIR=$(PREFIX)/sbin 7 | MANDIR=$(PREFIX)/share/man 8 | DOCDIR=$(PREFIX)/share/doc/$(PACKAGE) 9 | MAN1DIR=$(MANDIR)/man1/ 10 | BASHCOMPLDIR=$(PREFIX)/share/bash-completion/completions/ 11 | ZSHCOMPLDIR=$(PREFIX)/share/zsh/vendor-completions/ 12 | 13 | export PERLDIR=$(PREFIX)/share/perl5 14 | -include /usr/share/pve-doc-generator/pve-doc-generator.mk 15 | 16 | all: 17 | 18 | pveum.bash-completion: PVE/CLI/pveum.pm 19 | perl -I. -T -e "use PVE::CLI::pveum; PVE::CLI::pveum->generate_bash_completions();" >$@.tmp 20 | mv $@.tmp $@ 21 | 22 | pveum.zsh-completion: PVE/CLI/pveum.pm 23 | perl -I. -T -e "use PVE::CLI::pveum; PVE::CLI::pveum->generate_zsh_completions();" >$@.tmp 24 | mv $@.tmp $@ 25 | 26 | .PHONY: install 27 | install: pveum.1 oathkeygen pveum.bash-completion pveum.zsh-completion 28 | install -d $(DESTDIR)$(BINDIR) 29 | install -d $(DESTDIR)$(SBINDIR) 30 | install -m 0755 pveum $(DESTDIR)$(SBINDIR) 31 | install -m 0755 oathkeygen $(DESTDIR)$(BINDIR) 32 | make -C PVE install 33 | install -d $(DESTDIR)/$(MAN1DIR) 34 | install -d $(DESTDIR)/$(DOCDIR) 35 | install -m 0644 pveum.1 $(DESTDIR)/$(MAN1DIR) 36 | install -m 0644 -D pveum.bash-completion $(DESTDIR)$(BASHCOMPLDIR)/pveum 37 | install -m 0644 -D pveum.zsh-completion $(DESTDIR)$(ZSHCOMPLDIR)/_pveum 38 | 39 | .PHONY: test 40 | test: 41 | perl -I. ./pveum verifyapi 42 | perl -I. -T -e "use PVE::CLI::pveum; PVE::CLI::pveum->verify_api();" 43 | make -C test check 44 | 45 | .PHONY: clean distclean 46 | distclean: clean 47 | clean: 48 | rm -f *.xml.tmp *.1 *.5 *.8 *{synopsis,opts}.adoc docinfo.xml 49 | -------------------------------------------------------------------------------- /src/PVE/Auth/PAM.pm: -------------------------------------------------------------------------------- 1 | package PVE::Auth::PAM; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use PVE::Tools qw(run_command); 7 | use PVE::Auth::Plugin; 8 | use Authen::PAM qw(:constants); 9 | 10 | use base qw(PVE::Auth::Plugin); 11 | 12 | sub type { 13 | return 'pam'; 14 | } 15 | 16 | sub options { 17 | return { 18 | default => { optional => 1 }, 19 | comment => { optional => 1 }, 20 | tfa => { optional => 1 }, 21 | }; 22 | } 23 | 24 | sub authenticate_user { 25 | my ($class, $config, $realm, $username, $password) = @_; 26 | 27 | # user (www-data) need to be able to read /etc/passwd /etc/shadow 28 | die "no password\n" if !$password; 29 | 30 | my $pamh = Authen::PAM->new( 31 | 'proxmox-ve-auth', 32 | $username, 33 | sub { 34 | my @res; 35 | while (@_) { 36 | my $msg_type = shift; 37 | my $msg = shift; 38 | push @res, (0, $password); 39 | } 40 | push @res, 0; 41 | return @res; 42 | }, 43 | ); 44 | 45 | if (!ref($pamh)) { 46 | my $err = $pamh->pam_strerror($pamh); 47 | die "error during PAM init: $err"; 48 | } 49 | 50 | if (my $rpcenv = PVE::RPCEnvironment::get()) { 51 | if (my $ip = $rpcenv->get_client_ip()) { 52 | $pamh->pam_set_item(PAM_RHOST(), $ip); 53 | } 54 | } 55 | 56 | my $res; 57 | 58 | if (($res = $pamh->pam_authenticate(0)) != PAM_SUCCESS) { 59 | my $err = $pamh->pam_strerror($res); 60 | die "$err\n"; 61 | } 62 | 63 | if (($res = $pamh->pam_acct_mgmt(0)) != PAM_SUCCESS) { 64 | my $err = $pamh->pam_strerror($res); 65 | die "$err\n"; 66 | } 67 | 68 | $pamh = 0; # call destructor 69 | 70 | return 1; 71 | } 72 | 73 | sub store_password { 74 | my ($class, $config, $realm, $username, $password) = @_; 75 | 76 | my $cmd = ['usermod']; 77 | 78 | my $epw = PVE::Tools::encrypt_pw($password); 79 | 80 | push @$cmd, '-p', $epw, $username; 81 | 82 | run_command($cmd, errmsg => 'change password failed'); 83 | } 84 | 85 | 1; 86 | -------------------------------------------------------------------------------- /src/PVE/TokenConfig.pm: -------------------------------------------------------------------------------- 1 | package PVE::TokenConfig; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use UUID; 7 | 8 | use PVE::AccessControl; 9 | use PVE::Cluster; 10 | 11 | my $parse_token_cfg = sub { 12 | my ($filename, $raw) = @_; 13 | 14 | my $parsed = {}; 15 | return $parsed if !defined($raw); 16 | 17 | my @lines = split(/\n/, $raw); 18 | foreach my $line (@lines) { 19 | next if $line =~ m/^\s*$/; 20 | 21 | if ($line =~ m/^(\S+) (\S+)$/) { 22 | if (PVE::AccessControl::pve_verify_tokenid($1, 1)) { 23 | $parsed->{$1} = $2; 24 | next; 25 | } 26 | } 27 | 28 | warn "skipping invalid token.cfg entry\n"; 29 | } 30 | 31 | return $parsed; 32 | }; 33 | 34 | my $write_token_cfg = sub { 35 | my ($filename, $data) = @_; 36 | 37 | my $raw = ''; 38 | foreach my $tokenid (sort keys %$data) { 39 | $raw .= "$tokenid $data->{$tokenid}\n"; 40 | } 41 | 42 | return $raw; 43 | }; 44 | 45 | PVE::Cluster::cfs_register_file('priv/token.cfg', $parse_token_cfg, $write_token_cfg); 46 | 47 | sub generate_token { 48 | my ($tokenid) = @_; 49 | 50 | PVE::AccessControl::pve_verify_tokenid($tokenid); 51 | 52 | my $token_value = PVE::Cluster::cfs_lock_file( 53 | 'priv/token.cfg', 54 | 10, 55 | sub { 56 | my $uuid = UUID::uuid(); 57 | my $token_cfg = PVE::Cluster::cfs_read_file('priv/token.cfg'); 58 | 59 | $token_cfg->{$tokenid} = $uuid; 60 | 61 | PVE::Cluster::cfs_write_file('priv/token.cfg', $token_cfg); 62 | 63 | return $uuid; 64 | }, 65 | ); 66 | 67 | die "$@\n" if defined($@); 68 | 69 | return $token_value; 70 | } 71 | 72 | sub delete_token { 73 | my ($tokenid) = @_; 74 | 75 | PVE::Cluster::cfs_lock_file( 76 | 'priv/token.cfg', 77 | 10, 78 | sub { 79 | my $token_cfg = PVE::Cluster::cfs_read_file('priv/token.cfg'); 80 | 81 | delete $token_cfg->{$tokenid}; 82 | 83 | PVE::Cluster::cfs_write_file('priv/token.cfg', $token_cfg); 84 | }, 85 | ); 86 | 87 | die "$@\n" if defined($@); 88 | } 89 | -------------------------------------------------------------------------------- /src/test/perm-test8.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use PVE::Tools; 7 | 8 | use PVE::AccessControl; 9 | use PVE::RPCEnvironment; 10 | 11 | my $rpcenv = PVE::RPCEnvironment->init('cli'); 12 | 13 | my $cfgfn = "test8.cfg"; 14 | $rpcenv->init_request(userconfig => $cfgfn); 15 | 16 | sub check_roles { 17 | my ($user, $path, $expected_result) = @_; 18 | 19 | my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path); 20 | my $res = join(',', sort keys %$roles); 21 | 22 | die "unexpected result\nneed '${expected_result}'\ngot '$res'\n" 23 | if $res ne $expected_result; 24 | 25 | print "ROLES:$path:$user:$res\n"; 26 | } 27 | 28 | sub check_permission { 29 | my ($user, $path, $expected_result) = @_; 30 | 31 | my $perm = $rpcenv->permissions($user, $path); 32 | my $res = join(',', sort keys %$perm); 33 | 34 | die "unexpected result\nneed '${expected_result}'\ngot '$res'\n" 35 | if $res ne $expected_result; 36 | 37 | $perm = $rpcenv->permissions($user, $path); 38 | $res = join(',', sort keys %$perm); 39 | die "unexpected result (compiled)\nneed '${expected_result}'\ngot '$res'\n" 40 | if $res ne $expected_result; 41 | 42 | print "PERM:$path:$user:$res\n"; 43 | } 44 | 45 | check_roles('max@pve', '/', ''); 46 | check_roles('max@pve', '/vms', 'vm_admin'); 47 | 48 | #user permissions overrides group permissions 49 | check_roles('max@pve', '/vms/100', 'customer'); 50 | check_roles('max@pve', '/vms/101', 'vm_admin'); 51 | 52 | check_permission('max@pve', '/', ''); 53 | check_permission('max@pve', '/vms', 'VM.Allocate,VM.Audit,VM.Console'); 54 | check_permission('max@pve', '/vms/100', 'VM.Audit,VM.PowerMgmt'); 55 | 56 | check_permission('alex@pve', '/vms', ''); 57 | check_permission('alex@pve', '/vms/100', 'VM.Audit,VM.PowerMgmt'); 58 | 59 | check_roles('max@pve', '/vms/200', 'storage_manager'); 60 | check_roles('joe@pve', '/vms/200', 'vm_admin'); 61 | check_roles('sue@pve', '/vms/200', 'NoAccess'); 62 | 63 | check_roles('carol@pam', '/vms/200', 'NoAccess'); 64 | check_roles('carol@pam!token', '/vms/200', 'NoAccess'); 65 | check_roles('max@pve!token', '/vms/200', 'storage_manager'); 66 | check_roles('max@pve!token2', '/vms/200', 'customer'); 67 | 68 | # check intersection -> token has Administrator, but user only vm_admin 69 | check_permission('max@pve!token2', '/vms/300', 'VM.Allocate,VM.Audit,VM.Console,VM.PowerMgmt'); 70 | 71 | print "all tests passed\n"; 72 | 73 | exit(0); 74 | 75 | -------------------------------------------------------------------------------- /src/PVE/Auth/PVE.pm: -------------------------------------------------------------------------------- 1 | package PVE::Auth::PVE; 2 | 3 | use strict; 4 | use warnings; 5 | use Encode; 6 | 7 | use PVE::Tools; 8 | use PVE::Auth::Plugin; 9 | use PVE::Cluster qw(cfs_register_file cfs_read_file cfs_write_file cfs_lock_file); 10 | 11 | use base qw(PVE::Auth::Plugin); 12 | 13 | my $shadowconfigfile = "priv/shadow.cfg"; 14 | 15 | cfs_register_file($shadowconfigfile, \&parse_shadow_passwd, \&write_shadow_config); 16 | 17 | sub parse_shadow_passwd { 18 | my ($filename, $raw) = @_; 19 | 20 | my $shadow = {}; 21 | 22 | return $shadow if !defined($raw); 23 | 24 | while ($raw =~ /^\s*(.+?)\s*$/gm) { 25 | my $line = $1; 26 | 27 | if ($line !~ m/^\S+:\S+:$/) { 28 | warn "pve shadow password: ignore invalid line $.\n"; 29 | next; 30 | } 31 | 32 | my ($userid, $crypt_pass) = split(/:/, $line); 33 | $shadow->{users}->{$userid}->{shadow} = $crypt_pass; 34 | } 35 | 36 | return $shadow; 37 | } 38 | 39 | sub write_shadow_config { 40 | my ($filename, $cfg) = @_; 41 | 42 | my $data = ''; 43 | foreach my $userid (keys %{ $cfg->{users} }) { 44 | my $crypt_pass = $cfg->{users}->{$userid}->{shadow}; 45 | $data .= "$userid:$crypt_pass:\n"; 46 | } 47 | 48 | return $data; 49 | } 50 | 51 | sub lock_shadow_config { 52 | my ($code, $errmsg) = @_; 53 | 54 | cfs_lock_file($shadowconfigfile, undef, $code); 55 | my $err = $@; 56 | if ($err) { 57 | $errmsg ? die "$errmsg: $err" : die $err; 58 | } 59 | } 60 | 61 | sub type { 62 | return 'pve'; 63 | } 64 | 65 | sub options { 66 | return { 67 | default => { optional => 1 }, 68 | comment => { optional => 1 }, 69 | tfa => { optional => 1 }, 70 | }; 71 | } 72 | 73 | sub authenticate_user { 74 | my ($class, $config, $realm, $username, $password) = @_; 75 | 76 | die "no password\n" if !$password; 77 | 78 | my $shadow_cfg = cfs_read_file($shadowconfigfile); 79 | 80 | if ($shadow_cfg->{users}->{$username}) { 81 | my $encpw = 82 | crypt(Encode::encode('utf8', $password), $shadow_cfg->{users}->{$username}->{shadow}); 83 | die "invalid credentials\n" if ($encpw ne $shadow_cfg->{users}->{$username}->{shadow}); 84 | } else { 85 | die "no password set\n"; 86 | } 87 | 88 | return 1; 89 | } 90 | 91 | sub store_password { 92 | my ($class, $config, $realm, $username, $password) = @_; 93 | 94 | lock_shadow_config(sub { 95 | my $shadow_cfg = cfs_read_file($shadowconfigfile); 96 | my $epw = PVE::Tools::encrypt_pw($password); 97 | $shadow_cfg->{users}->{$username}->{shadow} = $epw; 98 | cfs_write_file($shadowconfigfile, $shadow_cfg); 99 | }); 100 | } 101 | 102 | sub delete_user { 103 | my ($class, $config, $realm, $username) = @_; 104 | 105 | lock_shadow_config(sub { 106 | my $shadow_cfg = cfs_read_file($shadowconfigfile); 107 | 108 | delete $shadow_cfg->{users}->{$username}; 109 | 110 | cfs_write_file($shadowconfigfile, $shadow_cfg); 111 | }); 112 | } 113 | 114 | 1; 115 | -------------------------------------------------------------------------------- /src/test/perm-test1.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Getopt::Long; 7 | 8 | use PVE::Tools; 9 | 10 | use PVE::AccessControl; 11 | use PVE::RPCEnvironment; 12 | 13 | my $rpcenv = PVE::RPCEnvironment->init('cli'); 14 | 15 | my $cfgfn = "test1.cfg"; 16 | $rpcenv->init_request(userconfig => $cfgfn); 17 | 18 | sub check_roles { 19 | my ($user, $path, $expected_result) = @_; 20 | 21 | my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path); 22 | my $res = join(',', sort keys %$roles); 23 | 24 | die "unexpected result\nneed '${expected_result}'\ngot '$res'\n" 25 | if $res ne $expected_result; 26 | 27 | print "ROLES:$path:$user:$res\n"; 28 | } 29 | 30 | sub check_permission { 31 | my ($user, $path, $expected_result) = @_; 32 | 33 | my $perm = $rpcenv->permissions($user, $path); 34 | my $res = join(',', sort keys %$perm); 35 | 36 | die "unexpected result\nneed '${expected_result}'\ngot '$res'\n" 37 | if $res ne $expected_result; 38 | 39 | $perm = $rpcenv->permissions($user, $path); 40 | $res = join(',', sort keys %$perm); 41 | die "unexpected result (compiled)\nneed '${expected_result}'\ngot '$res'\n" 42 | if $res ne $expected_result; 43 | 44 | print "PERM:$path:$user:$res\n"; 45 | } 46 | 47 | check_roles('max@pve', '/', ''); 48 | check_roles('max@pve', '/vms', 'vm_admin'); 49 | 50 | #user permissions overrides group permissions 51 | check_roles('max@pve', '/vms/100', 'customer'); 52 | check_roles('max@pve', '/vms/101', 'vm_admin'); 53 | 54 | check_permission('max@pve', '/', ''); 55 | check_permission('max@pve', '/vms', 'Permissions.Modify,VM.Allocate,VM.Audit,VM.Console'); 56 | check_permission('max@pve', '/vms/100', 'VM.Audit,VM.PowerMgmt'); 57 | 58 | check_permission('alex@pve', '/vms', ''); 59 | check_permission('alex@pve', '/vms/100', 'VM.Audit,VM.PowerMgmt'); 60 | 61 | # PVEVMAdmin -> no Permissions.Modify! 62 | check_permission( 63 | 'alex@pve', 64 | '/vms/300', 65 | '' # sorted, comma-separated expected privilege string 66 | . 'VM.Allocate,VM.Audit,VM.Backup,VM.Clone,VM.Config.CDROM,VM.Config.CPU,VM.Config.Cloudinit,' 67 | . 'VM.Config.Disk,VM.Config.HWType,VM.Config.Memory,VM.Config.Network,VM.Config.Options,' 68 | . 'VM.Console,VM.GuestAgent.Audit,VM.GuestAgent.FileRead,VM.GuestAgent.FileSystemMgmt,' 69 | . 'VM.GuestAgent.FileWrite,VM.GuestAgent.Unrestricted,VM.Migrate,VM.PowerMgmt,VM.Replicate,' 70 | . 'VM.Snapshot,VM.Snapshot.Rollback', 71 | ); 72 | # Administrator -> Permissions.Modify! 73 | check_permission( 74 | 'alex@pve', 75 | '/vms/400', 76 | '' # sorted, comma-separated expected privilege string, loosely grouped by prefix 77 | . 'Datastore.Allocate,Datastore.AllocateSpace,Datastore.AllocateTemplate,Datastore.Audit,' 78 | . 'Group.Allocate,' 79 | . 'Mapping.Audit,Mapping.Modify,Mapping.Use,' 80 | . 'Permissions.Modify,' 81 | . 'Pool.Allocate,Pool.Audit,' 82 | . 'Realm.Allocate,Realm.AllocateUser,' 83 | . 'SDN.Allocate,SDN.Audit,SDN.Use,' 84 | . 'Sys.AccessNetwork,Sys.Audit,Sys.Console,Sys.Incoming,Sys.Modify,Sys.PowerMgmt,Sys.Syslog,' 85 | . 'User.Modify,' 86 | . 'VM.Allocate,VM.Audit,VM.Backup,VM.Clone,VM.Config.CDROM,VM.Config.CPU,VM.Config.Cloudinit,' 87 | . 'VM.Config.Disk,VM.Config.HWType,VM.Config.Memory,VM.Config.Network,VM.Config.Options,' 88 | . 'VM.Console,VM.GuestAgent.Audit,VM.GuestAgent.FileRead,VM.GuestAgent.FileSystemMgmt,' 89 | . 'VM.GuestAgent.FileWrite,VM.GuestAgent.Unrestricted,VM.Migrate,VM.PowerMgmt,VM.Replicate,' 90 | . 'VM.Snapshot,VM.Snapshot.Rollback', 91 | ); 92 | 93 | check_roles('max@pve', '/vms/200', 'storage_manager'); 94 | check_roles('joe@pve', '/vms/200', 'vm_admin'); 95 | check_roles('sue@pve', '/vms/200', 'NoAccess'); 96 | 97 | print "all tests passed\n"; 98 | 99 | exit(0); 100 | -------------------------------------------------------------------------------- /src/test/perm-test6.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Getopt::Long; 7 | 8 | use PVE::Tools; 9 | 10 | use PVE::AccessControl; 11 | use PVE::RPCEnvironment; 12 | 13 | my $rpcenv = PVE::RPCEnvironment->init('cli'); 14 | 15 | my $cfgfn = "test6.cfg"; 16 | $rpcenv->init_request(userconfig => $cfgfn); 17 | 18 | sub check_roles { 19 | my ($user, $path, $expected_result) = @_; 20 | 21 | my $roles = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path); 22 | my $res = join(',', sort keys %$roles); 23 | 24 | die "unexpected result\nneed '${expected_result}'\ngot '$res'\n" 25 | if $res ne $expected_result; 26 | 27 | print "ROLES:$path:$user:$res\n"; 28 | } 29 | 30 | sub check_permissions { 31 | my ($user, $path, $expected_result) = @_; 32 | 33 | my $perm = $rpcenv->permissions($user, $path); 34 | my $res = join(',', sort keys %$perm); 35 | 36 | die "unexpected result\nneed '${expected_result}'\ngot '$res'\n" 37 | if $res ne $expected_result; 38 | 39 | $perm = $rpcenv->permissions($user, $path); 40 | $res = join(',', sort keys %$perm); 41 | die "unexpected result (compiled)\nneed '${expected_result}'\ngot '$res'\n" 42 | if $res ne $expected_result; 43 | 44 | print "PERM:$path:$user:$res\n"; 45 | } 46 | 47 | check_roles('User1@pve', '', ''); 48 | check_roles('User2@pve', '', ''); 49 | check_roles('User3@pve', '', ''); 50 | check_roles('User4@pve', '', ''); 51 | 52 | check_roles('User1@pve', '/vms', 'RoleTEST1'); 53 | check_roles('User2@pve', '/vms', 'RoleTEST1'); 54 | check_roles('User3@pve', '/vms', 'NoAccess'); 55 | check_roles('User4@pve', '/vms', ''); 56 | 57 | check_roles('User1@pve', '/vms/100', 'RoleTEST1'); 58 | check_roles('User2@pve', '/vms/100', 'RoleTEST1'); 59 | check_roles('User3@pve', '/vms/100', 'NoAccess'); 60 | check_roles('User4@pve', '/vms/100', ''); 61 | 62 | check_roles('User1@pve', '/vms/300', 'RoleTEST1'); 63 | check_roles('User2@pve', '/vms/300', 'RoleTEST1'); 64 | check_roles('User3@pve', '/vms/300', 'NoAccess'); 65 | check_roles('User4@pve', '/vms/300', 'RoleTEST1'); 66 | 67 | check_permissions('User1@pve', '/vms/500', 'VM.Console,VM.PowerMgmt'); 68 | check_permissions('User2@pve', '/vms/500', 'VM.Console,VM.PowerMgmt'); 69 | # without pool 70 | check_roles('User3@pve', '/vms/500', 'NoAccess'); 71 | # with pool 72 | check_permissions('User3@pve', '/vms/500', ''); 73 | # without pool 74 | check_roles('User4@pve', '/vms/500', ''); 75 | # with pool 76 | check_permissions('User4@pve', '/vms/500', ''); 77 | 78 | # without pool, checking no access on parent pool 79 | check_roles('intern@pve', '/vms/600', ''); 80 | # once more, with VM in nested pool 81 | check_roles('intern@pve', '/vms/700', ''); 82 | # with propagated ACL 83 | check_roles('User4@pve', '/vms/700', ''); 84 | # with pool, checking no access on parent pool 85 | check_permissions('intern@pve', '/vms/600', ''); 86 | # once more, with VM in nested pool 87 | check_permissions('intern@pve', '/vms/700', 'VM.Audit'); 88 | # with propagated ACL 89 | check_permissions('User4@pve', '/vms/700', 'VM.Console'); 90 | 91 | # check nested pool permissions 92 | check_roles('intern@pve', '/pool/marketing/interns', 'RoleINTERN'); 93 | check_roles('User4@pve', '/pool/marketing/interns', 'RoleMARKETING'); 94 | 95 | check_permissions('User1@pve', '/vms/600', 'VM.Console'); 96 | check_permissions('User2@pve', '/vms/600', 'VM.Console'); 97 | check_permissions('User3@pve', '/vms/600', ''); 98 | check_permissions('User4@pve', '/vms/600', 'VM.Console'); 99 | 100 | check_permissions('User1@pve', '/storage/store1', 'VM.Console,VM.PowerMgmt'); 101 | check_permissions('User2@pve', '/storage/store1', 'VM.PowerMgmt'); 102 | check_permissions('User3@pve', '/storage/store1', 'VM.PowerMgmt'); 103 | check_permissions('User4@pve', '/storage/store1', 'VM.Console'); 104 | 105 | check_permissions('User1@pve', '/storage/store2', 'VM.PowerMgmt'); 106 | check_permissions('User2@pve', '/storage/store2', 'VM.PowerMgmt'); 107 | check_permissions('User3@pve', '/storage/store2', 'VM.PowerMgmt'); 108 | check_permissions('User4@pve', '/storage/store2', ''); 109 | 110 | print "all tests passed\n"; 111 | 112 | exit(0); 113 | -------------------------------------------------------------------------------- /src/PVE/Auth/OpenId.pm: -------------------------------------------------------------------------------- 1 | package PVE::Auth::OpenId; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use PVE::Tools; 7 | use PVE::Auth::Plugin; 8 | use PVE::Cluster qw(cfs_register_file cfs_read_file cfs_write_file cfs_lock_file); 9 | 10 | use base qw(PVE::Auth::Plugin); 11 | 12 | # FIXME: restrict username-claim as well? 13 | my $openid_claim_regex = qr/[A-Za-z0-9\.\-_]+/; 14 | 15 | sub type { 16 | return 'openid'; 17 | } 18 | 19 | sub properties { 20 | return { 21 | "issuer-url" => { 22 | description => "OpenID Issuer Url", 23 | type => 'string', 24 | maxLength => 256, 25 | }, 26 | "client-id" => { 27 | description => "OpenID Client ID", 28 | type => 'string', 29 | maxLength => 256, 30 | }, 31 | "client-key" => { 32 | description => "OpenID Client Key", 33 | type => 'string', 34 | optional => 1, 35 | maxLength => 256, 36 | }, 37 | autocreate => { 38 | description => "Automatically create users if they do not exist.", 39 | optional => 1, 40 | type => 'boolean', 41 | default => 0, 42 | }, 43 | "username-claim" => { 44 | description => "OpenID claim used to generate the unique username.", 45 | type => 'string', 46 | optional => 1, 47 | }, 48 | "groups-claim" => { 49 | description => "OpenID claim used to retrieve groups with.", 50 | type => 'string', 51 | pattern => $openid_claim_regex, 52 | maxLength => 256, 53 | optional => 1, 54 | }, 55 | "groups-autocreate" => { 56 | description => "Automatically create groups if they do not exist.", 57 | optional => 1, 58 | type => 'boolean', 59 | default => 0, 60 | }, 61 | "groups-overwrite" => { 62 | description => "All groups will be overwritten for the user on login.", 63 | type => 'boolean', 64 | default => 0, 65 | optional => 1, 66 | }, 67 | prompt => { 68 | description => "Specifies whether the Authorization Server prompts the End-User for" 69 | . " reauthentication and consent.", 70 | type => 'string', 71 | pattern => '(?:none|login|consent|select_account|\S+)', # \S+ is the extension variant 72 | optional => 1, 73 | }, 74 | scopes => { 75 | description => "Specifies the scopes (user details) that should be authorized and" 76 | . " returned, for example 'email' or 'profile'.", 77 | type => 'string', # format => 'some-safe-id-list', # FIXME: TODO 78 | default => "email profile", 79 | optional => 1, 80 | }, 81 | 'acr-values' => { 82 | description => 83 | "Specifies the Authentication Context Class Reference values that the" 84 | . "Authorization Server is being requested to use for the Auth Request.", 85 | type => 'string', 86 | pattern => '^[^\x00-\x1F\x7F <>#"]*$', # Prohibit characters not allowed in URI RFC 2396. 87 | optional => 1, 88 | }, 89 | "query-userinfo" => { 90 | description => "Enables querying the userinfo endpoint for claims values.", 91 | type => 'boolean', 92 | default => 1, 93 | optional => 1, 94 | }, 95 | }; 96 | } 97 | 98 | sub options { 99 | return { 100 | "issuer-url" => {}, 101 | "client-id" => {}, 102 | "client-key" => { optional => 1 }, 103 | autocreate => { optional => 1 }, 104 | "username-claim" => { optional => 1, fixed => 1 }, 105 | "groups-claim" => { optional => 1 }, 106 | "groups-autocreate" => { optional => 1 }, 107 | "groups-overwrite" => { optional => 1 }, 108 | prompt => { optional => 1 }, 109 | scopes => { optional => 1 }, 110 | "acr-values" => { optional => 1 }, 111 | default => { optional => 1 }, 112 | comment => { optional => 1 }, 113 | "query-userinfo" => { optional => 1 }, 114 | }; 115 | } 116 | 117 | sub authenticate_user { 118 | my ($class, $config, $realm, $username, $password) = @_; 119 | 120 | die "OpenID realm does not allow password verification.\n"; 121 | } 122 | 123 | 1; 124 | -------------------------------------------------------------------------------- /src/PVE/Auth/AD.pm: -------------------------------------------------------------------------------- 1 | package PVE::Auth::AD; 2 | 3 | use strict; 4 | use warnings; 5 | use PVE::Auth::LDAP; 6 | use PVE::LDAP; 7 | 8 | use base qw(PVE::Auth::LDAP); 9 | 10 | sub type { 11 | return 'ad'; 12 | } 13 | 14 | sub properties { 15 | return { 16 | server1 => { 17 | description => "Server IP address (or DNS name)", 18 | type => 'string', 19 | format => 'address', 20 | maxLength => 256, 21 | }, 22 | server2 => { 23 | description => "Fallback Server IP address (or DNS name)", 24 | type => 'string', 25 | optional => 1, 26 | format => 'address', 27 | maxLength => 256, 28 | }, 29 | secure => { 30 | description => "Use secure LDAPS protocol. DEPRECATED: use 'mode' instead.", 31 | type => 'boolean', 32 | optional => 1, 33 | }, 34 | sslversion => { 35 | description => 36 | "LDAPS TLS/SSL version. It's not recommended to use version older than 1.2!", 37 | type => 'string', 38 | enum => [qw(tlsv1 tlsv1_1 tlsv1_2 tlsv1_3)], 39 | optional => 1, 40 | }, 41 | default => { 42 | description => "Use this as default realm", 43 | type => 'boolean', 44 | optional => 1, 45 | }, 46 | comment => { 47 | description => "Description.", 48 | type => 'string', 49 | optional => 1, 50 | maxLength => 4096, 51 | }, 52 | port => { 53 | description => "Server port.", 54 | type => 'integer', 55 | minimum => 1, 56 | maximum => 65535, 57 | optional => 1, 58 | }, 59 | domain => { 60 | description => "AD domain name", 61 | type => 'string', 62 | pattern => '\S+', 63 | optional => 1, 64 | maxLength => 256, 65 | }, 66 | tfa => PVE::JSONSchema::get_standard_option('tfa'), 67 | }; 68 | } 69 | 70 | sub options { 71 | return { 72 | server1 => {}, 73 | server2 => { optional => 1 }, 74 | domain => {}, 75 | port => { optional => 1 }, 76 | secure => { optional => 1 }, 77 | sslversion => { optional => 1 }, 78 | default => { optional => 1 }, 79 | comment => { optional => 1 }, 80 | tfa => { optional => 1 }, 81 | verify => { optional => 1 }, 82 | capath => { optional => 1 }, 83 | cert => { optional => 1 }, 84 | certkey => { optional => 1 }, 85 | base_dn => { optional => 1 }, 86 | bind_dn => { optional => 1 }, 87 | password => { optional => 1 }, 88 | user_attr => { optional => 1 }, 89 | filter => { optional => 1 }, 90 | sync_attributes => { optional => 1 }, 91 | user_classes => { optional => 1 }, 92 | group_dn => { optional => 1 }, 93 | group_name_attr => { optional => 1 }, 94 | group_filter => { optional => 1 }, 95 | group_classes => { optional => 1 }, 96 | 'sync-defaults-options' => { optional => 1 }, 97 | mode => { optional => 1 }, 98 | 'case-sensitive' => { optional => 1 }, 99 | }; 100 | } 101 | 102 | sub get_users { 103 | my ($class, $config, $realm) = @_; 104 | 105 | $config->{user_attr} //= 'sAMAccountName'; 106 | 107 | return $class->SUPER::get_users($config, $realm); 108 | } 109 | 110 | sub authenticate_user { 111 | my ($class, $config, $realm, $username, $password) = @_; 112 | 113 | my $servers = [$config->{server1}]; 114 | push @$servers, $config->{server2} if $config->{server2}; 115 | 116 | my ($scheme, $port) = $class->get_scheme_and_port($config); 117 | 118 | my %ad_args; 119 | if ($config->{verify}) { 120 | $ad_args{verify} = 'require'; 121 | $ad_args{clientcert} = $config->{cert} if $config->{cert}; 122 | $ad_args{clientkey} = $config->{certkey} if $config->{certkey}; 123 | if (defined(my $capath = $config->{capath})) { 124 | if (-d $capath) { 125 | $ad_args{capath} = $capath; 126 | } else { 127 | $ad_args{cafile} = $capath; 128 | } 129 | } 130 | } elsif (defined($config->{verify})) { 131 | $ad_args{verify} = 'none'; 132 | } 133 | 134 | if ($scheme ne 'ldap') { 135 | $ad_args{sslversion} = $config->{sslversion} // 'tlsv1_2'; 136 | } 137 | 138 | my $ldap = PVE::LDAP::ldap_connect($servers, $scheme, $port, \%ad_args); 139 | 140 | $username = "$username\@$config->{domain}" 141 | if $username !~ m/@/ && $config->{domain}; 142 | 143 | PVE::LDAP::auth_user_dn($ldap, $username, $password); 144 | 145 | $ldap->unbind(); 146 | return 1; 147 | } 148 | 149 | 1; 150 | -------------------------------------------------------------------------------- /src/PVE/Jobs/RealmSync.pm: -------------------------------------------------------------------------------- 1 | package PVE::Jobs::RealmSync; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use JSON qw(decode_json encode_json); 7 | use POSIX qw(ENOENT); 8 | 9 | use PVE::JSONSchema qw(get_standard_option); 10 | use PVE::Cluster (); 11 | use PVE::CalendarEvent (); 12 | use PVE::Tools (); 13 | 14 | use PVE::API2::Domains (); 15 | 16 | # load user-* standard options 17 | use PVE::API2::User (); 18 | 19 | use base qw(PVE::Job::Registry); 20 | 21 | sub type { 22 | return 'realm-sync'; 23 | } 24 | 25 | my $props = get_standard_option('realm-sync-options', { 26 | realm => get_standard_option('realm'), 27 | }); 28 | 29 | sub properties { 30 | return $props; 31 | } 32 | 33 | sub options { 34 | my $options = { 35 | enabled => { optional => 1 }, 36 | schedule => {}, 37 | comment => { optional => 1 }, 38 | scope => {}, 39 | }; 40 | for my $opt (keys %$props) { 41 | next if defined($options->{$opt}); 42 | # ignore legacy props from realm-sync schema 43 | next if $opt eq 'full' || $opt eq 'purge'; 44 | if ($props->{$opt}->{optional}) { 45 | $options->{$opt} = { optional => 1 }; 46 | } else { 47 | $options->{$opt} = {}; 48 | } 49 | } 50 | $options->{realm}->{fixed} = 1; 51 | 52 | return $options; 53 | } 54 | 55 | sub decode_value { 56 | my ($class, $type, $key, $value) = @_; 57 | return $value; 58 | } 59 | 60 | sub encode_value { 61 | my ($class, $type, $key, $value) = @_; 62 | return $value; 63 | } 64 | 65 | sub createSchema { 66 | my ($class, $skip_type) = @_; 67 | 68 | my $schema = $class->SUPER::createSchema($skip_type); 69 | 70 | my $opts = $class->options(); 71 | for my $opt (keys $schema->{properties}->%*) { 72 | next if defined($opts->{$opt}) || $opt eq 'id'; 73 | delete $schema->{properties}->{$opt}; 74 | } 75 | 76 | return $schema; 77 | } 78 | 79 | sub updateSchema { 80 | my ($class, $skip_type) = @_; 81 | my $schema = $class->SUPER::updateSchema($skip_type); 82 | 83 | my $opts = $class->options(); 84 | for my $opt (keys $schema->{properties}->%*) { 85 | next if defined($opts->{$opt}); 86 | next if $opt eq 'id' || $opt eq 'delete'; 87 | delete $schema->{properties}->{$opt}; 88 | } 89 | 90 | return $schema; 91 | } 92 | 93 | my $statedir = "/etc/pve/priv/jobs"; 94 | 95 | sub get_state { 96 | my ($id) = @_; 97 | 98 | mkdir $statedir; 99 | my $statefile = "$statedir/realm-sync-$id.json"; 100 | my $raw = eval { PVE::Tools::file_get_contents($statefile) } // ''; 101 | 102 | my $state = ($raw =~ m/^(\{.*\})$/) ? decode_json($1) : {}; 103 | 104 | return $state; 105 | } 106 | 107 | sub save_state { 108 | my ($id, $state) = @_; 109 | 110 | mkdir $statedir; 111 | my $statefile = "$statedir/realm-sync-$id.json"; 112 | 113 | if (defined($state)) { 114 | PVE::Tools::file_set_contents($statefile, encode_json($state)); 115 | } else { 116 | unlink $statefile or $! == ENOENT or die "could not delete state for $id - $!\n"; 117 | } 118 | 119 | return undef; 120 | } 121 | 122 | sub run { 123 | my ($class, $conf, $id, $schedule) = @_; 124 | 125 | for my $opt (keys %$conf) { 126 | delete $conf->{$opt} if !defined($props->{$opt}); 127 | } 128 | 129 | my $realm = $conf->{realm}; 130 | 131 | # cluster synced 132 | my $now = time(); 133 | my $nodename = PVE::INotify::nodename(); 134 | 135 | # check statefile in pmxcfs if we should start 136 | my $shouldrun = PVE::Cluster::cfs_lock_domain( 137 | 'realm-sync', 138 | undef, 139 | sub { 140 | my $members = PVE::Cluster::get_members(); 141 | 142 | my $state = get_state($id); 143 | my $last_node = $state->{node} // $nodename; 144 | my $last_upid = $state->{upid}; 145 | my $last_time = $state->{time}; 146 | 147 | my $last_node_online = 148 | $last_node eq $nodename || ($members->{$last_node} // {})->{online}; 149 | 150 | if (defined($last_upid)) { 151 | # first check if the next run is scheduled 152 | if (my $parsed = PVE::Tools::upid_decode($last_upid, 1)) { 153 | my $cal_spec = PVE::CalendarEvent::parse_calendar_event($schedule); 154 | my $next_sync = 155 | PVE::CalendarEvent::compute_next_event($cal_spec, $parsed->{starttime}); 156 | return 0 if !defined($next_sync) || $now < $next_sync; # not yet its (next) turn 157 | } 158 | # check if still running and node is online 159 | my $tasks = PVE::Cluster::get_tasklist(); 160 | for my $task (@$tasks) { 161 | next if $task->{upid} ne $last_upid; 162 | last if defined($task->{endtime}); # it's already finished 163 | last if !$last_node_online; # it's not finished and the node is offline 164 | return 0; # not finished and online 165 | } 166 | } elsif (defined($last_time) && ($last_time + 60) > $now && $last_node_online) { 167 | # another node started this job in the last 60 seconds and is still online 168 | return 0; 169 | } 170 | 171 | # any of the following conditions should be true here: 172 | # * it was started on another node but that node is offline now 173 | # * it was started but either too long ago, or with an error 174 | # * the started task finished 175 | 176 | save_state( 177 | $id, 178 | { 179 | node => $nodename, 180 | time => $now, 181 | }, 182 | ); 183 | return 1; 184 | }, 185 | ); 186 | die $@ if $@; 187 | 188 | if ($shouldrun) { 189 | my $upid = eval { PVE::API2::Domains->sync($conf) }; 190 | my $err = $@; 191 | PVE::Cluster::cfs_lock_domain( 192 | 'realm-sync', 193 | undef, 194 | sub { 195 | if ($err && !$upid) { 196 | save_state( 197 | $id, 198 | { 199 | node => $nodename, 200 | time => $now, 201 | error => $err, 202 | }, 203 | ); 204 | die "$err\n"; 205 | } 206 | 207 | save_state( 208 | $id, 209 | { 210 | node => $nodename, 211 | upid => $upid, 212 | }, 213 | ); 214 | }, 215 | ); 216 | die $@ if $@; 217 | return $upid; 218 | } 219 | 220 | return "OK"; # all other cases should not run the sync on this node 221 | } 222 | 223 | 1; 224 | -------------------------------------------------------------------------------- /src/PVE/API2/Role.pm: -------------------------------------------------------------------------------- 1 | package PVE::API2::Role; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use PVE::AccessControl (); 7 | use PVE::Cluster qw(cfs_read_file cfs_write_file); 8 | use PVE::Exception qw(raise_param_exc); 9 | use PVE::JSONSchema qw(get_standard_option register_standard_option); 10 | 11 | use base qw(PVE::RESTHandler); 12 | 13 | register_standard_option( 14 | 'role-id', 15 | { 16 | type => 'string', 17 | format => 'pve-roleid', 18 | }, 19 | ); 20 | register_standard_option( 21 | 'role-privs', 22 | { 23 | type => 'string', 24 | format => 'pve-priv-list', 25 | optional => 1, 26 | }, 27 | ); 28 | 29 | __PACKAGE__->register_method({ 30 | name => 'index', 31 | path => '', 32 | method => 'GET', 33 | description => "Role index.", 34 | permissions => { 35 | user => 'all', 36 | }, 37 | parameters => { 38 | additionalProperties => 0, 39 | properties => {}, 40 | }, 41 | returns => { 42 | type => 'array', 43 | items => { 44 | type => "object", 45 | properties => { 46 | roleid => get_standard_option('role-id'), 47 | privs => get_standard_option('role-privs'), 48 | special => { type => 'boolean', optional => 1, default => 0 }, 49 | }, 50 | }, 51 | links => [{ rel => 'child', href => "{roleid}" }], 52 | }, 53 | code => sub { 54 | my ($param) = @_; 55 | 56 | my $res = []; 57 | 58 | my $usercfg = cfs_read_file("user.cfg"); 59 | 60 | foreach my $role (keys %{ $usercfg->{roles} }) { 61 | my $privs = join(',', sort keys %{ $usercfg->{roles}->{$role} }); 62 | push @$res, 63 | { 64 | roleid => $role, 65 | privs => $privs, 66 | special => PVE::AccessControl::role_is_special($role), 67 | }; 68 | } 69 | 70 | return $res; 71 | }, 72 | }); 73 | 74 | __PACKAGE__->register_method({ 75 | name => 'create_role', 76 | protected => 1, 77 | path => '', 78 | method => 'POST', 79 | permissions => { 80 | check => ['perm', '/access', ['Sys.Modify']], 81 | }, 82 | description => "Create new role.", 83 | parameters => { 84 | additionalProperties => 0, 85 | properties => { 86 | roleid => get_standard_option('role-id'), 87 | privs => get_standard_option('role-privs'), 88 | }, 89 | }, 90 | returns => { type => 'null' }, 91 | code => sub { 92 | my ($param) = @_; 93 | 94 | my $role = $param->{roleid}; 95 | 96 | if ($role =~ /^PVE/i) { 97 | raise_param_exc({ 98 | roleid => 99 | "cannot use role ID starting with the (case-insensitive) 'PVE' namespace", 100 | }); 101 | } 102 | 103 | PVE::AccessControl::lock_user_config( 104 | sub { 105 | my $usercfg = cfs_read_file("user.cfg"); 106 | 107 | die "role '$role' already exists\n" if $usercfg->{roles}->{$role}; 108 | 109 | $usercfg->{roles}->{$role} = {}; 110 | 111 | PVE::AccessControl::add_role_privs($role, $usercfg, $param->{privs}); 112 | 113 | cfs_write_file("user.cfg", $usercfg); 114 | }, 115 | "create role failed", 116 | ); 117 | 118 | return undef; 119 | }, 120 | }); 121 | 122 | __PACKAGE__->register_method({ 123 | name => 'update_role', 124 | protected => 1, 125 | path => '{roleid}', 126 | method => 'PUT', 127 | permissions => { 128 | check => ['perm', '/access', ['Sys.Modify']], 129 | }, 130 | description => "Update an existing role.", 131 | parameters => { 132 | additionalProperties => 0, 133 | properties => { 134 | roleid => get_standard_option('role-id'), 135 | privs => get_standard_option('role-privs'), 136 | append => { type => 'boolean', optional => 1, requires => 'privs' }, 137 | }, 138 | }, 139 | returns => { type => 'null' }, 140 | code => sub { 141 | my ($param) = @_; 142 | 143 | my $role = $param->{roleid}; 144 | 145 | die "auto-generated role '$role' cannot be modified\n" 146 | if PVE::AccessControl::role_is_special($role); 147 | 148 | PVE::AccessControl::lock_user_config( 149 | sub { 150 | my $usercfg = cfs_read_file("user.cfg"); 151 | 152 | die "role '$role' does not exist\n" if !$usercfg->{roles}->{$role}; 153 | 154 | $usercfg->{roles}->{$role} = {} if !$param->{append}; 155 | 156 | PVE::AccessControl::add_role_privs($role, $usercfg, $param->{privs}); 157 | 158 | cfs_write_file("user.cfg", $usercfg); 159 | }, 160 | "update role failed", 161 | ); 162 | 163 | return undef; 164 | }, 165 | }); 166 | 167 | __PACKAGE__->register_method({ 168 | name => 'read_role', 169 | path => '{roleid}', 170 | method => 'GET', 171 | permissions => { 172 | user => 'all', 173 | }, 174 | description => "Get role configuration.", 175 | parameters => { 176 | additionalProperties => 0, 177 | properties => { 178 | roleid => get_standard_option('role-id'), 179 | }, 180 | }, 181 | returns => { 182 | type => "object", 183 | additionalProperties => 0, 184 | properties => PVE::AccessControl::create_priv_properties(), 185 | }, 186 | code => sub { 187 | my ($param) = @_; 188 | 189 | my $usercfg = cfs_read_file("user.cfg"); 190 | 191 | my $role = $param->{roleid}; 192 | 193 | my $data = $usercfg->{roles}->{$role}; 194 | 195 | die "role '$role' does not exist\n" if !$data; 196 | 197 | return $data; 198 | }, 199 | }); 200 | 201 | __PACKAGE__->register_method({ 202 | name => 'delete_role', 203 | protected => 1, 204 | path => '{roleid}', 205 | method => 'DELETE', 206 | permissions => { 207 | check => ['perm', '/access', ['Sys.Modify']], 208 | }, 209 | description => "Delete role.", 210 | parameters => { 211 | additionalProperties => 0, 212 | properties => { 213 | roleid => get_standard_option('role-id'), 214 | }, 215 | }, 216 | returns => { type => 'null' }, 217 | code => sub { 218 | my ($param) = @_; 219 | 220 | my $role = $param->{roleid}; 221 | 222 | die "auto-generated role '$role' cannot be deleted\n" 223 | if PVE::AccessControl::role_is_special($role); 224 | 225 | PVE::AccessControl::lock_user_config( 226 | sub { 227 | my $usercfg = cfs_read_file("user.cfg"); 228 | 229 | die "role '$role' does not exist\n" if !$usercfg->{roles}->{$role}; 230 | 231 | delete($usercfg->{roles}->{$role}); 232 | 233 | # fixme: delete role from acl? 234 | 235 | cfs_write_file("user.cfg", $usercfg); 236 | }, 237 | "delete role failed", 238 | ); 239 | 240 | return undef; 241 | }, 242 | }); 243 | 244 | 1; 245 | -------------------------------------------------------------------------------- /src/PVE/API2/Group.pm: -------------------------------------------------------------------------------- 1 | package PVE::API2::Group; 2 | 3 | use strict; 4 | use warnings; 5 | use PVE::Cluster qw (cfs_read_file cfs_write_file); 6 | use PVE::AccessControl; 7 | use PVE::SafeSyslog; 8 | use PVE::RESTHandler; 9 | use PVE::JSONSchema qw(get_standard_option register_standard_option); 10 | 11 | use base qw(PVE::RESTHandler); 12 | 13 | register_standard_option( 14 | 'group-id', 15 | { 16 | type => 'string', 17 | format => 'pve-groupid', 18 | completion => \&PVE::AccessControl::complete_group, 19 | }, 20 | ); 21 | 22 | register_standard_option('group-comment', { type => 'string', optional => 1 }); 23 | 24 | __PACKAGE__->register_method({ 25 | name => 'index', 26 | path => '', 27 | method => 'GET', 28 | description => "Group index.", 29 | permissions => { 30 | description => 31 | "The returned list is restricted to groups where you have 'User.Modify', 'Sys.Audit' or 'Group.Allocate' permissions on /access/groups/.", 32 | user => 'all', 33 | }, 34 | parameters => { 35 | additionalProperties => 0, 36 | properties => {}, 37 | }, 38 | returns => { 39 | type => 'array', 40 | items => { 41 | type => "object", 42 | properties => { 43 | groupid => get_standard_option('group-id'), 44 | comment => get_standard_option('group-comment'), 45 | users => { 46 | type => 'string', 47 | format => 'pve-userid-list', 48 | optional => 1, 49 | description => 'list of users which form this group', 50 | }, 51 | }, 52 | }, 53 | links => [{ rel => 'child', href => "{groupid}" }], 54 | }, 55 | code => sub { 56 | my ($param) = @_; 57 | 58 | my $res = []; 59 | 60 | my $rpcenv = PVE::RPCEnvironment::get(); 61 | my $usercfg = cfs_read_file("user.cfg"); 62 | my $authuser = $rpcenv->get_user(); 63 | 64 | my $privs = ['User.Modify', 'Sys.Audit', 'Group.Allocate']; 65 | 66 | foreach my $group (keys %{ $usercfg->{groups} }) { 67 | next if !$rpcenv->check_any($authuser, "/access/groups/$group", $privs, 1); 68 | my $data = $usercfg->{groups}->{$group}; 69 | my $entry = { groupid => $group }; 70 | $entry->{comment} = $data->{comment} if defined($data->{comment}); 71 | $entry->{users} = join(',', sort keys %{ $data->{users} }) 72 | if defined($data->{users}); 73 | push @$res, $entry; 74 | } 75 | 76 | return $res; 77 | }, 78 | }); 79 | 80 | __PACKAGE__->register_method({ 81 | name => 'create_group', 82 | protected => 1, 83 | path => '', 84 | method => 'POST', 85 | permissions => { 86 | check => ['perm', '/access/groups', ['Group.Allocate']], 87 | }, 88 | description => "Create new group.", 89 | parameters => { 90 | additionalProperties => 0, 91 | properties => { 92 | groupid => get_standard_option('group-id'), 93 | comment => get_standard_option('group-comment'), 94 | }, 95 | }, 96 | returns => { type => 'null' }, 97 | code => sub { 98 | my ($param) = @_; 99 | 100 | PVE::AccessControl::lock_user_config( 101 | sub { 102 | 103 | my $usercfg = cfs_read_file("user.cfg"); 104 | 105 | my $group = $param->{groupid}; 106 | 107 | die "group '$group' already exists\n" 108 | if $usercfg->{groups}->{$group}; 109 | 110 | $usercfg->{groups}->{$group} = { users => {} }; 111 | 112 | $usercfg->{groups}->{$group}->{comment} = $param->{comment} 113 | if $param->{comment}; 114 | 115 | cfs_write_file("user.cfg", $usercfg); 116 | }, 117 | "create group failed", 118 | ); 119 | 120 | return undef; 121 | }, 122 | }); 123 | 124 | __PACKAGE__->register_method({ 125 | name => 'update_group', 126 | protected => 1, 127 | path => '{groupid}', 128 | method => 'PUT', 129 | permissions => { 130 | check => ['perm', '/access/groups', ['Group.Allocate']], 131 | }, 132 | description => "Update group data.", 133 | parameters => { 134 | additionalProperties => 0, 135 | properties => { 136 | groupid => get_standard_option('group-id'), 137 | comment => get_standard_option('group-comment'), 138 | }, 139 | }, 140 | returns => { type => 'null' }, 141 | code => sub { 142 | my ($param) = @_; 143 | 144 | PVE::AccessControl::lock_user_config( 145 | sub { 146 | 147 | my $usercfg = cfs_read_file("user.cfg"); 148 | 149 | my $group = $param->{groupid}; 150 | 151 | my $data = $usercfg->{groups}->{$group}; 152 | 153 | die "group '$group' does not exist\n" 154 | if !$data; 155 | 156 | $data->{comment} = $param->{comment} if defined($param->{comment}); 157 | 158 | cfs_write_file("user.cfg", $usercfg); 159 | }, 160 | "update group failed", 161 | ); 162 | 163 | return undef; 164 | }, 165 | }); 166 | 167 | __PACKAGE__->register_method({ 168 | name => 'read_group', 169 | path => '{groupid}', 170 | method => 'GET', 171 | permissions => { 172 | check => ['perm', '/access/groups', ['Sys.Audit', 'Group.Allocate'], any => 1], 173 | }, 174 | description => "Get group configuration.", 175 | parameters => { 176 | additionalProperties => 0, 177 | properties => { 178 | groupid => get_standard_option('group-id'), 179 | }, 180 | }, 181 | returns => { 182 | type => "object", 183 | additionalProperties => 0, 184 | properties => { 185 | comment => get_standard_option('group-comment'), 186 | members => { 187 | type => 'array', 188 | items => get_standard_option('userid-completed'), 189 | }, 190 | }, 191 | }, 192 | code => sub { 193 | my ($param) = @_; 194 | 195 | my $group = $param->{groupid}; 196 | 197 | my $usercfg = cfs_read_file("user.cfg"); 198 | 199 | my $data = $usercfg->{groups}->{$group}; 200 | 201 | die "group '$group' does not exist\n" if !$data; 202 | 203 | my $members = $data->{users} ? [keys %{ $data->{users} }] : []; 204 | 205 | my $res = { members => $members }; 206 | 207 | $res->{comment} = $data->{comment} if defined($data->{comment}); 208 | 209 | return $res; 210 | }, 211 | }); 212 | 213 | __PACKAGE__->register_method({ 214 | name => 'delete_group', 215 | protected => 1, 216 | path => '{groupid}', 217 | method => 'DELETE', 218 | permissions => { 219 | check => ['perm', '/access/groups', ['Group.Allocate']], 220 | }, 221 | description => "Delete group.", 222 | parameters => { 223 | additionalProperties => 0, 224 | properties => { 225 | groupid => get_standard_option('group-id'), 226 | }, 227 | }, 228 | returns => { type => 'null' }, 229 | code => sub { 230 | my ($param) = @_; 231 | 232 | PVE::AccessControl::lock_user_config( 233 | sub { 234 | 235 | my $usercfg = cfs_read_file("user.cfg"); 236 | 237 | my $group = $param->{groupid}; 238 | 239 | die "group '$group' does not exist\n" 240 | if !$usercfg->{groups}->{$group}; 241 | 242 | delete($usercfg->{groups}->{$group}); 243 | 244 | PVE::AccessControl::delete_group_acl($group, $usercfg); 245 | 246 | cfs_write_file("user.cfg", $usercfg); 247 | }, 248 | "delete group failed", 249 | ); 250 | 251 | return undef; 252 | }, 253 | }); 254 | 255 | 1; 256 | -------------------------------------------------------------------------------- /src/PVE/API2/Jobs/RealmSync.pm: -------------------------------------------------------------------------------- 1 | package PVE::API2::Jobs::RealmSync; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use PVE::Cluster qw(cfs_lock_file cfs_read_file cfs_write_file); 7 | use PVE::Exception qw(raise_param_exc); 8 | use PVE::JSONSchema qw(get_standard_option); 9 | use PVE::Job::Registry (); 10 | use PVE::SectionConfig (); 11 | use PVE::Tools qw(extract_param); 12 | 13 | use PVE::Jobs::RealmSync (); 14 | 15 | use base qw(PVE::RESTHandler); 16 | 17 | my $get_cluster_last_run = sub { 18 | my ($jobid) = @_; 19 | 20 | my $state = eval { PVE::Jobs::RealmSync::get_state($jobid) }; 21 | die "error on getting state for '$jobid': $@\n" if $@; 22 | 23 | if (my $upid = $state->{upid}) { 24 | if (my $decoded = PVE::Tools::upid_decode($upid)) { 25 | return $decoded->{starttime}; 26 | } 27 | } else { 28 | return $state->{time}; 29 | } 30 | 31 | return undef; 32 | }; 33 | 34 | __PACKAGE__->register_method({ 35 | name => 'syncjob_index', 36 | path => '', 37 | method => 'GET', 38 | description => "List configured realm-sync-jobs.", 39 | permissions => { 40 | check => ['perm', '/', ['Sys.Audit']], 41 | }, 42 | parameters => { 43 | additionalProperties => 0, 44 | properties => {}, 45 | }, 46 | returns => { 47 | type => 'array', 48 | items => { 49 | type => "object", 50 | properties => { 51 | id => { 52 | description => "The ID of the entry.", 53 | type => 'string', 54 | }, 55 | enabled => { 56 | description => "If the job is enabled or not.", 57 | type => 'boolean', 58 | }, 59 | comment => { 60 | description => "A comment for the job.", 61 | type => 'string', 62 | optional => 1, 63 | }, 64 | schedule => { 65 | description => "The configured sync schedule.", 66 | type => 'string', 67 | }, 68 | realm => get_standard_option('realm'), 69 | scope => get_standard_option('sync-scope'), 70 | 'remove-vanished' => get_standard_option('sync-remove-vanished'), 71 | 'last-run' => { 72 | description => 73 | "Last execution time of the job in seconds since the beginning of the UNIX epoch", 74 | type => 'integer', 75 | optional => 1, 76 | }, 77 | 'next-run' => { 78 | description => 79 | "Next planned execution time of the job in seconds since the beginning of the UNIX epoch.", 80 | type => 'integer', 81 | optional => 1, 82 | }, 83 | }, 84 | }, 85 | links => [{ rel => 'child', href => "{id}" }], 86 | }, 87 | code => sub { 88 | my ($param) = @_; 89 | 90 | my $rpcenv = PVE::RPCEnvironment::get(); 91 | my $user = $rpcenv->get_user(); 92 | 93 | my $jobs_data = cfs_read_file('jobs.cfg'); 94 | my $order = $jobs_data->{order}; 95 | my $jobs = $jobs_data->{ids}; 96 | 97 | my $res = []; 98 | for my $jobid (sort { $order->{$a} <=> $order->{$b} } keys %$jobs) { 99 | my $job = $jobs->{$jobid}; 100 | next if $job->{type} ne 'realm-sync'; 101 | 102 | $job->{id} = $jobid; 103 | if (my $schedule = $job->{schedule}) { 104 | $job->{'last-run'} = eval { $get_cluster_last_run->($jobid) }; 105 | my $last_run = $job->{'last-run'} // time(); # current time as fallback 106 | 107 | my $calendar_event = Proxmox::RS::CalendarEvent->new($schedule); 108 | my $next_run = $calendar_event->compute_next_event($last_run); 109 | $job->{'next-run'} = $next_run if defined($next_run); 110 | } 111 | 112 | push @$res, $job; 113 | } 114 | 115 | return $res; 116 | }, 117 | }); 118 | 119 | __PACKAGE__->register_method({ 120 | name => 'read_job', 121 | path => '{id}', 122 | method => 'GET', 123 | description => "Read realm-sync job definition.", 124 | permissions => { 125 | check => ['perm', '/', ['Sys.Audit']], 126 | }, 127 | parameters => { 128 | additionalProperties => 0, 129 | properties => { 130 | id => { 131 | type => 'string', 132 | format => 'pve-configid', 133 | }, 134 | }, 135 | }, 136 | returns => { 137 | type => 'object', 138 | }, 139 | code => sub { 140 | my ($param) = @_; 141 | 142 | my $jobs = cfs_read_file('jobs.cfg'); 143 | my $id = $param->{id}; 144 | my $job = $jobs->{ids}->{$id}; 145 | return $job if $job && $job->{type} eq 'realm-sync'; 146 | 147 | raise_param_exc({ id => "No such job '$id'" }); 148 | 149 | }, 150 | }); 151 | 152 | __PACKAGE__->register_method({ 153 | name => 'create_job', 154 | path => '{id}', 155 | method => 'POST', 156 | protected => 1, 157 | description => "Create new realm-sync job.", 158 | permissions => { 159 | description => "'Realm.AllocateUser' on '/access/realm/' and " 160 | . "'User.Modify' permissions to '/access/groups/'.", 161 | check => [ 162 | 'and', 163 | ['perm', '/access/realm/{realm}', ['Realm.AllocateUser']], 164 | ['perm', '/access/groups', ['User.Modify']], 165 | ], 166 | }, 167 | parameters => PVE::Jobs::RealmSync->createSchema(), 168 | returns => { type => 'null' }, 169 | code => sub { 170 | my ($param) = @_; 171 | 172 | my $id = extract_param($param, 'id'); 173 | 174 | cfs_lock_file( 175 | 'jobs.cfg', 176 | undef, 177 | sub { 178 | my $data = cfs_read_file('jobs.cfg'); 179 | 180 | die "Job '$id' already exists\n" 181 | if $data->{ids}->{$id}; 182 | 183 | my $plugin = PVE::Job::Registry->lookup('realm-sync'); 184 | my $opts = $plugin->check_config($id, $param, 1, 1); 185 | 186 | my $realm = $opts->{realm}; 187 | my $cfg = cfs_read_file('domains.cfg'); 188 | 189 | raise_param_exc({ realm => "No such realm '$realm'" }) 190 | if !defined($cfg->{ids}->{$realm}); 191 | 192 | my $realm_type = $cfg->{ids}->{$realm}->{type}; 193 | raise_param_exc({ realm => "Only LDAP/AD realms can be synced." }) 194 | if $realm_type ne 'ldap' && $realm_type ne 'ad'; 195 | 196 | $data->{ids}->{$id} = $opts; 197 | 198 | cfs_write_file('jobs.cfg', $data); 199 | }, 200 | ); 201 | die "$@" if ($@); 202 | 203 | return undef; 204 | }, 205 | }); 206 | 207 | __PACKAGE__->register_method({ 208 | name => 'update_job', 209 | path => '{id}', 210 | method => 'PUT', 211 | protected => 1, 212 | description => "Update realm-sync job definition.", 213 | permissions => { 214 | description => "'Realm.AllocateUser' on '/access/realm/' and 'User.Modify'" 215 | . " permissions to '/access/groups/'.", 216 | check => [ 217 | 'and', 218 | ['perm', '/access/realm/{realm}', ['Realm.AllocateUser']], 219 | ['perm', '/access/groups', ['User.Modify']], 220 | ], 221 | }, 222 | parameters => PVE::Jobs::RealmSync->updateSchema(), 223 | returns => { type => 'null' }, 224 | code => sub { 225 | my ($param) = @_; 226 | 227 | my $id = extract_param($param, 'id'); 228 | my $delete = extract_param($param, 'delete'); 229 | $delete = [PVE::Tools::split_list($delete)] if $delete; 230 | 231 | die "no job options specified\n" if !scalar(keys %$param); 232 | 233 | cfs_lock_file( 234 | 'jobs.cfg', 235 | undef, 236 | sub { 237 | my $jobs = cfs_read_file('jobs.cfg'); 238 | 239 | my $plugin = PVE::Job::Registry->lookup('realm-sync'); 240 | my $opts = $plugin->check_config($id, $param, 0, 1); 241 | 242 | my $job = $jobs->{ids}->{$id}; 243 | die "no such realm-sync job\n" if !$job || $job->{type} ne 'realm-sync'; 244 | 245 | my $options = $plugin->options(); 246 | PVE::SectionConfig::delete_from_config($job, $options, $opts, $delete); 247 | 248 | $job->{$_} = $param->{$_} for keys $param->%*; 249 | 250 | cfs_write_file('jobs.cfg', $jobs); 251 | 252 | return; 253 | }, 254 | ); 255 | die "$@" if ($@); 256 | }, 257 | }); 258 | 259 | __PACKAGE__->register_method({ 260 | name => 'delete_job', 261 | path => '{id}', 262 | method => 'DELETE', 263 | description => "Delete realm-sync job definition.", 264 | permissions => { 265 | check => ['perm', '/', ['Sys.Modify']], 266 | }, 267 | protected => 1, 268 | parameters => { 269 | additionalProperties => 0, 270 | properties => { 271 | id => { 272 | type => 'string', 273 | format => 'pve-configid', 274 | }, 275 | }, 276 | }, 277 | returns => { type => 'null' }, 278 | code => sub { 279 | my ($param) = @_; 280 | 281 | my $id = $param->{id}; 282 | 283 | cfs_lock_file( 284 | 'jobs.cfg', 285 | undef, 286 | sub { 287 | my $jobs = cfs_read_file('jobs.cfg'); 288 | 289 | if ( 290 | !defined($jobs->{ids}->{$id}) 291 | || $jobs->{ids}->{$id}->{type} ne 'realm-sync' 292 | ) { 293 | raise_param_exc({ id => "No such job '$id'" }); 294 | } 295 | delete $jobs->{ids}->{$id}; 296 | 297 | cfs_write_file('jobs.cfg', $jobs); 298 | PVE::Jobs::RealmSync::save_state($id, undef); 299 | }, 300 | ); 301 | die "$@" if $@; 302 | 303 | return undef; 304 | }, 305 | }); 306 | 307 | 1; 308 | -------------------------------------------------------------------------------- /src/PVE/API2/ACL.pm: -------------------------------------------------------------------------------- 1 | package PVE::API2::ACL; 2 | 3 | use strict; 4 | use warnings; 5 | use PVE::Cluster qw (cfs_read_file cfs_write_file); 6 | use PVE::Tools qw(split_list); 7 | use PVE::AccessControl; 8 | use PVE::Exception qw(raise_param_exc); 9 | use PVE::JSONSchema qw(get_standard_option register_standard_option); 10 | 11 | use PVE::SafeSyslog; 12 | 13 | use PVE::RESTHandler; 14 | 15 | use base qw(PVE::RESTHandler); 16 | 17 | register_standard_option( 18 | 'acl-propagate', 19 | { 20 | description => "Allow to propagate (inherit) permissions.", 21 | type => 'boolean', 22 | optional => 1, 23 | default => 1, 24 | }, 25 | ); 26 | register_standard_option( 27 | 'acl-path', 28 | { 29 | description => "Access control path", 30 | type => 'string', 31 | }, 32 | ); 33 | 34 | __PACKAGE__->register_method({ 35 | name => 'read_acl', 36 | path => '', 37 | method => 'GET', 38 | description => "Get Access Control List (ACLs).", 39 | permissions => { 40 | description => 41 | "The returned list is restricted to objects where you have rights to modify permissions.", 42 | user => 'all', 43 | }, 44 | parameters => { 45 | additionalProperties => 0, 46 | properties => {}, 47 | }, 48 | returns => { 49 | type => 'array', 50 | items => { 51 | type => "object", 52 | additionalProperties => 0, 53 | properties => { 54 | propagate => get_standard_option('acl-propagate'), 55 | path => get_standard_option('acl-path'), 56 | type => { type => 'string', enum => ['user', 'group', 'token'] }, 57 | ugid => { type => 'string' }, 58 | roleid => { type => 'string' }, 59 | }, 60 | }, 61 | }, 62 | code => sub { 63 | my ($param) = @_; 64 | 65 | my $rpcenv = PVE::RPCEnvironment::get(); 66 | my $authuser = $rpcenv->get_user(); 67 | my $res = []; 68 | 69 | my $usercfg = $rpcenv->{user_cfg}; 70 | if (!$usercfg || !$usercfg->{acl_root}) { 71 | return $res; 72 | } 73 | 74 | my $audit = $rpcenv->check($authuser, '/access', ['Sys.Audit'], 1); 75 | 76 | my $root = $usercfg->{acl_root}; 77 | PVE::AccessControl::iterate_acl_tree( 78 | "/", 79 | $root, 80 | sub { 81 | my ($path, $node) = @_; 82 | foreach my $type (qw(user group token)) { 83 | my $d = $node->{"${type}s"}; 84 | next if !$d; 85 | next if !($audit || $rpcenv->check_perm_modify($authuser, $path, 1)); 86 | foreach my $id (keys %$d) { 87 | foreach my $role (keys %{ $d->{$id} }) { 88 | my $propagate = $d->{$id}->{$role}; 89 | push @$res, 90 | { 91 | path => $path, 92 | type => $type, 93 | ugid => $id, 94 | roleid => $role, 95 | propagate => $propagate, 96 | }; 97 | } 98 | } 99 | } 100 | }, 101 | ); 102 | 103 | return $res; 104 | }, 105 | }); 106 | 107 | __PACKAGE__->register_method({ 108 | name => 'update_acl', 109 | protected => 1, 110 | path => '', 111 | method => 'PUT', 112 | permissions => { 113 | check => ['perm-modify', '{path}'], 114 | }, 115 | description => "Update Access Control List (add or remove permissions).", 116 | parameters => { 117 | additionalProperties => 0, 118 | properties => { 119 | propagate => get_standard_option('acl-propagate'), 120 | path => get_standard_option('acl-path'), 121 | users => { 122 | description => "List of users.", 123 | type => 'string', 124 | format => 'pve-userid-list', 125 | optional => 1, 126 | }, 127 | groups => { 128 | description => "List of groups.", 129 | type => 'string', 130 | format => 'pve-groupid-list', 131 | optional => 1, 132 | }, 133 | tokens => { 134 | description => "List of API tokens.", 135 | type => 'string', 136 | format => 'pve-tokenid-list', 137 | optional => 1, 138 | }, 139 | roles => { 140 | description => "List of roles.", 141 | type => 'string', 142 | format => 'pve-roleid-list', 143 | }, 144 | delete => { 145 | description => "Remove permissions (instead of adding it).", 146 | type => 'boolean', 147 | optional => 1, 148 | }, 149 | }, 150 | }, 151 | returns => { type => 'null' }, 152 | code => sub { 153 | my ($param) = @_; 154 | 155 | if (!($param->{users} || $param->{groups} || $param->{tokens})) { 156 | raise_param_exc({ 157 | map { $_ => "either 'users', 'groups' or 'tokens' is required." } 158 | qw(users groups tokens) 159 | }); 160 | } 161 | 162 | my $path = PVE::AccessControl::normalize_path($param->{path}); 163 | raise_param_exc({ path => "invalid ACL path '$param->{path}'" }) if !$path; 164 | 165 | if (!$param->{delete} && !PVE::AccessControl::check_path($path)) { 166 | raise_param_exc({ path => "invalid ACL path '$param->{path}'" }); 167 | } 168 | 169 | PVE::AccessControl::lock_user_config( 170 | sub { 171 | my $cfg = cfs_read_file("user.cfg"); 172 | 173 | my $rpcenv = PVE::RPCEnvironment::get(); 174 | my $authuser = $rpcenv->get_user(); 175 | my $auth_user_privs = $rpcenv->permissions($authuser, $path); 176 | 177 | my $propagate = 1; 178 | 179 | if (defined($param->{propagate})) { 180 | $propagate = $param->{propagate} ? 1 : 0; 181 | } 182 | 183 | my $node = PVE::AccessControl::find_acl_tree_node($cfg->{acl_root}, $path); 184 | 185 | foreach my $role (split_list($param->{roles})) { 186 | die "role '$role' does not exist\n" 187 | if !$cfg->{roles}->{$role}; 188 | 189 | # permissions() returns set privs as key, and propagate bit as value! 190 | if (!defined($auth_user_privs->{'Permissions.Modify'})) { 191 | # 'perm-modify' allows /vms/* with VM.Allocate and similar restricted use cases 192 | # filter those to only allow handing out a subset of currently active privs 193 | my $role_privs = $cfg->{roles}->{$role}; 194 | my $verb = $param->{delete} ? 'remove' : 'add'; 195 | foreach my $priv (keys $role_privs->%*) { 196 | raise_param_exc( 197 | { 198 | role => 199 | "Cannot $verb role '$role' - requires 'Permissions.Modify' or superset of privileges.", 200 | }, 201 | ) if !defined($auth_user_privs->{$priv}); 202 | 203 | # propagation is only potentially problematic for adding ACLs, not removing.. 204 | raise_param_exc( 205 | { 206 | role => 207 | "Cannot $verb role '$role' with propagation - requires 'Permissions.Modify' or propagated superset of privileges.", 208 | }, 209 | ) 210 | if $propagate 211 | && $auth_user_privs->{$priv} != $propagate 212 | && !$param->{delete}; 213 | } 214 | 215 | # NoAccess has no privs, needs an explicit check 216 | raise_param_exc( 217 | { 218 | role => 219 | "Cannot $verb role '$role' - requires 'Permissions.Modify'", 220 | }, 221 | ) if $role eq 'NoAccess'; 222 | } 223 | 224 | foreach my $group (split_list($param->{groups})) { 225 | 226 | die "group '$group' does not exist\n" 227 | if !$cfg->{groups}->{$group}; 228 | 229 | if ($param->{delete}) { 230 | delete($node->{groups}->{$group}->{$role}); 231 | } else { 232 | $node->{groups}->{$group}->{$role} = $propagate; 233 | } 234 | } 235 | 236 | foreach my $userid (split_list($param->{users})) { 237 | my $username = PVE::AccessControl::verify_username($userid); 238 | 239 | die "user '$username' does not exist\n" 240 | if !$cfg->{users}->{$username}; 241 | 242 | if ($param->{delete}) { 243 | delete($node->{users}->{$username}->{$role}); 244 | } else { 245 | $node->{users}->{$username}->{$role} = $propagate; 246 | } 247 | } 248 | 249 | foreach my $tokenid (split_list($param->{tokens})) { 250 | my ($username, $token) = PVE::AccessControl::split_tokenid($tokenid); 251 | PVE::AccessControl::check_token_exist($cfg, $username, $token); 252 | 253 | if ($param->{delete}) { 254 | delete $node->{tokens}->{$tokenid}->{$role}; 255 | } else { 256 | $node->{tokens}->{$tokenid}->{$role} = $propagate; 257 | } 258 | } 259 | } 260 | 261 | cfs_write_file("user.cfg", $cfg); 262 | }, 263 | "ACL update failed", 264 | ); 265 | 266 | return undef; 267 | }, 268 | }); 269 | 270 | 1; 271 | -------------------------------------------------------------------------------- /src/test/api-get-permissions-test.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use lib qw(..); 7 | 8 | use PVE::Tools; 9 | 10 | use Test::More; 11 | use Test::MockModule; 12 | 13 | use PVE::AccessControl; 14 | use PVE::RPCEnvironment; 15 | use PVE::API2::AccessControl; 16 | 17 | my $cluster_module = Test::MockModule->new('PVE::Cluster'); 18 | # make cfs_update a stub as it's not relevant to the test cases and will 19 | # make these tests fail if the user doesn't have access to the cluster ipcc 20 | $cluster_module->noop('cfs_update'); 21 | 22 | my $rpcenv = PVE::RPCEnvironment->init('cli'); 23 | $rpcenv->init_request(userconfig => 'api-get-permissions-test.cfg'); 24 | 25 | my ($handler, $handler_info) = PVE::API2::AccessControl->find_handler('GET', 'permissions'); 26 | 27 | # stranger = user without Sys.Audit permission 28 | my $stranger_perms = $rpcenv->get_effective_permissions('stranger@pve'); 29 | my $stranger_privsep_perms = $rpcenv->get_effective_permissions('stranger@pve!privsep'); 30 | 31 | my $stranger_user_tests = [ 32 | { 33 | description => 'get stranger\'s perms without passing the user\'s userid', 34 | rpcuser => 'stranger@pve', 35 | params => {}, 36 | result => $stranger_perms, 37 | }, 38 | { 39 | description => 'get stranger\'s perms with passing the user\'s userid', 40 | rpcuser => 'stranger@pve', 41 | params => { 42 | userid => 'stranger@pve', 43 | }, 44 | result => $stranger_perms, 45 | }, 46 | { 47 | description => 'get stranger-owned non-priv-sep\'d token\'s perms from stranger user', 48 | rpcuser => 'stranger@pve', 49 | params => { 50 | userid => 'stranger@pve!noprivsep', 51 | }, 52 | result => $stranger_perms, 53 | }, 54 | { 55 | description => 'get stranger-owned priv-sep\'d token\'s perms from stranger user', 56 | rpcuser => 'stranger@pve', 57 | params => { 58 | userid => 'stranger@pve!privsep', 59 | }, 60 | result => $stranger_privsep_perms, 61 | }, 62 | { 63 | description => 'get auditor\'s perms from stranger user', 64 | should_fail => 1, 65 | rpcuser => 'stranger@pve', 66 | params => { 67 | userid => 'auditor@pam', 68 | }, 69 | }, 70 | { 71 | description => 'get auditor-owned token\'s perms from stranger user', 72 | should_fail => 1, 73 | rpcuser => 'stranger@pve', 74 | params => { 75 | userid => 'auditor@pam!noprivsep', 76 | }, 77 | }, 78 | ]; 79 | 80 | my $stranger_nonprivsep_tests = [ 81 | { 82 | description => 83 | 'get stranger-owned non-priv-sep\'d token\'s perms without passing the token', 84 | rpcuser => 'stranger@pve!noprivsep', 85 | params => {}, 86 | result => $stranger_perms, 87 | }, 88 | { 89 | description => 90 | 'get stranger-owned non-priv-sep\'d token\'s perms with passing the token', 91 | rpcuser => 'stranger@pve!noprivsep', 92 | params => { 93 | userid => 'stranger@pve!noprivsep', 94 | }, 95 | result => $stranger_perms, 96 | }, 97 | { 98 | description => 'get stranger\'s perms from stranger-owned non-priv-sep\'d token', 99 | should_fail => 1, 100 | rpcuser => 'stranger@pve!noprivsep', 101 | params => { 102 | userid => 'stranger@pve', 103 | }, 104 | }, 105 | { 106 | description => 'get stranger-owned priv-sep\'d token\'s perms ' 107 | . 'from stranger-owned non-priv-sep\'d token', 108 | should_fail => 1, 109 | rpcuser => 'stranger@pve!noprivsep', 110 | params => { 111 | userid => 'stranger@pve!privsep', 112 | }, 113 | }, 114 | { 115 | description => 116 | 'get auditor-owned token\'s perms from stranger-owned non-priv-sep\'d token', 117 | should_fail => 1, 118 | rpcuser => 'stranger@pve!noprivsep', 119 | params => { 120 | userid => 'auditor@pam!noprivsep', 121 | }, 122 | }, 123 | ]; 124 | 125 | my $stranger_privsep_tests = [ 126 | { 127 | description => 128 | 'get stranger-owned priv-sep\'d token\'s perms without passing the token', 129 | rpcuser => 'stranger@pve!privsep', 130 | params => {}, 131 | result => $stranger_privsep_perms, 132 | }, 133 | { 134 | description => 'get stranger-owned priv-sep\'d token\'s perms with passing the token', 135 | rpcuser => 'stranger@pve!privsep', 136 | params => { 137 | userid => 'stranger@pve!privsep', 138 | }, 139 | result => $stranger_privsep_perms, 140 | }, 141 | { 142 | description => 'get stranger\'s perms from stranger-owned priv-sep\'d token', 143 | should_fail => 1, 144 | rpcuser => 'stranger@pve!privsep', 145 | params => { 146 | userid => 'stranger@pve', 147 | }, 148 | }, 149 | { 150 | description => 'get stranger-owned non-priv-sep\'d token\'s perms ' 151 | . 'from stranger-owned priv-sep\'d token', 152 | should_fail => 1, 153 | rpcuser => 'stranger@pve!privsep', 154 | params => { 155 | userid => 'stranger@pve!noprivsep', 156 | }, 157 | }, 158 | { 159 | description => 'get auditor-owned token\'s perms from stranger-owned priv-sep\'d token', 160 | should_fail => 1, 161 | rpcuser => 'stranger@pve!privsep', 162 | params => { 163 | userid => 'auditor@pam!noprivsep', 164 | }, 165 | }, 166 | ]; 167 | 168 | # auditor = user with Sys.Audit permission 169 | my $auditor_perms = $rpcenv->get_effective_permissions('auditor@pam'); 170 | my $auditor_privsep_perms = $rpcenv->get_effective_permissions('auditor@pam!privsep'); 171 | 172 | my $auditor_user_tests = [ 173 | { 174 | description => 'get auditor\'s perms without passing the user\'s userid', 175 | rpcuser => 'auditor@pam', 176 | params => {}, 177 | result => $auditor_perms, 178 | }, 179 | { 180 | description => 'get auditor\'s perms with passing the user\'s userid', 181 | rpcuser => 'auditor@pam', 182 | params => { 183 | userid => 'auditor@pam', 184 | }, 185 | result => $auditor_perms, 186 | }, 187 | { 188 | description => 'get auditor-owned non-priv-sep\'d token\'s perms from auditor user', 189 | rpcuser => 'auditor@pam', 190 | params => { 191 | userid => 'auditor@pam!noprivsep', 192 | }, 193 | result => $auditor_perms, 194 | }, 195 | { 196 | description => 'get auditor-owned priv-sep\'d token\'s perms from auditor user', 197 | rpcuser => 'auditor@pam', 198 | params => { 199 | userid => 'auditor@pam!privsep', 200 | }, 201 | result => $auditor_privsep_perms, 202 | }, 203 | { 204 | description => 'get stranger\'s perms from auditor user', 205 | rpcuser => 'auditor@pam', 206 | params => { 207 | userid => 'stranger@pve', 208 | }, 209 | result => $stranger_perms, 210 | }, 211 | { 212 | description => 'get stranger-owned token\'s perms from auditor user', 213 | rpcuser => 'auditor@pam', 214 | params => { 215 | userid => 'stranger@pve!noprivsep', 216 | }, 217 | result => $stranger_perms, 218 | }, 219 | ]; 220 | 221 | my $auditor_nonprivsep_tests = [ 222 | { 223 | description => 224 | 'get auditor-owned non-priv-sep\'d token\'s perms without passing the token', 225 | rpcuser => 'auditor@pam!noprivsep', 226 | params => {}, 227 | result => $auditor_perms, 228 | }, 229 | { 230 | description => 231 | 'get auditor-owned non-priv-sep\'d token\'s perms with passing the token', 232 | rpcuser => 'auditor@pam!noprivsep', 233 | params => { 234 | userid => 'auditor@pam!noprivsep', 235 | }, 236 | result => $auditor_perms, 237 | }, 238 | { 239 | description => 'get auditor\'s perms from auditor-owned non-priv-sep\'d token', 240 | rpcuser => 'auditor@pam!noprivsep', 241 | params => { 242 | userid => 'auditor@pam', 243 | }, 244 | result => $auditor_perms, 245 | }, 246 | { 247 | description => 'get auditor-owned priv-sep\'d token\'s perms ' 248 | . 'from auditor-owned non-priv-sep\'d token', 249 | rpcuser => 'auditor@pam!noprivsep', 250 | params => { 251 | userid => 'auditor@pam!privsep', 252 | }, 253 | result => $auditor_privsep_perms, 254 | }, 255 | { 256 | description => 257 | 'get stranger-owned token\'s perms from auditor-owned non-priv-sep\'d token', 258 | rpcuser => 'auditor@pam!noprivsep', 259 | params => { 260 | userid => 'stranger@pve!noprivsep', 261 | }, 262 | result => $stranger_perms, 263 | }, 264 | ]; 265 | 266 | my $auditor_privsep_tests = [ 267 | { 268 | description => 'get auditor-owned priv-sep\'d token\'s perms without passing the token', 269 | rpcuser => 'auditor@pam!privsep', 270 | params => {}, 271 | result => $auditor_privsep_perms, 272 | }, 273 | { 274 | description => 'get auditor-owned priv-sep\'d token\'s perms with passing the token', 275 | rpcuser => 'auditor@pam!privsep', 276 | params => { 277 | userid => 'auditor@pam!privsep', 278 | }, 279 | result => $auditor_privsep_perms, 280 | }, 281 | { 282 | description => 'get auditor\'s perms from auditor-owned priv-sep\'d token', 283 | should_fail => 1, 284 | rpcuser => 'auditor@pam!privsep', 285 | params => { 286 | userid => 'auditor@pam', 287 | }, 288 | }, 289 | { 290 | description => 'get auditor-owned non-priv-sep\'d token\'s perms ' 291 | . 'from auditor-owned priv-sep\'d token', 292 | should_fail => 1, 293 | rpcuser => 'auditor@pam!privsep', 294 | params => { 295 | userid => 'auditor@pam!noprivsep', 296 | }, 297 | }, 298 | { 299 | description => 'get stranger-owned token\'s perms from auditor-owned priv-sep\'d token', 300 | should_fail => 1, 301 | rpcuser => 'auditor@pam!privsep', 302 | params => { 303 | userid => 'stranger@pve!noprivsep', 304 | }, 305 | }, 306 | ]; 307 | 308 | my $tests = [ 309 | @$stranger_user_tests, 310 | @$stranger_nonprivsep_tests, 311 | @$stranger_privsep_tests, 312 | @$auditor_user_tests, 313 | @$auditor_nonprivsep_tests, 314 | @$auditor_privsep_tests, 315 | ]; 316 | 317 | plan(tests => scalar($tests->@*)); 318 | 319 | for my $case ($tests->@*) { 320 | $rpcenv->set_user($case->{rpcuser}); 321 | 322 | my $result = eval { $handler->handle($handler_info, $case->{params}) }; 323 | 324 | if ($@) { 325 | my $should_fail = exists($case->{should_fail}) ? $case->{should_fail} : 0; 326 | is(defined($@), $should_fail, "should fail: $case->{description}") || diag explain $@; 327 | } else { 328 | is_deeply($result, $case->{result}, $case->{description}); 329 | } 330 | } 331 | 332 | done_testing(); 333 | -------------------------------------------------------------------------------- /src/test/realm_sync_test.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Test::MockModule; 7 | use Test::More; 8 | use Storable qw(dclone); 9 | 10 | use PVE::AccessControl; 11 | use PVE::API2::Domains; 12 | 13 | my $domainscfg = { 14 | ids => { 15 | "pam" => { type => 'pam' }, 16 | "pve" => { type => 'pve' }, 17 | "syncedrealm" => { type => 'ldap' }, 18 | }, 19 | }; 20 | 21 | my $initialusercfg = { 22 | users => { 23 | 'root@pam' => { username => 'root' }, 24 | 'user1@syncedrealm' => { 25 | username => 'user1', 26 | enable => 1, 27 | 'keys' => 'some', 28 | }, 29 | 'user2@syncedrealm' => { 30 | username => 'user2', 31 | enable => 1, 32 | }, 33 | 'user3@syncedrealm' => { 34 | username => 'user3', 35 | enable => 1, 36 | }, 37 | }, 38 | groups => { 39 | 'group1-syncedrealm' => { users => {} }, 40 | 'group2-syncedrealm' => { users => {} }, 41 | }, 42 | acl_root => { 43 | users => { 44 | 'user3@syncedrealm' => {}, 45 | }, 46 | groups => {}, 47 | }, 48 | }; 49 | 50 | my $sync_response = { 51 | user => [ 52 | { 53 | attributes => { 'uid' => ['user1'] }, 54 | dn => 'uid=user1,dc=syncedrealm', 55 | }, 56 | { 57 | attributes => { 'uid' => ['user2'] }, 58 | dn => 'uid=user2,dc=syncedrealm', 59 | }, 60 | { 61 | attributes => { 'uid' => ['user4'] }, 62 | dn => 'uid=user4,dc=syncedrealm', 63 | }, 64 | ], 65 | groups => [ 66 | { 67 | dn => 'dc=group1,dc=syncedrealm', 68 | members => [ 69 | 'uid=user1,dc=syncedrealm', 70 | ], 71 | }, 72 | { 73 | dn => 'dc=group3,dc=syncedrealm', 74 | members => [ 75 | 'uid=nonexisting,dc=syncedrealm', 76 | ], 77 | }, 78 | ], 79 | }; 80 | 81 | my $returned_user_cfg = {}; 82 | 83 | # mocking all cluster and ldap operations 84 | my $pve_cluster_module = Test::MockModule->new('PVE::Cluster'); 85 | $pve_cluster_module->mock( 86 | cfs_update => sub { }, 87 | cfs_read_file => sub { 88 | my ($filename) = @_; 89 | if ($filename eq 'domains.cfg') { return dclone($domainscfg); } 90 | if ($filename eq 'user.cfg') { return dclone($initialusercfg); } 91 | die "unexpected cfs_read_file"; 92 | }, 93 | cfs_write_file => sub { 94 | my ($filename, $data) = @_; 95 | if ($filename eq 'user.cfg') { 96 | $returned_user_cfg = $data; 97 | return; 98 | } 99 | die "unexpected cfs_read_file"; 100 | }, 101 | cfs_lock_file => sub { 102 | my ($filename, $timeout, $code) = @_; 103 | return $code->(); 104 | }, 105 | ); 106 | 107 | my $pve_api_domains = Test::MockModule->new('PVE::API2::Domains'); 108 | $pve_api_domains->mock( 109 | cfs_read_file => sub { PVE::Cluster::cfs_read_file(@_); }, 110 | cfs_write_file => sub { PVE::Cluster::cfs_write_file(@_); }, 111 | ); 112 | 113 | my $pve_accesscontrol = Test::MockModule->new('PVE::AccessControl'); 114 | $pve_accesscontrol->mock( 115 | cfs_lock_file => sub { PVE::Cluster::cfs_lock_file(@_); }, 116 | ); 117 | 118 | my $pve_rpcenvironment = Test::MockModule->new('PVE::RPCEnvironment'); 119 | $pve_rpcenvironment->mock( 120 | get => sub { return bless {}, 'PVE::RPCEnvironment'; }, 121 | get_user => sub { return 'root@pam'; }, 122 | fork_worker => sub { 123 | my ($class, $workertype, $id, $user, $code) = @_; 124 | 125 | return $code->(); 126 | }, 127 | ); 128 | 129 | my $pve_ldap_module = Test::MockModule->new('PVE::LDAP'); 130 | $pve_ldap_module->mock( 131 | ldap_connect => sub { return {}; }, 132 | ldap_bind => sub { }, 133 | query_users => sub { 134 | return $sync_response->{user}; 135 | }, 136 | query_groups => sub { 137 | return $sync_response->{groups}; 138 | }, 139 | ); 140 | 141 | my $pve_auth_ldap = Test::MockModule->new('PVE::Auth::LDAP'); 142 | $pve_auth_ldap->mock( 143 | connect_and_bind => sub { return {}; }, 144 | ); 145 | 146 | my $tests = [ 147 | [ 148 | "non-full without purge", 149 | { 150 | realm => 'syncedrealm', 151 | scope => 'both', 152 | }, 153 | { 154 | users => { 155 | 'root@pam' => { username => 'root' }, 156 | 'user1@syncedrealm' => { 157 | username => 'user1', 158 | enable => 1, 159 | 'keys' => 'some', 160 | }, 161 | 'user2@syncedrealm' => { 162 | username => 'user2', 163 | enable => 1, 164 | }, 165 | 'user3@syncedrealm' => { 166 | username => 'user3', 167 | enable => 1, 168 | }, 169 | 'user4@syncedrealm' => { 170 | username => 'user4', 171 | enable => 1, 172 | }, 173 | }, 174 | groups => { 175 | 'group1-syncedrealm' => { 176 | users => { 177 | 'user1@syncedrealm' => 1, 178 | }, 179 | }, 180 | 'group2-syncedrealm' => { users => {} }, 181 | 'group3-syncedrealm' => { users => {} }, 182 | }, 183 | acl_root => { 184 | users => { 185 | 'user3@syncedrealm' => {}, 186 | }, 187 | groups => {}, 188 | }, 189 | }, 190 | ], 191 | [ 192 | "full without purge", 193 | { 194 | realm => 'syncedrealm', 195 | 'remove-vanished' => 'entry;properties', 196 | scope => 'both', 197 | }, 198 | { 199 | users => { 200 | 'root@pam' => { username => 'root' }, 201 | 'user1@syncedrealm' => { 202 | username => 'user1', 203 | enable => 1, 204 | }, 205 | 'user2@syncedrealm' => { 206 | username => 'user2', 207 | enable => 1, 208 | }, 209 | 'user4@syncedrealm' => { 210 | username => 'user4', 211 | enable => 1, 212 | }, 213 | }, 214 | groups => { 215 | 'group1-syncedrealm' => { 216 | users => { 217 | 'user1@syncedrealm' => 1, 218 | }, 219 | }, 220 | 'group3-syncedrealm' => { users => {} }, 221 | }, 222 | acl_root => { 223 | users => { 224 | 'user3@syncedrealm' => {}, 225 | }, 226 | groups => {}, 227 | }, 228 | }, 229 | ], 230 | [ 231 | "non-full with purge", 232 | { 233 | realm => 'syncedrealm', 234 | 'remove-vanished' => 'acl', 235 | scope => 'both', 236 | }, 237 | { 238 | users => { 239 | 'root@pam' => { username => 'root' }, 240 | 'user1@syncedrealm' => { 241 | username => 'user1', 242 | enable => 1, 243 | 'keys' => 'some', 244 | }, 245 | 'user2@syncedrealm' => { 246 | username => 'user2', 247 | enable => 1, 248 | }, 249 | 'user3@syncedrealm' => { 250 | username => 'user3', 251 | enable => 1, 252 | }, 253 | 'user4@syncedrealm' => { 254 | username => 'user4', 255 | enable => 1, 256 | }, 257 | }, 258 | groups => { 259 | 'group1-syncedrealm' => { 260 | users => { 261 | 'user1@syncedrealm' => 1, 262 | }, 263 | }, 264 | 'group2-syncedrealm' => { users => {} }, 265 | 'group3-syncedrealm' => { users => {} }, 266 | }, 267 | acl_root => { 268 | users => {}, 269 | groups => {}, 270 | }, 271 | }, 272 | ], 273 | [ 274 | "full with purge", 275 | { 276 | realm => 'syncedrealm', 277 | 'remove-vanished' => 'acl;entry;properties', 278 | scope => 'both', 279 | }, 280 | { 281 | users => { 282 | 'root@pam' => { username => 'root' }, 283 | 'user1@syncedrealm' => { 284 | username => 'user1', 285 | enable => 1, 286 | }, 287 | 'user2@syncedrealm' => { 288 | username => 'user2', 289 | enable => 1, 290 | }, 291 | 'user4@syncedrealm' => { 292 | username => 'user4', 293 | enable => 1, 294 | }, 295 | }, 296 | groups => { 297 | 'group1-syncedrealm' => { 298 | users => { 299 | 'user1@syncedrealm' => 1, 300 | }, 301 | }, 302 | 'group3-syncedrealm' => { users => {} }, 303 | }, 304 | acl_root => { 305 | users => {}, 306 | groups => {}, 307 | }, 308 | }, 309 | ], 310 | [ 311 | "don't delete properties, but users and acls", 312 | { 313 | realm => 'syncedrealm', 314 | 'remove-vanished' => 'acl;entry', 315 | scope => 'both', 316 | }, 317 | { 318 | users => { 319 | 'root@pam' => { username => 'root' }, 320 | 'user1@syncedrealm' => { 321 | username => 'user1', 322 | enable => 1, 323 | 'keys' => 'some', 324 | }, 325 | 'user2@syncedrealm' => { 326 | username => 'user2', 327 | enable => 1, 328 | }, 329 | 'user4@syncedrealm' => { 330 | username => 'user4', 331 | enable => 1, 332 | }, 333 | }, 334 | groups => { 335 | 'group1-syncedrealm' => { 336 | users => { 337 | 'user1@syncedrealm' => 1, 338 | }, 339 | }, 340 | 'group3-syncedrealm' => { users => {} }, 341 | }, 342 | acl_root => { 343 | users => {}, 344 | groups => {}, 345 | }, 346 | }, 347 | ], 348 | ]; 349 | 350 | for my $test (@$tests) { 351 | my $name = $test->[0]; 352 | my $parameters = $test->[1]; 353 | my $expected = $test->[2]; 354 | $returned_user_cfg = {}; 355 | PVE::API2::Domains->sync($parameters); 356 | is_deeply($returned_user_cfg, $expected, $name); 357 | } 358 | 359 | done_testing(); 360 | -------------------------------------------------------------------------------- /src/PVE/Auth/Plugin.pm: -------------------------------------------------------------------------------- 1 | package PVE::Auth::Plugin; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Digest::SHA; 7 | use Encode; 8 | 9 | use PVE::Cluster qw(cfs_register_file cfs_read_file cfs_lock_file); 10 | use PVE::JSONSchema qw(get_standard_option); 11 | use PVE::SectionConfig; 12 | use PVE::Tools; 13 | 14 | use base qw(PVE::SectionConfig); 15 | 16 | my $domainconfigfile = "domains.cfg"; 17 | 18 | cfs_register_file( 19 | $domainconfigfile, 20 | sub { __PACKAGE__->parse_config(@_); }, 21 | sub { __PACKAGE__->write_config(@_); }, 22 | ); 23 | 24 | sub lock_domain_config { 25 | my ($code, $errmsg) = @_; 26 | 27 | cfs_lock_file($domainconfigfile, undef, $code); 28 | my $err = $@; 29 | if ($err) { 30 | $errmsg ? die "$errmsg: $err" : die $err; 31 | } 32 | } 33 | 34 | our $realm_regex = qr/[A-Za-z][A-Za-z0-9\.\-_]+/; 35 | our $user_regex = qr![^\s:/]+!; 36 | our $groupname_regex = qr/[A-Za-z0-9\.\-_]+/; 37 | 38 | PVE::JSONSchema::register_format('pve-realm', \&pve_verify_realm); 39 | 40 | sub pve_verify_realm { 41 | my ($realm, $noerr) = @_; 42 | 43 | if ($realm !~ m/^${realm_regex}$/) { 44 | return undef if $noerr; 45 | die "value does not look like a valid realm\n"; 46 | } 47 | return $realm; 48 | } 49 | 50 | PVE::JSONSchema::register_standard_option( 51 | 'realm', 52 | { 53 | description => "Authentication domain ID", 54 | type => 'string', 55 | format => 'pve-realm', 56 | maxLength => 32, 57 | }, 58 | ); 59 | 60 | my $remove_options = "(?:acl|properties|entry)"; 61 | 62 | PVE::JSONSchema::register_standard_option( 63 | 'sync-scope', 64 | { 65 | description => "Select what to sync.", 66 | type => 'string', 67 | enum => [qw(users groups both)], 68 | optional => '1', 69 | }, 70 | ); 71 | 72 | PVE::JSONSchema::register_standard_option( 73 | 'sync-remove-vanished', 74 | { 75 | description => "A semicolon-separated list of things to remove when they or the user" 76 | . " vanishes during a sync. The following values are possible: 'entry' removes the" 77 | . " user/group when not returned from the sync. 'properties' removes the set" 78 | . " properties on existing user/group that do not appear in the source (even custom ones)." 79 | . " 'acl' removes acls when the user/group is not returned from the sync." 80 | . " Instead of a list it also can be 'none' (the default).", 81 | type => 'string', 82 | default => 'none', 83 | typetext => "([acl];[properties];[entry])|none", 84 | pattern => "(?:(?:$remove_options\;)*$remove_options)|none", 85 | optional => '1', 86 | }, 87 | ); 88 | 89 | my $realm_sync_options_desc = { 90 | scope => get_standard_option('sync-scope'), 91 | 'remove-vanished' => get_standard_option('sync-remove-vanished'), 92 | # TODO check/rewrite in pve7to8, and remove with 8.0 93 | full => { 94 | description => 95 | "DEPRECATED: use 'remove-vanished' instead. If set, uses the LDAP Directory as source of truth," 96 | . " deleting users or groups not returned from the sync and removing" 97 | . " all locally modified properties of synced users. If not set," 98 | . " only syncs information which is present in the synced data, and does not" 99 | . " delete or modify anything else.", 100 | type => 'boolean', 101 | optional => '1', 102 | }, 103 | 'enable-new' => { 104 | description => "Enable newly synced users immediately.", 105 | type => 'boolean', 106 | default => '1', 107 | optional => '1', 108 | }, 109 | purge => { 110 | description => "DEPRECATED: use 'remove-vanished' instead. Remove ACLs for users or" 111 | . " groups which were removed from the config during a sync.", 112 | type => 'boolean', 113 | optional => '1', 114 | }, 115 | }; 116 | PVE::JSONSchema::register_standard_option('realm-sync-options', $realm_sync_options_desc); 117 | PVE::JSONSchema::register_format('realm-sync-options', $realm_sync_options_desc); 118 | 119 | PVE::JSONSchema::register_format('pve-userid', \&verify_username); 120 | 121 | sub verify_username { 122 | my ($username, $noerr) = @_; 123 | 124 | $username = '' if !$username; 125 | my $len = length($username); 126 | if ($len < 3) { 127 | die "user name '$username' is too short\n" if !$noerr; 128 | return undef; 129 | } 130 | if ($len > 64) { 131 | die "user name '$username' is too long ($len > 64)\n" if !$noerr; 132 | return undef; 133 | } 134 | 135 | # we only allow a limited set of characters 136 | # colon is not allowed, because we store usernames in 137 | # colon separated lists)! 138 | # slash is not allowed because it is used as pve API delimiter 139 | # also see "man useradd" 140 | if ($username =~ m!^(${user_regex})\@(${realm_regex})$!) { 141 | return wantarray ? ($username, $1, $2) : $username; 142 | } 143 | 144 | die "value '$username' does not look like a valid user name\n" if !$noerr; 145 | 146 | return undef; 147 | } 148 | 149 | PVE::JSONSchema::register_standard_option( 150 | 'userid', 151 | { 152 | description => "Full User ID, in the `name\@realm` format.", 153 | type => 'string', 154 | format => 'pve-userid', 155 | maxLength => 64, 156 | }, 157 | ); 158 | 159 | my $tfa_format = { 160 | type => { 161 | description => "The type of 2nd factor authentication.", 162 | format_description => 'TFATYPE', 163 | type => 'string', 164 | enum => [qw(yubico oath)], 165 | }, 166 | id => { 167 | description => "Yubico API ID.", 168 | format_description => 'ID', 169 | type => 'string', 170 | optional => 1, 171 | }, 172 | key => { 173 | description => "Yubico API Key.", 174 | format_description => 'KEY', 175 | type => 'string', 176 | optional => 1, 177 | }, 178 | url => { 179 | description => "Yubico API URL.", 180 | format_description => 'URL', 181 | type => 'string', 182 | optional => 1, 183 | }, 184 | digits => { 185 | description => "TOTP digits.", 186 | format_description => 'COUNT', 187 | type => 'integer', 188 | minimum => 6, 189 | maximum => 8, 190 | default => 6, 191 | optional => 1, 192 | }, 193 | step => { 194 | description => "TOTP time period.", 195 | format_description => 'SECONDS', 196 | type => 'integer', 197 | minimum => 10, 198 | default => 30, 199 | optional => 1, 200 | }, 201 | }; 202 | 203 | PVE::JSONSchema::register_format('pve-tfa-config', $tfa_format); 204 | 205 | PVE::JSONSchema::register_standard_option( 206 | 'tfa', 207 | { 208 | description => "Use Two-factor authentication.", 209 | type => 'string', 210 | format => 'pve-tfa-config', 211 | optional => 1, 212 | maxLength => 128, 213 | }, 214 | ); 215 | 216 | sub parse_tfa_config { 217 | my ($data) = @_; 218 | 219 | return PVE::JSONSchema::parse_property_string($tfa_format, $data); 220 | } 221 | 222 | my $defaultData = { 223 | propertyList => { 224 | type => { description => "Realm type." }, 225 | realm => get_standard_option('realm'), 226 | }, 227 | }; 228 | 229 | sub private { 230 | return $defaultData; 231 | } 232 | 233 | sub parse_section_header { 234 | my ($class, $line) = @_; 235 | 236 | if ($line =~ m/^(\S+):\s*(\S+)\s*$/) { 237 | my ($type, $realm) = (lc($1), $2); 238 | my $errmsg = undef; # set if you want to skip whole section 239 | eval { pve_verify_realm($realm); }; 240 | $errmsg = $@ if $@; 241 | my $config = {}; # to return additional attributes 242 | return ($type, $realm, $errmsg, $config); 243 | } 244 | return undef; 245 | } 246 | 247 | sub parse_config { 248 | my ($class, $filename, $raw) = @_; 249 | 250 | my $cfg = $class->SUPER::parse_config($filename, $raw); 251 | 252 | my $default; 253 | foreach my $realm (keys %{ $cfg->{ids} }) { 254 | my $data = $cfg->{ids}->{$realm}; 255 | # make sure there is only one default marker 256 | if ($data->{default}) { 257 | if ($default) { 258 | delete $data->{default}; 259 | } else { 260 | $default = $realm; 261 | } 262 | } 263 | 264 | if ($data->{comment}) { 265 | $data->{comment} = PVE::Tools::decode_text($data->{comment}); 266 | } 267 | 268 | } 269 | 270 | # add default domains 271 | 272 | $cfg->{ids}->{pve}->{type} = 'pve'; # force type 273 | $cfg->{ids}->{pve}->{comment} = "Proxmox VE authentication server" 274 | if !$cfg->{ids}->{pve}->{comment}; 275 | 276 | $cfg->{ids}->{pam}->{type} = 'pam'; # force type 277 | $cfg->{ids}->{pam}->{plugin} = 'PVE::Auth::PAM'; 278 | $cfg->{ids}->{pam}->{comment} = "Linux PAM standard authentication" 279 | if !$cfg->{ids}->{pam}->{comment}; 280 | 281 | return $cfg; 282 | } 283 | 284 | sub write_config { 285 | my ($class, $filename, $cfg) = @_; 286 | 287 | foreach my $realm (keys %{ $cfg->{ids} }) { 288 | my $data = $cfg->{ids}->{$realm}; 289 | if ($data->{comment}) { 290 | $data->{comment} = PVE::Tools::encode_text($data->{comment}); 291 | } 292 | } 293 | 294 | $class->SUPER::write_config($filename, $cfg); 295 | } 296 | 297 | sub authenticate_user { 298 | my ($class, $config, $realm, $username, $password) = @_; 299 | 300 | die "overwrite me"; 301 | } 302 | 303 | sub store_password { 304 | my ($class, $config, $realm, $username, $password) = @_; 305 | 306 | my $type = $class->type(); 307 | 308 | die "can't set password on auth type '$type'\n"; 309 | } 310 | 311 | sub delete_user { 312 | my ($class, $config, $realm, $username) = @_; 313 | 314 | # do nothing by default 315 | } 316 | 317 | # called during addition of realm (before the new domain config got written) 318 | # `password` is moved to %param to avoid writing it out to the config 319 | # die to abort addition if there are (grave) problems 320 | # NOTE: runs in a domain config *locked* context 321 | sub on_add_hook { 322 | my ($class, $realm, $config, %param) = @_; 323 | # do nothing by default 324 | } 325 | 326 | # called during domain configuration update (before the updated domain config got 327 | # written). `password` is moved to %param to avoid writing it out to the config 328 | # die to abort the update if there are (grave) problems 329 | # NOTE: runs in a domain config *locked* context 330 | sub on_update_hook { 331 | my ($class, $realm, $config, %param) = @_; 332 | # do nothing by default 333 | } 334 | 335 | # called during deletion of realms (before the new domain config got written) 336 | # and if the activate check on addition fails, to cleanup all storage traces 337 | # which on_add_hook may have created. 338 | # die to abort deletion if there are (very grave) problems 339 | # NOTE: runs in a domain config *locked* context 340 | sub on_delete_hook { 341 | my ($class, $realm, $config) = @_; 342 | # do nothing by default 343 | } 344 | 345 | # called during addition and updates of realms (before the new domain config gets written) 346 | # die to abort addition/update in case the connection/bind fails 347 | # NOTE: runs in a domain config *locked* context 348 | sub check_connection { 349 | my ($class, $realm, $config, %param) = @_; 350 | # do nothing by default 351 | } 352 | 353 | 1; 354 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | User Management and Access Control 2 | ================================== 3 | 4 | Proxmox VE implements an easy but flexible way to manage users. A 5 | powerful Access Control algorithm is used to grant permissions to 6 | individual users or group of users. 7 | 8 | Best Practices: 9 | 10 | Use groups in ACLs (not individual users). 11 | 12 | User Authentication 13 | =================== 14 | 15 | Proxmox VE can use different authentication servers. Those 16 | servers are listed in '/etc/pve/priv/domain.cfg', indexed by a unique 17 | ID (called 'authentication domain' or 'realm'). 18 | 19 | User names need to be unique. We create unique names by adding the 20 | 'realm' to the user ID: @ 21 | 22 | File format 'domain.cfg' 23 | ----example domains.cfg ------------------ 24 | 25 | # an active directory server 26 | AD: mycompany 27 | server1 10.10.10.1 28 | server2 10.10.10.2 29 | ... 30 | 31 | # an LDAP server 32 | LDAP: example.com 33 | server1 10.10.10.2 34 | .... 35 | 36 | ------------------------------------------ 37 | 38 | There are 2 special authentication domains name 'pve' and 'pam': 39 | 40 | * pve: stores passwords to "/etc/pve/priv/shadow.cfg" (SHA256 crypt); 41 | 42 | * pam: use unix 'pam' 43 | 44 | 45 | Proposed user database fields: 46 | ============================== 47 | 48 | users: 49 | 50 | login_name: email address (user@domain) 51 | enable: 1 = TRUE, 0 = FALSE 52 | expire: (account expiration date) 53 | domid: reference to authentication domain 54 | firstname: user first name 55 | lastname: user last name 56 | email: user's email address 57 | comment: arbitrary comment 58 | 59 | special user root: The root user has full administrative privileges 60 | 61 | group: 62 | 63 | group_name: the name of the group 64 | user_list: list of login names 65 | comment: a more verbose description 66 | 67 | pool: 68 | 69 | pool_name: the name of the pool 70 | comment: a more verbose description 71 | vm_list: list of VMs associated with the pool 72 | storage_list: list of storage IDs associated with the pool 73 | 74 | privileges: 75 | 76 | defines rights required to execute actions or read 77 | information. 78 | 79 | 80 | Node / System related privileges: 81 | 82 | Group.Allocate: create/modify/remove groups 83 | Mapping.Audit: view resource mappings 84 | Mapping.Modify: manage resource mappings 85 | Mapping.Use: use resource mappings 86 | Permissions.Modify: modify access permissions 87 | Pool.Allocate: create/modify/remove a pool 88 | Pool.Audit: view a pool 89 | Realm.AllocateUser: assign user to a realm 90 | Realm.Allocate: create/modify/remove authentication realms 91 | SDN.Allocate: manage SDN configuration 92 | SDN.Audit: view SDN configuration 93 | Sys.Audit: view node status/config, Corosync cluster config, and HA config 94 | Sys.Console: console access to node 95 | Sys.Incoming: allow incoming data streams from other clusters (experimental) 96 | Sys.Modify: create/modify/remove node network parameters 97 | Sys.PowerMgmt: node power management (start, stop, reset, shutdown, ...) 98 | Sys.Syslog: view syslog 99 | User.Modify: create/modify/remove user access and details. 100 | 101 | Virtual machine related privileges:: 102 | 103 | SDN.Use: access SDN vnets and local network bridges 104 | VM.Allocate: create/remove VM on a server 105 | VM.Audit: view VM config 106 | VM.Backup: backup/restore VMs 107 | VM.Clone: clone/copy a VM 108 | VM.Config.CDROM: eject/change CD-ROM 109 | VM.Config.CPU: modify CPU settings 110 | VM.Config.Cloudinit: modify Cloud-init parameters 111 | VM.Config.Disk: add/modify/remove disks 112 | VM.Config.HWType: modify emulated hardware types 113 | VM.Config.Memory: modify memory settings 114 | VM.Config.Network: add/modify/remove network devices 115 | VM.Config.Options: modify any other VM configuration 116 | VM.Console: console access to VM 117 | VM.GuestAgent.Audit: issue informational QEMU guest agent commands 118 | VM.GuestAgent.FileRead: read files from the guest via QEMU guest agent 119 | VM.GuestAgent.FileSystemMgmt: freeze/thaw/trim file systems via QEMU guest gent 120 | VM.GuestAgent.FileWrite: write files in the guest via QEMU guest agent 121 | VM.GuestAgent.Unrestricted: issue arbitrary QEMU guest agent commands 122 | VM.Migrate: migrate VM to alternate server on cluster 123 | VM.Replicate: configure and run guest replication 124 | VM.PowerMgmt: power management (start, stop, reset, shutdown, ...) 125 | VM.Snapshot.Rollback: rollback VM to one of its snapshots 126 | VM.Snapshot: create/delete VM snapshots 127 | 128 | Storage related privileges:: 129 | 130 | Datastore.Allocate: create/modify/remove a datastore and delete volumes 131 | Datastore.AllocateSpace: allocate space on a datastore 132 | Datastore.AllocateTemplate: allocate/upload templates and ISO images 133 | Datastore.Audit: view/browse a datastore 134 | 135 | 136 | We may need to refine those in future - the following privs 137 | are just examples: 138 | 139 | VM.Create: create new VM to server inventory 140 | VM.Remove: remove VM from inventory 141 | VM.AddNewDisk: add new disk to VM 142 | VM.AddExistingDisk: add an existing disk to VM 143 | VM.DiskModify: modify disk space for associated VM 144 | VM.UseRawDevice: associate a raw device with VM 145 | VM.PowerOn: power on VM 146 | VM.PowerOff: power off VM 147 | VM.CpuModify: modify number of CPUs associated with VM 148 | VM.CpuCyclesModify: modify CPU cycles for VM 149 | VM.NetworkAdd: add network device to VM 150 | VM.NetworkConfigure: configure network device associated with VM 151 | VM.NetworkRemove: remove network device from VM 152 | 153 | Network.AssignNetwork: assign system networks 154 | 155 | role: 156 | 157 | defines a sets of privileges 158 | 159 | predefined roles: 160 | 161 | administrator: full administrative privileges 162 | read_only: read only 163 | no_access: no privileges 164 | 165 | We store the following attribute for roles: 166 | 167 | role_name: the name of the group 168 | description: a more verbose description 169 | privileges: list of privileges 170 | 171 | permission: 172 | 173 | Assign roles to users or groups. 174 | 175 | 176 | ACL and Objects: 177 | ================ 178 | 179 | An access control list (ACL) is a list of permissions attached to an object. 180 | The list specifies who or what is allowed to access the object and what 181 | operations are allowed to be performed on the object. 182 | 183 | Object: A Virtual machine, Network (bridge, venet), Hosts, Host Memory, 184 | Storage, ... 185 | 186 | We can identify our objects by an unique (file system like) path, which also 187 | defines a tree like hierarchy relation. ACL can be inherited. Permissions are 188 | inherited if the propagate flag is set on the parent. Child permissions always 189 | overwrite inherited permissions. User permission takes precedence over all 190 | group permissions. If multiple group permission apply the resulting role is the 191 | union of all those group privileges. 192 | 193 | There is at most one object permission per user or group 194 | 195 | We store the following attributes for ACLs: 196 | 197 | propagate: propagate permissions down in the hierarchy 198 | path: path to uniquely identify the object 199 | user_or_group: ID of user or group (group ID start with @) 200 | role: list of role IDs. 201 | 202 | User Database: 203 | 204 | To keep it simple, we suggest to use a single text file, which is replicated to all cluster nodes. 205 | 206 | Also, we can store ACLs inside this file. 207 | 208 | Here is a short example how such file could look like: 209 | 210 | -----User/Group/Role Database example-------- 211 | 212 | user:joe@example.com:$1$nd91DtDy$mJtzWJAN2AAABKij0JgMy1/:Joe Average:Just a comment: 213 | user:max@example.com:$1$nd91DtDy$LANSNJAN2AAABKidhfgMy3/:Max Mustermann:Another comment: 214 | user:edward@example.com:$1$nd91DtDy$LANSNAAAAAAABKidhfgMy3/:Edward Example:Example VM Manager: 215 | 216 | group:admin:Internal Administrator Group:root: 217 | group:audit:Read only accounts used for audit:: 218 | group:customers:Our Customers:joe@example.com,max@example.com: 219 | 220 | role:vm_user:Virtual Machine User:VM.ConfigureCD,VM.Console: 221 | role:vm_manager:Virtual Machine Manager:VM.ConfigureCD,VM.Console,VM.AddNewDisk,VM.PowerOn,VM.PowerOff: 222 | role:vm_operator:Virtual Machine Operator:VM.Create,VM.ConfigureCD,VM.Console,VM.AddNewDisk,VM.PowerOn,VM.PowerOff: 223 | role:ds_consumer:DataStore Consumer:Datastore.AllocateSpace: 224 | role:nw_consumer:Network Consumer:Network.AssignNetwork: 225 | 226 | # group admin can do anything 227 | acl:0:/:@admin:Administrator: 228 | # group audit can view anything 229 | acl:1:/:@audit:read_only: 230 | 231 | # user max can manage all qemu/kvm machines 232 | acl:1:/vm/qemu:max@example.com:vm_manager: 233 | 234 | # user joe can use openvz vm 230 235 | acl:1:/vm/openvz/230:joe@example.com:vm_user: 236 | 237 | # user Edward can create openvz VMs using vmbr0 and store0 238 | acl:1:/vm/openvz:edward@example.com:vm_operator: 239 | acl:1:/network/vmbr0:edward@example.com:ds_consumer: 240 | acl:1:/storage/store0:edward@example.com:nw_consumer: 241 | 242 | --------------------------------------------- 243 | 244 | Basic model RBAC -> http://en.wikipedia.org/wiki/Role-based_access_control 245 | 246 | # Subject: A person or automated agent 247 | subject:joe@example.com: 248 | subject:max@example.com: 249 | 250 | # Role: Job function or title which defines an authority level 251 | role:vm_user:Virtual Machine User: 252 | role:admin:Administrator: 253 | 254 | # Subject Assignment: Subject -> Role(s) 255 | SA:vm_user:joe@example.com,max@example.com: 256 | SA:admin:joe@example.com: 257 | 258 | # Permissions: An approval of a mode of access to a resource 259 | # Permission Assignment: Role -> Permissions (set of allowed operation) 260 | perm:vm_user:VM.ConfigureCD,VM.Console: 261 | perm:admin:VM.ConfigureCD,VM.Console,VM.Create: 262 | 263 | --------------------------------------------- 264 | 265 | We can merge 'perm' into the 'role' table, because it is 266 | a 1 -> 1 mapping 267 | 268 | subject:joe@example.com: 269 | subject:max@example.com: 270 | 271 | role:vm_user:Virtual Machine User:VM.ConfigureCD,VM.Console: 272 | role:admin:Administrator:VM.ConfigureCD,VM.Console,VM.Create: 273 | 274 | SA:vm_user:joe@example.com,max@example.com: 275 | SA:admin:joe@example.com: 276 | 277 | ----------------------------------------------- 278 | 279 | We can have different subject assignment for different objects. 280 | 281 | subject:joe@example.com: 282 | subject:max@example.com: 283 | 284 | role:vm_user:Virtual Machine User:VM.ConfigureCD,VM.Console: 285 | role:admin:Administrator:VM.ConfigureCD,VM.Console,VM.Create: 286 | 287 | # joe is 'admin' for openvz VMs, but 'vm_user' for qemu VMs 288 | SA:/vm/openvz:admin:joe@example.com: 289 | SA:/vm/qemu:vm_user:joe@example.com,max@example.com: 290 | 291 | ----------------------------------------------- 292 | 293 | Let us use more convenient names. 294 | Use 'user' instead of 'subject'. 295 | Use 'acl' instead of 'SA'. 296 | 297 | user:joe@example.com: 298 | user:max@example.com: 299 | 300 | role:vm_user:Virtual Machine User:VM.ConfigureCD,VM.Console: 301 | role:admin:Administrator:VM.ConfigureCD,VM.Console,VM.Create: 302 | 303 | # joe is 'admin' for openvz VMs, but 'vm_user' for qemu VMs 304 | acl:/vm/openvz:admin:joe@example.com: 305 | acl:/vm/qemu:vm_user:joe@example.com,max@example.com: 306 | 307 | ----------------------------------------------- 308 | 309 | Finally introduce groups to group users. ACL can then 310 | use 'users' or 'groups'. 311 | 312 | user:joe@example.com: 313 | user:max@example.com: 314 | 315 | group:customers:Our Customers:joe@example.com,max@example.com: 316 | 317 | role:vm_user:Virtual Machine User:VM.ConfigureCD,VM.Console: 318 | role:admin:Administrator:VM.ConfigureCD,VM.Console,VM.Create: 319 | 320 | acl:/vm/openvz:admin:joe@example.com: 321 | acl:/vm/qemu:vm_user:@customers: 322 | 323 | 324 | ----------------------------------------------- 325 | -------------------------------------------------------------------------------- /src/PVE/CLI/pveum.pm: -------------------------------------------------------------------------------- 1 | package PVE::CLI::pveum; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use PVE::AccessControl; 7 | use PVE::RPCEnvironment; 8 | use PVE::API2::User; 9 | use PVE::API2::Group; 10 | use PVE::API2::Role; 11 | use PVE::API2::ACL; 12 | use PVE::API2::AccessControl; 13 | use PVE::API2::Domains; 14 | use PVE::API2::TFA; 15 | use PVE::Cluster qw(cfs_read_file cfs_write_file); 16 | use PVE::CLIFormatter; 17 | use PVE::CLIHandler; 18 | use PVE::JSONSchema qw(get_standard_option); 19 | use PVE::PTY; 20 | use PVE::RESTHandler; 21 | use PVE::Tools qw(extract_param); 22 | 23 | use base qw(PVE::CLIHandler); 24 | 25 | sub setup_environment { 26 | PVE::RPCEnvironment->setup_default_cli_env(); 27 | } 28 | 29 | sub param_mapping { 30 | my ($name) = @_; 31 | 32 | my $mapping = { 33 | 'change_password' => [ 34 | PVE::CLIHandler::get_standard_mapping('pve-password'), 35 | ], 36 | 'create_ticket' => [ 37 | PVE::CLIHandler::get_standard_mapping( 38 | 'pve-password', 39 | { 40 | func => sub { 41 | # do not accept values given on cmdline 42 | return PVE::PTY::read_password('Enter password: '); 43 | }, 44 | }, 45 | ), 46 | ], 47 | }; 48 | 49 | return $mapping->{$name}; 50 | } 51 | 52 | my $print_api_result = sub { 53 | my ($data, $schema, $options) = @_; 54 | PVE::CLIFormatter::print_api_result($data, $schema, undef, $options); 55 | }; 56 | 57 | my $print_perm_result = sub { 58 | my ($data, $schema, $options) = @_; 59 | 60 | if (!defined($options->{'output-format'}) || $options->{'output-format'} eq 'text') { 61 | my $table_schema = { 62 | type => 'array', 63 | items => { 64 | type => 'object', 65 | properties => { 66 | 'path' => { type => 'string', title => 'ACL path' }, 67 | 'permissions' => { type => 'string', title => 'Permissions' }, 68 | }, 69 | }, 70 | }; 71 | my $table_data = []; 72 | foreach my $path (sort keys %$data) { 73 | my $value = ''; 74 | my $curr = $data->{$path}; 75 | foreach my $perm (sort keys %$curr) { 76 | $value .= "\n" if $value; 77 | $value .= $perm; 78 | $value .= " (*)" if $curr->{$perm}; 79 | } 80 | push @$table_data, { path => $path, permissions => $value }; 81 | } 82 | PVE::CLIFormatter::print_api_result($table_data, $table_schema, undef, $options); 83 | print "Permissions marked with '(*)' have the 'propagate' flag set.\n"; 84 | } else { 85 | PVE::CLIFormatter::print_api_result($data, $schema, undef, $options); 86 | } 87 | }; 88 | 89 | __PACKAGE__->register_method({ 90 | name => 'token_permissions', 91 | path => 'token_permissions', 92 | method => 'GET', 93 | description => 'Retrieve effective permissions of given token.', 94 | parameters => { 95 | additionalProperties => 0, 96 | properties => { 97 | userid => get_standard_option('userid'), 98 | tokenid => get_standard_option('token-subid'), 99 | path => get_standard_option( 100 | 'acl-path', 101 | { 102 | description => "Only dump this specific path, not the whole tree.", 103 | optional => 1, 104 | }, 105 | ), 106 | }, 107 | }, 108 | returns => { 109 | type => 'object', 110 | description => 'Hash of structure "path" => "privilege" => "propagate boolean".', 111 | }, 112 | code => sub { 113 | my ($param) = @_; 114 | 115 | my $token_subid = extract_param($param, "tokenid"); 116 | $param->{userid} = PVE::AccessControl::join_tokenid($param->{userid}, $token_subid); 117 | 118 | return PVE::API2::AccessControl->permissions($param); 119 | }, 120 | }); 121 | 122 | __PACKAGE__->register_method({ 123 | name => 'delete_tfa', 124 | path => 'delete_tfa', 125 | method => 'PUT', 126 | description => 'Delete TFA entries from a user.', 127 | parameters => { 128 | additionalProperties => 0, 129 | properties => { 130 | userid => get_standard_option('userid'), 131 | id => { 132 | description => "The TFA ID, if none provided, all TFA entries will be deleted.", 133 | type => 'string', 134 | optional => 1, 135 | }, 136 | }, 137 | }, 138 | returns => { type => 'null' }, 139 | code => sub { 140 | my ($param) = @_; 141 | 142 | my $userid = extract_param($param, "userid"); 143 | my $tfa_id = extract_param($param, "id"); 144 | my $update_user_config; 145 | 146 | PVE::AccessControl::lock_tfa_config(sub { 147 | my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); 148 | if (defined($tfa_id)) { 149 | my $has_entries_left = $tfa_cfg->api_delete_tfa($userid, $tfa_id); 150 | $update_user_config = !$has_entries_left; 151 | } else { 152 | $tfa_cfg->remove_user($userid); 153 | $update_user_config = 1; 154 | } 155 | 156 | if ($update_user_config) { 157 | PVE::AccessControl::lock_user_config(sub { 158 | my $user_cfg = cfs_read_file('user.cfg'); 159 | my $user = $user_cfg->{users}->{$userid}; 160 | $user->{keys} = undef; 161 | cfs_write_file('user.cfg', $user_cfg); 162 | }); 163 | } 164 | cfs_write_file('priv/tfa.cfg', $tfa_cfg); 165 | }); 166 | return; 167 | }, 168 | }); 169 | 170 | __PACKAGE__->register_method({ 171 | name => 'list_tfa', 172 | path => 'list_tfa', 173 | method => 'GET', 174 | description => "List TFA entries.", 175 | parameters => { 176 | additionalProperties => 0, 177 | properties => { 178 | userid => get_standard_option('userid', { optional => 1 }), 179 | }, 180 | }, 181 | returns => { type => 'null' }, 182 | code => sub { 183 | my ($param) = @_; 184 | 185 | my $userid = extract_param($param, "userid"); 186 | 187 | my sub format_tfa_entries : prototype($;$) { 188 | my ($entries, $indent) = @_; 189 | 190 | $indent //= ''; 191 | 192 | my $nl = ''; 193 | for my $entry (@$entries) { 194 | my ($id, $ty, $desc) = ($entry->@{qw/id type description/}); 195 | printf("${nl}${indent}%-9s %s\n${indent} %s\n", "$ty:", $id, $desc // ''); 196 | $nl = "\n"; 197 | } 198 | } 199 | 200 | my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); 201 | if (defined($userid)) { 202 | format_tfa_entries($tfa_cfg->api_list_user_tfa($userid)); 203 | } else { 204 | my $result = $tfa_cfg->api_list_tfa('', 1); 205 | my $nl = ''; 206 | for my $entry (sort { $a->{userid} cmp $b->{userid} } @$result) { 207 | print "${nl}$entry->{userid}:\n"; 208 | format_tfa_entries($entry->{entries}, ' '); 209 | $nl = "\n"; 210 | } 211 | } 212 | return; 213 | }, 214 | }); 215 | 216 | our $cmddef = { 217 | user => { 218 | add => ['PVE::API2::User', 'create_user', ['userid']], 219 | modify => ['PVE::API2::User', 'update_user', ['userid']], 220 | delete => ['PVE::API2::User', 'delete_user', ['userid']], 221 | list => [ 222 | 'PVE::API2::User', 223 | 'index', 224 | [], 225 | {}, 226 | $print_api_result, 227 | $PVE::RESTHandler::standard_output_options, 228 | ], 229 | permissions => [ 230 | 'PVE::API2::AccessControl', 231 | 'permissions', 232 | ['userid'], 233 | {}, 234 | $print_perm_result, 235 | $PVE::RESTHandler::standard_output_options, 236 | ], 237 | tfa => { 238 | delete => [__PACKAGE__, 'delete_tfa', ['userid']], 239 | list => [__PACKAGE__, 'list_tfa', ['userid']], 240 | unlock => ['PVE::API2::User', 'unlock_tfa', ['userid']], 241 | }, 242 | token => { 243 | add => [ 244 | 'PVE::API2::User', 245 | 'generate_token', 246 | ['userid', 'tokenid'], 247 | {}, 248 | $print_api_result, 249 | $PVE::RESTHandler::standard_output_options, 250 | ], 251 | modify => [ 252 | 'PVE::API2::User', 253 | 'update_token_info', 254 | ['userid', 'tokenid'], 255 | {}, 256 | $print_api_result, 257 | $PVE::RESTHandler::standard_output_options, 258 | ], 259 | delete => [ 260 | 'PVE::API2::User', 261 | 'remove_token', 262 | ['userid', 'tokenid'], 263 | {}, 264 | $print_api_result, 265 | $PVE::RESTHandler::standard_output_options, 266 | ], 267 | remove => { alias => 'delete' }, 268 | list => [ 269 | 'PVE::API2::User', 270 | 'token_index', 271 | ['userid'], 272 | {}, 273 | $print_api_result, 274 | $PVE::RESTHandler::standard_output_options, 275 | ], 276 | permissions => [ 277 | __PACKAGE__, 278 | 'token_permissions', 279 | ['userid', 'tokenid'], 280 | {}, 281 | $print_perm_result, 282 | $PVE::RESTHandler::standard_output_options, 283 | ], 284 | }, 285 | }, 286 | group => { 287 | add => ['PVE::API2::Group', 'create_group', ['groupid']], 288 | modify => ['PVE::API2::Group', 'update_group', ['groupid']], 289 | delete => ['PVE::API2::Group', 'delete_group', ['groupid']], 290 | list => [ 291 | 'PVE::API2::Group', 292 | 'index', 293 | [], 294 | {}, 295 | $print_api_result, 296 | $PVE::RESTHandler::standard_output_options, 297 | ], 298 | }, 299 | role => { 300 | add => ['PVE::API2::Role', 'create_role', ['roleid']], 301 | modify => ['PVE::API2::Role', 'update_role', ['roleid']], 302 | delete => ['PVE::API2::Role', 'delete_role', ['roleid']], 303 | list => [ 304 | 'PVE::API2::Role', 305 | 'index', 306 | [], 307 | {}, 308 | $print_api_result, 309 | $PVE::RESTHandler::standard_output_options, 310 | ], 311 | }, 312 | acl => { 313 | modify => ['PVE::API2::ACL', 'update_acl', ['path'], { delete => 0 }], 314 | delete => ['PVE::API2::ACL', 'update_acl', ['path'], { delete => 1 }], 315 | list => [ 316 | 'PVE::API2::ACL', 317 | 'read_acl', 318 | [], 319 | {}, 320 | $print_api_result, 321 | $PVE::RESTHandler::standard_output_options, 322 | ], 323 | }, 324 | realm => { 325 | add => ['PVE::API2::Domains', 'create', ['realm']], 326 | modify => ['PVE::API2::Domains', 'update', ['realm']], 327 | delete => ['PVE::API2::Domains', 'delete', ['realm']], 328 | list => [ 329 | 'PVE::API2::Domains', 330 | 'index', 331 | [], 332 | {}, 333 | $print_api_result, 334 | $PVE::RESTHandler::standard_output_options, 335 | ], 336 | sync => ['PVE::API2::Domains', 'sync', ['realm']], 337 | }, 338 | 339 | ticket => [ 340 | 'PVE::API2::AccessControl', 341 | 'create_ticket', 342 | ['username'], 343 | undef, 344 | sub { 345 | my ($res) = @_; 346 | print "$res->{ticket}\n"; 347 | }, 348 | ], 349 | 350 | passwd => ['PVE::API2::AccessControl', 'change_password', ['userid']], 351 | 352 | useradd => { alias => 'user add' }, 353 | usermod => { alias => 'user modify' }, 354 | userdel => { alias => 'user delete' }, 355 | 356 | groupadd => { alias => 'group add' }, 357 | groupmod => { alias => 'group modify' }, 358 | groupdel => { alias => 'group delete' }, 359 | 360 | roleadd => { alias => 'role add' }, 361 | rolemod => { alias => 'role modify' }, 362 | roledel => { alias => 'role delete' }, 363 | 364 | aclmod => { alias => 'acl modify' }, 365 | acldel => { alias => 'acl delete' }, 366 | }; 367 | 368 | # FIXME: HACK! The pool API is in pve-manager as it needs access to storage guest and RRD stats, 369 | # so we only add the pool commands if the API module is available (required for boots-trapping) 370 | my $have_pool_api; 371 | eval { 372 | require PVE::API2::Pool; 373 | PVE::API2::Pool->import(); 374 | $have_pool_api = 1; 375 | }; 376 | 377 | if ($have_pool_api) { 378 | $cmddef->{pool} = { 379 | add => ['PVE::API2::Pool', 'create_pool', ['poolid']], 380 | modify => ['PVE::API2::Pool', 'update_pool', ['poolid']], 381 | delete => ['PVE::API2::Pool', 'delete_pool', ['poolid']], 382 | list => [ 383 | 'PVE::API2::Pool', 384 | 'index', 385 | [], 386 | {}, 387 | $print_api_result, 388 | $PVE::RESTHandler::standard_output_options, 389 | ], 390 | }; 391 | } 392 | 393 | 1; 394 | -------------------------------------------------------------------------------- /src/PVE/API2/OpenId.pm: -------------------------------------------------------------------------------- 1 | package PVE::API2::OpenId; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use PVE::Tools qw(extract_param); 7 | use PVE::RS::OpenId; 8 | 9 | use PVE::Exception qw(raise raise_perm_exc raise_param_exc); 10 | use PVE::SafeSyslog; 11 | use PVE::RPCEnvironment; 12 | use PVE::Cluster qw(cfs_read_file cfs_write_file); 13 | use PVE::AccessControl; 14 | use PVE::JSONSchema qw(get_standard_option); 15 | use PVE::Auth::Plugin; 16 | use PVE::Auth::OpenId; 17 | 18 | use PVE::RESTHandler; 19 | 20 | use base qw(PVE::RESTHandler); 21 | 22 | my $openid_state_path = "/var/lib/pve-manager"; 23 | 24 | my $lookup_openid_auth = sub { 25 | my ($realm, $redirect_url) = @_; 26 | 27 | my $cfg = cfs_read_file('domains.cfg'); 28 | my $ids = $cfg->{ids}; 29 | 30 | die "authentication domain '$realm' does not exist\n" if !$ids->{$realm}; 31 | 32 | my $config = $ids->{$realm}; 33 | die "wrong realm type ($config->{type} != openid)\n" if $config->{type} ne "openid"; 34 | 35 | my $openid_config = { 36 | issuer_url => $config->{'issuer-url'}, 37 | client_id => $config->{'client-id'}, 38 | client_key => $config->{'client-key'}, 39 | }; 40 | $openid_config->{prompt} = $config->{'prompt'} if defined($config->{'prompt'}); 41 | 42 | my $scopes = $config->{'scopes'} // 'email profile'; 43 | $openid_config->{scopes} = [PVE::Tools::split_list($scopes)]; 44 | 45 | if (defined(my $acr = $config->{'acr-values'})) { 46 | $openid_config->{acr_values} = [PVE::Tools::split_list($acr)]; 47 | } 48 | 49 | my $openid = PVE::RS::OpenId->discover($openid_config, $redirect_url); 50 | return ($config, $openid); 51 | }; 52 | 53 | __PACKAGE__->register_method({ 54 | name => 'index', 55 | path => '', 56 | method => 'GET', 57 | description => "Directory index.", 58 | permissions => { 59 | user => 'all', 60 | }, 61 | parameters => { 62 | additionalProperties => 0, 63 | properties => {}, 64 | }, 65 | returns => { 66 | type => 'array', 67 | items => { 68 | type => "object", 69 | properties => { 70 | subdir => { type => 'string' }, 71 | }, 72 | }, 73 | links => [{ rel => 'child', href => "{subdir}" }], 74 | }, 75 | code => sub { 76 | my ($param) = @_; 77 | 78 | return [ 79 | { subdir => 'auth-url' }, { subdir => 'login' }, 80 | ]; 81 | }, 82 | }); 83 | 84 | __PACKAGE__->register_method({ 85 | name => 'auth_url', 86 | path => 'auth-url', 87 | method => 'POST', 88 | protected => 1, 89 | description => "Get the OpenId Authorization Url for the specified realm.", 90 | parameters => { 91 | additionalProperties => 0, 92 | properties => { 93 | realm => get_standard_option('realm'), 94 | 'redirect-url' => { 95 | description => 96 | "Redirection Url. The client should set this to the used server url (location.origin).", 97 | type => 'string', 98 | maxLength => 255, 99 | }, 100 | }, 101 | }, 102 | returns => { 103 | type => "string", 104 | description => "Redirection URL.", 105 | }, 106 | permissions => { user => 'world' }, 107 | code => sub { 108 | my ($param) = @_; 109 | 110 | my $dcconf = PVE::Cluster::cfs_read_file('datacenter.cfg'); 111 | local $ENV{all_proxy} = $dcconf->{http_proxy} if exists $dcconf->{http_proxy}; 112 | 113 | my $realm = extract_param($param, 'realm'); 114 | my $redirect_url = extract_param($param, 'redirect-url'); 115 | 116 | my ($config, $openid) = $lookup_openid_auth->($realm, $redirect_url); 117 | my $url = $openid->authorize_url($openid_state_path, $realm); 118 | 119 | return $url; 120 | }, 121 | }); 122 | 123 | __PACKAGE__->register_method({ 124 | name => 'login', 125 | path => 'login', 126 | method => 'POST', 127 | protected => 1, 128 | description => " Verify OpenID authorization code and create a ticket.", 129 | parameters => { 130 | additionalProperties => 0, 131 | properties => { 132 | 'state' => { 133 | description => "OpenId state.", 134 | type => 'string', 135 | maxLength => 1024, 136 | }, 137 | code => { 138 | description => "OpenId authorization code.", 139 | type => 'string', 140 | maxLength => 4096, 141 | }, 142 | 'redirect-url' => { 143 | description => 144 | "Redirection Url. The client should set this to the used server url (location.origin).", 145 | type => 'string', 146 | maxLength => 255, 147 | }, 148 | }, 149 | }, 150 | returns => { 151 | properties => { 152 | username => { type => 'string' }, 153 | ticket => { type => 'string' }, 154 | CSRFPreventionToken => { type => 'string' }, 155 | cap => { type => 'object' }, # computed api permissions 156 | clustername => { type => 'string', optional => 1 }, 157 | }, 158 | }, 159 | permissions => { user => 'world' }, 160 | code => sub { 161 | my ($param) = @_; 162 | 163 | my $rpcenv = PVE::RPCEnvironment::get(); 164 | 165 | my $res; 166 | eval { 167 | my $dcconf = PVE::Cluster::cfs_read_file('datacenter.cfg'); 168 | local $ENV{all_proxy} = $dcconf->{http_proxy} if exists $dcconf->{http_proxy}; 169 | 170 | my ($realm, $private_auth_state) = 171 | PVE::RS::OpenId::verify_public_auth_state($openid_state_path, $param->{'state'}); 172 | 173 | my $redirect_url = extract_param($param, 'redirect-url'); 174 | 175 | my ($config, $openid) = $lookup_openid_auth->($realm, $redirect_url); 176 | 177 | my $info = $openid->verify_authorization_code( 178 | $param->{code}, 179 | $private_auth_state, 180 | $config->{'query-userinfo'} // 1, 181 | ); 182 | my $subject = $info->{'sub'}; 183 | 184 | my $unique_name; 185 | 186 | my $user_attr = $config->{'username-claim'} // 'sub'; 187 | if (defined($info->{$user_attr})) { 188 | $unique_name = $info->{$user_attr}; 189 | } elsif ($user_attr eq 'subject') { # stay compat with old versions 190 | $unique_name = $subject; 191 | } elsif ($user_attr eq 'username') { # stay compat with old versions 192 | my $username = $info->{'preferred_username'}; 193 | die "missing claim 'preferred_username'\n" if !defined($username); 194 | $unique_name = $username; 195 | } else { 196 | # neither the attr nor fallback are defined in info.. 197 | die "missing configured claim '$user_attr' in returned info object\n"; 198 | } 199 | 200 | my $username = "${unique_name}\@${realm}"; 201 | 202 | # first, check if $username respects our naming conventions 203 | PVE::Auth::Plugin::verify_username($username); 204 | 205 | if ($config->{'autocreate'} && !$rpcenv->check_user_exist($username, 1)) { 206 | PVE::AccessControl::lock_user_config( 207 | sub { 208 | my $usercfg = cfs_read_file("user.cfg"); 209 | 210 | die "user '$username' already exists\n" 211 | if $usercfg->{users}->{$username}; 212 | 213 | my $entry = { enable => 1 }; 214 | if (defined(my $email = $info->{'email'})) { 215 | $entry->{email} = $email; 216 | } 217 | if (defined(my $given_name = $info->{'given_name'})) { 218 | $entry->{firstname} = $given_name; 219 | } 220 | if (defined(my $family_name = $info->{'family_name'})) { 221 | $entry->{lastname} = $family_name; 222 | } 223 | 224 | $usercfg->{users}->{$username} = $entry; 225 | 226 | cfs_write_file("user.cfg", $usercfg); 227 | }, 228 | "autocreate openid user failed", 229 | ); 230 | } else { 231 | # test if user exists and is enabled 232 | $rpcenv->check_user_enabled($username); 233 | } 234 | 235 | if (defined(my $groups_claim = $config->{'groups-claim'})) { 236 | if (defined(my $groups_list = $info->{$groups_claim})) { 237 | if (ref($groups_list) eq 'ARRAY') { 238 | PVE::AccessControl::lock_user_config( 239 | sub { 240 | my $usercfg = cfs_read_file("user.cfg"); 241 | 242 | my $oidc_groups; 243 | for my $group (@$groups_list) { 244 | if (PVE::AccessControl::verify_groupname($group, 1)) { 245 | # add realm name as suffix to group 246 | $oidc_groups->{"$group-$realm"} = 1; 247 | } else { 248 | # ignore any groups in the list that have invalid characters 249 | syslog( 250 | 'warn', 251 | "openid group '$group' contains invalid characters", 252 | ); 253 | } 254 | } 255 | 256 | # get groups that exist in OIDC and PVE 257 | my $groups_intersect; 258 | for my $group (keys %$oidc_groups) { 259 | $groups_intersect->{$group} = 1 260 | if $usercfg->{groups}->{$group}; 261 | } 262 | 263 | if ($config->{'groups-autocreate'}) { 264 | # populate all groups in claim 265 | $groups_intersect = $oidc_groups; 266 | my $groups_to_create; 267 | for my $group (keys %$oidc_groups) { 268 | $groups_to_create->{$group} = 1 269 | if !$usercfg->{groups}->{$group}; 270 | } 271 | if ($groups_to_create) { 272 | # log a messages about created groups here 273 | my $groups_to_create_string = 274 | join(', ', sort keys %$groups_to_create); 275 | syslog( 276 | 'info', 277 | "groups created automatically from openid claim: $groups_to_create_string", 278 | ); 279 | } 280 | } 281 | 282 | # if groups should be overwritten, delete all the users groups first 283 | if ($config->{'groups-overwrite'}) { 284 | PVE::AccessControl::delete_user_group( 285 | $username, $usercfg, 286 | ); 287 | syslog( 288 | 'info', 289 | "openid overwrite groups enabled; user '$username' removed from all groups", 290 | ); 291 | } 292 | 293 | if (keys %$groups_intersect) { 294 | # ensure user is a member of the groups 295 | for my $group (keys %$groups_intersect) { 296 | PVE::AccessControl::add_user_group( 297 | $username, 298 | $usercfg, 299 | $group, 300 | ); 301 | } 302 | 303 | my $groups_intersect_string = 304 | join(', ', sort keys %$groups_intersect); 305 | syslog( 306 | 'info', 307 | "openid user '$username' added to groups: $groups_intersect_string", 308 | ); 309 | } 310 | 311 | cfs_write_file("user.cfg", $usercfg); 312 | }, 313 | "openid group mapping failed", 314 | ); 315 | } else { 316 | syslog( 317 | 'err', 318 | "openid groups list is not an array; groups will not be updated", 319 | ); 320 | } 321 | } else { 322 | syslog('err', "openid groups claim '$groups_claim' is not found in claims"); 323 | } 324 | } 325 | 326 | my $ticket = PVE::AccessControl::assemble_ticket($username); 327 | my $csrftoken = PVE::AccessControl::assemble_csrf_prevention_token($username); 328 | my $cap = $rpcenv->compute_api_permission($username); 329 | 330 | $res = { 331 | ticket => $ticket, 332 | username => $username, 333 | CSRFPreventionToken => $csrftoken, 334 | cap => $cap, 335 | }; 336 | 337 | my $clinfo = PVE::Cluster::get_clinfo(); 338 | if ( 339 | $clinfo->{cluster}->{name} && $rpcenv->check($username, '/', ['Sys.Audit'], 1) 340 | ) { 341 | $res->{clustername} = $clinfo->{cluster}->{name}; 342 | } 343 | }; 344 | if (my $err = $@) { 345 | my $clientip = $rpcenv->get_client_ip() || ''; 346 | syslog('err', "openid authentication failure; rhost=$clientip msg=$err"); 347 | # do not return any info to prevent user enumeration attacks 348 | die PVE::Exception->new("authentication failure\n", code => 401); 349 | } 350 | 351 | PVE::Cluster::log_msg( 352 | 'info', 353 | 'root@pam', 354 | "successful openid auth for user '$res->{username}'", 355 | ); 356 | 357 | return $res; 358 | }, 359 | }); 360 | -------------------------------------------------------------------------------- /src/PVE/Auth/LDAP.pm: -------------------------------------------------------------------------------- 1 | package PVE::Auth::LDAP; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use PVE::Auth::Plugin; 7 | use PVE::JSONSchema; 8 | use PVE::LDAP; 9 | use PVE::Tools; 10 | 11 | use base qw(PVE::Auth::Plugin); 12 | 13 | sub type { 14 | return 'ldap'; 15 | } 16 | 17 | sub properties { 18 | return { 19 | base_dn => { 20 | description => "LDAP base domain name", 21 | type => 'string', 22 | optional => 1, 23 | maxLength => 256, 24 | }, 25 | user_attr => { 26 | description => "LDAP user attribute name", 27 | type => 'string', 28 | pattern => '\S{2,}', 29 | optional => 1, 30 | maxLength => 256, 31 | }, 32 | bind_dn => { 33 | description => "LDAP bind domain name", 34 | type => 'string', 35 | optional => 1, 36 | maxLength => 256, 37 | }, 38 | password => { 39 | description => 40 | "LDAP bind password. Will be stored in '/etc/pve/priv/realm/.pw'.", 41 | type => 'string', 42 | optional => 1, 43 | }, 44 | verify => { 45 | description => "Verify the server's SSL certificate", 46 | type => 'boolean', 47 | optional => 1, 48 | default => 0, 49 | }, 50 | capath => { 51 | description => "Path to the CA certificate store", 52 | type => 'string', 53 | optional => 1, 54 | default => '/etc/ssl/certs', 55 | }, 56 | cert => { 57 | description => "Path to the client certificate", 58 | type => 'string', 59 | optional => 1, 60 | }, 61 | certkey => { 62 | description => "Path to the client certificate key", 63 | type => 'string', 64 | optional => 1, 65 | }, 66 | filter => { 67 | description => "LDAP filter for user sync.", 68 | type => 'string', 69 | optional => 1, 70 | maxLength => 2048, 71 | }, 72 | sync_attributes => { 73 | description => "Comma separated list of key=value pairs for specifying" 74 | . " which LDAP attributes map to which PVE user field. For example," 75 | . " to map the LDAP attribute 'mail' to PVEs 'email', write " 76 | . " 'email=mail'. By default, each PVE user field is represented " 77 | . " by an LDAP attribute of the same name.", 78 | optional => 1, 79 | type => 'string', 80 | pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*', 81 | }, 82 | user_classes => { 83 | description => "The objectclasses for users.", 84 | type => 'string', 85 | default => 'inetorgperson, posixaccount, person, user', 86 | format => 'ldap-simple-attr-list', 87 | optional => 1, 88 | }, 89 | group_dn => { 90 | description => "LDAP base domain name for group sync. If not set, the" 91 | . " base_dn will be used.", 92 | type => 'string', 93 | optional => 1, 94 | maxLength => 256, 95 | }, 96 | group_name_attr => { 97 | description => "LDAP attribute representing a groups name. If not set" 98 | . " or found, the first value of the DN will be used as name.", 99 | type => 'string', 100 | format => 'ldap-simple-attr', 101 | optional => 1, 102 | maxLength => 256, 103 | }, 104 | group_filter => { 105 | description => "LDAP filter for group sync.", 106 | type => 'string', 107 | optional => 1, 108 | maxLength => 2048, 109 | }, 110 | group_classes => { 111 | description => "The objectclasses for groups.", 112 | type => 'string', 113 | default => 'groupOfNames, group, univentionGroup, ipausergroup', 114 | format => 'ldap-simple-attr-list', 115 | optional => 1, 116 | }, 117 | 'sync-defaults-options' => { 118 | description => "The default options for behavior of synchronizations.", 119 | type => 'string', 120 | format => 'realm-sync-options', 121 | optional => 1, 122 | }, 123 | mode => { 124 | description => "LDAP protocol mode.", 125 | type => 'string', 126 | enum => ['ldap', 'ldaps', 'ldap+starttls'], 127 | optional => 1, 128 | default => 'ldap', 129 | }, 130 | 'case-sensitive' => { 131 | description => "username is case-sensitive", 132 | type => 'boolean', 133 | optional => 1, 134 | default => 1, 135 | }, 136 | }; 137 | } 138 | 139 | sub options { 140 | return { 141 | server1 => {}, 142 | server2 => { optional => 1 }, 143 | base_dn => {}, 144 | bind_dn => { optional => 1 }, 145 | password => { optional => 1 }, 146 | user_attr => {}, 147 | port => { optional => 1 }, 148 | secure => { optional => 1 }, 149 | sslversion => { optional => 1 }, 150 | default => { optional => 1 }, 151 | comment => { optional => 1 }, 152 | tfa => { optional => 1 }, 153 | verify => { optional => 1 }, 154 | capath => { optional => 1 }, 155 | cert => { optional => 1 }, 156 | certkey => { optional => 1 }, 157 | filter => { optional => 1 }, 158 | sync_attributes => { optional => 1 }, 159 | user_classes => { optional => 1 }, 160 | group_dn => { optional => 1 }, 161 | group_name_attr => { optional => 1 }, 162 | group_filter => { optional => 1 }, 163 | group_classes => { optional => 1 }, 164 | 'sync-defaults-options' => { optional => 1 }, 165 | mode => { optional => 1 }, 166 | 'case-sensitive' => { optional => 1 }, 167 | }; 168 | } 169 | 170 | my sub verify_sync_attribute_value { 171 | my ($attr, $value) = @_; 172 | 173 | # The attribute does not include the realm, so can't use PVE::Auth::Plugin::verify_username 174 | if ($attr eq 'username') { 175 | die "value '$value' does not look like a valid user name\n" 176 | if $value !~ m/${PVE::Auth::Plugin::user_regex}/; 177 | return; 178 | } 179 | 180 | return if $attr eq 'enable'; # for backwards compat, don't parse/validate 181 | 182 | if (my $schema = PVE::JSONSchema::get_standard_option("user-$attr")) { 183 | PVE::JSONSchema::validate($value, $schema, "invalid value '$value'\n"); 184 | } else { 185 | die "internal error: no schema for attribute '$attr' with value '$value' available!\n"; 186 | } 187 | } 188 | 189 | sub get_scheme_and_port { 190 | my ($class, $config) = @_; 191 | 192 | my $scheme = $config->{mode} // ($config->{secure} ? 'ldaps' : 'ldap'); 193 | 194 | my $default_port = $scheme eq 'ldaps' ? 636 : 389; 195 | my $port = $config->{port} // $default_port; 196 | 197 | return ($scheme, $port); 198 | } 199 | 200 | sub connect_and_bind { 201 | my ($class, $config, $realm, $param) = @_; 202 | 203 | my $servers = [$config->{server1}]; 204 | push @$servers, $config->{server2} if $config->{server2}; 205 | 206 | my ($scheme, $port) = $class->get_scheme_and_port($config); 207 | 208 | my %ldap_args; 209 | if ($config->{verify}) { 210 | $ldap_args{verify} = 'require'; 211 | $ldap_args{clientcert} = $config->{cert} if $config->{cert}; 212 | $ldap_args{clientkey} = $config->{certkey} if $config->{certkey}; 213 | if (defined(my $capath = $config->{capath})) { 214 | if (-d $capath) { 215 | $ldap_args{capath} = $capath; 216 | } else { 217 | $ldap_args{cafile} = $capath; 218 | } 219 | } 220 | } else { 221 | $ldap_args{verify} = 'none'; 222 | } 223 | 224 | if ($scheme ne 'ldap') { 225 | $ldap_args{sslversion} = $config->{sslversion} || 'tlsv1_2'; 226 | } 227 | 228 | my $ldap = PVE::LDAP::ldap_connect($servers, $scheme, $port, \%ldap_args); 229 | 230 | if ($config->{bind_dn}) { 231 | my $bind_dn = $config->{bind_dn}; 232 | my $bind_pass = $param->{password} || ldap_get_credentials($realm); 233 | die "missing password for realm $realm\n" if !defined($bind_pass); 234 | PVE::LDAP::ldap_bind($ldap, $bind_dn, $bind_pass); 235 | } elsif ($config->{cert} && $config->{certkey}) { 236 | warn "skipping anonymous bind with clientcert\n"; 237 | } else { 238 | PVE::LDAP::ldap_bind($ldap); 239 | } 240 | 241 | if (!$config->{base_dn}) { 242 | my $root = $ldap->root_dse(attrs => ['defaultNamingContext']); 243 | $config->{base_dn} = $root->get_value('defaultNamingContext'); 244 | } 245 | 246 | return $ldap; 247 | } 248 | 249 | # returns: 250 | # { 251 | # 'username@realm' => { 252 | # 'attr1' => 'value1', 253 | # 'attr2' => 'value2', 254 | # ... 255 | # }, 256 | # ... 257 | # } 258 | # 259 | # or in list context: 260 | # ( 261 | # { 262 | # 'username@realm' => { 263 | # 'attr1' => 'value1', 264 | # 'attr2' => 'value2', 265 | # ... 266 | # }, 267 | # ... 268 | # }, 269 | # { 270 | # 'uid=username,dc=....' => 'username@realm', 271 | # ... 272 | # } 273 | # ) 274 | # the map of dn->username is needed for group membership sync 275 | sub get_users { 276 | my ($class, $config, $realm) = @_; 277 | 278 | my $ldap = $class->connect_and_bind($config, $realm); 279 | 280 | my $user_name_attr = $config->{user_attr} // 'uid'; 281 | my $ldap_attribute_map = { 282 | $user_name_attr => 'username', 283 | enable => 'enable', 284 | expire => 'expire', 285 | firstname => 'firstname', 286 | lastname => 'lastname', 287 | email => 'email', 288 | comment => 'comment', 289 | keys => 'keys', 290 | # NOTE: also ensure verify_sync_attribute_value can handle any new/changed attribute name 291 | }; 292 | # build on the fly as this is small and only called once per realm in a ldap-sync anyway 293 | my $valid_sync_attributes = { map { $_ => 1 } values $ldap_attribute_map->%* }; 294 | 295 | foreach my $attr (PVE::Tools::split_list($config->{sync_attributes})) { 296 | my ($ours, $ldap) = ($attr =~ m/^\s*(\w+)=(.*)\s*$/); 297 | if (!$valid_sync_attributes->{$ours}) { 298 | warn "skipping bad 'sync_attributes' entry – '$ours' is not a valid target attribute\n"; 299 | next; 300 | } 301 | $ldap_attribute_map->{$ldap} = $ours; 302 | } 303 | 304 | my $filter = $config->{filter}; 305 | my $basedn = $config->{base_dn}; 306 | 307 | $config->{user_classes} //= 'inetorgperson, posixaccount, person, user'; 308 | my $classes = [PVE::Tools::split_list($config->{user_classes})]; 309 | 310 | my $users = 311 | PVE::LDAP::query_users($ldap, $filter, [keys %$ldap_attribute_map], $basedn, $classes); 312 | 313 | my $ret = {}; 314 | my $dnmap = {}; 315 | 316 | foreach my $user (@$users) { 317 | my $user_attributes = $user->{attributes}; 318 | my $userid = $user_attributes->{$user_name_attr}->[0]; 319 | my $username = "$userid\@$realm"; 320 | 321 | # we cannot sync usernames that do not meet our criteria 322 | eval { PVE::Auth::Plugin::verify_username($username) }; 323 | if (my $err = $@) { 324 | warn "$err"; 325 | next; 326 | } 327 | 328 | $ret->{$username} = {}; 329 | 330 | foreach my $attr (keys %$user_attributes) { 331 | if (my $ours = $ldap_attribute_map->{$attr}) { 332 | my $value = $user_attributes->{$attr}->[0]; 333 | eval { verify_sync_attribute_value($ours, $value) }; 334 | if (my $err = $@) { 335 | warn "skipping attribute mapping '$attr'->'$ours' for user '$username' - $err"; 336 | next; 337 | } 338 | $ret->{$username}->{$ours} = $value; 339 | } 340 | } 341 | 342 | if (wantarray) { 343 | my $dn = $user->{dn}; 344 | $dnmap->{ lc($dn) } = $username; 345 | } 346 | } 347 | 348 | return wantarray ? ($ret, $dnmap) : $ret; 349 | } 350 | 351 | # needs a map for dn -> username, we get this from the get_users call 352 | # otherwise we cannot determine the group membership 353 | sub get_groups { 354 | my ($class, $config, $realm, $dnmap) = @_; 355 | 356 | my $filter = $config->{group_filter}; 357 | my $basedn = $config->{group_dn} // $config->{base_dn}; 358 | my $attr = $config->{group_name_attr}; 359 | $config->{group_classes} //= 'groupOfNames, group, univentionGroup, ipausergroup'; 360 | my $classes = [PVE::Tools::split_list($config->{group_classes})]; 361 | 362 | my $ldap = $class->connect_and_bind($config, $realm); 363 | 364 | my $groups = PVE::LDAP::query_groups($ldap, $basedn, $classes, $filter, $attr); 365 | 366 | my $ret = {}; 367 | 368 | foreach my $group (@$groups) { 369 | my $name = $group->{name}; 370 | if (!$name && $group->{dn} =~ m/^[^=]+=([^,]+),/) { 371 | $name = PVE::Tools::trim($1); 372 | } 373 | if ($name) { 374 | $name .= "-$realm"; 375 | 376 | # we cannot sync groups that do not meet our criteria 377 | eval { PVE::AccessControl::verify_groupname($name) }; 378 | if (my $err = $@) { 379 | warn "$err"; 380 | next; 381 | } 382 | 383 | $ret->{$name} = { users => {} }; 384 | foreach my $member (@{ $group->{members} }) { 385 | if (my $user = $dnmap->{ lc($member) }) { 386 | $ret->{$name}->{users}->{$user} = 1; 387 | } 388 | } 389 | } 390 | } 391 | 392 | return $ret; 393 | } 394 | 395 | sub authenticate_user { 396 | my ($class, $config, $realm, $username, $password) = @_; 397 | 398 | my $ldap = $class->connect_and_bind($config, $realm); 399 | 400 | my $user_dn = 401 | PVE::LDAP::get_user_dn($ldap, $username, $config->{user_attr}, $config->{base_dn}); 402 | PVE::LDAP::auth_user_dn($ldap, $user_dn, $password); 403 | 404 | $ldap->unbind(); 405 | return 1; 406 | } 407 | 408 | my $ldap_pw_dir = "/etc/pve/priv/realm"; 409 | 410 | sub ldap_cred_file_name { 411 | my ($realmid) = @_; 412 | return "${ldap_pw_dir}/${realmid}.pw"; 413 | } 414 | 415 | sub get_cred_file { 416 | my ($realmid) = @_; 417 | 418 | my $cred_file = ldap_cred_file_name($realmid); 419 | if (-e $cred_file) { 420 | return $cred_file; 421 | } elsif (-e "/etc/pve/priv/ldap/${realmid}.pw") { 422 | # FIXME: remove fallback with 7.0 by doing a rename on upgrade from 6.x 423 | return "/etc/pve/priv/ldap/${realmid}.pw"; 424 | } 425 | 426 | return $cred_file; 427 | } 428 | 429 | sub ldap_set_credentials { 430 | my ($password, $realmid) = @_; 431 | 432 | my $cred_file = ldap_cred_file_name($realmid); 433 | mkdir $ldap_pw_dir; 434 | 435 | PVE::Tools::file_set_contents($cred_file, $password); 436 | 437 | return $cred_file; 438 | } 439 | 440 | sub ldap_get_credentials { 441 | my ($realmid) = @_; 442 | 443 | if (my $cred_file = get_cred_file($realmid)) { 444 | return PVE::Tools::file_read_firstline($cred_file); 445 | } 446 | return undef; 447 | } 448 | 449 | sub ldap_delete_credentials { 450 | my ($realmid) = @_; 451 | 452 | if (my $cred_file = get_cred_file($realmid)) { 453 | return if !-e $cred_file; # nothing to do 454 | unlink($cred_file) or warn "removing LDAP credentials '$cred_file' failed: $!\n"; 455 | } 456 | } 457 | 458 | sub on_add_hook { 459 | my ($class, $realm, $config, %param) = @_; 460 | 461 | if (defined($param{password})) { 462 | ldap_set_credentials($param{password}, $realm); 463 | } else { 464 | ldap_delete_credentials($realm); 465 | } 466 | } 467 | 468 | sub on_update_hook { 469 | my ($class, $realm, $config, %param) = @_; 470 | 471 | return if !exists($param{password}); 472 | 473 | if (defined($param{password})) { 474 | ldap_set_credentials($param{password}, $realm); 475 | } else { 476 | ldap_delete_credentials($realm); 477 | } 478 | } 479 | 480 | sub on_delete_hook { 481 | my ($class, $realm, $config) = @_; 482 | 483 | ldap_delete_credentials($realm); 484 | } 485 | 486 | sub check_connection { 487 | my ($class, $realm, $config, %param) = @_; 488 | 489 | $class->connect_and_bind($config, $realm, \%param); 490 | } 491 | 492 | 1; 493 | -------------------------------------------------------------------------------- /src/PVE/API2/TFA.pm: -------------------------------------------------------------------------------- 1 | package PVE::API2::TFA; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use HTTP::Status qw(:constants); 7 | 8 | use PVE::AccessControl; 9 | use PVE::Cluster qw(cfs_read_file cfs_write_file); 10 | use PVE::Exception qw(raise raise_perm_exc raise_param_exc); 11 | use PVE::JSONSchema qw(get_standard_option); 12 | use PVE::RPCEnvironment; 13 | use PVE::SafeSyslog; 14 | 15 | use PVE::RESTHandler; 16 | 17 | use base qw(PVE::RESTHandler); 18 | 19 | our $OPTIONAL_PASSWORD_SCHEMA = { 20 | description => "The current password of the user performing the change.", 21 | type => 'string', 22 | optional => 1, # Only required if not root@pam 23 | minLength => 5, 24 | maxLength => 64, 25 | }; 26 | 27 | my $TFA_TYPE_SCHEMA = { 28 | type => 'string', 29 | description => 'TFA Entry Type.', 30 | enum => [qw(totp u2f webauthn recovery yubico)], 31 | }; 32 | 33 | my %TFA_INFO_PROPERTIES = ( 34 | id => { 35 | type => 'string', 36 | description => 'The id used to reference this entry.', 37 | }, 38 | description => { 39 | type => 'string', 40 | description => 'User chosen description for this entry.', 41 | }, 42 | created => { 43 | type => 'integer', 44 | description => 'Creation time of this entry as unix epoch.', 45 | }, 46 | enable => { 47 | type => 'boolean', 48 | description => 'Whether this TFA entry is currently enabled.', 49 | optional => 1, 50 | default => 1, 51 | }, 52 | ); 53 | 54 | my $TYPED_TFA_ENTRY_SCHEMA = { 55 | type => 'object', 56 | description => 'TFA Entry.', 57 | properties => { 58 | type => $TFA_TYPE_SCHEMA, 59 | %TFA_INFO_PROPERTIES, 60 | }, 61 | }; 62 | 63 | my $TFA_ID_SCHEMA = { 64 | type => 'string', 65 | description => 'A TFA entry id.', 66 | }; 67 | 68 | my $TFA_UPDATE_INFO_SCHEMA = { 69 | type => 'object', 70 | properties => { 71 | id => { 72 | type => 'string', 73 | description => 'The id of a newly added TFA entry.', 74 | }, 75 | challenge => { 76 | type => 'string', 77 | optional => 1, 78 | description => 79 | 'When adding u2f entries, this contains a challenge the user must respond to in order' 80 | . ' to finish the registration.', 81 | }, 82 | recovery => { 83 | type => 'array', 84 | optional => 1, 85 | description => 86 | 'When adding recovery codes, this contains the list of codes to be displayed to' 87 | . ' the user', 88 | items => { 89 | type => 'string', 90 | description => 'A recovery entry.', 91 | }, 92 | }, 93 | }, 94 | }; 95 | 96 | # Set TFA to enabled if $tfa_cfg is passed, or to disabled if $tfa_cfg is undef, 97 | # When enabling we also merge the old user.cfg keys into the $tfa_cfg. 98 | my sub set_user_tfa_enabled : prototype($$$) { 99 | my ($userid, $realm, $tfa_cfg) = @_; 100 | 101 | PVE::AccessControl::lock_user_config( 102 | sub { 103 | my $user_cfg = cfs_read_file('user.cfg'); 104 | my $user = $user_cfg->{users}->{$userid}; 105 | my $keys = $user->{keys}; 106 | # When enabling, we convert old-old keys, 107 | # When disabling, we shouldn't actually have old keys anymore, so if they are there, 108 | # they'll be removed. 109 | if ($tfa_cfg && $keys && $keys !~ /^x(?:!.*)?$/) { 110 | my $domain_cfg = cfs_read_file('domains.cfg'); 111 | my $realm_cfg = $domain_cfg->{ids}->{$realm}; 112 | die "auth domain '$realm' does not exist\n" if !$realm_cfg; 113 | 114 | my $realm_tfa = $realm_cfg->{tfa}; 115 | $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_tfa) if $realm_tfa; 116 | 117 | PVE::AccessControl::add_old_keys_to_realm_tfa( 118 | $userid, $tfa_cfg, $realm_tfa, $keys, 119 | ); 120 | } 121 | $user->{keys} = $tfa_cfg ? 'x' : undef; 122 | cfs_write_file("user.cfg", $user_cfg); 123 | }, 124 | "enabling TFA for the user failed", 125 | ); 126 | } 127 | 128 | __PACKAGE__->register_method({ 129 | name => 'list_user_tfa', 130 | path => '{userid}', 131 | method => 'GET', 132 | permissions => { 133 | check => [ 134 | 'or', ['userid-param', 'self'], ['userid-group', ['User.Modify', 'Sys.Audit']], 135 | ], 136 | }, 137 | protected => 1, # else we can't access shadow files 138 | description => 'List TFA configurations of users.', 139 | parameters => { 140 | additionalProperties => 0, 141 | properties => { 142 | userid => get_standard_option( 143 | 'userid', 144 | { 145 | completion => \&PVE::AccessControl::complete_username, 146 | }, 147 | ), 148 | }, 149 | }, 150 | returns => { 151 | description => "A list of the user's TFA entries.", 152 | type => 'array', 153 | items => $TYPED_TFA_ENTRY_SCHEMA, 154 | links => [{ rel => 'child', href => "{id}" }], 155 | }, 156 | code => sub { 157 | my ($param) = @_; 158 | my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); 159 | return $tfa_cfg->api_list_user_tfa($param->{userid}); 160 | }, 161 | }); 162 | 163 | __PACKAGE__->register_method({ 164 | name => 'get_tfa_entry', 165 | path => '{userid}/{id}', 166 | method => 'GET', 167 | permissions => { 168 | check => [ 169 | 'or', ['userid-param', 'self'], ['userid-group', ['User.Modify', 'Sys.Audit']], 170 | ], 171 | }, 172 | protected => 1, # else we can't access shadow files 173 | description => 'Fetch a requested TFA entry if present.', 174 | parameters => { 175 | additionalProperties => 0, 176 | properties => { 177 | userid => get_standard_option( 178 | 'userid', 179 | { 180 | completion => \&PVE::AccessControl::complete_username, 181 | }, 182 | ), 183 | id => $TFA_ID_SCHEMA, 184 | }, 185 | }, 186 | returns => $TYPED_TFA_ENTRY_SCHEMA, 187 | code => sub { 188 | my ($param) = @_; 189 | my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); 190 | my $id = $param->{id}; 191 | my $entry = $tfa_cfg->api_get_tfa_entry($param->{userid}, $id); 192 | raise("No such tfa entry '$id'", code => HTTP::Status::HTTP_NOT_FOUND) if !$entry; 193 | return $entry; 194 | }, 195 | }); 196 | 197 | __PACKAGE__->register_method({ 198 | name => 'delete_tfa', 199 | path => '{userid}/{id}', 200 | method => 'DELETE', 201 | permissions => { 202 | check => [ 203 | 'or', ['userid-param', 'self'], ['userid-group', ['User.Modify']], 204 | ], 205 | }, 206 | protected => 1, # else we can't access shadow files 207 | allowtoken => 0, # we don't want tokens to change the regular user's TFA settings 208 | description => 'Delete a TFA entry by ID.', 209 | parameters => { 210 | additionalProperties => 0, 211 | properties => { 212 | userid => get_standard_option( 213 | 'userid', 214 | { 215 | completion => \&PVE::AccessControl::complete_username, 216 | }, 217 | ), 218 | id => $TFA_ID_SCHEMA, 219 | password => $OPTIONAL_PASSWORD_SCHEMA, 220 | }, 221 | }, 222 | returns => { type => 'null' }, 223 | code => sub { 224 | my ($param) = @_; 225 | 226 | my $rpcenv = PVE::RPCEnvironment::get(); 227 | my $authuser = $rpcenv->get_user(); 228 | my $userid = $rpcenv->reauth_user_for_user_modification( 229 | $authuser, $param->{userid}, $param->{password}, 230 | ); 231 | 232 | my $has_entries_left = PVE::AccessControl::lock_tfa_config(sub { 233 | my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); 234 | my $has_entries_left = $tfa_cfg->api_delete_tfa($userid, $param->{id}); 235 | cfs_write_file('priv/tfa.cfg', $tfa_cfg); 236 | return $has_entries_left; 237 | }); 238 | if (!$has_entries_left) { 239 | set_user_tfa_enabled($userid, undef, undef); 240 | } 241 | }, 242 | }); 243 | 244 | __PACKAGE__->register_method({ 245 | name => 'list_tfa', 246 | path => '', 247 | method => 'GET', 248 | permissions => { 249 | description => "Returns all or just the logged-in user, depending on privileges.", 250 | user => 'all', 251 | }, 252 | protected => 1, # else we can't access shadow files 253 | description => 'List TFA configurations of users.', 254 | parameters => { 255 | additionalProperties => 0, 256 | properties => {}, 257 | }, 258 | returns => { 259 | description => "The list tuples of user and TFA entries.", 260 | type => 'array', 261 | items => { 262 | type => 'object', 263 | properties => { 264 | userid => { 265 | type => 'string', 266 | description => 'User this entry belongs to.', 267 | }, 268 | entries => { 269 | type => 'array', 270 | items => $TYPED_TFA_ENTRY_SCHEMA, 271 | }, 272 | 'totp-locked' => { 273 | type => 'boolean', 274 | optional => 1, 275 | description => 'True if the user is currently locked out of TOTP factors.', 276 | }, 277 | 'tfa-locked-until' => { 278 | type => 'integer', 279 | optional => 1, 280 | description => 281 | 'Contains a timestamp until when a user is locked out of 2nd factors.', 282 | }, 283 | }, 284 | }, 285 | links => [{ rel => 'child', href => "{userid}" }], 286 | }, 287 | code => sub { 288 | my ($param) = @_; 289 | 290 | my $rpcenv = PVE::RPCEnvironment::get(); 291 | my $authuser = $rpcenv->get_user(); 292 | 293 | my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); 294 | my $entries = $tfa_cfg->api_list_tfa($authuser, 1); 295 | 296 | my $privs = ['User.Modify', 'Sys.Audit']; 297 | if ($rpcenv->check_any($authuser, "/access/groups", $privs, 1)) { 298 | # can modify all 299 | return $entries; 300 | } 301 | 302 | my $groups = $rpcenv->filter_groups($authuser, $privs, 1); 303 | my $allowed_users = $rpcenv->group_member_join([keys %$groups]); 304 | return [ 305 | grep { 306 | my $userid = $_->{userid}; 307 | $userid eq $authuser || $allowed_users->{$userid} 308 | } $entries->@* 309 | ]; 310 | }, 311 | }); 312 | 313 | __PACKAGE__->register_method({ 314 | name => 'add_tfa_entry', 315 | path => '{userid}', 316 | method => 'POST', 317 | permissions => { 318 | check => [ 319 | 'or', ['userid-param', 'self'], ['userid-group', ['User.Modify']], 320 | ], 321 | }, 322 | protected => 1, # else we can't access shadow files 323 | allowtoken => 0, # we don't want tokens to change the regular user's TFA settings 324 | description => 'Add a TFA entry for a user.', 325 | parameters => { 326 | additionalProperties => 0, 327 | properties => { 328 | userid => get_standard_option( 329 | 'userid', 330 | { 331 | completion => \&PVE::AccessControl::complete_username, 332 | }, 333 | ), 334 | type => $TFA_TYPE_SCHEMA, 335 | description => { 336 | type => 'string', 337 | description => 'A description to distinguish multiple entries from one another', 338 | maxLength => 255, 339 | optional => 1, 340 | }, 341 | totp => { 342 | type => 'string', 343 | description => "A totp URI.", 344 | optional => 1, 345 | }, 346 | value => { 347 | type => 'string', 348 | description => 'The current value for the provided totp URI, or a Webauthn/U2F' 349 | . ' challenge response', 350 | optional => 1, 351 | }, 352 | challenge => { 353 | type => 'string', 354 | description => 355 | 'When responding to a u2f challenge: the original challenge string', 356 | optional => 1, 357 | }, 358 | password => $OPTIONAL_PASSWORD_SCHEMA, 359 | }, 360 | }, 361 | returns => $TFA_UPDATE_INFO_SCHEMA, 362 | code => sub { 363 | my ($param) = @_; 364 | 365 | my $rpcenv = PVE::RPCEnvironment::get(); 366 | my $authuser = $rpcenv->get_user(); 367 | my ($userid, undef, $realm) = $rpcenv->reauth_user_for_user_modification( 368 | $authuser, $param->{userid}, $param->{password}, 369 | ); 370 | 371 | my $type = delete $param->{type}; 372 | my $value = delete $param->{value}; 373 | if ($type eq 'yubico') { 374 | $value = validate_yubico_otp($userid, $realm, $value); 375 | } 376 | 377 | return PVE::AccessControl::lock_tfa_config(sub { 378 | my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); 379 | 380 | set_user_tfa_enabled($userid, $realm, $tfa_cfg); 381 | 382 | PVE::AccessControl::configure_u2f_and_wa($tfa_cfg); 383 | 384 | my $response = $tfa_cfg->api_add_tfa_entry( 385 | $userid, 386 | $param->{description}, 387 | $param->{totp}, 388 | $value, 389 | $param->{challenge}, 390 | $type, 391 | ); 392 | 393 | cfs_write_file('priv/tfa.cfg', $tfa_cfg); 394 | 395 | return $response; 396 | }); 397 | }, 398 | }); 399 | 400 | sub validate_yubico_otp : prototype($$$) { 401 | my ($userid, $realm, $value) = @_; 402 | 403 | my $domain_cfg = cfs_read_file('domains.cfg'); 404 | my $realm_cfg = $domain_cfg->{ids}->{$realm}; 405 | die "auth domain '$realm' does not exist\n" if !$realm_cfg; 406 | 407 | my $realm_tfa = $realm_cfg->{tfa}; 408 | die "no yubico otp configuration available for realm $realm\n" 409 | if !$realm_tfa; 410 | 411 | $realm_tfa = PVE::Auth::Plugin::parse_tfa_config($realm_tfa); 412 | die "realm is not setup for Yubico OTP\n" 413 | if !$realm_tfa || $realm_tfa->{type} ne 'yubico'; 414 | 415 | my $public_key = substr($value, 0, 12); 416 | 417 | PVE::AccessControl::authenticate_yubico_do($value, $public_key, $realm_tfa); 418 | 419 | return $public_key; 420 | } 421 | 422 | __PACKAGE__->register_method({ 423 | name => 'update_tfa_entry', 424 | path => '{userid}/{id}', 425 | method => 'PUT', 426 | permissions => { 427 | check => [ 428 | 'or', ['userid-param', 'self'], ['userid-group', ['User.Modify']], 429 | ], 430 | }, 431 | protected => 1, # else we can't access shadow files 432 | allowtoken => 0, # we don't want tokens to change the regular user's TFA settings 433 | description => 'Add a TFA entry for a user.', 434 | parameters => { 435 | additionalProperties => 0, 436 | properties => { 437 | userid => get_standard_option( 438 | 'userid', 439 | { 440 | completion => \&PVE::AccessControl::complete_username, 441 | }, 442 | ), 443 | id => $TFA_ID_SCHEMA, 444 | description => { 445 | type => 'string', 446 | description => 'A description to distinguish multiple entries from one another', 447 | maxLength => 255, 448 | optional => 1, 449 | }, 450 | enable => { 451 | type => 'boolean', 452 | description => 'Whether the entry should be enabled for login.', 453 | optional => 1, 454 | }, 455 | password => $OPTIONAL_PASSWORD_SCHEMA, 456 | }, 457 | }, 458 | returns => { type => 'null' }, 459 | code => sub { 460 | my ($param) = @_; 461 | 462 | my $rpcenv = PVE::RPCEnvironment::get(); 463 | my $authuser = $rpcenv->get_user(); 464 | my $userid = $rpcenv->reauth_user_for_user_modification( 465 | $authuser, $param->{userid}, $param->{password}, 466 | ); 467 | 468 | PVE::AccessControl::lock_tfa_config(sub { 469 | my $tfa_cfg = cfs_read_file('priv/tfa.cfg'); 470 | 471 | $tfa_cfg->api_update_tfa_entry( 472 | $userid, $param->{id}, $param->{description}, $param->{enable}, 473 | ); 474 | 475 | cfs_write_file('priv/tfa.cfg', $tfa_cfg); 476 | }); 477 | }, 478 | }); 479 | 480 | 1; 481 | -------------------------------------------------------------------------------- /src/PVE/API2/AccessControl.pm: -------------------------------------------------------------------------------- 1 | package PVE::API2::AccessControl; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use JSON; 7 | use MIME::Base64; 8 | 9 | use PVE::Exception qw(raise raise_perm_exc raise_param_exc); 10 | use PVE::SafeSyslog; 11 | use PVE::RPCEnvironment; 12 | use PVE::Cluster qw(cfs_read_file); 13 | use PVE::DataCenterConfig; 14 | use PVE::RESTHandler; 15 | use PVE::AccessControl; 16 | use PVE::JSONSchema qw(get_standard_option); 17 | use PVE::API2::Domains; 18 | use PVE::API2::User; 19 | use PVE::API2::Group; 20 | use PVE::API2::Role; 21 | use PVE::API2::ACL; 22 | use PVE::API2::OpenId; 23 | use PVE::API2::TFA; 24 | use PVE::Auth::Plugin; 25 | use PVE::OTP; 26 | 27 | use base qw(PVE::RESTHandler); 28 | 29 | __PACKAGE__->register_method({ 30 | subclass => "PVE::API2::User", 31 | path => 'users', 32 | }); 33 | 34 | __PACKAGE__->register_method({ 35 | subclass => "PVE::API2::Group", 36 | path => 'groups', 37 | }); 38 | 39 | __PACKAGE__->register_method({ 40 | subclass => "PVE::API2::Role", 41 | path => 'roles', 42 | }); 43 | 44 | __PACKAGE__->register_method({ 45 | subclass => "PVE::API2::ACL", 46 | path => 'acl', 47 | }); 48 | 49 | __PACKAGE__->register_method({ 50 | subclass => "PVE::API2::Domains", 51 | path => 'domains', 52 | }); 53 | 54 | __PACKAGE__->register_method({ 55 | subclass => "PVE::API2::OpenId", 56 | path => 'openid', 57 | }); 58 | 59 | __PACKAGE__->register_method({ 60 | subclass => "PVE::API2::TFA", 61 | path => 'tfa', 62 | }); 63 | 64 | __PACKAGE__->register_method({ 65 | name => 'index', 66 | path => '', 67 | method => 'GET', 68 | description => "Directory index.", 69 | permissions => { 70 | user => 'all', 71 | }, 72 | parameters => { 73 | additionalProperties => 0, 74 | properties => {}, 75 | }, 76 | returns => { 77 | type => 'array', 78 | items => { 79 | type => "object", 80 | properties => { 81 | subdir => { type => 'string' }, 82 | }, 83 | }, 84 | links => [{ rel => 'child', href => "{subdir}" }], 85 | }, 86 | code => sub { 87 | my ($param) = @_; 88 | 89 | my $res = []; 90 | 91 | my $ma = __PACKAGE__->method_attributes(); 92 | 93 | foreach my $info (@$ma) { 94 | next if !$info->{subclass}; 95 | 96 | my $subpath = $info->{match_re}->[0]; 97 | 98 | push @$res, { subdir => $subpath }; 99 | } 100 | 101 | push @$res, { subdir => 'ticket' }; 102 | push @$res, { subdir => 'password' }; 103 | 104 | return $res; 105 | }, 106 | }); 107 | 108 | my sub verify_auth : prototype($$$$$$) { 109 | my ($rpcenv, $username, $pw_or_ticket, $otp, $path, $privs) = @_; 110 | 111 | my $normpath = PVE::AccessControl::normalize_path($path); 112 | die "invalid path - $path\n" if defined($path) && !defined($normpath); 113 | 114 | my $ticketuser; 115 | if ( 116 | ($ticketuser = PVE::AccessControl::verify_ticket($pw_or_ticket, 1)) 117 | && ($ticketuser eq $username) 118 | ) { 119 | # valid ticket 120 | } elsif (PVE::AccessControl::verify_vnc_ticket($pw_or_ticket, $username, $normpath, 1)) { 121 | # valid vnc ticket 122 | } else { 123 | $username = PVE::AccessControl::authenticate_user( 124 | $username, $pw_or_ticket, $otp, 125 | ); 126 | } 127 | 128 | my $privlist = [PVE::Tools::split_list($privs)]; 129 | if (!($normpath && scalar(@$privlist) && $rpcenv->check($username, $normpath, $privlist))) { 130 | die "no permission ($path, $privs)\n"; 131 | } 132 | 133 | return { username => $username }; 134 | } 135 | 136 | my sub create_ticket_do : prototype($$$$$) { 137 | my ($rpcenv, $username, $pw_or_ticket, $otp, $tfa_challenge) = @_; 138 | 139 | die "TFA response should be in 'password', not 'otp' when 'tfa-challenge' is set\n" 140 | if defined($otp) && defined($tfa_challenge); 141 | 142 | my ($ticketuser, undef, $tfa_info); 143 | if (!defined($tfa_challenge)) { 144 | # We only verify this ticket if we're not responding to a TFA challenge, as in that case 145 | # it is a TFA-data ticket and will be verified by `authenticate_user`. 146 | 147 | ($ticketuser, undef, $tfa_info) = PVE::AccessControl::verify_ticket($pw_or_ticket, 1); 148 | } 149 | 150 | if (defined($ticketuser) && ($ticketuser eq 'root@pam' || $ticketuser eq $username)) { 151 | if (defined($tfa_info)) { 152 | die "incomplete ticket\n"; 153 | } 154 | # valid ticket. Note: root@pam can create tickets for other users 155 | } else { 156 | ($username, $tfa_info) = PVE::AccessControl::authenticate_user( 157 | $username, $pw_or_ticket, $otp, $tfa_challenge, 158 | ); 159 | } 160 | 161 | my %extra; 162 | my $ticket_data = $username; 163 | my $aad; 164 | if (defined($tfa_info)) { 165 | $extra{NeedTFA} = 1; 166 | $ticket_data = "!tfa!$tfa_info"; 167 | $aad = $username; 168 | } 169 | 170 | my $ticket = PVE::AccessControl::assemble_ticket($ticket_data, $aad); 171 | my $csrftoken = PVE::AccessControl::assemble_csrf_prevention_token($username); 172 | 173 | return { 174 | ticket => $ticket, 175 | username => $username, 176 | CSRFPreventionToken => $csrftoken, 177 | %extra, 178 | }; 179 | } 180 | 181 | __PACKAGE__->register_method({ 182 | name => 'get_ticket', 183 | path => 'ticket', 184 | method => 'GET', 185 | permissions => { user => 'world' }, 186 | description => "Dummy. Useful for formatters which want to provide a login page.", 187 | parameters => { 188 | additionalProperties => 0, 189 | }, 190 | returns => { type => "null" }, 191 | code => sub { return undef; }, 192 | }); 193 | 194 | __PACKAGE__->register_method({ 195 | name => 'create_ticket', 196 | path => 'ticket', 197 | method => 'POST', 198 | permissions => { 199 | description => "You need to pass valid credientials.", 200 | user => 'world', 201 | }, 202 | protected => 1, # else we can't access shadow files 203 | allowtoken => 0, # we don't want tokens to create tickets 204 | description => "Create or verify authentication ticket.", 205 | parameters => { 206 | additionalProperties => 0, 207 | properties => { 208 | username => { 209 | description => "User name", 210 | type => 'string', 211 | maxLength => 64, 212 | completion => \&PVE::AccessControl::complete_username, 213 | }, 214 | realm => get_standard_option( 215 | 'realm', 216 | { 217 | description => 218 | "You can optionally pass the realm using this parameter. Normally" 219 | . " the realm is simply added to the username \@.", 220 | optional => 1, 221 | completion => \&PVE::AccessControl::complete_realm, 222 | }, 223 | ), 224 | password => { 225 | description => "The secret password. This can also be a valid ticket.", 226 | type => 'string', 227 | }, 228 | otp => { 229 | description => "One-time password for Two-factor authentication.", 230 | type => 'string', 231 | optional => 1, 232 | }, 233 | path => { 234 | description => "Verify ticket, and check if user have access 'privs' on 'path'", 235 | type => 'string', 236 | requires => 'privs', 237 | optional => 1, 238 | maxLength => 64, 239 | }, 240 | privs => { 241 | description => "Verify ticket, and check if user have access 'privs' on 'path'", 242 | type => 'string', 243 | format => 'pve-priv-list', 244 | requires => 'path', 245 | optional => 1, 246 | maxLength => 64, 247 | }, 248 | 'new-format' => { 249 | type => 'boolean', 250 | description => 'This parameter is now ignored and assumed to be 1.', 251 | optional => 1, 252 | default => 1, 253 | }, 254 | 'tfa-challenge' => { 255 | type => 'string', 256 | description => "The signed TFA challenge string the user wants to respond to.", 257 | optional => 1, 258 | }, 259 | }, 260 | }, 261 | returns => { 262 | type => "object", 263 | properties => { 264 | username => { type => 'string' }, 265 | ticket => { type => 'string', optional => 1 }, 266 | CSRFPreventionToken => { type => 'string', optional => 1 }, 267 | clustername => { type => 'string', optional => 1 }, 268 | # cap => computed api permissions, unless there's a u2f challenge 269 | }, 270 | }, 271 | code => sub { 272 | my ($param) = @_; 273 | 274 | my $username = $param->{username}; 275 | $username .= "\@$param->{realm}" if $param->{realm}; 276 | 277 | $username = PVE::AccessControl::lookup_username($username); 278 | my $rpcenv = PVE::RPCEnvironment::get(); 279 | 280 | my $res; 281 | eval { 282 | # test if user exists and is enabled 283 | $rpcenv->check_user_enabled($username); 284 | 285 | if ($param->{path} && $param->{privs}) { 286 | $res = verify_auth( 287 | $rpcenv, 288 | $username, 289 | $param->{password}, 290 | $param->{otp}, 291 | $param->{path}, 292 | $param->{privs}, 293 | ); 294 | } else { 295 | $res = create_ticket_do( 296 | $rpcenv, 297 | $username, 298 | $param->{password}, 299 | $param->{otp}, 300 | $param->{'tfa-challenge'}, 301 | ); 302 | } 303 | }; 304 | if (my $err = $@) { 305 | my $clientip = $rpcenv->get_client_ip() || ''; 306 | syslog('err', "authentication failure; rhost=$clientip user=$username msg=$err"); 307 | # do not return any info to prevent user enumeration attacks 308 | die PVE::Exception->new("authentication failure\n", code => 401); 309 | } 310 | 311 | $res->{cap} = $rpcenv->compute_api_permission($username) 312 | if !defined($res->{NeedTFA}); 313 | 314 | my $clinfo = PVE::Cluster::get_clinfo(); 315 | if ($clinfo->{cluster}->{name} && $rpcenv->check($username, '/', ['Sys.Audit'], 1)) { 316 | $res->{clustername} = $clinfo->{cluster}->{name}; 317 | } 318 | 319 | PVE::Cluster::log_msg('info', 'root@pam', "successful auth for user '$username'"); 320 | 321 | return $res; 322 | }, 323 | }); 324 | 325 | __PACKAGE__->register_method({ 326 | name => 'verify_vnc_ticket', 327 | path => 'vncticket', 328 | method => 'POST', 329 | permissions => { 330 | description => "You need to pass valid credientials.", 331 | user => 'world', 332 | }, 333 | protected => 1, # else we can't access authkey files 334 | description => "verify VNC authentication ticket.", 335 | parameters => { 336 | additionalProperties => 0, 337 | properties => { 338 | authid => { 339 | description => "UserId or token", 340 | type => 'string', 341 | maxLength => 64, 342 | }, 343 | vncticket => { 344 | description => "The VNC ticket.", 345 | type => 'string', 346 | }, 347 | path => { 348 | description => "Verify ticket, and check if user have access 'privs' on 'path'", 349 | type => 'string', 350 | maxLength => 64, 351 | }, 352 | privs => { 353 | description => "Verify ticket, and check if user have access 'privs' on 'path'", 354 | type => 'string', 355 | format => 'pve-priv-list', 356 | maxLength => 64, 357 | }, 358 | }, 359 | }, 360 | returns => { type => "null" }, 361 | code => sub { 362 | my ($param) = @_; 363 | 364 | my $auth_id = $param->{authid}; 365 | 366 | my $rpcenv = PVE::RPCEnvironment::get(); 367 | 368 | my $res = eval { 369 | my $normpath = PVE::AccessControl::normalize_path($param->{path}); 370 | PVE::AccessControl::verify_vnc_ticket($param->{vncticket}, $auth_id, $normpath); 371 | }; 372 | if (my $err = $@) { 373 | my $clientip = $rpcenv->get_client_ip() || ''; 374 | syslog('err', "authentication failure; rhost=$clientip user=$auth_id msg=$err"); 375 | # do not return any info to prevent user enumeration attacks 376 | die PVE::Exception->new("authentication failure\n", code => 401); 377 | } 378 | 379 | PVE::Cluster::log_msg('info', 'root@pam', "successful auth for user '$auth_id'"); 380 | 381 | return undef; 382 | }, 383 | }); 384 | 385 | __PACKAGE__->register_method({ 386 | name => 'change_password', 387 | path => 'password', 388 | method => 'PUT', 389 | permissions => { 390 | description => 391 | "Each user is allowed to change their own password. A user can change the" 392 | . " password of another user if they have 'Realm.AllocateUser' (on the realm of user" 393 | . " ) and 'User.Modify' permission on /access/groups/ on a group where" 394 | . " user is member of. For the PAM realm, a password change does not take " 395 | . " effect cluster-wide, but only applies to the local node.", 396 | check => [ 397 | 'or', 398 | ['userid-param', 'self'], 399 | [ 400 | 'and', ['userid-param', 'Realm.AllocateUser'], 401 | ['userid-group', ['User.Modify']], 402 | ], 403 | ], 404 | }, 405 | protected => 1, # else we can't access shadow files 406 | allowtoken => 0, # we don't want tokens to change the regular user password 407 | description => "Change user password.", 408 | parameters => { 409 | additionalProperties => 0, 410 | properties => { 411 | userid => get_standard_option('userid-completed'), 412 | password => { 413 | description => "The new password.", 414 | type => 'string', 415 | minLength => 8, 416 | maxLength => 64, 417 | }, 418 | 'confirmation-password' => $PVE::API2::TFA::OPTIONAL_PASSWORD_SCHEMA, 419 | }, 420 | }, 421 | returns => { type => "null" }, 422 | code => sub { 423 | my ($param) = @_; 424 | 425 | my $rpcenv = PVE::RPCEnvironment::get(); 426 | my $authuser = $rpcenv->get_user(); 427 | 428 | my ($userid, $ruid, $realm) = $rpcenv->reauth_user_for_user_modification( 429 | $authuser, 430 | $param->{userid}, 431 | $param->{'confirmation-password'}, 432 | 'confirmation-password', 433 | ); 434 | 435 | if ($authuser eq 'root@pam') { 436 | # OK - root can change anything 437 | } else { 438 | if ($authuser eq $userid) { 439 | $rpcenv->check_user_enabled($userid); 440 | # OK - each user can change their own password 441 | } else { 442 | # only root may change root password 443 | raise_perm_exc() if $userid eq 'root@pam'; 444 | # do not allow to change system user passwords 445 | raise_perm_exc() if $realm eq 'pam'; 446 | } 447 | } 448 | 449 | PVE::AccessControl::domain_set_password($realm, $ruid, $param->{password}); 450 | 451 | PVE::Cluster::log_msg('info', 'root@pam', "changed password for user '$userid'"); 452 | 453 | return undef; 454 | }, 455 | }); 456 | 457 | sub verify_user_tfa_config { 458 | my ($type, $tfa_cfg, $value) = @_; 459 | 460 | if (!defined($type)) { 461 | die "missing tfa 'type'\n"; 462 | } 463 | 464 | if ($type ne 'oath') { 465 | die "invalid type for custom tfa authentication\n"; 466 | } 467 | 468 | my $secret = $tfa_cfg->{keys} 469 | or die "missing TOTP secret\n"; 470 | $tfa_cfg = $tfa_cfg->{config}; 471 | # Copy the hash to verify that we have no unexpected keys without modifying the original hash. 472 | $tfa_cfg = {%$tfa_cfg}; 473 | 474 | # We can only verify 1 secret but oath_verify_otp allows multiple: 475 | if (scalar(PVE::Tools::split_list($secret)) != 1) { 476 | die "only exactly one secret key allowed\n"; 477 | } 478 | 479 | my $digits = delete($tfa_cfg->{digits}) // 6; 480 | my $step = delete($tfa_cfg->{step}) // 30; 481 | # Maybe also this? 482 | # my $algorithm = delete($tfa_cfg->{algorithm}) // 'sha1'; 483 | 484 | if (length(my $more = join(', ', keys %$tfa_cfg))) { 485 | die "unexpected tfa config keys: $more\n"; 486 | } 487 | 488 | PVE::OTP::oath_verify_otp($value, $secret, $step, $digits); 489 | } 490 | 491 | __PACKAGE__->register_method({ 492 | name => 'permissions', 493 | path => 'permissions', 494 | method => 'GET', 495 | description => 'Retrieve effective permissions of given user/token.', 496 | permissions => { 497 | description => 498 | "Each user/token is allowed to dump their own permissions (or that of owned" 499 | . " tokens). A user can dump the permissions of another user or their tokens if they" 500 | . " have 'Sys.Audit' permission on /access.", 501 | user => 'all', 502 | }, 503 | parameters => { 504 | additionalProperties => 0, 505 | properties => { 506 | userid => { 507 | type => 'string', 508 | description => "User ID or full API token ID", 509 | pattern => $PVE::AccessControl::userid_or_token_regex, 510 | optional => 1, 511 | }, 512 | path => get_standard_option( 513 | 'acl-path', 514 | { 515 | description => "Only dump this specific path, not the whole tree.", 516 | optional => 1, 517 | }, 518 | ), 519 | }, 520 | }, 521 | returns => { 522 | type => 'object', 523 | description => 'Map of "path" => (Map of "privilege" => "propagate boolean").', 524 | }, 525 | code => sub { 526 | my ($param) = @_; 527 | 528 | my $rpcenv = PVE::RPCEnvironment::get(); 529 | my $authid = $rpcenv->get_user(); 530 | 531 | my $userid = $param->{userid}; 532 | $userid = $authid if !defined($userid); 533 | 534 | my ($user, $token) = PVE::AccessControl::split_tokenid($userid, 1); 535 | my $check_self = $userid eq $authid; 536 | my $check_owned_token = defined($user) && $user eq $authid; 537 | 538 | if (!($check_self || $check_owned_token)) { 539 | $rpcenv->check($rpcenv->get_user(), '/access', ['Sys.Audit']); 540 | } 541 | my $res; 542 | 543 | if (my $path = $param->{path}) { 544 | my $perms = $rpcenv->permissions($userid, $path); 545 | if ($perms) { 546 | $res = { $path => $perms }; 547 | } else { 548 | $res = {}; 549 | } 550 | } else { 551 | $res = $rpcenv->get_effective_permissions($userid); 552 | } 553 | 554 | return $res; 555 | }, 556 | }); 557 | 558 | 1; 559 | --------------------------------------------------------------------------------