Skip to content

Commit f72c974

Browse files
[DPE-6898] User->databases pg_hba rules (#885)
* Restrict each user to their allowed databases Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Fix unit tests Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Fix sync users on replicas Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Fix unit test Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Add default landscape user permission Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Increase sleep time in pg_hba test, fix user->database mapping for upgrade from stable and skip event trigger function code when not a superuser Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Improve users list check Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Fix raft reinitialisation in tests Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Decrease the amount of API calls by one Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Check users list directly Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Tweak test fast interval Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Improvements to avoid replica restart while syncing from primary Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Fix linting Signed-off-by: Marcelo Henrique Neppel <[email protected]> --------- Signed-off-by: Marcelo Henrique Neppel <[email protected]>
1 parent 5c19764 commit f72c974

18 files changed

+544
-58
lines changed

lib/charms/postgresql_k8s/v0/postgresql.py

Lines changed: 178 additions & 35 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 = 54
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}
@@ -700,42 +777,108 @@ def list_valid_privileges_and_roles(self) -> Tuple[Set[str], Set[str]]:
700777
"superuser",
701778
}, {role[0] for role in cursor.fetchall() if role[0]}
702779

703-
def set_up_database(self, temp_location: Optional[str] = None) -> None:
780+
def set_up_database(self) -> None:
704781
"""Set up postgres database with the right permissions."""
705782
connection = None
706-
cursor = None
707783
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';")
784+
with self._connect_to_database(
785+
database="template1"
786+
) as connection, connection.cursor() as cursor:
787+
# Create database function and event trigger to identify users created by PgBouncer.
788+
cursor.execute(
789+
"SELECT TRUE FROM pg_event_trigger WHERE evtname = 'update_pg_hba_on_create_schema';"
790+
)
713791
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)
792+
cursor.execute("""
793+
CREATE OR REPLACE FUNCTION update_pg_hba()
794+
RETURNS event_trigger
795+
LANGUAGE plpgsql
796+
AS $$
797+
DECLARE
798+
hba_file TEXT;
799+
copy_command TEXT;
800+
connection_type TEXT;
801+
rec record;
802+
insert_value TEXT;
803+
changes INTEGER = 0;
804+
BEGIN
805+
-- Don't execute on replicas.
806+
IF NOT pg_is_in_recovery() THEN
807+
-- Load the current authorisation rules.
808+
DROP TABLE IF EXISTS pg_hba;
809+
CREATE TEMPORARY TABLE pg_hba (lines TEXT);
810+
SELECT setting INTO hba_file FROM pg_settings WHERE name = 'hba_file';
811+
IF hba_file IS NOT NULL THEN
812+
copy_command='COPY pg_hba FROM ''' || hba_file || '''' ;
813+
EXECUTE copy_command;
814+
-- Build a list of the relation users and the databases they can access.
815+
DROP TABLE IF EXISTS relation_users;
816+
CREATE TEMPORARY TABLE relation_users AS
817+
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;
818+
IF (SELECT COUNT(lines) FROM pg_hba WHERE lines LIKE 'hostssl %') > 0 THEN
819+
connection_type := 'hostssl';
820+
ELSE
821+
connection_type := 'host';
822+
END IF;
823+
-- Add the new users to the pg_hba file.
824+
FOR rec IN SELECT * FROM relation_users
825+
LOOP
826+
insert_value := connection_type || ' ' || rec.databases || ' ' || rec.user || ' 0.0.0.0/0 md5';
827+
IF (SELECT COUNT(lines) FROM pg_hba WHERE lines = insert_value) = 0 THEN
828+
INSERT INTO pg_hba (lines) VALUES (insert_value);
829+
changes := changes + 1;
830+
END IF;
831+
END LOOP;
832+
-- Remove users that don't exist anymore from the pg_hba file.
833+
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_%_%')
834+
LOOP
835+
DELETE FROM pg_hba WHERE lines = rec.lines;
836+
changes := changes + 1;
837+
END LOOP;
838+
-- Apply the changes to the pg_hba file.
839+
IF changes > 0 THEN
840+
copy_command='COPY pg_hba TO ''' || hba_file || '''' ;
841+
EXECUTE copy_command;
842+
PERFORM pg_reload_conf();
843+
END IF;
844+
END IF;
845+
END IF;
846+
END;
847+
$$;
848+
""")
849+
cursor.execute("""
850+
CREATE EVENT TRIGGER update_pg_hba_on_create_schema
851+
ON ddl_command_end
852+
WHEN TAG IN ('CREATE SCHEMA')
853+
EXECUTE FUNCTION update_pg_hba();
854+
""")
855+
cursor.execute("""
856+
CREATE EVENT TRIGGER update_pg_hba_on_drop_schema
857+
ON ddl_command_end
858+
WHEN TAG IN ('DROP SCHEMA')
859+
EXECUTE FUNCTION update_pg_hba();
860+
""")
861+
with self._connect_to_database() as connection, connection.cursor() as cursor:
862+
cursor.execute("SELECT TRUE FROM pg_roles WHERE rolname='admin';")
863+
if cursor.fetchone() is None:
864+
# Allow access to the postgres database only to the system users.
865+
cursor.execute("REVOKE ALL PRIVILEGES ON DATABASE postgres FROM PUBLIC;")
866+
cursor.execute("REVOKE CREATE ON SCHEMA public FROM PUBLIC;")
867+
for user in self.system_users:
868+
cursor.execute(
869+
SQL("GRANT ALL PRIVILEGES ON DATABASE postgres TO {};").format(
870+
Identifier(user)
871+
)
726872
)
873+
self.create_user(
874+
PERMISSIONS_GROUP_ADMIN,
875+
extra_user_roles=["pg_read_all_data", "pg_write_all_data"],
727876
)
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;")
877+
cursor.execute("GRANT CONNECT ON DATABASE postgres TO admin;")
733878
except psycopg2.Error as e:
734879
logger.error(f"Failed to set up databases: {e}")
735880
raise PostgreSQLDatabasesSetupError() from e
736881
finally:
737-
if cursor is not None:
738-
cursor.close()
739882
if connection is not None:
740883
connection.close()
741884

scripts/cluster_topology_observer.py

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,51 @@ class UnreachableUnitsError(Exception):
2222
"""Cannot reach any known cluster member."""
2323

2424

25-
def dispatch(run_cmd, unit, charm_dir):
26-
"""Use the input juju-run command to dispatch a :class:`ClusterTopologyChangeEvent`."""
27-
dispatch_sub_cmd = "JUJU_DISPATCH_PATH=hooks/cluster_topology_change {}/dispatch"
25+
def check_for_authorisation_rules_changes(run_cmd, unit, charm_dir, previous_authorisation_rules):
26+
"""Check for changes in the authorisation rules.
27+
28+
If changes are detected, dispatch an event to handle them.
29+
"""
30+
# Read contents from the pg_hba.conf file.
31+
with open("/var/snap/charmed-postgresql/common/var/lib/postgresql/pg_hba.conf") as file:
32+
current_authorisation_rules = file.read()
33+
current_authorisation_rules = [
34+
line for line in current_authorisation_rules.splitlines() if not line.startswith("#")
35+
]
36+
# If it's the first time the authorisation rules were retrieved, then store it and use
37+
# it for subsequent checks.
38+
if not previous_authorisation_rules:
39+
previous_authorisation_rules = current_authorisation_rules
40+
# If the authorisation rules changed, dispatch a charm event to handle this change.
41+
elif current_authorisation_rules != previous_authorisation_rules:
42+
previous_authorisation_rules = current_authorisation_rules
43+
dispatch(run_cmd, unit, charm_dir, "authorisation_rules_change")
44+
return previous_authorisation_rules
45+
46+
47+
def check_for_cluster_topology_changes(
48+
run_cmd, unit, charm_dir, previous_cluster_topology, current_cluster_topology
49+
):
50+
"""Check for changes in the cluster topology.
51+
52+
If changes are detected, dispatch an event to handle them.
53+
"""
54+
# If it's the first time the cluster topology was retrieved, then store it and use
55+
# it for subsequent checks.
56+
if not previous_cluster_topology:
57+
previous_cluster_topology = current_cluster_topology
58+
# If the cluster topology changed, dispatch a charm event to handle this change.
59+
elif current_cluster_topology != previous_cluster_topology:
60+
previous_cluster_topology = current_cluster_topology
61+
dispatch(run_cmd, unit, charm_dir, "cluster_topology_change")
62+
return previous_cluster_topology
63+
64+
65+
def dispatch(run_cmd, unit, charm_dir, custom_event):
66+
"""Use the input juju-run command to dispatch a custom event."""
67+
dispatch_sub_cmd = "JUJU_DISPATCH_PATH=hooks/{} {}/dispatch"
2868
# Input is generated by the charm
29-
subprocess.run([run_cmd, "-u", unit, dispatch_sub_cmd.format(charm_dir)]) # noqa: S603
69+
subprocess.run([run_cmd, "-u", unit, dispatch_sub_cmd.format(custom_event, charm_dir)]) # noqa: S603
3070

3171

3272
def main():
@@ -37,6 +77,7 @@ def main():
3777
patroni_urls, run_cmd, unit, charm_dir = sys.argv[1:]
3878

3979
previous_cluster_topology = {}
80+
previous_authorisation_rules = []
4081
urls = [urljoin(url, PATRONI_CLUSTER_STATUS_ENDPOINT) for url in patroni_urls.split(",")]
4182
member_name = unit.replace("/", "-")
4283
while True:
@@ -63,23 +104,27 @@ def main():
63104
raise UnreachableUnitsError("Unable to reach cluster members")
64105
current_cluster_topology = {}
65106
urls = []
107+
is_primary = False
66108
for member in cluster_status["members"]:
67109
current_cluster_topology[member["name"]] = member["role"]
68110
member_url = urljoin(member["api_url"], PATRONI_CLUSTER_STATUS_ENDPOINT)
69111
# Call the current unit first
70112
if member["name"] == member_name:
71113
urls.insert(0, member_url)
114+
# Check if the current member is the primary.
115+
if member["role"] == "leader":
116+
is_primary = True
72117
else:
73118
urls.append(member_url)
74119

75-
# If it's the first time the cluster topology was retrieved, then store it and use
76-
# it for subsequent checks.
77-
if not previous_cluster_topology:
78-
previous_cluster_topology = current_cluster_topology
79-
# If the cluster topology changed, dispatch a charm event to handle this change.
80-
elif current_cluster_topology != previous_cluster_topology:
81-
previous_cluster_topology = current_cluster_topology
82-
dispatch(run_cmd, unit, charm_dir)
120+
previous_cluster_topology = check_for_cluster_topology_changes(
121+
run_cmd, unit, charm_dir, previous_cluster_topology, current_cluster_topology
122+
)
123+
124+
if is_primary:
125+
previous_authorisation_rules = check_for_authorisation_rules_changes(
126+
run_cmd, unit, charm_dir, previous_authorisation_rules
127+
)
83128

84129
# Wait some time before checking again for a cluster topology change.
85130
sleep(30)

0 commit comments

Comments
 (0)