]> git.ipfire.org Git - thirdparty/psycopg.git/commitdiff
feat: add libpq interface to change a password
authorDenis Laxalde <denis.laxalde@dalibo.com>
Wed, 17 Apr 2024 08:35:45 +0000 (10:35 +0200)
committerDaniele Varrazzo <daniele.varrazzo@gmail.com>
Thu, 30 May 2024 20:53:32 +0000 (22:53 +0200)
See https://git.postgresql.org/gitweb/?p=postgresql.git;a=commit;h=a7be2a6c262d5352756d909b29c419ea5e5fa1d9:
> drivers built on top of libpq should expose this function and its
> use should be generally encouraged over doing ALTER USER directly for
> password changes.

The test case assumes that the role connected to postgres has CREATEROLE
rights. If this is not true, the test is skipped.

docs/api/pq.rst
docs/news.rst
psycopg/psycopg/errors.py
psycopg/psycopg/pq/_pq_ctypes.py
psycopg/psycopg/pq/_pq_ctypes.pyi
psycopg/psycopg/pq/abc.py
psycopg/psycopg/pq/pq_ctypes.py
psycopg_c/psycopg_c/pq/libpq.pxd
psycopg_c/psycopg_c/pq/pgconn.pyx
tests/pq/test_pgconn.py

index a0d304c6e3fbd8485afdef77273fa2c7e4f50783..82922ef25317e84813d9040cc4387a0981ddf9a9 100644 (file)
@@ -99,6 +99,8 @@ Objects wrapping libpq structures and functions
            >>> encrypted = conn.pgconn.encrypt_password(password.encode(enc), rolename.encode(enc))
            b'SCRAM-SHA-256$4096:...
 
+    .. .. automethod:: change_password FIXME: needs libpq 17's docs
+
     .. automethod:: trace
     .. automethod:: set_trace_flags
     .. automethod:: untrace
index d19bd2dc4c01281712f2a11dc2359d1dc6821cff..9893cd22a094c4e53a315e04b002f673ccd2a17c 100644 (file)
@@ -42,6 +42,8 @@ Psycopg 3.2 (unreleased)
   termination (:ticket:`#754`).
 - Add support for libpq function to retrieve results in chunks introduced in
   libpq v17 (:ticket:`#793`).
+- Add support for libpq function to change role passwords introduced in
+  libpq v17 (:ticket:`#818`).
 
 .. __: https://numpy.org/doc/stable/reference/arrays.scalars.html#built-in-scalar-types
 
index e02fe8556288005be799caf87057a5829f9df5b4..0f5218ba59b9d8b987ec54101c6a654e168d3b1e 100644 (file)
@@ -204,6 +204,9 @@ class FinishedPGconn:
     def encrypt_password(self, *args: Any) -> NoReturn:
         self._raise()
 
+    def change_password(self, *args: Any) -> NoReturn:
+        self._raise()
+
     def make_empty_result(self, *args: Any) -> NoReturn:
         self._raise()
 
index 787ab99992fc6226bbe753a850779ed173b5dcbc..a76a898713b514f9f84c901a5dd2431f1ed0b53d 100644 (file)
@@ -688,6 +688,17 @@ if libpq_version >= 100000:
 else:
     PQencryptPasswordConn = not_supported_before("PQencryptPasswordConn", 100000)
 
+if libpq_version >= 170000:
+    PQchangePassword = pq.PQchangePassword
+    PQchangePassword.argtypes = [
+        PGconn_ptr,
+        c_char_p,
+        c_char_p,
+    ]
+    PQchangePassword.restype = PGresult_ptr
+else:
+    PQchangePassword = not_supported_before("PQchangePassword", 170000)
+
 PQmakeEmptyPGresult = pq.PQmakeEmptyPGresult
 PQmakeEmptyPGresult.argtypes = [PGconn_ptr, c_int]
 PQmakeEmptyPGresult.restype = PGresult_ptr
index 20da60ac04cea32b09c6824ce106b92b2ff4f157..265861176caf823b9fb0cebded9b39996827b8a1 100644 (file)
@@ -216,6 +216,7 @@ def PQfreeCancel(arg1: Optional[PGcancel_struct]) -> None: ...
 def PQputCopyData(arg1: Optional[PGconn_struct], arg2: bytes, arg3: int) -> int: ...
 def PQuntrace(arg1: Optional[PGconn_struct]) -> None: ...
 def PQfreemem(arg1: Any) -> None: ...
+def PQchangePassword(arg1: Optional[PGconn_struct], arg2: bytes, arg3: bytes) -> PGresult_struct: ...
 def PQmakeEmptyPGresult(arg1: Optional[PGconn_struct], arg2: int) -> PGresult_struct: ...
 def PQinitOpenSSL(arg1: int, arg2: int) -> None: ...
 # autogenerated: end
index c439c85a14e7461dfe1773f2c0481955e60de870..8b98c7d010e73629347cc085a56d402b6654f13c 100644 (file)
@@ -204,6 +204,8 @@ class PGconn(Protocol):
         self, passwd: bytes, user: bytes, algorithm: Optional[bytes] = None
     ) -> bytes: ...
 
+    def change_password(self, user: bytes, passwd: bytes) -> None: ...
+
     def make_empty_result(self, exec_status: int) -> "PGresult": ...
 
     @property
index fe68fbc53b9936edb1d651ba060a474618036802..eba9872c4ac51c36b8cff2627a9698e2ca5ed407 100644 (file)
@@ -704,6 +704,20 @@ class PGconn:
         impl.PQfreemem(out)
         return rv
 
+    def change_password(self, user: bytes, passwd: bytes) -> None:
+        """
+        Change a PostgreSQL password.
+
+        :raises OperationalError: if the command to change password failed.
+
+        See :pq:`PQchangePassword` for details.
+        """
+        res = impl.PQchangePassword(self._pgconn_ptr, user, passwd)
+        if impl.PQresultStatus(res) != ExecStatus.COMMAND_OK:
+            raise e.OperationalError(
+                f"failed to change password change command: {error_message(self)}"
+            )
+
     def make_empty_result(self, exec_status: int) -> "PGresult":
         rv = impl.PQmakeEmptyPGresult(self._pgconn_ptr, exec_status)
         if not rv:
index ec5e5f4c444951556b3f93c98ec7885d6dad75f0..747ddf59df14ea4d42a8e8dd13fd5a2771101118 100644 (file)
@@ -288,6 +288,7 @@ cdef extern from "libpq-fe.h":
     void PQconninfoFree(PQconninfoOption *connOptions)
     char *PQencryptPasswordConn(
         PGconn *conn, const char *passwd, const char *user, const char *algorithm);
+    PGresult *PQchangePassword(PGconn *conn, const char *user, const char *passwd);
     PGresult *PQmakeEmptyPGresult(PGconn *conn, ExecStatusType status)
     int PQsetResultAttrs(PGresult *res, int numAttributes, PGresAttDesc *attDescs)
     int PQlibVersion()
@@ -342,6 +343,7 @@ typedef enum {
 
 #if PG_VERSION_NUM < 170000
 typedef struct pg_cancel_conn PGcancelConn;
+#define PQchangePassword(conn, user, passwd) NULL
 #define PQclosePrepared(conn, name) NULL
 #define PQclosePortal(conn, name) NULL
 #define PQsendClosePrepared(conn, name) 0
index 9f84a4b34715c96d7692b38f0ce0fa251eb91e04..6117abd1e474447cb07bd46dcc097be9e70ede06 100644 (file)
@@ -26,6 +26,7 @@ import sys
 
 from psycopg.pq import Format as PqFormat, Trace, version_pretty
 from psycopg.pq.misc import PGnotify, connection_summary
+from psycopg.pq._enums import ExecStatus
 from psycopg_c.pq cimport PQBuffer
 
 cdef object _check_supported(fname, int pgversion):
@@ -593,6 +594,18 @@ cdef class PGconn:
         libpq.PQfreemem(out)
         return rv
 
+    def change_password(
+        self, const char *user, const char *passwd
+    ) -> None:
+        _check_supported("PQchangePassword", 170000)
+
+        cdef libpq.PGresult *res
+        res = libpq.PQchangePassword(self._pgconn_ptr, user, passwd)
+        if libpq.PQresultStatus(res) != ExecStatus.COMMAND_OK:
+            raise e.OperationalError(
+                f"password encryption failed: {error_message(self)}"
+            )
+
     def make_empty_result(self, int exec_status) -> PGresult:
         cdef libpq.PGresult *rv = libpq.PQmakeEmptyPGresult(
             self._pgconn_ptr, <libpq.ExecStatusType>exec_status)
index f1010b1ec863f31132c0c85491248d1559e5a49a..43db54b00658b4182d758798c1df757b0da49d82 100644 (file)
@@ -628,6 +628,42 @@ def test_trace_nonlinux(pgconn):
         pgconn.trace(1)
 
 
+@pytest.mark.libpq(">= 17")
+def test_change_password_error(pgconn):
+    with pytest.raises(psycopg.OperationalError, match='role "ashesh" does not exist'):
+        pgconn.change_password(b"ashesh", b"psycopg")
+
+
+@pytest.fixture
+def role(pgconn: PGconn) -> Iterator[tuple[bytes, bytes]]:
+    user, passwd = "ashesh", "psycopg2"
+    r = pgconn.exec_(f"CREATE USER {user} LOGIN PASSWORD '{passwd}'".encode())
+    if r.status != pq.ExecStatus.COMMAND_OK:
+        pytest.skip(f"cannot create a PostgreSQL role: {r.error_message.decode()}")
+    yield user.encode(), passwd.encode()
+    r = pgconn.exec_(f"DROP USER {user}".encode())
+    if r.status != pq.ExecStatus.COMMAND_OK:
+        pytest.fail(f"failed to drop {user} role: {r.error_message.decode()}")
+
+
+@pytest.mark.libpq(">= 17")
+def test_change_password(pgconn, dsn, role):
+    user, passwd = role
+    conninfo = {e.keyword: e.val for e in pq.Conninfo.parse(dsn.encode()) if e.val}
+    conninfo |= {
+        b"dbname": b"postgres",
+        b"user": user,
+        b"password": passwd,
+    }
+    conn = pq.PGconn.connect(b" ".join(b"%s='%s'" % item for item in conninfo.items()))
+    assert conn.status == pq.ConnStatus.OK, conn.error_message
+
+    pgconn.change_password(user, b"psycopg")
+    conninfo[b"password"] = b"psycopg"
+    conn = pq.PGconn.connect(b" ".join(b"%s='%s'" % item for item in conninfo.items()))
+    assert conn.status == pq.ConnStatus.OK, conn.error_message
+
+
 @pytest.mark.libpq(">= 10")
 def test_encrypt_password(pgconn):
     enc = pgconn.encrypt_password(b"psycopg2", b"ashesh", b"md5")