From: Denis Laxalde Date: Wed, 17 Apr 2024 08:35:45 +0000 (+0200) Subject: feat: add libpq interface to change a password X-Git-Tag: 3.2.0~22 X-Git-Url: http://git.ipfire.org/gitweb/gitweb.cgi?a=commitdiff_plain;h=23643f5a43ede28acdf16c71b1fdb4612b10ed8f;p=thirdparty%2Fpsycopg.git feat: add libpq interface to change a password 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. --- diff --git a/docs/api/pq.rst b/docs/api/pq.rst index a0d304c6e..82922ef25 100644 --- a/docs/api/pq.rst +++ b/docs/api/pq.rst @@ -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 diff --git a/docs/news.rst b/docs/news.rst index d19bd2dc4..9893cd22a 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -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 diff --git a/psycopg/psycopg/errors.py b/psycopg/psycopg/errors.py index e02fe8556..0f5218ba5 100644 --- a/psycopg/psycopg/errors.py +++ b/psycopg/psycopg/errors.py @@ -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() diff --git a/psycopg/psycopg/pq/_pq_ctypes.py b/psycopg/psycopg/pq/_pq_ctypes.py index 787ab9999..a76a89871 100644 --- a/psycopg/psycopg/pq/_pq_ctypes.py +++ b/psycopg/psycopg/pq/_pq_ctypes.py @@ -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 diff --git a/psycopg/psycopg/pq/_pq_ctypes.pyi b/psycopg/psycopg/pq/_pq_ctypes.pyi index 20da60ac0..265861176 100644 --- a/psycopg/psycopg/pq/_pq_ctypes.pyi +++ b/psycopg/psycopg/pq/_pq_ctypes.pyi @@ -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 diff --git a/psycopg/psycopg/pq/abc.py b/psycopg/psycopg/pq/abc.py index c439c85a1..8b98c7d01 100644 --- a/psycopg/psycopg/pq/abc.py +++ b/psycopg/psycopg/pq/abc.py @@ -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 diff --git a/psycopg/psycopg/pq/pq_ctypes.py b/psycopg/psycopg/pq/pq_ctypes.py index fe68fbc53..eba9872c4 100644 --- a/psycopg/psycopg/pq/pq_ctypes.py +++ b/psycopg/psycopg/pq/pq_ctypes.py @@ -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: diff --git a/psycopg_c/psycopg_c/pq/libpq.pxd b/psycopg_c/psycopg_c/pq/libpq.pxd index ec5e5f4c4..747ddf59d 100644 --- a/psycopg_c/psycopg_c/pq/libpq.pxd +++ b/psycopg_c/psycopg_c/pq/libpq.pxd @@ -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 diff --git a/psycopg_c/psycopg_c/pq/pgconn.pyx b/psycopg_c/psycopg_c/pq/pgconn.pyx index 9f84a4b34..6117abd1e 100644 --- a/psycopg_c/psycopg_c/pq/pgconn.pyx +++ b/psycopg_c/psycopg_c/pq/pgconn.pyx @@ -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, exec_status) diff --git a/tests/pq/test_pgconn.py b/tests/pq/test_pgconn.py index f1010b1ec..43db54b00 100644 --- a/tests/pq/test_pgconn.py +++ b/tests/pq/test_pgconn.py @@ -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")