flask-postgresql-audit 1.0.0__1-py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ # ruff: noqa: F401
2
+ from .core import Audit, PostgreSQLAudit
3
+
4
+ __version__ = "1.0.0"
@@ -0,0 +1,2 @@
1
+ # ruff: noqa: F401
2
+ from .setup import register_triggers, setup_db
@@ -0,0 +1,64 @@
1
+ from alembic_utils.pg_extension import PGExtension
2
+ from alembic_utils.pg_function import PGFunction
3
+ from alembic_utils.pg_trigger import PGTrigger
4
+
5
+ from ..utils import parse_template
6
+
7
+ btree_gist = PGExtension(schema="public", signature="btree_gist")
8
+
9
+
10
+ def create_activity_factory(**context):
11
+ sql = parse_template("create_activity.sql", **context)
12
+ return PGFunction(
13
+ schema=context["schema_name"],
14
+ signature="create_activity()",
15
+ definition=sql["definition"],
16
+ )
17
+
18
+
19
+ def trigger_delete_factory(**context):
20
+ sql = parse_template("audit_trigger_delete.sql", **context)
21
+ return PGTrigger(
22
+ schema=context["table_schema"],
23
+ signature="audit_trigger_delete",
24
+ on_entity=f"{context['table_schema']}.{context['table_name']}",
25
+ definition=sql["definition"],
26
+ )
27
+
28
+
29
+ def trigger_insert_factory(**context):
30
+ sql = parse_template("audit_trigger_insert.sql", **context)
31
+ return PGTrigger(
32
+ schema=context["table_schema"],
33
+ signature="audit_trigger_insert",
34
+ on_entity=f"{context['table_schema']}.{context['table_name']}",
35
+ definition=sql["definition"],
36
+ )
37
+
38
+
39
+ def trigger_update_factory(**context):
40
+ sql = parse_template("audit_trigger_update.sql", **context)
41
+ return PGTrigger(
42
+ schema=context["table_schema"],
43
+ signature="audit_trigger_update",
44
+ on_entity=f"{context['table_schema']}.{context['table_name']}",
45
+ definition=sql["definition"],
46
+ )
47
+
48
+
49
+ def get_setting_factory(**context):
50
+ sql = parse_template("get_setting.sql", **context)
51
+ return PGFunction(
52
+ schema=context["schema_name"],
53
+ signature="get_setting(setting text, fallback text)",
54
+ definition=sql["definition"],
55
+ )
56
+
57
+
58
+ def jsonb_subtract_factory(**context):
59
+ sql = parse_template("jsonb_subtract.sql", **context)
60
+ return PGFunction(
61
+ schema=context["schema_name"],
62
+ signature="jsonb_subtract(arg1 jsonb, arg2 jsonb)",
63
+ definition=sql["definition"],
64
+ )
@@ -0,0 +1,58 @@
1
+ from alembic.autogenerate import renderers
2
+ from alembic.autogenerate.api import AutogenContext
3
+ from alembic.operations import MigrateOperation, Operations
4
+ from sqlalchemy import text
5
+
6
+ # Operations #
7
+
8
+
9
+ class SchemaOperation(MigrateOperation):
10
+ def __init__(self, schema_name) -> None:
11
+ self.schema_name = schema_name
12
+
13
+ @classmethod
14
+ def invoke_for_schema(cls, operations: Operations, schema_name: str):
15
+ return operations.invoke(cls(schema_name))
16
+
17
+
18
+ @Operations.register_operation("create_schema", "invoke_for_schema")
19
+ class SchemaCreate(SchemaOperation):
20
+ def reverse(self) -> MigrateOperation:
21
+ return SchemaDrop(self.schema_name)
22
+
23
+
24
+ @Operations.register_operation("drop_schema", "invoke_for_schema")
25
+ class SchemaDrop(SchemaOperation):
26
+ def reverse(self) -> MigrateOperation:
27
+ return SchemaCreate(self.schema_name)
28
+
29
+
30
+ # Implementations #
31
+
32
+
33
+ @Operations.implementation_for(SchemaCreate)
34
+ def create_schema(operations: Operations, operation: SchemaCreate):
35
+ stmt = """
36
+ CREATE SCHEMA {name};
37
+ REVOKE ALL ON SCHEMA {name} FROM public;
38
+ """.format(name=operation.schema_name)
39
+ operations.execute(text(stmt))
40
+
41
+
42
+ @Operations.implementation_for(SchemaDrop)
43
+ def drop_schema(operations: Operations, operation: SchemaDrop):
44
+ stmt = "DROP SCHEMA {name} CASCADE;".format(name=operation.schema_name)
45
+ operations.execute(text(stmt))
46
+
47
+
48
+ # Render #
49
+
50
+
51
+ @renderers.dispatch_for(SchemaCreate)
52
+ def render_create_schema(autogen_context: AutogenContext, op: SchemaCreate) -> str:
53
+ return f'op.create_schema("{op.schema_name}")'
54
+
55
+
56
+ @renderers.dispatch_for(SchemaDrop)
57
+ def render_drop_schema(autogen_context: AutogenContext, op: SchemaDrop) -> str:
58
+ return f'op.drop_schema("{op.schema_name}")'
@@ -0,0 +1,93 @@
1
+ import logging
2
+ from typing import TYPE_CHECKING, Optional, Set, Union
3
+
4
+ from alembic.autogenerate import comparators
5
+ from alembic.autogenerate.api import AutogenContext
6
+ from alembic.operations import ops
7
+ from alembic_utils.replaceable_entity import register_entities, registry
8
+ from alembic_utils.reversible_op import CreateOp
9
+ from sqlalchemy import Connection, text
10
+ from sqlalchemy.orm import Session
11
+
12
+ from . import entities
13
+ from .schema import SchemaCreate
14
+
15
+ if TYPE_CHECKING:
16
+ from alembic_utils.replaceable_entity import ReplaceableEntity
17
+
18
+ from ..core import PostgreSQLAudit
19
+
20
+
21
+ logger = logging.getLogger("pg_audit.setup")
22
+
23
+
24
+ def setup_db(audit: "PostgreSQLAudit"):
25
+ register_core_entities(audit)
26
+ register_triggers(audit)
27
+ register_entities(audit.pg_audit_entities)
28
+
29
+ def compare_audit_schema(
30
+ autogen_context: AutogenContext,
31
+ upgrade_ops: ops.UpgradeOps,
32
+ schemas: Union[Set[None], Set[Optional[str]]],
33
+ ) -> None:
34
+ idx = 0
35
+ if connection := autogen_context.connection:
36
+ check_schema = """
37
+ SELECT TRUE FROM information_schema.schemata
38
+ WHERE schema_name = '{name}'
39
+ """.format(name=audit.context["schema_name"])
40
+ if not connection.scalar(text(check_schema)):
41
+ upgrade_ops.ops.insert(idx, SchemaCreate(audit.context["schema_name"]))
42
+ idx += 1
43
+
44
+ for ent in audit.pg_audit_entities:
45
+ if op := get_blind_migration_op(ent, connection):
46
+ registry._entities.pop(ent.identity)
47
+ if ent.signature == "btree_gist":
48
+ upgrade_ops.ops.insert(idx, op)
49
+ else:
50
+ upgrade_ops.ops.append(op)
51
+
52
+ for idx, comp_fn in enumerate(comparators._registry.get(("schema", "default"), [])):
53
+ # Insert pg_audit comparators before alembic_utils comparators
54
+ # to override alembic_utils migration caveats when entity
55
+ # depend on new object in same migration
56
+ if comp_fn.__module__ == "alembic_utils.replaceable_entity":
57
+ comparators._registry.setdefault(("schema", "default"), []).insert(
58
+ idx, compare_audit_schema
59
+ )
60
+ break
61
+
62
+
63
+ def register_core_entities(audit: "PostgreSQLAudit"):
64
+ audit.pg_audit_entities.add(entities.btree_gist)
65
+ audit.pg_audit_entities.add(entities.get_setting_factory(**audit.context))
66
+ audit.pg_audit_entities.add(entities.jsonb_subtract_factory(**audit.context))
67
+ audit.pg_audit_entities.add(entities.create_activity_factory(**audit.context))
68
+
69
+
70
+ def register_triggers(audit: "PostgreSQLAudit"):
71
+ for cls in audit.pg_audit_classes:
72
+ exclude = cls.__audit_args__.get("exclude", [])
73
+ ctx = dict(
74
+ table_name=cls.__tablename__,
75
+ table_schema=cls.__table__.schema or "public",
76
+ excluded_columns="'{" + ",".join(exclude) + "}'",
77
+ **audit.context,
78
+ )
79
+
80
+ audit.pg_audit_entities.add(entities.trigger_insert_factory(**ctx))
81
+ audit.pg_audit_entities.add(entities.trigger_update_factory(**ctx))
82
+ audit.pg_audit_entities.add(entities.trigger_delete_factory(**ctx))
83
+
84
+
85
+ def get_blind_migration_op(entity: "ReplaceableEntity", connection: Connection):
86
+ session = Session(bind=connection)
87
+ db_ents: list["ReplaceableEntity"] = entity.from_database(session, entity.schema)
88
+
89
+ for db_ent in db_ents:
90
+ if entity.identity == db_ent.identity:
91
+ return None # Offload migration op creation to alembic_utils if exist
92
+ logger.info("Detected blind CreateOp %s", entity.identity)
93
+ return CreateOp(entity)
@@ -0,0 +1,51 @@
1
+ from datetime import datetime
2
+ from typing import TYPE_CHECKING, Any
3
+
4
+ from sqlalchemy import BigInteger, Text, func, text
5
+ from sqlalchemy.dialects.postgresql import INET, JSONB
6
+ from sqlalchemy.orm import Mapped, mapped_column
7
+
8
+ from .typing import OMap
9
+
10
+
11
+ class ActivityBase:
12
+ if TYPE_CHECKING:
13
+ transaction_id: Mapped[int]
14
+ transaction: Mapped["TransactionBase"]
15
+
16
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
17
+ schema_name: OMap[str] = mapped_column(Text)
18
+ table_name: OMap[str] = mapped_column(Text)
19
+
20
+ relid: OMap[int]
21
+ issued_at: OMap[datetime]
22
+ native_transaction_id: OMap[int] = mapped_column(BigInteger, index=True)
23
+ verb: OMap[str] = mapped_column(Text)
24
+
25
+ old_data: OMap[dict] = mapped_column(JSONB, default={}, server_default="{}")
26
+ changed_data: OMap[dict] = mapped_column(JSONB, default={}, server_default="{}")
27
+
28
+ def __repr__(self):
29
+ return "<{cls} table_name={table_name!r} id={id!r}>".format(
30
+ cls=self.__class__.__name__, table_name=self.table_name, id=self.id
31
+ )
32
+
33
+
34
+ class TransactionBase:
35
+ if TYPE_CHECKING:
36
+ actor_id: OMap[Any]
37
+
38
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
39
+ native_transaction_id: OMap[int] = mapped_column(BigInteger)
40
+ issued_at: OMap[datetime]
41
+ client_addr: OMap[str] = mapped_column(INET)
42
+
43
+ @classmethod
44
+ def __transaction_interval__(cls):
45
+ interval = text("INTERVAL '1 hour'")
46
+ return func.tsrange(cls.issued_at - interval, cls.issued_at)
47
+
48
+ def __repr__(self):
49
+ return "<{cls} id={id!r} issued_at={issued_at!r}>".format(
50
+ cls=self.__class__.__name__, id=self.id, issued_at=self.issued_at
51
+ )
@@ -0,0 +1,233 @@
1
+ import typing as t
2
+ from contextlib import contextmanager
3
+
4
+ import sqlalchemy as sa
5
+ import sqlalchemy.event as event
6
+ import sqlalchemy.orm as orm
7
+ from alembic_utils.replaceable_entity import ReplaceableEntity
8
+ from flask import Flask
9
+ from flask_sqlalchemy import SQLAlchemy
10
+ from sqlalchemy.dialects.postgresql import insert as pg_insert
11
+ from sqlalchemy.util import OrderedSet
12
+
13
+ from .alembic import register_triggers, setup_db
14
+ from .models import activity_model_factory, transaction_model_factory
15
+ from .typing import AnyAttrribute
16
+ from .utils import load_template
17
+
18
+ Base = t.TypeVar("Base", bound=orm.DeclarativeBase)
19
+
20
+
21
+ class ImproperlyConfigured(Exception):
22
+ pass
23
+
24
+
25
+ class Audit:
26
+ __audit_args__: dict = {}
27
+
28
+ if t.TYPE_CHECKING:
29
+ __table__: sa.Table
30
+ __tablename__: str
31
+
32
+ def __init__(self, **kw: t.Any): ...
33
+
34
+ def __init_subclass__(cls) -> None:
35
+ if not hasattr(cls, "__audit_args__"):
36
+ cls.__audit_args__ = {}
37
+
38
+
39
+ def get_modified_columns(obj: orm.DeclarativeBase):
40
+ modified: set[sa.ColumnElement[t.Any]] = set()
41
+ mapper = sa.inspect(obj.__class__)
42
+ for key, attr in sa.inspect(obj).attrs.items():
43
+ if key in mapper.synonyms.keys():
44
+ continue
45
+ if not attr.history.has_changes():
46
+ continue
47
+
48
+ cls_attr: AnyAttrribute = getattr(obj.__class__, key)
49
+ prop = cls_attr.property
50
+ if isinstance(prop, orm.ColumnProperty):
51
+ modified.update(prop.columns)
52
+ if isinstance(prop, orm.RelationshipProperty) and prop.local_remote_pairs:
53
+ modified.update([local for local, _ in prop.local_remote_pairs])
54
+ return modified
55
+
56
+
57
+ def get_audit_models(registry: orm.registry):
58
+ models: set[type[Audit]] = set()
59
+ for mapper in registry.mappers:
60
+ if issubclass(mapper.class_, Audit):
61
+ models.add(mapper.class_)
62
+ return models
63
+
64
+
65
+ def is_object_modified(obj: object):
66
+ if isinstance(obj, Audit):
67
+ excluded = set(obj.__class__.__audit_args__.get("exclude", []))
68
+ modified = {col.name for col in get_modified_columns(obj)} # type: ignore
69
+ return bool(modified - excluded)
70
+ return False
71
+
72
+
73
+ def is_session_modified(session: orm.Session):
74
+ return any(
75
+ is_object_modified(obj) or obj in session.deleted
76
+ for obj in session
77
+ if isinstance(obj, Audit)
78
+ )
79
+
80
+
81
+ def _default_actor_id() -> int | str | None:
82
+ try:
83
+ from flask_login import current_user # type: ignore
84
+ except ImportError:
85
+ return None
86
+
87
+ try:
88
+ return current_user.id
89
+ except AttributeError:
90
+ return None
91
+
92
+
93
+ def _default_client_addr() -> str | None:
94
+ try:
95
+ from flask import request
96
+ except ImportError:
97
+ return None
98
+ return (request and request.remote_addr) or None
99
+
100
+
101
+ class PostgreSQLAudit:
102
+ pg_audit_classes: set[type[Audit]]
103
+ pg_audit_enabled: bool
104
+ pg_audit_entities: OrderedSet[ReplaceableEntity]
105
+
106
+ def __init__(
107
+ self,
108
+ *,
109
+ actor_cls: t.Optional[str] = None,
110
+ actor_id_getter: t.Callable[[], t.Any] = _default_actor_id,
111
+ client_address_getter: t.Callable[[], t.Any] = _default_client_addr,
112
+ schema_name: t.Optional[str] = None,
113
+ ):
114
+ self._actor_cls = actor_cls
115
+ self.schema_name = schema_name
116
+
117
+ self.get_actor_id = actor_id_getter
118
+ self.get_client_addr = client_address_getter
119
+
120
+ @property
121
+ def context(self):
122
+ ctx = dict(schema_name=self.schema_name or "public")
123
+ ctx["schema_prefix"] = f"{ctx['schema_name']}."
124
+ ctx["revoke_cmd"] = f"REVOKE ALL ON {ctx['schema_prefix']}activity FROM public;"
125
+ return ctx
126
+
127
+ @contextmanager
128
+ def disable(self, session: orm.Session | orm.scoped_session[t.Any]):
129
+ session.execute(self.set_local("'false'"))
130
+ self.pg_audit_enabled = False
131
+ try:
132
+ yield
133
+ finally:
134
+ self.pg_audit_enabled = True
135
+ session.execute(self.set_local("'true'"))
136
+
137
+ def set_local(self, value: t.Any, var: str = "enable_audit"):
138
+ return sa.text(f"SET LOCAL flask_pga.{var} = {value}")
139
+
140
+ def render_tmpl(self, tmpl_name: str, **kwargs):
141
+ return load_template(tmpl_name).substitute(**self.context)
142
+
143
+ def receive_do_orm_execute(self, orm_execute_state: orm.ORMExecuteState):
144
+ if (
145
+ orm_execute_state.is_insert
146
+ or orm_execute_state.is_update
147
+ or orm_execute_state.is_delete
148
+ ) and any(
149
+ isinstance(mapper.class_, Audit) for mapper in orm_execute_state.all_mappers
150
+ ):
151
+ self.insert_transaction(orm_execute_state.session)
152
+
153
+ def receive_before_flush(self, session: orm.Session, flush_context, instances):
154
+ if is_session_modified(session):
155
+ self.insert_transaction(session)
156
+
157
+ def insert_transaction(self, session: orm.Session):
158
+ if self.pg_audit_enabled:
159
+ values = {
160
+ "native_transaction_id": sa.func.txid_current(),
161
+ "issued_at": sa.text("NOW() AT TIME ZONE 'UTC'"),
162
+ "client_addr": self.get_client_addr(),
163
+ "actor_id": self.get_actor_id(),
164
+ }
165
+
166
+ session.execute(
167
+ pg_insert(self.Transaction)
168
+ .values(**values)
169
+ .on_conflict_do_nothing(
170
+ constraint="pga_transaction_unique_native_tx_id"
171
+ )
172
+ )
173
+
174
+ @property
175
+ def actor_cls(self) -> type | None:
176
+ if isinstance(self._actor_cls, str):
177
+ if not self.Base:
178
+ raise ImproperlyConfigured(
179
+ "This manager does not have declarative base set up yet. "
180
+ "Call init method to set up this manager."
181
+ )
182
+ try:
183
+ return self.Base.registry._class_registry[self._actor_cls] # type: ignore
184
+ except KeyError:
185
+ raise ImproperlyConfigured(
186
+ "Could not build relationship between PGAuditActivity"
187
+ f" and {self._actor_cls}. {self._actor_cls} was not found in declarative class "
188
+ "registry. Either configure VersioningManager to "
189
+ "use different actor class or disable this "
190
+ "relationship by setting it to None."
191
+ )
192
+ return self._actor_cls
193
+
194
+ def attach_listeners(self):
195
+ event.listen(orm.Session, "before_flush", self.receive_before_flush)
196
+ event.listen(orm.Session, "do_orm_execute", self.receive_do_orm_execute)
197
+
198
+ def remove_listeners(self):
199
+ event.remove(orm.Session, "before_flush", self.receive_before_flush)
200
+ event.remove(orm.Session, "do_orm_execute", self.receive_do_orm_execute)
201
+
202
+ def register_triggers(self):
203
+ register_triggers(self)
204
+
205
+ def setup_db(self):
206
+ setup_db(self)
207
+
208
+ def init_app(self, app: Flask, db: SQLAlchemy, **kwargs):
209
+ self.pg_audit_enabled = True
210
+ self.pg_audit_entities = OrderedSet()
211
+
212
+ self.Base: type["orm.DeclarativeBase"] = db.Model # type: ignore
213
+
214
+ self.Transaction = transaction_model_factory(
215
+ self.Base,
216
+ actor_cls=self.actor_cls,
217
+ schema_name=self.schema_name,
218
+ **kwargs,
219
+ )
220
+
221
+ self.Activity = activity_model_factory(
222
+ self.Base,
223
+ transaction_cls=self.Transaction,
224
+ schema_name=self.schema_name,
225
+ **kwargs,
226
+ )
227
+
228
+ self.pg_audit_classes = get_audit_models(self.Base.registry)
229
+
230
+ self.setup_db()
231
+ self.attach_listeners()
232
+
233
+ app.extensions["postgresql-audit"] = self
@@ -0,0 +1,80 @@
1
+ import typing as t
2
+
3
+ import sqlalchemy as sa
4
+ from sqlalchemy import ForeignKey
5
+ from sqlalchemy.dialects.postgresql import ExcludeConstraint
6
+ from sqlalchemy.orm import declared_attr, mapped_column, relationship
7
+
8
+ from .base import ActivityBase, TransactionBase
9
+ from .typing import OMap
10
+
11
+ if t.TYPE_CHECKING:
12
+ from sqlalchemy.orm import DeclarativeBase, Mapper
13
+
14
+ T = t.TypeVar("T")
15
+ TBase = t.TypeVar("TBase", bound=type[DeclarativeBase])
16
+
17
+
18
+ class PGAuditFactoryError(Exception):
19
+ pass
20
+
21
+
22
+ def activity_model_factory(
23
+ base_cls: "type[DeclarativeBase]",
24
+ transaction_cls: type[TransactionBase],
25
+ activity_base: type[ActivityBase] = ActivityBase,
26
+ schema_name: str | None = None,
27
+ **kwargs,
28
+ ):
29
+ class PGAuditActivity(base_cls, activity_base):
30
+ __tablename__ = "pga_activity"
31
+ __table_args__ = {"schema": schema_name}
32
+
33
+ transaction_id = mapped_column(sa.BigInteger, sa.ForeignKey(transaction_cls.id))
34
+ transaction = relationship(transaction_cls, backref="activities")
35
+
36
+ return PGAuditActivity
37
+
38
+
39
+ def transaction_model_factory(
40
+ base_cls: "type[DeclarativeBase]",
41
+ actor_cls: type | None = None,
42
+ transaction_base: type[TransactionBase] = TransactionBase,
43
+ schema_name: str | None = None,
44
+ **kwargs,
45
+ ):
46
+ if actor_cls:
47
+ actor_mapper: "Mapper[DeclarativeBase]" = sa.inspect(actor_cls)
48
+ if len(actor_mapper.primary_key) != 1:
49
+ raise PGAuditFactoryError(
50
+ "PGAudit does not support actor class"
51
+ f"with composite PK: {actor_cls.__name__}"
52
+ )
53
+
54
+ actor_pk = actor_mapper.primary_key[0]
55
+
56
+ class PGAuditTransaction(base_cls, transaction_base):
57
+ __tablename__ = "pga_transaction"
58
+
59
+ @declared_attr
60
+ def actor_id(cls) -> OMap[t.Any]:
61
+ if actor_cls:
62
+ return mapped_column(actor_pk.type, ForeignKey(actor_pk))
63
+ return mapped_column(sa.Text)
64
+
65
+ if actor_cls:
66
+ actor = relationship(actor_cls, viewonly=True)
67
+
68
+ @declared_attr.directive
69
+ @classmethod
70
+ def __table_args__(cls):
71
+ return (
72
+ ExcludeConstraint(
73
+ (cls.native_transaction_id, "="),
74
+ (cls.__transaction_interval__(), "&&"),
75
+ name="pga_transaction_unique_native_tx_id",
76
+ ),
77
+ {"schema": schema_name},
78
+ )
79
+
80
+ return PGAuditTransaction
@@ -0,0 +1,6 @@
1
+ CREATE OR REPLACE TRIGGER audit_trigger_delete AFTER DELETE ON ${table_name}
2
+ REFERENCING
3
+ OLD TABLE AS old_table
4
+ FOR EACH STATEMENT
5
+ WHEN (${schema_prefix}get_setting('flask_pga.enable_audit', 'true')::bool)
6
+ EXECUTE PROCEDURE ${schema_prefix}create_activity(${excluded_columns})
@@ -0,0 +1,6 @@
1
+ CREATE OR REPLACE TRIGGER audit_trigger_insert AFTER INSERT ON ${table_name}
2
+ REFERENCING
3
+ NEW TABLE AS new_table
4
+ FOR EACH STATEMENT
5
+ WHEN (${schema_prefix}get_setting('flask_pga.enable_audit', 'true')::bool)
6
+ EXECUTE PROCEDURE ${schema_prefix}create_activity(${excluded_columns})
@@ -0,0 +1,7 @@
1
+ CREATE OR REPLACE TRIGGER audit_trigger_update AFTER UPDATE ON ${table_name}
2
+ REFERENCING
3
+ NEW TABLE AS new_table
4
+ OLD TABLE AS old_table
5
+ FOR EACH STATEMENT
6
+ WHEN (${schema_prefix}get_setting('flask_pga.enable_audit', 'true')::bool)
7
+ EXECUTE PROCEDURE ${schema_prefix}create_activity(${excluded_columns})
@@ -0,0 +1,86 @@
1
+ CREATE OR REPLACE FUNCTION ${schema_prefix}create_activity() RETURNS TRIGGER AS $$
2
+ DECLARE
3
+ audit_row ${schema_prefix}pga_activity;
4
+ excluded_cols text[] = ARRAY[]::text[];
5
+ _transaction_id BIGINT;
6
+ BEGIN
7
+ _transaction_id := (
8
+ SELECT id
9
+ FROM ${schema_prefix}pga_transaction
10
+ WHERE native_transaction_id = txid_current()
11
+ AND issued_at >= (NOW() - INTERVAL '1 day')
12
+ ORDER BY issued_at DESC
13
+ LIMIT 1
14
+ );
15
+
16
+ IF TG_ARGV[0] IS NOT NULL THEN
17
+ excluded_cols = TG_ARGV[0]::text[];
18
+ END IF;
19
+
20
+ IF (TG_OP = 'UPDATE') THEN
21
+ INSERT INTO ${schema_prefix}pga_activity(
22
+ id, schema_name, table_name, relid, issued_at, native_transaction_id,
23
+ verb, old_data, changed_data, transaction_id)
24
+ SELECT
25
+ nextval('${schema_prefix}pga_activity_id_seq') as id,
26
+ TG_TABLE_SCHEMA::text AS schema_name,
27
+ TG_TABLE_NAME::text AS table_name,
28
+ TG_RELID AS relid,
29
+ statement_timestamp() AT TIME ZONE 'UTC' AS issued_at,
30
+ txid_current() AS native_transaction_id,
31
+ LOWER(TG_OP) AS verb,
32
+ old_data - excluded_cols AS old_data,
33
+ ${schema_prefix}jsonb_subtract(new_data, old_data) - excluded_cols AS changed_data,
34
+ _transaction_id AS transaction_id
35
+ FROM (
36
+ SELECT *
37
+ FROM (
38
+ SELECT row_to_json(old_table.*)::jsonb AS old_data, row_number() OVER ()
39
+ FROM old_table
40
+ ) AS old_table
41
+ JOIN (
42
+ SELECT row_to_json(new_table.*)::jsonb AS new_data, row_number() OVER ()
43
+ FROM new_table
44
+ ) AS new_table
45
+ USING(row_number)
46
+ ) as sub
47
+ WHERE ${schema_prefix}jsonb_subtract(new_data, old_data) - excluded_cols != '{}'::jsonb;
48
+ ELSIF (TG_OP = 'INSERT') THEN
49
+ INSERT INTO ${schema_prefix}pga_activity(
50
+ id, schema_name, table_name, relid, issued_at, native_transaction_id,
51
+ verb, old_data, changed_data, transaction_id)
52
+ SELECT
53
+ nextval('${schema_prefix}pga_activity_id_seq') as id,
54
+ TG_TABLE_SCHEMA::text AS schema_name,
55
+ TG_TABLE_NAME::text AS table_name,
56
+ TG_RELID AS relid,
57
+ statement_timestamp() AT TIME ZONE 'UTC' AS issued_at,
58
+ txid_current() AS native_transaction_id,
59
+ LOWER(TG_OP) AS verb,
60
+ '{}'::jsonb AS old_data,
61
+ row_to_json(new_table.*)::jsonb - excluded_cols AS changed_data,
62
+ _transaction_id AS transaction_id
63
+ FROM new_table;
64
+ ELSEIF TG_OP = 'DELETE' THEN
65
+ INSERT INTO ${schema_prefix}pga_activity(
66
+ id, schema_name, table_name, relid, issued_at, native_transaction_id,
67
+ verb, old_data, changed_data, transaction_id)
68
+ SELECT
69
+ nextval('${schema_prefix}pga_activity_id_seq') as id,
70
+ TG_TABLE_SCHEMA::text AS schema_name,
71
+ TG_TABLE_NAME::text AS table_name,
72
+ TG_RELID AS relid,
73
+ statement_timestamp() AT TIME ZONE 'UTC' AS issued_at,
74
+ txid_current() AS native_transaction_id,
75
+ LOWER(TG_OP) AS verb,
76
+ row_to_json(old_table.*)::jsonb - excluded_cols AS old_data,
77
+ '{}'::jsonb AS changed_data,
78
+ _transaction_id AS transaction_id
79
+ FROM old_table;
80
+ END IF;
81
+ RETURN NULL;
82
+ END;
83
+ $$
84
+ LANGUAGE plpgsql
85
+ SECURITY DEFINER
86
+ SET search_path = pg_catalog, public;
@@ -0,0 +1,2 @@
1
+ CREATE SCHEMA ${schema_name};
2
+ REVOKE ALL ON SCHEMA ${schema_name} FROM public;
@@ -0,0 +1 @@
1
+ DROP SCHEMA ${schema_name} CASCADE;
@@ -0,0 +1,6 @@
1
+ CREATE FUNCTION get_setting(setting text, fallback text) RETURNS text AS $$
2
+ SELECT coalesce(
3
+ nullif(current_setting(setting, 't'), ''),
4
+ fallback
5
+ );
6
+ $$ LANGUAGE SQL;
@@ -0,0 +1,5 @@
1
+ CREATE FUNCTION jsonb_subtract(arg1 jsonb, arg2 jsonb) RETURNS jsonb AS $$
2
+ SELECT COALESCE(json_object_agg(key, value), '{}')::jsonb
3
+ FROM jsonb_each(arg1)
4
+ WHERE (arg1->key) <> (arg2->key)
5
+ OR (arg2->key) IS NULL $$ LANGUAGE SQL;
@@ -0,0 +1,4 @@
1
+ CREATE TRIGGER audit_trigger AFTER INSERT OR UPDATE OR DELETE ON ${table_name}
2
+ FOR EACH ROW
3
+ WHEN (${schema_prefix}get_setting('flask_pga.enable_audit', 'true')::bool)
4
+ EXECUTE PROCEDURE ${schema_prefix}create_activity(${excluded_columns})
@@ -0,0 +1,48 @@
1
+ CREATE OR REPLACE FUNCTION ${schema_prefix}create_activity() RETURNS TRIGGER AS $$
2
+ DECLARE
3
+ audit_row ${schema_prefix}pga_activity;
4
+ excluded_cols text[] = ARRAY[]::text[];
5
+ BEGIN
6
+ audit_row.id = nextval('${schema_prefix}pga_activity_id_seq');
7
+ audit_row.schema_name = TG_TABLE_SCHEMA::text;
8
+ audit_row.table_name = TG_TABLE_NAME::text;
9
+ audit_row.relid = TG_RELID;
10
+ audit_row.issued_at = statement_timestamp() AT TIME ZONE 'UTC';
11
+ audit_row.native_transaction_id = txid_current();
12
+ audit_row.transaction_id = (
13
+ SELECT id
14
+ FROM ${schema_prefix}pga_transaction
15
+ WHERE native_transaction_id = txid_current()
16
+ AND issued_at >= (NOW() - INTERVAL '1 day')
17
+ ORDER BY issued_at DESC
18
+ LIMIT 1
19
+ );
20
+ audit_row.verb = LOWER(TG_OP);
21
+ audit_row.old_data = '{}'::jsonb;
22
+ audit_row.changed_data = '{}'::jsonb;
23
+
24
+ IF TG_ARGV[0] IS NOT NULL THEN
25
+ excluded_cols = TG_ARGV[0]::text[];
26
+ END IF;
27
+
28
+ IF (TG_OP = 'UPDATE' AND TG_LEVEL = 'ROW') THEN
29
+ audit_row.old_data = row_to_json(OLD.*)::jsonb - excluded_cols;
30
+ audit_row.changed_data = (
31
+ row_to_json(NEW.*)::jsonb - audit_row.old_data - excluded_cols
32
+ );
33
+ IF audit_row.changed_data = '{}'::jsonb THEN
34
+ -- All changed fields are ignored. Skip this update.
35
+ RETURN NULL;
36
+ END IF;
37
+ ELSIF (TG_OP = 'DELETE' AND TG_LEVEL = 'ROW') THEN
38
+ audit_row.old_data = row_to_json(OLD.*)::jsonb - excluded_cols;
39
+ ELSIF (TG_OP = 'INSERT' AND TG_LEVEL = 'ROW') THEN
40
+ audit_row.changed_data = row_to_json(NEW.*)::jsonb - excluded_cols;
41
+ END IF;
42
+ INSERT INTO ${schema_prefix}pga_activity VALUES (audit_row.*);
43
+ RETURN NULL;
44
+ END;
45
+ $$
46
+ LANGUAGE plpgsql
47
+ SECURITY DEFINER
48
+ SET search_path = pg_catalog, public;
@@ -0,0 +1,9 @@
1
+ from typing import Any, TypeVar
2
+
3
+ from sqlalchemy.orm import InstrumentedAttribute, Mapped
4
+
5
+ T = TypeVar("T")
6
+ OMap = Mapped[T | None] # Optional Mapped / Nullable
7
+
8
+
9
+ AnyAttrribute = InstrumentedAttribute[Any]
@@ -0,0 +1,30 @@
1
+ import os
2
+ import re
3
+ import string
4
+
5
+ ROOT_PATH = os.path.dirname(os.path.abspath(__file__))
6
+ SQL_DEFINITION_PATTERN = re.compile(
7
+ r"""
8
+ # Group 'type': Capture the start statement (e.g., CREATE FUNCTION)
9
+ ^\s*(?P<type>CREATE\s+(?:OR\s+REPLACE\s+)?(?:FUNCTION|PROCEDURE|TRIGGER))
10
+ # Group 'signature': Capture the full signature (for functions/procedures)
11
+ \s+(?P<signature>(?:[a-zA-Z0-9_."$]+\s*\([^;]*?\))|[a-zA-Z0-9_."$]+)
12
+ # Group 'definition': The entire body of the definition.
13
+ (?P<definition>[\s\S]*)
14
+ """,
15
+ re.IGNORECASE | re.DOTALL | re.MULTILINE | re.VERBOSE,
16
+ )
17
+
18
+
19
+ def load_template(tmpl_name: str):
20
+ with open(os.path.join(ROOT_PATH, f"templates/{tmpl_name}")) as f:
21
+ s = f.read()
22
+ return string.Template(s.replace("$$", "$$$$"))
23
+
24
+
25
+ def parse_template(tmpl_name: str, **context):
26
+ sql = load_template(tmpl_name).substitute(**context).strip()
27
+ res = SQL_DEFINITION_PATTERN.search(sql)
28
+ if not res:
29
+ return {}
30
+ return {k: v.strip() for k, v in res.groupdict().items()}
@@ -0,0 +1,197 @@
1
+ Metadata-Version: 2.4
2
+ Name: flask-postgresql_audit
3
+ Version: 1.0.0
4
+ Summary: Versioning and auditing extension for PostgreSQL and SQLAlchemy.
5
+ Project-URL: Homepage, https://github.com/higamigu/flask-postgresql-audit
6
+ Author-email: Refian Muhammad <higamigu@gmail.com>, Konsta Vesterinen <konsta@fastmonkeys.com>
7
+ License-Expression: BSD-2-Clause
8
+ License-File: LICENSE
9
+ Classifier: Environment :: Web Environment
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: BSD License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: alembic-utils>=0.8.8
22
+ Requires-Dist: alembic>=1.17.0
23
+ Requires-Dist: flask-sqlalchemy>=3.1.1
24
+ Requires-Dist: flask>=3.1.2
25
+ Requires-Dist: psycopg2>=2.9.11
26
+ Description-Content-Type: text/markdown
27
+
28
+ # Flask-PostgreSQL-Audit
29
+
30
+ ![BuildStatus](https://github.com/higamigu/flask-postgresql-audit/actions/workflows/test.yml/badge.svg?branch=main)
31
+ [![VersionStatus](https://img.shields.io/pypi/v/flask-postgresql-audit.svg)](https://pypi.org/project/flask-postgresql-audit/)
32
+
33
+ Auditing extension for Flask-SQLAlchemy with PostgreSQL.
34
+ Forked from [PostgreSQL-Audit](https://github.com/kvesteri/postgresql-audit), tries to combine the best of breed from existing solutions such as
35
+ [SQLAlchemy-Continuum](https://github.com/kvesteri/SQLAlchemy-Continuum),
36
+ [Papertrail](https://github.com/airblade/paper_trail) and especially
37
+ [Audit Trigger by 2ndQuadrant](https://github.com/2ndQuadrant/audit-trigger).
38
+
39
+ - Stores audit recordss into single table called `pga_activity`
40
+ - Uses trigger based approach to keep INSERTs, UPDATEs
41
+ and DELETEs as fast as possible
42
+ - Tracks and stores actor identities into table called `pga_transaction`
43
+ - Uses Alembic and Alembic-Utils to generate necessary database objects for migration
44
+
45
+ ## Installation
46
+ ```
47
+ pip install flask-postgresql-audit
48
+ ```
49
+ or using `uv`
50
+ ```
51
+ uv add flask-postgresql-audit
52
+ ```
53
+ or install directly from this repo
54
+ ```
55
+ uv add git+https://github.com/higamigu/flask-postgresql-audit --tag v1.0.0
56
+ ```
57
+
58
+ ## Usage
59
+
60
+ ``` python
61
+ from flask_sqlalchemy import SQLAlchemy
62
+ from flask_postgresql_audit import PostgreSQLAudit, Audit
63
+
64
+ from my_app import app # your flask app
65
+
66
+ db = SQLAlchemy()
67
+ audit = PostgreSQLAudit()
68
+
69
+ class Article(db.Model, Audit):
70
+ __tablename__ = 'article'
71
+
72
+ id = db.Column(db.Integer, primary_key=True)
73
+ name = db.Column(db.String)
74
+
75
+ db.init_app(app)
76
+ audit.init_app(app, db)
77
+
78
+ article = Article(name='Some article')
79
+ db.session.add(article)
80
+ db.session.commit()
81
+ ```
82
+ Then generate migration file
83
+ ```
84
+ flask db migrate -m "pg audit initial migration"
85
+ flask db upgrade
86
+ ```
87
+
88
+ Now we can check the newly created activity.
89
+
90
+ ``` python
91
+ activity = db.session.scalar(select(audit.Activity))
92
+ activity.id # 1
93
+ activity.table_name # 'article'
94
+ activity.verb # 'insert'
95
+ activity.old_data # None
96
+ activity.changed_data # {'id': '1', 'name': 'Some article'}
97
+ ```
98
+
99
+ ``` python
100
+ article.name = 'Some other article'
101
+ db.session.commit()
102
+
103
+ activity = db.session.scalar(select(audit.Activity).order_by(desc("id")))
104
+ activity.id # 2
105
+ activity.table_name # 'article'
106
+ activity.verb # 'update'
107
+ activity.object_id # 1
108
+ activity.old_data # {'id': '1', 'name': 'Some article'}
109
+ activity.changed_data # {'name': 'Some other article'}
110
+ ```
111
+
112
+ ``` python
113
+ db.session.delete(article)
114
+ db.session.commit()
115
+
116
+ activity = db.session.scalar(select(audit.Activity).order_by(desc("id")))
117
+ activity.id # 3
118
+ activity.table_name # 'article'
119
+ activity.verb # 'delete'
120
+ activity.object_id # 1
121
+ activity.old_data # {'id': '1', 'name': 'Some other article'}
122
+ activity.changed_data # None
123
+ ```
124
+
125
+ ## Different Schema
126
+ You can isolate `pg_audit` objects entirely to a different schema by doing
127
+
128
+ ``` python
129
+ from flask_postgresql_audit import PostgreSQLAudit
130
+
131
+ audit = PostgreSQLAudit(schema="audit")
132
+
133
+ ...
134
+ ```
135
+ And then you need to tell alembic to track other than `public` schema by adding following line in `alembic/env.py`
136
+ ``` python
137
+ ...
138
+
139
+ def run_migrations_online():
140
+ connectable = get_engine()
141
+
142
+ with connectable.connect() as connection:
143
+ context.configure(
144
+ connection=connection,
145
+ target_metadata=get_metadata(),
146
+ include_schemas=True, # add this arg
147
+ ...
148
+ )
149
+
150
+ with context.begin_transaction():
151
+ context.run_migrations()
152
+
153
+ ...
154
+ ```
155
+
156
+ ## Custom Actor ID getter
157
+ You can customize actor id getter function by doing the following. Here is an example using `current_user` from `flask_jwt_extended` library.
158
+
159
+ ``` python
160
+ from flask_jwt_extended import jwt_required, current_user
161
+
162
+ @jwt_required(optional=True)
163
+ def actor_id_getter():
164
+ try:
165
+ return current_user.email or None
166
+ except Exception:
167
+ return None
168
+
169
+ ...
170
+
171
+ audit = PostgreSQLAudit(actor_id_getter=actor_id_getter)
172
+
173
+ ...
174
+ ```
175
+
176
+ ## Enable Alembic Logger
177
+ You can enable alembic logger for `pg_audit` by adding the following to your `alembic.ini`
178
+ ``` ini
179
+ # Logging configuration
180
+ [loggers]
181
+ keys = root,sqlalchemy,alembic,alembic_utils,pg_audit # add 'pg_audit' here
182
+
183
+ ...
184
+
185
+ [logger_pg_audit] # and add the logger for pg_audit here
186
+ level = INFO
187
+ handlers =
188
+ qualname = pg_audit
189
+ ```
190
+
191
+ ## Running the tests
192
+
193
+ git clone https://github.com/higamigu/flask-postgresql-audit.git
194
+ cd flask-postgresql-audit
195
+ pip install tox
196
+ createdb postgresql_audit_test
197
+ tox
@@ -0,0 +1,24 @@
1
+ flask_postgresql_audit/__init__.py,sha256=nuctRPir5WQhoHqywM_zMo936pBXddqurB4husc9R7M,83
2
+ flask_postgresql_audit/base.py,sha256=QW_8sTTN7NdbLZcVqHKqcPMolSb-EVFJmuLPLfO2uGA,1701
3
+ flask_postgresql_audit/core.py,sha256=_dNk7ggk6zIDbJI6NHkH8ZcOec_77eMmPSBgBJlz3gA,7612
4
+ flask_postgresql_audit/models.py,sha256=43opmurL0-N1IpaYrxEFQ6dhBptXlP0z0xR-Hx_0ToU,2437
5
+ flask_postgresql_audit/typing.py,sha256=guMb2OEKeGBUsEpgHgy-gFlmpqG-jT0YFQxcgbdbRVs,207
6
+ flask_postgresql_audit/utils.py,sha256=XyLcSNl_fKVg8oWZgLGp57rRNDARqvtlSzCiMDDxyHM,1033
7
+ flask_postgresql_audit/alembic/__init__.py,sha256=9vUMv2H88Sp_s4NOElFFSqRsBT1b7yDe6vExcp_2SOI,66
8
+ flask_postgresql_audit/alembic/entities.py,sha256=NcVQtLnw6LEZcLYogLEQi4TNCozsQE4fH2ClTkXi7Ys,1997
9
+ flask_postgresql_audit/alembic/schema.py,sha256=SsFlRSxr6TVCbdYpSn4_qhIf2byPd49e37zlBwe41X4,1741
10
+ flask_postgresql_audit/alembic/setup.py,sha256=MeexzU_pZ52R12IZKWyLqCbK4tiG-yQiHnjIDGNxVBw,3661
11
+ flask_postgresql_audit/templates/audit_trigger_delete.sql,sha256=VgehkRdmnhp8wdvMs8VyTDCD5bpmXvwYcyn7pp_N7dY,281
12
+ flask_postgresql_audit/templates/audit_trigger_insert.sql,sha256=yLAAjXNh9C-zUhJqdcVAVhc7ONYeNbsIHsUmxpkvi2w,281
13
+ flask_postgresql_audit/templates/audit_trigger_update.sql,sha256=RxauNRA1HIaJw3KW9rzP-kLkmRvq5kDG-dMdNGO-Inw,308
14
+ flask_postgresql_audit/templates/create_activity.sql,sha256=8XWgC4nuY0_kbhJHklWVhfn0pd5eU8niM5cKJtxTHYo,3571
15
+ flask_postgresql_audit/templates/create_schema.sql,sha256=66L3ofA2Zs7VgDHEDMi-oH49l5EYfohqOAknmEBQldA,79
16
+ flask_postgresql_audit/templates/drop_schema.sql,sha256=XLM7ZjDqLqQgPu4sEX3RTHurSJlNovO-7yUZogu1jw0,35
17
+ flask_postgresql_audit/templates/get_setting.sql,sha256=E748kfEtYTtxlVlkw5hfMsCoMcjMEjUJgqFnpUrGlsw,184
18
+ flask_postgresql_audit/templates/jsonb_subtract.sql,sha256=G56SYEWWbIXBAENwSeaXCi0bJunD77q9Taw5YpcVHoc,231
19
+ flask_postgresql_audit/templates/row_audit_trigger.sql,sha256=1zsWX5sO7jydXtYxzmjSiLUcATZP7NEokU6-yfaN1QI,238
20
+ flask_postgresql_audit/templates/row_create_activity.sql,sha256=4ih79PRidBHFRG9aLEla8HQron9WauLHE8ej6V5zMFM,1810
21
+ flask_postgresql_audit-1.0.0.dist-info/METADATA,sha256=zHmW4P4hPMpCjExbgzNtcouSOmyRSk-T0kA6eb1BLbg,5758
22
+ flask_postgresql_audit-1.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
23
+ flask_postgresql_audit-1.0.0.dist-info/licenses/LICENSE,sha256=5HX4yP-Uz5iX3-IOU_8hPu6CoN387gMPqcEDt2fEo70,1330
24
+ flask_postgresql_audit-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,25 @@
1
+ Copyright (c) 2015, Konsta Vesterinen
2
+ Copyright (c) 2025, Higamigu
3
+ All rights reserved.
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ * Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ * Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
19
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
23
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
25
+