Skip to content

Commit 966620c

Browse files
committed
Merge branch 'main' into sync16
2 parents 894903c + d35d35e commit 966620c

File tree

16 files changed

+720
-64
lines changed

16 files changed

+720
-64
lines changed

lib/charms/postgresql_k8s/v0/postgresql.py

Lines changed: 177 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535

3636
# Increment this PATCH version before using `charmcraft publish-lib` or reset
3737
# to 0 if you are raising the major API version
38-
LIBPATCH = 52
38+
LIBPATCH = 53
3939

4040
# Groups to distinguish HBA access
4141
ACCESS_GROUP_IDENTITY = "identity_access"
@@ -113,6 +113,10 @@ class PostgreSQLGetPostgreSQLVersionError(Exception):
113113
"""Exception raised when retrieving PostgreSQL version fails."""
114114

115115

116+
class PostgreSQLListAccessibleDatabasesForUserError(Exception):
117+
"""Exception raised when retrieving the accessible databases for a user fails."""
118+
119+
116120
class PostgreSQLListGroupsError(Exception):
117121
"""Exception raised when retrieving PostgreSQL groups list fails."""
118122

@@ -190,6 +194,11 @@ def create_access_groups(self) -> None:
190194
try:
191195
with self._connect_to_database() as connection, connection.cursor() as cursor:
192196
for group in ACCESS_GROUPS:
197+
cursor.execute(
198+
SQL("SELECT TRUE FROM pg_roles WHERE rolname={};").format(Literal(group))
199+
)
200+
if cursor.fetchone() is not None:
201+
continue
193202
cursor.execute(
194203
SQL("CREATE ROLE {} NOLOGIN;").format(
195204
Identifier(group),
@@ -622,15 +631,22 @@ def is_tls_enabled(self, check_current_host: bool = False) -> bool:
622631
# Connection errors happen when PostgreSQL has not started yet.
623632
return False
624633

625-
def list_access_groups(self) -> Set[str]:
634+
def list_access_groups(self, current_host=False) -> Set[str]:
626635
"""Returns the list of PostgreSQL database access groups.
627636
637+
Args:
638+
current_host: whether to check the current host
639+
instead of the primary host.
640+
628641
Returns:
629642
List of PostgreSQL database access groups.
630643
"""
631644
connection = None
645+
host = self.current_host if current_host else None
632646
try:
633-
with self._connect_to_database() as connection, connection.cursor() as cursor:
647+
with self._connect_to_database(
648+
database_host=host
649+
) as connection, connection.cursor() as cursor:
634650
cursor.execute(
635651
"SELECT groname FROM pg_catalog.pg_group WHERE groname LIKE '%_access';"
636652
)
@@ -643,16 +659,69 @@ def list_access_groups(self) -> Set[str]:
643659
if connection is not None:
644660
connection.close()
645661

646-
def list_users(self) -> Set[str]:
662+
def list_accessible_databases_for_user(self, user: str, current_host=False) -> Set[str]:
663+
"""Returns the list of accessible databases for a specific user.
664+
665+
Args:
666+
user: the user to check.
667+
current_host: whether to check the current host
668+
instead of the primary host.
669+
670+
Returns:
671+
List of accessible database (the ones where
672+
the user has the CONNECT privilege).
673+
"""
674+
connection = None
675+
host = self.current_host if current_host else None
676+
try:
677+
with self._connect_to_database(
678+
database_host=host
679+
) as connection, connection.cursor() as cursor:
680+
cursor.execute(
681+
SQL(
682+
"SELECT TRUE FROM pg_catalog.pg_user WHERE usename = {} AND usesuper;"
683+
).format(Literal(user))
684+
)
685+
if cursor.fetchone() is not None:
686+
return {"all"}
687+
cursor.execute(
688+
SQL(
689+
"SELECT datname FROM pg_catalog.pg_database WHERE has_database_privilege({}, datname, 'CONNECT') AND NOT datistemplate;"
690+
).format(Literal(user))
691+
)
692+
databases = cursor.fetchall()
693+
return {database[0] for database in databases}
694+
except psycopg2.Error as e:
695+
logger.error(f"Failed to list accessible databases for user {user}: {e}")
696+
raise PostgreSQLListAccessibleDatabasesForUserError() from e
697+
finally:
698+
if connection is not None:
699+
connection.close()
700+
701+
def list_users(self, group: Optional[str] = None, current_host=False) -> Set[str]:
647702
"""Returns the list of PostgreSQL database users.
648703
704+
Args:
705+
group: optional group to filter the users.
706+
current_host: whether to check the current host
707+
instead of the primary host.
708+
649709
Returns:
650710
List of PostgreSQL database users.
651711
"""
652712
connection = None
713+
host = self.current_host if current_host else None
653714
try:
654-
with self._connect_to_database() as connection, connection.cursor() as cursor:
655-
cursor.execute("SELECT usename FROM pg_catalog.pg_user;")
715+
with self._connect_to_database(
716+
database_host=host
717+
) as connection, connection.cursor() as cursor:
718+
if group:
719+
query = SQL(
720+
"SELECT usename FROM (SELECT UNNEST(grolist) AS user_id FROM pg_catalog.pg_group WHERE groname = {}) AS g JOIN pg_catalog.pg_user AS u ON g.user_id = u.usesysid;"
721+
).format(Literal(group))
722+
else:
723+
query = "SELECT usename FROM pg_catalog.pg_user;"
724+
cursor.execute(query)
656725
usernames = cursor.fetchall()
657726
return {username[0] for username in usernames}
658727
except psycopg2.Error as e:
@@ -662,19 +731,27 @@ def list_users(self) -> Set[str]:
662731
if connection is not None:
663732
connection.close()
664733

665-
def list_users_from_relation(self) -> Set[str]:
734+
def list_users_from_relation(self, current_host=False) -> Set[str]:
666735
"""Returns the list of PostgreSQL database users that were created by a relation.
667736
737+
Args:
738+
current_host: whether to check the current host
739+
instead of the primary host.
740+
668741
Returns:
669742
List of PostgreSQL database users.
670743
"""
671744
connection = None
745+
host = self.current_host if current_host else None
672746
try:
673-
with self._connect_to_database() as connection, connection.cursor() as cursor:
747+
with self._connect_to_database(
748+
database_host=host
749+
) as connection, connection.cursor() as cursor:
674750
cursor.execute(
675751
"SELECT usename "
676752
"FROM pg_catalog.pg_user "
677-
"WHERE usename LIKE 'relation_id_%' OR usename LIKE 'relation-%';"
753+
"WHERE usename LIKE 'relation_id_%' OR usename LIKE 'relation-%' "
754+
"OR usename LIKE 'pgbouncer_auth_relation_id_%' OR usename LIKE '%_user_%_%';"
678755
)
679756
usernames = cursor.fetchall()
680757
return {username[0] for username in usernames}
@@ -705,31 +782,100 @@ def set_up_database(self, temp_location: Optional[str] = None) -> None:
705782
connection = None
706783
cursor = None
707784
try:
708-
connection = self._connect_to_database()
709-
cursor = connection.cursor()
710-
711-
if temp_location is not None:
712-
cursor.execute("SELECT TRUE FROM pg_tablespace WHERE spcname='temp';")
785+
with self._connect_to_database(
786+
database="template1"
787+
) as connection, connection.cursor() as cursor:
788+
# Create database function and event trigger to identify users created by PgBouncer.
789+
cursor.execute(
790+
"SELECT TRUE FROM pg_event_trigger WHERE evtname = 'update_pg_hba_on_create_schema';"
791+
)
713792
if cursor.fetchone() is None:
714-
cursor.execute(f"CREATE TABLESPACE temp LOCATION '{temp_location}';")
715-
cursor.execute("GRANT CREATE ON TABLESPACE temp TO public;")
716-
717-
cursor.execute("SELECT TRUE FROM pg_roles WHERE rolname='admin';")
718-
if cursor.fetchone() is None:
719-
# Allow access to the postgres database only to the system users.
720-
cursor.execute("REVOKE ALL PRIVILEGES ON DATABASE postgres FROM PUBLIC;")
721-
cursor.execute("REVOKE CREATE ON SCHEMA public FROM PUBLIC;")
722-
for user in self.system_users:
723-
cursor.execute(
724-
SQL("GRANT ALL PRIVILEGES ON DATABASE postgres TO {};").format(
725-
Identifier(user)
793+
cursor.execute("""
794+
CREATE OR REPLACE FUNCTION update_pg_hba()
795+
RETURNS event_trigger
796+
LANGUAGE plpgsql
797+
AS $$
798+
DECLARE
799+
hba_file TEXT;
800+
copy_command TEXT;
801+
connection_type TEXT;
802+
rec record;
803+
insert_value TEXT;
804+
changes INTEGER = 0;
805+
BEGIN
806+
-- Don't execute on replicas.
807+
IF NOT pg_is_in_recovery() THEN
808+
-- Load the current authorisation rules.
809+
DROP TABLE IF EXISTS pg_hba;
810+
CREATE TEMPORARY TABLE pg_hba (lines TEXT);
811+
SELECT setting INTO hba_file FROM pg_settings WHERE name = 'hba_file';
812+
IF hba_file IS NOT NULL THEN
813+
copy_command='COPY pg_hba FROM ''' || hba_file || '''' ;
814+
EXECUTE copy_command;
815+
-- Build a list of the relation users and the databases they can access.
816+
DROP TABLE IF EXISTS relation_users;
817+
CREATE TEMPORARY TABLE relation_users AS
818+
SELECT t.user, STRING_AGG(DISTINCT t.database, ',') AS databases FROM( SELECT u.usename AS user, CASE WHEN u.usesuper THEN 'all' ELSE d.datname END AS database FROM ( SELECT usename, usesuper FROM pg_catalog.pg_user WHERE usename NOT IN ('backup', 'monitoring', 'operator', 'postgres', 'replication', 'rewind')) AS u JOIN ( SELECT datname FROM pg_catalog.pg_database WHERE NOT datistemplate ) AS d ON has_database_privilege(u.usename, d.datname, 'CONNECT') ) AS t GROUP BY 1;
819+
IF (SELECT COUNT(lines) FROM pg_hba WHERE lines LIKE 'hostssl %') > 0 THEN
820+
connection_type := 'hostssl';
821+
ELSE
822+
connection_type := 'host';
823+
END IF;
824+
-- Add the new users to the pg_hba file.
825+
FOR rec IN SELECT * FROM relation_users
826+
LOOP
827+
insert_value := connection_type || ' ' || rec.databases || ' ' || rec.user || ' 0.0.0.0/0 md5';
828+
IF (SELECT COUNT(lines) FROM pg_hba WHERE lines = insert_value) = 0 THEN
829+
INSERT INTO pg_hba (lines) VALUES (insert_value);
830+
changes := changes + 1;
831+
END IF;
832+
END LOOP;
833+
-- Remove users that don't exist anymore from the pg_hba file.
834+
FOR rec IN SELECT h.lines FROM pg_hba AS h LEFT JOIN relation_users AS r ON SPLIT_PART(h.lines, ' ', 3) = r.user WHERE r.user IS NULL AND (SPLIT_PART(h.lines, ' ', 3) LIKE 'relation_id_%' OR SPLIT_PART(h.lines, ' ', 3) LIKE 'pgbouncer_auth_relation_id_%' OR SPLIT_PART(h.lines, ' ', 3) LIKE '%_user_%_%')
835+
LOOP
836+
DELETE FROM pg_hba WHERE lines = rec.lines;
837+
changes := changes + 1;
838+
END LOOP;
839+
-- Apply the changes to the pg_hba file.
840+
IF changes > 0 THEN
841+
copy_command='COPY pg_hba TO ''' || hba_file || '''' ;
842+
EXECUTE copy_command;
843+
PERFORM pg_reload_conf();
844+
END IF;
845+
END IF;
846+
END IF;
847+
END;
848+
$$;
849+
""")
850+
cursor.execute("""
851+
CREATE EVENT TRIGGER update_pg_hba_on_create_schema
852+
ON ddl_command_end
853+
WHEN TAG IN ('CREATE SCHEMA')
854+
EXECUTE FUNCTION update_pg_hba();
855+
""")
856+
cursor.execute("""
857+
CREATE EVENT TRIGGER update_pg_hba_on_drop_schema
858+
ON ddl_command_end
859+
WHEN TAG IN ('DROP SCHEMA')
860+
EXECUTE FUNCTION update_pg_hba();
861+
""")
862+
with self._connect_to_database() as connection, connection.cursor() as cursor:
863+
cursor.execute("SELECT TRUE FROM pg_roles WHERE rolname='admin';")
864+
if cursor.fetchone() is None:
865+
# Allow access to the postgres database only to the system users.
866+
cursor.execute("REVOKE ALL PRIVILEGES ON DATABASE postgres FROM PUBLIC;")
867+
cursor.execute("REVOKE CREATE ON SCHEMA public FROM PUBLIC;")
868+
for user in self.system_users:
869+
cursor.execute(
870+
SQL("GRANT ALL PRIVILEGES ON DATABASE postgres TO {};").format(
871+
Identifier(user)
872+
)
726873
)
874+
self.create_user(
875+
PERMISSIONS_GROUP_ADMIN,
876+
extra_user_roles=["pg_read_all_data", "pg_write_all_data"],
727877
)
728-
self.create_user(
729-
PERMISSIONS_GROUP_ADMIN,
730-
extra_user_roles=["pg_read_all_data", "pg_write_all_data"],
731-
)
732-
cursor.execute("GRANT CONNECT ON DATABASE postgres TO admin;")
878+
cursor.execute("GRANT CONNECT ON DATABASE postgres TO admin;")
733879
except psycopg2.Error as e:
734880
logger.error(f"Failed to set up databases: {e}")
735881
raise PostgreSQLDatabasesSetupError() from e
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Copyright 2025 Canonical Ltd.
2+
# See LICENSE file for licensing details.
3+
4+
"""Authorisation rules changes observer."""
5+
6+
import json
7+
import subprocess
8+
import sys
9+
from ssl import CERT_NONE, create_default_context
10+
from time import sleep
11+
from urllib.parse import urljoin
12+
from urllib.request import urlopen
13+
14+
API_REQUEST_TIMEOUT = 5
15+
PATRONI_CLUSTER_STATUS_ENDPOINT = "cluster"
16+
PATRONI_CONFIG_STATUS_ENDPOINT = "config"
17+
18+
19+
class UnreachableUnitsError(Exception):
20+
"""Cannot reach any known cluster member."""
21+
22+
23+
def dispatch(run_cmd, unit, charm_dir):
24+
"""Use the input juju-run command to dispatch a :class:`AuthorisationRulesChangeEvent`."""
25+
dispatch_sub_cmd = "JUJU_DISPATCH_PATH=hooks/authorisation_rules_change {}/dispatch"
26+
# Input is generated by the charm
27+
subprocess.run([run_cmd, "-u", unit, dispatch_sub_cmd.format(charm_dir)]) # noqa: S603
28+
29+
30+
def main():
31+
"""Main watch and dispatch loop.
32+
33+
Watch the Patroni API cluster info. When changes are detected, dispatch the change event.
34+
"""
35+
patroni_urls, run_cmd, unit, charm_dir = sys.argv[1:]
36+
37+
previous_authorisation_rules = []
38+
urls = [urljoin(url, PATRONI_CLUSTER_STATUS_ENDPOINT) for url in patroni_urls.split(",")]
39+
member_name = unit.replace("/", "-")
40+
while True:
41+
# Disable TLS chain verification
42+
context = create_default_context()
43+
context.check_hostname = False
44+
context.verify_mode = CERT_NONE
45+
46+
cluster_status = None
47+
for url in urls:
48+
try:
49+
# Scheme is generated by the charm
50+
resp = urlopen( # noqa: S310
51+
url,
52+
timeout=API_REQUEST_TIMEOUT,
53+
context=context,
54+
)
55+
cluster_status = json.loads(resp.read())
56+
break
57+
except Exception as e:
58+
print(f"Failed to contact {url} with {e}")
59+
continue
60+
if not cluster_status:
61+
raise UnreachableUnitsError("Unable to reach cluster members")
62+
is_primary = False
63+
for member in cluster_status["members"]:
64+
# Check if the current member is the primary.
65+
if member["name"] == member_name and member["role"] == "leader":
66+
is_primary = True
67+
break
68+
69+
if is_primary:
70+
# Read contents from the pg_hba.conf file.
71+
with open("/var/lib/postgresql/data/pgdata/pg_hba.conf") as file:
72+
current_authorisation_rules = file.read()
73+
current_authorisation_rules = [
74+
line
75+
for line in current_authorisation_rules.splitlines()
76+
if not line.startswith("#")
77+
]
78+
# If it's the first time the authorisation rules were retrieved, then store it and use
79+
# it for subsequent checks.
80+
if not previous_authorisation_rules:
81+
previous_authorisation_rules = current_authorisation_rules
82+
# If the authorisation rules changed, dispatch a charm event to handle this change.
83+
elif current_authorisation_rules != previous_authorisation_rules:
84+
previous_authorisation_rules = current_authorisation_rules
85+
dispatch(run_cmd, unit, charm_dir)
86+
87+
# Wait some time before checking again for a authorisation rules change.
88+
sleep(30)
89+
90+
91+
if __name__ == "__main__":
92+
main()

0 commit comments

Comments
 (0)