package PVE::API2::Domains; use strict; use warnings; use PVE::Exception qw(raise_param_exc); use PVE::Tools qw(extract_param); use PVE::Cluster qw (cfs_read_file cfs_write_file); use PVE::AccessControl; use PVE::JSONSchema qw(get_standard_option); use PVE::SafeSyslog; use PVE::RESTHandler; use PVE::Auth::Plugin; my $domainconfigfile = "domains.cfg"; use base qw(PVE::RESTHandler); # maps old 'full'/'purge' parameters to new 'remove-vanished' # TODO remove when we delete the 'full'/'purge' parameters my $map_remove_vanished = sub { my ($opt, $delete_deprecated) = @_; if (!defined($opt->{'remove-vanished'}) && ($opt->{full} || $opt->{purge})) { my $props = []; push @$props, 'entry', 'properties' if $opt->{full}; push @$props, 'acl' if $opt->{purge}; $opt->{'remove-vanished'} = join(';', @$props); } if ($delete_deprecated) { delete $opt->{full}; delete $opt->{purge}; } return $opt; }; my $map_sync_default_options = sub { my ($cfg, $delete_deprecated) = @_; my $opt = $cfg->{'sync-defaults-options'}; return if !defined($opt); my $sync_opts_fmt = PVE::JSONSchema::get_format('realm-sync-options'); my $old_opt = PVE::JSONSchema::parse_property_string($sync_opts_fmt, $opt); my $new_opt = $map_remove_vanished->($old_opt, $delete_deprecated); $cfg->{'sync-defaults-options'} = PVE::JSONSchema::print_property_string($new_opt, $sync_opts_fmt); }; __PACKAGE__->register_method({ name => 'index', path => '', method => 'GET', description => "Authentication domain index.", permissions => { description => "Anyone can access that, because we need that list for the login box (before the user is authenticated).", user => 'world', }, parameters => { additionalProperties => 0, properties => {}, }, returns => { type => 'array', items => { type => "object", properties => { realm => { type => 'string' }, type => { type => 'string' }, tfa => { description => "Two-factor authentication provider.", type => 'string', enum => ['yubico', 'oath'], optional => 1, }, comment => { description => "A comment. The GUI use this text when you select a domain (Realm) on the login window.", type => 'string', optional => 1, }, }, }, links => [{ rel => 'child', href => "{realm}" }], }, code => sub { my ($param) = @_; my $res = []; my $cfg = cfs_read_file($domainconfigfile); my $ids = $cfg->{ids}; foreach my $realm (keys %$ids) { my $d = $ids->{$realm}; my $entry = { realm => $realm, type => $d->{type} }; $entry->{comment} = $d->{comment} if $d->{comment}; $entry->{default} = 1 if $d->{default}; if ($d->{tfa} && (my $tfa_cfg = PVE::Auth::Plugin::parse_tfa_config($d->{tfa}))) { $entry->{tfa} = $tfa_cfg->{type}; } push @$res, $entry; } return $res; }, }); __PACKAGE__->register_method({ name => 'create', protected => 1, path => '', method => 'POST', permissions => { check => ['perm', '/access/realm', ['Realm.Allocate']], }, description => "Add an authentication server.", parameters => PVE::Auth::Plugin->createSchema( 0, { 'check-connection' => { description => 'Check bind connection to the server.', type => 'boolean', optional => 1, default => 0, }, }, ), returns => { type => 'null' }, code => sub { my ($param) = @_; # always extract, add it with hook my $password = extract_param($param, 'password'); PVE::Auth::Plugin::lock_domain_config( sub { my $cfg = cfs_read_file($domainconfigfile); my $ids = $cfg->{ids}; my $realm = extract_param($param, 'realm'); my $type = $param->{type}; my $check_connection = extract_param($param, 'check-connection'); die "domain '$realm' already exists\n" if $ids->{$realm}; die "unable to use reserved name '$realm'\n" if ($realm eq 'pam' || $realm eq 'pve'); die "unable to create builtin type '$type'\n" if ($type eq 'pam' || $type eq 'pve'); die "'check-connection' parameter can only be set for realms of type 'ldap' or 'ad'\n" if defined($check_connection) && !($type eq 'ldap' || $type eq 'ad'); if ($type eq 'ad' || $type eq 'ldap') { $map_sync_default_options->($param, 1); } my $plugin = PVE::Auth::Plugin->lookup($type); my $config = $plugin->check_config($realm, $param, 1, 1); if ($config->{default}) { foreach my $r (keys %$ids) { delete $ids->{$r}->{default}; } } $ids->{$realm} = $config; my $opts = $plugin->options(); if (defined($password) && !defined($opts->{password})) { $password = undef; warn "ignoring password parameter"; } $plugin->on_add_hook($realm, $config, password => $password); # Only for LDAP/AD, implied through the existence of the 'check-connection' param $plugin->check_connection($realm, $config, password => $password) if $check_connection; cfs_write_file($domainconfigfile, $cfg); }, "add auth server failed", ); return undef; }, }); __PACKAGE__->register_method({ name => 'update', path => '{realm}', method => 'PUT', permissions => { check => ['perm', '/access/realm', ['Realm.Allocate']], }, description => "Update authentication server settings.", protected => 1, parameters => PVE::Auth::Plugin->updateSchema( 0, { 'check-connection' => { description => 'Check bind connection to the server.', type => 'boolean', optional => 1, default => 0, }, }, ), returns => { type => 'null' }, code => sub { my ($param) = @_; # always extract, update in hook my $password = extract_param($param, 'password'); PVE::Auth::Plugin::lock_domain_config( sub { my $cfg = cfs_read_file($domainconfigfile); my $ids = $cfg->{ids}; my $digest = extract_param($param, 'digest'); PVE::SectionConfig::assert_if_modified($cfg, $digest); my $realm = extract_param($param, 'realm'); my $type = $ids->{$realm}->{type}; my $check_connection = extract_param($param, 'check-connection'); die "domain '$realm' does not exist\n" if !$ids->{$realm}; die "'check-connection' parameter can only be set for realms of type 'ldap' or 'ad'\n" if defined($check_connection) && !($type eq 'ldap' || $type eq 'ad'); my $delete_str = extract_param($param, 'delete'); die "no options specified\n" if !$delete_str && !scalar(keys %$param) && !defined($password); my $delete_pw = 0; foreach my $opt (PVE::Tools::split_list($delete_str)) { delete $ids->{$realm}->{$opt}; $delete_pw = 1 if $opt eq 'password'; } if ($type eq 'ad' || $type eq 'ldap') { $map_sync_default_options->($param, 1); } my $plugin = PVE::Auth::Plugin->lookup($type); my $config = $plugin->check_config($realm, $param, 0, 1); if ($config->{default}) { foreach my $r (keys %$ids) { delete $ids->{$r}->{default}; } } foreach my $p (keys %$config) { $ids->{$realm}->{$p} = $config->{$p}; } my $opts = $plugin->options(); if ($delete_pw || defined($password)) { $plugin->on_update_hook($realm, $config, password => $password); } else { $plugin->on_update_hook($realm, $config); } # Only for LDAP/AD, implied through the existence of the 'check-connection' param $plugin->check_connection($realm, $ids->{$realm}, password => $password) if $check_connection; cfs_write_file($domainconfigfile, $cfg); }, "update auth server failed", ); return undef; }, }); # fixme: return format! __PACKAGE__->register_method({ name => 'read', path => '{realm}', method => 'GET', description => "Get auth server configuration.", permissions => { check => ['perm', '/access/realm', ['Realm.Allocate', 'Sys.Audit'], any => 1], }, 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->{ids}->{$realm}; die "domain '$realm' does not exist\n" if !$data; my $type = $data->{type}; if ($type eq 'ad' || $type eq 'ldap') { $map_sync_default_options->($data); } $data->{digest} = $cfg->{digest}; return $data; }, }); __PACKAGE__->register_method({ name => 'delete', path => '{realm}', method => 'DELETE', permissions => { check => ['perm', '/access/realm', ['Realm.Allocate']], }, description => "Delete an authentication server.", protected => 1, parameters => { additionalProperties => 0, properties => { realm => get_standard_option('realm'), }, }, returns => { type => 'null' }, code => sub { my ($param) = @_; PVE::Auth::Plugin::lock_domain_config( sub { my $cfg = cfs_read_file($domainconfigfile); my $ids = $cfg->{ids}; my $realm = $param->{realm}; die "authentication domain '$realm' does not exist\n" if !$ids->{$realm}; my $plugin = PVE::Auth::Plugin->lookup($ids->{$realm}->{type}); $plugin->on_delete_hook($realm, $ids->{$realm}); delete $ids->{$realm}; cfs_write_file($domainconfigfile, $cfg); }, "delete auth server failed", ); return undef; }, }); my $update_users = sub { my ($usercfg, $realm, $synced_users, $opts) = @_; if (defined(my $vanished = $opts->{'remove-vanished'})) { print "syncing users (remove-vanished opts: $vanished)\n"; } else { print "syncing users\n"; } $usercfg->{users} = {} if !defined($usercfg->{users}); my $users = $usercfg->{users}; my $to_remove = { map { $_ => 1 } split(';', $opts->{'remove-vanished'} // '') }; print "deleting outdated existing users first\n" if $to_remove->{entry}; foreach my $userid (sort keys %$users) { next if $userid !~ m/\@$realm\z/; next if defined($synced_users->{$userid}); if ($to_remove->{entry}) { print "remove user '$userid'\n"; delete $users->{$userid}; } if ($to_remove->{acl}) { print "purge users '$userid' ACL entries\n"; PVE::AccessControl::delete_user_acl($userid, $usercfg); } } foreach my $userid (sort keys %$synced_users) { my $synced_user = $synced_users->{$userid} // {}; my $olduser = $users->{$userid}; if ($to_remove->{properties} || !defined($olduser)) { # we use the synced user, but want to keep some properties on update if (defined($olduser)) { print "overwriting user '$userid'\n"; } else { $olduser = {}; print "adding user '$userid'\n"; } my $user = $users->{$userid} = $synced_user; my $enabled = $olduser->{enable} // $opts->{'enable-new'}; $user->{enable} = $enabled if defined($enabled); $user->{tokens} = $olduser->{tokens} if defined($olduser->{tokens}); } else { foreach my $attr (keys %$synced_user) { $olduser->{$attr} = $synced_user->{$attr}; } print "updating user '$userid'\n"; } } }; my $update_groups = sub { my ($usercfg, $realm, $synced_groups, $opts) = @_; if (defined(my $vanished = $opts->{'remove-vanished'})) { print "syncing groups (remove-vanished opts: $vanished)\n"; } else { print "syncing groups\n"; } $usercfg->{groups} = {} if !defined($usercfg->{groups}); my $groups = $usercfg->{groups}; my $to_remove = { map { $_ => 1 } split(';', $opts->{'remove-vanished'} // '') }; print "deleting outdated existing groups first\n" if $to_remove->{entry}; foreach my $groupid (sort keys %$groups) { next if $groupid !~ m/\-$realm\z/; next if defined($synced_groups->{$groupid}); if ($to_remove->{entry}) { print "remove group '$groupid'\n"; delete $groups->{$groupid}; } if ($to_remove->{acl}) { print "purge groups '$groupid' ACL entries\n"; PVE::AccessControl::delete_group_acl($groupid, $usercfg); } } foreach my $groupid (sort keys %$synced_groups) { my $synced_group = $synced_groups->{$groupid}; my $oldgroup = $groups->{$groupid}; if ($to_remove->{properties} || !defined($oldgroup)) { if (defined($oldgroup)) { print "overwriting group '$groupid'\n"; } else { print "adding group '$groupid'\n"; } $groups->{$groupid} = $synced_group; } else { foreach my $attr (keys %$synced_group) { $oldgroup->{$attr} = $synced_group->{$attr}; } print "updating group '$groupid'\n"; } } }; my $parse_sync_opts = sub { my ($param, $realmconfig) = @_; my $sync_opts_fmt = PVE::JSONSchema::get_format('realm-sync-options'); my $cfg_defaults = {}; if (defined(my $cfg_opts = $realmconfig->{'sync-defaults-options'})) { $cfg_defaults = PVE::JSONSchema::parse_property_string($sync_opts_fmt, $cfg_opts); } my $res = {}; for my $opt (sort keys %$sync_opts_fmt) { my $fmt = $sync_opts_fmt->{$opt}; $res->{$opt} = $param->{$opt} // $cfg_defaults->{$opt} // $fmt->{default}; } $map_remove_vanished->($res, 1); # only scope has no implicit value raise_param_exc({ "scope" => 'Not passed as parameter and not defined in realm default sync options.', }) if !defined($res->{scope}); return $res; }; __PACKAGE__->register_method({ name => 'sync', path => '{realm}/sync', method => 'POST', permissions => { description => "'Realm.AllocateUser' on '/access/realm/' and " . " 'User.Modify' permissions to '/access/groups/'.", check => [ 'and', ['perm', '/access/realm/{realm}', ['Realm.AllocateUser']], ['perm', '/access/groups', ['User.Modify']], ], }, description => "Syncs users and/or groups from the configured LDAP to user.cfg." . " NOTE: Synced groups will have the name 'name-\$realm', so make sure" . " those groups do not exist to prevent overwriting.", protected => 1, parameters => { additionalProperties => 0, properties => get_standard_option( 'realm-sync-options', { realm => get_standard_option('realm'), 'dry-run' => { description => "If set, does not write anything.", type => 'boolean', optional => 1, default => 0, }, }, ), }, returns => { description => 'Worker Task-UPID', type => 'string', }, code => sub { my ($param) = @_; my $rpcenv = PVE::RPCEnvironment::get(); my $authuser = $rpcenv->get_user(); my $dry_run = extract_param($param, 'dry-run'); my $realm = $param->{realm}; my $cfg = cfs_read_file($domainconfigfile); my $realmconfig = $cfg->{ids}->{$realm}; raise_param_exc({ 'realm' => 'Realm does not exist.' }) if !defined($realmconfig); my $type = $realmconfig->{type}; if ($type ne 'ldap' && $type ne 'ad') { die "Cannot sync realm type '$type'! Only LDAP/AD realms can be synced.\n"; } my $opts = $parse_sync_opts->($param, $realmconfig); # can throw up my $scope = $opts->{scope}; my $whatstring = $scope eq 'both' ? "users and groups" : $scope; my $plugin = PVE::Auth::Plugin->lookup($type); my $worker = sub { print "(dry test run) " if $dry_run; print "starting sync for realm $realm\n"; my ($synced_users, $dnmap) = $plugin->get_users($realmconfig, $realm); my $synced_groups = {}; if ($scope eq 'groups' || $scope eq 'both') { $synced_groups = $plugin->get_groups($realmconfig, $realm, $dnmap); } PVE::AccessControl::lock_user_config( sub { my $usercfg = cfs_read_file("user.cfg"); print "got data from server, updating $whatstring\n"; if ($scope eq 'users' || $scope eq 'both') { $update_users->($usercfg, $realm, $synced_users, $opts); } if ($scope eq 'groups' || $scope eq 'both') { $update_groups->($usercfg, $realm, $synced_groups, $opts); } if ($dry_run) { print "\nNOTE: Dry test run, changes were NOT written to the configuration.\n"; return; } cfs_write_file("user.cfg", $usercfg); print "successfully updated $whatstring configuration\n"; }, "syncing $whatstring failed", ); }; my $workerid = !$dry_run ? 'auth-realm-sync' : 'auth-realm-sync-test'; return $rpcenv->fork_worker($workerid, $realm, $authuser, $worker); }, }); 1;