fastapi-admin-kit 0.1.0__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.
- fastapi_admin_kit/__init__.py +73 -0
- fastapi_admin_kit/actions/__init__.py +63 -0
- fastapi_admin_kit/actions/base.py +68 -0
- fastapi_admin_kit/actions/registry.py +43 -0
- fastapi_admin_kit/admin/__init__.py +17 -0
- fastapi_admin_kit/admin/admin_config.py +95 -0
- fastapi_admin_kit/admin/admin_database.py +138 -0
- fastapi_admin_kit/admin/admin_router.py +74 -0
- fastapi_admin_kit/admin/admin_template.py +203 -0
- fastapi_admin_kit/admin/builtin_models.py +284 -0
- fastapi_admin_kit/admin/core.py +1036 -0
- fastapi_admin_kit/admin/decorators.py +70 -0
- fastapi_admin_kit/admin/state.py +76 -0
- fastapi_admin_kit/admin.py +728 -0
- fastapi_admin_kit/api/__init__.py +44 -0
- fastapi_admin_kit/api/auth.py +342 -0
- fastapi_admin_kit/api/crud.py +128 -0
- fastapi_admin_kit/api/deps.py +79 -0
- fastapi_admin_kit/api/roles.py +128 -0
- fastapi_admin_kit/api/schema_generator.py +171 -0
- fastapi_admin_kit/api/schemas.py +81 -0
- fastapi_admin_kit/api/search.py +132 -0
- fastapi_admin_kit/audit/__init__.py +36 -0
- fastapi_admin_kit/audit/context.py +62 -0
- fastapi_admin_kit/audit/diff.py +77 -0
- fastapi_admin_kit/audit/event_bus.py +96 -0
- fastapi_admin_kit/audit/events.py +48 -0
- fastapi_admin_kit/audit/listener.py +159 -0
- fastapi_admin_kit/audit/logger.py +28 -0
- fastapi_admin_kit/audit/middleware.py +39 -0
- fastapi_admin_kit/audit/models.py +53 -0
- fastapi_admin_kit/audit/sqlalchemy_logger.py +58 -0
- fastapi_admin_kit/auth/__init__.py +34 -0
- fastapi_admin_kit/auth/backend.py +95 -0
- fastapi_admin_kit/auth/csrf.py +240 -0
- fastapi_admin_kit/auth/dependencies.py +150 -0
- fastapi_admin_kit/auth/identity.py +181 -0
- fastapi_admin_kit/auth/models.py +246 -0
- fastapi_admin_kit/auth/password.py +35 -0
- fastapi_admin_kit/auth/permissions.py +205 -0
- fastapi_admin_kit/auth/protocol.py +22 -0
- fastapi_admin_kit/auth/ratelimit.py +88 -0
- fastapi_admin_kit/auth/router.py +10 -0
- fastapi_admin_kit/auth/session.py +79 -0
- fastapi_admin_kit/auth/totp.py +83 -0
- fastapi_admin_kit/auth/views.py +165 -0
- fastapi_admin_kit/cli.py +229 -0
- fastapi_admin_kit/config/__init__.py +19 -0
- fastapi_admin_kit/config/audit.py +18 -0
- fastapi_admin_kit/config/auth.py +54 -0
- fastapi_admin_kit/config/behavior.py +27 -0
- fastapi_admin_kit/config/nav.py +32 -0
- fastapi_admin_kit/config/storage.py +22 -0
- fastapi_admin_kit/config/theme.py +215 -0
- fastapi_admin_kit/config/ui.py +147 -0
- fastapi_admin_kit/dashboard/__init__.py +64 -0
- fastapi_admin_kit/db.py +133 -0
- fastapi_admin_kit/exceptions.py +5 -0
- fastapi_admin_kit/field_types.py +81 -0
- fastapi_admin_kit/filters/__init__.py +21 -0
- fastapi_admin_kit/filters/base.py +170 -0
- fastapi_admin_kit/filters/registry.py +68 -0
- fastapi_admin_kit/flash.py +45 -0
- fastapi_admin_kit/form/__init__.py +1 -0
- fastapi_admin_kit/form/pipeline.py +106 -0
- fastapi_admin_kit/inspection/__init__.py +117 -0
- fastapi_admin_kit/inspection/registry.py +253 -0
- fastapi_admin_kit/inspection.py +115 -0
- fastapi_admin_kit/modeladmin.py +375 -0
- fastapi_admin_kit/models/__init__.py +7 -0
- fastapi_admin_kit/models/base.py +7 -0
- fastapi_admin_kit/nav.py +208 -0
- fastapi_admin_kit/pagination/__init__.py +14 -0
- fastapi_admin_kit/pagination/base.py +40 -0
- fastapi_admin_kit/pagination/cursor.py +97 -0
- fastapi_admin_kit/pagination/dynamic.py +48 -0
- fastapi_admin_kit/pagination/offset.py +42 -0
- fastapi_admin_kit/plugins/__init__.py +1 -0
- fastapi_admin_kit/py.typed +0 -0
- fastapi_admin_kit/registry/__init__.py +5 -0
- fastapi_admin_kit/registry/core.py +287 -0
- fastapi_admin_kit/registry/validation.py +107 -0
- fastapi_admin_kit/registry.py +15 -0
- fastapi_admin_kit/router.py +335 -0
- fastapi_admin_kit/static/css/admin.css +4736 -0
- fastapi_admin_kit/static/css/presets.css +317 -0
- fastapi_admin_kit/static/css/tokens.css +217 -0
- fastapi_admin_kit/static/css/variables.css +74 -0
- fastapi_admin_kit/static/icons/heroicons.svg +160 -0
- fastapi_admin_kit/static/js/admin.js +692 -0
- fastapi_admin_kit/static/js/htmx-config.js +42 -0
- fastapi_admin_kit/storage/__init__.py +6 -0
- fastapi_admin_kit/storage/base.py +48 -0
- fastapi_admin_kit/storage/local.py +73 -0
- fastapi_admin_kit/templates/base.html +142 -0
- fastapi_admin_kit/templates/macros/form_fields.html +660 -0
- fastapi_admin_kit/templates/macros/icons.html +50 -0
- fastapi_admin_kit/templates/macros/table.html +108 -0
- fastapi_admin_kit/templates/macros/widgets.html +159 -0
- fastapi_admin_kit/templates/pages/2fa/setup.html +122 -0
- fastapi_admin_kit/templates/pages/2fa/verify.html +55 -0
- fastapi_admin_kit/templates/pages/audit_detail.html +122 -0
- fastapi_admin_kit/templates/pages/audit_log.html +102 -0
- fastapi_admin_kit/templates/pages/dashboard.html +295 -0
- fastapi_admin_kit/templates/pages/detail.html +183 -0
- fastapi_admin_kit/templates/pages/form.html +119 -0
- fastapi_admin_kit/templates/pages/list.html +277 -0
- fastapi_admin_kit/templates/pages/login.html +85 -0
- fastapi_admin_kit/templates/pages/profile/password.html +78 -0
- fastapi_admin_kit/templates/pages/profile/profile.html +73 -0
- fastapi_admin_kit/templates/pages/role_form.html +75 -0
- fastapi_admin_kit/templates/pages/roles/form.html +117 -0
- fastapi_admin_kit/templates/pages/roles/list.html +69 -0
- fastapi_admin_kit/templates/pages/roles.html +77 -0
- fastapi_admin_kit/templates/pages/settings/theme.html +255 -0
- fastapi_admin_kit/templates/pages/users/form.html +229 -0
- fastapi_admin_kit/templates/pages/users/list.html +83 -0
- fastapi_admin_kit/templates/partials/command_palette.html +52 -0
- fastapi_admin_kit/templates/partials/field_wrapper.html +2 -0
- fastapi_admin_kit/templates/partials/flash_messages.html +39 -0
- fastapi_admin_kit/templates/partials/head.html +21 -0
- fastapi_admin_kit/templates/partials/head_minimal.html +18 -0
- fastapi_admin_kit/templates/partials/list_table.html +178 -0
- fastapi_admin_kit/templates/partials/mobile_backdrop.html +2 -0
- fastapi_admin_kit/templates/partials/pagination.html +82 -0
- fastapi_admin_kit/templates/partials/permission_widget.html +86 -0
- fastapi_admin_kit/templates/partials/scripts.html +13 -0
- fastapi_admin_kit/templates/partials/sidebar.html +94 -0
- fastapi_admin_kit/templates/partials/topbar.html +95 -0
- fastapi_admin_kit/types.py +145 -0
- fastapi_admin_kit/validation.py +43 -0
- fastapi_admin_kit/views/__init__.py +78 -0
- fastapi_admin_kit/views/audit.py +134 -0
- fastapi_admin_kit/views/bulk.py +28 -0
- fastapi_admin_kit/views/class_views.py +1040 -0
- fastapi_admin_kit/views/context.py +588 -0
- fastapi_admin_kit/views/dashboard.py +162 -0
- fastapi_admin_kit/views/delete.py +31 -0
- fastapi_admin_kit/views/extra.py +65 -0
- fastapi_admin_kit/views/factory.py +667 -0
- fastapi_admin_kit/views/form.py +159 -0
- fastapi_admin_kit/views/list.py +28 -0
- fastapi_admin_kit/views/profile.py +219 -0
- fastapi_admin_kit/views/protocols.py +54 -0
- fastapi_admin_kit/views/renderers.py +634 -0
- fastapi_admin_kit/views/roles.py +230 -0
- fastapi_admin_kit/views/search.py +31 -0
- fastapi_admin_kit/views/settings.py +31 -0
- fastapi_admin_kit/views/sidebar.py +101 -0
- fastapi_admin_kit/views/totp.py +249 -0
- fastapi_admin_kit/views/users.py +347 -0
- fastapi_admin_kit/views.py +117 -0
- fastapi_admin_kit/widgets/__init__.py +44 -0
- fastapi_admin_kit/widgets/base.py +44 -0
- fastapi_admin_kit/widgets/inputs.py +363 -0
- fastapi_admin_kit/widgets/registry.py +110 -0
- fastapi_admin_kit/widgets/relation.py +70 -0
- fastapi_admin_kit/widgets/resolver.py +102 -0
- fastapi_admin_kit-0.1.0.dist-info/METADATA +210 -0
- fastapi_admin_kit-0.1.0.dist-info/RECORD +163 -0
- fastapi_admin_kit-0.1.0.dist-info/WHEEL +4 -0
- fastapi_admin_kit-0.1.0.dist-info/entry_points.txt +3 -0
- fastapi_admin_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Audit event bus — publish/subscribe system for audit events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi_admin_kit.audit.diff import snapshot
|
|
9
|
+
from fastapi_admin_kit.audit.events import AuditEvent
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuditEventBus:
|
|
13
|
+
"""Central event bus for audit events.
|
|
14
|
+
|
|
15
|
+
The listener publishes events here. Loggers and other subscribers
|
|
16
|
+
consume them. This decouples the SQLAlchemy event hooks from the
|
|
17
|
+
audit logging implementation.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._listeners: dict[str, list[Callable[[AuditEvent], None]]] = {
|
|
22
|
+
"CREATE": [],
|
|
23
|
+
"UPDATE": [],
|
|
24
|
+
"DELETE": [],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
def subscribe(
|
|
28
|
+
self, event_type: str, listener: Callable[[AuditEvent], None]
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Register a listener for a specific event type.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
event_type: "CREATE", "UPDATE", or "DELETE"
|
|
34
|
+
listener: Callable that receives an AuditEvent
|
|
35
|
+
"""
|
|
36
|
+
if event_type not in self._listeners:
|
|
37
|
+
self._listeners[event_type] = []
|
|
38
|
+
self._listeners[event_type].append(listener)
|
|
39
|
+
|
|
40
|
+
def publish(self, event: AuditEvent) -> None:
|
|
41
|
+
"""Publish an event to all listeners of its type.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
event: The AuditEvent to publish
|
|
45
|
+
"""
|
|
46
|
+
for listener in self._listeners.get(event.event_type, []):
|
|
47
|
+
listener(event)
|
|
48
|
+
|
|
49
|
+
def emit_for_object(
|
|
50
|
+
self,
|
|
51
|
+
obj: Any,
|
|
52
|
+
event_type: str,
|
|
53
|
+
context: dict[str, Any],
|
|
54
|
+
changes: dict[str, Any] | None = None,
|
|
55
|
+
snapshot_data: dict[str, Any] | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Build an AuditEvent from a SQLAlchemy object and publish it.
|
|
58
|
+
|
|
59
|
+
All data is extracted from *snapshot_data* to avoid touching the
|
|
60
|
+
ORM object — critical inside ``after_flush`` where attributes are
|
|
61
|
+
expired on async sessions.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
obj: The SQLAlchemy model instance (used only for class/table
|
|
65
|
+
metadata which are class-level, not instance attributes)
|
|
66
|
+
event_type: "CREATE", "UPDATE", or "DELETE"
|
|
67
|
+
context: Audit context dict
|
|
68
|
+
changes: Pre-computed diff (used for UPDATE)
|
|
69
|
+
snapshot_data: Pre-computed snapshot dict
|
|
70
|
+
"""
|
|
71
|
+
obj_snapshot = snapshot_data if snapshot_data is not None else snapshot(obj)
|
|
72
|
+
|
|
73
|
+
# Extract id and repr from the snapshot to avoid accessing
|
|
74
|
+
# potentially-expired instance attributes.
|
|
75
|
+
object_id = str(obj_snapshot.get("id", ""))
|
|
76
|
+
object_repr = str(obj_snapshot.get("id", obj.__class__.__name__))[:255]
|
|
77
|
+
|
|
78
|
+
if event_type == "UPDATE" and changes is not None:
|
|
79
|
+
event_changes = changes
|
|
80
|
+
else:
|
|
81
|
+
event_changes = None
|
|
82
|
+
|
|
83
|
+
event = AuditEvent(
|
|
84
|
+
event_type=event_type,
|
|
85
|
+
model_name=obj.__class__.__name__,
|
|
86
|
+
table_name=obj.__tablename__,
|
|
87
|
+
object_id=object_id,
|
|
88
|
+
object_repr=object_repr,
|
|
89
|
+
changes=event_changes,
|
|
90
|
+
full_snapshot=obj_snapshot,
|
|
91
|
+
user_id=context.get("user_id"),
|
|
92
|
+
user_email=context.get("user_email"),
|
|
93
|
+
ip_address=context.get("ip_address"),
|
|
94
|
+
user_agent=context.get("user_agent"),
|
|
95
|
+
)
|
|
96
|
+
self.publish(event)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Audit events — data structures for audit events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class AuditEvent:
|
|
12
|
+
"""Represents a single audit event (CREATE, UPDATE, or DELETE).
|
|
13
|
+
|
|
14
|
+
This is a pure data structure with no side effects, making it
|
|
15
|
+
easy to test and serialize.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
event_type: str # "CREATE" | "UPDATE" | "DELETE"
|
|
19
|
+
model_name: str
|
|
20
|
+
table_name: str
|
|
21
|
+
object_id: str
|
|
22
|
+
object_repr: str = ""
|
|
23
|
+
changes: dict[str, Any] | None = None
|
|
24
|
+
full_snapshot: dict[str, Any] | None = None
|
|
25
|
+
user_id: int | None = None
|
|
26
|
+
user_email: str | None = None
|
|
27
|
+
ip_address: str | None = None
|
|
28
|
+
user_agent: str | None = None
|
|
29
|
+
timestamp: datetime.datetime | None = None
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
if self.timestamp is None:
|
|
33
|
+
self.timestamp = datetime.datetime.now(datetime.UTC)
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, Any]:
|
|
36
|
+
"""Serialize the event to a dictionary."""
|
|
37
|
+
data = asdict(self)
|
|
38
|
+
if self.timestamp is not None:
|
|
39
|
+
data["timestamp"] = self.timestamp.isoformat()
|
|
40
|
+
return data
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_dict(cls, data: dict[str, Any]) -> AuditEvent:
|
|
44
|
+
"""Deserialize an event from a dictionary."""
|
|
45
|
+
ts = data.get("timestamp")
|
|
46
|
+
if isinstance(ts, str):
|
|
47
|
+
data["timestamp"] = datetime.datetime.fromisoformat(ts)
|
|
48
|
+
return cls(**data)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Audit listener — SQLAlchemy event listeners that write audit rows atomically.
|
|
2
|
+
|
|
3
|
+
AuditLog rows are created inside ``before_flush`` and added to the session
|
|
4
|
+
via ``session.add()``. SQLAlchemy re-runs ``before_flush`` until no new
|
|
5
|
+
pending objects appear, so the audit rows ride along in the same flush pass.
|
|
6
|
+
No IO, no queries, no ``MissingGreenlet`` — just already-loaded attributes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from sqlalchemy import event
|
|
14
|
+
from sqlalchemy.orm import Session
|
|
15
|
+
from sqlalchemy.orm.attributes import instance_state
|
|
16
|
+
|
|
17
|
+
from fastapi_admin_kit.audit.context import get_audit_context
|
|
18
|
+
from fastapi_admin_kit.audit.diff import serialize_value
|
|
19
|
+
from fastapi_admin_kit.audit.models import AuditLog
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_registered_model(obj: Any, registry: Any) -> bool:
|
|
23
|
+
"""Check if a model class is registered with the admin."""
|
|
24
|
+
if not hasattr(obj, "__tablename__"):
|
|
25
|
+
return False
|
|
26
|
+
table_name = getattr(obj, "__tablename__")
|
|
27
|
+
return registry.get(table_name) is not None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _snapshot_from_committed(obj: Any) -> dict[str, Any]:
|
|
31
|
+
"""Snapshot column values from the committed (pre-flush) state.
|
|
32
|
+
|
|
33
|
+
Uses SQLAlchemy attribute history to read the *old* values that were
|
|
34
|
+
in the database before the current pending changes. No attribute
|
|
35
|
+
access that could trigger lazy-load I/O.
|
|
36
|
+
"""
|
|
37
|
+
if not hasattr(obj, "__table__"):
|
|
38
|
+
return {}
|
|
39
|
+
mapper = instance_state(obj).manager.mapper
|
|
40
|
+
data: dict[str, Any] = {}
|
|
41
|
+
for column in mapper.columns:
|
|
42
|
+
attr = instance_state(obj).attrs[column.key]
|
|
43
|
+
history = attr.history
|
|
44
|
+
if history.deleted:
|
|
45
|
+
data[column.key] = serialize_value(history.deleted[0])
|
|
46
|
+
elif history.unchanged:
|
|
47
|
+
data[column.key] = serialize_value(history.unchanged[0])
|
|
48
|
+
else:
|
|
49
|
+
data[column.key] = serialize_value(getattr(obj, column.key))
|
|
50
|
+
return data
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _snapshot_current(obj: Any) -> dict[str, Any]:
|
|
54
|
+
"""Snapshot all mapped columns of a SQLAlchemy model instance."""
|
|
55
|
+
if not hasattr(obj, "__table__"):
|
|
56
|
+
return {}
|
|
57
|
+
from sqlalchemy.inspection import inspect as sa_inspect
|
|
58
|
+
|
|
59
|
+
mapper = sa_inspect(obj.__class__)
|
|
60
|
+
data: dict[str, Any] = {}
|
|
61
|
+
for column in mapper.columns:
|
|
62
|
+
data[column.key] = serialize_value(getattr(obj, column.key))
|
|
63
|
+
return data
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _compute_diff(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]:
|
|
67
|
+
"""Compute changed fields between two snapshots.
|
|
68
|
+
|
|
69
|
+
Returns ``{"field": {"old": ..., "new": ...}}`` for each difference.
|
|
70
|
+
"""
|
|
71
|
+
diff: dict[str, Any] = {}
|
|
72
|
+
all_keys = set(before.keys()) | set(after.keys())
|
|
73
|
+
for key in all_keys:
|
|
74
|
+
old_val = before.get(key)
|
|
75
|
+
new_val = after.get(key)
|
|
76
|
+
if old_val != new_val:
|
|
77
|
+
diff[key] = {"old": old_val, "new": new_val}
|
|
78
|
+
return diff
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _build_audit_row(
|
|
82
|
+
obj: Any,
|
|
83
|
+
action: str,
|
|
84
|
+
context: dict[str, Any],
|
|
85
|
+
*,
|
|
86
|
+
changes: dict[str, Any] | None = None,
|
|
87
|
+
snapshot_data: dict[str, Any] | None = None,
|
|
88
|
+
) -> AuditLog:
|
|
89
|
+
"""Create an AuditLog row from an ORM object and audit context."""
|
|
90
|
+
snap = snapshot_data if snapshot_data is not None else _snapshot_current(obj)
|
|
91
|
+
return AuditLog(
|
|
92
|
+
action=action,
|
|
93
|
+
model_name=type(obj).__name__,
|
|
94
|
+
table_name=obj.__tablename__,
|
|
95
|
+
object_id=str(snap.get("id", getattr(obj, "id", ""))),
|
|
96
|
+
object_repr=str(obj)[:500],
|
|
97
|
+
changes=changes,
|
|
98
|
+
full_snapshot=snap,
|
|
99
|
+
user_id=context.get("user_id"),
|
|
100
|
+
user_email=context.get("user_email"),
|
|
101
|
+
ip_address=context.get("ip_address"),
|
|
102
|
+
user_agent=context.get("user_agent"),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def attach_audit_listener(
|
|
107
|
+
session_factory: Any,
|
|
108
|
+
registry: Any,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Set up SQLAlchemy ``before_flush`` listener for audit logging.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
session_factory: The session factory (sync or async).
|
|
114
|
+
registry: The AdminRegistry instance.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
@event.listens_for(Session, "before_flush")
|
|
118
|
+
def before_flush(session: Session, flush_context: Any, instances: Any) -> None:
|
|
119
|
+
"""Create AuditLog rows for all tracked mutations.
|
|
120
|
+
|
|
121
|
+
Runs inside the same flush pass — ``session.add()`` puts the
|
|
122
|
+
AuditLog into the pending set and SQLAlchemy will re-run
|
|
123
|
+
``before_flush`` until no new objects appear. No queries, no
|
|
124
|
+
lazy-loads, only already-loaded attribute history.
|
|
125
|
+
"""
|
|
126
|
+
context = get_audit_context()
|
|
127
|
+
|
|
128
|
+
# ── INSERT ──────────────────────────────────────────────────
|
|
129
|
+
for obj in list(session.new):
|
|
130
|
+
if not is_registered_model(obj, registry):
|
|
131
|
+
continue
|
|
132
|
+
if obj.__tablename__ == AuditLog.__tablename__:
|
|
133
|
+
continue
|
|
134
|
+
row = _build_audit_row(obj, "CREATE", context)
|
|
135
|
+
session.add(row)
|
|
136
|
+
|
|
137
|
+
# ── UPDATE ──────────────────────────────────────────────────
|
|
138
|
+
for obj in list(session.dirty):
|
|
139
|
+
if not is_registered_model(obj, registry):
|
|
140
|
+
continue
|
|
141
|
+
if obj.__tablename__ == AuditLog.__tablename__:
|
|
142
|
+
continue
|
|
143
|
+
before = _snapshot_from_committed(obj)
|
|
144
|
+
after = _snapshot_current(obj)
|
|
145
|
+
diff = _compute_diff(before, after)
|
|
146
|
+
if not diff:
|
|
147
|
+
continue
|
|
148
|
+
row = _build_audit_row(obj, "UPDATE", context, changes=diff, snapshot_data=after)
|
|
149
|
+
session.add(row)
|
|
150
|
+
|
|
151
|
+
# ── DELETE ──────────────────────────────────────────────────
|
|
152
|
+
for obj in list(session.deleted):
|
|
153
|
+
if not is_registered_model(obj, registry):
|
|
154
|
+
continue
|
|
155
|
+
if obj.__tablename__ == AuditLog.__tablename__:
|
|
156
|
+
continue
|
|
157
|
+
snap = _snapshot_current(obj)
|
|
158
|
+
row = _build_audit_row(obj, "DELETE", context, snapshot_data=snap)
|
|
159
|
+
session.add(row)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Audit logger — interface for audit log persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from fastapi_admin_kit.audit.events import AuditEvent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuditLogger(ABC):
|
|
11
|
+
"""Abstract interface for persisting audit events.
|
|
12
|
+
|
|
13
|
+
Implementations handle the actual storage (database, file, etc.).
|
|
14
|
+
This separation allows testing audit logic without a database, and
|
|
15
|
+
swapping storage backends without changing the event flow.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def log_create(self, event: AuditEvent) -> None:
|
|
20
|
+
"""Persist a CREATE audit event."""
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def log_update(self, event: AuditEvent) -> None:
|
|
24
|
+
"""Persist an UPDATE audit event."""
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def log_delete(self, event: AuditEvent) -> None:
|
|
28
|
+
"""Persist a DELETE audit event."""
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Audit middleware — sets and clears audit context from the request."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
6
|
+
from starlette.requests import Request
|
|
7
|
+
from starlette.responses import Response
|
|
8
|
+
|
|
9
|
+
from fastapi_admin_kit.audit.context import clear_audit_context, set_audit_context
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuditContextMiddleware(BaseHTTPMiddleware):
|
|
13
|
+
"""Middleware to set audit context from the request and clear it after.
|
|
14
|
+
|
|
15
|
+
Sets IP address and user-agent before the handler runs. The user
|
|
16
|
+
identity (user_id, user_email) is added later by
|
|
17
|
+
:func:`fastapi_admin_kit.auth.identity.resolve_user` once the auth
|
|
18
|
+
dependency resolves the current user — which always happens before
|
|
19
|
+
any ``session.commit()`` that would trigger audit listeners.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
23
|
+
context_data: dict = {}
|
|
24
|
+
|
|
25
|
+
if request.client is not None:
|
|
26
|
+
context_data["ip_address"] = request.client.host
|
|
27
|
+
|
|
28
|
+
user_agent = request.headers.get("user-agent")
|
|
29
|
+
if user_agent:
|
|
30
|
+
context_data["user_agent"] = user_agent
|
|
31
|
+
|
|
32
|
+
if context_data:
|
|
33
|
+
set_audit_context(context_data)
|
|
34
|
+
|
|
35
|
+
response = await call_next(request)
|
|
36
|
+
|
|
37
|
+
clear_audit_context()
|
|
38
|
+
|
|
39
|
+
return response
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""SQLAlchemy model for the admin audit log."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import (
|
|
6
|
+
JSON,
|
|
7
|
+
Column,
|
|
8
|
+
DateTime,
|
|
9
|
+
ForeignKey,
|
|
10
|
+
Index,
|
|
11
|
+
Integer,
|
|
12
|
+
String,
|
|
13
|
+
Text,
|
|
14
|
+
)
|
|
15
|
+
from sqlalchemy.sql import func
|
|
16
|
+
|
|
17
|
+
from fastapi_admin_kit.models.base import Base
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuditLog(Base):
|
|
21
|
+
__tablename__ = "admin_audit_log"
|
|
22
|
+
__table_args__ = (
|
|
23
|
+
Index("idx_audit_model", "model_name", "table_name"),
|
|
24
|
+
Index("idx_audit_user", "user_id"),
|
|
25
|
+
Index("idx_audit_timestamp", "timestamp"),
|
|
26
|
+
Index("idx_audit_object", "table_name", "object_id"),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
30
|
+
user_id = Column(
|
|
31
|
+
Integer,
|
|
32
|
+
ForeignKey("admin_users.id", ondelete="SET NULL"),
|
|
33
|
+
nullable=True,
|
|
34
|
+
)
|
|
35
|
+
user_email = Column(String(255))
|
|
36
|
+
action = Column(String(10), nullable=False) # CREATE | UPDATE | DELETE
|
|
37
|
+
model_name = Column(String(255), nullable=False)
|
|
38
|
+
table_name = Column(String(255), nullable=False)
|
|
39
|
+
object_id = Column(String(255), nullable=False)
|
|
40
|
+
object_repr = Column(String(500))
|
|
41
|
+
changes = Column(JSON) # diff (null for CREATE/DELETE)
|
|
42
|
+
full_snapshot = Column(JSON) # full object state at time of action
|
|
43
|
+
ip_address = Column(String(45))
|
|
44
|
+
user_agent = Column(Text)
|
|
45
|
+
timestamp = Column(DateTime(timezone=True), server_default=func.now())
|
|
46
|
+
|
|
47
|
+
def __str__(self) -> str:
|
|
48
|
+
return f"{self.action} {self.model_name}#{self.object_id}"
|
|
49
|
+
|
|
50
|
+
def __repr__(self) -> str:
|
|
51
|
+
return (
|
|
52
|
+
f"<AuditLog {self.action} {self.model_name}#{self.object_id}>"
|
|
53
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""SQLAlchemy audit logger — persists audit events to the database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi_admin_kit.audit.events import AuditEvent
|
|
8
|
+
from fastapi_admin_kit.audit.logger import AuditLogger
|
|
9
|
+
from fastapi_admin_kit.audit.models import AuditLog
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SqlAlchemyAuditLogger(AuditLogger):
|
|
13
|
+
"""Writes AuditEvents to the admin_audit_log table.
|
|
14
|
+
|
|
15
|
+
Buffers AuditLog entries during synchronous SQLAlchemy event callbacks
|
|
16
|
+
(e.g. ``after_flush``) and flushes them to the database asynchronously
|
|
17
|
+
after the main transaction commits. This avoids triggering implicit
|
|
18
|
+
autoflush inside a sync event handler, which would raise
|
|
19
|
+
``MissingGreenlet`` with async sessions.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, session: Any = None) -> None:
|
|
23
|
+
self._session = session
|
|
24
|
+
self._pending: list[AuditLog] = []
|
|
25
|
+
|
|
26
|
+
def log_create(self, event: AuditEvent) -> None:
|
|
27
|
+
self._buffer(event)
|
|
28
|
+
|
|
29
|
+
def log_update(self, event: AuditEvent) -> None:
|
|
30
|
+
self._buffer(event)
|
|
31
|
+
|
|
32
|
+
def log_delete(self, event: AuditEvent) -> None:
|
|
33
|
+
self._buffer(event)
|
|
34
|
+
|
|
35
|
+
def _buffer(self, event: AuditEvent) -> None:
|
|
36
|
+
entry = AuditLog(
|
|
37
|
+
user_id=event.user_id,
|
|
38
|
+
user_email=event.user_email,
|
|
39
|
+
action=event.event_type,
|
|
40
|
+
model_name=event.model_name,
|
|
41
|
+
table_name=event.table_name,
|
|
42
|
+
object_id=event.object_id,
|
|
43
|
+
object_repr=event.object_repr,
|
|
44
|
+
changes=event.changes,
|
|
45
|
+
full_snapshot=event.full_snapshot,
|
|
46
|
+
ip_address=event.ip_address,
|
|
47
|
+
user_agent=event.user_agent,
|
|
48
|
+
)
|
|
49
|
+
self._pending.append(entry)
|
|
50
|
+
|
|
51
|
+
async def flush_pending(self, session: Any) -> None:
|
|
52
|
+
"""Write all buffered entries to *session* and clear the buffer."""
|
|
53
|
+
if not self._pending:
|
|
54
|
+
return
|
|
55
|
+
for entry in self._pending:
|
|
56
|
+
session.add(entry)
|
|
57
|
+
self._pending.clear()
|
|
58
|
+
await session.flush()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Auth module — models, session, AuthBackend, PermissionChecker, dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi_admin_kit.auth.backend import AuthBackend, BuiltinAuthBackend
|
|
6
|
+
from fastapi_admin_kit.auth.csrf import (
|
|
7
|
+
auth_redirect_handler,
|
|
8
|
+
require_csrf_token,
|
|
9
|
+
set_csrf_cookie,
|
|
10
|
+
)
|
|
11
|
+
from fastapi_admin_kit.auth.models import (
|
|
12
|
+
AdminPermission,
|
|
13
|
+
AdminRole,
|
|
14
|
+
AdminUser,
|
|
15
|
+
AdminUserPermission,
|
|
16
|
+
)
|
|
17
|
+
from fastapi_admin_kit.auth.session import (
|
|
18
|
+
SessionBackend,
|
|
19
|
+
SignedCookieSessionBackend,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"AdminPermission",
|
|
24
|
+
"AdminRole",
|
|
25
|
+
"AdminUser",
|
|
26
|
+
"AdminUserPermission",
|
|
27
|
+
"AuthBackend",
|
|
28
|
+
"BuiltinAuthBackend",
|
|
29
|
+
"SessionBackend",
|
|
30
|
+
"SignedCookieSessionBackend",
|
|
31
|
+
"auth_redirect_handler",
|
|
32
|
+
"require_csrf_token",
|
|
33
|
+
"set_csrf_cookie",
|
|
34
|
+
]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Auth backend — ABC + built-in implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
import bcrypt
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from fastapi_admin_kit.auth.protocol import AdminUserProtocol
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _PasswordHasher:
|
|
15
|
+
"""Thin wrapper around bcrypt for hash/verify."""
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def hash(password: str) -> str:
|
|
19
|
+
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def verify(password: str, hashed: str) -> bool:
|
|
23
|
+
try:
|
|
24
|
+
return bcrypt.checkpw(password.encode(), hashed.encode())
|
|
25
|
+
except (ValueError, TypeError):
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
pwd_context = _PasswordHasher()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuthBackend(ABC):
|
|
33
|
+
"""Abstract authentication backend — verify credentials & load users."""
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
async def authenticate(
|
|
37
|
+
self, email: str, password: str, session: Any
|
|
38
|
+
) -> AdminUserProtocol | None:
|
|
39
|
+
"""Verify credentials. Return user object if valid, ``None`` otherwise."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
async def get_user(
|
|
44
|
+
self, user_id: int | str, session: Any
|
|
45
|
+
) -> AdminUserProtocol | None:
|
|
46
|
+
"""Load user by PK. Return ``None`` if not found or inactive."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
async def on_logout(self, user_id: int | str | None = None) -> None:
|
|
50
|
+
"""Called after a user logs out. Override to perform cleanup."""
|
|
51
|
+
# Default implementation does nothing
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class BuiltinAuthBackend(AuthBackend):
|
|
56
|
+
"""Default backend that works with the built-in ``AdminUser`` model."""
|
|
57
|
+
|
|
58
|
+
async def authenticate(
|
|
59
|
+
self, email: str, password: str, session: Any
|
|
60
|
+
) -> AdminUserProtocol | None:
|
|
61
|
+
from sqlalchemy import select
|
|
62
|
+
|
|
63
|
+
from fastapi_admin_kit.auth.models import AdminUser
|
|
64
|
+
|
|
65
|
+
result = await session.execute(
|
|
66
|
+
select(AdminUser).where(
|
|
67
|
+
AdminUser.email == email, AdminUser.is_active.is_(True)
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
user = result.scalar_one_or_none()
|
|
71
|
+
|
|
72
|
+
if not user:
|
|
73
|
+
return None
|
|
74
|
+
if not pwd_context.verify(password, user.hashed_password):
|
|
75
|
+
return None
|
|
76
|
+
return user
|
|
77
|
+
|
|
78
|
+
async def get_user(
|
|
79
|
+
self, user_id: int | str, session: Any
|
|
80
|
+
) -> AdminUserProtocol | None:
|
|
81
|
+
from sqlalchemy import select
|
|
82
|
+
from sqlalchemy.orm import selectinload
|
|
83
|
+
|
|
84
|
+
from fastapi_admin_kit.auth.models import AdminUser
|
|
85
|
+
|
|
86
|
+
result = await session.execute(
|
|
87
|
+
select(AdminUser)
|
|
88
|
+
.options(selectinload(AdminUser.roles))
|
|
89
|
+
.where(AdminUser.id == user_id, AdminUser.is_active.is_(True))
|
|
90
|
+
)
|
|
91
|
+
return result.scalar_one_or_none()
|
|
92
|
+
|
|
93
|
+
async def on_logout(self, user_id: int | str | None = None) -> None:
|
|
94
|
+
"""No-op for built-in backend."""
|
|
95
|
+
return None
|