{ config, lib, pkgs, environment, ... }: with lib; let cfg = config.fudo.postgresql; join-lines = lib.concatStringsSep "\n"; userDatabaseOpts = { database, ... }: { options = { access = mkOption { type = types.str; description = "Privileges for user on this database."; default = "CONNECT"; }; entity-access = mkOption { type = with types; attrsOf str; description = "A list of entities mapped to the access this user should have."; default = { }; example = { "TABLE users" = "SELECT,DELETE"; "ALL SEQUENCES IN public" = "SELECT"; }; }; }; }; userOpts = { username, ... }: { options = { password-file = mkOption { type = with types; nullOr str; description = "A file containing the user's (plaintext) password."; default = null; }; databases = mkOption { type = with types; attrsOf (submodule userDatabaseOpts); description = "Map of databases to required database/table perms."; default = { }; example = { my_database = { access = "ALL PRIVILEGES"; entity-access = { "ALL TABLES" = "SELECT"; }; }; }; }; }; }; databaseOpts = { dbname, ... }: { options = { users = mkOption { type = with types; listOf str; description = "A list of users who should have full access to this database."; default = [ ]; }; }; }; filterPasswordedUsers = filterAttrs (user: opts: opts.password-file != null); password-setter-script = user: password-file: sql-file: '' unset PASSWORD if [ ! -f ${password-file} ]; then echo "file does not exist: ${password-file}" exit 1 fi PASSWORD=$(cat ${password-file}) echo "setting password for user ${user}" echo "ALTER USER ${user} ENCRYPTED PASSWORD '$PASSWORD';" >> ${sql-file} ''; passwords-setter-script = users: pkgs.writeScriptBin "postgres-set-passwords.sh" '' #!${pkgs.bash}/bin/bash if [ $# -ne 1 ]; then echo "usage: $0 output-file.sql" exit 1 fi OUTPUT_FILE=$1 if [ ! -f $OUTPUT_FILE ]; then echo "file doesn't exist: $OUTPUT_FILE" exit 2 fi ${join-lines (mapAttrsToList (user: opts: password-setter-script user opts.password-file "$OUTPUT_FILE") (filterPasswordedUsers users))} ''; userDatabaseAccess = user: databases: mapAttrs' (database: databaseOpts: nameValuePair "DATABASE ${database}" databaseOpts.access) databases; makeEntry = nw: "host all all ${nw} gss include_realm=0 krb_realm=FUDO.ORG"; makeNetworksEntry = networks: join-lines (map makeEntry networks); makeLocalUserPasswordEntries = users: join-lines (mapAttrsToList (user: opts: join-lines (map (db: '' local ${db} ${user} md5 host ${db} ${user} 127.0.0.1/16 md5 host ${db} ${user} ::1/128 md5 '') (attrNames opts.databases))) (filterPasswordedUsers users)); userTableAccessSql = user: entity: access: "GRANT ${access} ON ${entity} TO ${user};"; userDatabaseAccessSql = user: database: dbOpts: '' \c ${database} ${join-lines (mapAttrsToList (userTableAccessSql user) dbOpts.entity-access)} ''; userAccessSql = user: userOpts: join-lines (mapAttrsToList (userDatabaseAccessSql user) userOpts.databases); usersAccessSql = users: join-lines (mapAttrsToList userAccessSql users); in { options.fudo.postgresql = { enable = mkEnableOption "Fudo PostgreSQL Server"; ssl-private-key = mkOption { type = types.str; description = "Location of the server SSL private key."; }; ssl-certificate = mkOption { type = types.str; description = "Location of the server SSL certificate."; }; keytab = mkOption { type = types.str; description = "Location of the server Kerberos keytab."; }; local-networks = mkOption { type = with types; listOf str; description = "A list of networks from which to accept connections."; example = [ "10.0.0.1/16" ]; default = [ ]; }; users = mkOption { type = with types; loaOf (submodule userOpts); description = "A map of users to user attributes."; example = { sampleUser = { password-file = "/path/to/password/file"; databases = { some_database = { access = "CONNECT"; entity-access = { "TABLE some_table" = "SELECT,UPDATE"; }; }; }; }; }; default = { }; }; databases = mkOption { type = with types; loaOf (submodule databaseOpts); description = "A map of databases to database options."; default = { }; }; socket-directory = mkOption { type = types.str; description = "Directory in which to place unix sockets."; default = "/run/postgresql"; }; socket-group = mkOption { type = types.str; description = "Group for accessing sockets."; default = "postgres_local"; }; local-users = mkOption { type = with types; listOf str; description = "Users able to access the server via local socket."; default = [ ]; }; required-services = mkOption { type = with types; listOf str; description = "List of services that should run before postgresql."; default = [ ]; example = [ "password-generator.service" ]; }; }; config = mkIf cfg.enable { environment = { systemPackages = with pkgs; [ postgresql_11_gssapi ]; etc = { "postgresql/private/privkey.pem" = { mode = "0400"; user = "postgres"; group = "postgres"; source = cfg.ssl-private-key; }; "postgresql/cert.pem" = { mode = "0444"; user = "postgres"; group = "postgres"; source = cfg.ssl-certificate; }; "postgresql/private/postgres.keytab" = { mode = "0400"; user = "postgres"; group = "postgres"; source = cfg.keytab; }; }; }; users.groups = { ${cfg.socket-group} = { members = [ "postgres" ] ++ cfg.local-users; }; }; services.postgresql = { enable = true; package = pkgs.postgresql_11_gssapi; enableTCPIP = true; ensureDatabases = mapAttrsToList (name: value: name) cfg.databases; ensureUsers = ((mapAttrsToList (username: attrs: { name = username; ensurePermissions = userDatabaseAccess username attrs.databases; }) cfg.users) ++ (flatten (mapAttrsToList (database: opts: (map (username: { name = username; ensurePermissions = { "DATABASE ${database}" = "ALL PRIVILEGES"; }; }) opts.users)) cfg.databases))); extraConfig = '' krb_server_keyfile = '/etc/postgresql/private/postgres.keytab' ssl = true ssl_cert_file = '/etc/postgresql/cert.pem' ssl_key_file = '/etc/postgresql/private/privkey.pem' unix_socket_directories = '${cfg.socket-directory}' unix_socket_group = '${cfg.socket-group}' unix_socket_permissions = 0777 ''; authentication = lib.mkForce '' ${makeLocalUserPasswordEntries cfg.users} local all all ident # host-local host all all 127.0.0.1/32 gss include_realm=0 krb_realm=FUDO.ORG host all all ::1/128 gss include_realm=0 krb_realm=FUDO.ORG # local networks ${makeNetworksEntry cfg.local-networks} ''; }; systemd = { services = { postgresql-password-setter = let passwords-script = passwords-setter-script cfg.users; password-wrapper-script = pkgs.writeScriptBin "password-script-wrapper.sh" '' #!${pkgs.bash}/bin/bash TMPDIR=$(${pkgs.coreutils}/bin/mktemp -d -t postgres-XXXXXXXXXX) echo "using temp dir $TMPDIR" PASSWORD_SQL_FILE=$TMPDIR/user-passwords.sql echo "password file $PASSWORD_SQL_FILE" touch $PASSWORD_SQL_FILE chown ${config.services.postgresql.superUser} $PASSWORD_SQL_FILE chmod go-rwx $PASSWORD_SQL_FILE ${passwords-script}/bin/postgres-set-passwords.sh $PASSWORD_SQL_FILE echo "executing $PASSWORD_SQL_FILE" ${pkgs.postgresql}/bin/psql --port ${ toString config.services.postgresql.port } -d postgres -f $PASSWORD_SQL_FILE echo rm $PASSWORD_SQL_FILE echo "Postgresql user passwords set."; exit 0 ''; in { description = "A service to set postgresql user passwords after the server has started."; after = [ "postgresql.service" ] ++ cfg.required-services; requires = [ "postgresql.service" ] ++ cfg.required-services; serviceConfig = { Type = "oneshot"; User = config.services.postgresql.superUser; }; script = "${password-wrapper-script}/bin/password-script-wrapper.sh"; }; postgresql.postStart = let allow-user-login = user: "ALTER ROLE ${user} WITH LOGIN;"; extra-settings-sql = pkgs.writeText "settings.sql" '' ${concatStringsSep "\n" (map allow-user-login (mapAttrsToList (key: val: key) cfg.users))} ${usersAccessSql cfg.users} ''; in '' ${pkgs.sudo}/bin/sudo -u ${config.services.postgresql.superUser} ${pkgs.postgresql}/bin/psql --port ${ toString config.services.postgresql.port } -d postgres -f ${extra-settings-sql} ${pkgs.coreutils}/bin/chgrp ${cfg.socket-group} ${cfg.socket-directory}/.s.PGSQL* ''; }; }; }; }