Merge ~cjwatson/launchpad:db-roles into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: 31b283765f2cb0c3c6551c46c02dd84cd51b505e
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:db-roles
Merge into: launchpad:master
Diff against target: 234 lines (+122/-14)
4 files modified
lib/lp/services/config/__init__.py (+1/-0)
lib/lp/services/config/schema-lazr.conf (+7/-0)
lib/lp/services/webapp/adapter.py (+16/-5)
lib/lp/services/webapp/tests/test_adapter.py (+98/-9)
Reviewer Review Type Date Requested Status
Andrey Fedoseev (community) Approve
Review via email: mp+432027@code.launchpad.net

Commit message

Add ability to switch DB role after connecting

Description of the change

Our traditional strategy for deploying database credentials has been to have a password for each database user we want to use, and to deploy those to `.pgpass` files on each machine that needs to use each user. This works, but it's quite manual and cumbersome. With charmed deployments it gets harder: each `db` relation can only communicate a single set of credentials, which means that a script server would be stuck having to have perhaps tens of relations representing the wide variety of database users that the scripts it runs want to use: as well as being ugly, I expect this would also make Juju very slow.

The natural approach with the PostgreSQL charm (or when emulating it using `external-services`) is to set the `roles` attribute on the `db` relation, which causes the relevant user to be made a member of that set of roles. The `SET ROLE` SQL command can then switch to that role.

I don't see a straightforward way to make these two strategies entirely compatible, but it's simple enough to add a configuration flag that selects which strategy we're using, so I've taken that approach. `database.set_role_after_connecting` defaults to false which indicates our traditional strategy; setting it to True causes us to use the `SET ROLE` command after connecting instead, expecting the session user to have been made a member of the target role.

To post a comment you must log in.
Revision history for this message
Andrey Fedoseev (andrey-fedoseev) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/services/config/__init__.py b/lib/lp/services/config/__init__.py
2index 859a38a..24ac875 100644
3--- a/lib/lp/services/config/__init__.py
4+++ b/lib/lp/services/config/__init__.py
5@@ -289,6 +289,7 @@ class DatabaseConfig:
6 "db_statement_timeout",
7 "db_statement_timeout_precision",
8 "isolation_level",
9+ "set_role_after_connecting",
10 "soft_request_timeout",
11 "storm_cache",
12 "storm_cache_size",
13diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
14index cf9c276..480d818 100644
15--- a/lib/lp/services/config/schema-lazr.conf
16+++ b/lib/lp/services/config/schema-lazr.conf
17@@ -599,6 +599,13 @@ storm_cache_size: 10000
18 # datatype: existing_directory
19 replication_logdir: database/replication
20
21+# If True, the connection string must contain a username, and the database
22+# adapter will switch to the target `dbuser` using `SET ROLE` after
23+# connecting (the session user must be a member of the target role). If
24+# False, the connection string must not contain a username, and the database
25+# adapter will connect directly as the target `dbuser`.
26+set_role_after_connecting: False
27+
28
29 [diff]
30 # The maximum size in bytes to read from the librarian to make available in
31diff --git a/lib/lp/services/webapp/adapter.py b/lib/lp/services/webapp/adapter.py
32index a2585dd..a1b67ed 100644
33--- a/lib/lp/services/webapp/adapter.py
34+++ b/lib/lp/services/webapp/adapter.py
35@@ -463,16 +463,24 @@ class LaunchpadDatabase(Postgres):
36 # is reconnected it pays attention to any config changes.
37 config_entry = "%s_%s" % (realm, flavor)
38 connection_string = getattr(dbconfig, config_entry)
39- assert "user" not in parse_dsn(connection_string), (
40- "Database username should not be specified in "
41- "connection string (%s)." % connection_string
42- )
43
44 # Try to lookup dbuser using the $realm_dbuser key. If this fails,
45 # fallback to the dbuser key.
46 dbuser = getattr(dbconfig, "%s_dbuser" % realm, dbconfig.dbuser)
47+ parsed_dsn = parse_dsn(connection_string)
48
49- self._dsn = make_dsn(connection_string, user=dbuser)
50+ if dbconfig.set_role_after_connecting:
51+ assert "user" in parsed_dsn, (
52+ "With set_role_after_connecting, database username must be "
53+ "specified in connection string (%s)." % connection_string
54+ )
55+ self._dsn = make_dsn(connection_string)
56+ else:
57+ assert "user" not in parsed_dsn, (
58+ "Database username must not be specified in "
59+ "connection string (%s)." % connection_string
60+ )
61+ self._dsn = make_dsn(connection_string, user=dbuser)
62
63 flags = _get_dirty_commit_flags()
64
65@@ -483,6 +491,9 @@ class LaunchpadDatabase(Postgres):
66
67 raw_connection = super().raw_connect()
68
69+ if dbconfig.set_role_after_connecting and dbuser != parsed_dsn["user"]:
70+ raw_connection.cursor().execute("SET ROLE %s", (dbuser,))
71+
72 # Set read only mode for the session.
73 # An alternative would be to use the _ro users generated by
74 # security.py, but this would needlessly double the number
75diff --git a/lib/lp/services/webapp/tests/test_adapter.py b/lib/lp/services/webapp/tests/test_adapter.py
76index ce03209..f409934 100644
77--- a/lib/lp/services/webapp/tests/test_adapter.py
78+++ b/lib/lp/services/webapp/tests/test_adapter.py
79@@ -1,6 +1,7 @@
80 # Copyright 2022 Canonical Ltd. This software is licensed under the
81 # GNU Affero General Public License version 3 (see the file LICENSE).
82
83+from psycopg2.errors import InsufficientPrivilege
84 from psycopg2.extensions import parse_dsn
85 from zope.component import getUtility
86
87@@ -19,36 +20,42 @@ class TestLaunchpadDatabase(TestCase):
88
89 layer = DatabaseFunctionalLayer
90
91- def test_refuses_connection_string_with_user(self):
92+ def setUp(self):
93+ super().setUp()
94 self.addCleanup(dbconfig.reset)
95+
96+ def assertCurrentUser(self, store, user):
97+ self.assertEqual(
98+ user, store.execute("SELECT current_user").get_one()[0]
99+ )
100+
101+ def test_refuses_connstring_with_user(self):
102 connstr = "dbname=%s user=foo" % DatabaseLayer._db_fixture.dbname
103 dbconfig.override(rw_main_primary=connstr)
104 disconnect_stores()
105 self.assertRaisesWithContent(
106 AssertionError,
107- "Database username should not be specified in connection string "
108+ "Database username must not be specified in connection string "
109 "(%s)." % connstr,
110 getUtility(IStoreSelector).get,
111 MAIN_STORE,
112 PRIMARY_FLAVOR,
113 )
114
115- def test_refuses_connection_string_uri_with_user(self):
116- self.addCleanup(dbconfig.reset)
117+ def test_refuses_connstring_uri_with_user(self):
118 connstr = "postgresql://foo@/%s" % DatabaseLayer._db_fixture.dbname
119 dbconfig.override(rw_main_primary=connstr)
120 disconnect_stores()
121 self.assertRaisesWithContent(
122 AssertionError,
123- "Database username should not be specified in connection string "
124+ "Database username must not be specified in connection string "
125 "(%s)." % connstr,
126 getUtility(IStoreSelector).get,
127 MAIN_STORE,
128 PRIMARY_FLAVOR,
129 )
130
131- def test_accepts_connection_string_without_user(self):
132- self.addCleanup(dbconfig.reset)
133+ def test_accepts_connstring_without_user(self):
134 connstr = "dbname=%s" % DatabaseLayer._db_fixture.dbname
135 dbconfig.override(rw_main_primary=connstr, dbuser="ro")
136 disconnect_stores()
137@@ -57,9 +64,9 @@ class TestLaunchpadDatabase(TestCase):
138 {"dbname": DatabaseLayer._db_fixture.dbname, "user": "ro"},
139 parse_dsn(store.get_database()._dsn),
140 )
141+ self.assertCurrentUser(store, "ro")
142
143- def test_accepts_connection_string_uri_without_user(self):
144- self.addCleanup(dbconfig.reset)
145+ def test_accepts_connstring_uri_without_user(self):
146 connstr = "postgresql:///%s" % DatabaseLayer._db_fixture.dbname
147 dbconfig.override(rw_main_primary=connstr, dbuser="ro")
148 disconnect_stores()
149@@ -68,3 +75,85 @@ class TestLaunchpadDatabase(TestCase):
150 {"dbname": DatabaseLayer._db_fixture.dbname, "user": "ro"},
151 parse_dsn(store.get_database()._dsn),
152 )
153+ self.assertCurrentUser(store, "ro")
154+
155+ def test_set_role_after_connecting_refuses_connstring_without_user(self):
156+ connstr = "dbname=%s" % DatabaseLayer._db_fixture.dbname
157+ dbconfig.override(
158+ set_role_after_connecting=True,
159+ rw_main_primary=connstr,
160+ dbuser="read",
161+ )
162+ disconnect_stores()
163+ self.assertRaisesWithContent(
164+ AssertionError,
165+ "With set_role_after_connecting, database username must be "
166+ "specified in connection string (%s)." % connstr,
167+ getUtility(IStoreSelector).get,
168+ MAIN_STORE,
169+ PRIMARY_FLAVOR,
170+ )
171+
172+ def test_set_role_after_connecting_refuses_connstring_uri_without_user(
173+ self,
174+ ):
175+ connstr = "postgresql:///%s" % DatabaseLayer._db_fixture.dbname
176+ dbconfig.override(
177+ set_role_after_connecting=True,
178+ rw_main_primary=connstr,
179+ dbuser="read",
180+ )
181+ disconnect_stores()
182+ self.assertRaisesWithContent(
183+ AssertionError,
184+ "With set_role_after_connecting, database username must be "
185+ "specified in connection string (%s)." % connstr,
186+ getUtility(IStoreSelector).get,
187+ MAIN_STORE,
188+ PRIMARY_FLAVOR,
189+ )
190+
191+ def test_set_role_after_connecting_accepts_connstring_with_user(self):
192+ connstr = "dbname=%s user=ro" % DatabaseLayer._db_fixture.dbname
193+ dbconfig.override(
194+ set_role_after_connecting=True,
195+ rw_main_primary=connstr,
196+ dbuser="read",
197+ )
198+ disconnect_stores()
199+ store = getUtility(IStoreSelector).get(MAIN_STORE, PRIMARY_FLAVOR)
200+ self.assertEqual(
201+ {"dbname": DatabaseLayer._db_fixture.dbname, "user": "ro"},
202+ parse_dsn(store.get_database()._dsn),
203+ )
204+ self.assertCurrentUser(store, "read")
205+
206+ def test_set_role_after_connecting_accepts_connstring_uri_with_user(self):
207+ connstr = "postgresql://ro@/%s" % DatabaseLayer._db_fixture.dbname
208+ dbconfig.override(
209+ set_role_after_connecting=True,
210+ rw_main_primary=connstr,
211+ dbuser="read",
212+ )
213+ disconnect_stores()
214+ store = getUtility(IStoreSelector).get(MAIN_STORE, PRIMARY_FLAVOR)
215+ self.assertEqual(
216+ {"dbname": DatabaseLayer._db_fixture.dbname, "user": "ro"},
217+ parse_dsn(store.get_database()._dsn),
218+ )
219+ self.assertCurrentUser(store, "read")
220+
221+ def test_set_role_after_connecting_not_member(self):
222+ connstr = "dbname=%s user=ro" % DatabaseLayer._db_fixture.dbname
223+ dbconfig.override(
224+ set_role_after_connecting=True,
225+ rw_main_primary=connstr,
226+ dbuser="launchpad_main",
227+ )
228+ disconnect_stores()
229+ self.assertRaises(
230+ InsufficientPrivilege,
231+ getUtility(IStoreSelector).get,
232+ MAIN_STORE,
233+ PRIMARY_FLAVOR,
234+ )

Subscribers

People subscribed via source and target branches

to status/vote changes: