1{ config, lib, options, pkgs, ... }:
2
3with lib;
4
5let
6 cfg = config.services.privacyidea;
7 opt = options.services.privacyidea;
8
9 uwsgi = pkgs.uwsgi.override { plugins = [ "python3" ]; python3 = pkgs.python310; };
10 python = uwsgi.python3;
11 penv = python.withPackages (const [ pkgs.privacyidea ]);
12 logCfg = pkgs.writeText "privacyidea-log.cfg" ''
13 [formatters]
14 keys=detail
15
16 [handlers]
17 keys=stream
18
19 [formatter_detail]
20 class=privacyidea.lib.log.SecureFormatter
21 format=[%(asctime)s][%(process)d][%(thread)d][%(levelname)s][%(name)s:%(lineno)d] %(message)s
22
23 [handler_stream]
24 class=StreamHandler
25 level=NOTSET
26 formatter=detail
27 args=(sys.stdout,)
28
29 [loggers]
30 keys=root,privacyidea
31
32 [logger_privacyidea]
33 handlers=stream
34 qualname=privacyidea
35 level=INFO
36
37 [logger_root]
38 handlers=stream
39 level=ERROR
40 '';
41
42 piCfgFile = pkgs.writeText "privacyidea.cfg" ''
43 SUPERUSER_REALM = [ '${concatStringsSep "', '" cfg.superuserRealm}' ]
44 SQLALCHEMY_DATABASE_URI = 'postgresql+psycopg2:///privacyidea'
45 SECRET_KEY = '${cfg.secretKey}'
46 PI_PEPPER = '${cfg.pepper}'
47 PI_ENCFILE = '${cfg.encFile}'
48 PI_AUDIT_KEY_PRIVATE = '${cfg.auditKeyPrivate}'
49 PI_AUDIT_KEY_PUBLIC = '${cfg.auditKeyPublic}'
50 PI_LOGCONFIG = '${logCfg}'
51 ${cfg.extraConfig}
52 '';
53
54 renderValue = x:
55 if isList x then concatMapStringsSep "," (x: ''"${x}"'') x
56 else if isString x && hasInfix "," x then ''"${x}"''
57 else x;
58
59 ldapProxyConfig = pkgs.writeText "ldap-proxy.ini"
60 (generators.toINI {}
61 (flip mapAttrs cfg.ldap-proxy.settings
62 (const (mapAttrs (const renderValue)))));
63
64 privacyidea-token-janitor = pkgs.writeShellScriptBin "privacyidea-token-janitor" ''
65 exec -a privacyidea-token-janitor \
66 /run/wrappers/bin/sudo -u ${cfg.user} \
67 env PRIVACYIDEA_CONFIGFILE=${cfg.stateDir}/privacyidea.cfg \
68 ${penv}/bin/privacyidea-token-janitor $@
69 '';
70in
71
72{
73 options = {
74 services.privacyidea = {
75 enable = mkEnableOption (lib.mdDoc "PrivacyIDEA");
76
77 environmentFile = mkOption {
78 type = types.nullOr types.path;
79 default = null;
80 example = "/root/privacyidea.env";
81 description = lib.mdDoc ''
82 File to load as environment file. Environment variables
83 from this file will be interpolated into the config file
84 using `envsubst` which is helpful for specifying
85 secrets:
86 ```
87 { services.privacyidea.secretKey = "$SECRET"; }
88 ```
89
90 The environment-file can now specify the actual secret key:
91 ```
92 SECRET=veryverytopsecret
93 ```
94 '';
95 };
96
97 stateDir = mkOption {
98 type = types.str;
99 default = "/var/lib/privacyidea";
100 description = lib.mdDoc ''
101 Directory where all PrivacyIDEA files will be placed by default.
102 '';
103 };
104
105 superuserRealm = mkOption {
106 type = types.listOf types.str;
107 default = [ "super" "administrators" ];
108 description = lib.mdDoc ''
109 The realm where users are allowed to login as administrators.
110 '';
111 };
112
113 secretKey = mkOption {
114 type = types.str;
115 example = "t0p s3cr3t";
116 description = lib.mdDoc ''
117 This is used to encrypt the auth_token.
118 '';
119 };
120
121 pepper = mkOption {
122 type = types.str;
123 example = "Never know...";
124 description = lib.mdDoc ''
125 This is used to encrypt the admin passwords.
126 '';
127 };
128
129 encFile = mkOption {
130 type = types.str;
131 default = "${cfg.stateDir}/enckey";
132 defaultText = literalExpression ''"''${config.${opt.stateDir}}/enckey"'';
133 description = lib.mdDoc ''
134 This is used to encrypt the token data and token passwords
135 '';
136 };
137
138 auditKeyPrivate = mkOption {
139 type = types.str;
140 default = "${cfg.stateDir}/private.pem";
141 defaultText = literalExpression ''"''${config.${opt.stateDir}}/private.pem"'';
142 description = lib.mdDoc ''
143 Private Key for signing the audit log.
144 '';
145 };
146
147 auditKeyPublic = mkOption {
148 type = types.str;
149 default = "${cfg.stateDir}/public.pem";
150 defaultText = literalExpression ''"''${config.${opt.stateDir}}/public.pem"'';
151 description = lib.mdDoc ''
152 Public key for checking signatures of the audit log.
153 '';
154 };
155
156 adminPasswordFile = mkOption {
157 type = types.path;
158 description = lib.mdDoc "File containing password for the admin user";
159 };
160
161 adminEmail = mkOption {
162 type = types.str;
163 example = "admin@example.com";
164 description = lib.mdDoc "Mail address for the admin user";
165 };
166
167 extraConfig = mkOption {
168 type = types.lines;
169 default = "";
170 description = lib.mdDoc ''
171 Extra configuration options for pi.cfg.
172 '';
173 };
174
175 user = mkOption {
176 type = types.str;
177 default = "privacyidea";
178 description = lib.mdDoc "User account under which PrivacyIDEA runs.";
179 };
180
181 group = mkOption {
182 type = types.str;
183 default = "privacyidea";
184 description = lib.mdDoc "Group account under which PrivacyIDEA runs.";
185 };
186
187 tokenjanitor = {
188 enable = mkEnableOption (lib.mdDoc "automatic runs of the token janitor");
189 interval = mkOption {
190 default = "quarterly";
191 type = types.str;
192 description = lib.mdDoc ''
193 Interval in which the cleanup program is supposed to run.
194 See {manpage}`systemd.time(7)` for further information.
195 '';
196 };
197 action = mkOption {
198 type = types.enum [ "delete" "mark" "disable" "unassign" ];
199 description = lib.mdDoc ''
200 Which action to take for matching tokens.
201 '';
202 };
203 unassigned = mkOption {
204 default = false;
205 type = types.bool;
206 description = lib.mdDoc ''
207 Whether to search for **unassigned** tokens
208 and apply [](#opt-services.privacyidea.tokenjanitor.action)
209 onto them.
210 '';
211 };
212 orphaned = mkOption {
213 default = true;
214 type = types.bool;
215 description = lib.mdDoc ''
216 Whether to search for **orphaned** tokens
217 and apply [](#opt-services.privacyidea.tokenjanitor.action)
218 onto them.
219 '';
220 };
221 };
222
223 ldap-proxy = {
224 enable = mkEnableOption (lib.mdDoc "PrivacyIDEA LDAP Proxy");
225
226 configFile = mkOption {
227 type = types.nullOr types.path;
228 default = null;
229 description = lib.mdDoc ''
230 Path to PrivacyIDEA LDAP Proxy configuration (proxy.ini).
231 '';
232 };
233
234 user = mkOption {
235 type = types.str;
236 default = "pi-ldap-proxy";
237 description = lib.mdDoc "User account under which PrivacyIDEA LDAP proxy runs.";
238 };
239
240 group = mkOption {
241 type = types.str;
242 default = "pi-ldap-proxy";
243 description = lib.mdDoc "Group account under which PrivacyIDEA LDAP proxy runs.";
244 };
245
246 settings = mkOption {
247 type = with types; attrsOf (attrsOf (oneOf [ str bool int (listOf str) ]));
248 default = {};
249 description = lib.mdDoc ''
250 Attribute-set containing the settings for `privacyidea-ldap-proxy`.
251 It's possible to pass secrets using env-vars as substitutes and
252 use the option [](#opt-services.privacyidea.ldap-proxy.environmentFile)
253 to inject them via `envsubst`.
254 '';
255 };
256
257 environmentFile = mkOption {
258 default = null;
259 type = types.nullOr types.str;
260 description = lib.mdDoc ''
261 Environment file containing secrets to be substituted into
262 [](#opt-services.privacyidea.ldap-proxy.settings).
263 '';
264 };
265 };
266 };
267 };
268
269 config = mkMerge [
270
271 (mkIf cfg.enable {
272
273 assertions = [
274 {
275 assertion = cfg.tokenjanitor.enable -> (cfg.tokenjanitor.orphaned || cfg.tokenjanitor.unassigned);
276 message = ''
277 privacyidea-token-janitor has no effect if neither orphaned nor unassigned tokens
278 are to be searched.
279 '';
280 }
281 ];
282
283 environment.systemPackages = [ pkgs.privacyidea (hiPrio privacyidea-token-janitor) ];
284
285 services.postgresql.enable = mkDefault true;
286
287 systemd.services.privacyidea-tokenjanitor = mkIf cfg.tokenjanitor.enable {
288 environment.PRIVACYIDEA_CONFIGFILE = "${cfg.stateDir}/privacyidea.cfg";
289 path = [ penv ];
290 serviceConfig = {
291 CapabilityBoundingSet = [ "" ];
292 ExecStart = "${pkgs.writeShellScript "pi-token-janitor" ''
293 ${optionalString cfg.tokenjanitor.orphaned ''
294 echo >&2 "Removing orphaned tokens..."
295 privacyidea-token-janitor find \
296 --orphaned true \
297 --action ${cfg.tokenjanitor.action}
298 ''}
299 ${optionalString cfg.tokenjanitor.unassigned ''
300 echo >&2 "Removing unassigned tokens..."
301 privacyidea-token-janitor find \
302 --assigned false \
303 --action ${cfg.tokenjanitor.action}
304 ''}
305 ''}";
306 Group = cfg.group;
307 LockPersonality = true;
308 MemoryDenyWriteExecute = true;
309 ProtectHome = true;
310 ProtectHostname = true;
311 ProtectKernelLogs = true;
312 ProtectKernelModules = true;
313 ProtectKernelTunables = true;
314 ProtectSystem = "strict";
315 ReadWritePaths = cfg.stateDir;
316 Type = "oneshot";
317 User = cfg.user;
318 WorkingDirectory = cfg.stateDir;
319 };
320 };
321 systemd.timers.privacyidea-tokenjanitor = mkIf cfg.tokenjanitor.enable {
322 wantedBy = [ "timers.target" ];
323 timerConfig.OnCalendar = cfg.tokenjanitor.interval;
324 timerConfig.Persistent = true;
325 };
326
327 systemd.services.privacyidea = let
328 piuwsgi = pkgs.writeText "uwsgi.json" (builtins.toJSON {
329 uwsgi = {
330 buffer-size = 8192;
331 plugins = [ "python3" ];
332 pythonpath = "${penv}/${uwsgi.python3.sitePackages}";
333 socket = "/run/privacyidea/socket";
334 uid = cfg.user;
335 gid = cfg.group;
336 chmod-socket = 770;
337 chown-socket = "${cfg.user}:nginx";
338 chdir = cfg.stateDir;
339 wsgi-file = "${penv}/etc/privacyidea/privacyideaapp.wsgi";
340 processes = 4;
341 harakiri = 60;
342 reload-mercy = 8;
343 stats = "/run/privacyidea/stats.socket";
344 max-requests = 2000;
345 limit-as = 1024;
346 reload-on-as = 512;
347 reload-on-rss = 256;
348 no-orphans = true;
349 vacuum = true;
350 };
351 });
352 in {
353 wantedBy = [ "multi-user.target" ];
354 after = [ "postgresql.service" ];
355 path = with pkgs; [ openssl ];
356 environment.PRIVACYIDEA_CONFIGFILE = "${cfg.stateDir}/privacyidea.cfg";
357 preStart = let
358 pi-manage = "${config.security.sudo.package}/bin/sudo -u privacyidea -HE ${penv}/bin/pi-manage";
359 pgsu = config.services.postgresql.superUser;
360 psql = config.services.postgresql.package;
361 in ''
362 mkdir -p ${cfg.stateDir} /run/privacyidea
363 chown ${cfg.user}:${cfg.group} -R ${cfg.stateDir} /run/privacyidea
364 umask 077
365 ${lib.getBin pkgs.envsubst}/bin/envsubst -o ${cfg.stateDir}/privacyidea.cfg \
366 -i "${piCfgFile}"
367 chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/privacyidea.cfg
368 if ! test -e "${cfg.stateDir}/db-created"; then
369 ${config.security.sudo.package}/bin/sudo -u ${pgsu} ${psql}/bin/createuser --no-superuser --no-createdb --no-createrole ${cfg.user}
370 ${config.security.sudo.package}/bin/sudo -u ${pgsu} ${psql}/bin/createdb --owner ${cfg.user} privacyidea
371 ${pi-manage} create_enckey
372 ${pi-manage} create_audit_keys
373 ${pi-manage} createdb
374 ${pi-manage} admin add admin -e ${cfg.adminEmail} -p "$(cat ${cfg.adminPasswordFile})"
375 ${pi-manage} db stamp head -d ${penv}/lib/privacyidea/migrations
376 touch "${cfg.stateDir}/db-created"
377 chmod g+r "${cfg.stateDir}/enckey" "${cfg.stateDir}/private.pem"
378 fi
379 ${pi-manage} db upgrade -d ${penv}/lib/privacyidea/migrations
380 '';
381 serviceConfig = {
382 Type = "notify";
383 ExecStart = "${uwsgi}/bin/uwsgi --json ${piuwsgi}";
384 ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
385 EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile;
386 ExecStop = "${pkgs.coreutils}/bin/kill -INT $MAINPID";
387 NotifyAccess = "main";
388 KillSignal = "SIGQUIT";
389 };
390 };
391
392 users.users.privacyidea = mkIf (cfg.user == "privacyidea") {
393 group = cfg.group;
394 isSystemUser = true;
395 };
396
397 users.groups.privacyidea = mkIf (cfg.group == "privacyidea") {};
398 })
399
400 (mkIf cfg.ldap-proxy.enable {
401
402 assertions = [
403 { assertion = let
404 xor = a: b: a && !b || !a && b;
405 in xor (cfg.ldap-proxy.settings == {}) (cfg.ldap-proxy.configFile == null);
406 message = "configFile & settings are mutually exclusive for services.privacyidea.ldap-proxy!";
407 }
408 ];
409
410 warnings = mkIf (cfg.ldap-proxy.configFile != null) [
411 "Using services.privacyidea.ldap-proxy.configFile is deprecated! Use the RFC42-style settings option instead!"
412 ];
413
414 systemd.services.privacyidea-ldap-proxy = let
415 ldap-proxy-env = pkgs.python3.withPackages (ps: [ ps.privacyidea-ldap-proxy ]);
416 in {
417 description = "privacyIDEA LDAP proxy";
418 wantedBy = [ "multi-user.target" ];
419 serviceConfig = {
420 User = cfg.ldap-proxy.user;
421 Group = cfg.ldap-proxy.group;
422 StateDirectory = "privacyidea-ldap-proxy";
423 EnvironmentFile = mkIf (cfg.ldap-proxy.environmentFile != null)
424 [ cfg.ldap-proxy.environmentFile ];
425 ExecStartPre =
426 "${pkgs.writeShellScript "substitute-secrets-ldap-proxy" ''
427 umask 0077
428 ${pkgs.envsubst}/bin/envsubst \
429 -i ${ldapProxyConfig} \
430 -o $STATE_DIRECTORY/ldap-proxy.ini
431 ''}";
432 ExecStart = let
433 configPath = if cfg.ldap-proxy.settings != {}
434 then "%S/privacyidea-ldap-proxy/ldap-proxy.ini"
435 else cfg.ldap-proxy.configFile;
436 in ''
437 ${ldap-proxy-env}/bin/twistd \
438 --nodaemon \
439 --pidfile= \
440 -u ${cfg.ldap-proxy.user} \
441 -g ${cfg.ldap-proxy.group} \
442 ldap-proxy \
443 -c ${configPath}
444 '';
445 Restart = "always";
446 };
447 };
448
449 users.users.pi-ldap-proxy = mkIf (cfg.ldap-proxy.user == "pi-ldap-proxy") {
450 group = cfg.ldap-proxy.group;
451 isSystemUser = true;
452 };
453
454 users.groups.pi-ldap-proxy = mkIf (cfg.ldap-proxy.group == "pi-ldap-proxy") {};
455 })
456 ];
457
458}