diff --git a/nixos/modules/services/databases/postgresql.nix b/nixos/modules/services/databases/postgresql.nix index d51f5d56c302..e046a7aa886a 100644 --- a/nixos/modules/services/databases/postgresql.nix +++ b/nixos/modules/services/databases/postgresql.nix @@ -70,8 +70,98 @@ let extensionNames = map getName cfg.finalPackage.installedExtensions; extensionInstalled = extension: elem extension extensionNames; -in + generateClauseSqlStatements = + user: + 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}" + ) user.ensureClauses; + + 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,141 +464,22 @@ 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 = { }; - 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 { - 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; - }; - }; + freeformType = types.attrsOf ( + types.oneOf [ + types.str + types.int + types.bool + ] + ); }; }; }; @@ -760,9 +731,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 +918,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() '';