Unverified Commit 899c3986 authored by Wolfgang Walther's avatar Wolfgang Walther Committed by GitHub
Browse files

nixos/postgrest: init module (#394041)

parents 77cffbb3 064432a5
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -131,6 +131,8 @@

- [Autotier](https://github.com/45Drives/autotier), a passthrough FUSE filesystem. Available as [services.autotierfs](options.html#opt-services.autotierfs.enable).

- [PostgREST](https://postgrest.org), a standalone web server that turns your PostgreSQL database directly into a RESTful API. Available as [services.postgrest](options.html#opt-services.postgrest.enable).

- [µStreamer](https://github.com/pikvm/ustreamer), a lightweight MJPEG-HTTP streamer. Available as [services.ustreamer](options.html#opt-services.ustreamer).

- [Whoogle Search](https://github.com/benbusby/whoogle-search), a self-hosted, ad-free, privacy-respecting metasearch engine. Available as [services.whoogle-search](options.html#opt-services.whoogle-search.enable).
+1 −0
Original line number Diff line number Diff line
@@ -513,6 +513,7 @@
  ./services/databases/pgbouncer.nix
  ./services/databases/pgmanage.nix
  ./services/databases/postgresql.nix
  ./services/databases/postgrest.nix
  ./services/databases/redis.nix
  ./services/databases/surrealdb.nix
  ./services/databases/tigerbeetle.nix
+311 −0
Original line number Diff line number Diff line
{
  config,
  lib,
  pkgs,
  ...
}:

let
  cfg = config.services.postgrest;

  # Turns an attrset of libpq connection params:
  #   {
  #     dbname = "postgres";
  #     user = "authenticator";
  #   }
  # into a libpq connection string:
  #   dbname=postgres user=authenticator
  db-uri = lib.pipe (cfg.settings.db-uri or { }) [
    (lib.filterAttrs (_: v: v != null))
    (lib.mapAttrsToList (k: v: "${k}=${v}"))
    (lib.concatStringsSep " ")
  ];

  # Writes a postgrest config file according to:
  #   https://hackage.haskell.org/package/configurator-0.3.0.0/docs/Data-Configurator.html
  # Only a subset of the functionality is used by PostgREST.
  configFile = lib.pipe (cfg.settings // { inherit db-uri; }) [
    (lib.filterAttrs (_: v: v != null))

    (lib.mapAttrs (
      _: v:
      if true == v then
        "true"
      else if false == v then
        "false"
      else if lib.isInt v then
        toString v
      else
        "\"${lib.escape [ "\"" ] v}\""
    ))

    (lib.mapAttrsToList (k: v: "${k} = ${v}"))
    (lib.concatStringsSep "\n")
    (pkgs.writeText "postgrest.conf")
  ];
in

{
  meta = {
    maintainers = with lib.maintainers; [ wolfgangwalther ];
  };

  options.services.postgrest = {
    enable = lib.mkEnableOption "PostgREST";

    pgpassFile = lib.mkOption {
      type =
        with lib.types;
        nullOr (pathWith {
          inStore = false;
          absolute = true;
        });
      default = null;
      example = "/run/keys/db_password";
      description = ''
        The password to authenticate to PostgreSQL with.
        Not needed for peer or trust based authentication.

        The file must be a valid `.pgpass` file as described in:
        <https://www.postgresql.org/docs/current/libpq-pgpass.html>

        In most cases, the following will be enough:
        ```
        *:*:*:*:<password>
        ```
      '';
    };

    jwtSecretFile = lib.mkOption {
      type =
        with lib.types;
        nullOr (pathWith {
          inStore = false;
          absolute = true;
        });
      default = null;
      example = "/run/keys/jwt_secret";
      description = ''
        The secret or JSON Web Key (JWK) (or set) used to decode JWT tokens clients provide for authentication.
        For security the key must be at least 32 characters long.
        If this parameter is not specified then PostgREST refuses authentication requests.

        <https://docs.postgrest.org/en/stable/references/configuration.html#jwt-secret>
      '';
    };

    settings = lib.mkOption {
      type = lib.types.submodule {
        freeformType =
          with lib.types;
          attrsOf (oneOf [
            bool
            ints.unsigned
            str
          ]);

        options = {
          admin-server-port = lib.mkOption {
            type = with lib.types; nullOr port;
            default = null;
            description = ''
              Specifies the port for the admin server, which can be used for healthchecks.

              <https://docs.postgrest.org/en/stable/references/admin_server.html#admin-server>
            '';
          };

          db-config = lib.mkOption {
            type = lib.types.bool;
            default = false;
            example = true;
            description = ''
              Enables the in-database configuration.

              <https://docs.postgrest.org/en/stable/references/configuration.html#in-database-configuration>

              ::: {.note}
              This is enabled by default upstream, but disabled by default in this module.
              :::
            '';
          };

          db-uri = lib.mkOption {
            type = lib.types.submodule {
              freeformType = with lib.types; attrsOf str;

              # This should not be used; use pgpassFile instead.
              options.password = lib.mkOption {
                default = null;
                readOnly = true;
                internal = true;
              };
              # This should not be used; use pgpassFile instead.
              options.passfile = lib.mkOption {
                default = null;
                readOnly = true;
                internal = true;
              };
            };
            default = { };
            description = ''
              libpq connection parameters as documented in:

              <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS>

              ::: {.note}
              The `settings.db-uri.password` and `settings.db-uri.passfile` options are blocked.
              Use [`pgpassFile`](#opt-services.postgrest.pgpassFile) instead.
              :::
            '';
            example = lib.literalExpression ''
              {
                host = "localhost";
                dbname = "postgres";
              }
            '';
          };

          # This should not be used; use jwtSecretFile instead.
          jwt-secret = lib.mkOption {
            default = null;
            readOnly = true;
            internal = true;
          };

          server-host = lib.mkOption {
            type = with lib.types; nullOr str;
            default = "127.0.0.1";
            description = ''
              Where to bind the PostgREST web server.

              ::: {.note}
              The admin server will also bind here, but potentially exposes sensitive information.
              Make sure you turn off the admin server, when opening this to the public.

              <https://github.com/PostgREST/postgrest/issues/3956>
              :::
            '';
          };

          server-port = lib.mkOption {
            type = with lib.types; nullOr port;
            default = null;
            example = 3000;
            description = ''
              The TCP port to bind the web server.
            '';
          };

          server-unix-socket = lib.mkOption {
            type = with lib.types; nullOr path;
            default = "/run/postgrest/postgrest.sock";
            description = ''
              Unix domain socket where to bind the PostgREST web server.
            '';
          };
        };
      };
      default = { };
      description = ''
        PostgREST configuration as documented in:
        <https://docs.postgrest.org/en/stable/references/configuration.html#list-of-parameters>

        `db-uri` is represented as an attribute set, see [`settings.db-uri`](#opt-services.postgrest.settings.db-uri)

        ::: {.note}
        The `settings.jwt-secret` option is blocked.
        Use [`jwtSecretFile`](#opt-services.postgrest.jwtSecretFile) instead.
        :::
      '';
      example = lib.literalExpression ''
        {
          db-anon-role = "anon";
          db-uri.dbname = "postgres";
          "app.settings.custom" = "value";
        }
      '';
    };
  };

  config = lib.mkIf cfg.enable {
    assertions = [
      {
        assertion = (cfg.settings.server-port == null) != (cfg.settings.server-unix-socket == null);
        message = ''
          PostgREST can listen either on a TCP port or on a unix socket, but not both.
          Please set one of `settings.server-port`](#opt-services.postgrest.jwtSecretFile) or `settings.server-unix-socket` to `null`.

          <https://docs.postgrest.org/en/stable/references/configuration.html#server-unix-socket>
        '';
      }
    ];

    warnings =
      lib.optional (cfg.settings.admin-server-port != null && cfg.settings.server-host != "127.0.0.1")
        "The PostgREST admin server is potentially listening on a public host. This may expose sensitive information via the `/config` endpoint.";

    systemd.services.postgrest = {
      description = "PostgREST";

      wantedBy = [ "multi-user.target" ];
      wants = [ "network-online.target" ];
      after = [
        "network-online.target"
        "postgresql.service"
      ];

      serviceConfig = {
        CacheDirectory = "postgrest";
        CacheDirectoryMode = "0700";
        Environment =
          lib.optional (cfg.pgpassFile != null) "PGPASSFILE=%C/postgrest/pgpass"
          ++ lib.optional (cfg.jwtSecretFile != null) "PGRST_JWT_SECRET=@%d/jwt_secret";
        LoadCredential =
          lib.optional (cfg.pgpassFile != null) "pgpass:${cfg.pgpassFile}"
          ++ lib.optional (cfg.jwtSecretFile != null) "jwt_secret:${cfg.jwtSecretFile}";
        Restart = "always";
        RuntimeDirectory = "postgrest";
        User = "postgrest";

        # Hardening
        CapabilityBoundingSet = [ "" ];
        DevicePolicy = "closed";
        DynamicUser = true;
        LockPersonality = true;
        MemoryDenyWriteExecute = true;
        NoNewPrivileges = true;
        PrivateDevices = true;
        PrivateIPC = true;
        PrivateMounts = true;
        ProcSubset = "pid";
        ProtectClock = true;
        ProtectControlGroups = true;
        ProtectHostname = true;
        ProtectKernelLogs = true;
        ProtectKernelModules = true;
        ProtectKernelTunables = true;
        ProtectProc = "invisible";
        RestrictAddressFamilies = [
          "AF_INET"
          "AF_INET6"
          "AF_UNIX"
        ];
        RestrictNamespaces = true;
        RestrictRealtime = true;
        SystemCallArchitectures = "native";
        SystemCallFilter = [ "" ];
        UMask = "0077";
      };

      # Copy the pgpass file to different location, to have it report mode 0400.
      # Fixes: https://github.com/systemd/systemd/issues/29435
      script = ''
        if [ -f "$CREDENTIALS_DIRECTORY/pgpass" ]; then
            cp -f "$CREDENTIALS_DIRECTORY/pgpass" "$CACHE_DIRECTORY/pgpass"
        fi
        exec ${lib.getExe pkgs.postgrest} ${configFile}
      '';
    };
  };
}
+1 −0
Original line number Diff line number Diff line
@@ -974,6 +974,7 @@ in {
  postfix-raise-smtpd-tls-security-level = handleTest ./postfix-raise-smtpd-tls-security-level.nix {};
  postfixadmin = handleTest ./postfixadmin.nix {};
  postgresql = handleTest ./postgresql {};
  postgrest = runTest ./postgrest.nix;
  powerdns = handleTest ./powerdns.nix {};
  powerdns-admin = handleTest ./powerdns-admin.nix {};
  power-profiles-daemon = handleTest ./power-profiles-daemon.nix {};
+88 −0
Original line number Diff line number Diff line
{ lib, ... }:
{
  name = "postgrest";

  meta = {
    maintainers = with lib.maintainers; [ wolfgangwalther ];
  };

  nodes.machine =
    {
      config,
      lib,
      pkgs,
      ...
    }:
    {
      services.postgresql = {
        enable = true;
        initialScript = pkgs.writeText "init.sql" ''
          CREATE ROLE postgrest LOGIN NOINHERIT;
          CREATE ROLE anon ROLE postgrest;

          CREATE ROLE postgrest_with_password LOGIN NOINHERIT PASSWORD 'password';
          CREATE ROLE authenticated ROLE postgrest_with_password;
        '';
      };

      services.postgrest = {
        enable = true;
        settings = {
          admin-server-port = 3001;
          db-anon-role = "anon";
          db-uri.dbname = "postgres";
        };
      };

      specialisation.withSecrets.configuration = {
        services.postgresql.enableTCPIP = true;
        services.postgrest = {
          pgpassFile = "/run/secrets/.pgpass";
          jwtSecretFile = "/run/secrets/jwt.secret";
          settings.db-uri.host = "localhost";
          settings.db-uri.user = "postgrest_with_password";
          settings.server-port = 3000;
          settings.server-unix-socket = null;
        };
      };
    };

  extraPythonPackages = p: [ p.pyjwt ];

  testScript =
    { nodes, ... }:
    let
      withSecrets = "${nodes.machine.system.build.toplevel}/specialisation/withSecrets";
    in
    ''
      import jwt

      machine.wait_for_unit("postgresql.service")

      def wait_for_postgrest():
          machine.wait_for_unit("postgrest.service")
          machine.wait_until_succeeds("curl --fail -s http://localhost:3001/ready", timeout=30)

      with subtest("anonymous access"):
          wait_for_postgrest()
          machine.succeed(
            "curl --fail-with-body --no-progress-meter --unix-socket /run/postgrest/postgrest.sock http://localhost",
            timeout=2
          )

      machine.execute("""
        mkdir -p /run/secrets
        echo "*:*:*:*:password" > /run/secrets/.pgpass
        echo reallyreallyreallyreallyverysafe > /run/secrets/jwt.secret
      """)

      with subtest("authenticated access"):
          machine.succeed("${withSecrets}/bin/switch-to-configuration test >&2")
          wait_for_postgrest()
          token = jwt.encode({ "role": "authenticated" }, "reallyreallyreallyreallyverysafe")
          machine.succeed(
            f"curl --fail-with-body --no-progress-meter -H 'Authorization: Bearer {token}' http://localhost:3000",
            timeout=2
          )
    '';
}
Loading