Merge ~cjwatson/launchpad:sqlbase-connect-set-role-after-connecting into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: f0e405fde4eb69c5d800201af66100ae1b136670
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:sqlbase-connect-set-role-after-connecting
Merge into: launchpad:master
Diff against target: 316 lines (+188/-66)
5 files modified
dev/null (+0/-41)
lib/lp/services/database/sqlbase.py (+23/-18)
lib/lp/services/database/tests/test_sqlbase.py (+156/-7)
lib/lp/services/webapp/adapter.py (+3/-0)
lib/lp/testing/pgsql.py (+6/-0)
Reviewer Review Type Date Requested Status
William Grant code Approve
Guruprasad Approve
Review via email: mp+439909@code.launchpad.net

Commit message

Rework sqlbase.connect to honour set_role_after_connecting

Description of the change

Commit 31b283765f2cb0c3c6551c46c02dd84cd51b505e reworked `LaunchpadDatabase.raw_connect` to add the ability to switch database role after connecting; this covers everything that uses the Storm store interface. However, a few scripts (including those that run without the Zope component architecture and those that do some particularly arcane things with database connections) construct database connections a little more directly, typically via `lp.services.database.sqlbase.connect`. The one I've noticed is `librarian-gc`, which failed to work in a charmed deployment configured with `set_role_after_connecting: True`.

To fix this, rework `sqlbase.connect` so that it honours `set_role_after_connecting` as well. Ideally we'd now also rework `LaunchpadDatabase.raw_connect` on top of `sqlbase.connect` so that we only have one implementation of this logic, but that's difficult since in that case the actual connection is made by code in a base class, so I've just left an XXX comment for now.

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) wrote :

I amended this to remove my change to always use `dbconfig.dbuser` as the user to connect to, since that turned out to break `make schema`.

Revision history for this message
Guruprasad (lgp171188) wrote :

LGTM ๐Ÿ‘๐Ÿผ

review: Approve
Revision history for this message
William Grant (wgrant) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/services/database/doc/sqlbaseconnect.rst b/lib/lp/services/database/doc/sqlbaseconnect.rst
2deleted file mode 100644
3index 6e9385e..0000000
4--- a/lib/lp/services/database/doc/sqlbaseconnect.rst
5+++ /dev/null
6@@ -1,41 +0,0 @@
7-Ensure that lp.services.database.sqlbase connects as we expect.
8-
9- >>> from lp.services.config import config
10- >>> from lp.services.database.sqlbase import (
11- ... connect,
12- ... ISOLATION_LEVEL_DEFAULT,
13- ... ISOLATION_LEVEL_SERIALIZABLE,
14- ... )
15-
16- >>> def do_connect(user, dbname=None, isolation=ISOLATION_LEVEL_DEFAULT):
17- ... con = connect(user=user, dbname=dbname, isolation=isolation)
18- ... cur = con.cursor()
19- ... cur.execute("SHOW session_authorization")
20- ... who = cur.fetchone()[0]
21- ... cur.execute("SELECT current_database()")
22- ... where = cur.fetchone()[0]
23- ... cur.execute("SHOW transaction_isolation")
24- ... how = cur.fetchone()[0]
25- ... print(
26- ... "Connected as %s to %s in %s isolation." % (who, where, how)
27- ... )
28- ...
29-
30-Specifying the user connects as that user.
31-
32- >>> do_connect(user=config.launchpad_session.dbuser)
33- Connected as session to ... in read committed isolation.
34-
35-Specifying the database name connects to that database.
36-
37- >>> do_connect(user=config.launchpad.dbuser, dbname="launchpad_empty")
38- Connected as launchpad_main to launchpad_empty in read committed
39- isolation.
40-
41-Specifying the isolation level works too.
42-
43- >>> do_connect(
44- ... user=config.launchpad.dbuser,
45- ... isolation=ISOLATION_LEVEL_SERIALIZABLE,
46- ... )
47- Connected as launchpad_main to ... in serializable isolation.
48diff --git a/lib/lp/services/database/sqlbase.py b/lib/lp/services/database/sqlbase.py
49index c02d042..04b3c7f 100644
50--- a/lib/lp/services/database/sqlbase.py
51+++ b/lib/lp/services/database/sqlbase.py
52@@ -37,6 +37,8 @@ from psycopg2.extensions import (
53 ISOLATION_LEVEL_READ_COMMITTED,
54 ISOLATION_LEVEL_REPEATABLE_READ,
55 ISOLATION_LEVEL_SERIALIZABLE,
56+ make_dsn,
57+ parse_dsn,
58 )
59 from storm.databases.postgres import compile as postgres_compile
60 from storm.expr import State
61@@ -576,27 +578,30 @@ def connect(user=None, dbname=None, isolation=ISOLATION_LEVEL_DEFAULT):
62
63 Default database name is the one specified in the main configuration file.
64 """
65- con = psycopg2.connect(connect_string(user=user, dbname=dbname))
66- con.set_isolation_level(isolation)
67- return con
68-
69-
70-def connect_string(user=None, dbname=None):
71- """Return a PostgreSQL connection string.
72-
73- Allows you to pass the generated connection details to external
74- programs like pg_dump or embed in slonik scripts.
75- """
76 # We must connect to the read-write DB here, so we use rw_main_primary
77 # directly.
78- from lp.services.database.postgresql import ConnectionString
79-
80- con_str = ConnectionString(dbconfig.rw_main_primary)
81- if user is not None:
82- con_str.user = user
83+ parsed_dsn = parse_dsn(dbconfig.rw_main_primary)
84+ dsn_kwargs = {}
85 if dbname is not None:
86- con_str.dbname = dbname
87- return str(con_str)
88+ dsn_kwargs["dbname"] = dbname
89+ if dbconfig.set_role_after_connecting:
90+ assert "user" in parsed_dsn, (
91+ "With set_role_after_connecting, database username must be "
92+ "specified in connection string (%s)." % dbconfig.rw_main_primary
93+ )
94+ else:
95+ assert "user" not in parsed_dsn, (
96+ "Database username must not be specified in connection string "
97+ "(%s)." % dbconfig.rw_main_primary
98+ )
99+ dsn_kwargs["user"] = user
100+ dsn = make_dsn(dbconfig.rw_main_primary, **dsn_kwargs)
101+
102+ con = psycopg2.connect(dsn)
103+ con.set_isolation_level(isolation)
104+ if dbconfig.set_role_after_connecting and user != parsed_dsn["user"]:
105+ con.cursor().execute("SET ROLE %s", (user,))
106+ return con
107
108
109 class cursor:
110diff --git a/lib/lp/services/database/tests/test_sqlbase.py b/lib/lp/services/database/tests/test_sqlbase.py
111index 35f12a7..dc88a38 100644
112--- a/lib/lp/services/database/tests/test_sqlbase.py
113+++ b/lib/lp/services/database/tests/test_sqlbase.py
114@@ -4,16 +4,165 @@
115 import doctest
116 import unittest
117 from doctest import ELLIPSIS, NORMALIZE_WHITESPACE, REPORT_NDIFF
118+from typing import Tuple
119
120+from psycopg2.errors import InsufficientPrivilege
121+from psycopg2.extensions import connection, parse_dsn
122+
123+from lp.services.config import config, dbconfig
124 from lp.services.database import sqlbase
125+from lp.testing import TestCase
126+from lp.testing.layers import DatabaseLayer, ZopelessDatabaseLayer
127
128
129-def test_suite():
130- optionflags = ELLIPSIS | NORMALIZE_WHITESPACE | REPORT_NDIFF
131- dt_suite = doctest.DocTestSuite(sqlbase, optionflags=optionflags)
132- return unittest.TestSuite((dt_suite,))
133+class TestConnect(TestCase):
134+
135+ layer = ZopelessDatabaseLayer
136+
137+ @staticmethod
138+ def examineConnection(con: connection) -> Tuple[str, str, str]:
139+ with con.cursor() as cur:
140+ cur.execute("SHOW session_authorization")
141+ who = cur.fetchone()[0]
142+ cur.execute("SELECT current_database()")
143+ where = cur.fetchone()[0]
144+ cur.execute("SHOW transaction_isolation")
145+ how = cur.fetchone()[0]
146+ return (who, where, how)
147+
148+ def test_honours_user(self):
149+ con = sqlbase.connect(user=config.launchpad_session.dbuser)
150+ who, _, how = self.examineConnection(con)
151+ self.assertEqual(("session", "read committed"), (who, how))
152+
153+ def test_honours_dbname(self):
154+ con = sqlbase.connect(
155+ user=config.launchpad.dbuser, dbname="launchpad_empty"
156+ )
157+ self.assertEqual(
158+ ("launchpad_main", "launchpad_empty", "read committed"),
159+ self.examineConnection(con),
160+ )
161+
162+ def test_honours_isolation(self):
163+ con = sqlbase.connect(
164+ user=config.launchpad.dbuser,
165+ isolation=sqlbase.ISOLATION_LEVEL_SERIALIZABLE,
166+ )
167+ who, _, how = self.examineConnection(con)
168+ self.assertEqual(("launchpad_main", "serializable"), (who, how))
169+
170+ def getCurrentUser(self, con: connection) -> None:
171+ with con.cursor() as cur:
172+ cur.execute("SELECT current_user")
173+ return cur.fetchone()[0]
174+
175+ def test_refuses_connstring_with_user(self):
176+ connstr = "dbname=%s user=foo" % DatabaseLayer._db_fixture.dbname
177+ dbconfig.override(rw_main_primary=connstr)
178+ self.assertRaisesWithContent(
179+ AssertionError,
180+ "Database username must not be specified in connection string "
181+ "(%s)." % connstr,
182+ sqlbase.connect,
183+ )
184
185+ def test_refuses_connstring_uri_with_user(self):
186+ connstr = "postgresql://foo@/%s" % DatabaseLayer._db_fixture.dbname
187+ dbconfig.override(rw_main_primary=connstr)
188+ self.assertRaisesWithContent(
189+ AssertionError,
190+ "Database username must not be specified in connection string "
191+ "(%s)." % connstr,
192+ sqlbase.connect,
193+ )
194
195-if __name__ == "__main__":
196- runner = unittest.TextTestRunner()
197- runner.run(test_suite())
198+ def test_accepts_connstring_without_user(self):
199+ connstr = "dbname=%s" % DatabaseLayer._db_fixture.dbname
200+ dbconfig.override(rw_main_primary=connstr)
201+ con = sqlbase.connect(user="ro")
202+ self.assertEqual(
203+ {"dbname": DatabaseLayer._db_fixture.dbname, "user": "ro"},
204+ parse_dsn(con.dsn),
205+ )
206+ self.assertEqual("ro", self.getCurrentUser(con))
207+
208+ def test_accepts_connstring_uri_without_user(self):
209+ connstr = "postgresql:///%s" % DatabaseLayer._db_fixture.dbname
210+ dbconfig.override(rw_main_primary=connstr)
211+ con = sqlbase.connect(user="ro")
212+ self.assertEqual(
213+ {"dbname": DatabaseLayer._db_fixture.dbname, "user": "ro"},
214+ parse_dsn(con.dsn),
215+ )
216+ self.assertEqual("ro", self.getCurrentUser(con))
217+
218+ def test_set_role_after_connecting_refuses_connstring_without_user(self):
219+ connstr = "dbname=%s" % DatabaseLayer._db_fixture.dbname
220+ dbconfig.override(
221+ set_role_after_connecting=True, rw_main_primary=connstr
222+ )
223+ self.assertRaisesWithContent(
224+ AssertionError,
225+ "With set_role_after_connecting, database username must be "
226+ "specified in connection string (%s)." % connstr,
227+ sqlbase.connect,
228+ user="read",
229+ )
230+
231+ def test_set_role_after_connecting_refuses_connstring_uri_without_user(
232+ self,
233+ ):
234+ connstr = "postgresql:///%s" % DatabaseLayer._db_fixture.dbname
235+ dbconfig.override(
236+ set_role_after_connecting=True, rw_main_primary=connstr
237+ )
238+ self.assertRaisesWithContent(
239+ AssertionError,
240+ "With set_role_after_connecting, database username must be "
241+ "specified in connection string (%s)." % connstr,
242+ sqlbase.connect,
243+ user="read",
244+ )
245+
246+ def test_set_role_after_connecting_accepts_connstring_with_user(self):
247+ connstr = "dbname=%s user=ro" % DatabaseLayer._db_fixture.dbname
248+ dbconfig.override(
249+ set_role_after_connecting=True, rw_main_primary=connstr
250+ )
251+ con = sqlbase.connect(user="read")
252+ self.assertEqual(
253+ {"dbname": DatabaseLayer._db_fixture.dbname, "user": "ro"},
254+ parse_dsn(con.dsn),
255+ )
256+ self.assertEqual("read", self.getCurrentUser(con))
257+
258+ def test_set_role_after_connecting_accepts_connstring_uri_with_user(self):
259+ connstr = "postgresql://ro@/%s" % DatabaseLayer._db_fixture.dbname
260+ dbconfig.override(
261+ set_role_after_connecting=True, rw_main_primary=connstr
262+ )
263+ con = sqlbase.connect(user="read")
264+ self.assertEqual(
265+ {"dbname": DatabaseLayer._db_fixture.dbname, "user": "ro"},
266+ parse_dsn(con.dsn),
267+ )
268+ self.assertEqual("read", self.getCurrentUser(con))
269+
270+ def test_set_role_after_connecting_not_member(self):
271+ connstr = "dbname=%s user=ro" % DatabaseLayer._db_fixture.dbname
272+ dbconfig.override(
273+ set_role_after_connecting=True, rw_main_primary=connstr
274+ )
275+ self.assertRaises(
276+ InsufficientPrivilege, sqlbase.connect, user="launchpad_main"
277+ )
278+
279+
280+def test_suite():
281+ suite = unittest.TestSuite()
282+ loader = unittest.TestLoader()
283+ suite.addTest(loader.loadTestsFromTestCase(TestConnect))
284+ optionflags = ELLIPSIS | NORMALIZE_WHITESPACE | REPORT_NDIFF
285+ suite.addTest(doctest.DocTestSuite(sqlbase, optionflags=optionflags))
286+ return suite
287diff --git a/lib/lp/services/webapp/adapter.py b/lib/lp/services/webapp/adapter.py
288index 2e16397..a9910a5 100644
289--- a/lib/lp/services/webapp/adapter.py
290+++ b/lib/lp/services/webapp/adapter.py
291@@ -422,6 +422,9 @@ class LaunchpadDatabase(Postgres):
292 self.name = uri.database
293
294 def raw_connect(self):
295+ # XXX cjwatson 2023-03-28: Can we rework this on top of
296+ # lp.services.database.sqlbase.connect somehow so that we only need
297+ # one implementation of the set_role_after_connecting logic?
298 try:
299 realm, flavor = self._uri.database.split("-")
300 except ValueError:
301diff --git a/lib/lp/testing/pgsql.py b/lib/lp/testing/pgsql.py
302index 7069d83..68aa01f 100644
303--- a/lib/lp/testing/pgsql.py
304+++ b/lib/lp/testing/pgsql.py
305@@ -126,6 +126,12 @@ class CursorWrapper:
306 CursorWrapper.last_executed_sql.append(args[0])
307 return self.real_cursor.execute(*args, **kwargs)
308
309+ def __enter__(self):
310+ return self.real_cursor.__enter__()
311+
312+ def __exit__(self, exc_type, exc_value, exc_tb):
313+ return self.real_cursor.__exit__(exc_type, exc_value, exc_tb)
314+
315 def __getattr__(self, key):
316 return getattr(self.real_cursor, key)
317

Subscribers

People subscribed via source and target branches

to status/vote changes: