Compare commits

...
10 Commits
Author SHA1 Message Date
dualfroz f87cd64c00 Describe this repository 2026-10-22 21:06:06 +02:00
dualfroz 96d1c6ff89 fix #6889: ldap sync: use the description of groups as comment
Synced users get their comment from the 'comment' attribute, but synced
groups never get one, as only the name and the members of a group are
queried. With FreeIPA for example, groups are described with the
'description' attribute, which is then not visible in Proxmox VE.

Set the comment of a synced group to its description, if it has one.
Like for the attributes of users, an existing comment is overwritten
by the synced one, and kept if the group has no description, unless
'properties' is part of 'remove-vanished'.

Signed-off-by: Michal Fox <me@dualfroz.com>
2026-10-03 02:10:21 +02:00
upstream 0a15bc820b Upstream proxmox-git/pve-access-control state of the touched files before 129545f673 2026-10-03 02:10:21 +02:00
dualfroz f2a9258451 Notes added by 'git notes add' 2026-10-02 16:51:26 +02:00
dualfroz 3805ae44e9 Notes added by 'git notes add' 2026-10-02 16:51:16 +02:00
dualfroz 72650150d0 fix #5368: pam: say that a failed password change is about the host user
Users of the PAM realm are users of the host system, and setting their
password runs usermod on the node handling the request. If the user
only got added to the PAM realm, but does not exist on the host, this
failed with:

  change password failed: user 'jdoe' does not exist

which is confusing, as the user is shown in the user list of the web UI
and the message does not say that the user on the host is meant.

Mention the PAM user and the host system in the error message, so that
it reads:

  changing the password of the PAM user 'jdoe' on the host system
  failed: user 'jdoe' does not exist

The output of usermod is still added at the end, so this helps with its
other errors too.

Signed-off-by: Michal Fox <me@dualfroz.com>
2026-10-02 16:51:26 +02:00
upstream 502157aaeb Upstream proxmox-git/pve-access-control state of the touched files before 42a41effd5 2026-10-02 16:51:16 +02:00
dualfroz a2a3d2acff Notes added by 'git notes add' 2026-10-02 16:47:52 +02:00
dualfroz 3230be1bc0 fix #6429: openid: use the updated user config for the login permissions
When the OpenID login creates a user, or adds a user to groups from the
groups claim, it writes these changes to user.cfg. The permissions it
returns for the web UI however got computed with the copy of user.cfg
the RPC environment loaded at the start of the request, which does not
know about the new user or group memberships yet.

So a user logging in for the first time got no permissions from the
ACLs of its groups in this session, and buttons like "Create VM" stayed
disabled, until the next login. The same happened to existing users on
the login that added them to a new group.

Add a helper to reload the user config of the RPC environment, and use
it after the login changed user.cfg, before computing the permissions,
as suggested in the bug.

Add a test for both cases, which mocks the OpenID provider and keeps
user.cfg in memory.

Signed-off-by: Michal Fox <me@dualfroz.com>
2026-10-02 16:47:52 +02:00
upstream c520ce3ec1 Upstream proxmox-git/pve-access-control state of the touched files before 726b26a750 2026-10-02 16:47:52 +02:00
11 changed files with 2103 additions and 2 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
tested by calling store_password() as root in a Debian trixie container,
for a missing user, which gave the error above, and for an existing
user, whose password got changed. 'make test' in src passes.
the errors of the PAM authentication itself are left as they are, as
they are already PAM's own messages and only end up in the log.
+6
View File
@@ -0,0 +1,6 @@
tested by calling store_password() as root in a Debian trixie container,
for a missing user, which gave the error above, and for an existing
user, whose password got changed. 'make test' in src passes.
the errors of the PAM authentication itself are left as they are, they
already are PAM's own messages like 'Authentication failure'.
+3
View File
@@ -0,0 +1,3 @@
tested with 'make test' in src. without the change to OpenId.pm and
RPCEnvironment.pm, the new test fails for both cases, as the returned
'cap' has no VM privileges.
+6
View File
@@ -0,0 +1,6 @@
# Contributions to proxmox-git/pve-access-control
This repository holds only my commits to the upstream project, with their original author, date and diff.
Each of my commits is preceded by a snapshot of the upstream files it touched, so every diff shows exactly what I changed.
+370
View File
@@ -0,0 +1,370 @@
package PVE::API2::OpenId;
use strict;
use warnings;
use PVE::Tools qw(extract_param);
use PVE::RS::OpenId;
use PVE::Exception qw(raise raise_perm_exc raise_param_exc);
use PVE::SafeSyslog;
use PVE::RPCEnvironment;
use PVE::Cluster qw(cfs_read_file cfs_write_file);
use PVE::AccessControl;
use PVE::JSONSchema qw(get_standard_option);
use PVE::Auth::Plugin;
use PVE::Auth::OpenId;
use PVE::RESTHandler;
use base qw(PVE::RESTHandler);
my $openid_state_path = "/var/lib/pve-manager";
my $lookup_openid_auth = sub {
my ($realm, $redirect_url) = @_;
my $cfg = cfs_read_file('domains.cfg');
my $ids = $cfg->{ids};
die "authentication domain '$realm' does not exist\n" if !$ids->{$realm};
my $config = $ids->{$realm};
die "wrong realm type ($config->{type} != openid)\n" if $config->{type} ne "openid";
my $openid_config = {
issuer_url => $config->{'issuer-url'},
client_id => $config->{'client-id'},
client_key => $config->{'client-key'},
};
$openid_config->{prompt} = $config->{'prompt'} if defined($config->{'prompt'});
my $scopes = $config->{'scopes'} // 'email profile';
$openid_config->{scopes} = [PVE::Tools::split_list($scopes)];
if (defined(my $acr = $config->{'acr-values'})) {
$openid_config->{acr_values} = [PVE::Tools::split_list($acr)];
}
if (defined(my $audiences = $config->{'audiences'})) {
$openid_config->{audiences} = [PVE::Tools::split_list($audiences)];
}
my $openid = PVE::RS::OpenId->discover($openid_config, $redirect_url);
return ($config, $openid);
};
__PACKAGE__->register_method({
name => 'index',
path => '',
method => 'GET',
description => "Directory index.",
permissions => {
user => 'all',
},
parameters => {
additionalProperties => 0,
properties => {},
},
returns => {
type => 'array',
items => {
type => "object",
properties => {
subdir => { type => 'string' },
},
},
links => [{ rel => 'child', href => "{subdir}" }],
},
code => sub {
my ($param) = @_;
return [
{ subdir => 'auth-url' }, { subdir => 'login' },
];
},
});
__PACKAGE__->register_method({
name => 'auth_url',
path => 'auth-url',
method => 'POST',
protected => 1,
description => "Get the OpenId Authorization Url for the specified realm.",
parameters => {
additionalProperties => 0,
properties => {
realm => get_standard_option('realm'),
'redirect-url' => {
description =>
"Redirection Url. The client should set this to the used server url (location.origin).",
type => 'string',
maxLength => 255,
},
},
},
returns => {
type => "string",
description => "Redirection URL.",
},
permissions => { user => 'world' },
code => sub {
my ($param) = @_;
my $dcconf = PVE::Cluster::cfs_read_file('datacenter.cfg');
local $ENV{all_proxy} = $dcconf->{http_proxy} if exists $dcconf->{http_proxy};
my $realm = extract_param($param, 'realm');
my $redirect_url = extract_param($param, 'redirect-url');
my ($config, $openid) = $lookup_openid_auth->($realm, $redirect_url);
my $url = $openid->authorize_url($openid_state_path, $realm);
return $url;
},
});
__PACKAGE__->register_method({
name => 'login',
path => 'login',
method => 'POST',
protected => 1,
description => " Verify OpenID authorization code and create a ticket.",
parameters => {
additionalProperties => 0,
properties => {
'state' => {
description => "OpenId state.",
type => 'string',
maxLength => 1024,
},
code => {
description => "OpenId authorization code.",
type => 'string',
maxLength => 4096,
},
'redirect-url' => {
description =>
"Redirection Url. The client should set this to the used server url (location.origin).",
type => 'string',
maxLength => 255,
},
},
},
returns => {
properties => {
username => { type => 'string' },
ticket => { type => 'string' },
CSRFPreventionToken => { type => 'string' },
cap => { type => 'object' }, # computed api permissions
clustername => { type => 'string', optional => 1 },
},
},
permissions => { user => 'world' },
code => sub {
my ($param) = @_;
my $rpcenv = PVE::RPCEnvironment::get();
my $res;
eval {
my $dcconf = PVE::Cluster::cfs_read_file('datacenter.cfg');
local $ENV{all_proxy} = $dcconf->{http_proxy} if exists $dcconf->{http_proxy};
my ($realm, $private_auth_state) =
PVE::RS::OpenId::verify_public_auth_state($openid_state_path, $param->{'state'});
my $redirect_url = extract_param($param, 'redirect-url');
my ($config, $openid) = $lookup_openid_auth->($realm, $redirect_url);
my $info = $openid->verify_authorization_code(
$param->{code},
$private_auth_state,
$config->{'query-userinfo'} // 1,
);
my $subject = $info->{'sub'};
my $unique_name;
my $user_attr = $config->{'username-claim'} // 'sub';
if (defined($info->{$user_attr})) {
$unique_name = $info->{$user_attr};
} elsif ($user_attr eq 'subject') { # stay compat with old versions
$unique_name = $subject;
} elsif ($user_attr eq 'username') { # stay compat with old versions
my $username = $info->{'preferred_username'};
die "missing claim 'preferred_username'\n" if !defined($username);
$unique_name = $username;
} else {
# neither the attr nor fallback are defined in info..
die "missing configured claim '$user_attr' in returned info object\n";
}
my $username = "${unique_name}\@${realm}";
# first, check if $username respects our naming conventions
PVE::Auth::Plugin::verify_username($username);
my $user_cfg_changed;
if ($config->{'autocreate'} && !$rpcenv->check_user_exist($username, 1)) {
PVE::AccessControl::lock_user_config(
sub {
my $usercfg = cfs_read_file("user.cfg");
die "user '$username' already exists\n"
if $usercfg->{users}->{$username};
my $entry = { enable => 1 };
if (defined(my $email = $info->{'email'})) {
$entry->{email} = $email;
}
if (defined(my $given_name = $info->{'given_name'})) {
$entry->{firstname} = $given_name;
}
if (defined(my $family_name = $info->{'family_name'})) {
$entry->{lastname} = $family_name;
}
$usercfg->{users}->{$username} = $entry;
cfs_write_file("user.cfg", $usercfg);
},
"autocreate openid user failed",
);
$user_cfg_changed = 1;
} else {
# test if user exists and is enabled
$rpcenv->check_user_enabled($username);
}
if (defined(my $groups_claim = $config->{'groups-claim'})) {
if (defined(my $groups_list = $info->{$groups_claim})) {
if (ref($groups_list) eq 'ARRAY') {
PVE::AccessControl::lock_user_config(
sub {
my $usercfg = cfs_read_file("user.cfg");
my $oidc_groups;
for my $group (@$groups_list) {
if (PVE::AccessControl::verify_groupname($group, 1)) {
# add realm name as suffix to group
$oidc_groups->{"$group-$realm"} = 1;
} else {
# ignore any groups in the list that have invalid characters
syslog(
'warn',
"openid group '$group' contains invalid characters",
);
}
}
# get groups that exist in OIDC and PVE
my $groups_intersect;
for my $group (keys %$oidc_groups) {
$groups_intersect->{$group} = 1
if $usercfg->{groups}->{$group};
}
if ($config->{'groups-autocreate'}) {
# populate all groups in claim
$groups_intersect = $oidc_groups;
my $groups_to_create;
for my $group (keys %$oidc_groups) {
$groups_to_create->{$group} = 1
if !$usercfg->{groups}->{$group};
}
if ($groups_to_create) {
# log a messages about created groups here
my $groups_to_create_string =
join(', ', sort keys %$groups_to_create);
syslog(
'info',
"groups created automatically from openid claim: $groups_to_create_string",
);
}
}
# if groups should be overwritten, delete all the users groups first
if ($config->{'groups-overwrite'}) {
PVE::AccessControl::delete_user_group(
$username, $usercfg,
);
syslog(
'info',
"openid overwrite groups enabled; user '$username' removed from all groups",
);
}
if (keys %$groups_intersect) {
# ensure user is a member of the groups
for my $group (keys %$groups_intersect) {
PVE::AccessControl::add_user_group(
$username,
$usercfg,
$group,
);
}
my $groups_intersect_string =
join(', ', sort keys %$groups_intersect);
syslog(
'info',
"openid user '$username' added to groups: $groups_intersect_string",
);
}
cfs_write_file("user.cfg", $usercfg);
},
"openid group mapping failed",
);
$user_cfg_changed = 1;
} else {
syslog(
'err',
"openid groups list is not an array; groups will not be updated",
);
}
} else {
syslog('err', "openid groups claim '$groups_claim' is not found in claims");
}
}
# the cached user config of this request does not know about the changes yet
$rpcenv->reload_user_config() if $user_cfg_changed;
my $ticket = PVE::AccessControl::assemble_ticket($username);
my $csrftoken = PVE::AccessControl::assemble_csrf_prevention_token($username);
my $cap = $rpcenv->compute_api_permission($username);
$res = {
ticket => $ticket,
username => $username,
CSRFPreventionToken => $csrftoken,
cap => $cap,
};
my $clinfo = PVE::Cluster::get_clinfo();
if (
$clinfo->{cluster}->{name} && $rpcenv->check($username, '/', ['Sys.Audit'], 1)
) {
$res->{clustername} = $clinfo->{cluster}->{name};
}
};
if (my $err = $@) {
my $clientip = $rpcenv->get_client_ip() || '';
syslog('err', "openid authentication failure; rhost=$clientip msg=$err");
# do not return any info to prevent user enumeration attacks
die PVE::Exception->new("authentication failure\n", code => 401);
}
PVE::Cluster::log_msg(
'info',
'root@pam',
"successful openid auth for user '$res->{username}'",
);
return $res;
},
});
+499
View File
@@ -0,0 +1,499 @@
package PVE::Auth::LDAP;
use strict;
use warnings;
use Encode;
use PVE::Auth::Plugin;
use PVE::JSONSchema;
use PVE::LDAP;
use PVE::Tools;
use base qw(PVE::Auth::Plugin);
sub type {
return 'ldap';
}
sub properties {
return {
base_dn => {
description => "LDAP base domain name",
type => 'string',
optional => 1,
maxLength => 256,
},
user_attr => {
description => "LDAP user attribute name",
type => 'string',
pattern => '\S{2,}',
optional => 1,
maxLength => 256,
},
bind_dn => {
description => "LDAP bind domain name",
type => 'string',
optional => 1,
maxLength => 256,
},
password => {
description =>
"LDAP bind password. Will be stored in '/etc/pve/priv/realm/<REALM>.pw'.",
type => 'string',
optional => 1,
},
verify => {
description => "Verify the server's SSL certificate",
type => 'boolean',
optional => 1,
default => 0,
},
capath => {
description => "Path to the CA certificate store",
type => 'string',
optional => 1,
default => '/etc/ssl/certs',
},
cert => {
description => "Path to the client certificate",
type => 'string',
optional => 1,
},
certkey => {
description => "Path to the client certificate key",
type => 'string',
optional => 1,
},
filter => {
description => "LDAP filter for user sync.",
type => 'string',
optional => 1,
maxLength => 2048,
},
sync_attributes => {
description => "Comma separated list of key=value pairs for specifying"
. " which LDAP attributes map to which PVE user field. For example,"
. " to map the LDAP attribute 'mail' to PVEs 'email', write "
. " 'email=mail'. By default, each PVE user field is represented "
. " by an LDAP attribute of the same name.",
optional => 1,
type => 'string',
pattern => '\w+=[^,]+(,\s*\w+=[^,]+)*',
},
user_classes => {
description => "The objectclasses for users.",
type => 'string',
default => 'inetorgperson, posixaccount, person, user',
format => 'ldap-simple-attr-list',
optional => 1,
},
group_dn => {
description => "LDAP base domain name for group sync. If not set, the"
. " base_dn will be used.",
type => 'string',
optional => 1,
maxLength => 256,
},
group_name_attr => {
description => "LDAP attribute representing a groups name. If not set"
. " or found, the first value of the DN will be used as name.",
type => 'string',
format => 'ldap-simple-attr',
optional => 1,
maxLength => 256,
},
group_filter => {
description => "LDAP filter for group sync.",
type => 'string',
optional => 1,
maxLength => 2048,
},
group_classes => {
description => "The objectclasses for groups.",
type => 'string',
default => 'groupOfNames, group, univentionGroup, ipausergroup',
format => 'ldap-simple-attr-list',
optional => 1,
},
'sync-defaults-options' => {
description => "The default options for behavior of synchronizations.",
type => 'string',
format => 'realm-sync-options',
optional => 1,
},
mode => {
description => "LDAP protocol mode.",
type => 'string',
enum => ['ldap', 'ldaps', 'ldap+starttls'],
optional => 1,
default => 'ldap',
},
'case-sensitive' => {
description => "username is case-sensitive",
type => 'boolean',
optional => 1,
default => 1,
},
};
}
sub options {
return {
server1 => {},
server2 => { optional => 1 },
base_dn => {},
bind_dn => { optional => 1 },
password => { optional => 1 },
user_attr => {},
port => { optional => 1 },
secure => { optional => 1 },
sslversion => { optional => 1 },
default => { optional => 1 },
comment => { optional => 1 },
tfa => { optional => 1 },
verify => { optional => 1 },
capath => { optional => 1 },
cert => { optional => 1 },
certkey => { optional => 1 },
filter => { optional => 1 },
sync_attributes => { optional => 1 },
user_classes => { optional => 1 },
group_dn => { optional => 1 },
group_name_attr => { optional => 1 },
group_filter => { optional => 1 },
group_classes => { optional => 1 },
'sync-defaults-options' => { optional => 1 },
mode => { optional => 1 },
'case-sensitive' => { optional => 1 },
};
}
my sub verify_sync_attribute_value {
my ($attr, $value) = @_;
# The attribute does not include the realm, so can't use PVE::Auth::Plugin::verify_username
if ($attr eq 'username') {
die "value '$value' does not look like a valid user name\n"
if $value !~ m/${PVE::Auth::Plugin::user_regex}/;
return;
}
return if $attr eq 'enable'; # for backwards compat, don't parse/validate
if (my $schema = PVE::JSONSchema::get_standard_option("user-$attr")) {
PVE::JSONSchema::validate($value, $schema, "invalid value '$value'\n");
} else {
die "internal error: no schema for attribute '$attr' with value '$value' available!\n";
}
}
sub get_scheme_and_port {
my ($class, $config) = @_;
my $scheme = $config->{mode} // ($config->{secure} ? 'ldaps' : 'ldap');
my $default_port = $scheme eq 'ldaps' ? 636 : 389;
my $port = $config->{port} // $default_port;
return ($scheme, $port);
}
sub connect_and_bind {
my ($class, $config, $realm, $param) = @_;
my $servers = [$config->{server1}];
push @$servers, $config->{server2} if $config->{server2};
my ($scheme, $port) = $class->get_scheme_and_port($config);
my %ldap_args;
if ($config->{verify}) {
$ldap_args{verify} = 'require';
$ldap_args{clientcert} = $config->{cert} if $config->{cert};
$ldap_args{clientkey} = $config->{certkey} if $config->{certkey};
if (defined(my $capath = $config->{capath})) {
if (-d $capath) {
$ldap_args{capath} = $capath;
} else {
$ldap_args{cafile} = $capath;
}
}
} else {
$ldap_args{verify} = 'none';
}
if ($scheme ne 'ldap') {
$ldap_args{sslversion} = $config->{sslversion} || 'tlsv1_2';
}
my $ldap = PVE::LDAP::ldap_connect($servers, $scheme, $port, \%ldap_args);
if ($config->{bind_dn}) {
my $bind_dn = $config->{bind_dn};
my $bind_pass = $param->{password} || ldap_get_credentials($realm);
die "missing password for realm $realm\n" if !defined($bind_pass);
PVE::LDAP::ldap_bind($ldap, $bind_dn, $bind_pass);
} elsif ($config->{cert} && $config->{certkey}) {
warn "skipping anonymous bind with clientcert\n";
} else {
PVE::LDAP::ldap_bind($ldap);
}
if (!$config->{base_dn}) {
my $root = $ldap->root_dse(attrs => ['defaultNamingContext']);
$config->{base_dn} = $root->get_value('defaultNamingContext');
}
return $ldap;
}
# returns:
# {
# 'username@realm' => {
# 'attr1' => 'value1',
# 'attr2' => 'value2',
# ...
# },
# ...
# }
#
# or in list context:
# (
# {
# 'username@realm' => {
# 'attr1' => 'value1',
# 'attr2' => 'value2',
# ...
# },
# ...
# },
# {
# 'uid=username,dc=....' => 'username@realm',
# ...
# }
# )
# the map of dn->username is needed for group membership sync
sub get_users {
my ($class, $config, $realm) = @_;
my $ldap = $class->connect_and_bind($config, $realm);
my $user_name_attr = $config->{user_attr} // 'uid';
my $ldap_attribute_map = {
$user_name_attr => 'username',
enable => 'enable',
expire => 'expire',
firstname => 'firstname',
lastname => 'lastname',
email => 'email',
comment => 'comment',
keys => 'keys',
# NOTE: also ensure verify_sync_attribute_value can handle any new/changed attribute name
};
# build on the fly as this is small and only called once per realm in a ldap-sync anyway
my $valid_sync_attributes = { map { $_ => 1 } values $ldap_attribute_map->%* };
foreach my $attr (PVE::Tools::split_list($config->{sync_attributes})) {
my ($ours, $ldap) = ($attr =~ m/^\s*(\w+)=(.*)\s*$/);
if (!$valid_sync_attributes->{$ours}) {
warn "skipping bad 'sync_attributes' entry – '$ours' is not a valid target attribute\n";
next;
}
$ldap_attribute_map->{$ldap} = $ours;
}
my $filter = $config->{filter};
my $basedn = $config->{base_dn};
$config->{user_classes} //= 'inetorgperson, posixaccount, person, user';
my $classes = [PVE::Tools::split_list($config->{user_classes})];
my $users =
PVE::LDAP::query_users($ldap, $filter, [keys %$ldap_attribute_map], $basedn, $classes);
my $ret = {};
my $dnmap = {};
foreach my $user (@$users) {
my $user_attributes = $user->{attributes};
my $userid = $user_attributes->{$user_name_attr}->[0];
my $username = "$userid\@$realm";
# we cannot sync usernames that do not meet our criteria
eval { PVE::Auth::Plugin::verify_username($username) };
if (my $err = $@) {
warn "$err";
next;
}
$ret->{$username} = {};
foreach my $attr (keys %$user_attributes) {
if (my $ours = $ldap_attribute_map->{$attr}) {
my $value = $user_attributes->{$attr}->[0];
# Net::LDAP returns octets; DirectoryString-syntax attributes (sn, givenName, mail,
# description, ...) are UTF-8 on the wire, so mark them as such before we hand them
# off to the rest of the stack, which assumes character strings.
$value = Encode::decode('UTF-8', $value) if defined($value);
eval { verify_sync_attribute_value($ours, $value) };
if (my $err = $@) {
warn "skipping attribute mapping '$attr'->'$ours' for user '$username' - $err";
next;
}
$ret->{$username}->{$ours} = $value;
}
}
if (wantarray) {
my $dn = $user->{dn};
$dnmap->{ lc($dn) } = $username;
}
}
return wantarray ? ($ret, $dnmap) : $ret;
}
# needs a map for dn -> username, we get this from the get_users call
# otherwise we cannot determine the group membership
sub get_groups {
my ($class, $config, $realm, $dnmap) = @_;
my $filter = $config->{group_filter};
my $basedn = $config->{group_dn} // $config->{base_dn};
my $attr = $config->{group_name_attr};
$config->{group_classes} //= 'groupOfNames, group, univentionGroup, ipausergroup';
my $classes = [PVE::Tools::split_list($config->{group_classes})];
my $ldap = $class->connect_and_bind($config, $realm);
my $groups = PVE::LDAP::query_groups($ldap, $basedn, $classes, $filter, $attr);
my $ret = {};
foreach my $group (@$groups) {
my $name = $group->{name};
if (!$name && $group->{dn} =~ m/^[^=]+=([^,]+),/) {
$name = PVE::Tools::trim($1);
}
if ($name) {
$name .= "-$realm";
# we cannot sync groups that do not meet our criteria
eval { PVE::AccessControl::verify_groupname($name) };
if (my $err = $@) {
warn "$err";
next;
}
$ret->{$name} = { users => {} };
$ret->{$name}->{comment} = $group->{description} if defined($group->{description});
foreach my $member (@{ $group->{members} }) {
if (my $user = $dnmap->{ lc($member) }) {
$ret->{$name}->{users}->{$user} = 1;
}
}
}
}
return $ret;
}
sub authenticate_user {
my ($class, $config, $realm, $username, $password) = @_;
my $ldap = $class->connect_and_bind($config, $realm);
my $user_dn =
PVE::LDAP::get_user_dn($ldap, $username, $config->{user_attr}, $config->{base_dn});
PVE::LDAP::auth_user_dn($ldap, $user_dn, $password);
$ldap->unbind();
return 1;
}
my $ldap_pw_dir = "/etc/pve/priv/realm";
sub ldap_cred_file_name {
my ($realmid) = @_;
return "${ldap_pw_dir}/${realmid}.pw";
}
sub get_cred_file {
my ($realmid) = @_;
my $cred_file = ldap_cred_file_name($realmid);
if (-e $cred_file) {
return $cred_file;
} elsif (-e "/etc/pve/priv/ldap/${realmid}.pw") {
# FIXME: remove fallback with 7.0 by doing a rename on upgrade from 6.x
return "/etc/pve/priv/ldap/${realmid}.pw";
}
return $cred_file;
}
sub ldap_set_credentials {
my ($password, $realmid) = @_;
my $cred_file = ldap_cred_file_name($realmid);
mkdir $ldap_pw_dir;
PVE::Tools::file_set_contents($cred_file, $password);
return $cred_file;
}
sub ldap_get_credentials {
my ($realmid) = @_;
if (my $cred_file = get_cred_file($realmid)) {
return PVE::Tools::file_read_firstline($cred_file);
}
return undef;
}
sub ldap_delete_credentials {
my ($realmid) = @_;
if (my $cred_file = get_cred_file($realmid)) {
return if !-e $cred_file; # nothing to do
unlink($cred_file) or warn "removing LDAP credentials '$cred_file' failed: $!\n";
}
}
sub on_add_hook {
my ($class, $realm, $config, %param) = @_;
if (defined($param{password})) {
ldap_set_credentials($param{password}, $realm);
} else {
ldap_delete_credentials($realm);
}
}
sub on_update_hook {
my ($class, $realm, $config, %param) = @_;
return if !exists($param{password});
if (defined($param{password})) {
ldap_set_credentials($param{password}, $realm);
} else {
ldap_delete_credentials($realm);
}
}
sub on_delete_hook {
my ($class, $realm, $config) = @_;
ldap_delete_credentials($realm);
}
sub check_connection {
my ($class, $realm, $config, %param) = @_;
$class->connect_and_bind($config, $realm, \%param);
}
1;
+5 -2
View File
@@ -25,7 +25,7 @@ sub authenticate_user {
my ($class, $config, $realm, $username, $password) = @_;
# user (www-data) need to be able to read /etc/passwd /etc/shadow
die "no password\n" if !defined($password) || $password eq '';
die "no password\n" if !$password;
# PAM modules may temporarily override $SIG{CHLD}, causing SIGCHLDs from
# RESTEnvironment workers to be lost. Running the PAM interaction in a fork
@@ -89,7 +89,10 @@ sub store_password {
push @$cmd, '-p', $epw, $username;
run_command($cmd, errmsg => 'change password failed');
run_command(
$cmd,
errmsg => "changing the password of the PAM user '$username' on the host system failed",
);
}
1;
+694
View File
@@ -0,0 +1,694 @@
package PVE::RPCEnvironment;
use strict;
use warnings;
use PVE::AccessControl;
use PVE::Cluster;
use PVE::Exception qw(raise raise_param_exc raise_perm_exc);
use PVE::INotify;
use PVE::ProcFSTools;
use PVE::RESTEnvironment;
use PVE::SafeSyslog;
use PVE::Tools;
use base qw(PVE::RESTEnvironment);
# ACL cache
my $compile_acl_path = sub {
my ($self, $user, $path) = @_;
my $cfg = $self->{user_cfg};
return undef if !$cfg->{roles};
# permissions() has an early return for this case
die "internal error" if $user eq 'root@pam';
my $cache = $self->{aclcache};
$cache->{$user} = {} if !$cache->{$user};
my $data = $cache->{$user};
# permissions() will always prime the cache for the owning user
my ($username, undef) = PVE::AccessControl::split_tokenid($user, 1);
die "internal error"
if $username && $username ne 'root@pam' && !defined($cache->{$username});
# resolve and cache roles of the current user/token for all pool ACL paths
if (!$data->{poolroles}) {
$data->{poolroles} = {};
foreach my $pool (keys %{ $cfg->{pools} }) {
my $d = $cfg->{pools}->{$pool};
my $pool_roles = PVE::AccessControl::roles($cfg, $user, "/pool/$pool"); # pool roles
next if !scalar(keys %$pool_roles);
foreach my $vmid (keys %{ $d->{vms} }) {
for my $role (keys %$pool_roles) {
$data->{poolroles}->{"/vms/$vmid"}->{$role} = 1;
}
}
foreach my $storeid (keys %{ $d->{storage} }) {
for my $role (keys %$pool_roles) {
$data->{poolroles}->{"/storage/$storeid"}->{$role} = 1;
}
}
}
}
# get roles of current user/token on checked path - this already handles
# propagation and NoAccess along the path
#
# hash mapping role name to propagation flag value, a key being defined
# means the role is set
my $roles = PVE::AccessControl::roles($cfg, $user, $path);
# apply roles inherited from pools
if ($data->{poolroles}->{$path}) {
# NoAccess must not be trumped by pool ACLs
if (!defined($roles->{NoAccess})) {
if ($data->{poolroles}->{$path}->{NoAccess}) {
# but pool ACL NoAccess trumps regular ACL
$roles = { 'NoAccess' => 0 };
} else {
foreach my $role (keys %{ $data->{poolroles}->{$path} }) {
# only use role from pool ACL if regular ACL didn't already
# set it, and never set propagation for pool-derived ACLs
$roles->{$role} = 0 if !defined($roles->{$role});
}
}
}
}
# cache roles
$data->{roles}->{$path} = $roles;
# derive privs from set roles - hash mapping privilege name to propagation
# flag value, a key being defined means the priv is set
my $privs = {};
foreach my $role (keys %$roles) {
if (my $privset = $cfg->{roles}->{$role}) {
foreach my $p (keys %$privset) {
# set priv '$p' to propagated iff any of the set roles
# containing it have the propagated flag set
$privs->{$p} ||= $roles->{$role};
}
}
}
# intersect user and token permissions
if ($username && $username ne 'root@pam') {
# map of set privs to their propagation flag value, for the owning user
my $user_privs = $cache->{$username}->{privs}->{$path};
# list of privs set both for token and owning user
my $filtered_privs = [grep { defined($user_privs->{$_}) } keys %$privs];
# intersection of privs using filtered list, combining both propagation
# flags
$privs = { map { $_ => $user_privs->{$_} && $privs->{$_} } @$filtered_privs };
}
foreach my $priv (keys %$privs) {
# safeguard, this should never happen anyway
delete $privs->{$priv} if !defined($privs->{$priv});
}
# cache privs
$data->{privs}->{$path} = $privs;
return $privs;
};
# this is the method used by permission check helpers below
#
# returned value is a hash mapping all set privileges on $path to their
# respective propagation flag. the propagation flag is informational only -
# actual propagation is handled in PVE::AccessControl::roles(). to determine
# whether a privilege is set, check for definedness in the returned hash.
#
# compiled ACLs are cached, so repeated checks for the same path and user are
# almost free.
#
# if $user is a tokenid, permissions are calculated depending on the
# privilege-separation flag value:
# - non-priv-separated: permissions for owning user are returned
# - priv-separated: permissions for owning user are calculated and intersected
# with those of token
sub permissions {
my ($self, $user, $path) = @_;
if ($user eq 'root@pam') { # root can do anything
my $cfg = $self->{user_cfg};
return { map { $_ => 1 } keys %{ $cfg->{roles}->{'Administrator'} } };
}
if (!defined($path)) {
# this shouldn't happen!
warn "internal error: ACL check called for undefined ACL path!\n";
return {};
}
if (PVE::AccessControl::pve_verify_tokenid($user, 1)) {
my ($username, $token) = PVE::AccessControl::split_tokenid($user);
my $cfg = $self->{user_cfg};
my $token_info = $cfg->{users}->{$username}->{tokens}->{$token};
return {} if !$token_info;
# ensure cache for user is populated
my $user_perms = $self->permissions($username, $path);
# return user privs for non-privsep tokens
return $user_perms if !$token_info->{privsep};
} else {
$user = PVE::AccessControl::verify_username($user, 1);
return {} if !$user;
}
my $cache = $self->{aclcache};
$cache->{$user} = {} if !$cache->{$user};
my $acl = $cache->{$user};
my $perm = $acl->{privs}->{$path};
return $perm if $perm;
return &$compile_acl_path($self, $user, $path);
}
sub compute_api_permission {
my ($self, $authuser) = @_;
my $usercfg = $self->{user_cfg};
my $res = {};
my $priv_re_map = {
vms => qr/VM\.|Permissions\.Modify/,
access => qr/(User|Group)\.|Permissions\.Modify/,
storage => qr/Datastore\.|Permissions\.Modify/,
nodes => qr/Sys\.|Permissions\.Modify/,
sdn => qr/SDN\.|Permissions\.Modify/,
dc => qr/Sys\.Audit|Sys\.Modify|SDN\./,
mapping => qr/Mapping\.|Permissions.Modify/,
};
map { $res->{$_} = {} } keys %$priv_re_map;
my $required_paths = ['/', '/nodes', '/access/groups', '/vms', '/storage', '/sdn', '/mapping'];
my $defined_paths = [];
PVE::AccessControl::iterate_acl_tree(
"/",
$usercfg->{acl_root},
sub {
my ($path, $node) = @_;
push @$defined_paths, $path;
},
);
my $checked_paths = {};
foreach my $path (@$required_paths, @$defined_paths) {
next if $checked_paths->{$path};
$checked_paths->{$path} = 1;
my $path_perm = $self->permissions($authuser, $path);
my $toplevel = ($path =~ /^\/(\w+)/) ? $1 : 'dc';
if ($toplevel eq 'pool') {
foreach my $priv (keys %$path_perm) {
next if !defined($path_perm->{$priv});
if ($priv =~ m/^VM\./) {
$res->{vms}->{$priv} = 1;
} elsif ($priv =~ m/^Datastore\./) {
$res->{storage}->{$priv} = 1;
} elsif ($priv eq 'Permissions.Modify') {
$res->{storage}->{$priv} = 1;
$res->{vms}->{$priv} = 1;
}
}
} else {
my $priv_regex = $priv_re_map->{$toplevel} // next;
foreach my $priv (keys %$path_perm) {
next if !defined($path_perm->{$priv});
next if $priv !~ m/^($priv_regex)/;
$res->{$toplevel}->{$priv} = 1;
}
}
}
return $res;
}
sub get_effective_permissions {
my ($self, $user) = @_;
# default / top level paths
my $paths = {
'/' => 1,
'/access' => 1,
'/access/groups' => 1,
'/nodes' => 1,
'/pool' => 1,
'/sdn' => 1,
'/storage' => 1,
'/vms' => 1,
};
my $cfg = $self->{user_cfg};
# paths explicitly listed in ACLs
PVE::AccessControl::iterate_acl_tree(
"/",
$cfg->{acl_root},
sub {
my ($path, $node) = @_;
$paths->{$path} = 1;
},
);
# paths referenced by pool definitions
foreach my $pool (keys %{ $cfg->{pools} }) {
my $d = $cfg->{pools}->{$pool};
foreach my $vmid (keys %{ $d->{vms} }) {
$paths->{"/vms/$vmid"} = 1;
}
foreach my $storeid (keys %{ $d->{storage} }) {
$paths->{"/storage/$storeid"} = 1;
}
}
my $perms = {};
foreach my $path (keys %$paths) {
my $path_perms = $self->permissions($user, $path);
foreach my $priv (keys %$path_perms) {
delete $path_perms->{$priv} if !defined($path_perms->{$priv});
}
# filter paths where user has NO permissions
$perms->{$path} = $path_perms if %$path_perms;
}
return $perms;
}
sub check {
my ($self, $user, $path, $privs, $noerr) = @_;
my $perm = $self->permissions($user, $path);
foreach my $priv (@$privs) {
PVE::AccessControl::verify_privname($priv);
if (!defined($perm->{$priv})) {
return undef if $noerr;
raise_perm_exc("$path, $priv");
}
}
return 1;
}
sub check_any {
my ($self, $user, $path, $privs, $noerr) = @_;
my $perm = $self->permissions($user, $path);
my $found = 0;
foreach my $priv (@$privs) {
PVE::AccessControl::verify_privname($priv);
if (defined($perm->{$priv})) {
$found = 1;
last;
}
}
return 1 if $found;
return undef if $noerr;
raise_perm_exc("$path, " . join("|", @$privs));
}
sub check_full {
my ($self, $username, $path, $privs, $any, $noerr) = @_;
if ($any) {
return $self->check_any($username, $path, $privs, $noerr);
} else {
return $self->check($username, $path, $privs, $noerr);
}
}
# check for any fashion of access to vnet/bridge
sub check_sdn_bridge {
my ($self, $username, $zone, $bridge, $privs, $noerr) = @_;
my $path = "/sdn/zones/$zone/$bridge";
# check access to bridge itself
return 1 if $self->check_any($username, $path, $privs, 1);
my $cfg = $self->{user_cfg};
my $bridge_acl = PVE::AccessControl::find_acl_tree_node($cfg->{acl_root}, $path);
if ($bridge_acl) {
# check access to VLANs
my $vlans = $bridge_acl->{children};
for my $vlan (keys %$vlans) {
my $vlanpath = "$path/$vlan";
return 1 if $self->check_any($username, $vlanpath, $privs, 1);
}
}
# repeat check, but fatal
$self->check_any($username, $path, $privs, 0) if !$noerr;
return;
}
sub check_user_enabled {
my ($self, $user, $noerr) = @_;
my $cfg = $self->{user_cfg};
return PVE::AccessControl::check_user_enabled($cfg, $user, $noerr);
}
sub check_user_exist {
my ($self, $user, $noerr) = @_;
my $cfg = $self->{user_cfg};
return PVE::AccessControl::check_user_exist($cfg, $user, $noerr);
}
sub check_pool_exist {
my ($self, $pool, $noerr) = @_;
my $cfg = $self->{user_cfg};
return 1 if $cfg->{pools}->{$pool};
return undef if $noerr;
raise_perm_exc("pool '$pool' does not exist");
}
sub check_vm_perm {
my ($self, $user, $vmid, $pool, $privs, $any, $noerr) = @_;
my $cfg = $self->{user_cfg};
if ($pool) {
return if $self->check_full($user, "/pool/$pool", $privs, $any, 1);
}
return $self->check_full($user, "/vms/$vmid", $privs, $any, $noerr);
}
sub is_group_member {
my ($self, $group, $user) = @_;
my $cfg = $self->{user_cfg};
return 0 if !$cfg->{groups}->{$group};
return defined($cfg->{groups}->{$group}->{users}->{$user});
}
sub filter_groups {
my ($self, $user, $privs, $any) = @_;
my $cfg = $self->{user_cfg};
my $groups = {};
foreach my $group (keys %{ $cfg->{groups} }) {
my $path = "/access/groups/$group";
if ($self->check_full($user, $path, $privs, $any, 1)) {
$groups->{$group} = $cfg->{groups}->{$group};
}
}
return $groups;
}
sub group_member_join {
my ($self, $grouplist) = @_;
my $users = {};
my $cfg = $self->{user_cfg};
foreach my $group (@$grouplist) {
my $data = $cfg->{groups}->{$group};
next if !$data;
foreach my $user (keys %{ $data->{users} }) {
$users->{$user} = 1;
}
}
return $users;
}
sub check_perm_modify {
my ($self, $username, $path, $noerr) = @_;
return $self->check($username, '/access', ['Permissions.Modify'], $noerr) if !$path;
my $testperms = ['Permissions.Modify'];
if ($path =~ m|^/storage/.+$|) {
push @$testperms, 'Datastore.Allocate';
} elsif ($path =~ m|^/vms/.+$|) {
push @$testperms, 'VM.Allocate';
} elsif ($path =~ m|^/pool/.+$|) {
push @$testperms, 'Pool.Allocate';
}
return $self->check_any($username, $path, $testperms, $noerr);
}
sub exec_api2_perm_check {
my ($self, $check, $username, $param, $noerr) = @_;
# syslog("info", "CHECK " . join(', ', @$check));
my $ind = 0;
my $test = $check->[$ind++];
die "no permission test specified" if !$test;
if ($test eq 'and') {
while (my $subcheck = $check->[$ind++]) {
$self->exec_api2_perm_check($subcheck, $username, $param);
}
return 1;
} elsif ($test eq 'or') {
while (my $subcheck = $check->[$ind++]) {
return 1 if $self->exec_api2_perm_check($subcheck, $username, $param, 1);
}
return 0 if $noerr;
raise_perm_exc();
} elsif ($test eq 'perm') {
my ($t, $tmplpath, $privs, %options) = @$check;
my $any = $options{any};
die "missing parameters" if !($tmplpath && $privs);
my $require_param = $options{require_param};
if ($require_param && !defined($param->{$require_param})) {
return 0 if $noerr;
raise_perm_exc();
}
my $path = PVE::Tools::template_replace($tmplpath, $param);
my $normpath = PVE::AccessControl::normalize_path($path);
warn "Failed to normalize '$path'\n" if !defined($normpath) && defined($path);
return $self->check_full($username, $normpath, $privs, $any, $noerr);
} elsif ($test eq 'userid-group') {
my $userid = $param->{userid};
my ($t, $privs, %options) = @$check;
my $check_existing_user = !$options{groups_param} || $options{groups_param} ne 'create';
return 0 if $check_existing_user && !$self->check_user_exist($userid, $noerr);
# check permission for ALL groups (and thus ALL users)
if (!$self->check_any($username, "/access/groups", $privs, 1)) {
# list of groups $username has any of $privs on
my $groups = $self->filter_groups($username, $privs, 1);
if ($options{groups_param}) {
# does $username have any of $privs on all new/updated/.. groups?
my @group_param = PVE::Tools::split_list($param->{groups});
raise_perm_exc("/access/groups, " . join("|", @$privs)) if !scalar(@group_param);
foreach my $pg (@group_param) {
raise_perm_exc("/access/groups/$pg, " . join("|", @$privs))
if !$groups->{$pg};
}
}
if ($check_existing_user) {
# does $username have any of $privs on any existing group of $userid
my $allowed_users = $self->group_member_join([keys %$groups]);
if (!$allowed_users->{$userid}) {
return 0 if $noerr;
raise_perm_exc();
}
}
}
return 1;
} elsif ($test eq 'userid-param') {
my ($userid, undef, $realm) = PVE::AccessControl::verify_username($param->{userid});
my ($t, $subtest) = @$check;
die "missing parameters" if !$subtest;
if ($subtest eq 'self') {
return 0 if !$self->check_user_exist($userid, $noerr);
return 1 if $username eq $userid;
return 0 if $noerr;
raise_perm_exc();
} elsif ($subtest eq 'Realm.AllocateUser') {
my $path = "/access/realm/$realm";
return $self->check($username, $path, ['Realm.AllocateUser'], $noerr);
} else {
die "unknown userid-param test";
}
} elsif ($test eq 'perm-modify') {
my ($t, $tmplpath) = @$check;
my $path = PVE::Tools::template_replace($tmplpath, $param);
$path = PVE::AccessControl::normalize_path($path);
return 0 if !defined($path); # should already die in API2::ACL
return $self->check_perm_modify($username, $path, $noerr);
} else {
die "unknown permission test";
}
}
sub check_api2_permissions {
my ($self, $perm, $username, $param) = @_;
return 1 if !$username && $perm->{user} && $perm->{user} eq 'world';
raise_perm_exc("user != null") if !$username;
return 1 if $username eq 'root@pam';
raise_perm_exc('user != root@pam') if !$perm;
return 1 if $perm->{user} && $perm->{user} eq 'all';
return $self->exec_api2_perm_check($perm->{check}, $username, $param)
if $perm->{check};
raise_perm_exc();
}
sub log_cluster_msg {
my ($self, $pri, $user, $msg) = @_;
PVE::Cluster::log_msg($pri, $user, $msg);
}
sub broadcast_tasklist {
my ($self, $tlist) = @_;
PVE::Cluster::broadcast_tasklist($tlist);
}
# initialize environment - must be called once at program startup
sub init {
my ($class, $type, %params) = @_;
$class = ref($class) || $class;
my $self = $class->SUPER::init($type, %params);
$self->{user_cfg} = {};
$self->{aclcache} = {};
$self->{aclversion} = undef;
return $self;
}
# init_request - must be called before each RPC request
sub init_request {
my ($self, %params) = @_;
PVE::Cluster::cfs_update();
$self->{result_attributes} = {};
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";
}
}
# reload the user config, needed if the current request modified it and the permissions should
# already reflect that
sub reload_user_config {
my ($self) = @_;
$self->{aclcache} = {};
$self->{user_cfg} = PVE::Cluster::cfs_read_file('user.cfg');
}
# hacks: to provide better backwards compatibility
# old code uses PVE::RPCEnvironment::get();
# new code should use PVE::RPCEnvironment->get();
sub get {
return PVE::RESTEnvironment->get();
}
# old code uses PVE::RPCEnvironment::is_worker();
# new code should use PVE::RPCEnvironment->is_worker();
sub is_worker {
return PVE::RESTEnvironment->is_worker();
}
# Permission helper for TFA and password API endpoints modifying users.
# Only root may modify root, regular users need to specify their password.
#
# Returns the same as `verify_username` in list context (userid, ruid, realm),
# or just the userid in scalar context.
sub reauth_user_for_user_modification : prototype($$$$;$) {
my ($rpcenv, $authuser, $userid, $password, $param_name) = @_;
$param_name //= 'password';
($userid, my $ruid, my $realm) = PVE::AccessControl::verify_username($userid);
$rpcenv->check_user_exist($userid);
raise_perm_exc() if $userid eq 'root@pam' && $authuser ne 'root@pam';
# Regular users need to confirm their password to change TFA settings.
if ($authuser ne 'root@pam') {
raise_param_exc({ $param_name => 'password is required to modify user' })
if !defined($password);
($authuser, my $auth_username, my $auth_realm) =
PVE::AccessControl::verify_username($authuser);
my $domain_cfg = PVE::Cluster::cfs_read_file('domains.cfg');
my $cfg = $domain_cfg->{ids}->{$auth_realm};
die "auth domain '$auth_realm' does not exist\n" if !$cfg;
my $plugin = PVE::Auth::Plugin->lookup($cfg->{type});
$plugin->authenticate_user($cfg, $auth_realm, $auth_username, $password);
}
return wantarray ? ($userid, $ruid, $realm) : $userid;
}
1;
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env perl
use strict;
use warnings;
use TAP::Harness;
my $harness = TAP::Harness->new({ verbosity => -1 });
my $result = $harness->runtests('api-get-permissions-test.pl', 'openid-login-test.pl');
exit -1 if $result->{failed};
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env perl
use strict;
use warnings;
use lib qw(..);
use Test::More;
use Test::MockModule;
use PVE::AccessControl;
use PVE::RPCEnvironment;
use PVE::API2::OpenId;
my $realm_config = {
type => 'openid',
'issuer-url' => 'https://idp.example.com',
'client-id' => 'pve',
autocreate => 1,
'groups-claim' => 'groups',
};
my $user_cfg_raw;
my $user_cfg_version = 1;
my $read_file = sub {
my ($filename) = @_;
return PVE::AccessControl::parse_user_config('/etc/pve/user.cfg', $user_cfg_raw)
if $filename eq 'user.cfg';
return { ids => { oidc => $realm_config } } if $filename eq 'domains.cfg';
return {} if $filename eq 'datacenter.cfg';
die "unexpected read of '$filename'\n";
};
my $write_file = sub {
my ($filename, $cfg) = @_;
die "unexpected write of '$filename'\n" if $filename ne 'user.cfg';
$user_cfg_raw = PVE::AccessControl::write_user_config('/etc/pve/user.cfg', $cfg);
$user_cfg_version++;
};
my $cluster_module = Test::MockModule->new('PVE::Cluster');
$cluster_module->noop('cfs_update', 'log_msg');
$cluster_module->mock(
cfs_file_version => sub { return ($user_cfg_version, {}) },
cfs_read_file => $read_file,
get_clinfo => sub { return {} },
);
my $access_control_module = Test::MockModule->new('PVE::AccessControl');
$access_control_module->mock(
cfs_lock_file => sub {
my ($filename, $timeout, $code) = @_;
my $res = eval { $code->() };
return $res;
},
assemble_ticket => sub { return "ticket:$_[0]" },
assemble_csrf_prevention_token => sub { return "csrf:$_[0]" },
);
my $openid_api_module = Test::MockModule->new('PVE::API2::OpenId');
$openid_api_module->mock(
cfs_read_file => $read_file,
cfs_write_file => $write_file,
syslog => sub { },
);
my $claims;
my $openid_module = Test::MockModule->new('PVE::RS::OpenId');
$openid_module->mock(
verify_public_auth_state => sub { return ('oidc', 'private-state') },
discover => sub { return bless {}, 'PVE::RS::OpenId' },
verify_authorization_code => sub { return $claims },
);
my $rpcenv = PVE::RPCEnvironment->init('cli');
my ($handler, $handler_info) = PVE::API2::OpenId->find_handler('POST', 'login');
# each test is comprised of the following keys:
# description => what the test is about
# user_cfg => user.cfg before the login
# claims => claims returned by the OpenID provider
# expected_vms_cap => VM privileges the login result must contain
my $tests = [
{
description => 'new user added to a group with ACLs',
user_cfg => <<"EOF",
group:tenant-oidc:::
acl:1:/vms:\@tenant-oidc:PVEVMAdmin:
EOF
claims => { sub => 'alice', groups => ['tenant'] },
expected_vms_cap => ['VM.Allocate', 'VM.PowerMgmt'],
},
{
description => 'existing user newly added to a group with ACLs',
user_cfg => <<"EOF",
user:bob\@oidc:1:0:::::
group:tenant-oidc:::
acl:1:/vms:\@tenant-oidc:PVEVMAdmin:
EOF
claims => { sub => 'bob', groups => ['tenant'] },
expected_vms_cap => ['VM.Allocate', 'VM.PowerMgmt'],
},
];
for my $test (@$tests) {
$user_cfg_raw = $test->{user_cfg};
$claims = $test->{claims};
$rpcenv->init_request();
my $login = eval {
$handler->handle(
$handler_info,
{
state => 'public-state',
code => 'authorization-code',
'redirect-url' => 'https://pve.example.com:8006',
},
);
};
is($@, '', "$test->{description}: login succeeds");
my $vms_cap = $login->{cap}->{vms} // {};
my $missing = [grep { !$vms_cap->{$_} } $test->{expected_vms_cap}->@*];
is_deeply($missing, [], "$test->{description}: permissions are active right away");
}
done_testing();
+365
View File
@@ -0,0 +1,365 @@
#!/usr/bin/perl
use strict;
use warnings;
use Test::MockModule;
use Test::More;
use Storable qw(dclone);
use PVE::AccessControl;
use PVE::API2::Domains;
my $domainscfg = {
ids => {
"pam" => { type => 'pam' },
"pve" => { type => 'pve' },
"syncedrealm" => { type => 'ldap' },
},
};
my $initialusercfg = {
users => {
'root@pam' => { username => 'root' },
'user1@syncedrealm' => {
username => 'user1',
enable => 1,
'keys' => 'some',
},
'user2@syncedrealm' => {
username => 'user2',
enable => 1,
},
'user3@syncedrealm' => {
username => 'user3',
enable => 1,
},
},
groups => {
'group1-syncedrealm' => { users => {}, comment => 'outdated comment' },
'group2-syncedrealm' => { users => {} },
},
acl_root => {
users => {
'user3@syncedrealm' => {},
},
groups => {},
},
};
my $sync_response = {
user => [
{
attributes => { 'uid' => ['user1'] },
dn => 'uid=user1,dc=syncedrealm',
},
{
attributes => { 'uid' => ['user2'] },
dn => 'uid=user2,dc=syncedrealm',
},
{
attributes => { 'uid' => ['user4'] },
dn => 'uid=user4,dc=syncedrealm',
},
],
groups => [
{
dn => 'dc=group1,dc=syncedrealm',
description => 'First group',
members => [
'uid=user1,dc=syncedrealm',
],
},
{
dn => 'dc=group3,dc=syncedrealm',
members => [
'uid=nonexisting,dc=syncedrealm',
],
},
],
};
my $returned_user_cfg = {};
# mocking all cluster and ldap operations
my $pve_cluster_module = Test::MockModule->new('PVE::Cluster');
$pve_cluster_module->mock(
cfs_update => sub { },
cfs_read_file => sub {
my ($filename) = @_;
if ($filename eq 'domains.cfg') { return dclone($domainscfg); }
if ($filename eq 'user.cfg') { return dclone($initialusercfg); }
die "unexpected cfs_read_file";
},
cfs_write_file => sub {
my ($filename, $data) = @_;
if ($filename eq 'user.cfg') {
$returned_user_cfg = $data;
return;
}
die "unexpected cfs_read_file";
},
cfs_lock_file => sub {
my ($filename, $timeout, $code) = @_;
return $code->();
},
);
my $pve_api_domains = Test::MockModule->new('PVE::API2::Domains');
$pve_api_domains->mock(
cfs_read_file => sub { PVE::Cluster::cfs_read_file(@_); },
cfs_write_file => sub { PVE::Cluster::cfs_write_file(@_); },
);
my $pve_accesscontrol = Test::MockModule->new('PVE::AccessControl');
$pve_accesscontrol->mock(
cfs_lock_file => sub { PVE::Cluster::cfs_lock_file(@_); },
);
my $pve_rpcenvironment = Test::MockModule->new('PVE::RPCEnvironment');
$pve_rpcenvironment->mock(
get => sub { return bless {}, 'PVE::RPCEnvironment'; },
get_user => sub { return 'root@pam'; },
fork_worker => sub {
my ($class, $workertype, $id, $user, $code) = @_;
return $code->();
},
);
my $pve_ldap_module = Test::MockModule->new('PVE::LDAP');
$pve_ldap_module->mock(
ldap_connect => sub { return {}; },
ldap_bind => sub { },
query_users => sub {
return $sync_response->{user};
},
query_groups => sub {
return $sync_response->{groups};
},
);
my $pve_auth_ldap = Test::MockModule->new('PVE::Auth::LDAP');
$pve_auth_ldap->mock(
connect_and_bind => sub { return {}; },
);
my $tests = [
[
"non-full without purge",
{
realm => 'syncedrealm',
scope => 'both',
},
{
users => {
'root@pam' => { username => 'root' },
'user1@syncedrealm' => {
username => 'user1',
enable => 1,
'keys' => 'some',
},
'user2@syncedrealm' => {
username => 'user2',
enable => 1,
},
'user3@syncedrealm' => {
username => 'user3',
enable => 1,
},
'user4@syncedrealm' => {
username => 'user4',
enable => 1,
},
},
groups => {
'group1-syncedrealm' => {
users => {
'user1@syncedrealm' => 1,
},
comment => 'First group',
},
'group2-syncedrealm' => { users => {} },
'group3-syncedrealm' => { users => {} },
},
acl_root => {
users => {
'user3@syncedrealm' => {},
},
groups => {},
},
},
],
[
"full without purge",
{
realm => 'syncedrealm',
'remove-vanished' => 'entry;properties',
scope => 'both',
},
{
users => {
'root@pam' => { username => 'root' },
'user1@syncedrealm' => {
username => 'user1',
enable => 1,
},
'user2@syncedrealm' => {
username => 'user2',
enable => 1,
},
'user4@syncedrealm' => {
username => 'user4',
enable => 1,
},
},
groups => {
'group1-syncedrealm' => {
users => {
'user1@syncedrealm' => 1,
},
comment => 'First group',
},
'group3-syncedrealm' => { users => {} },
},
acl_root => {
users => {
'user3@syncedrealm' => {},
},
groups => {},
},
},
],
[
"non-full with purge",
{
realm => 'syncedrealm',
'remove-vanished' => 'acl',
scope => 'both',
},
{
users => {
'root@pam' => { username => 'root' },
'user1@syncedrealm' => {
username => 'user1',
enable => 1,
'keys' => 'some',
},
'user2@syncedrealm' => {
username => 'user2',
enable => 1,
},
'user3@syncedrealm' => {
username => 'user3',
enable => 1,
},
'user4@syncedrealm' => {
username => 'user4',
enable => 1,
},
},
groups => {
'group1-syncedrealm' => {
users => {
'user1@syncedrealm' => 1,
},
comment => 'First group',
},
'group2-syncedrealm' => { users => {} },
'group3-syncedrealm' => { users => {} },
},
acl_root => {
users => {},
groups => {},
},
},
],
[
"full with purge",
{
realm => 'syncedrealm',
'remove-vanished' => 'acl;entry;properties',
scope => 'both',
},
{
users => {
'root@pam' => { username => 'root' },
'user1@syncedrealm' => {
username => 'user1',
enable => 1,
},
'user2@syncedrealm' => {
username => 'user2',
enable => 1,
},
'user4@syncedrealm' => {
username => 'user4',
enable => 1,
},
},
groups => {
'group1-syncedrealm' => {
users => {
'user1@syncedrealm' => 1,
},
comment => 'First group',
},
'group3-syncedrealm' => { users => {} },
},
acl_root => {
users => {},
groups => {},
},
},
],
[
"don't delete properties, but users and acls",
{
realm => 'syncedrealm',
'remove-vanished' => 'acl;entry',
scope => 'both',
},
{
users => {
'root@pam' => { username => 'root' },
'user1@syncedrealm' => {
username => 'user1',
enable => 1,
'keys' => 'some',
},
'user2@syncedrealm' => {
username => 'user2',
enable => 1,
},
'user4@syncedrealm' => {
username => 'user4',
enable => 1,
},
},
groups => {
'group1-syncedrealm' => {
users => {
'user1@syncedrealm' => 1,
},
comment => 'First group',
},
'group3-syncedrealm' => { users => {} },
},
acl_root => {
users => {},
groups => {},
},
},
],
];
for my $test (@$tests) {
my $name = $test->[0];
my $parameters = $test->[1];
my $expected = $test->[2];
$returned_user_cfg = {};
PVE::API2::Domains->sync($parameters);
is_deeply($returned_user_cfg, $expected, $name);
}
done_testing();