1{ kanidmPackage, pkgs, ... }:
2let
3 certs = import ./common/acme/server/snakeoil-certs.nix;
4 serverDomain = certs.domain;
5
6 # copy certs to store to work around mount namespacing
7 certsPath = pkgs.runCommandNoCC "snakeoil-certs" { } ''
8 mkdir $out
9 cp ${certs."${serverDomain}".cert} $out/snakeoil.crt
10 cp ${certs."${serverDomain}".key} $out/snakeoil.key
11 '';
12
13 provisionAdminPassword = "very-strong-password-for-admin";
14 provisionIdmAdminPassword = "very-strong-password-for-idm-admin";
15 provisionIdmAdminPassword2 = "very-strong-alternative-password-for-idm-admin";
16in
17{
18 name = "kanidm-provisioning-${kanidmPackage.version}";
19 meta.maintainers = with pkgs.lib.maintainers; [ oddlama ];
20
21 _module.args.kanidmPackage = pkgs.lib.mkDefault pkgs.kanidmWithSecretProvisioning;
22
23 nodes.provision =
24 { pkgs, lib, ... }:
25 {
26 services.kanidm = {
27 package = kanidmPackage;
28 enableServer = true;
29 serverSettings = {
30 origin = "https://${serverDomain}";
31 domain = serverDomain;
32 bindaddress = "[::]:443";
33 ldapbindaddress = "[::1]:636";
34 tls_chain = "${certsPath}/snakeoil.crt";
35 tls_key = "${certsPath}/snakeoil.key";
36 };
37 # So we can check whether provisioning did what we wanted
38 enableClient = true;
39 clientSettings = {
40 uri = "https://${serverDomain}";
41 verify_ca = true;
42 verify_hostnames = true;
43 };
44 };
45
46 specialisation.credentialProvision.configuration =
47 { ... }:
48 {
49 services.kanidm.provision = lib.mkForce {
50 enable = true;
51 adminPasswordFile = pkgs.writeText "admin-pw" provisionAdminPassword;
52 idmAdminPasswordFile = pkgs.writeText "idm-admin-pw" provisionIdmAdminPassword;
53 };
54 };
55
56 specialisation.changedCredential.configuration =
57 { ... }:
58 {
59 services.kanidm.provision = lib.mkForce {
60 enable = true;
61 idmAdminPasswordFile = pkgs.writeText "idm-admin-pw" provisionIdmAdminPassword2;
62 };
63 };
64
65 specialisation.addEntities.configuration =
66 { ... }:
67 {
68 services.kanidm.provision = lib.mkForce {
69 enable = true;
70 # Test whether credential recovery works without specific idmAdmin password
71 #idmAdminPasswordFile =
72
73 groups.supergroup1 = {
74 members = [ "testgroup1" ];
75 };
76
77 groups.testgroup1 = { };
78 groups.imperative = {
79 overwriteMembers = false;
80 members = [ "testuser1" ];
81 };
82
83 persons.testuser1 = {
84 displayName = "Test User";
85 legalName = "Jane Doe";
86 mailAddresses = [ "jane.doe@example.com" ];
87 groups = [
88 "testgroup1"
89 "service1-access"
90 ];
91 };
92
93 persons.testuser2 = {
94 displayName = "Powerful Test User";
95 legalName = "Ryouiki Tenkai";
96 groups = [ "service1-admin" ];
97 };
98
99 groups.service1-access = { };
100 groups.service1-admin = { };
101 systems.oauth2.service1 = {
102 displayName = "Service One";
103 originUrl = "https://one.example.com/";
104 originLanding = "https://one.example.com/landing";
105 basicSecretFile = pkgs.writeText "bs-service1" "very-strong-secret-for-service1";
106 scopeMaps.service1-access = [
107 "openid"
108 "email"
109 "profile"
110 ];
111 supplementaryScopeMaps.service1-admin = [ "admin" ];
112 claimMaps.groups = {
113 valuesByGroup.service1-admin = [ "admin" ];
114 };
115 };
116
117 systems.oauth2.service2 = {
118 displayName = "Service Two";
119 originUrl = "https://two.example.com/";
120 originLanding = "https://landing2.example.com/";
121 # Test not setting secret
122 # basicSecretFile =
123 allowInsecureClientDisablePkce = true;
124 preferShortUsername = true;
125 };
126 };
127 };
128
129 specialisation.changeAttributes.configuration =
130 { ... }:
131 {
132 services.kanidm.provision = lib.mkForce {
133 enable = true;
134 # Changing admin credentials at any time should not be a problem:
135 idmAdminPasswordFile = pkgs.writeText "idm-admin-pw" provisionIdmAdminPassword;
136
137 groups.supergroup1 = {
138 #members = ["testgroup1"];
139 };
140
141 groups.testgroup1 = { };
142 groups.imperative = {
143 overwriteMembers = false;
144 # Will be retained:
145 # members = [ "testuser1" ];
146 };
147
148 persons.testuser1 = {
149 displayName = "Test User (changed)";
150 legalName = "Jane Doe (changed)";
151 mailAddresses = [
152 "jane.doe@example.com"
153 "second.doe@example.com"
154 ];
155 groups = [
156 #"testgroup1"
157 "service1-access"
158 ];
159 };
160
161 persons.testuser2 = {
162 displayName = "Powerful Test User (changed)";
163 legalName = "Ryouiki Tenkai (changed)";
164 groups = [ "service1-admin" ];
165 };
166
167 groups.service1-access = { };
168 groups.service1-admin = { };
169 systems.oauth2.service1 = {
170 displayName = "Service One (changed)";
171 # multiple origin urls
172 originUrl = [
173 "https://changed-one.example.com/"
174 "https://changed-one.example.org/"
175 ];
176 originLanding = "https://changed-one.example.com/landing-changed";
177 basicSecretFile = pkgs.writeText "bs-service1" "changed-very-strong-secret-for-service1";
178 scopeMaps.service1-access = [
179 "openid"
180 "email"
181 #"profile"
182 ];
183 supplementaryScopeMaps.service1-admin = [ "adminchanged" ];
184 claimMaps.groups = {
185 valuesByGroup.service1-admin = [ "adminchanged" ];
186 };
187 };
188
189 systems.oauth2.service2 = {
190 displayName = "Service Two (changed)";
191 originUrl = "https://changed-two.example.com/";
192 originLanding = "https://changed-landing2.example.com/";
193 # Test not setting secret
194 # basicSecretFile =
195 allowInsecureClientDisablePkce = false;
196 preferShortUsername = false;
197 };
198 };
199 };
200
201 specialisation.removeAttributes.configuration =
202 { ... }:
203 {
204 services.kanidm.provision = lib.mkForce {
205 enable = true;
206 idmAdminPasswordFile = pkgs.writeText "idm-admin-pw" provisionIdmAdminPassword;
207
208 groups.supergroup1 = { };
209
210 persons.testuser1 = {
211 displayName = "Test User (changed)";
212 };
213
214 persons.testuser2 = {
215 displayName = "Powerful Test User (changed)";
216 groups = [ "service1-admin" ];
217 };
218
219 groups.service1-access = { };
220 groups.service1-admin = { };
221 systems.oauth2.service1 = {
222 displayName = "Service One (changed)";
223 originUrl = "https://changed-one.example.com/";
224 originLanding = "https://changed-one.example.com/landing-changed";
225 basicSecretFile = pkgs.writeText "bs-service1" "changed-very-strong-secret-for-service1";
226 # Removing maps requires setting them to the empty list
227 scopeMaps.service1-access = [ ];
228 supplementaryScopeMaps.service1-admin = [ ];
229 };
230
231 systems.oauth2.service2 = {
232 displayName = "Service Two (changed)";
233 originUrl = "https://changed-two.example.com/";
234 originLanding = "https://changed-landing2.example.com/";
235 };
236 };
237 };
238
239 specialisation.removeEntities.configuration =
240 { ... }:
241 {
242 services.kanidm.provision = lib.mkForce {
243 enable = true;
244 idmAdminPasswordFile = pkgs.writeText "idm-admin-pw" provisionIdmAdminPassword;
245 };
246 };
247
248 specialisation.extraJsonFile.configuration =
249 { ... }:
250 {
251 services.kanidm.provision = lib.mkForce {
252 enable = true;
253 idmAdminPasswordFile = pkgs.writeText "idm-admin-pw" provisionIdmAdminPassword;
254
255 extraJsonFile = pkgs.writeText "extra-json.json" (
256 builtins.toJSON {
257 persons.testuser2.displayName = "Test User 2";
258 groups.testgroup1.members = [ "testuser2" ];
259 }
260 );
261
262 groups.testgroup1 = { };
263
264 persons.testuser1 = {
265 displayName = "Test User 1";
266 groups = [ "testgroup1" ];
267 };
268 };
269 };
270
271 security.pki.certificateFiles = [ certs.ca.cert ];
272
273 networking.hosts."::1" = [ serverDomain ];
274 networking.firewall.allowedTCPPorts = [ 443 ];
275
276 users.users.kanidm.shell = pkgs.bashInteractive;
277
278 environment.systemPackages = [
279 kanidmPackage
280 pkgs.openldap
281 pkgs.ripgrep
282 pkgs.jq
283 ];
284 };
285
286 testScript =
287 { nodes, ... }:
288 let
289 # We need access to the config file in the test script.
290 filteredConfig = pkgs.lib.converge (pkgs.lib.filterAttrsRecursive (
291 _: v: v != null
292 )) nodes.provision.services.kanidm.serverSettings;
293 serverConfigFile = (pkgs.formats.toml { }).generate "server.toml" filteredConfig;
294
295 specialisations = "${nodes.provision.system.build.toplevel}/specialisation";
296 in
297 ''
298 import re
299
300 def assert_contains(haystack, needle):
301 if needle not in haystack:
302 print("The haystack that will cause the following exception is:")
303 print("---")
304 print(haystack)
305 print("---")
306 raise Exception(f"Expected string '{needle}' was not found")
307
308 def assert_matches(haystack, expr):
309 if not re.search(expr, haystack):
310 print("The haystack that will cause the following exception is:")
311 print("---")
312 print(haystack)
313 print("---")
314 raise Exception(f"Expected regex '{expr}' did not match")
315
316 def assert_lacks(haystack, needle):
317 if needle in haystack:
318 print("The haystack that will cause the following exception is:")
319 print("---")
320 print(haystack, end="")
321 print("---")
322 raise Exception(f"Unexpected string '{needle}' was found")
323
324 provision.start()
325
326 def provision_login(pw):
327 provision.wait_for_unit("kanidm.service")
328 provision.wait_until_succeeds("curl -Lsf https://${serverDomain} | grep Kanidm")
329 if pw is None:
330 pw = provision.succeed("su - kanidm -c 'kanidmd recover-account -c ${serverConfigFile} idm_admin 2>&1 | rg -o \'[A-Za-z0-9]{48}\' '").strip().removeprefix("'").removesuffix("'")
331 out = provision.succeed(f"KANIDM_PASSWORD={pw} kanidm login -D idm_admin")
332 assert_contains(out, "Login Success for idm_admin")
333
334 with subtest("Test Provisioning - setup"):
335 provision_login(None)
336 provision.succeed("kanidm logout -D idm_admin")
337
338 with subtest("Test Provisioning - credentialProvision"):
339 provision.succeed('${specialisations}/credentialProvision/bin/switch-to-configuration test')
340 provision_login("${provisionIdmAdminPassword}")
341
342 # Make sure neither password is logged
343 provision.fail("journalctl --since -10m --unit kanidm.service --grep '${provisionAdminPassword}'")
344 provision.fail("journalctl --since -10m --unit kanidm.service --grep '${provisionIdmAdminPassword}'")
345
346 # Test provisioned admin pw
347 out = provision.succeed("KANIDM_PASSWORD=${provisionAdminPassword} kanidm login -D admin")
348 assert_contains(out, "Login Success for admin")
349 provision.succeed("kanidm logout -D admin")
350 provision.succeed("kanidm logout -D idm_admin")
351
352 with subtest("Test Provisioning - changedCredential"):
353 provision.succeed('${specialisations}/changedCredential/bin/switch-to-configuration test')
354 provision_login("${provisionIdmAdminPassword2}")
355 provision.succeed("kanidm logout -D idm_admin")
356
357 with subtest("Test Provisioning - addEntities"):
358 provision.succeed('${specialisations}/addEntities/bin/switch-to-configuration test')
359 # Unspecified idm admin password
360 provision_login(None)
361
362 out = provision.succeed("kanidm group get testgroup1")
363 assert_contains(out, "name: testgroup1")
364
365 out = provision.succeed("kanidm group get imperative")
366 assert_contains(out, "name: imperative")
367 assert_contains(out, "member: testuser1")
368
369 out = provision.succeed("kanidm group get supergroup1")
370 assert_contains(out, "name: supergroup1")
371 assert_contains(out, "member: testgroup1")
372
373 out = provision.succeed("kanidm person get testuser1")
374 assert_contains(out, "name: testuser1")
375 assert_contains(out, "displayname: Test User")
376 assert_contains(out, "legalname: Jane Doe")
377 assert_contains(out, "mail: jane.doe@example.com")
378 assert_contains(out, "memberof: testgroup1")
379 assert_contains(out, "memberof: imperative")
380 assert_contains(out, "memberof: service1-access")
381
382 out = provision.succeed("kanidm person get testuser2")
383 assert_contains(out, "name: testuser2")
384 assert_contains(out, "displayname: Powerful Test User")
385 assert_contains(out, "legalname: Ryouiki Tenkai")
386 assert_contains(out, "memberof: service1-admin")
387 assert_lacks(out, "mail:")
388
389 out = provision.succeed("kanidm group get service1-access")
390 assert_contains(out, "name: service1-access")
391
392 out = provision.succeed("kanidm group get service1-admin")
393 assert_contains(out, "name: service1-admin")
394
395 out = provision.succeed("kanidm system oauth2 get service1")
396 assert_contains(out, "name: service1")
397 assert_contains(out, "displayname: Service One")
398 assert_contains(out, "oauth2_rs_origin: https://one.example.com/")
399 assert_contains(out, "oauth2_rs_origin_landing: https://one.example.com/landing")
400 assert_matches(out, 'oauth2_rs_scope_map: service1-access.*{"email", "openid", "profile"}')
401 assert_matches(out, 'oauth2_rs_sup_scope_map: service1-admin.*{"admin"}')
402 assert_matches(out, 'oauth2_rs_claim_map: groups:.*"admin"')
403
404 out = provision.succeed("kanidm system oauth2 show-basic-secret service1")
405 assert_contains(out, "very-strong-secret-for-service1")
406
407 out = provision.succeed("kanidm system oauth2 get service2")
408 assert_contains(out, "name: service2")
409 assert_contains(out, "displayname: Service Two")
410 assert_contains(out, "oauth2_rs_origin: https://two.example.com/")
411 assert_contains(out, "oauth2_rs_origin_landing: https://landing2.example.com/")
412 assert_contains(out, "oauth2_allow_insecure_client_disable_pkce: true")
413 assert_contains(out, "oauth2_prefer_short_username: true")
414
415 provision.succeed("kanidm logout -D idm_admin")
416
417 with subtest("Test Provisioning - changeAttributes"):
418 provision.succeed('${specialisations}/changeAttributes/bin/switch-to-configuration test')
419 provision_login("${provisionIdmAdminPassword}")
420
421 out = provision.succeed("kanidm group get testgroup1")
422 assert_contains(out, "name: testgroup1")
423
424 out = provision.succeed("kanidm group get imperative")
425 assert_contains(out, "name: imperative")
426 assert_contains(out, "member: testuser1")
427
428 out = provision.succeed("kanidm group get supergroup1")
429 assert_contains(out, "name: supergroup1")
430 assert_lacks(out, "member: testgroup1")
431
432 out = provision.succeed("kanidm person get testuser1")
433 assert_contains(out, "name: testuser1")
434 assert_contains(out, "displayname: Test User (changed)")
435 assert_contains(out, "legalname: Jane Doe (changed)")
436 assert_contains(out, "mail: jane.doe@example.com")
437 assert_contains(out, "mail: second.doe@example.com")
438 assert_lacks(out, "memberof: testgroup1")
439 assert_contains(out, "memberof: imperative")
440 assert_contains(out, "memberof: service1-access")
441
442 out = provision.succeed("kanidm person get testuser2")
443 assert_contains(out, "name: testuser2")
444 assert_contains(out, "displayname: Powerful Test User (changed)")
445 assert_contains(out, "legalname: Ryouiki Tenkai (changed)")
446 assert_contains(out, "memberof: service1-admin")
447 assert_lacks(out, "mail:")
448
449 out = provision.succeed("kanidm group get service1-access")
450 assert_contains(out, "name: service1-access")
451
452 out = provision.succeed("kanidm group get service1-admin")
453 assert_contains(out, "name: service1-admin")
454
455 out = provision.succeed("kanidm system oauth2 get service1")
456 assert_contains(out, "name: service1")
457 assert_contains(out, "displayname: Service One (changed)")
458 assert_contains(out, "oauth2_rs_origin: https://changed-one.example.com/")
459 assert_contains(out, "oauth2_rs_origin: https://changed-one.example.org/")
460 assert_contains(out, "oauth2_rs_origin_landing: https://changed-one.example.com/landing")
461 assert_matches(out, 'oauth2_rs_scope_map: service1-access.*{"email", "openid"}')
462 assert_matches(out, 'oauth2_rs_sup_scope_map: service1-admin.*{"adminchanged"}')
463 assert_matches(out, 'oauth2_rs_claim_map: groups:.*"adminchanged"')
464
465 out = provision.succeed("kanidm system oauth2 show-basic-secret service1")
466 assert_contains(out, "changed-very-strong-secret-for-service1")
467
468 out = provision.succeed("kanidm system oauth2 get service2")
469 assert_contains(out, "name: service2")
470 assert_contains(out, "displayname: Service Two (changed)")
471 assert_contains(out, "oauth2_rs_origin: https://changed-two.example.com/")
472 assert_contains(out, "oauth2_rs_origin_landing: https://changed-landing2.example.com/")
473 assert_lacks(out, "oauth2_allow_insecure_client_disable_pkce: true")
474 assert_lacks(out, "oauth2_prefer_short_username: true")
475
476 provision.succeed("kanidm logout -D idm_admin")
477
478 with subtest("Test Provisioning - removeAttributes"):
479 provision.succeed('${specialisations}/removeAttributes/bin/switch-to-configuration test')
480 provision_login("${provisionIdmAdminPassword}")
481
482 out = provision.succeed("kanidm group get testgroup1")
483 assert_lacks(out, "name: testgroup1")
484
485 out = provision.succeed("kanidm group get supergroup1")
486 assert_contains(out, "name: supergroup1")
487 assert_lacks(out, "member: testgroup1")
488
489 out = provision.succeed("kanidm person get testuser1")
490 assert_contains(out, "name: testuser1")
491 assert_contains(out, "displayname: Test User (changed)")
492 assert_lacks(out, "legalname: Jane Doe (changed)")
493 assert_lacks(out, "mail: jane.doe@example.com")
494 assert_lacks(out, "mail: second.doe@example.com")
495 assert_lacks(out, "memberof: testgroup1")
496 assert_lacks(out, "memberof: service1-access")
497
498 out = provision.succeed("kanidm person get testuser2")
499 assert_contains(out, "name: testuser2")
500 assert_contains(out, "displayname: Powerful Test User (changed)")
501 assert_lacks(out, "legalname: Ryouiki Tenkai (changed)")
502 assert_contains(out, "memberof: service1-admin")
503 assert_lacks(out, "mail:")
504
505 out = provision.succeed("kanidm group get service1-access")
506 assert_contains(out, "name: service1-access")
507
508 out = provision.succeed("kanidm group get service1-admin")
509 assert_contains(out, "name: service1-admin")
510
511 out = provision.succeed("kanidm system oauth2 get service1")
512 assert_contains(out, "name: service1")
513 assert_contains(out, "displayname: Service One (changed)")
514 assert_contains(out, "oauth2_rs_origin: https://changed-one.example.com/")
515 assert_lacks(out, "oauth2_rs_origin: https://changed-one.example.org/")
516 assert_contains(out, "oauth2_rs_origin_landing: https://changed-one.example.com/landing")
517 assert_lacks(out, "oauth2_rs_scope_map")
518 assert_lacks(out, "oauth2_rs_sup_scope_map")
519 assert_lacks(out, "oauth2_rs_claim_map")
520
521 out = provision.succeed("kanidm system oauth2 show-basic-secret service1")
522 assert_contains(out, "changed-very-strong-secret-for-service1")
523
524 out = provision.succeed("kanidm system oauth2 get service2")
525 assert_contains(out, "name: service2")
526 assert_contains(out, "displayname: Service Two (changed)")
527 assert_contains(out, "oauth2_rs_origin: https://changed-two.example.com/")
528 assert_contains(out, "oauth2_rs_origin_landing: https://changed-landing2.example.com/")
529 assert_lacks(out, "oauth2_allow_insecure_client_disable_pkce: true")
530 assert_lacks(out, "oauth2_prefer_short_username: true")
531
532 provision.succeed("kanidm logout -D idm_admin")
533
534 with subtest("Test Provisioning - removeEntities"):
535 provision.succeed('${specialisations}/removeEntities/bin/switch-to-configuration test')
536 provision_login("${provisionIdmAdminPassword}")
537
538 out = provision.succeed("kanidm group get testgroup1")
539 assert_lacks(out, "name: testgroup1")
540
541 out = provision.succeed("kanidm group get supergroup1")
542 assert_lacks(out, "name: supergroup1")
543
544 out = provision.succeed("kanidm person get testuser1")
545 assert_lacks(out, "name: testuser1")
546
547 out = provision.succeed("kanidm person get testuser2")
548 assert_lacks(out, "name: testuser2")
549
550 out = provision.succeed("kanidm group get service1-access")
551 assert_lacks(out, "name: service1-access")
552
553 out = provision.succeed("kanidm group get service1-admin")
554 assert_lacks(out, "name: service1-admin")
555
556 out = provision.succeed("kanidm system oauth2 get service1")
557 assert_lacks(out, "name: service1")
558
559 out = provision.succeed("kanidm system oauth2 get service2")
560 assert_lacks(out, "name: service2")
561
562 provision.succeed("kanidm logout -D idm_admin")
563
564 with subtest("Test Provisioning - extraJsonFile"):
565 provision.succeed('${specialisations}/extraJsonFile/bin/switch-to-configuration test')
566 provision_login("${provisionIdmAdminPassword}")
567
568 out = provision.succeed("kanidm group get testgroup1")
569 assert_contains(out, "name: testgroup1")
570
571 out = provision.succeed("kanidm person get testuser1")
572 assert_contains(out, "name: testuser1")
573
574 out = provision.succeed("kanidm person get testuser2")
575 assert_contains(out, "name: testuser2")
576
577 out = provision.succeed("kanidm group get testgroup1")
578 assert_contains(out, "member: testuser1")
579 assert_contains(out, "member: testuser2")
580
581 provision.succeed("kanidm logout -D idm_admin")
582 '';
583}