1{
2 config,
3 pkgs,
4 lib,
5 ...
6}:
7let
8 cfg = config.services.dependency-track;
9
10 settingsFormat = pkgs.formats.javaProperties { };
11
12 frontendConfigFormat = pkgs.formats.json { };
13 frontendConfigFile = frontendConfigFormat.generate "config.json" {
14 API_BASE_URL = cfg.frontend.baseUrl;
15 OIDC_ISSUER = cfg.oidc.issuer;
16 OIDC_CLIENT_ID = cfg.oidc.clientId;
17 OIDC_SCOPE = cfg.oidc.scope;
18 OIDC_FLOW = cfg.oidc.flow;
19 OIDC_LOGIN_BUTTON_TEXT = cfg.oidc.loginButtonText;
20 };
21
22 sslEnabled =
23 config.services.nginx.virtualHosts.${cfg.nginx.domain}.addSSL
24 || config.services.nginx.virtualHosts.${cfg.nginx.domain}.forceSSL
25 || config.services.nginx.virtualHosts.${cfg.nginx.domain}.onlySSL
26 || config.services.nginx.virtualHosts.${cfg.nginx.domain}.enableACME;
27
28 assertStringPath =
29 optionName: value:
30 if lib.isPath value then
31 throw ''
32 services.dependency-track.${optionName}:
33 ${toString value}
34 is a Nix path, but should be a string, since Nix
35 paths are copied into the world-readable Nix store.
36 ''
37 else
38 value;
39
40 filterNull = lib.filterAttrs (_: v: v != null);
41
42 renderSettings =
43 settings:
44 lib.mapAttrs' (
45 n: v:
46 lib.nameValuePair (lib.toUpper (lib.replaceStrings [ "." ] [ "_" ] n)) (
47 if lib.isBool v then lib.boolToString v else v
48 )
49 ) (filterNull settings);
50in
51{
52 options.services.dependency-track = {
53 enable = lib.mkEnableOption "dependency-track";
54
55 package = lib.mkPackageOption pkgs "dependency-track" { };
56
57 logLevel = lib.mkOption {
58 type = lib.types.enum [
59 "INFO"
60 "WARN"
61 "ERROR"
62 "DEBUG"
63 "TRACE"
64 ];
65 default = "INFO";
66 description = "Log level for dependency-track";
67 };
68
69 port = lib.mkOption {
70 type = lib.types.port;
71 default = 8080;
72 description = ''
73 On which port dependency-track should listen for new HTTP connections.
74 '';
75 };
76
77 javaArgs = lib.mkOption {
78 type = lib.types.listOf lib.types.str;
79 default = [ "-Xmx4G" ];
80 description = "Java options passed to JVM";
81 };
82
83 database = {
84 type = lib.mkOption {
85 type = lib.types.enum [
86 "h2"
87 "postgresql"
88 "manual"
89 ];
90 default = "postgresql";
91 description = ''
92 `h2` database is not recommended for a production setup.
93 `postgresql` this settings it recommended for production setups.
94 `manual` the module doesn't handle database settings.
95 '';
96 };
97
98 createLocally = lib.mkOption {
99 type = lib.types.bool;
100 default = true;
101 description = ''
102 Whether a database should be automatically created on the
103 local host. Set this to false if you plan on provisioning a
104 local database yourself.
105 '';
106 };
107
108 databaseName = lib.mkOption {
109 type = lib.types.str;
110 default = "dependency-track";
111 description = ''
112 Database name to use when connecting to an external or
113 manually provisioned database; has no effect when a local
114 database is automatically provisioned.
115
116 To use this with a local database, set {option}`services.dependency-track.database.createLocally`
117 to `false` and create the database and user.
118 '';
119 };
120
121 username = lib.mkOption {
122 type = lib.types.str;
123 default = "dependency-track";
124 description = ''
125 Username to use when connecting to an external or manually
126 provisioned database; has no effect when a local database is
127 automatically provisioned.
128
129 To use this with a local database, set {option}`services.dependency-track.database.createLocally`
130 to `false` and create the database and user.
131 '';
132 };
133
134 passwordFile = lib.mkOption {
135 type = lib.types.path;
136 example = "/run/keys/db_password";
137 apply = assertStringPath "passwordFile";
138 description = ''
139 The path to a file containing the database password.
140 '';
141 };
142 };
143
144 ldap.bindPasswordFile = lib.mkOption {
145 type = lib.types.path;
146 example = "/run/keys/ldap_bind_password";
147 apply = assertStringPath "bindPasswordFile";
148 description = ''
149 The path to a file containing the LDAP bind password.
150 '';
151 };
152
153 frontend = {
154 baseUrl = lib.mkOption {
155 type = lib.types.str;
156 default = lib.optionalString cfg.nginx.enable "${
157 if sslEnabled then "https" else "http"
158 }://${cfg.nginx.domain}";
159 defaultText = lib.literalExpression ''
160 lib.optionalString config.services.dependency-track.nginx.enable "''${
161 if sslEnabled then "https" else "http"
162 }://''${config.services.dependency-track.nginx.domain}";
163 '';
164 description = ''
165 The base URL of the API server.
166
167 NOTE:
168 * This URL must be reachable by the browsers of your users.
169 * The frontend container itself does NOT communicate with the API server directly, it just serves static files.
170 * When deploying to dedicated servers, please use the external IP or domain of the API server.
171 '';
172 };
173 };
174
175 oidc = {
176 enable = lib.mkEnableOption "oidc support";
177 issuer = lib.mkOption {
178 type = lib.types.str;
179 default = "";
180 description = ''
181 Defines the issuer URL to be used for OpenID Connect.
182 See alpine.oidc.issuer property of the API server.
183 '';
184 };
185 clientId = lib.mkOption {
186 type = lib.types.str;
187 default = "";
188 description = ''
189 Defines the client ID for OpenID Connect.
190 '';
191 };
192 scope = lib.mkOption {
193 type = lib.types.str;
194 default = "openid profile email";
195 description = ''
196 Defines the scopes to request for OpenID Connect.
197 See also: <https://openid.net/specs/openid-connect-basic-1_0.html#Scopes>
198 '';
199 };
200 flow = lib.mkOption {
201 type = lib.types.enum [
202 "code"
203 "implicit"
204 ];
205 default = "code";
206 description = ''
207 Specifies the OpenID Connect flow to use.
208 Values other than "implicit" will result in the Code+PKCE flow to be used.
209 Usage of the implicit flow is strongly discouraged, but may be necessary when
210 the IdP of choice does not support the Code+PKCE flow.
211 See also:
212 - <https://oauth.net/2/grant-types/implicit/>
213 - <https://oauth.net/2/pkce/>
214 '';
215 };
216 loginButtonText = lib.mkOption {
217 type = lib.types.str;
218 default = "Login with OpenID Connect";
219 description = ''
220 Defines the scopes to request for OpenID Connect.
221 See also: <https://openid.net/specs/openid-connect-basic-1_0.html#Scopes>
222 '';
223 };
224 usernameClaim = lib.mkOption {
225 type = lib.types.str;
226 default = "name";
227 example = "preferred_username";
228 description = ''
229 Defines the name of the claim that contains the username in the provider's userinfo endpoint.
230 Common claims are "name", "username", "preferred_username" or "nickname".
231 See also: <https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse>
232 '';
233 };
234 userProvisioning = lib.mkOption {
235 type = lib.types.bool;
236 default = false;
237 example = true;
238 description = ''
239 Specifies if mapped OpenID Connect accounts are automatically created upon successful
240 authentication. When a user logs in with a valid access token but an account has
241 not been previously provisioned, an authentication failure will be returned.
242 This allows admins to control specifically which OpenID Connect users can access the
243 system and which users cannot. When this value is set to true, a local OpenID Connect
244 user will be created and mapped to the OpenID Connect account automatically. This
245 automatic provisioning only affects authentication, not authorization.
246 '';
247 };
248 teamSynchronization = lib.mkOption {
249 type = lib.types.bool;
250 default = false;
251 example = true;
252 description = ''
253 This option will ensure that team memberships for OpenID Connect users are dynamic and
254 synchronized with membership of OpenID Connect groups or assigned roles. When a team is
255 mapped to an OpenID Connect group, all local OpenID Connect users will automatically be
256 assigned to the team if they are a member of the group the team is mapped to. If the user
257 is later removed from the OpenID Connect group, they will also be removed from the team. This
258 option provides the ability to dynamically control user permissions via the identity provider.
259 Note that team synchronization is only performed during user provisioning and after successful
260 authentication.
261 '';
262 };
263 teams = {
264 claim = lib.mkOption {
265 type = lib.types.str;
266 default = "groups";
267 description = ''
268 Defines the name of the claim that contains group memberships or role assignments in the provider's userinfo endpoint.
269 The claim must be an array of strings. Most public identity providers do not support group or role management.
270 When using a customizable / on-demand hosted identity provider, name, content, and inclusion in the userinfo endpoint
271 will most likely need to be configured.
272 '';
273 };
274 default = lib.mkOption {
275 type = lib.types.nullOr lib.types.commas;
276 default = null;
277 description = ''
278 Defines one or more team names that auto-provisioned OIDC users shall be added to.
279 Multiple team names may be provided as comma-separated list.
280
281 Has no effect when {option}`services.dependency-track.oidc.userProvisioning`=false,
282 or {option}`services.dependency-track.oidc.teamSynchronization`=true.
283 '';
284 };
285 };
286 };
287
288 nginx = {
289 enable = lib.mkOption {
290 type = lib.types.bool;
291 default = true;
292 example = false;
293 description = ''
294 Whether to set up an nginx virtual host.
295 '';
296 };
297
298 domain = lib.mkOption {
299 type = lib.types.str;
300 example = "dtrack.example.com";
301 description = ''
302 The domain name under which to set up the virtual host.
303 '';
304 };
305 };
306
307 settings = lib.mkOption {
308 type = lib.types.submodule {
309 freeformType = settingsFormat.type;
310 options = {
311 "alpine.data.directory" = lib.mkOption {
312 type = lib.types.path;
313 default = "/var/lib/dependency-track";
314 description = ''
315 Defines the path to the data directory. This directory will hold logs, keys,
316 and any database or index files along with application-specific files or
317 directories.
318 '';
319 };
320 "alpine.database.mode" = lib.mkOption {
321 type = lib.types.enum [
322 "server"
323 "embedded"
324 "external"
325 ];
326 default =
327 if cfg.database.type == "h2" then
328 "embedded"
329 else if cfg.database.type == "postgresql" then
330 "external"
331 else
332 null;
333 defaultText = lib.literalExpression ''
334 if config.services.dependency-track.database.type == "h2" then "embedded"
335 else if config.services.dependency-track.database.type == "postgresql" then "external"
336 else null
337 '';
338 description = ''
339 Defines the database mode of operation. Valid choices are:
340 'server', 'embedded', and 'external'.
341 In server mode, the database will listen for connections from remote hosts.
342 In embedded mode, the system will be more secure and slightly faster.
343 External mode should be used when utilizing an external database server
344 (i.e. mysql, postgresql, etc).
345 '';
346 };
347 "alpine.database.url" = lib.mkOption {
348 type = lib.types.str;
349 default =
350 if cfg.database.type == "h2" then
351 "jdbc:h2:/var/lib/dependency-track/db"
352 else if cfg.database.type == "postgresql" then
353 "jdbc:postgresql:${cfg.database.databaseName}?socketFactory=org.newsclub.net.unix.AFUNIXSocketFactory$FactoryArg&socketFactoryArg=/run/postgresql/.s.PGSQL.5432"
354 else
355 null;
356
357 defaultText = lib.literalExpression ''
358 if config.services.dependency-track.database.type == "h2" then "jdbc:h2:/var/lib/dependency-track/db"
359 else if config.services.dependency-track.database.type == "postgresql" then "jdbc:postgresql:''${config.services.dependency-track.database.name}?socketFactory=org.newsclub.net.unix.AFUNIXSocketFactory$FactoryArg&socketFactoryArg=/run/postgresql/.s.PGSQL.5432"
360 else null
361 '';
362 description = "Specifies the JDBC URL to use when connecting to the database.";
363 };
364 "alpine.database.driver" = lib.mkOption {
365 type = lib.types.enum [
366 "org.h2.Driver"
367 "org.postgresql.Driver"
368 "com.microsoft.sqlserver.jdbc.SQLServerDriver"
369 "com.mysql.cj.jdbc.Driver"
370 ];
371 default =
372 if cfg.database.type == "h2" then
373 "org.h2.Driver"
374 else if cfg.database.type == "postgresql" then
375 "org.postgresql.Driver"
376 else
377 null;
378 defaultText = lib.literalExpression ''
379 if config.services.dependency-track.database.type == "h2" then "org.h2.Driver"
380 else if config.services.dependency-track.database.type == "postgresql" then "org.postgresql.Driver"
381 else null;
382 '';
383 description = "Specifies the JDBC driver class to use.";
384 };
385 "alpine.database.username" = lib.mkOption {
386 type = lib.types.str;
387 default = if cfg.database.createLocally then "dependency-track" else cfg.database.username;
388 defaultText = lib.literalExpression ''
389 if config.services.dependency-track.database.createLocally then "dependency-track"
390 else config.services.dependency-track.database.username
391 '';
392 description = "Specifies the username to use when authenticating to the database.";
393 };
394 "alpine.ldap.enabled" = lib.mkOption {
395 type = lib.types.bool;
396 default = false;
397 description = ''
398 Defines if LDAP will be used for user authentication. If enabled,
399 alpine.ldap.* properties should be set accordingly.
400 '';
401 };
402 "alpine.oidc.enabled" = lib.mkOption {
403 type = lib.types.bool;
404 default = cfg.oidc.enable;
405 defaultText = lib.literalExpression "config.services.dependency-track.oidc.enable";
406 description = ''
407 Defines if OpenID Connect will be used for user authentication.
408 If enabled, alpine.oidc.* properties should be set accordingly.
409 '';
410 };
411 "alpine.oidc.client.id" = lib.mkOption {
412 type = lib.types.str;
413 default = cfg.oidc.clientId;
414 defaultText = lib.literalExpression "config.services.dependency-track.oidc.clientId";
415 description = ''
416 Defines the client ID to be used for OpenID Connect.
417 The client ID should be the same as the one configured for the frontend,
418 and will only be used to validate ID tokens.
419 '';
420 };
421 "alpine.oidc.issuer" = lib.mkOption {
422 type = lib.types.str;
423 default = cfg.oidc.issuer;
424 defaultText = lib.literalExpression "config.services.dependency-track.oidc.issuer";
425 description = ''
426 Defines the issuer URL to be used for OpenID Connect.
427 This issuer MUST support provider configuration via the /.well-known/openid-configuration endpoint.
428 See also:
429 - <https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata>
430 - <https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig>
431 '';
432 };
433 "alpine.oidc.username.claim" = lib.mkOption {
434 type = lib.types.str;
435 default = cfg.oidc.usernameClaim;
436 defaultText = lib.literalExpression "config.services.dependency-track.oidc.usernameClaim";
437 description = ''
438 Defines the name of the claim that contains the username in the provider's userinfo endpoint.
439 Common claims are "name", "username", "preferred_username" or "nickname".
440 See also: <https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse>
441 '';
442 };
443 "alpine.oidc.user.provisioning" = lib.mkOption {
444 type = lib.types.bool;
445 default = cfg.oidc.userProvisioning;
446 defaultText = lib.literalExpression "config.services.dependency-track.oidc.userProvisioning";
447 description = ''
448 Specifies if mapped OpenID Connect accounts are automatically created upon successful
449 authentication. When a user logs in with a valid access token but an account has
450 not been previously provisioned, an authentication failure will be returned.
451 This allows admins to control specifically which OpenID Connect users can access the
452 system and which users cannot. When this value is set to true, a local OpenID Connect
453 user will be created and mapped to the OpenID Connect account automatically. This
454 automatic provisioning only affects authentication, not authorization.
455 '';
456 };
457 "alpine.oidc.team.synchronization" = lib.mkOption {
458 type = lib.types.bool;
459 default = cfg.oidc.teamSynchronization;
460 defaultText = lib.literalExpression "config.services.dependency-track.oidc.teamSynchronization";
461 description = ''
462 This option will ensure that team memberships for OpenID Connect users are dynamic and
463 synchronized with membership of OpenID Connect groups or assigned roles. When a team is
464 mapped to an OpenID Connect group, all local OpenID Connect users will automatically be
465 assigned to the team if they are a member of the group the team is mapped to. If the user
466 is later removed from the OpenID Connect group, they will also be removed from the team. This
467 option provides the ability to dynamically control user permissions via the identity provider.
468 Note that team synchronization is only performed during user provisioning and after successful
469 authentication.
470 '';
471 };
472 "alpine.oidc.teams.claim" = lib.mkOption {
473 type = lib.types.str;
474 default = cfg.oidc.teams.claim;
475 defaultText = lib.literalExpression "config.services.dependency-track.oidc.teams.claim";
476 description = ''
477 Defines the name of the claim that contains group memberships or role assignments in the provider's userinfo endpoint.
478 The claim must be an array of strings. Most public identity providers do not support group or role management.
479 When using a customizable / on-demand hosted identity provider, name, content, and inclusion in the userinfo endpoint
480 will most likely need to be configured.
481 '';
482 };
483 "alpine.oidc.teams.default" = lib.mkOption {
484 type = lib.types.nullOr lib.types.commas;
485 default = cfg.oidc.teams.default;
486 defaultText = lib.literalExpression "config.services.dependency-track.oidc.teams.default";
487 description = ''
488 Defines one or more team names that auto-provisioned OIDC users shall be added to.
489 Multiple team names may be provided as comma-separated list.
490
491 Has no effect when {option}`services.dependency-track.oidc.userProvisioning`=false,
492 or {option}`services.dependency-track.oidc.teamSynchronization`=true.
493 '';
494 };
495 };
496 };
497 default = { };
498 description = "See <https://docs.dependencytrack.org/getting-started/configuration/#default-configuration> for possible options";
499 };
500 };
501
502 config = lib.mkIf cfg.enable {
503 services.nginx = lib.mkIf cfg.nginx.enable {
504 enable = true;
505 recommendedGzipSettings = lib.mkDefault true;
506 recommendedOptimisation = lib.mkDefault true;
507 recommendedProxySettings = lib.mkDefault true;
508 recommendedTlsSettings = lib.mkDefault true;
509 upstreams.dependency-track.servers."localhost:${toString cfg.port}" = { };
510 virtualHosts.${cfg.nginx.domain} = {
511 locations = {
512 "/" = {
513 alias = "${cfg.package.frontend}/dist/";
514 index = "index.html";
515 tryFiles = "$uri $uri/ /index.html";
516 extraConfig = ''
517 location ~ (index\.html)$ {
518 add_header Cache-Control "max-age=0, no-cache, no-store, must-revalidate";
519 add_header Pragma "no-cache";
520 add_header Expires 0;
521 }
522 '';
523 };
524 "/api".proxyPass = "http://dependency-track";
525 "= /static/config.json" = {
526 alias = frontendConfigFile;
527 extraConfig = ''
528 add_header Cache-Control "max-age=0, no-cache, no-store, must-revalidate";
529 add_header Pragma "no-cache";
530 add_header Expires 0;
531 '';
532 };
533 };
534 };
535 };
536
537 systemd.services.dependency-track-postgresql-init = lib.mkIf cfg.database.createLocally {
538 after = [ "postgresql.service" ];
539 before = [ "dependency-track.service" ];
540 bindsTo = [ "postgresql.service" ];
541 path = [ config.services.postgresql.package ];
542 serviceConfig = {
543 Type = "oneshot";
544 RemainAfterExit = true;
545 User = "postgres";
546 Group = "postgres";
547 LoadCredential = [ "db_password:${cfg.database.passwordFile}" ];
548 PrivateTmp = true;
549 };
550 script = ''
551 set -eou pipefail
552 shopt -s inherit_errexit
553
554 # Read the password from the credentials directory and
555 # escape any single quotes by adding additional single
556 # quotes after them, following the rules laid out here:
557 # https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-CONSTANTS
558 db_password="$(<"$CREDENTIALS_DIRECTORY/db_password")"
559 db_password="''${db_password//\'/\'\'}"
560
561 echo "CREATE ROLE \"dependency-track\" WITH LOGIN PASSWORD '$db_password' CREATEDB" > /tmp/create_role.sql
562 psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='dependency-track'" | grep -q 1 || psql -tA --file="/tmp/create_role.sql"
563 psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'dependency-track'" | grep -q 1 || psql -tAc 'CREATE DATABASE "dependency-track" OWNER "dependency-track"'
564 '';
565 };
566
567 services.postgresql.enable = lib.mkIf cfg.database.createLocally (lib.mkDefault true);
568
569 systemd.services."dependency-track" =
570 let
571 databaseServices =
572 if cfg.database.createLocally then
573 [
574 "dependency-track-postgresql-init.service"
575 "postgresql.service"
576 ]
577 else
578 [ ];
579 in
580 {
581 description = "Dependency Track";
582 wantedBy = [ "multi-user.target" ];
583 requires = databaseServices;
584 after = databaseServices;
585 # provide settings via env vars to allow overriding default settings.
586 environment = {
587 HOME = "%S/dependency-track";
588 } // renderSettings cfg.settings;
589 serviceConfig = {
590 User = "dependency-track";
591 Group = "dependency-track";
592 DynamicUser = true;
593 StateDirectory = "dependency-track";
594 LoadCredential =
595 [ "db_password:${cfg.database.passwordFile}" ]
596 ++ lib.optional cfg.settings."alpine.ldap.enabled"
597 "ldap_bind_password:${cfg.ldap.bindPasswordFile}";
598 };
599 script = ''
600 set -eou pipefail
601 shopt -s inherit_errexit
602
603 export ALPINE_DATABASE_PASSWORD_FILE="$CREDENTIALS_DIRECTORY/db_password"
604 ${lib.optionalString cfg.settings."alpine.ldap.enabled" ''
605 export ALPINE_LDAP_BIND_PASSWORD="$(<"$CREDENTIALS_DIRECTORY/ldap_bind_password")"
606 ''}
607
608 exec ${lib.getExe pkgs.jre_headless} ${
609 lib.escapeShellArgs (
610 cfg.javaArgs
611 ++ [
612 "-DdependencyTrack.logging.level=${cfg.logLevel}"
613 "-jar"
614 "${cfg.package}/share/dependency-track/dependency-track.jar"
615 "-port"
616 "${toString cfg.port}"
617 ]
618 )
619 }
620 '';
621 };
622 };
623
624 meta = {
625 maintainers = lib.teams.cyberus.members;
626 };
627}