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.
- flask_postgresql_audit/__init__.py +4 -0
- flask_postgresql_audit/alembic/__init__.py +2 -0
- flask_postgresql_audit/alembic/entities.py +64 -0
- flask_postgresql_audit/alembic/schema.py +58 -0
- flask_postgresql_audit/alembic/setup.py +93 -0
- flask_postgresql_audit/base.py +51 -0
- flask_postgresql_audit/core.py +233 -0
- flask_postgresql_audit/models.py +80 -0
- flask_postgresql_audit/templates/audit_trigger_delete.sql +6 -0
- flask_postgresql_audit/templates/audit_trigger_insert.sql +6 -0
- flask_postgresql_audit/templates/audit_trigger_update.sql +7 -0
- flask_postgresql_audit/templates/create_activity.sql +86 -0
- flask_postgresql_audit/templates/create_schema.sql +2 -0
- flask_postgresql_audit/templates/drop_schema.sql +1 -0
- flask_postgresql_audit/templates/get_setting.sql +6 -0
- flask_postgresql_audit/templates/jsonb_subtract.sql +5 -0
- flask_postgresql_audit/templates/row_audit_trigger.sql +4 -0
- flask_postgresql_audit/templates/row_create_activity.sql +48 -0
- flask_postgresql_audit/typing.py +9 -0
- flask_postgresql_audit/utils.py +30 -0
- flask_postgresql_audit-1.0.0.dist-info/METADATA +197 -0
- flask_postgresql_audit-1.0.0.dist-info/RECORD +24 -0
- flask_postgresql_audit-1.0.0.dist-info/WHEEL +4 -0
- flask_postgresql_audit-1.0.0.dist-info/licenses/LICENSE +25 -0
|
@@ -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 @@
|
|
|
1
|
+
DROP SCHEMA ${schema_name} CASCADE;
|
|
@@ -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,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
|
+

|
|
31
|
+
[](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,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
|
+
|