]> git.proxmox.com Git - pve-access-control.git/blob - src/PVE/API2/Domains.pm
fix #3668: realm-sync: replace 'full' & 'purge' with 'remove-vanished'
[pve-access-control.git] / src / PVE / API2 / Domains.pm
1 package PVE::API2::Domains;
2
3 use strict;
4 use warnings;
5
6 use PVE::Exception qw(raise_param_exc);
7 use PVE::Tools qw(extract_param);
8 use PVE::Cluster qw (cfs_read_file cfs_write_file);
9 use PVE::AccessControl;
10 use PVE::JSONSchema qw(get_standard_option);
11
12 use PVE::SafeSyslog;
13 use PVE::RESTHandler;
14 use PVE::Auth::Plugin;
15
16 my $domainconfigfile = "domains.cfg";
17
18 use base qw(PVE::RESTHandler);
19
20 # maps old 'full'/'purge' parameters to new 'remove-vanished'
21 # TODO remove when we delete the 'full'/'purge' parameters
22 my $map_remove_vanished = sub {
23 my ($opt, $delete_deprecated) = @_;
24
25 if (!defined($opt->{'remove-vanished'}) && ($opt->{full} || $opt->{purge})) {
26 my $props = [];
27 push @$props, 'entry', 'properties' if $opt->{full};
28 push @$props, 'acl' if $opt->{purge};
29 $opt->{'remove-vanished'} = join(';', @$props);
30 }
31
32 if ($delete_deprecated) {
33 delete $opt->{full};
34 delete $opt->{purge};
35 }
36
37 return $opt;
38 };
39
40 my $map_sync_default_options = sub {
41 my ($cfg, $delete_deprecated) = @_;
42
43 my $opt = $cfg->{'sync-defaults-options'};
44 return if !defined($opt);
45 my $sync_opts_fmt = PVE::JSONSchema::get_format('realm-sync-options');
46
47 my $old_opt = PVE::JSONSchema::parse_property_string($sync_opts_fmt, $opt);
48
49 my $new_opt = $map_remove_vanished->($old_opt, $delete_deprecated);
50
51 $cfg->{'sync-defaults-options'} = PVE::JSONSchema::print_property_string($new_opt, $sync_opts_fmt);
52 };
53
54 __PACKAGE__->register_method ({
55 name => 'index',
56 path => '',
57 method => 'GET',
58 description => "Authentication domain index.",
59 permissions => {
60 description => "Anyone can access that, because we need that list for the login box (before the user is authenticated).",
61 user => 'world',
62 },
63 parameters => {
64 additionalProperties => 0,
65 properties => {},
66 },
67 returns => {
68 type => 'array',
69 items => {
70 type => "object",
71 properties => {
72 realm => { type => 'string' },
73 type => { type => 'string' },
74 tfa => {
75 description => "Two-factor authentication provider.",
76 type => 'string',
77 enum => [ 'yubico', 'oath' ],
78 optional => 1,
79 },
80 comment => {
81 description => "A comment. The GUI use this text when you select a domain (Realm) on the login window.",
82 type => 'string',
83 optional => 1,
84 },
85 },
86 },
87 links => [ { rel => 'child', href => "{realm}" } ],
88 },
89 code => sub {
90 my ($param) = @_;
91
92 my $res = [];
93
94 my $cfg = cfs_read_file($domainconfigfile);
95 my $ids = $cfg->{ids};
96
97 foreach my $realm (keys %$ids) {
98 my $d = $ids->{$realm};
99 my $entry = { realm => $realm, type => $d->{type} };
100 $entry->{comment} = $d->{comment} if $d->{comment};
101 $entry->{default} = 1 if $d->{default};
102 if ($d->{tfa} && (my $tfa_cfg = PVE::Auth::Plugin::parse_tfa_config($d->{tfa}))) {
103 $entry->{tfa} = $tfa_cfg->{type};
104 }
105 push @$res, $entry;
106 }
107
108 return $res;
109 }});
110
111 __PACKAGE__->register_method ({
112 name => 'create',
113 protected => 1,
114 path => '',
115 method => 'POST',
116 permissions => {
117 check => ['perm', '/access/realm', ['Realm.Allocate']],
118 },
119 description => "Add an authentication server.",
120 parameters => PVE::Auth::Plugin->createSchema(),
121 returns => { type => 'null' },
122 code => sub {
123 my ($param) = @_;
124
125 # always extract, add it with hook
126 my $password = extract_param($param, 'password');
127
128 PVE::Auth::Plugin::lock_domain_config(
129 sub {
130
131 my $cfg = cfs_read_file($domainconfigfile);
132 my $ids = $cfg->{ids};
133
134 my $realm = extract_param($param, 'realm');
135 my $type = $param->{type};
136
137 die "domain '$realm' already exists\n"
138 if $ids->{$realm};
139
140 die "unable to use reserved name '$realm'\n"
141 if ($realm eq 'pam' || $realm eq 'pve');
142
143 die "unable to create builtin type '$type'\n"
144 if ($type eq 'pam' || $type eq 'pve');
145
146 if ($type eq 'ad' || $type eq 'ldap') {
147 $map_sync_default_options->($param, 1);
148 }
149
150 my $plugin = PVE::Auth::Plugin->lookup($type);
151 my $config = $plugin->check_config($realm, $param, 1, 1);
152
153 if ($config->{default}) {
154 foreach my $r (keys %$ids) {
155 delete $ids->{$r}->{default};
156 }
157 }
158
159 $ids->{$realm} = $config;
160
161 my $opts = $plugin->options();
162 if (defined($password) && !defined($opts->{password})) {
163 $password = undef;
164 warn "ignoring password parameter";
165 }
166 $plugin->on_add_hook($realm, $config, password => $password);
167
168 cfs_write_file($domainconfigfile, $cfg);
169 }, "add auth server failed");
170
171 return undef;
172 }});
173
174 __PACKAGE__->register_method ({
175 name => 'update',
176 path => '{realm}',
177 method => 'PUT',
178 permissions => {
179 check => ['perm', '/access/realm', ['Realm.Allocate']],
180 },
181 description => "Update authentication server settings.",
182 protected => 1,
183 parameters => PVE::Auth::Plugin->updateSchema(),
184 returns => { type => 'null' },
185 code => sub {
186 my ($param) = @_;
187
188 # always extract, update in hook
189 my $password = extract_param($param, 'password');
190
191 PVE::Auth::Plugin::lock_domain_config(
192 sub {
193
194 my $cfg = cfs_read_file($domainconfigfile);
195 my $ids = $cfg->{ids};
196
197 my $digest = extract_param($param, 'digest');
198 PVE::SectionConfig::assert_if_modified($cfg, $digest);
199
200 my $realm = extract_param($param, 'realm');
201
202 die "domain '$realm' does not exist\n"
203 if !$ids->{$realm};
204
205 my $delete_str = extract_param($param, 'delete');
206 die "no options specified\n" if !$delete_str && !scalar(keys %$param);
207
208 my $delete_pw = 0;
209 foreach my $opt (PVE::Tools::split_list($delete_str)) {
210 delete $ids->{$realm}->{$opt};
211 $delete_pw = 1 if $opt eq 'password';
212 }
213
214 my $type = $ids->{$realm}->{type};
215 if ($type eq 'ad' || $type eq 'ldap') {
216 $map_sync_default_options->($param, 1);
217 }
218
219 my $plugin = PVE::Auth::Plugin->lookup($type);
220 my $config = $plugin->check_config($realm, $param, 0, 1);
221
222 if ($config->{default}) {
223 foreach my $r (keys %$ids) {
224 delete $ids->{$r}->{default};
225 }
226 }
227
228 foreach my $p (keys %$config) {
229 $ids->{$realm}->{$p} = $config->{$p};
230 }
231
232 my $opts = $plugin->options();
233 if ($delete_pw || defined($password)) {
234 $plugin->on_update_hook($realm, $config, password => $password);
235 } else {
236 $plugin->on_update_hook($realm, $config);
237 }
238
239 cfs_write_file($domainconfigfile, $cfg);
240 }, "update auth server failed");
241
242 return undef;
243 }});
244
245 # fixme: return format!
246 __PACKAGE__->register_method ({
247 name => 'read',
248 path => '{realm}',
249 method => 'GET',
250 description => "Get auth server configuration.",
251 permissions => {
252 check => ['perm', '/access/realm', ['Realm.Allocate', 'Sys.Audit'], any => 1],
253 },
254 parameters => {
255 additionalProperties => 0,
256 properties => {
257 realm => get_standard_option('realm'),
258 },
259 },
260 returns => {},
261 code => sub {
262 my ($param) = @_;
263
264 my $cfg = cfs_read_file($domainconfigfile);
265
266 my $realm = $param->{realm};
267
268 my $data = $cfg->{ids}->{$realm};
269 die "domain '$realm' does not exist\n" if !$data;
270
271 my $type = $data->{type};
272 if ($type eq 'ad' || $type eq 'ldap') {
273 $map_sync_default_options->($data);
274 }
275
276 $data->{digest} = $cfg->{digest};
277
278 return $data;
279 }});
280
281
282 __PACKAGE__->register_method ({
283 name => 'delete',
284 path => '{realm}',
285 method => 'DELETE',
286 permissions => {
287 check => ['perm', '/access/realm', ['Realm.Allocate']],
288 },
289 description => "Delete an authentication server.",
290 protected => 1,
291 parameters => {
292 additionalProperties => 0,
293 properties => {
294 realm => get_standard_option('realm'),
295 }
296 },
297 returns => { type => 'null' },
298 code => sub {
299 my ($param) = @_;
300
301 PVE::Auth::Plugin::lock_domain_config(
302 sub {
303
304 my $cfg = cfs_read_file($domainconfigfile);
305 my $ids = $cfg->{ids};
306 my $realm = $param->{realm};
307
308 die "authentication domain '$realm' does not exist\n" if !$ids->{$realm};
309
310 my $plugin = PVE::Auth::Plugin->lookup($ids->{$realm}->{type});
311
312 $plugin->on_delete_hook($realm, $ids->{$realm});
313
314 delete $ids->{$realm};
315
316 cfs_write_file($domainconfigfile, $cfg);
317 }, "delete auth server failed");
318
319 return undef;
320 }});
321
322 my $update_users = sub {
323 my ($usercfg, $realm, $synced_users, $opts) = @_;
324
325 print "syncing users\n";
326 print "remove-vanished: $opts->{'remove-vanished'}\n" if defined($opts->{'remove-vanished'});
327
328 $usercfg->{users} = {} if !defined($usercfg->{users});
329 my $users = $usercfg->{users};
330 my $to_remove = { map { $_ => 1 } split(';', $opts->{'remove-vanished'} // '') };
331
332 print "deleting outdated existing users first\n" if $to_remove->{entry};
333 foreach my $userid (sort keys %$users) {
334 next if $userid !~ m/\@$realm$/;
335 next if defined($synced_users->{$userid});
336
337 if ($to_remove->{entry}) {
338 print "remove user '$userid'\n";
339 delete $users->{$userid};
340 }
341
342 if ($to_remove->{acl}) {
343 print "purge users '$userid' ACL entries\n";
344 PVE::AccessControl::delete_user_acl($userid, $usercfg);
345 }
346 }
347
348 foreach my $userid (sort keys %$synced_users) {
349 my $synced_user = $synced_users->{$userid} // {};
350 my $olduser = $users->{$userid};
351 if ($to_remove->{properties} || !defined($olduser)) {
352 # we use the synced user, but want to keep some properties on update
353 if (defined($olduser)) {
354 print "overwriting user '$userid'\n";
355 } else {
356 $olduser = {};
357 print "adding user '$userid'\n";
358 }
359 my $user = $users->{$userid} = $synced_user;
360
361 my $enabled = $olduser->{enable} // $opts->{'enable-new'};
362 $user->{enable} = $enabled if defined($enabled);
363 $user->{tokens} = $olduser->{tokens} if defined($olduser->{tokens});
364
365 } else {
366 foreach my $attr (keys %$synced_user) {
367 $olduser->{$attr} = $synced_user->{$attr};
368 }
369 print "updating user '$userid'\n";
370 }
371 }
372 };
373
374 my $update_groups = sub {
375 my ($usercfg, $realm, $synced_groups, $opts) = @_;
376
377 print "syncing groups\n";
378 print "remove-vanished: $opts->{'remove-vanished'}\n" if defined($opts->{'remove-vanished'});
379
380 $usercfg->{groups} = {} if !defined($usercfg->{groups});
381 my $groups = $usercfg->{groups};
382 my $to_remove = { map { $_ => 1 } split(';', $opts->{'remove-vanished'} // '') };
383
384 print "deleting outdated existing groups first\n" if $to_remove->{entry};
385 foreach my $groupid (sort keys %$groups) {
386 next if $groupid !~ m/\-$realm$/;
387 next if defined($synced_groups->{$groupid});
388
389 if ($to_remove->{entry}) {
390 print "remove group '$groupid'\n";
391 delete $groups->{$groupid};
392 }
393
394 if ($to_remove->{acl}) {
395 print "purge groups '$groupid' ACL entries\n";
396 PVE::AccessControl::delete_group_acl($groupid, $usercfg);
397 }
398 }
399
400 foreach my $groupid (sort keys %$synced_groups) {
401 my $synced_group = $synced_groups->{$groupid};
402 my $oldgroup = $groups->{$groupid};
403 if ($to_remove->{properties} || !defined($oldgroup)) {
404 if (defined($oldgroup)) {
405 print "overwriting group '$groupid'\n";
406 } else {
407 print "adding group '$groupid'\n";
408 }
409 $groups->{$groupid} = $synced_group;
410 } else {
411 foreach my $attr (keys %$synced_group) {
412 $oldgroup->{$attr} = $synced_group->{$attr};
413 }
414 print "updating group '$groupid'\n";
415 }
416 }
417 };
418
419 my $parse_sync_opts = sub {
420 my ($param, $realmconfig) = @_;
421
422 my $sync_opts_fmt = PVE::JSONSchema::get_format('realm-sync-options');
423
424 my $cfg_defaults = {};
425 if (defined(my $cfg_opts = $realmconfig->{'sync-defaults-options'})) {
426 $cfg_defaults = PVE::JSONSchema::parse_property_string($sync_opts_fmt, $cfg_opts);
427 }
428
429 my $res = {};
430 for my $opt (sort keys %$sync_opts_fmt) {
431 my $fmt = $sync_opts_fmt->{$opt};
432
433 $res->{$opt} = $param->{$opt} // $cfg_defaults->{$opt} // $fmt->{default};
434 }
435
436 $map_remove_vanished->($res, 1);
437
438 # only scope has no implicit value
439 raise_param_exc({
440 "scope" => 'Not passed as parameter and not defined in realm default sync options.'
441 }) if !defined($res->{scope});
442
443 return $res;
444 };
445
446 __PACKAGE__->register_method ({
447 name => 'sync',
448 path => '{realm}/sync',
449 method => 'POST',
450 permissions => {
451 description => "'Realm.AllocateUser' on '/access/realm/<realm>' and "
452 ." 'User.Modify' permissions to '/access/groups/'.",
453 check => [ 'and',
454 ['perm', '/access/realm/{realm}', ['Realm.AllocateUser']],
455 ['perm', '/access/groups', ['User.Modify']],
456 ],
457 },
458 description => "Syncs users and/or groups from the configured LDAP to user.cfg."
459 ." NOTE: Synced groups will have the name 'name-\$realm', so make sure"
460 ." those groups do not exist to prevent overwriting.",
461 protected => 1,
462 parameters => {
463 additionalProperties => 0,
464 properties => get_standard_option('realm-sync-options', {
465 realm => get_standard_option('realm'),
466 'dry-run' => {
467 description => "If set, does not write anything.",
468 type => 'boolean',
469 optional => 1,
470 default => 0,
471 },
472 }),
473 },
474 returns => {
475 description => 'Worker Task-UPID',
476 type => 'string'
477 },
478 code => sub {
479 my ($param) = @_;
480
481 my $rpcenv = PVE::RPCEnvironment::get();
482 my $authuser = $rpcenv->get_user();
483
484 my $dry_run = extract_param($param, 'dry-run');
485 my $realm = $param->{realm};
486 my $cfg = cfs_read_file($domainconfigfile);
487 my $realmconfig = $cfg->{ids}->{$realm};
488
489 raise_param_exc({ 'realm' => 'Realm does not exist.' }) if !defined($realmconfig);
490 my $type = $realmconfig->{type};
491
492 if ($type ne 'ldap' && $type ne 'ad') {
493 die "Cannot sync realm type '$type'! Only LDAP/AD realms can be synced.\n";
494 }
495
496 my $opts = $parse_sync_opts->($param, $realmconfig); # can throw up
497
498 my $scope = $opts->{scope};
499 my $whatstring = $scope eq 'both' ? "users and groups" : $scope;
500
501 my $plugin = PVE::Auth::Plugin->lookup($type);
502
503 my $worker = sub {
504 print "(dry test run) " if $dry_run;
505 print "starting sync for realm $realm\n";
506
507 my ($synced_users, $dnmap) = $plugin->get_users($realmconfig, $realm);
508 my $synced_groups = {};
509 if ($scope eq 'groups' || $scope eq 'both') {
510 $synced_groups = $plugin->get_groups($realmconfig, $realm, $dnmap);
511 }
512
513 PVE::AccessControl::lock_user_config(sub {
514 my $usercfg = cfs_read_file("user.cfg");
515 print "got data from server, updating $whatstring\n";
516
517 if ($scope eq 'users' || $scope eq 'both') {
518 $update_users->($usercfg, $realm, $synced_users, $opts);
519 }
520
521 if ($scope eq 'groups' || $scope eq 'both') {
522 $update_groups->($usercfg, $realm, $synced_groups, $opts);
523 }
524
525 if ($dry_run) {
526 print "\nNOTE: Dry test run, changes were NOT written to the configuration.\n";
527 return;
528 }
529 cfs_write_file("user.cfg", $usercfg);
530 print "successfully updated $whatstring configuration\n";
531 }, "syncing $whatstring failed");
532 };
533
534 my $workerid = !$dry_run ? 'auth-realm-sync' : 'auth-realm-sync-test';
535 return $rpcenv->fork_worker($workerid, $realm, $authuser, $worker);
536 }});
537
538 1;