From 3c589f75504515e7fdf153d13c665c2b55e2e0ff Mon Sep 17 00:00:00 2001 From: Mikilio Date: Tue, 2 Dec 2025 15:44:21 +0100 Subject: [PATCH 1/2] nixos/postgresql: add freeformType to ensureClauses option This commit adds a freeform type to accomodate for new options to use with ALTER ROLE. It also will perform extra checking and conversion for options with parameters. Converts option names into upper case with space instead of underscore. --- .../modules/services/databases/postgresql.nix | 137 +++++++++++++++--- nixos/tests/postgresql/postgresql.nix | 16 +- 2 files changed, 129 insertions(+), 24 deletions(-) diff --git a/nixos/modules/services/databases/postgresql.nix b/nixos/modules/services/databases/postgresql.nix index d51f5d56c302..b4969913d331 100644 --- a/nixos/modules/services/databases/postgresql.nix +++ b/nixos/modules/services/databases/postgresql.nix @@ -70,8 +70,101 @@ let extensionNames = map getName cfg.finalPackage.installedExtensions; extensionInstalled = extension: elem extension extensionNames; -in + generateClauseSqlStatements = + user: + let + filteredClauses = filterAttrs (name: value: value != null) user.ensureClauses; + in + mapAttrsToList ( + n: v: + let + directive = lib.toUpper (lib.replaceStrings [ "_" ] [ " " ] n); + in + if builtins.isBool v then + (if v then directive else "NO${directive}") + else if builtins.isString v then + "${directive} '${v}'" + else + "${directive} ${builtins.toString v}" + ) filteredClauses; + + generateAlterRoleSQL = + user: + let + clauseSqlStatements = generateClauseSqlStatements user; + in + if clauseSqlStatements == [ ] then + "" + else + ''ALTER ROLE "${user.name}" ${concatStringsSep " " clauseSqlStatements};''; + + generateUserSetupScript = + user: + let + dbOwnershipStmt = optionalString user.ensureDBOwnership '' + psql -tAc 'ALTER DATABASE "${user.name}" OWNER TO "${user.name}";' + ''; + + alterRoleSQL = generateAlterRoleSQL user; + + userClauses = optionalString (alterRoleSQL != "") '' + psql -tAc ${lib.escapeShellArg alterRoleSQL} + ''; + in + '' + psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='${user.name}'" | grep -q 1 || psql -tAc 'CREATE USER "${user.name}"' + ${userClauses} + ${dbOwnershipStmt} + ''; + + validateUserClauses = + let + validationScript = + pkgs.writers.writePython3 "validate-postgresql-clauses" + { + libraries = [ pkgs.python3Packages.pglast ]; + } + '' + import sys + from pglast import parse_sql + + + def validate_sql(username, sql): + if not sql: + return True + + try: + parse_sql(sql) + print(f"Valid SQL for user {username}") + return True + except Exception as e: + print(f"Invalid SQL for user {username}:", file=sys.stderr) + print(f" {sql}", file=sys.stderr) + print(f" Error: {e}", file=sys.stderr) + return False + + + if __name__ == "__main__": + username = sys.argv[1] + sql = sys.argv[2] + sys.exit(0 if validate_sql(username, sql) else 1) + ''; + in + pkgs.runCommand "postgresql-user-clauses-check" { } '' + ${concatMapStrings ( + user: + let + sql = generateAlterRoleSQL user; + in + optionalString (sql != "") '' + ${validationScript} ${lib.escapeShellArg user.name} ${lib.escapeShellArg sql} + '' + ) cfg.ensureUsers} + + touch $out + ''; +in { imports = [ (mkRemovedOptionModule [ @@ -374,6 +467,11 @@ in superuser = true; createrole = true; createdb = true; + connection_limit = 5; + + # SCRAM-SHA-256 hashed password for "password" + # Generate hashes using PostgreSQL or a dedicated script rather than storing passwords in plain text. + password = "SCRAM-SHA-256$4096:SZEJF5Si4QZ6l4fedrZZWQ==$6u3PWVcz+dts+NdpByPIjKa4CaSnoXGG3M2vpo76bVU=:WSZ0iGUCmVtKYVvNX0pFOp/60IgsdJ+90Y67Eun+QE0="; } ''; default = { }; @@ -381,6 +479,14 @@ in The default, `null`, means that the user created will have the default permissions assigned by PostgreSQL. Subsequent server starts will not set or unset the clause, so imperative changes are preserved. ''; type = types.submodule { + freeformType = types.attrsOf ( + types.oneOf [ + types.str + types.int + types.bool + ] + ); + options = let defaultText = lib.literalMD '' @@ -760,9 +866,13 @@ in "/share/postgresql" ]; - system.checks = lib.optional ( - cfg.checkConfig && pkgs.stdenv.hostPlatform == pkgs.stdenv.buildPlatform - ) configFileCheck; + system.checks = + lib.optional ( + cfg.checkConfig && pkgs.stdenv.hostPlatform == pkgs.stdenv.buildPlatform + ) configFileCheck + ++ lib.optional ( + cfg.ensureUsers != [ ] && pkgs.stdenv.hostPlatform == pkgs.stdenv.buildPlatform + ) validateUserClauses; systemd.targets.postgresql = { description = "PostgreSQL"; @@ -943,24 +1053,7 @@ in '') cfg.ensureDatabases} '' + '' - ${concatMapStrings ( - user: - let - dbOwnershipStmt = optionalString user.ensureDBOwnership ''psql -tAc 'ALTER DATABASE "${user.name}" OWNER TO "${user.name}";' ''; - - filteredClauses = filterAttrs (name: value: value != null) user.ensureClauses; - - clauseSqlStatements = attrValues (mapAttrs (n: v: if v then n else "no${n}") filteredClauses); - - userClauses = ''psql -tAc 'ALTER ROLE "${user.name}" ${concatStringsSep " " clauseSqlStatements}' ''; - in - '' - psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='${user.name}'" | grep -q 1 || psql -tAc 'CREATE USER "${user.name}"' - ${userClauses} - - ${dbOwnershipStmt} - '' - ) cfg.ensureUsers} + ${concatMapStrings generateUserSetupScript cfg.ensureUsers} ''; }; }; diff --git a/nixos/tests/postgresql/postgresql.nix b/nixos/tests/postgresql/postgresql.nix index 2c80e8fe67e5..32a096346445 100644 --- a/nixos/tests/postgresql/postgresql.nix +++ b/nixos/tests/postgresql/postgresql.nix @@ -195,6 +195,9 @@ let login = true; replication = true; bypassrls = true; + # SCRAM-SHA-256 hashed password for "password" + password = "SCRAM-SHA-256$4096:SZEJF5Si4QZ6l4fedrZZWQ==$6u3PWVcz+dts+NdpByPIjKa4CaSnoXGG3M2vpo76bVU=:WSZ0iGUCmVtKYVvNX0pFOp/60IgsdJ+90Y67Eun+QE0="; + connection_limit = 5; }; } { @@ -218,8 +221,10 @@ let "rolcreatedb," "rolcanlogin," "rolreplication," - "rolbypassrls" - "FROM pg_roles" + "rolbypassrls," + "rolconnlimit," + "rolpassword" + "FROM pg_authid" "WHERE rolname = '${user}'" ") row;" ]; @@ -243,6 +248,11 @@ let t.assertTrue(clauses["rolcanlogin"]) t.assertTrue(clauses["rolreplication"]) t.assertTrue(clauses["rolbypassrls"]) + t.assertTrue(clauses["rolconnlimit"] == 5) + t.assertTrue(clauses["rolpassword"]) + machine.succeed( + "PGPASSWORD='password' psql -h localhost -U all-clauses -d postgres -c \"SELECT 1\"" + ) with subtest("All user permissions default when ensureClauses is not provided"): clauses = json.loads( @@ -257,6 +267,8 @@ let t.assertTrue(clauses["rolcanlogin"]) t.assertFalse(clauses["rolreplication"]) t.assertFalse(clauses["rolbypassrls"]) + t.assertFalse(clauses["rolconnlimit"] == 5) + t.assertFalse(clauses["rolpassword"]) machine.shutdown() ''; From fcdfa7b5d0bc65ac086a12dd98dd7de82e2d522f Mon Sep 17 00:00:00 2001 From: Mikilio Date: Mon, 22 Dec 2025 13:00:53 +0100 Subject: [PATCH 2/2] nixos/postgresql: remove explicit submodule options These can be set via the freemform type anyway. --- .../modules/services/databases/postgresql.nix | 137 +----------------- 1 file changed, 1 insertion(+), 136 deletions(-) diff --git a/nixos/modules/services/databases/postgresql.nix b/nixos/modules/services/databases/postgresql.nix index b4969913d331..e046a7aa886a 100644 --- a/nixos/modules/services/databases/postgresql.nix +++ b/nixos/modules/services/databases/postgresql.nix @@ -73,9 +73,6 @@ let generateClauseSqlStatements = user: - let - filteredClauses = filterAttrs (name: value: value != null) user.ensureClauses; - in mapAttrsToList ( n: v: let @@ -87,7 +84,7 @@ let "${directive} '${v}'" else "${directive} ${builtins.toString v}" - ) filteredClauses; + ) user.ensureClauses; generateAlterRoleSQL = user: @@ -475,9 +472,6 @@ in } ''; default = { }; - defaultText = lib.literalMD '' - The default, `null`, means that the user created will have the default permissions assigned by PostgreSQL. Subsequent server starts will not set or unset the clause, so imperative changes are preserved. - ''; type = types.submodule { freeformType = types.attrsOf ( types.oneOf [ @@ -486,135 +480,6 @@ in types.bool ] ); - - options = - let - defaultText = lib.literalMD '' - `null`: do not set. For newly created roles, use PostgreSQL's default. For existing roles, do not touch this clause. - ''; - in - { - superuser = mkOption { - type = types.nullOr types.bool; - description = '' - Grants the user, created by the ensureUser attr, superuser permissions. From the postgres docs: - - A database superuser bypasses all permission checks, - except the right to log in. This is a dangerous privilege - and should not be used carelessly; it is best to do most - of your work as a role that is not a superuser. To create - a new database superuser, use CREATE ROLE name SUPERUSER. - You must do this as a role that is already a superuser. - - More information on postgres roles can be found [here](https://www.postgresql.org/docs/current/role-attributes.html) - ''; - default = null; - inherit defaultText; - }; - createrole = mkOption { - type = types.nullOr types.bool; - description = '' - Grants the user, created by the ensureUser attr, createrole permissions. From the postgres docs: - - A role must be explicitly given permission to create more - roles (except for superusers, since those bypass all - permission checks). To create such a role, use CREATE - ROLE name CREATEROLE. A role with CREATEROLE privilege - can alter and drop other roles, too, as well as grant or - revoke membership in them. However, to create, alter, - drop, or change membership of a superuser role, superuser - status is required; CREATEROLE is insufficient for that. - - More information on postgres roles can be found [here](https://www.postgresql.org/docs/current/role-attributes.html) - ''; - default = null; - inherit defaultText; - }; - createdb = mkOption { - type = types.nullOr types.bool; - description = '' - Grants the user, created by the ensureUser attr, createdb permissions. From the postgres docs: - - A role must be explicitly given permission to create - databases (except for superusers, since those bypass all - permission checks). To create such a role, use CREATE - ROLE name CREATEDB. - - More information on postgres roles can be found [here](https://www.postgresql.org/docs/current/role-attributes.html) - ''; - default = null; - inherit defaultText; - }; - "inherit" = mkOption { - type = types.nullOr types.bool; - description = '' - Grants the user created inherit permissions. From the postgres docs: - - A role is given permission to inherit the privileges of - roles it is a member of, by default. However, to create a - role without the permission, use CREATE ROLE name - NOINHERIT. - - More information on postgres roles can be found [here](https://www.postgresql.org/docs/current/role-attributes.html) - ''; - default = null; - inherit defaultText; - }; - login = mkOption { - type = types.nullOr types.bool; - description = '' - Grants the user, created by the ensureUser attr, login permissions. From the postgres docs: - - Only roles that have the LOGIN attribute can be used as - the initial role name for a database connection. A role - with the LOGIN attribute can be considered the same as a - “database user”. To create a role with login privilege, - use either: - - CREATE ROLE name LOGIN; CREATE USER name; - - (CREATE USER is equivalent to CREATE ROLE except that - CREATE USER includes LOGIN by default, while CREATE ROLE - does not.) - - More information on postgres roles can be found [here](https://www.postgresql.org/docs/current/role-attributes.html) - ''; - default = null; - inherit defaultText; - }; - replication = mkOption { - type = types.nullOr types.bool; - description = '' - Grants the user, created by the ensureUser attr, replication permissions. From the postgres docs: - - A role must explicitly be given permission to initiate - streaming replication (except for superusers, since those - bypass all permission checks). A role used for streaming - replication must have LOGIN permission as well. To create - such a role, use CREATE ROLE name REPLICATION LOGIN. - - More information on postgres roles can be found [here](https://www.postgresql.org/docs/current/role-attributes.html) - ''; - default = null; - inherit defaultText; - }; - bypassrls = mkOption { - type = types.nullOr types.bool; - description = '' - Grants the user, created by the ensureUser attr, replication permissions. From the postgres docs: - - A role must be explicitly given permission to bypass - every row-level security (RLS) policy (except for - superusers, since those bypass all permission checks). To - create such a role, use CREATE ROLE name BYPASSRLS as a - superuser. - - More information on postgres roles can be found [here](https://www.postgresql.org/docs/current/role-attributes.html) - ''; - default = null; - inherit defaultText; - }; - }; }; }; };