iimported from svn 'pve-access-control/trunk'
authorDietmar Maurer <dietmar@proxmox.com>
Tue, 23 Aug 2011 05:27:48 +0000 (07:27 +0200)
committerDietmar Maurer <dietmar@proxmox.com>
Tue, 23 Aug 2011 05:27:48 +0000 (07:27 +0200)
32 files changed:
ChangeLog [new file with mode: 0644]
Makefile [new file with mode: 0644]
PVE/API2/ACL.pm [new file with mode: 0644]
PVE/API2/AccessControl.pm [new file with mode: 0644]
PVE/API2/Domains.pm [new file with mode: 0644]
PVE/API2/Group.pm [new file with mode: 0644]
PVE/API2/Makefile [new file with mode: 0644]
PVE/API2/Role.pm [new file with mode: 0644]
PVE/API2/User.pm [new file with mode: 0644]
PVE/AccessControl.pm [new file with mode: 0644]
PVE/Makefile [new file with mode: 0644]
PVE/RPCEnvironment.pm [new file with mode: 0644]
README [new file with mode: 0644]
TODO [new file with mode: 0644]
changelog.Debian [new file with mode: 0644]
control.in [new file with mode: 0644]
copyright [new file with mode: 0644]
pveum [new file with mode: 0755]
test.pl [new file with mode: 0755]
test/Makefile [new file with mode: 0644]
test/auth-test.pl [new file with mode: 0644]
test/dump-perm.pl [new file with mode: 0755]
test/dump-users.pl [new file with mode: 0755]
test/perm-test1.pl [new file with mode: 0755]
test/perm-test2.pl [new file with mode: 0755]
test/perm-test3.pl [new file with mode: 0755]
test/perm-test4.pl [new file with mode: 0755]
test/test2.cfg [new file with mode: 0644]
test/test3.cfg [new file with mode: 0644]
test/test4.cfg [new file with mode: 0644]
test/user.cfg.ex1 [new file with mode: 0644]
user.cfg.ex [new file with mode: 0644]

diff --git a/ChangeLog b/ChangeLog
new file mode 100644 (file)
index 0000000..4d91a0f
--- /dev/null
+++ b/ChangeLog
@@ -0,0 +1,457 @@
+2011-08-15  Proxmox Support Team  <support@proxmox.com>
+
+       * PVE/AccessControl.pm (parse_user_config): fix parser for files
+       without newline at eof
+       (parse_shadow_passwd): fix parser for files without newline at eof
+       (parse_domains): fix parser for files without newline at eof
+
+2011-08-01  Proxmox Support Team  <support@proxmox.com>
+
+       * PVE/AccessControl.pm (lock_*): remove $parent in calls to
+       cfs_lock_file() 
+
+2011-07-22  Proxmox Support Team  <support@proxmox.com>
+
+       * PVE/API2/Domains.pm (create): use lower case: s/AD/ad/ and
+       s/LDAP/ldap/
+
+       * PVE/AccessControl.pm (write_domains): use lc($type)
+
+2011-07-14  Proxmox Support Team  <support@proxmox.com>
+
+       * control.in (Depends): remove depend on liburi-perl (code moved
+       to pve-common)
+
+2011-07-05  Proxmox Support Team  <support@proxmox.com>
+
+       * PVE/API2/User.pm (create_user): add -enable parameter
+
+       * PVE/API2/User.pm (update_user): use -enable instead of
+       -lock/-unlock
+
+2011-06-27  Proxmox Support Team  <support@proxmox.com>
+
+       * PVE/AccessControl.pm (normalize_path): allow '-' in path
+
+2011-05-30  Proxmox Support Team  <support@proxmox.com>
+
+       * PVE/AccessControl.pm (assemble_csrf_prevention_token): CSRF
+       token may not depend on cookie, because cookie can be updated from
+       other window.
+
+2011-03-30  Proxmox Support Team  <support@proxmox.com>
+
+       * PVE/API2/AccessControl.pm (create_ticket): also return user name
+
+2011-03-24  Proxmox Support Team  <support@proxmox.com>
+
+       * PVE/AccessControl.pm (verify_csrf_prevention_token): add CSRF
+       prevention code
+
+2011-03-23  Proxmox Support Team  <support@proxmox.com>
+
+       * PVE/RPCEnvironment.pm (active_workers): simple log rotation when
+       file is bigger that 50KB
+
+2011-03-22  Proxmox Support Team  <support@proxmox.com>
+
+       * PVE/RPCEnvironment.pm (set_result_count): a way to set the total
+       number of results - we use that for the ExtJS paging grid.
+
+2011-03-21  Proxmox Support Team  <support@proxmox.com>
+
+       * PVE/RPCEnvironment.pm (active_workers): immediately move finished
+       task to the index file.
+
+2011-03-17  Proxmox Support Team  <support@proxmox.com>
+
+       * PVE/RPCEnvironment.pm (active_workers): update/get worker list
+
+2011-03-16  Proxmox Support Team  <support@proxmox.com>
+
+       * PVE/RPCEnvironment.pm (fork_worker): add code to simulate running
+       in foreground (cli).
+
+2011-02-24  Proxmox Support Team  <support@proxmox.com>
+
+       * PVE/AccessControl.pm (roles): fix group permission propagation
+
+       * PVE/API2/ACL.pm: cleanup API - use '-users' and '-gropus'
+       instead of '-uglist'
+
+2011-02-23  Proxmox Support Team  <support@proxmox.com>
+
+       * PVE/API2/AccessControl.pm (create_ticket): moved code from REST.pm
+
+2011-02-22  Proxmox Support Team  <support@proxmox.com>
+
+       * PVE/AccessControl.pm: make 'domains.cfg' readable by www-data,
+       add 'default' attribute.
+
+       * PVE/AccessControl.pm: realm is now part of the username.
+       Example: 'userid@realm'
+       (valid_attributes): add 'domain, port, secure' attributes for AD. 
+       (parse_domains): add attribute 'secure' (replace LDAPS type),
+
+       * PVE/AccessControl.pm (parse_user_config): add firstname/lastname
+       and email fields.
+
+2011-02-21  Proxmox Support Team  <support@proxmox.com>
+
+       * PVE/API2/Group.pm (update_group): implement modgroup (set
+       comment)
+
+2011-02-18  Proxmox Support Team  <support@proxmox.com>
+
+       * PVE/AccessControl.pm (create_roles): try to create a predefined
+       set of roles automatically.
+
+2011-02-17  Proxmox Support Team  <support@proxmox.com>
+
+       * PVE/API2/Domains.pm: new API to for domains.cfg
+
+       * PVE/AccessControl.pm (authenticate_user_domain): added a 'domid'
+       attribute to users. This references an entry in the domain
+       config. This is simpler than the previous domain search
+       algorithm.
+
+       * PVE/API2/User.pm: save domid, name, comment and expire time for
+       user entries.
+
+       * PVE/AccessControl.pm (authenticate_user): check for expired
+       accounts
+
+       * control.in (Depends): depend on liburi-perl (we use URI::Escape
+       to encode text in our config files).
+
+       * PVE/AccessControl.pm (enable_user, disable_user): removed
+       clumsy methods, not needed.
+
+2011-02-16  Proxmox Support Team  <support@proxmox.com>
+
+       * README (privileges): Changes set of privileges. We try to be as
+       simple as possible. We can refinen them in future.
+
+       * PVE/ACLCache.pm: deleted - moved code into RPCEnvironment.
+
+2011-02-15  Proxmox Support Team  <support@proxmox.com>
+
+       * PVE/AccessControl.pm (verify_username): restrict user names to
+       64 charachters. Add new priviledges Sys.PowerOff, Sys.Console and
+       Sys.Syslog
+
+       * PVE/ACLCache.pm: move code into new file.
+
+       * test/perm-test1.pl: modified to use new PVE::ACLCache class.
+
+       * PVE/AccessControl.pm: add new class PVE::ACLCache (speed up ACL
+       checks)
+
+2011-01-27  Proxmox Support Team  <support@proxmox.com>
+
+       * pveum (auth): remove auth method - we do not use it any
+       longer,  comment out ability to pass password via environment
+       variable.
+
+       * PVE/AccessControl.pm (check_permissions): new helper to check
+       permissions.
+
+2011-01-21  root  <root@maui.maurer-it.com>
+
+       * PVE/AccessControl.pm: register a JSONSchema standard option for
+       'userid'.
+
+       * pveum: allow to pass passwords with environment variable
+       PVE_PW_TICKET
+       * pveum (auth): new method to verify credentials/privileges (used
+       by our kvm patches and vncterm)
+
+2011-01-12  root  <root@maui.maurer-it.com>
+
+       * PVE/AccessControl.pm: use new PVE::Cluster class and read data
+       from cluster filesystem (instead of local filesystem).
+
+2011-01-11  root  <root@maui.maurer-it.com>
+
+       * control.in (Depends): depend on new pve-cluster package
+
+       * PVE/AccessControl.pm (read_pubkey, read_privkey): inotify does
+       not work on the cluster filesystem, so I removed that code. Also
+       moved lock files to /var/lock/pve-manager (cluster filesystem does
+       not support locks - we need to do cluster wide locks later)
+
+2010-09-14  Proxmox Support Team  <support@proxmox.com>
+
+       * PVE/API2/AccessControl.pm: moved from pve-manager
+
+       * PVE/: create correct directory hierarchy
+
+       * Makefile (install): use 'verifyapi'
+
+       * pveum: add verifyapi
+
+2010-08-25  Proxmox Support Team  <support@proxmox.com>
+
+       * pveum: use new PVE::CLIHandler
+
+2010-08-24  Proxmox Support Team  <support@proxmox.com>
+
+       * pveum: use new PVE::RPCEnvironment
+
+       * *.pm: remove $conn parameter everywhere
+
+2010-08-16  Proxmox Support Team  <support@proxmox.com>
+
+       * AccessControl.pm (lock_user_config): add call to die, remove
+       @param - we do not need that here
+       (lock_shadow_config): add call to die, remove @param
+
+       * *.pm: remove $resp parameter everywhere.
+
+       * AccessControl.pm (verify_username): add test for username
+       length (at least 3 characters)
+
+2010-08-13  Proxmox Support Team  <support@proxmox.com>
+
+       * User.pm: use new 'format' property in schema
+
+       * ACL.pm: use new 'format' property in schema, remove redundant
+       calls to verify_XXX calls.
+
+       * Role.pm: use new 'format' property in schema, remove redundant
+       calls to verify_XXX calls.
+
+       * Group.pm: use new 'format' property in schema, remove redundant
+       calls to verify_XXX calls.
+
+       * AccessControl.pm (modify_acl): strict error checking - use 'die'
+       instead of 'warn', moved to ACL.pm
+       (verify_username): fix serious bug
+
+2010-08-12  Proxmox Support Team  <support@proxmox.com>
+
+       * Group.pm: use the new RESTHandler for API methods
+       
+       * Role.pm: use the new RESTHandler for API methods
+
+       * AccessControl.pm (add_group): moved to Group.pm
+       (delete_group): moved to Group.pm
+       (delete_role): moved to Role.pm
+       (modify_role): moved to Role.pm
+
+       * User.pm: strict error checking - use 'die' instead of 'warn'
+
+       * User.pm (delete_user): raise error when user does not exist.
+
+       * Group.pm (delete_group):  raise error when group does not exist.
+
+       * pveum: use the new
+       RESTHandler (PVE::API2::User->cli_handler()). That way we have
+       automatic command line argument parsing.
+
+       * User.pm: use the new RESTHandler for API methods. Those methods
+       are automatically exposed with the API Server (pve-manager), and
+       we can use them in the command line tools.
+
+       * AccessControl.pm (modify_user, delete_user): moved to User.pm
+
+2010-08-10  Proxmox Support Team  <support@proxmox.com>
+
+       * control.in (Depends): depend on libpve-common-perl
+
+       * AccessControl.pm: initialize Crypt::OpenSSL::RSA with
+       import_random_seed(), else I get a 'Segmentation fault' when
+       creating tickets ("pveum ticket <testuser>").
+
+       * AccessControl.pm:  Moved utilities to new PVE::Tools
+       module (pve-common), use new PVE::INotify to read/write config files.
+
+       * AccessControl.pm (parse_domains): ignore case (always convert
+       type to lower case), fix bug from Seth and test for 'ldaps'.
+       (file_set_contents): use O_WRONLY|O_CREAT instead of 'w' - else
+       perm gets ignored.
+
+2010-08-09  Seth Lauzon <seth.lauzon@gmail.com>
+
+       * AccessControl.pm (authenticate_user_ldap): changed the bind function
+       for LDAP to allow for secure connection
+
+2010-07-21  Seth Lauzon <seth.lauzon@gmail.com>
+
+       * AccessControl.pm (parse_domains): require base_dn for LDAP domains
+       (valid_attributes): renamed from valid_params to maintain conformity
+
+2010-07-19  Proxmox Support Team  <support@proxmox.com>
+
+       * AccessControl.pm (authenticate_user_domain): always add timeout
+       after failed auth
+       (file_set_contents): correctly emit exception if print/close fails
+
+2010-07-19  Seth Lauzon <seth.lauzon@gmail.com>
+
+       * AccessControl.pm: fixed timeout for ldap/AD errors and reduced to two seconds
+
+       * AccessControl.pm: modified LDAP authentication to a two step bind method
+
+2010-07-16  Proxmox Support Team  <support@proxmox.com>
+
+       * AccessControl.pm (authenticate_user_domain): catch special
+       case ($domain eq '')
+       (parse_domains): fix various bugs, allow spaces between domains,
+       skip duplicate parameters
+
+2010-07-16  Seth Lauzon <seth.lauzon@gmail.com>
+
+       * AccessControl.pm (parse_domains): borrowed code from Storage.pm to make it
+       less fragile to syntax errors in the domains.cfg file
+
+       * AccessControl.pm: implemented LDAP authentication
+
+       * AccessControl.pm: added four second timeout on authentication failure for
+       user_authentication_ldap and user_authentication_ad
+
+2010-07-14  Proxmox Support Team  <support@proxmox.com>
+
+       * AccessControl.pm (ldap_bind): rename to authenticate_user_ad (AD
+       only)
+       (load_domains_config): return a reference to an array (not the
+       array itself)
+       (parse_config): return a reference to an array (not the array
+       itself)
+       (authenticate_user_domain): restructure code - this is no the
+       centralized interface for authenticationn
+       (authenticate_user_domain): add 'shadow' and 'PAM' default entries
+       if there is no configuration for them in domain.cfg
+       (authenticate_user_shadow): renamed from authenticate_user_pve
+
+       * control.in (Depends): add libnet-ldap-perl
+
+2010-07-14  Seth Lauzon <seth.lauzon@gmail.com>A
+
+       * AccessControl.pm: implemented Active Directory authentication
+
+2010-07-09  Seth Lauzon <seth.lauzon@gmail.com>
+
+       * AccessControl.pm (modify_acl): check if role exists
+
+2010-07-08  Proxmox Support Team  <support@proxmox.com>
+
+       * pveum (print_usage): improve usage text.
+
+2010-07-08  Seth Lauzon <seth.lauzon@gmail.com>
+
+       * AccessControl.pm: modify/delete ACL functionality
+
+       * pveum (aclmod): Add/Modify ACL
+       (acldel): Delete ACL
+
+2010-07-07  Proxmox Support Team  <support@proxmox.com>
+
+       * AccessControl.pm: implemented shadowauthentication (add/modify/delete/verify)
+       with file locking (Seth)
+       (encrypt_pw): use SHA256 to crypt passwords
+       (save_shadow_config): change mode to 0600, store to /etc/pve/auth/shadow.cfg
+       (parse_shadow): simplify code - there is no need to trim strings. Instead check for
+       correct format.
+
+       * test/auth-test.pl: program for testing authentication methods (Seth)
+
+       * pveum (read_password): added confirm password
+
+2010-07-05  Proxmox Support Team  <support@proxmox.com>
+
+       * AccessControl.pm (modify_user): remove call to change_password()
+       - not neccessary at all (Seth)
+       * AccessControl.pm: cleanup - remove space in function calls(Seth)
+
+2010-07-02  Proxmox Support Team  <support@proxmox.com>
+
+       * AccessControl.pm (lock_user_config): renamed from lock_config,
+       because we will have more then one config file (auth.conf, shadow
+       password, ...)
+       (modify_user): check for exceptions after lock_user_config()
+       (delete_user): check for exceptions after lock_user_config(),
+       raise invalid characters exception
+       (delete_group): check for exceptions after lock_user_config(),
+       raise invalid characters exception
+       (modify_role): check for exceptions after lock_user_config()
+       (delete_role): check for exceptions after lock_user_config(),
+       raise invalid characters exception
+       (verify_username): add $noerr parameter, raise exeption if
+       user name contain invalid characters and $noerr is not set
+       (verify_groupname): add $noerr parameter, raise exeption if
+       group name contain invalid characters and $noerr is not set
+       (verify_rolename): add $noerr parameter, raise exeption if
+       role name contain invalid characters and $noerr is not set
+
+2010-07-01  Proxmox Support Team  <support@proxmox.com>
+
+       * AccessControl.pm: implemented file locking functionality for all
+       processes that make modifications to configuration file (Seth) -
+       code for lock_file() was copied from QemuServer.pm.
+
+2010-06-29  Proxmox Support Team  <support@proxmox.com>
+
+       * pveum: new roleadd/rolemod/roledel (Seth)
+
+       * AccessControl.pm (modify_role): create role and modify privileges (Seth)
+
+       * AccessControl.pm (delete_role): delete role functionality (Seth)
+
+2010-06-28  Proxmox Support Team  <support@proxmox.com>
+
+       * pveum: new groupadd/groupdel (patch from Seth)
+
+       * AccessControl.pm (add_user): moved functionality to modify_user and
+       removed subroutine (Seth)
+
+       * pveum: useradd command no longer requires a password and now uses
+       modify_user (Seth)
+
+2010-06-25  Proxmox Support Team  <support@proxmox.com>
+
+       * AccessControl.pm (modify_user): include patch from Seth
+
+2010-06-24  Proxmox Support Team  <support@proxmox.com>
+
+       * test/perm-test1.pl (check_permission): a first regression test
+
+       * test/user.cfg.ex1: add another example - for use by regression
+       tests
+
+       * test/dump-perm.pl: print permission as nice list, add ability to
+       specify usr.cfg file
+
+2010-06-23  Proxmox Support Team  <support@proxmox.com>
+
+       * pveum: implement some simple functions (add user, create ticket) 
+
+       * pveum-pl: rename to pveum
+
+       * pveum.c: remove suexec code - we will use a daemon instead
+
+       * pvesh: removed (dead code)
+       
+       * test/dump-perm.pl: simple script to dump permissions
+
+       * test/: created new directory for test skripts
+       
+       * test/dump-users.pl: simple script to dump user table
+
+2010-06-22  Proxmox Support Team  <support@proxmox.com>
+
+       * AccessControl.pm (add_user): Updated "valid_privs" with new
+       permissions from readme (Seth)
+
+2010-06-21  Proxmox Support Team  <support@proxmox.com>
+
+       * copyright: change license to AGPL
+
+2010-03-17  Proxmox Support Team  <support@proxmox.com>
+
+       * pveum-pl: move all priviledged function to this file. 
+
+2009-07-09  Proxmox Support Team  <support@proxmox.com>
+
+       * pveum: added dummy binary
+
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..b1318ca
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,68 @@
+RELEASE=2.0
+
+VERSION=1.0
+PACKAGE=libpve-access-control
+PKGREL=1
+
+DESTDIR=
+PREFIX=/usr
+BINDIR=${PREFIX}/bin
+SBINDIR=${PREFIX}/sbin
+MANDIR=${PREFIX}/share/man
+DOCDIR=${PREFIX}/share/doc
+MAN1DIR=${MANDIR}/man1/
+export PERLDIR=${PREFIX}/share/perl5
+
+ARCH:=$(shell dpkg-architecture -qDEB_BUILD_ARCH)
+DEB=${PACKAGE}_${VERSION}-${PKGREL}_${ARCH}.deb
+
+all: ${DEB}
+
+.PHONY: dinstall
+dinstall: deb
+       dpkg -i ${DEB}
+
+.PHONY: install
+install:
+       install -d ${DESTDIR}${BINDIR}
+       install -d ${DESTDIR}${SBINDIR}
+       install -m 0755 pveum ${DESTDIR}${SBINDIR}
+       make -C PVE install
+       perl -I. ./pveum verifyapi 
+       install -d ${DESTDIR}/usr/share/man/man1
+       pod2man -n pveum -s 1 -r "proxmox 2.0" -c "Proxmox Documentation" <pveum | gzip -9 > ${DESTDIR}/usr/share/man/man1/pveum.1.gz
+
+.PHONY: deb ${DEB}
+deb ${DEB}:
+       rm -rf debian
+       mkdir debian
+       make DESTDIR=${CURDIR}/debian install
+       install -d -m 0755 debian/DEBIAN
+       sed -e s/@@VERSION@@/${VERSION}/ -e s/@@PKGRELEASE@@/${PKGREL}/ -e s/@@ARCH@@/${ARCH}/ <control.in >debian/DEBIAN/control
+       install -D -m 0644 copyright debian/${DOCDIR}/${PACKAGE}/copyright
+       install -m 0644 changelog.Debian debian/${DOCDIR}/${PACKAGE}/
+       gzip -9 debian/${DOCDIR}/${PACKAGE}/changelog.Debian
+       install -m 0644 ChangeLog debian/${DOCDIR}/${PACKAGE}/changelog
+       gzip -9 debian/${DOCDIR}/${PACKAGE}/changelog
+       dpkg-deb --build debian 
+       mv debian.deb ${DEB}
+       #rm -rf debian
+       lintian ${DEB}
+
+.PHONY: upload
+upload: ${DEB}
+       umount /pve/${RELEASE}; mount /pve/${RELEASE} -o rw 
+       mkdir -p /pve/${RELEASE}/extra
+       rm -f /pve/${RELEASE}/extra/${PACKAGE}_*.deb
+       rm -f /pve/${RELEASE}/extra/Packages*
+       cp ${DEB} /pve/${RELEASE}/extra
+       cd /pve/${RELEASE}/extra; dpkg-scanpackages . /dev/null > Packages; gzip -9c Packages > Packages.gz
+       umount /pve/${RELEASE}; mount /pve/${RELEASE} -o ro
+
+.PHONY: clean
+clean:         
+       rm -rf debian *~ *.deb ${PACKAGE}-*.tar.gz
+       find . -name '*~' -exec rm {} ';'
+
+.PHONY: distclean
+distclean: clean
diff --git a/PVE/API2/ACL.pm b/PVE/API2/ACL.pm
new file mode 100644 (file)
index 0000000..f122542
--- /dev/null
@@ -0,0 +1,170 @@
+package PVE::API2::ACL;
+
+use strict;
+use warnings;
+use PVE::Cluster qw (cfs_read_file cfs_write_file);
+use PVE::Tools qw(split_list);
+use PVE::AccessControl;
+use PVE::Exception qw(raise_param_exc);
+
+use PVE::SafeSyslog;
+
+use Data::Dumper; # fixme: remove
+
+use PVE::RESTHandler;
+
+use base qw(PVE::RESTHandler);
+
+__PACKAGE__->register_method ({
+    name => 'read_acl', 
+    path => '', 
+    method => 'GET',
+    description => "Get Access Control List (ACLs).",
+    parameters => {
+       additionalProperties => 0,
+       properties => {},
+    },
+    returns => {
+       type => 'array',
+       items => {
+           type => "object",
+           additionalProperties => 0,
+           properties => {
+               path => { type => 'string' },
+               type => { type => 'string', enum => ['user', 'group'] },
+               ugid => { type => 'string' },
+               roleid => { type => 'string' },
+               propagate => { type => 'boolean' },
+           },
+       },
+    },
+    code => sub {
+       my ($param) = @_;
+    
+       my $res = [];
+
+       my $usercfg = cfs_read_file("user.cfg");
+
+       if (!$usercfg || !$usercfg->{acl}) {
+           return {};
+       }
+
+       my $acl = $usercfg->{acl};
+       foreach my $path (keys %$acl) {
+           foreach my $type (qw(users groups)) {
+               my $d = $acl->{$path}->{$type};
+               next if !$d;
+               foreach my $id (keys %$d) {
+                   foreach my $role (keys %{$d->{$id}}) {
+                       my $propagate = $d->{$id}->{$role};
+                       push @$res, {
+                           path => $path,
+                           type => $type eq 'groups' ? 'group' : 'user',
+                           ugid => $id,
+                           roleid => $role,
+                           propagate => $propagate,
+                       };
+                   }
+               }
+           }
+       }
+
+       return $res;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'update_acl', 
+    protected => 1,
+    path => '', 
+    method => 'PUT',
+    description => "Update Access Control List (add or remove permissions).",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           path => {
+               description => "Access control path",
+               type => 'string',
+           },
+           users => { 
+               description => "List of users.",
+               type => 'string',  format => 'pve-userid-list',  
+               optional => 1,
+           },
+           groups => { 
+               description => "List of groups.",
+               type => 'string', format => 'pve-groupid-list',
+               optional => 1,  
+           },
+           roles => { 
+               description => "List of roles.",
+               type => 'string', format => 'pve-roleid-list',
+           },
+           propagate => { 
+               description => "Allow to propagate (inherit) permissions.",
+               type => 'boolean', 
+               optional => 1,
+           },
+           delete => {
+               description => "Remove permissions (instead of adding it).",
+               type => 'boolean', 
+               optional => 1,
+           },
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       if (!($param->{users} || $param->{groups})) {
+           raise_param_exc({ 
+               users => "either 'users' or 'groups' is required.", 
+               groups => "either 'users' or 'groups' is required." });
+       }
+
+       my $path = PVE::AccessControl::normalize_path($param->{path});
+       raise_param_exc({ path => "invalid ACL path '$param->{path}'" }) if !$path;
+
+       PVE::AccessControl::lock_user_config(
+           sub {
+                       
+               my $cfg = cfs_read_file("user.cfg");
+
+               my $propagate = $param->{propagate} ? 1 : 0;
+
+               foreach my $role (split_list($param->{roles})) {
+                   die "role '$role' does not exist\n" 
+                       if !$cfg->{roles}->{$role};
+
+                   foreach my $group (split_list($param->{groups})) {
+
+                       die "group '$group' does not exist\n"
+                           if !$cfg->{groups}->{$group};
+
+                       if ($param->{delete}) {
+                           delete($cfg->{acl}->{$path}->{groups}->{$group}->{$role});
+                       } else {
+                           $cfg->{acl}->{$path}->{groups}->{$group}->{$role} = $propagate;
+                       }
+                   }
+
+                   foreach my $userid (split_list($param->{users})) {
+                       my $username = PVE::AccessControl::verify_username($userid);
+
+                       die "user '$username' does not exist\n"
+                           if !$cfg->{users}->{$username};
+
+                       if ($param->{delete}) {
+                           delete($cfg->{acl}->{$path}->{users}->{$username}->{$role});
+                       } else {
+                           $cfg->{acl}->{$path}->{users}->{$username}->{$role} = $propagate;
+                       } 
+                   }
+               }
+
+               cfs_write_file("user.cfg", $cfg);
+           }, "ACL update failed");
+
+       return undef;
+    }});
+
+1;
diff --git a/PVE/API2/AccessControl.pm b/PVE/API2/AccessControl.pm
new file mode 100644 (file)
index 0000000..10b6161
--- /dev/null
@@ -0,0 +1,175 @@
+package PVE::API2::AccessControl;
+
+use strict;
+use warnings;
+
+use PVE::SafeSyslog;
+use PVE::RPCEnvironment;
+use PVE::Cluster;
+use PVE::RESTHandler;
+use PVE::AccessControl;
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::API2::Domains;
+use PVE::API2::User;
+use PVE::API2::Group;
+use PVE::API2::Role;
+use PVE::API2::ACL;
+
+use base qw(PVE::RESTHandler);
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::User",  
+    path => 'users',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Group",  
+    path => 'groups',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Role",  
+    path => 'roles',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::ACL",  
+    path => 'acl',
+});
+
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Domains",  
+    path => 'domains',
+});
+
+__PACKAGE__->register_method ({
+    name => 'index', 
+    path => '', 
+    method => 'GET',
+    description => "Directory index.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {},
+    },
+    returns => {
+       type => 'array',
+       items => {
+           type => "object",
+           properties => {
+               subdir => { type => 'string' },
+           },
+       },
+       links => [ { rel => 'child', href => "{subdir}" } ],
+    },
+    code => sub {
+       my ($param) = @_;
+    
+       my $res = [];
+
+       my $ma = __PACKAGE__->method_attributes();
+
+       foreach my $info (@$ma) {
+           next if !$info->{subclass};
+
+           my $subpath = $info->{match_re}->[0];
+
+           push @$res, { subdir => $subpath };
+       }
+
+       push @$res, { subdir => 'ticket' };
+
+       return $res;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'create_ticket', 
+    path => 'ticket', 
+    method => 'POST',
+    permissions => { user => 'world' },
+    protected => 1, # else we can't access shadow files
+    description => "Create authentication ticket.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           username => {
+               description => "User name",
+               type => 'string',
+               maxLength => 64,
+           },
+           realm =>  get_standard_option('realm', {
+               description => "You can optionally pass the realm using this parameter. Normally the realm is simply added to the username <username>\@<relam>.",
+               optional => 1}),
+           password => { 
+               description => "The secret password. This can also be a valid ticket.",
+               type => 'string',
+           },
+           path => {
+               description => "Only create ticket if user have access 'privs' on 'path'",
+               type => 'string',
+               requires => 'privs',
+               optional => 1,
+               maxLength => 64,
+           },
+           privs => { 
+               description => "Only create ticket if user have access 'privs' on 'path'",
+               type => 'string' , format => 'pve-priv-list',
+               requires => 'path',
+               optional => 1,
+               maxLength => 64,
+           },
+       }
+    },
+    returns => {
+       type => "object",
+       properties => {
+           ticket => { type => 'string' },
+           username => { type => 'string' },
+           CSRFPreventionToken => { type => 'string' },
+       }
+    },
+    code => sub {
+       my ($param) = @_;
+    
+       my $username = $param->{username};
+       $username .= "\@$param->{realm}" if $param->{realm};
+
+       my $rpcenv = PVE::RPCEnvironment::get();
+       my $clientip = $rpcenv->get_client_ip() || '';
+
+       my $ticket;
+       my $token;
+       eval {
+
+           if ($param->{path} && $param->{privs}) {
+               my $privs = [ PVE::Tools::split_list($param->{privs}) ];
+               my $path = PVE::AccessControl::normalize_path($param->{path});
+               if (!($path && scalar(@$privs) && $rpcenv->check($username, $path, $privs))) {
+                   die "no permission ($param->{path}, $param->{privs})\n";
+               }
+           }
+
+           my $tmp;
+           if (($tmp = PVE::AccessControl::verify_ticket($param->{password}, 1)) &&
+               ($tmp eq $username)) {
+               # got valid ticket
+           } else {
+               $username = PVE::AccessControl::authenticate_user($username, $param->{password});
+           }
+           $ticket = PVE::AccessControl::assemble_ticket($username);
+           $token = PVE::AccessControl::assemble_csrf_prevention_token($username);
+       };
+       if (my $err = $@) {
+           syslog('err', "authentication failure; rhost=$clientip user=$username msg=$err");
+           die $err;
+       }
+
+       PVE::Cluster::log_msg('info', 'root@pam', "successful auth for user '$username'");
+
+       return {
+           ticket => $ticket,
+           username => $username,
+           CSRFPreventionToken => $token,
+       };
+    }});
+
+1;
diff --git a/PVE/API2/Domains.pm b/PVE/API2/Domains.pm
new file mode 100644 (file)
index 0000000..26223d3
--- /dev/null
@@ -0,0 +1,304 @@
+package PVE::API2::Domains;
+
+use strict;
+use warnings;
+use PVE::Cluster qw (cfs_read_file cfs_write_file);
+use PVE::AccessControl;
+use PVE::JSONSchema qw(get_standard_option);
+
+use PVE::SafeSyslog;
+
+use Data::Dumper; # fixme: remove
+
+use PVE::RESTHandler;
+
+my $domainconfigfile = "domains.cfg";
+
+use base qw(PVE::RESTHandler);
+
+__PACKAGE__->register_method ({
+    name => 'index', 
+    path => '', 
+    method => 'GET',
+    description => "Authentication domain index.",
+    permissions => { user => 'world' },
+    parameters => {
+       additionalProperties => 0,
+       properties => {},
+    },
+    returns => {
+       type => 'array',
+       items => {
+           type => "object",
+           properties => {
+               realm => { type => 'string' },
+               comment => { type => 'string', optional => 1 },
+           },
+       },
+       links => [ { rel => 'child', href => "{realm}" } ],
+    },
+    code => sub {
+       my ($param) = @_;
+    
+       my $res = [];
+
+       my $cfg = cfs_read_file($domainconfigfile);
+       foreach my $realm (keys %$cfg) {
+           my $d = $cfg->{$realm};
+           my $entry = { realm => $realm, type => $d->{type} };
+           $entry->{comment} = $d->{comment} if $d->{comment};
+           $entry->{default} = 1 if $d->{default};
+           push @$res, $entry;
+       }
+
+       return $res;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'create', 
+    protected => 1,
+    path => '', 
+    method => 'POST',
+    description => "Add an authentication server.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           realm =>  get_standard_option('realm'),
+           type => {
+               description => "Server type.",
+               type => 'string', 
+               enum => [ 'ad', 'ldap' ],
+           },
+           server1 => { 
+               description => "Server IP address (or DNS name)",               
+               type => 'string',
+           },
+           server2 => { 
+               description => "Fallback Server IP address (or DNS name)",
+               type => 'string',
+               optional => 1,
+           },
+           secure => { 
+               description => "Use secure LDAPS protocol.",
+               type => 'boolean', 
+               optional => 1,
+           },
+           default => { 
+               description => "Use this as default realm",
+               type => 'boolean', 
+               optional => 1,
+           },
+           comment => { 
+               type => 'string', 
+               optional => 1,
+           },
+           port => {
+               description => "Server port",
+               type => 'integer',
+               minimum => 1,
+               maximum => 65535,
+               optional => 1,
+           },
+           base_dn => {
+               description => "LDAP base domain name",
+               type => 'string',
+               optional => 1,
+           },
+           user_attr => {
+               description => "LDAP user attribute name",
+               type => 'string',
+               optional => 1,
+           },
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       PVE::AccessControl::lock_domain_config(
+           sub {
+                       
+               my $cfg = cfs_read_file($domainconfigfile);
+
+               my $realm = $param->{realm};
+       
+               die "domain '$realm' already exists\n" 
+                   if $cfg->{$realm};
+
+               die "unable to use reserved name '$realm'\n"
+                   if ($realm eq 'pam' || $realm eq 'pve');
+
+               if (defined($param->{secure})) {
+                   $cfg->{$realm}->{secure} = $param->{secure} ? 1 : 0;
+               }
+       
+               if ($param->{default}) {
+                   foreach my $r (keys %$cfg) {
+                       delete $cfg->{$r}->{default};
+                   }
+               }
+
+               foreach my $p (keys %$param) {
+                   next if $p eq 'realm';
+                   $cfg->{$realm}->{$p} = $param->{$p};
+               }
+
+               cfs_write_file($domainconfigfile, $cfg);
+           }, "add auth server failed");
+
+       return undef;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'update', 
+    path => '{realm}', 
+    method => 'PUT',
+    description => "Update authentication server settings.",
+    protected => 1,
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           realm =>  get_standard_option('realm'),
+           server1 => { 
+               description => "Server IP address (or DNS name)",               
+               type => 'string',
+               optional => 1,
+           },
+           server2 => { 
+               description => "Fallback Server IP address (or DNS name)",
+               type => 'string',
+               optional => 1,
+           },
+           secure => { 
+               description => "Use secure LDAPS protocol.",
+               type => 'boolean', 
+               optional => 1,
+           },
+           default => { 
+               description => "Use this as default realm",
+               type => 'boolean', 
+               optional => 1,
+           },
+           comment => { 
+               type => 'string', 
+               optional => 1,
+           },
+           port => {
+               description => "Server port",
+               type => 'integer',
+               minimum => 1,
+               maximum => 65535,
+               optional => 1,
+           },
+           base_dn => {
+               description => "LDAP base domain name",
+               type => 'string',
+               optional => 1,
+           },
+           user_attr => {
+               description => "LDAP user attribute name",
+               type => 'string',
+               optional => 1,
+           },
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       PVE::AccessControl::lock_domain_config(
+           sub {
+                       
+               my $cfg = cfs_read_file($domainconfigfile);
+
+               my $realm = $param->{realm};
+               delete $param->{realm};
+
+               die "unable to modify bultin domain '$realm'\n"
+                   if ($realm eq 'pam' || $realm eq 'pve');
+
+               die "domain '$realm' does not exist\n" 
+                   if !$cfg->{$realm};
+
+               if (defined($param->{secure})) {
+                   $cfg->{$realm}->{secure} = $param->{secure} ? 1 : 0;
+               }
+
+               if ($param->{default}) {
+                   foreach my $r (keys %$cfg) {
+                       delete $cfg->{$r}->{default};
+                   }
+               }
+
+               foreach my $p (keys %$param) {
+                   $cfg->{$realm}->{$p} = $param->{$p};
+               }
+
+               cfs_write_file($domainconfigfile, $cfg);
+           }, "update auth server failed");
+
+       return undef;
+    }});
+
+# fixme: return format!
+__PACKAGE__->register_method ({
+    name => 'read', 
+    path => '{realm}', 
+    method => 'GET',
+    description => "Get auth server configuration.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           realm =>  get_standard_option('realm'),
+       },
+    },
+    returns => {},
+    code => sub {
+       my ($param) = @_;
+
+       my $cfg = cfs_read_file($domainconfigfile);
+
+       my $realm = $param->{realm};
+       
+       my $data = $cfg->{$realm};
+       die "domain '$realm' does not exist\n" if !$data;
+
+       return $data;
+    }});
+
+
+__PACKAGE__->register_method ({
+    name => 'delete', 
+    path => '{realm}', 
+    method => 'DELETE',
+    description => "Delete an authentication server.",
+    protected => 1,
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           realm =>  get_standard_option('realm'),
+       }
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       PVE::AccessControl::lock_user_config(
+           sub {
+
+               my $cfg = cfs_read_file($domainconfigfile);
+
+               my $realm = $param->{realm};
+       
+               die "domain '$realm' does not exist\n" if !$cfg->{$realm};
+
+               delete $cfg->{$realm};
+
+               cfs_write_file($domainconfigfile, $cfg);
+           }, "delete auth server failed");
+       
+       return undef;
+    }});
+
+1;
diff --git a/PVE/API2/Group.pm b/PVE/API2/Group.pm
new file mode 100644 (file)
index 0000000..3a80225
--- /dev/null
@@ -0,0 +1,206 @@
+package PVE::API2::Group;
+
+use strict;
+use warnings;
+use PVE::Cluster qw (cfs_read_file cfs_write_file);
+use PVE::AccessControl;
+
+use PVE::SafeSyslog;
+
+use Data::Dumper; # fixme: remove
+
+use PVE::RESTHandler;
+
+use base qw(PVE::RESTHandler);
+
+my $extract_group_data = sub {
+    my ($data, $full) = @_;
+
+    my $res = {};
+
+    $res->{comment} = $data->{comment} if defined($data->{comment});
+
+    return $res if !$full;
+
+    $res->{users} = $data->{users} ? [ keys %{$data->{users}} ] : [];
+
+    return $res;
+};
+
+# fixme: index should return more/all attributes?
+__PACKAGE__->register_method ({
+    name => 'index', 
+    path => '', 
+    method => 'GET',
+    description => "Group index.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {},
+    },
+    returns => {
+       type => 'array',
+       items => {
+           type => "object",
+           properties => {
+               groupid => { type => 'string' },
+           },
+       },
+       links => [ { rel => 'child', href => "{groupid}" } ],
+    },
+    code => sub {
+       my ($param) = @_;
+    
+       my $res = [];
+
+       my $usercfg = cfs_read_file("user.cfg");
+       foreach my $group (keys %{$usercfg->{groups}}) {
+           my $entry = &$extract_group_data($usercfg->{groups}->{$group});
+           $entry->{groupid} = $group;
+           push @$res, $entry;
+       }
+
+       return $res;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'create_group', 
+    protected => 1,
+    path => '', 
+    method => 'POST',
+    description => "Create new group.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           groupid => { type => 'string', format => 'pve-groupid' },
+           comment => { type => 'string', optional => 1 },
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       PVE::AccessControl::lock_user_config(
+           sub {
+                       
+               my $usercfg = cfs_read_file("user.cfg");
+
+               my $group = $param->{groupid};
+       
+               die "group '$group' already exists\n" 
+                   if $usercfg->{groups}->{$group};
+
+               $usercfg->{groups}->{$group} = { users => {} };
+
+               $usercfg->{groups}->{$group}->{comment} = $param->{comment} if $param->{comment};
+
+               
+               cfs_write_file("user.cfg", $usercfg);
+           }, "create group failed");
+
+       return undef;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'update_group', 
+    protected => 1,
+    path => '{groupid}', 
+    method => 'PUT',
+    description => "Update group data.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           # fixme: set/delete members
+           groupid => { type => 'string', format => 'pve-groupid' },
+           comment => { type => 'string', optional => 1 },
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       PVE::AccessControl::lock_user_config(
+           sub {
+                       
+               my $usercfg = cfs_read_file("user.cfg");
+
+               my $group = $param->{groupid};
+       
+               my $data = $usercfg->{groups}->{$group};
+
+               die "group '$group' does not exist\n" 
+                   if !$data;
+
+               $data->{comment} = $param->{comment} if $param->{comment};
+               
+               cfs_write_file("user.cfg", $usercfg);
+           }, "create group failed");
+
+       return undef;
+    }});
+
+# fixme: return format!
+__PACKAGE__->register_method ({
+    name => 'read_group', 
+    path => '{groupid}', 
+    method => 'GET',
+    description => "Get group configuration.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           groupid => { type => 'string', format => 'pve-groupid' },
+       },
+    },
+    returns => {},
+    code => sub {
+       my ($param) = @_;
+
+       my $group = $param->{groupid};
+
+       my $usercfg = cfs_read_file("user.cfg");
+       my $data = $usercfg->{groups}->{$group};
+
+       die "group '$group' does not exist\n" if !$data;
+
+       return &$extract_group_data($data, 1);
+    }});
+
+
+__PACKAGE__->register_method ({
+    name => 'delete_group', 
+    protected => 1,
+    path => '{groupid}', 
+    method => 'DELETE',
+    description => "Delete group.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           groupid => { type => 'string' , format => 'pve-groupid' },
+       }
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       PVE::AccessControl::lock_user_config(
+           sub {
+
+               my $usercfg = cfs_read_file("user.cfg");
+
+               my $group = $param->{groupid};
+
+               die "group '$group' does not exist\n" 
+                   if !$usercfg->{groups}->{$group};
+       
+               delete ($usercfg->{groups}->{$group});
+
+               PVE::AccessControl::delete_group_acl($group, $usercfg);
+
+               cfs_write_file("user.cfg", $usercfg);
+           }, "delete group failed");
+       
+       return undef;
+    }});
+
+1;
diff --git a/PVE/API2/Makefile b/PVE/API2/Makefile
new file mode 100644 (file)
index 0000000..64aa8d3
--- /dev/null
@@ -0,0 +1,12 @@
+
+API2_SOURCES=           \
+       AccessControl.pm \
+       Domains.pm       \
+       ACL.pm           \
+       Role.pm          \
+       Group.pm         \
+       User.pm
+
+.PHONY: install
+install:
+       for i in ${API2_SOURCES}; do install -D -m 0644 $$i ${DESTDIR}${PERLDIR}/PVE/API2/$$i; done
diff --git a/PVE/API2/Role.pm b/PVE/API2/Role.pm
new file mode 100644 (file)
index 0000000..396ba48
--- /dev/null
@@ -0,0 +1,193 @@
+package PVE::API2::Role;
+
+use strict;
+use warnings;
+use PVE::Cluster qw (cfs_read_file cfs_write_file);
+use PVE::AccessControl;
+
+use PVE::SafeSyslog;
+
+use Data::Dumper; # fixme: remove
+
+use PVE::RESTHandler;
+
+use base qw(PVE::RESTHandler);
+
+__PACKAGE__->register_method ({
+    name => 'index', 
+    path => '', 
+    method => 'GET',
+    description => "Role index.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {},
+    },
+    returns => {
+       type => 'array',
+       items => {
+           type => "object",
+           properties => {
+               roleid => { type => 'string' },
+           },
+       },
+       links => [ { rel => 'child', href => "{roleid}" } ],
+    },
+    code => sub {
+       my ($param) = @_;
+    
+       my $res = [];
+
+       my $usercfg = cfs_read_file("user.cfg");
+       foreach my $role (keys %{$usercfg->{roles}}) {
+           my $privs = join(',', sort keys %{$usercfg->{roles}->{$role}});
+           push @$res, { roleid => $role, privs => $privs };
+       }
+
+       return $res;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'create_role', 
+    protected => 1,
+    path => '', 
+    method => 'POST',
+    description => "Create new role.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           roleid => { type => 'string', format => 'pve-roleid' },
+           privs => { type => 'string' , format => 'pve-priv-list', optional => 1 },
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       PVE::AccessControl::lock_user_config(
+           sub {
+                       
+               my $usercfg = cfs_read_file("user.cfg");
+
+               my $role = $param->{roleid};
+
+               die "role '$role' already exists\n" 
+                   if $usercfg->{roles}->{$role};
+
+               $usercfg->{roles}->{$role} = {};
+
+               PVE::AccessControl::add_role_privs($role, $usercfg, $param->{privs});
+
+               cfs_write_file("user.cfg", $usercfg);
+           }, "create role failed");
+
+       return undef;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'update_role', 
+    protected => 1,
+    path => '{roleid}', 
+    method => 'PUT',
+    description => "Create new role.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           roleid => { type => 'string', format => 'pve-roleid' },
+           privs => { type => 'string' , format => 'pve-priv-list' },
+           append => { 
+               type => 'boolean', 
+               optional => 1,
+               requires => 'privs',
+           },
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       PVE::AccessControl::lock_user_config(
+           sub {
+                       
+               my $role = $param->{roleid};
+
+               my $usercfg = cfs_read_file("user.cfg");
+       
+               die "role '$role' does not exist\n" 
+                   if !$usercfg->{roles}->{$role};
+
+               $usercfg->{roles}->{$role} = {} if !$param->{append};
+
+               PVE::AccessControl::add_role_privs($role, $usercfg, $param->{privs});
+
+               cfs_write_file("user.cfg", $usercfg);
+           }, "update role failed");
+
+       return undef;
+    }});
+
+# fixme: return format!
+__PACKAGE__->register_method ({
+    name => 'read_role', 
+    path => '{roleid}', 
+    method => 'GET',
+    description => "Get role configuration.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           roleid => { type => 'string' , format => 'pve-roleid' },
+       },
+    },
+    returns => {},
+    code => sub {
+       my ($param) = @_;
+
+       my $usercfg = cfs_read_file("user.cfg");
+
+       my $role = $param->{roleid};
+
+       my $data = $usercfg->{roles}->{$role};
+
+       die "role '$role' does not exist\n" if !$data;
+
+       return $data;
+    }});
+
+
+__PACKAGE__->register_method ({
+    name => 'delete_role', 
+    protected => 1,
+    path => '{roleid}', 
+    method => 'DELETE',
+    description => "Delete role.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           roleid => { type => 'string', format => 'pve-roleid' },
+       }
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       PVE::AccessControl::lock_user_config(
+           sub {
+
+               my $role = $param->{roleid};
+
+               my $usercfg = cfs_read_file("user.cfg");
+
+               die "role '$role' does not exist\n"
+                   if !$usercfg->{roles}->{$role};
+       
+               delete ($usercfg->{roles}->{$role});
+
+               # fixme: delete role from acl?
+
+               cfs_write_file("user.cfg", $usercfg);
+           }, "delete role failed");
+       
+       return undef;
+    }});
+
+1;
diff --git a/PVE/API2/User.pm b/PVE/API2/User.pm
new file mode 100644 (file)
index 0000000..0637f76
--- /dev/null
@@ -0,0 +1,300 @@
+package PVE::API2::User;
+
+use strict;
+use warnings;
+use PVE::Cluster qw (cfs_read_file cfs_write_file);
+use PVE::Tools qw(split_list);
+use PVE::AccessControl;
+use PVE::JSONSchema qw(get_standard_option);
+
+use PVE::SafeSyslog;
+
+use Data::Dumper; # fixme: remove
+
+use PVE::RESTHandler;
+
+use base qw(PVE::RESTHandler);
+
+my $extract_user_data = sub {
+    my ($data, $full) = @_;
+
+    my $res = {};
+
+    foreach my $prop (qw(enable expire firstname lastname email comment)) {
+       $res->{$prop} = $data->{$prop} if defined($data->{$prop});
+    }
+
+    return $res if !$full;
+
+    $res->{groups} = $data->{groups} ? [ keys %{$data->{groups}} ] : [];
+
+    return $res;
+};
+
+__PACKAGE__->register_method ({
+    name => 'index', 
+    path => '', 
+    method => 'GET',
+    description => "User index.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {},
+    },
+    returns => {
+       type => 'array',
+       items => {
+           type => "object",
+           properties => {
+               userid => { type => 'string' },
+           },
+       },
+       links => [ { rel => 'child', href => "{userid}" } ],
+    },
+    code => sub {
+       my ($param) = @_;
+    
+       my $res = [];
+
+       my $usercfg = cfs_read_file("user.cfg");
+       foreach my $user (keys %{$usercfg->{users}}) {
+           next if $user eq 'root';
+
+           my $entry = &$extract_user_data($usercfg->{users}->{$user});
+           $entry->{userid} = $user;
+           push @$res, $entry;
+       }
+
+       return $res;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'create_user', 
+    protected => 1,
+    path => '', 
+    method => 'POST',
+    description => "Create new user.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid'),
+           password => { type => 'string', optional => 1 },
+           groups => { type => 'string', optional => 1, format => 'pve-groupid-list'},
+           firstname => { type => 'string', optional => 1 },
+           lastname => { type => 'string', optional => 1 },
+           email => { type => 'string', optional => 1, format => 'email-opt' },
+           comment => { type => 'string', optional => 1 },
+           expire => { 
+               description => "Account expiration date (seconds since epoch). '0' means no expiration date.",
+               type => 'integer', 
+               minimum => 0,
+               optional => 1,
+           },
+           enable => {
+               description => "Enable the account (default). You can set this to '0' to disable the accout",
+               type => 'boolean',
+               optional => 1,
+               default => 1,
+           },
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       PVE::AccessControl::lock_user_config(
+           sub {
+                       
+               my ($username, $ruid, $realm) = PVE::AccessControl::verify_username($param->{userid});
+       
+               my $usercfg = cfs_read_file("user.cfg");
+
+               die "user '$username' already exists\n" 
+                   if $usercfg->{users}->{$username};
+                        
+               PVE::AccessControl::domain_set_password($realm, $ruid, $param->{password})
+                   if $param->{password};
+
+               my $enable = defined($param->{enable}) ? $param->{enable} : 1;
+               $usercfg->{users}->{$username} = { enable => $enable };
+               $usercfg->{users}->{$username}->{expire} = $param->{expire} if $param->{expire};
+
+               if ($param->{groups}) {
+                   foreach my $group (split_list($param->{groups})) {
+                       if ($usercfg->{groups}->{$group}) {
+                           PVE::AccessControl::add_user_group($username, $usercfg, $group);
+                       } else {
+                           die "no such group '$group'\n";
+                       }
+                   }
+               }
+
+               $usercfg->{users}->{$username}->{firstname} = $param->{firstname} if $param->{firstname};
+               $usercfg->{users}->{$username}->{lastname} = $param->{lastname} if $param->{lastname};
+               $usercfg->{users}->{$username}->{email} = $param->{email} if $param->{email};
+               $usercfg->{users}->{$username}->{comment} = $param->{comment} if $param->{comment};
+
+               cfs_write_file("user.cfg", $usercfg);
+           }, "create user failed");
+
+       return undef;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'read_user', 
+    path => '{userid}', 
+    method => 'GET',
+    description => "Get user configuration.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid'),
+       },
+    },
+    returns => {
+       additionalProperties => 0,
+       properties => {
+           enable => { type => 'boolean' },
+           expire => { type => 'integer', optional => 1 },
+           firstname => { type => 'string', optional => 1 },
+           lastname => { type => 'string', optional => 1 },
+           email => { type => 'string', optional => 1 },
+           comment => { type => 'string', optional => 1 },    
+           groups => { type => 'array' },
+       }
+    },
+    code => sub {
+       my ($param) = @_;
+
+       my ($username, undef, $domain) = 
+           PVE::AccessControl::verify_username($param->{userid});
+
+       my $usercfg = cfs_read_file("user.cfg");
+       my $data = $usercfg->{users}->{$username};
+
+       die "user '$username' does not exist\n" if !$data;
+
+       return &$extract_user_data($data, 1);
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'update_user', 
+    protected => 1,
+    path => '{userid}', 
+    method => 'PUT',
+    description => "Update user configuration.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid'),
+           password => { type => 'string', optional => 1 },
+           groups => { type => 'string', optional => 1,  format => 'pve-groupid-list'  },
+           append => { 
+               type => 'boolean', 
+               optional => 1,
+               requires => 'groups',
+           },
+           enable => {
+               description => "Enable/disable the account.",
+               type => 'boolean',
+               optional => 1,
+           },
+           firstname => { type => 'string', optional => 1 },
+           lastname => { type => 'string', optional => 1 },
+           email => { type => 'string', optional => 1, format => 'email-opt' },
+           comment => { type => 'string', optional => 1 },
+           expire => { 
+               description => "Account expiration date (seconds since epoch). '0' means no expiration date.",
+               type => 'integer', 
+               minimum => 0,
+               optional => 1 
+           },
+       },
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+       
+       PVE::AccessControl::lock_user_config(
+           sub {
+
+               my ($username, $ruid, $realm) = 
+                   PVE::AccessControl::verify_username($param->{userid});
+       
+               my $usercfg = cfs_read_file("user.cfg");
+
+               die "user '$username' does not exist\n" 
+                   if !$usercfg->{users}->{$username};
+
+               PVE::AccessControl::domain_set_password($realm, $ruid, $param->{password})
+                   if $param->{password};
+
+               $usercfg->{users}->{$username}->{enable} = $param->{enable} if defined($param->{enable});
+
+               $usercfg->{users}->{$username}->{expire} = $param->{expire} if defined($param->{expire});
+
+               PVE::AccessControl::delete_user_group($username, $usercfg) 
+                   if (!$param->{append} && $param->{groups});
+
+               if ($param->{groups}) {
+                   foreach my $group (split_list($param->{groups})) {
+                       if ($usercfg->{groups}->{$group}) {
+                           PVE::AccessControl::add_user_group($username, $usercfg, $group);
+                       } else {
+                           die "no such group '$group'\n";
+                       }
+                   }
+               }
+
+               $usercfg->{users}->{$username}->{firstname} = $param->{firstname} if defined($param->{firstname});
+               $usercfg->{users}->{$username}->{lastname} = $param->{lastname} if defined($param->{lastname});
+               $usercfg->{users}->{$username}->{email} = $param->{email} if defined($param->{email});
+               $usercfg->{users}->{$username}->{comment} = $param->{comment} if defined($param->{comment});
+
+               cfs_write_file("user.cfg", $usercfg);
+           }, "update user failed");
+       
+       return undef;
+    }});
+
+__PACKAGE__->register_method ({
+    name => 'delete_user', 
+    protected => 1,
+    path => '{userid}', 
+    method => 'DELETE',
+    description => "Delete user.",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           userid => get_standard_option('userid'),
+       }
+    },
+    returns => { type => 'null' },
+    code => sub {
+       my ($param) = @_;
+
+       PVE::AccessControl::lock_user_config(
+           sub {
+
+               my ($username, $ruid, $realm) = 
+                   PVE::AccessControl::verify_username($param->{userid});
+
+               my $usercfg = cfs_read_file("user.cfg");
+
+               die "user '$username' does not exist\n" 
+                   if !$usercfg->{users}->{$username};
+
+               delete ($usercfg->{users}->{$username});
+
+               PVE::AccessControl::delete_shadow_password($ruid) if $realm eq 'pve';
+               PVE::AccessControl::delete_user_group($username, $usercfg);
+               PVE::AccessControl::delete_user_acl($username, $usercfg);
+
+               cfs_write_file("user.cfg", $usercfg);
+           }, "delete user failed");
+       
+       return undef;
+    }});
+
+1;
diff --git a/PVE/AccessControl.pm b/PVE/AccessControl.pm
new file mode 100644 (file)
index 0000000..f018826
--- /dev/null
@@ -0,0 +1,1235 @@
+package PVE::AccessControl;
+
+use strict;
+use Encode;
+use Crypt::OpenSSL::Random;
+use Crypt::OpenSSL::RSA;
+use MIME::Base64;
+use Digest::SHA;
+use Authen::PAM qw(:constants);
+use Net::LDAP;
+use PVE::Tools qw(run_command lock_file file_get_contents split_list safe_print);
+use PVE::Cluster qw(cfs_register_file cfs_read_file cfs_write_file cfs_lock_file);
+use PVE::JSONSchema;
+use Encode;
+
+use Data::Dumper; # fixme: remove
+
+# $authdir must be writable by root only!
+my $confdir = "/etc/pve";
+my $authdir = "$confdir/priv";
+my $authprivkeyfn = "$authdir/authkey.key";
+my $authpubkeyfn = "$confdir/authkey.pub";
+my $shadowconfigfile = "priv/shadow.cfg";
+my $domainconfigfile = "domains.cfg";
+my $pve_www_key_fn = "$confdir/pve-www.key";
+
+my $ticket_lifetime = 3600*2; # 2 hours
+
+Crypt::OpenSSL::RSA->import_random_seed();
+
+cfs_register_file('user.cfg', 
+                 \&parse_user_config,  
+                 \&write_user_config);
+
+cfs_register_file($shadowconfigfile, 
+                 \&parse_shadow_passwd, 
+                 \&write_shadow_config);
+
+cfs_register_file($domainconfigfile, 
+                 \&parse_domains,
+                 \&write_domains);
+
+
+sub lock_user_config {
+    my ($code, $errmsg) = @_;
+
+    cfs_lock_file("user.cfg", undef, $code);
+    my $err = $@;
+    if ($err) {
+       $errmsg ? die "$errmsg: $err" : die $err;
+    }
+}
+
+sub lock_domain_config {
+    my ($code, $errmsg) = @_;
+
+    cfs_lock_file($domainconfigfile, undef, $code);
+    my $err = $@;
+    if ($err) {
+       $errmsg ? die "$errmsg: $err" : die $err;
+    }
+}
+
+sub lock_shadow_config {
+    my ($code, $errmsg) = @_;
+
+    cfs_lock_file($shadowconfigfile, undef, $code);
+    my $err = $@;
+    if ($err) {
+       $errmsg ? die "$errmsg: $err" : die $err;
+    }
+}
+
+my $pve_auth_pub_key;
+sub get_pubkey {    
+
+    return $pve_auth_pub_key if $pve_auth_pub_key;
+
+    my $input = PVE::Tools::file_get_contents($authpubkeyfn); 
+
+    $pve_auth_pub_key = Crypt::OpenSSL::RSA->new_public_key($input);
+
+    return $pve_auth_pub_key;
+}
+
+my $csrf_prevention_secret;
+my $get_csrfr_secret = sub {
+    if (!$csrf_prevention_secret) {
+       my $input = PVE::Tools::file_get_contents($pve_www_key_fn); 
+       $csrf_prevention_secret = Digest::SHA::sha1_base64($input);
+    }
+    return $csrf_prevention_secret;
+};
+
+sub assemble_csrf_prevention_token {
+    my ($username) = @_;
+
+    my $timestamp = sprintf("%08X", time());
+
+    my $digest = Digest::SHA::sha1_base64("$timestamp:$username", &$get_csrfr_secret());
+
+    return "$timestamp:$digest"; 
+}
+
+sub verify_csrf_prevention_token {
+    my ($username, $token, $noerr) = @_;
+
+    if ($token =~ m/^([A-Z0-9]{8}):(\S+)$/) {
+       my $sig = $2;
+       my $timestamp = $1;
+       my $ttime = hex($timestamp);
+
+       my $digest = Digest::SHA::sha1_base64("$timestamp:$username", &$get_csrfr_secret());
+
+       my $age = time() - $ttime;
+       return if ($digest eq $sig) && ($age > -300) && ($age < $ticket_lifetime);
+    }
+
+    die "Permission denied - invalid csrf token\n" if !$noerr;
+
+    return undef;
+}
+
+my $pve_auth_priv_key;
+sub get_privkey {
+
+    return $pve_auth_priv_key if $pve_auth_priv_key;
+
+    my $input = PVE::Tools::file_get_contents($authprivkeyfn); 
+
+    $pve_auth_priv_key = Crypt::OpenSSL::RSA->new_private_key($input);
+
+    return $pve_auth_priv_key;
+}
+
+sub assemble_ticket {
+    my ($username) = @_;
+
+    my $rsa_priv = get_privkey();
+
+    my $timestamp = sprintf("%08X", time());
+
+    my $plain = "PVE:$username:$timestamp";
+
+    my $ticket = $plain . "::" . encode_base64($rsa_priv->sign($plain), '');
+
+    return $ticket;
+}
+
+sub verify_ticket {
+    my ($ticket, $noerr) = @_;
+
+    if ($ticket && $ticket =~ m/^(\S+)::([^:\s]+)$/) {
+       my $plain = $1;
+       my $sig = $2;
+
+       my $rsa_pub = get_pubkey();
+       if ($rsa_pub->verify($plain, decode_base64($sig))) {
+           if ($plain =~ m/^PVE:(([A-Za-z0-9\.\-_]+)(\@([A-Za-z0-9\.\-_]+))?):([A-Z0-9]{8})$/) {
+               my $username = $1;
+               my $timestamp = $5;
+               my $ttime = hex($timestamp);
+
+               my $age = time() - $ttime;
+
+               if (($age > -300) && ($age < $ticket_lifetime)) {
+                   return wantarray ? ($username, $age) : $username;
+               }
+           }
+       }
+    }
+
+    die "permission denied - invalid ticket\n" if !$noerr;
+
+    return undef;
+}
+
+sub authenticate_user_shadow {
+    my ($userid, $password) = @_;
+
+    die "no password\n" if !$password;
+
+    my $shadow_cfg = cfs_read_file($shadowconfigfile);
+    
+    if ($shadow_cfg->{users}->{$userid}) {
+       my $encpw = crypt($password, $shadow_cfg->{users}->{$userid}->{shadow});
+        die "invalid credentials\n" if ($encpw ne $shadow_cfg->{users}->{$userid}->{shadow});
+    } else {
+       die "no password set\n";
+    }
+}
+
+sub authenticate_user_pam {
+    my ($userid, $password) = @_;
+
+    # user (www-data) need to be able to read /etc/passwd /etc/shadow
+
+    die "no password\n" if !$password;
+
+    my $pamh = new Authen::PAM ('common-auth', $userid, sub {
+       my @res;
+       while(@_) {
+           my $msg_type = shift;
+           my $msg = shift;
+           push @res, (0, $password);
+       }
+       push @res, 0;
+       return @res;
+    });
+
+    if (!ref ($pamh)) {
+       my $err = $pamh->pam_strerror($pamh);
+       die "error during PAM init: $err";
+    }
+
+    my $res;
+
+    if (($res = $pamh->pam_authenticate(0)) != PAM_SUCCESS) {
+       my $err = $pamh->pam_strerror($res);
+       die "$err\n";
+    }
+
+    if (($res = $pamh->pam_acct_mgmt (0)) != PAM_SUCCESS) {
+       my $err = $pamh->pam_strerror($res);
+       die "$err\n";
+    }
+
+    $pamh = 0; # call destructor
+}
+
+sub authenticate_user_ad {
+
+    my ($entry, $server, $userid, $password) = @_;
+
+    my $default_port = $entry->{secure} ? 636: 389;
+    my $port = $entry->{port} ? $entry->{port} : $default_port;
+    my $scheme = $entry->{secure} ? 'ldaps' : 'ldap';
+    my $conn_string = "$scheme://${server}:$port";
+    
+    my $ldap = Net::LDAP->new($server) || die "$@\n";
+
+    $userid = "$userid\@$entry->{domain}" 
+       if $userid !~ m/@/ && $entry->{domain};
+
+    my $res = $ldap->bind($userid, password => $password);
+
+    my $code = $res->code();
+    my $err = $res->error;
+
+    $ldap->unbind();
+
+    die "$err\n" if ($code);
+}
+
+sub authenticate_user_ldap {
+
+    my ($entry, $server, $userid, $password) = @_;
+
+    my $default_port = $entry->{secure} ? 636: 389;
+    my $port = $entry->{port} ? $entry->{port} : $default_port;
+    my $scheme = $entry->{secure} ? 'ldaps' : 'ldap';
+    my $conn_string = "$scheme://${server}:$port";
+
+    my $ldap = Net::LDAP->new($conn_string, verify => 'none') || die "$@\n";
+    my $search = $entry->{user_attr} . "=" . $userid;
+    my $result = $ldap->search( base    => "$entry->{base_dn}",
+                               scope   => "sub",
+                               filter  => "$search",
+                               attrs   => ['dn']
+                               );
+    die "no entries returned\n" if !$result->entries;
+    my @entries = $result->entries;
+    my $res = $ldap->bind($entries[0]->dn, password => $password);
+
+    my $code = $res->code();
+    my $err = $res->error;
+
+    $ldap->unbind();
+
+    die "$err\n" if ($code);
+}
+
+sub authenticate_user_domain {
+    my ($realm, $userid, $password) = @_;
+    my $domain_cfg = cfs_read_file($domainconfigfile);
+
+    die "no auth domain specified" if !$realm;
+
+    if ($realm eq 'pam') {
+       authenticate_user_pam($userid, $password);
+       return;
+    } 
+
+    eval {
+       if ($realm eq 'pve') {
+           authenticate_user_shadow($userid, $password);
+       } else { 
+
+           my $cfg = $domain_cfg->{$realm};
+           die "auth domain '$realm' does not exists\n" if !$cfg;
+    
+           if ($cfg->{type} eq 'ad') {
+               eval { authenticate_user_ad($cfg, $cfg->{server1}, $userid, $password); };
+               my $err = $@;
+               return if !$err;
+               die $err if !$cfg->{server2};
+               authenticate_user_ad($cfg, $cfg->{server2}, $userid, $password); 
+           } elsif ($cfg->{type} eq 'ldap') {
+               eval { authenticate_user_ldap($cfg, $cfg->{server1}, $userid, $password); };
+               my $err = $@;
+               return if !$err;
+               die $err if !$cfg->{server2};
+               authenticate_user_ldap($cfg, $cfg->{server2}, $userid, $password); 
+           } else {
+               die "unknown auth type '$cfg->{type}'\n";
+           }
+       }
+    };
+    if (my $err = $@) {
+       sleep(2); # timeout after failed auth
+       die $err;
+    }
+}
+
+sub user_enabled {
+    my ($usercfg, $username) = @_;
+
+    $username = verify_username($username, 1);
+    return undef if !$username;
+    return 1 if $usercfg && $usercfg->{users}->{$username} &&
+       $usercfg->{users}->{$username}->{enable};
+
+    return 1 if $username eq 'root@pam'; # root is always enabled
+
+    return 0;
+}
+
+# password should be utf8 encoded
+sub authenticate_user {
+    my ($username, $password) = @_;
+
+    die "no username specified\n" if !$username;
+    my ($userid, $realm);
+
+    ($username, $userid, $realm) = verify_username($username);
+
+    my $usercfg = cfs_read_file('user.cfg');
+
+    if (!user_enabled($usercfg, $username)) {
+       sleep(2);
+       die "no such user ('$username')\n"
+    }
+
+    my $ctime = time();
+    my $expire = $usercfg->{users}->{$username}->{expire};
+
+    if ($expire && ($expire < $ctime)) {
+       sleep(2);
+       die "account expired\n"
+    }
+
+    authenticate_user_domain($realm, $userid, $password);
+
+    return $username;
+}
+
+sub delete_shadow_password {
+    my ($userid) = @_;
+    lock_shadow_config(sub {
+       my $shadow_cfg = cfs_read_file($shadowconfigfile);
+       delete ($shadow_cfg->{users}->{$userid})
+           if $shadow_cfg->{users}->{$userid};
+       cfs_write_file($shadowconfigfile, $shadow_cfg);
+    });
+}
+
+sub store_shadow_password {
+    my ($userid, $password) = @_;
+  
+    lock_shadow_config(sub {
+       my $shadow_cfg = cfs_read_file($shadowconfigfile);
+       $shadow_cfg->{users}->{$userid}->{shadow} = encrypt_pw($password);
+       cfs_write_file($shadowconfigfile, $shadow_cfg);
+    });
+}
+
+sub encrypt_pw {
+    my ($pw) = @_;
+
+    my $time = substr (Digest::SHA::sha1_base64 (time), 0, 8);
+    return crypt (encode("utf8", $pw), "\$5\$$time\$");
+}
+
+sub store_pam_password {
+    my ($userid, $password) = @_;
+
+    my $cmd = ['/usr/sbin/usermod'];
+
+    my $epw = encrypt_pw($password);
+    push @$cmd, '-p', $epw;
+
+    push @$cmd, $userid;
+
+    run_command($cmd);
+}
+
+sub domain_set_password {
+    my ($realm, $userid, $password) = @_;
+
+    die "no auth domain specified" if !$realm;
+
+    if ($realm eq 'pam') {
+       store_pam_password($userid, $password);
+    } elsif ($realm eq 'pve') {
+       store_shadow_password($userid, $password);
+    } else {
+       die "can't set password on auth domain '$realm'\n";
+    }
+}
+
+sub add_user_group {
+
+    my ($username, $usercfg, $group) = @_;
+    $usercfg->{users}->{$username}->{groups}->{$group} = 1;
+    $usercfg->{groups}->{$group}->{users}->{$username} = 1;
+}
+
+sub delete_user_group {
+
+    my ($username, $usercfg) = @_;
+    
+    foreach my $group (keys %{$usercfg->{groups}}) {
+
+       delete ($usercfg->{groups}->{$group}->{users}->{$username}) 
+           if $usercfg->{groups}->{$group}->{users}->{$username};
+    }
+}
+
+sub delete_user_acl {
+
+    my ($username, $usercfg) = @_;
+
+    foreach my $acl (keys %{$usercfg->{acl}}) {
+
+       delete ($usercfg->{acl}->{$acl}->{users}->{$username}) 
+           if $usercfg->{acl}->{$acl}->{users}->{$username};
+    }
+
+}
+sub delete_group_acl {
+
+    my ($group, $usercfg) = @_;
+
+    foreach my $acl (keys %{$usercfg->{acl}}) {
+
+       delete ($usercfg->{acl}->{$acl}->{groups}->{$group}) 
+           if $usercfg->{acl}->{$acl}->{groups}->{$group};
+    }
+
+}
+
+# we automatically create some predefined roles by splitting privs
+# into 3 groups (per category)
+# root: only root is allowed to do that
+# admin: an administrator can to that
+# user: a normak user/customer can to that
+my $privgroups = {
+    VM => {
+       root => [],
+       admin => [           
+           'VM.Modify', 
+           'VM.Allocate', 
+           'VM.Migrate',
+           'Permissions.Modify',
+       ],
+       user => [
+           'VM.Console', 
+           'VM.PowerMgmt',
+       ],
+       audit => [ 
+           'VM.Audit' 
+       ],
+    },
+    Sys => {
+       root => [
+           'Sys.PowerMgmt',     
+           'Sys.Modify', # edit/change node settings    
+       ],
+       admin => [
+           'Sys.Console',    
+           'Sys.Syslog',
+       ],
+       user => [],
+       audit => [
+           'Sys.Audit',
+       ],
+    },
+    Datastore => {
+       root => [
+           'Datastore.Allocate',
+           'Permissions.Modify',
+       ],
+       admin => [],
+       user => [
+           'Datastore.AllocateSpace',
+       ],
+       audit => [
+           'Datastore.Audit',
+       ],
+    },
+};
+
+my $valid_privs = {};
+
+my $special_roles = {
+    'NoAccess' => {}, # no priviledges
+    'Administrator' => $valid_privs, # all priviledges
+};
+
+sub create_roles {
+
+    foreach my $cat (keys %$privgroups) {
+       my $cd = $privgroups->{$cat};
+       foreach my $p (@{$cd->{root}}, @{$cd->{admin}}, 
+                      @{$cd->{user}}, @{$cd->{audit}}) {
+           $valid_privs->{$p} = 1;
+       }
+       foreach my $p (@{$cd->{admin}}, @{$cd->{user}}, @{$cd->{audit}}) {
+
+           $special_roles->{"PVE${cat}Admin"}->{$p} = 1;
+           $special_roles->{"PVEAdmin"}->{$p} = 1;
+       }
+       if (scalar(@{$cd->{user}})) {
+           foreach my $p (@{$cd->{user}}, @{$cd->{audit}}) {
+               $special_roles->{"PVE${cat}User"}->{$p} = 1;
+           }
+       }
+       foreach my $p (@{$cd->{audit}}) {
+           $special_roles->{"PVEAuditor"}->{$p} = 1;
+       }
+    }
+};
+
+create_roles();
+
+my $valid_attributes = {
+    ad => {
+       server1 => '[\w\d]+(.[\w\d]+)*',
+       server2 => '[\w\d]+(.[\w\d]+)*',
+       domain => '\S+',
+       port => '\d*',
+       secure => '',
+       comment => '.*',
+    },
+    ldap => {
+       server1 => '[\w\d]+(.[\w\d]+)*',
+       server2 => '[\w\d]+(.[\w\d]+)*',
+       base_dn => '\w+=[\w\s]+(,\s*\w+=[\w\s]+)*',
+       user_attr => '\S{2,}',
+       secure => '',
+       port => '\d*',
+       comment => '.*',
+    }
+};
+
+sub add_role_privs {
+    my ($role, $usercfg, $privs) = @_;
+
+    return if !$privs;
+
+    die "role '$role' does not exist\n" if !$usercfg->{roles}->{$role};
+
+    foreach my $priv (split_list($privs)) {
+       if (defined ($valid_privs->{$priv})) {
+           $usercfg->{roles}->{$role}->{$priv} = 1;
+       } else {
+           die "invalid priviledge '$priv'\n";
+       } 
+    }  
+}
+
+sub normalize_path {
+    my $path = shift;
+
+    $path =~ s|/+|/|;
+
+    $path =~ s|/$||;
+
+    $path = '/' if !$path;
+
+    return undef if $path !~ m|^[[:alnum:]\-\_\/]+$|;
+
+    return $path;
+} 
+
+my $realm_regex = qr/[A-Za-z][A-Za-z0-9\.\-_]+/;
+
+sub pve_verify_realm {
+    my ($realm, $noerr) = @_;
+    if ($realm !~ m/^${realm_regex}$/) {
+       return undef if $noerr;
+       die "value does not look like a valid realm\n"; 
+    }
+    return $realm;
+}
+
+PVE::JSONSchema::register_format('pve-userid', \&verify_username);
+sub verify_username {
+    my ($username, $noerr) = @_;
+
+    $username = '' if !$username;
+    my $len = length($username);
+    if ($len < 3) {
+       die "user name '$username' is too short\n" if !$noerr;
+       return undef;
+    }
+    if ($len > 64) {
+       die "user name '$username' is too long ($len > 64)\n" if !$noerr;
+       return undef;
+    }
+
+    # we only allow a limited set of characters (colon is not allowed,
+    # because we store usernames in colon separated lists)!
+    if ($username =~ m/^([^\s:]+)\@(${realm_regex})$/) {
+       return wantarray ? ($username, $1, $2) : $username;
+    }
+
+    die "value '$username' does not look like a valid user name\n" if !$noerr;
+
+    return undef;
+}
+PVE::JSONSchema::register_standard_option('userid', {
+    description => "User ID",
+    type => 'string', format => 'pve-userid',
+    maxLength => 64,
+});
+
+PVE::JSONSchema::register_standard_option('realm', {
+    description => "Authentication domain ID",
+    type => 'string', format => 'pve-configid',
+    maxLength => 32,
+});
+
+PVE::JSONSchema::register_format('pve-groupid', \&verify_groupname);
+sub verify_groupname {
+    my ($groupname, $noerr) = @_;
+
+    if ($groupname !~ m/^[A-Za-z0-9\.\-_]+$/) {
+
+       die "group name '$groupname' contains invalid characters\n" if !$noerr;
+
+       return undef;
+    }
+    
+    return $groupname;
+}
+
+PVE::JSONSchema::register_format('pve-roleid', \&verify_rolename);
+sub verify_rolename {
+    my ($rolename, $noerr) = @_;
+
+    if ($rolename !~ m/^[A-Za-z0-9\.\-_]+$/) {
+
+       die "role name '$rolename' contains invalid characters\n" if !$noerr;
+
+       return undef;
+    }
+    
+    return $rolename;
+}
+
+PVE::JSONSchema::register_format('pve-priv', \&verify_privname);
+sub verify_privname {
+    my ($priv, $noerr) = @_;
+
+    if (!$valid_privs->{$priv}) {
+       die "invalid priviledge '$priv'\n" if !$noerr;
+
+       return undef;
+    }
+    
+    return $priv;
+}
+
+sub userconfig_force_defaults {
+    my ($cfg) = @_;
+
+    foreach my $r (keys %$special_roles) {
+       $cfg->{roles}->{$r} = $special_roles->{$r};
+    }
+
+    # fixme: remove 'root' group (not required)?
+
+    # add root user 
+    $cfg->{users}->{'root@pam'}->{enable} = 1;
+}
+
+sub parse_user_config {
+    my ($filename, $raw) = @_;
+
+    my $cfg = {};
+
+    userconfig_force_defaults($cfg);
+
+    while ($raw && $raw =~ s/^(.*?)(\n|$)//) {
+       my $line = $1;
+
+       next if $line =~ m/^\s*$/; # skip empty lines
+
+       my @data;
+
+       foreach my $d (split (/:/, $line)) {
+           $d =~ s/^\s+//; 
+           $d =~ s/\s+$//;
+           push @data, $d
+       }
+
+       my $et = shift @data;
+
+       if ($et eq 'user') {
+           my ($user, $enable, $expire, $firstname, $lastname, $email, $comment) = @data;
+
+           my (undef, undef, $realm) = verify_username($user, 1);
+           if (!$realm) {
+               warn "user config - ignore user '$user' - invalid user name\n";
+               next;
+           }
+
+           $enable = $enable ? 1 : 0;
+
+           $expire = 0 if !$expire;
+
+           if ($expire !~ m/^\d+$/) {
+               warn "user config - ignore user '$user' - (illegal characters in expire '$expire')\n";
+               next;
+           }
+           $expire = int($expire);
+
+           #if (!verify_groupname ($group, 1)) {
+           #    warn "user config - ignore user '$user' - invalid characters in group name\n";
+           #    next;
+           #}
+
+           $cfg->{users}->{$user} = {
+               enable => $enable,
+               # group => $group,
+           };
+           $cfg->{users}->{$user}->{firstname} = PVE::Tools::decode_text($firstname) if $firstname;
+           $cfg->{users}->{$user}->{lastname} = PVE::Tools::decode_text($lastname) if $lastname;
+           $cfg->{users}->{$user}->{email} = $email;
+           $cfg->{users}->{$user}->{comment} = PVE::Tools::decode_text($comment) if $comment;
+           $cfg->{users}->{$user}->{expire} = $expire;
+
+           #$cfg->{users}->{$user}->{groups}->{$group} = 1;
+           #$cfg->{groups}->{$group}->{$user} = 1;
+
+       } elsif ($et eq 'group') {
+           my ($group, $userlist, $comment) = @data;
+
+           if (!verify_groupname($group, 1)) {
+               warn "user config - ignore group '$group' - invalid characters in group name\n";
+               next;
+           }
+
+           # make sure to add the group (even if there are no members)
+           $cfg->{groups}->{$group} = { users => {} } if !$cfg->{groups}->{$group};
+
+           $cfg->{groups}->{$group}->{comment} = PVE::Tools::decode_text($comment) if $comment;
+
+           foreach my $user (split_list($userlist)) {
+
+               if (!verify_username($user, 1)) {
+                   warn "user config - ignore invalid group member '$user'\n";
+                   next;
+               }
+
+               if ($cfg->{users}->{$user}) { # user exists 
+                   $cfg->{users}->{$user}->{groups}->{$group} = 1;
+                   $cfg->{groups}->{$group}->{users}->{$user} = 1;
+               } else {
+                   warn "user config - ignore invalid group member '$user'\n";
+               }
+           }
+
+       } elsif ($et eq 'role') {
+           my ($role, $privlist) = @data;
+               
+           if (!verify_rolename($role, 1)) {
+               warn "user config - ignore role '$role' - invalid characters in role name\n";
+               next;
+           }
+
+           # make sure to add the role (even if there are no privileges)
+           $cfg->{roles}->{$role} = {} if !$cfg->{roles}->{$role};
+
+           foreach my $priv (split_list($privlist)) {
+               if (defined ($valid_privs->{$priv})) {
+                   $cfg->{roles}->{$role}->{$priv} = 1;
+               } else {
+                   warn "user config - ignore invalid priviledge '$priv'\n";
+               } 
+           }
+           
+       } elsif ($et eq 'acl') {
+           my ($propagate, $pathtxt, $uglist, $rolelist) = @data;
+
+           if (my $path = normalize_path($pathtxt)) {
+               foreach my $role (split_list($rolelist)) {
+                       
+                   if (!verify_rolename($role, 1)) {
+                       warn "user config - ignore invalid role name '$role' in acl\n";
+                       next;
+                   }
+
+                   foreach my $ug (split_list($uglist)) {
+                       if ($ug =~ m/^@(\w+)$/) {
+                           my $group = $1;
+                           if ($cfg->{groups}->{$group}) { # group exists 
+                               $cfg->{acl}->{$path}->{groups}->{$group}->{$role} = $propagate;
+                           } else {
+                               warn "user config - ignore invalid acl group '$group'\n";
+                           }
+                       } elsif (verify_username($ug, 1)) {
+                           if ($cfg->{users}->{$ug}) { # user exists 
+                               $cfg->{acl}->{$path}->{users}->{$ug}->{$role} = $propagate;
+                           } else {
+                               warn "user config - ignore invalid acl member '$ug'\n";
+                           }
+                       } else {
+                           warn "user config - invalid user/group '$ug' in acl\n";
+                       }
+                   }
+               }
+           } else {
+               warn "user config - ignore invalid path in acl '$pathtxt'\n";
+           }
+       } else {
+           warn "user config - ignore config line: $line\n";
+       }
+    }
+
+    userconfig_force_defaults($cfg);
+
+    return $cfg;
+}
+
+sub parse_shadow_passwd {
+    my ($filename, $raw) = @_;
+
+    my $shadow = {};
+
+    while ($raw && $raw =~ s/^(.*?)(\n|$)//) {
+       my $line = $1;
+
+       next if $line =~ m/^\s*$/; # skip empty lines
+
+       if ($line !~ m/^\S+:\S+:$/) {
+           warn "pve shadow password: ignore invalid line $.\n";
+           next;
+       }
+
+       my ($userid, $crypt_pass) = split (/:/, $line);
+       $shadow->{users}->{$userid}->{shadow} = $crypt_pass;
+    }
+
+    return $shadow;
+}
+
+sub write_domains {
+    my ($filename, $cfg) = @_;
+
+    my $data = '';
+
+    my $wrote_default;
+
+    foreach my $realm (sort keys %$cfg) {
+       my $entry = $cfg->{$realm};
+       my $type = lc($entry->{type});
+
+       next if !$type;
+
+       next if ($type eq 'pam') || ($type eq 'pve');
+
+       my $formats = $valid_attributes->{$type};
+       next if !$formats;
+
+       $data .= "$type: $realm\n";
+
+       foreach my $k (sort keys %$entry) {
+           next if $k eq 'type';
+           my $v = $entry->{$k};
+           if ($k eq 'default') {
+                   $data .= "\t$k\n" if $v && !$wrote_default;
+                   $wrote_default = 1;
+           } elsif (defined($formats->{$k})) {
+               if (!$formats->{$k}) {
+                   $data .= "\t$k\n";
+               } elsif ($v =~ m/^$formats->{$k}$/) {
+                   $v = PVE::Tools::encode_text($v) if $k eq 'comment';
+                   $data .= "\t$k $v\n";
+               } else {
+                   die "invalid value '$v' for attribute '$k'\n";
+               }
+           } else {
+               die "invalid attribute '$k' - not supported\n";
+           }
+       }
+
+       $data .= "\n";
+    }
+
+    return $data;
+}
+
+sub parse_domains {
+    my ($filename, $raw) = @_;
+
+    my $cfg = {};
+
+    my $default;
+
+    while ($raw && $raw =~ s/^(.*?)(\n|$)//) {
+       my $line = $1;
+       next if $line =~ m/^\#/; # skip comment lines
+       next if $line =~ m/^\s*$/; # skip empty lines
+
+       if ($line =~ m/^(\S+):\s*(\S+)\s*$/) {
+           my $realm = $2;
+           my $type = lc($1);
+
+           my $ignore = 0;
+           my $entry;
+
+           my $formats = $valid_attributes->{$type};
+           if (!$formats) {
+               $ignore = 1;
+               warn "ignoring domain '$realm' - (unsupported authentication type '$type')\n";
+           } elsif (!pve_verify_realm($realm, 1)) {
+               $ignore = 1;
+               warn "ignoring domain '$realm' - (illegal characters)\n";
+           } else {
+               $entry = { type => $type };
+           }
+
+           while ($raw && $raw =~ s/^(.*?)(\n|$)//) {
+               $line = $1;
+
+               next if $line =~ m/^\#/; #skip comment lines
+               last if $line =~ m/^\s*$/;
+                   
+               next if $ignore; # skip
+
+               if ($line =~ m/^\s+(default)\s*$/) {
+                   $default = $realm if !$default;
+               } elsif ($line =~ m/^\s+(\S+)(\s+(.*\S))?\s*$/) {
+                   my ($k, $v) = (lc($1), $3);
+                   if (defined($formats->{$k})) {
+                       if (!$formats->{$k} && !defined($v)) {
+                               $entry->{$k} = 1;                           
+                       } elsif ($formats->{$k} && $v =~ m/^$formats->{$k}$/) {
+                           if (!defined($entry->{$k})) {
+                               $v = PVE::Tools::decode_text($v) if $k eq 'comment';
+                               $entry->{$k} = $v;
+                           } else {
+                               warn "ignoring duplicate attribute '$k $v'\n";
+                           }
+                       } else {
+                           warn "ignoring value '$v' for attribute '$k' - invalid format\n";
+                       }
+                   } else {
+                       warn "ignoring attribute '$k' - not supported\n";
+                   }
+               } else {
+                   warn "ignore config line: $line\n";
+               }
+           }
+
+           if ($entry->{server2} && !$entry->{server1}) {
+               $entry->{server1} = $entry->{server2};
+               delete $entry->{server2};
+           }
+
+           if ($ignore) {
+               # do nothing
+           } elsif (!$entry->{server1}) {
+               warn "ignoring domain '$realm' - missing server attribute\n";
+           } elsif (($entry->{type} eq "ldap") && !$entry->{user_attr}) {
+               warn "ignoring domain '$realm' - missing user attribute\n";
+           } elsif (($entry->{type} eq "ldap") && !$entry->{base_dn}) {
+               warn "ignoring domain '$realm' - missing base_dn attribute\n";
+           } else {
+               $cfg->{$realm} = $entry;
+           }
+     
+       } else {
+           warn "ignore config line: $line\n";
+       }
+    }
+
+    $cfg->{$default}->{default} = 1 if $default;
+
+    # add default domains
+
+    $cfg->{pve} = {
+       type => 'builtin',
+       comment => "Proxmox VE authentication server", 
+    };
+
+    $cfg->{pam} = {
+       type => 'builtin',
+       comment => "Linux PAM standard authentication", 
+    };
+       
+    return $cfg;
+}
+
+sub write_shadow_config {
+    my ($filename, $cfg) = @_;
+
+    my $data = '';
+    foreach my $userid (keys %{$cfg->{users}}) {
+       my $crypt_pass = $cfg->{users}->{$userid}->{shadow};
+       $data .= "$userid:$crypt_pass:\n";
+    }
+
+    return $data
+}
+
+sub write_user_config {
+    my ($filename, $cfg) = @_;
+
+    my $data = '';
+
+    foreach my $user (keys %{$cfg->{users}}) {
+       next if $user eq 'root@pam';
+
+       my $d = $cfg->{users}->{$user};
+       my $firstname = $d->{firstname} ? PVE::Tools::encode_text($d->{firstname}) : '';
+       my $lastname = $d->{lastname} ? PVE::Tools::encode_text($d->{lastname}) : '';
+       my $email = $d->{email} || '';
+       my $comment = $d->{comment} ? PVE::Tools::encode_text($d->{comment}) : '';
+       my $expire = int($d->{expire}) || 0;
+       my $enable = $d->{enable} ? 1 : 0;
+       $data .= "user:$user:$enable:$expire:$firstname:$lastname:$email:$comment:\n";
+    }
+
+    $data .= "\n";
+
+    foreach my $group (keys %{$cfg->{groups}}) {
+       my $d = $cfg->{groups}->{$group};
+       my $list = join (',', keys %{$d->{users}});
+       my $comment = $d->{comment} ? PVE::Tools::encode_text($d->{comment}) : '';      
+       $data .= "group:$group:$list:$comment:\n";
+    }
+
+    $data .= "\n";
+
+    foreach my $role (keys %{$cfg->{roles}}) {
+       next if $special_roles->{$role};
+
+       my $d = $cfg->{roles}->{$role};
+       my $list = join (',', keys %$d);
+       $data .= "role:$role:$list:\n";
+    }
+
+    $data .= "\n";
+
+    foreach my $path (sort keys %{$cfg->{acl}}) {
+       my $d = $cfg->{acl}->{$path};
+
+       my $ra = {};
+
+       foreach my $group (keys %{$d->{groups}}) {
+           my $l0 = '';
+           my $l1 = '';
+           foreach my $role (sort keys %{$d->{groups}->{$group}}) {
+               my $propagate = $d->{groups}->{$group}->{$role};
+               if ($propagate) {
+                   $l1 .= ',' if $l1;
+                   $l1 .= $role;
+               } else {
+                   $l0 .= ',' if $l0;
+                   $l0 .= $role;
+               }
+           }
+           $ra->{0}->{$l0}->{"\@$group"} = 1 if $l0;
+           $ra->{1}->{$l1}->{"\@$group"} = 1 if $l1;
+       }
+
+       foreach my $user (keys %{$d->{users}}) {
+           # no need to save, because root is always 'Administartor'
+           next if $user eq 'root@pam'; 
+
+           my $l0 = '';
+           my $l1 = '';
+           foreach my $role (sort keys %{$d->{users}->{$user}}) {
+               my $propagate = $d->{users}->{$user}->{$role};
+               if ($propagate) {
+                   $l1 .= ',' if $l1;
+                   $l1 .= $role;
+               } else {
+                   $l0 .= ',' if $l0;
+                   $l0 .= $role;
+               }
+           }
+           $ra->{0}->{$l0}->{$user} = 1 if $l0;
+           $ra->{1}->{$l1}->{$user} = 1 if $l1;
+       }
+
+       foreach my $rolelist (sort keys %{$ra->{0}}) {
+           my $uglist = join (',', keys %{$ra->{0}->{$rolelist}});
+           $data .= "acl:0:$path:$uglist:$rolelist:\n";
+       }
+       foreach my $rolelist (sort keys %{$ra->{1}}) {
+           my $uglist = join (',', keys %{$ra->{1}->{$rolelist}});
+           $data .= "acl:1:$path:$uglist:$rolelist:\n";
+       }
+    }
+
+    return $data;
+}
+
+sub roles {
+    my ($cfg, $user, $path) = @_;
+
+    return 'Administrator' if $user eq 'root@pam'; # root can do anything
+
+    my $perm = {};
+
+    foreach my $p (sort keys %{$cfg->{acl}}) {
+       my $final = ($path eq $p);
+
+       next if !(($p eq '/') || $final || ($path =~ m|^$p/|));
+
+       my $acl = $cfg->{acl}->{$p};
+
+       #print "CHECKACL $path $p\n";
+       #print "ACL $path = " . Dumper ($acl);
+
+       if (my $ri = $acl->{users}->{$user}) {
+           my $new;
+           foreach my $role (keys %$ri) {
+               my $propagate = $ri->{$role};
+               if ($final || $propagate) {
+                   #print "APPLY ROLE $p $user $role\n";
+                   $new = {} if !$new;
+                   $new->{$role} = 1;
+               }
+           }
+           if ($new) {
+               $perm = $new; # overwrite previous settings
+               next; # user privs always override group privs
+           }
+       }
+
+       my $new;
+       foreach my $g (keys %{$acl->{groups}}) {
+           next if !$cfg->{groups}->{$g}->{users}->{$user};
+           if (my $ri = $acl->{groups}->{$g}) {
+               foreach my $role (keys %$ri) {
+                   my $propagate = $ri->{$role};
+                   if ($final || $propagate) {
+                       #print "APPLY ROLE $p \@$g $role\n";
+                       $new = {} if !$new;
+                       $new->{$role} = 1;
+                   }
+               }
+           }
+       }
+       if ($new) {
+           $perm = $new; # overwrite previous settings
+           next;
+       }
+
+       #die "what herea?";
+    }
+
+    my $res = {};
+    if (!defined ($perm->{NoAccess})) {
+       $res = $perm; 
+    }
+   
+    #print "permission $user $path = " . Dumper ($perm);
+
+    my @ra = keys %$res;
+
+    #print "roles $user $path = " . join (',', @ra) . "\n";
+
+    return @ra;
+}
+    
+sub permission {
+    my ($cfg, $user, $path) = @_;
+
+    $user = verify_username($user, 1);
+    return {} if !$user;
+
+    my @ra = roles($cfg, $user, $path);
+    
+    my $privs = {};
+
+    foreach my $role (@ra) {
+       if (my $privset = $cfg->{roles}->{$role}) {
+           foreach my $p (keys %$privset) {
+               $privs->{$p} = 1;
+           }
+       }
+    }
+
+    #print "priviledges $user $path = " . Dumper ($privs);
+
+    return $privs;
+}
+
+sub check_permissions {
+    my ($username, $path, $privlist) = @_;
+
+    $path = normalize_path($path);
+    my $usercfg = cfs_read_file('user.cfg');
+    my $perm = permission($usercfg, $username, $path);
+
+    foreach my $priv (split_list($privlist)) {
+       return undef if !$perm->{$priv};
+    };
+
+    return 1;
+}
+
+1;
diff --git a/PVE/Makefile b/PVE/Makefile
new file mode 100644 (file)
index 0000000..ec9bbb2
--- /dev/null
@@ -0,0 +1,7 @@
+
+
+.PHONY: install
+install:
+       install -D -m 0644 AccessControl.pm ${DESTDIR}${PERLDIR}/PVE/AccessControl.pm
+       install -D -m 0644 RPCEnvironment.pm ${DESTDIR}${PERLDIR}/PVE/RPCEnvironment.pm
+       make -C API2 install
\ No newline at end of file
diff --git a/PVE/RPCEnvironment.pm b/PVE/RPCEnvironment.pm
new file mode 100644 (file)
index 0000000..a18ceb1
--- /dev/null
@@ -0,0 +1,672 @@
+package PVE::RPCEnvironment;
+
+use strict;
+use warnings;
+use POSIX ":sys_wait_h";
+use IO::File;
+use Fcntl qw(:flock);
+use PVE::SafeSyslog;
+use PVE::Tools;
+use PVE::INotify;
+use PVE::Cluster;
+use PVE::ProcFSTools;
+use PVE::AccessControl;
+
+# we use this singleton class to pass RPC related environment values
+
+my $pve_env;
+
+# save $SIG{CHLD} handler implementation.
+# simply set $SIG{CHLD} = $worker_reaper;
+# and register forked processes with &$register_worker(pid)
+# Note: using $SIG{CHLD} = 'IGNORE' or $SIG{CHLD} = sub { wait (); } or ...
+# has serious side effects, because perls built in system() and open()
+# functions can't get the correct exit status of a child. So we cant use 
+# that (also see perlipc)
+
+my $WORKER_PIDS;
+
+my $log_task_result = sub {
+    my ($upid, $user, $status) = @_;
+
+    my $msg = 'successful';
+    my $pri = 'info';
+    if ($status != 0) {
+       my $ec = $status >> 8;
+       my $ic = $status & 255;
+       $msg = $ec ? "failed ($ec)" : "interrupted ($ic)";
+       $pri = 'err';
+    }
+    my $tlist = active_workers($upid);
+    PVE::Cluster::broadcast_tasklist($tlist);
+    my $task;
+    foreach my $t (@$tlist) {
+       if ($t->{upid} eq $upid) {
+           $task = $t;
+           last;
+       }
+    }
+    if ($task && $task->{status}) {
+       $msg = $task->{status};
+    }
+    PVE::Cluster::log_msg($pri, $user, "end task $upid $msg");
+};
+
+my $worker_reaper = sub {
+    local $!; local $?;
+    foreach my $pid (keys %$WORKER_PIDS) {
+        my $waitpid = waitpid ($pid, WNOHANG);
+        if (defined($waitpid) && ($waitpid == $pid)) {
+           my $info = $WORKER_PIDS->{$pid};
+           if ($info && $info->{upid} && $info->{user}) {
+               &$log_task_result($info->{upid}, $info->{user}, $?);
+           }
+            delete ($WORKER_PIDS->{$pid});
+       }
+    }
+};
+
+my $register_worker = sub {
+    my ($pid, $user, $upid) = @_;
+
+    return if !$pid;
+
+    # do not register if already finished
+    my $waitpid = waitpid ($pid, WNOHANG);
+    if (defined($waitpid) && ($waitpid == $pid)) {
+       delete ($WORKER_PIDS->{$pid});
+       return;
+    }
+
+    $WORKER_PIDS->{$pid} = {
+       user => $user,
+       upid => $upid,
+    };
+};
+
+# ACL cache
+
+my $compile_acl = sub {
+    my ($self, $user) = @_;
+
+    my $res = {};
+    my $cfg = $self->{user_cfg};
+
+    return undef if !$cfg->{roles};
+
+    if ($user eq 'root@pam') { # root can do anything
+       return {'/' => $cfg->{roles}->{'Administrator'}};
+    } 
+
+    foreach my $path (sort keys %{$cfg->{acl}}) {
+       my @ra = PVE::AccessControl::roles($cfg, $user, $path);
+
+       my $privs = {};
+       foreach my $role (@ra) {
+           if (my $privset = $cfg->{roles}->{$role}) {
+               foreach my $p (keys %$privset) {
+                   $privs->{$p} = 1;
+               }
+           }
+       }
+
+       $res->{$path} = $privs;
+    }
+
+    return $res;
+};
+
+sub permissions {
+    my ($self, $user, $path) = @_;
+
+    $user = PVE::AccessControl::verify_username($user, 1);
+    return {} if !$user;
+
+    my $cache = $self->{aclcache};
+
+    my $acl = $cache->{$user};
+
+    if (!$acl) {
+       if (!($acl = &$compile_acl($self, $user))) {
+           return {};
+       }
+       $cache->{$user} = $acl;
+    }
+
+    my $perm;
+
+    if (!($perm = $acl->{$path})) {
+       $perm = {};
+       foreach my $p (sort keys %$acl) {
+           my $final = ($path eq $p);
+           
+           next if !(($p eq '/') || $final || ($path =~ m|^$p/|));
+
+           $perm = $acl->{$p};
+       }
+       $acl->{$path} = $perm;
+    }
+
+    return $perm;
+}
+
+sub check {
+    my ($self, $user, $path, $privs) = @_;
+
+    my $perm = $self->permissions($user, $path);
+
+    foreach my $priv (@$privs) {
+       return undef if !$perm->{$priv};
+    };
+
+    return 1;
+};
+
+sub user_enabled {
+    my ($self, $user) = @_;
+    
+    my $cfg = $self->{user_cfg};
+    return PVE::AccessControl::user_enabled($cfg, $user);
+}
+
+# initialize environment - must be called once at program startup
+sub init {
+    my ($class, $type, %params) = @_;
+
+    $class = ref($class) || $class;
+
+    die "already initialized" if $pve_env;
+
+    die "unknown environment type" if !$type || $type !~ m/^(cli|pub|priv)$/;
+
+    $SIG{CHLD} = $worker_reaper;
+
+    # environment types
+    # cli  ... command started fron command line
+    # pub  ... access from public server (apache)
+    # priv ... access from private server (pvedaemon)
+    
+    my $self = {
+       user_cfg => {},
+       aclcache => {},
+       aclversion => undef,
+       type => $type,
+    };
+
+    bless $self, $class;
+
+    foreach my $p (keys %params) {
+       if ($p eq 'atfork') {
+           $self->{$p} = $params{$p};
+       } else {
+           die "unknown option '$p'";
+       }
+    }
+
+    $pve_env = $self;
+
+    my ($sysname, $nodename) = POSIX::uname();
+
+    $nodename =~ s/\..*$//; # strip domain part, if any
+
+    $self->{nodename} = $nodename;
+
+    return $self;
+}; 
+
+# get the singleton 
+sub get {
+
+    die "not initialized" if !$pve_env;
+
+    return $pve_env;
+}
+
+# init_request - must be called before each RPC request
+sub init_request {
+    my ($self, %params) = @_;
+
+    PVE::Cluster::cfs_update();
+
+    my $userconfig; # we use this for regression tests
+    foreach my $p (keys %params) {
+       if ($p eq 'userconfig') {
+           $userconfig = $params{$p};
+       } else {
+           die "unknown parameter '$p'";
+       }
+    }
+
+    eval {
+       $self->{aclcache} = {};
+       if ($userconfig) {
+           my $ucdata = PVE::Tools::file_get_contents($userconfig);
+           my $cfg = PVE::AccessControl::parse_user_config($userconfig, $ucdata);
+           $self->{user_cfg} = $cfg;
+       } else {
+           my $ucvers = PVE::Cluster::cfs_file_version('user.cfg'); 
+           if (!$self->{aclcache} || !defined($self->{aclversion}) || 
+               !defined($ucvers) ||  ($ucvers ne $self->{aclversion})) {
+               $self->{aclversion} = $ucvers;
+               my $cfg = PVE::Cluster::cfs_read_file('user.cfg');
+               $self->{user_cfg} = $cfg;
+           }
+       }
+    };
+    if (my $err = $@) {
+       $self->{user_cfg} = {};
+       die "Unable to load access control list: $err";
+    }
+}
+
+sub set_client_ip {
+    my ($self, $ip) = @_;
+
+    $self->{client_ip} = $ip;
+}
+
+sub get_client_ip {
+    my ($self) = @_;
+
+    return $self->{client_ip};
+}
+
+sub set_result_count {
+    my ($self, $count) = @_;
+
+    $self->{result_count} = $count;
+}
+
+sub get_result_count {
+    my ($self) = @_;
+
+    return $self->{result_count};
+}
+
+sub set_language {
+    my ($self, $lang) = @_;
+
+    # fixme: initialize I18N
+
+    $self->{language} = $lang;
+}
+
+sub get_language {
+    my ($self) = @_;
+
+    return $self->{language};
+}
+
+sub set_user {
+    my ($self, $user) = @_;
+
+    # fixme: get ACLs
+
+    $self->{user} = $user;
+}
+
+sub get_user {
+    my ($self) = @_;
+
+    die "user name not set\n" if !$self->{user};
+
+    return $self->{user};
+}
+
+# read/update list of active workers 
+# we move all finished tasks to the archive index,
+# but keep aktive and most recent task in the active file.
+sub active_workers  {
+    my ($new_upid) = @_;
+
+    my $lkfn = "/var/log/pve/tasks/.active.lock";
+
+    my $timeout = 10;
+
+    my $code = sub {
+
+       my $tasklist = PVE::INotify::read_file('active');
+
+       my @ta;
+       my $tlist = [];
+       my $thash = {}; # only list task once
+
+       my $check_task = sub {
+           my ($task) = @_;
+
+           my $pstart = PVE::ProcFSTools::read_proc_starttime($task->{pid});
+           if ($pstart && ($pstart == $task->{pstart})) {
+               push @$tlist, $task;
+           } else {
+               delete $task->{pid};
+               push @ta, $task;
+           }
+           delete $task->{pstart};
+       };
+
+       foreach my $task (@$tasklist) {
+           my $upid = $task->{upid};
+           next if $thash->{$upid};
+           $thash->{$upid} = $task;
+           &$check_task($task);
+       }
+
+       if ($new_upid && !(my $task = $thash->{$new_upid})) {
+           $task = PVE::Tools::upid_decode($new_upid);
+           $task->{upid} = $new_upid;
+           $thash->{$new_upid} = $task;
+           &$check_task($task);
+       }
+
+
+       @ta = sort { $b->{starttime} cmp $a->{starttime} } @ta;
+
+       my $save = defined($new_upid);
+
+       foreach my $task (@ta) {
+           next if $task->{endtime};
+           $task->{endtime} = time();
+           $task->{status} = PVE::Tools::upid_read_status($task->{upid});
+           $save = 1;
+       }
+
+       my $archive = '';
+       my @arlist = ();
+       foreach my $task (@ta) {
+           if (!$task->{saved}) {
+               $archive .= sprintf("$task->{upid} %08X $task->{status}\n", $task->{endtime});
+               $save = 1;
+               push @arlist, $task;
+               $task->{saved} = 1;
+           }
+       }
+
+       if ($archive) {
+           my $size = 0;
+           my $filename = "/var/log/pve/tasks/index";
+           eval {
+               my $fh = IO::File->new($filename, '>>', 0644) ||
+                   die "unable to open file '$filename' - $!\n";
+               PVE::Tools::safe_print($filename, $fh, $archive);
+               $size = -s $fh;
+               close($fh) ||
+                   die "unable to close file '$filename' - $!\n";
+           };
+           my $err = $@;
+           if ($err) {
+               syslog('err', $err);
+               foreach my $task (@arlist) { # mark as not saved
+                   $task->{saved} = 0;
+               }
+           }
+           my $maxsize = 50000; # about 1000 entries
+           if ($size > $maxsize) {
+               rename($filename, "$filename.1");
+           }
+       }
+
+       # we try to reduce the amount of data
+       # list all running tasks and task and a few others
+       # try to limit to 25 tasks
+       my $ctime = time();
+       my $max = 25 - scalar(@$tlist);
+        foreach my $task (@ta) {
+           last if $max <= 0;
+           push @$tlist, $task;
+           $max--;
+       }
+
+       PVE::INotify::write_file('active', $tlist) if $save;
+
+       return $tlist;
+    };
+
+    my $res = PVE::Tools::lock_file($lkfn, $timeout, $code);
+    die $@ if $@;
+
+    return $res;
+}
+
+# start long running workers
+# STDIN is redirected to /dev/null
+# STDOUT,STDERR are redirected to the filename returned by upid_decode
+# NOTE: we simulate running in foreground if ($self->{type} eq 'cli')
+sub fork_worker {
+    my ($self, $dtype, $id, $user, $function) = @_;
+
+    $dtype = 'unknown' if !defined ($dtype);
+    $id = '' if !defined ($id);
+
+    $user = 'root@pve' if !defined ($user);
+
+    my $sync = $self->{type} eq 'cli' ? 1 : 0;
+
+    local $SIG{INT} = 
+       local $SIG{QUIT} = 
+       local $SIG{PIPE} = 
+       local $SIG{TERM} = 'IGNORE';
+
+    my $starttime = time ();
+
+    my @psync = POSIX::pipe();
+    my @csync = POSIX::pipe();
+
+    my $node = $self->{nodename};
+
+    my $cpid = fork();
+    die "unable to fork worker - $!" if !defined($cpid);
+
+    my $workerpuid = $cpid ? $cpid : $$;
+
+    my $pstart = PVE::ProcFSTools::read_proc_starttime($workerpuid) ||
+       die "unable to read process start time";
+
+    my $upid = PVE::Tools::upid_encode ({
+       node => $node, pid => $workerpuid, pstart => $pstart, 
+       starttime => $starttime, type => $dtype, id => $id, user => $user });
+
+    my $outfh;
+
+    if (!$cpid) { # child
+
+       $0 = "task $upid";
+
+       $SIG{INT} = $SIG{QUIT} = $SIG{TERM} = sub { die "received interrupt\n"; };
+
+       $SIG{CHLD} = $SIG{PIPE} = 'DEFAULT';
+
+       # set sess/process group - we want to be able to kill the
+       # whole process group
+       POSIX::setsid(); 
+
+       POSIX::close ($psync[0]);
+       POSIX::close ($csync[1]);
+
+       $outfh = $sync ? $psync[1] : undef;
+
+       eval {
+           PVE::INotify::inotify_close();
+
+           if (my $atfork = $self->{atfork}) {
+               &$atfork();
+           }
+
+           # same algorythm as used inside SA
+           # STDIN = /dev/null
+           my $fd = fileno (STDIN);
+           close STDIN;
+           POSIX::close(0) if $fd != 0;
+
+           die "unable to redirect STDIN - $!" 
+               if !open(STDIN, "</dev/null");
+
+           $outfh = PVE::Tools::upid_open($upid) if !$sync;
+
+           # redirect STDOUT
+           $fd = fileno(STDOUT);
+           close STDOUT;
+           POSIX::close (1) if $fd != 1;
+
+           die "unable to redirect STDOUT - $!" 
+               if !open(STDOUT, ">&", $outfh);
+
+           STDOUT->autoflush (1);
+      
+           #  redirect STDERR to STDOUT
+           $fd = fileno (STDERR);
+           close STDERR;
+           POSIX::close(2) if $fd != 2;
+
+           die "unable to redirect STDERR - $!" 
+               if !open(STDERR, ">&1");
+           
+           STDERR->autoflush(1);
+       };
+       if (my $err = $@) {
+           my $msg =  "ERROR: $err";
+           POSIX::write($psync[1], $msg, length ($msg));
+           POSIX::close($psync[1]);
+           POSIX::_exit(1); 
+           kill('KILL', $$); 
+       }
+
+       # sync with parent (signal that we are read)
+       if ($sync) {
+           print "$upid\n";
+       } else {
+           POSIX::write($psync[1], $upid, length ($upid));
+           POSIX::close($psync[1]);
+       }
+
+       my $readbuf = '';
+       # sync with parent (wait until parent is ready)
+       POSIX::read($csync[0], $readbuf, 4096);
+       die "parent setup error\n" if $readbuf ne 'OK';
+
+       eval { &$function($upid); };
+       my $err = $@;
+       if ($err) {
+           chomp $err;
+           $err =~ s/\n/ /mg;
+           syslog('err', $err);
+           print STDERR "TASK ERROR: $err\n";
+           POSIX::_exit(-1); 
+       } else {
+           print STDERR "TASK OK\n";
+           POSIX::_exit (0);
+       } 
+       kill('KILL', $$); 
+    }
+
+    # parent
+
+    POSIX::close ($psync[1]);
+    POSIX::close ($csync[0]);
+
+    my $readbuf = '';
+    # sync with child (wait until child starts)
+    POSIX::read($psync[0], $readbuf, 4096);
+
+    if (!$sync) {
+       POSIX::close($psync[0]);
+       &$register_worker($cpid, $user, $upid);
+    } else {
+       chomp $readbuf;
+    }
+
+    eval {
+       die "got no worker upid - start worker failed\n" if !$readbuf;
+
+       if ($readbuf =~ m/^ERROR:\s*(.+)$/m) {
+           die "starting worker failed: $1\n";
+       }
+
+       if ($readbuf ne $upid) {
+           die "got strange worker upid ('$readbuf' != '$upid') - start worker failed\n";
+       }
+
+       if ($sync) {
+           $outfh = PVE::Tools::upid_open($upid);
+       }
+    };
+    my $err = $@;
+
+    if (!$err) {
+       my $msg = 'OK';
+       POSIX::write($csync[1], $msg, length ($msg));
+       POSIX::close($csync[1]);
+       
+    } else {
+       POSIX::close($csync[1]);
+       kill (9, $cpid); # make sure it gets killed
+       die $err;
+    }
+
+    PVE::Cluster::log_msg('info', $user, "starting task $upid");
+
+    my $tlist = active_workers($upid);
+    PVE::Cluster::broadcast_tasklist($tlist);
+   
+    my $res = 0;
+
+    if ($sync) {
+       my $count;
+       my $outbuf = '';
+       eval {
+           local $SIG{INT} = 
+               local $SIG{QUIT} = 
+               local $SIG{TERM} = sub { die "got interrupt\n"; };
+           local $SIG{PIPE} = sub { die "broken pipe\n"; };
+       
+           while (($count = POSIX::read($psync[0], $readbuf, 4096)) && ($count > 0)) {
+               $outbuf .= $readbuf;
+               while ($outbuf =~ s/^(([^\010\r\n]*)(\r|\n|(\010)+|\r\n))//s) {
+                   my $line = $1;
+                   my $data = $2;
+                   if ($data =~ m/^TASK OK$/) {
+                       # skip
+                   } elsif ($data =~ m/^TASK ERROR: (.+)$/) {
+                       print STDERR "$1\n";
+                   } else {
+                       print $line;
+                   }
+                   if ($outfh) {
+                       print $outfh $line;
+                   }
+               }
+           }
+       };
+       my $err = $@;
+
+       POSIX::close($psync[0]);
+
+       if ($outbuf) { # just to be sure
+           print $outbuf;
+           if ($outfh) {
+               print $outfh $outbuf;
+           }
+       }
+
+       if ($err) {
+           $err =~ s/\n/ /mg;
+           print STDERR "$err\n";
+           if ($outfh) {
+               print $outfh "TASK ERROR: $err\n";
+           }
+           kill (15, $cpid);
+
+       } else {
+           kill (9, $cpid); # make sure it gets killed
+       }
+
+       close($outfh);
+
+       waitpid ($cpid, 0);
+       $res = $?;
+       &$log_task_result($upid, $user, $res);
+    }
+
+    return wantarray ? ($upid, $res) : $upid;
+}
+
+1;
diff --git a/README b/README
new file mode 100644 (file)
index 0000000..a2ec7f1
--- /dev/null
+++ b/README
@@ -0,0 +1,273 @@
+User Management and Access Control
+==================================
+
+Proxmox VE implements an easy but flexible way to manage users. A
+powerful Access Control algorithm is used to grant permissions to
+individual users or group of users.
+
+Best Practices:
+
+Use groups in ACLs (not individual users).
+
+User Authentication
+===================
+
+Proxmox VE can use different authentication servers. Those
+servers are listed in '/etc/pve/priv/domain.cfg', indexed by a unique
+ID (called 'authentication domain' or 'realm').
+
+User names need to be unique. We create unique names by adding the
+'realm' to the user ID: <userid>@<realm>
+
+File format 'domain.cfg'
+----example domains.cfg ------------------
+
+# an active directory server
+AD: mycompany
+       server1 10.10.10.1
+       server2 10.10.10.2
+       ...
+
+# an LDAP server
+LDAP: example.com
+       server1 10.10.10.2
+       ....
+
+------------------------------------------
+
+There are 2 special authentication domains name 'pve' and 'pam':
+
+ * pve: stores paswords to "/etc/pve/priv/shadow.cfg" (SHA256 crypt); 
+
+ * pam: use unix 'pam'
+
+
+Proposed user database fields:
+==============================
+
+users:
+
+       login_name: email address (user@domain)
+       enable: 1 = TRUE, 0 = FALSE
+       expire: <integer> (account expiration date)
+       domid: reference to authentication domain
+       firstname: user first name
+       lastname: user last name
+       email: user's email address
+       comment: arbitrary comment
+
+       special user root: The root user has full administrative privileges
+
+group:
+
+       group_name: the name of the group
+       user_list: list of login names
+       comment: a more verbose description
+
+privileges: 
+
+       defines rights required to execute actions or read
+       information.
+
+       VM.Allocate: create/remove new VM to server inventory
+       VM.Migrate: migrate VM to alternate server on cluster
+       VM.PowerMgmt: power management (start, stop, reset, shutdown, ...)
+       VM.Console: console access to VM
+       VM.Audit: view VM config
+       VM.Modify: modify VM config
+
+       Datastore.Allocate: create/remove/modify a data store.
+       Datastore.AllocateSpace: allocate space on a datastore
+       Datastore.Audit: view/browse a datastore
+
+       Permissions.Modify: modify access permissions
+
+       Sys.PowerMgmt: Node power management (start, stop, reset, shutdown, ...)
+       Sys.Console: console access to Node
+       Sys.Syslog: view Syslog
+       Sys.Audit: view node status/config
+
+
+       We may need to refine those in future - the following privs
+       are just examples:
+
+       VM.Create: create new VM to server inventory
+       VM.Remove: remove VM from inventory
+       VM.MemoryModify: modify memory associated with VM
+       VM.AddNewDisk: add new disk to VM
+       VM.AddExistingDisk: add an existing disk to VM
+       VM.DiskModify: modify disk space for associated VM
+       VM.UseRawDevice: associate a raw device with VM
+       VM.PowerOn: power on VM
+       VM.PowerOff: power off VM
+       VM.ConfigureCD: assign a device/image file to VM
+       VM.CpuModify: modify number of CPUs associated with VM
+       VM.CpuCyclesModify: modify CPU cycles for VM
+       VM.NetworkAdd: add network device to VM
+       VM.NetworkConfigure: configure network device associated with VM
+       VM.NetworkRemove: remove network device from VM
+
+       Network.AssignNetwork: assign system networks
+
+role:
+
+       defines a sets of priviledges
+
+       predefined roles:
+
+       administrator: full administrative privileges
+       read_only: read only
+       no_access: no privileges
+
+       We store the following attribute for roles:
+
+       role_name: the name of the group
+       description: a more verbose description
+       privileges: list of privileges
+
+permission:
+
+       Assign roles to users or groups.
+
+
+ACL and Objects:
+================
+An access control list (ACL) is a list of permissions attached to an object. The list specifies who or what is allowed to access the object and what operations are allowed to be performed on the object.
+
+Object: A Virtual machine, Network (bridge, venet), Hosts, Host Memory, Storage, ...
+
+We can identify our objects by an unique (file system like) path, which also defines a tree like hierarchy relation. ACL can be inherited. Permissions are inherited if the propagate flag is set on the parent. Child permissions always overwrite inherited permissions. User permission takes precedence over all group permissions. If multiple group permission apply the resulting role is the union of all those group priviledges.
+
+There is at most one object permission per user or group
+
+We store the following attributes for ACLs:
+
+       propagate: propagate permissions down in the hierarchy
+       path: path to uniquely identify the object
+       user_or_group: ID of user or group (group ID start with @)
+       role: list of role IDs.
+
+User Database:
+
+To keep it simple, we suggest to use a single text file, which is replicated to all cluster nodes.
+
+Also, we can store ACLs inside this file.
+
+Here is a short example how such file could look like:
+
+-----User/Group/Role Database example--------
+
+user:joe@example.com:$1$nd91DtDy$mJtzWJAN2AAABKij0JgMy1/:Joe Average:Just a comment:
+user:max@example.com:$1$nd91DtDy$LANSNJAN2AAABKidhfgMy3/:Max Mustermann:Another comment:
+user:edward@example.com:$1$nd91DtDy$LANSNAAAAAAABKidhfgMy3/:Edward Example:Example VM Manager:
+
+group:admin:Internal Administrator Group:root:
+group:audit:Read only accounts used for audit::
+group:customers:Our Customers:joe@example.com,max@example.com:
+
+role:vm_user:Virtual Machine User:VM.ConfigureCD,VM.Console:
+role:vm_manager:Virtual Machine Manager:VM.ConfigureCD,VM.Console,VM.AddNewDisk,VM.PowerOn,VM.PowerOff:
+role:vm_operator:Virtual Machine Operator:VM.Create,VM.ConfigureCD,VM.Console,VM.AddNewDisk,VM.PowerOn,VM.PowerOff:
+role:ds_consumer:DataStore Consumer:Datastore.AllocateSpace:
+role:nw_consumer:Network Consumer:Network.AssignNetwork:
+
+# group admin can do anything
+acl:0:/:@admin:Administrator:
+# group audit can view anything
+acl:1:/:@audit:read_only:
+
+# user max can manage all qemu/kvm machines
+acl:1:/vm/qemu:max@example.com:vm_manager:
+
+# user joe can use openvz vm 230
+acl:1:/vm/openvz/230:joe@example.com:vm_user:
+
+# user Edward can create openvz VMs using vmbr0 and store0
+acl:1:/vm/openvz:edward@example.com:vm_operator:
+acl:1:/network/vmbr0:edward@example.com:ds_consumer:
+acl:1:/storage/store0:edward@example.com:nw_consumer:
+
+---------------------------------------------
+
+Basic model RBAC -> http://en.wikipedia.org/wiki/Role-based_access_control
+
+# Subject: A person or automated agent 
+subject:joe@example.com:
+subject:max@example.com:
+
+# Role: Job function or title which defines an authority level 
+role:vm_user:Virtual Machine User:
+role:admin:Administrator:
+
+# Subject Assignment: Subject -> Role(s) 
+SA:vm_user:joe@example.com,max@example.com:
+SA:admin:joe@example.com:
+
+# Permissions: An approval of a mode of access to a resource 
+# Permission Assignment: Role -> Permissions (set of allowed operation)
+perm:vm_user:VM.ConfigureCD,VM.Console:
+perm:admin:VM.ConfigureCD,VM.Console,VM.Create:
+
+---------------------------------------------
+
+We can merge 'perm' into the 'role' table, because it is 
+a 1 -> 1 mapping
+
+subject:joe@example.com:
+subject:max@example.com:
+
+role:vm_user:Virtual Machine User:VM.ConfigureCD,VM.Console:
+role:admin:Administrator:VM.ConfigureCD,VM.Console,VM.Create:
+
+SA:vm_user:joe@example.com,max@example.com:
+SA:admin:joe@example.com:
+
+-----------------------------------------------
+
+We can have different subject assignment for different objects.
+
+subject:joe@example.com:
+subject:max@example.com:
+
+role:vm_user:Virtual Machine User:VM.ConfigureCD,VM.Console:
+role:admin:Administrator:VM.ConfigureCD,VM.Console,VM.Create:
+
+# joe is 'admin' for openvz VMs, but 'vm_user' for qemu VMs
+SA:/vm/openvz:admin:joe@example.com:
+SA:/vm/qemu:vm_user:joe@example.com,max@example.com:
+
+-----------------------------------------------
+
+Let us use more convenient names. 
+Use 'user' instead of 'subject'.
+Use 'acl' instead of 'SA'.
+
+user:joe@example.com:
+user:max@example.com:
+
+role:vm_user:Virtual Machine User:VM.ConfigureCD,VM.Console:
+role:admin:Administrator:VM.ConfigureCD,VM.Console,VM.Create:
+
+# joe is 'admin' for openvz VMs, but 'vm_user' for qemu VMs
+acl:/vm/openvz:admin:joe@example.com:
+acl:/vm/qemu:vm_user:joe@example.com,max@example.com:
+
+-----------------------------------------------
+
+Finally introduce groups to group users. ACL can then
+use 'users' or 'groups'.
+
+user:joe@example.com:
+user:max@example.com:
+
+group:customers:Our Customers:joe@example.com,max@example.com:
+
+role:vm_user:Virtual Machine User:VM.ConfigureCD,VM.Console:
+role:admin:Administrator:VM.ConfigureCD,VM.Console,VM.Create:
+
+acl:/vm/openvz:admin:joe@example.com:
+acl:/vm/qemu:vm_user:@customers:
+
+
+-----------------------------------------------
diff --git a/TODO b/TODO
new file mode 100644 (file)
index 0000000..a082d8c
--- /dev/null
+++ b/TODO
@@ -0,0 +1,37 @@
+TODO: pve-access-control
+------------------------
+
+Seth?: Implement API Class to manage the domains.cfg file
+   (AuthDomains.pm)
+
+
+pveum api: 
+
+Is it worth to emulate the useradd/usermod interface? We initially
+   done that because we thought users are common with that.
+
+But now it would be possible to expose a 'REST' like interface - like
+   the one we use with pvesh.
+pveum (get|set|create|delete) <path> [OPTIONS]
+
+useradd: pveum create users/<username> [OPTIONS]
+usermod: pveum set users/<username> [OPTIONS]
+userdel: pveum delete users/<username>
+list:    pveum get users
+data:    pveum get users/<username>
+
+groupadd: pveum create groups/<groupname> [OPTIONS]
+groupmod: pveum set groups/<groupname> [OPTIONS]
+groupdel: pveum delete groups/<groupname>
+list:     pveum get groups
+data:     pveum get groups/<groupname>
+
+roleadd: pveum create roles/<rolename> [OPTIONS]
+rolemod: pveum set roles/<rolename> [OPTIONS]
+roledel: pveum delete roles/<rolename>
+list:    pveum get roles
+data:    pveum get roles/<rolename>
+
+...
+
diff --git a/changelog.Debian b/changelog.Debian
new file mode 100644 (file)
index 0000000..ede7a97
--- /dev/null
@@ -0,0 +1,14 @@
+libpve-access-control (1.0-1) unstable; urgency=low
+
+  * allow '-' in permission paths
+  
+  * bump version to 1.0
+
+ -- Proxmox Support Team <support@proxmox.com>  Mon, 27 Jun 2011 13:51:48 +0200
+
+libpve-access-control (0.1) unstable; urgency=low
+
+  * first dummy package - no functionality
+
+ -- Proxmox Support Team <support@proxmox.com>  Thu, 09 Jul 2009 16:03:00 +0200
+
diff --git a/control.in b/control.in
new file mode 100644 (file)
index 0000000..847c220
--- /dev/null
@@ -0,0 +1,10 @@
+Package: libpve-access-control
+Version: @@VERSION@@-@@PKGRELEASE@@
+Section: perl
+Priority: optional
+Architecture: @@ARCH@@
+Depends: libc6 (>= 2.3), perl (>= 5.6.0-16), libcrypt-openssl-rsa-perl, libcrypt-openssl-random-perl, libjson-xs-perl, libjson-perl, libterm-readline-gnu-perl,libnet-ldap-perl, libpve-common-perl, pve-cluster
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Description: Proxmox VE access control library
+ This package contains the role based user management and access
+ control function used by Proxmox VE.
diff --git a/copyright b/copyright
new file mode 100644 (file)
index 0000000..f96f3fb
--- /dev/null
+++ b/copyright
@@ -0,0 +1,16 @@
+Copyright (C) 2010 Proxmox Server Solutions GmbH
+
+This software is written by Proxmox Server Solutions GmbH <support@proxmox.com>
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
diff --git a/pveum b/pveum
new file mode 100755 (executable)
index 0000000..1a2f532
--- /dev/null
+++ b/pveum
@@ -0,0 +1,102 @@
+#!/usr/bin/perl -w 
+
+use strict;
+use Getopt::Long;
+use PVE::Tools qw(run_command);
+use PVE::Cluster;
+use PVE::SafeSyslog;
+use PVE::AccessControl;
+use File::Path qw(make_path remove_tree);
+use Term::ReadLine;
+use PVE::INotify;
+use PVE::RPCEnvironment;
+use PVE::API2::User;
+use PVE::API2::Group;
+use PVE::API2::Role;
+use PVE::API2::ACL;
+use PVE::API2::AccessControl;
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::CLIHandler;
+
+use base qw(PVE::CLIHandler);
+
+use Data::Dumper; # fixme: remove
+
+$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin';
+
+initlog('pveum');
+
+#fixme: logging?
+
+die "please run as root\n" if $> != 0;
+
+PVE::INotify::inotify_init();
+
+my $rpcenv = PVE::RPCEnvironment->init('cli');
+
+$rpcenv->init_request();
+$rpcenv->set_language($ENV{LANG});
+$rpcenv->set_user('root@pam'); 
+
+# autmatically generate the private key if it does not already exists
+PVE::Cluster::gen_auth_key();
+
+my $read_password = sub {
+
+    # return $ENV{PVE_PW_TICKET} if defined($ENV{PVE_PW_TICKET});
+
+    my $term = new Term::ReadLine ('pveum');
+    my $attribs = $term->Attribs;
+    $attribs->{redisplay_function} = $attribs->{shadow_redisplay};
+    my $input = $term->readline('Enter new password: ');
+    my $conf = $term->readline('Retype new password: ');
+    die "Passwords do not match.\n" if ($input ne $conf);
+    return $input;
+};
+
+my $cmddef = {
+    ticket => [ 'PVE::API2::AccessControl', 'create_ticket', ['username'], undef,
+               sub {
+                   my ($res) = @_;
+                   print "$res->{ticket}\n";
+               }],
+    useradd => [ 'PVE::API2::User', 'create_user', ['userid'] ],
+    usermod => [ 'PVE::API2::User', 'update_user', ['userid'] ],
+    userdel => [ 'PVE::API2::User', 'delete_user', ['userid'] ],
+
+    groupadd => [ 'PVE::API2::Group', 'create_group', ['groupid'] ],
+    groupmod => [ 'PVE::API2::Group', 'update_group', ['groupid'] ],
+    groupdel => [ 'PVE::API2::Group', 'delete_group', ['groupid'] ],
+
+    roleadd => [ 'PVE::API2::Role', 'create_role', ['roleid'] ],
+    rolemod => [ 'PVE::API2::Role', 'update_role', ['roleid'] ],
+    roledel => [ 'PVE::API2::Role', 'delete_role', ['roleid'] ],
+
+    aclmod => [ 'PVE::API2::ACL', 'update_acl', ['path', 'roles'], { delete => 0 }],
+    acldel => [ 'PVE::API2::ACL', 'update_acl', ['path', 'roles'], { delete => 1 }],
+};
+
+my $cmd = shift;
+
+if ($cmd && $cmd eq 'verifyapi') {
+    PVE::RESTHandler::validate_method_schemas();
+    exit 0;
+}
+
+PVE::CLIHandler::handle_cmd($cmddef, "pveum", $cmd, \@ARGV, $read_password);
+
+exit 0;
+
+__END__
+
+=head1 NAME
+
+pveum - PVE User Manager
+
+=head1 SYNOPSIS
+
+    pveum <COMMAND> [OPTIONS]
+
+=head1 DESCRIPTION
+
+no description available
diff --git a/test.pl b/test.pl
new file mode 100755 (executable)
index 0000000..937ec1b
--- /dev/null
+++ b/test.pl
@@ -0,0 +1,22 @@
+#!/usr/bin/perl -w 
+
+use strict;
+use PVE::AccessControl;
+
+# create ticket using username and password
+#my $ticket =  PVE::AccessControl::create_ticket(undef, $username, $password);
+
+# create ticket using ident auth
+my $login = getpwuid($<);
+my $username = ($< == 0) ? 'root' : "$login\@localhost";
+my $ticket = PVE::AccessControl::create_ticket(undef, $username);
+print "got ticket using ident auth: $ticket\n";
+
+for (my $i = 0; $i < 1; $i++) { 
+    $ticket =  PVE::AccessControl::create_ticket($ticket, $username);
+    print "renewed ticket: $ticket\n";
+}
+
+my $user = 'testuser';
+
+PVE::AccessControl::add_user($ticket, $user, 'testpw');
diff --git a/test/Makefile b/test/Makefile
new file mode 100644 (file)
index 0000000..a046dac
--- /dev/null
@@ -0,0 +1,10 @@
+
+all:
+
+.PHONY: check
+check:
+       perl -I.. perm-test1.pl
+       perl -I.. perm-test2.pl
+       perl -I.. perm-test3.pl
+       perl -I.. perm-test4.pl
+
diff --git a/test/auth-test.pl b/test/auth-test.pl
new file mode 100644 (file)
index 0000000..50a7f89
--- /dev/null
@@ -0,0 +1,23 @@
+#!/usr/bin/perl -w
+
+use strict;
+use Term::ReadLine;
+use PVE::AccessControl;
+
+my $username = shift;
+die "Username missing" if !$username;
+sub read_password {
+
+    my $term = new Term::ReadLine ('pveum');
+    my $attribs = $term->Attribs;
+    $attribs->{redisplay_function} = $attribs->{shadow_redisplay};
+    my $input = $term->readline('password: ');
+    return $input;
+}
+
+my $password = read_password();
+PVE::AccessControl::authenticate_user($username,$password);
+
+print "Authentication Successful!!\n";
+
+exit (0);
diff --git a/test/dump-perm.pl b/test/dump-perm.pl
new file mode 100755 (executable)
index 0000000..96bc023
--- /dev/null
@@ -0,0 +1,42 @@
+#!/usr/bin/perl -w
+
+use strict;
+use PVE::AccessControl;
+use Getopt::Long;
+use Data::Dumper;
+
+# example: 
+# dump-perm.pl -f myuser.cfg root /
+
+my $opt_file;
+if (!GetOptions ("file=s"   => \$opt_file)) {
+    exit (-1);
+}
+
+my $username = shift;
+my $path = shift;
+if (!($username && $path)) {
+    print "usage: $0 <username> <path>\n";
+    exit (-1);
+}
+
+my $cfg;
+
+if ($opt_file) {
+
+    my $fh = IO::File->new ($opt_file, 'r') ||
+       die "can't open file $opt_file - $!\n";
+
+    $cfg = PVE::AccessControl::parse_config ($opt_file, $fh);
+    $fh->close();
+
+} else {
+    $cfg = PVE::AccessControl::load_user_config();
+}
+my $perm = PVE::AccessControl::permission($cfg, $username, $path);
+
+print "permission for user '$username' on '$path':\n";
+print join(',', keys %$perm) . "\n";
+
+exit (0);
diff --git a/test/dump-users.pl b/test/dump-users.pl
new file mode 100755 (executable)
index 0000000..f08d30b
--- /dev/null
@@ -0,0 +1,13 @@
+#!/usr/bin/perl -w
+
+use strict;
+use PVE::AccessControl;
+use Data::Dumper;
+
+my $cfg;
+
+$cfg = PVE::AccessControl::load_user_config();
+
+print Dumper($cfg) . "\n";
+
+exit (0);
diff --git a/test/perm-test1.pl b/test/perm-test1.pl
new file mode 100755 (executable)
index 0000000..a80e648
--- /dev/null
@@ -0,0 +1,65 @@
+#!/usr/bin/perl -w
+
+use strict;
+use PVE::Tools;
+use PVE::AccessControl;
+use PVE::RPCEnvironment;
+use Getopt::Long;
+
+my $rpcenv = PVE::RPCEnvironment->init('cli');
+
+my $cfgfn = "user.cfg.ex1";
+$rpcenv->init_request(userconfig => $cfgfn);
+
+sub check_roles {
+    my ($user, $path, $expected_result) = @_;
+
+    my @ra = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path);
+    my $res = join(',', sort @ra);
+
+    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    print "ROLES:$path:$user:$res\n";
+}
+
+sub check_permission {
+    my ($user, $path, $expected_result) = @_;
+
+    my $perm = PVE::AccessControl::permission($rpcenv->{user_cfg}, $user, $path);
+    my $res = join(',', sort keys %$perm);
+
+    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    $perm = $rpcenv->permissions($user, $path);
+    $res = join(',', sort keys %$perm);
+    die "unexpected result (compiled)\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    print "PERM:$path:$user:$res\n";
+
+}
+
+check_roles('max@pve', '/', '');
+check_roles('max@pve', '/vms', 'vm_admin');
+
+#user permissions overrides group permissions
+check_roles('max@pve', '/vms/100', 'customer');
+check_roles('max@pve', '/vms/101', 'vm_admin');
+
+check_permission('max@pve', '/', '');
+check_permission('max@pve', '/vms', 'Permissions.Modify,VM.Allocate,VM.Audit,VM.Console');
+check_permission('max@pve', '/vms/100', 'VM.Audit,VM.PowerMgmt');
+
+check_permission('alex@pve', '/vms', '');
+check_permission('alex@pve', '/vms/100', 'VM.Audit,VM.PowerMgmt');
+
+
+check_roles('max@pve', '/vms/200', 'storage_manager');
+check_roles('joe@pve', '/vms/200', 'vm_admin');
+check_roles('sue@pve', '/vms/200', '');
+
+print "all tests passed\n";
+
+exit (0);
diff --git a/test/perm-test2.pl b/test/perm-test2.pl
new file mode 100755 (executable)
index 0000000..745aa09
--- /dev/null
@@ -0,0 +1,39 @@
+#!/usr/bin/perl -w
+
+use strict;
+use PVE::Tools;
+use PVE::AccessControl;
+use PVE::RPCEnvironment;
+use Getopt::Long;
+
+my $rpcenv = PVE::RPCEnvironment->init('cli');
+
+my $cfgfn = "test2.cfg";
+$rpcenv->init_request(userconfig => $cfgfn);
+
+sub check_roles {
+    my ($user, $path, $expected_result) = @_;
+
+    my @ra = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path);
+    my $res = join(',', sort @ra);
+
+    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    print "ROLES:$path:$user:$res\n";
+}
+
+# inherit multiple group permissions
+
+check_roles('User1@pve', '/', '');
+check_roles('User2@pve', '/', '');
+
+check_roles('User1@pve', '/vms', 'Role1,Role2');
+check_roles('User2@pve', '/vms', '');
+
+check_roles('User1@pve', '/vms/100', 'Role1,Role2');
+check_roles('User2@pve', '/vms', '');
+
+print "all tests passed\n";
+
+exit (0);
diff --git a/test/perm-test3.pl b/test/perm-test3.pl
new file mode 100755 (executable)
index 0000000..3426b87
--- /dev/null
@@ -0,0 +1,34 @@
+#!/usr/bin/perl -w
+
+use strict;
+use PVE::Tools;
+use PVE::AccessControl;
+use PVE::RPCEnvironment;
+use Getopt::Long;
+
+my $rpcenv = PVE::RPCEnvironment->init('cli');
+
+my $cfgfn = "test3.cfg";
+$rpcenv->init_request(userconfig => $cfgfn);
+
+sub check_roles {
+    my ($user, $path, $expected_result) = @_;
+
+    my @ra = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path);
+    my $res = join(',', sort @ra);
+
+    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    print "ROLES:$path:$user:$res\n";
+}
+
+check_roles('User1@pve', '', '');
+check_roles('User2@pve', '', '');
+
+check_roles('User1@pve', '/vms/300', 'Role1');
+check_roles('User1@pve', '/vms/200', 'Role2');
+
+print "all tests passed\n";
+
+exit (0);
diff --git a/test/perm-test4.pl b/test/perm-test4.pl
new file mode 100755 (executable)
index 0000000..92ecfb2
--- /dev/null
@@ -0,0 +1,32 @@
+#!/usr/bin/perl -w
+
+use strict;
+use PVE::Tools;
+use PVE::AccessControl;
+use PVE::RPCEnvironment;
+use Getopt::Long;
+
+my $rpcenv = PVE::RPCEnvironment->init('cli');
+
+my $cfgfn = "test4.cfg";
+$rpcenv->init_request(userconfig => $cfgfn);
+
+sub check_roles {
+    my ($user, $path, $expected_result) = @_;
+
+    my @ra = PVE::AccessControl::roles($rpcenv->{user_cfg}, $user, $path);
+    my $res = join(',', sort @ra);
+
+    die "unexpected result\nneed '${expected_result}'\ngot '$res'\n"
+       if $res ne $expected_result;
+
+    print "ROLES:$path:$user:$res\n";
+}
+
+
+check_roles('User1@pve', '/vms/300', 'Role1');
+check_roles('User2@pve', '/vms/300', '');
+
+print "all tests passed\n";
+
+exit (0);
diff --git a/test/test2.cfg b/test/test2.cfg
new file mode 100644 (file)
index 0000000..efad292
--- /dev/null
@@ -0,0 +1,11 @@
+user:User1@pve:1:
+user:User2@pve:1:
+
+group:GroupA:User1@pve:
+group:GroupB:User1@pve:
+
+role:Role1:VM.PowerMgmt:
+role:Role2:VM.Console:
+
+acl:1:/vms:@GroupA:Role1:
+acl:1:/vms:@GroupB:Role2:
diff --git a/test/test3.cfg b/test/test3.cfg
new file mode 100644 (file)
index 0000000..75a9732
--- /dev/null
@@ -0,0 +1,11 @@
+user:User1@pve:1:
+user:User2@pve:1:
+
+group:GroupA:User1@pve:
+group:GroupB:User1@pve:
+
+role:Role1:VM.PowerMgmt:
+role:Role2:VM.Console:
+
+acl:1:/vms:@GroupA:Role1:
+acl:1:/vms/200:@GroupB:Role2:
diff --git a/test/test4.cfg b/test/test4.cfg
new file mode 100644 (file)
index 0000000..c6daccb
--- /dev/null
@@ -0,0 +1,11 @@
+user:User1@pve:1:
+user:User2@pve:1:
+
+group:GroupA:User1@pve,User2@pve:
+group:GroupB:User1@pve,User2@pve:
+
+role:Role1:VM.PowerMgmt:
+role:Role2:VM.Console:
+
+acl:1:/vms:@GroupA:Role1:
+acl:1:/vms:User2@pve:NoAccess:
diff --git a/test/user.cfg.ex1 b/test/user.cfg.ex1
new file mode 100644 (file)
index 0000000..d27c5d6
--- /dev/null
@@ -0,0 +1,22 @@
+user:joe@pve:1:
+user:max@pve:1:
+user:alex@pve:1:
+user:sue@pve:1:
+user:carol@pam:1:
+
+group:testgroup1:joe@pve,max@pve,sue@pve:
+group:testgroup2:alex@pve,carol@pam,sue@pve:
+group:testgroup3:max@pve:
+
+role:storage_manager:Datastore.AllocateSpace,Datastore.Audit:
+role:customer:VM.Audit,VM.PowerMgmt:
+role:vm_admin:VM.Audit,VM.Allocate,Permissions.Modify,VM.Console:
+
+acl:1:/vms:@testgroup1:vm_admin:
+acl:1:/vms/100/:alex@pve,max@pve:customer:
+acl:1:/storage/nfs1:@testgroup2:storage_manager:
+acl:1:/users:max@pve:Administrator:
+
+acl:1:/vms/200:@testgroup3:storage_manager:
+acl:1:/vms/200:@testgroup2:NoAccess:
+
diff --git a/user.cfg.ex b/user.cfg.ex
new file mode 100644 (file)
index 0000000..fbd8882
--- /dev/null
@@ -0,0 +1,8 @@
+user:joe@localhost:1:
+
+group:testgroup:joe@localhost:
+
+role:admin:VM.ConfigureCD,VM.Create,Permissions.Modify,VM.Console:
+
+acl:0:/users:@testgroup,joe@localhost:Administrator:
+