Skip to content

Commit ca3c587

Browse files
[DPE-5248] Add pgAudit (#612)
* Add pgAudit Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Update snap revisions Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Add pgAudit and integration test Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Fix integration test Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Remove txt file Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Add unit test Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Enable pgAudit by default Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Enable/disable the plugin at the unit test Signed-off-by: Marcelo Henrique Neppel <[email protected]> * Fix test_no_password_exposed_on_logs Signed-off-by: Marcelo Henrique Neppel <[email protected]> --------- Signed-off-by: Marcelo Henrique Neppel <[email protected]>
1 parent 01cfd3e commit ca3c587

File tree

13 files changed

+198
-23
lines changed

13 files changed

+198
-23
lines changed

config.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,10 @@ options:
303303
default: false
304304
type: boolean
305305
description: Enable timescaledb extension
306+
plugin_audit_enable:
307+
default: true
308+
type: boolean
309+
description: Enable pgAudit extension
306310
profile:
307311
description: |
308312
Profile representing the scope of deployment, and used to tune resource allocation.

lib/charms/postgresql_k8s/v0/postgresql.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636

3737
# Increment this PATCH version before using `charmcraft publish-lib` or reset
3838
# to 0 if you are raising the major API version
39-
LIBPATCH = 34
39+
LIBPATCH = 35
4040

4141
INVALID_EXTRA_USER_ROLE_BLOCKING_MESSAGE = "invalid role(s) for extra user roles"
4242

@@ -114,6 +114,25 @@ def __init__(
114114
self.database = database
115115
self.system_users = system_users
116116

117+
def _configure_pgaudit(self, enable: bool) -> None:
118+
connection = None
119+
try:
120+
connection = self._connect_to_database()
121+
connection.autocommit = True
122+
with connection.cursor() as cursor:
123+
if enable:
124+
cursor.execute("ALTER SYSTEM SET pgaudit.log = 'ROLE,DDL,MISC,MISC_SET';")
125+
cursor.execute("ALTER SYSTEM SET pgaudit.log_client TO off;")
126+
cursor.execute("ALTER SYSTEM SET pgaudit.log_parameter TO off")
127+
else:
128+
cursor.execute("ALTER SYSTEM RESET pgaudit.log;")
129+
cursor.execute("ALTER SYSTEM RESET pgaudit.log_client;")
130+
cursor.execute("ALTER SYSTEM RESET pgaudit.log_parameter;")
131+
cursor.execute("SELECT pg_reload_conf();")
132+
finally:
133+
if connection is not None:
134+
connection.close()
135+
117136
def _connect_to_database(
118137
self, database: str = None, database_host: str = None
119138
) -> psycopg2.extensions.connection:
@@ -325,6 +344,7 @@ def enable_disable_extensions(self, extensions: Dict[str, bool], database: str =
325344
if enable
326345
else f"DROP EXTENSION IF EXISTS {extension};"
327346
)
347+
self._configure_pgaudit(ordered_extensions.get("pgaudit", False))
328348
except psycopg2.errors.UniqueViolation:
329349
pass
330350
except psycopg2.errors.DependentObjectsStillExist:

src/charm.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
PATRONI_CONF_PATH,
7777
PATRONI_PASSWORD_KEY,
7878
PEER,
79+
PLUGIN_OVERRIDES,
7980
POSTGRESQL_SNAP_NAME,
8081
RAFT_PASSWORD_KEY,
8182
REPLICATION_PASSWORD_KEY,
@@ -84,6 +85,7 @@
8485
SECRET_INTERNAL_LABEL,
8586
SECRET_KEY_OVERRIDES,
8687
SNAP_PACKAGES,
88+
SPI_MODULE,
8789
SYSTEM_USERS,
8890
TLS_CA_FILE,
8991
TLS_CERT_FILE,
@@ -996,8 +998,6 @@ def enable_disable_extensions(self, database: str = None) -> None:
996998
if self._patroni.get_primary() is None:
997999
logger.debug("Early exit enable_disable_extensions: standby cluster")
9981000
return
999-
spi_module = ["refint", "autoinc", "insert_username", "moddatetime"]
1000-
plugins_exception = {"uuid_ossp": '"uuid-ossp"'}
10011001
original_status = self.unit.status
10021002
extensions = {}
10031003
# collect extensions
@@ -1007,10 +1007,10 @@ def enable_disable_extensions(self, database: str = None) -> None:
10071007
# Enable or disable the plugin/extension.
10081008
extension = "_".join(plugin.split("_")[1:-1])
10091009
if extension == "spi":
1010-
for ext in spi_module:
1010+
for ext in SPI_MODULE:
10111011
extensions[ext] = enable
10121012
continue
1013-
extension = plugins_exception.get(extension, extension)
1013+
extension = PLUGIN_OVERRIDES.get(extension, extension)
10141014
if self._check_extension_dependencies(extension, enable):
10151015
self.unit.status = BlockedStatus(EXTENSIONS_DEPENDENCY_MESSAGE)
10161016
return
@@ -1514,7 +1514,6 @@ def _install_snap_packages(self, packages: List[str], refresh: bool = False) ->
15141514
snap_package.hold()
15151515
else:
15161516
snap_package.ensure(snap.SnapState.Latest, channel=snap_version["channel"])
1517-
15181517
except (snap.SnapError, snap.SnapNotFoundError) as e:
15191518
logger.error(
15201519
"An exception occurred when installing %s. Reason: %s", snap_name, str(e)
@@ -1861,6 +1860,20 @@ def log_pitr_last_transaction_time(self) -> None:
18611860
else:
18621861
logger.error("Can't tell last completed transaction time")
18631862

1863+
def get_plugins(self) -> List[str]:
1864+
"""Return a list of installed plugins."""
1865+
plugins = [
1866+
"_".join(plugin.split("_")[1:-1])
1867+
for plugin in self.config.plugin_keys()
1868+
if self.config[plugin]
1869+
]
1870+
plugins = [PLUGIN_OVERRIDES.get(plugin, plugin) for plugin in plugins]
1871+
if "spi" in plugins:
1872+
plugins.remove("spi")
1873+
for ext in SPI_MODULE:
1874+
plugins.append(ext)
1875+
return plugins
1876+
18641877

18651878
if __name__ == "__main__":
18661879
main(PostgresqlOperatorCharm)

src/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class CharmConfig(BaseConfigModel):
3636
optimizer_join_collapse_limit: Optional[int]
3737
profile: str
3838
profile_limit_memory: Optional[int]
39+
plugin_audit_enable: bool
3940
plugin_citext_enable: bool
4041
plugin_debversion_enable: bool
4142
plugin_hstore_enable: bool

src/constants.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
(
4242
POSTGRESQL_SNAP_NAME,
4343
{
44-
"revision": {"aarch64": "121", "x86_64": "120"},
44+
"revision": {"aarch64": "125", "x86_64": "124"},
4545
"channel": "14/stable",
4646
},
4747
)
@@ -84,3 +84,6 @@
8484
TRACING_PROTOCOL = "otlp_http"
8585

8686
BACKUP_TYPE_OVERRIDES = {"full": "full", "differential": "diff", "incremental": "incr"}
87+
PLUGIN_OVERRIDES = {"audit": "pgaudit", "uuid_ossp": '"uuid-ossp"'}
88+
89+
SPI_MODULE = ["refint", "autoinc", "insert_username", "moddatetime"]

src/relations/db.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,11 +201,7 @@ def set_up_relation(self, relation: Relation) -> bool:
201201
self.charm.set_secret(APP_SCOPE, f"{user}-database", database)
202202

203203
self.charm.postgresql.create_user(user, password, self.admin)
204-
plugins = [
205-
"_".join(plugin.split("_")[1:-1])
206-
for plugin in self.charm.config.plugin_keys()
207-
if self.charm.config[plugin]
208-
]
204+
plugins = self.charm.get_plugins()
209205

210206
self.charm.postgresql.create_database(
211207
database, user, plugins=plugins, client_relations=self.charm.client_relations

src/relations/postgresql_provider.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,7 @@ def _on_database_requested(self, event: DatabaseRequestedEvent) -> None:
9191
user = f"relation-{event.relation.id}"
9292
password = new_password()
9393
self.charm.postgresql.create_user(user, password, extra_user_roles=extra_user_roles)
94-
plugins = [
95-
"_".join(plugin.split("_")[1:-1])
96-
for plugin in self.charm.config.plugin_keys()
97-
if self.charm.config[plugin]
98-
]
94+
plugins = self.charm.get_plugins()
9995

10096
self.charm.postgresql.create_database(
10197
database, user, plugins=plugins, client_relations=self.charm.client_relations

templates/patroni.yml.j2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ bootstrap:
9797
log_truncate_on_rotation: 'on'
9898
logging_collector: 'on'
9999
wal_level: logical
100-
shared_preload_libraries: 'timescaledb'
100+
shared_preload_libraries: 'timescaledb,pgaudit'
101101
{%- if pg_parameters %}
102102
{%- for key, value in pg_parameters.items() %}
103103
{{key}}: {{value}}

tests/integration/test_audit.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2024 Canonical Ltd.
3+
# See LICENSE file for licensing details.
4+
import asyncio
5+
import logging
6+
7+
import psycopg2 as psycopg2
8+
import pytest as pytest
9+
from pytest_operator.plugin import OpsTest
10+
from tenacity import Retrying, stop_after_delay, wait_fixed
11+
12+
from .helpers import (
13+
APPLICATION_NAME,
14+
DATABASE_APP_NAME,
15+
run_command_on_unit,
16+
)
17+
from .new_relations.helpers import build_connection_string
18+
19+
logger = logging.getLogger(__name__)
20+
21+
RELATION_ENDPOINT = "database"
22+
23+
24+
@pytest.mark.group(1)
25+
@pytest.mark.abort_on_fail
26+
async def test_audit_plugin(ops_test: OpsTest, charm) -> None:
27+
"""Test the audit plugin."""
28+
await asyncio.gather(
29+
ops_test.model.deploy(charm, config={"profile": "testing"}),
30+
ops_test.model.deploy(APPLICATION_NAME),
31+
)
32+
await ops_test.model.relate(f"{APPLICATION_NAME}:{RELATION_ENDPOINT}", DATABASE_APP_NAME)
33+
async with ops_test.fast_forward():
34+
await ops_test.model.wait_for_idle(
35+
apps=[APPLICATION_NAME, DATABASE_APP_NAME], status="active"
36+
)
37+
38+
logger.info("Checking that the audit plugin is enabled")
39+
connection_string = await build_connection_string(
40+
ops_test, APPLICATION_NAME, RELATION_ENDPOINT
41+
)
42+
connection = None
43+
try:
44+
connection = psycopg2.connect(connection_string)
45+
with connection.cursor() as cursor:
46+
cursor.execute("CREATE TABLE test2(value TEXT);")
47+
cursor.execute("GRANT SELECT ON test2 TO PUBLIC;")
48+
cursor.execute("SET TIME ZONE 'Europe/Rome';")
49+
finally:
50+
if connection is not None:
51+
connection.close()
52+
unit_name = f"{DATABASE_APP_NAME}/0"
53+
for attempt in Retrying(stop=stop_after_delay(90), wait=wait_fixed(10), reraise=True):
54+
with attempt:
55+
try:
56+
logs = await run_command_on_unit(
57+
ops_test,
58+
unit_name,
59+
"sudo grep AUDIT /var/snap/charmed-postgresql/common/var/log/postgresql/postgresql-*.log",
60+
)
61+
assert "MISC,BEGIN,,,BEGIN" in logs
62+
assert (
63+
"DDL,CREATE TABLE,TABLE,public.test2,CREATE TABLE test2(value TEXT);" in logs
64+
)
65+
assert "ROLE,GRANT,TABLE,,GRANT SELECT ON test2 TO PUBLIC;" in logs
66+
assert "MISC,SET,,,SET TIME ZONE 'Europe/Rome';" in logs
67+
except Exception:
68+
assert False, "Audit logs were not found when the plugin is enabled."
69+
70+
logger.info("Disabling the audit plugin")
71+
await ops_test.model.applications[DATABASE_APP_NAME].set_config({
72+
"plugin_audit_enable": "False"
73+
})
74+
await ops_test.model.wait_for_idle(apps=[DATABASE_APP_NAME], status="active")
75+
76+
logger.info("Removing the previous logs")
77+
await run_command_on_unit(
78+
ops_test,
79+
unit_name,
80+
"rm /var/snap/charmed-postgresql/common/var/log/postgresql/postgresql-*.log",
81+
)
82+
83+
logger.info("Checking that the audit plugin is disabled")
84+
try:
85+
connection = psycopg2.connect(connection_string)
86+
with connection.cursor() as cursor:
87+
cursor.execute("CREATE TABLE test1(value TEXT);")
88+
cursor.execute("GRANT SELECT ON test1 TO PUBLIC;")
89+
cursor.execute("SET TIME ZONE 'Europe/Rome';")
90+
finally:
91+
if connection is not None:
92+
connection.close()
93+
try:
94+
logs = await run_command_on_unit(
95+
ops_test,
96+
unit_name,
97+
"sudo grep AUDIT /var/snap/charmed-postgresql/common/var/log/postgresql/postgresql-*.log",
98+
)
99+
except Exception:
100+
pass
101+
else:
102+
logger.info(f"Logs: {logs}")
103+
assert False, "Audit logs were found when the plugin is disabled."

tests/integration/test_password_rotation.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# Copyright 2022 Canonical Ltd.
33
# See LICENSE file for licensing details.
44
import json
5+
import re
56
import time
67

78
import psycopg2
@@ -194,4 +195,8 @@ async def test_no_password_exposed_on_logs(ops_test: OpsTest) -> None:
194195
)
195196
except Exception:
196197
continue
197-
assert len(logs) == 0, f"Sensitive information detected on {unit.name} logs"
198+
regex = re.compile("(PASSWORD )(?!<REDACTED>)")
199+
logs_without_false_positives = regex.findall(logs)
200+
assert (
201+
len(logs_without_false_positives) == 0
202+
), f"Sensitive information detected on {unit.name} logs"

0 commit comments

Comments
 (0)