Skip to content

Commit 8c65b22

Browse files
varmar05claude
andcommitted
Add audit log foundation
Introduce a pluggable audit module with NullSink default and emit() API. Wire SQLAlchemy listeners for user and project lifecycle events, explicit emits for auth and sync endpoints (login, password, access grants/revocations, version push, soft/hard delete, restore). Add actor_context()/request_context() helpers, device_id capture, scope_id for workspace-scoped filtering, and target_type auto-derivation. This is groundwork for adding more events and to be extended in EE with custom sinks, query API, and retention policy. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e0968d5 commit 8c65b22

25 files changed

Lines changed: 694 additions & 44 deletions

server/mergin/app.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ def create_app(public_keys: List[str] = None) -> Flask:
154154
"""Factory function to create Flask app instance"""
155155
from itsdangerous import BadTimeSignature, BadSignature
156156

157+
from .audit import register as register_audit
157158
from .auth import auth_required, decode_token, register as register_auth
158159
from .auth.models import User
159160
from .sync.app import register as register_sync
@@ -180,6 +181,9 @@ def create_app(public_keys: List[str] = None) -> Flask:
180181
csrf.init_app(app.app)
181182
login_manager.init_app(app.app)
182183

184+
# register audit module (NullSink by default; custom sinks overrides app.audit_sink)
185+
register_audit(app.app)
186+
183187
# register auth blueprint
184188
register_auth(app.app)
185189

server/mergin/audit/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Copyright (C) Lutra Consulting Limited
2+
#
3+
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
4+
5+
from .app import emit, register

server/mergin/audit/app.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Copyright (C) Lutra Consulting Limited
2+
#
3+
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
4+
5+
import datetime
6+
7+
from flask import Flask, current_app
8+
9+
from .events import AuditEvent, EventType
10+
from .sinks import NullSink
11+
12+
13+
def register(app: Flask) -> None:
14+
"""Wire the audit module into a Flask app.
15+
16+
Sets NullSink as the default.
17+
"""
18+
app.audit_sink = NullSink()
19+
20+
21+
def emit(
22+
event_type: EventType,
23+
actor_id=None,
24+
actor_email=None,
25+
actor_user_agent=None,
26+
actor_device_id=None,
27+
ip_address=None,
28+
target_id=None,
29+
scope_id=None,
30+
**detail,
31+
) -> None:
32+
"""Emit one audit event to the configured sink.
33+
34+
target_type is auto-derived from the noun segment of event_type (e.g. "user"
35+
from "user.login.succeeded"). scope_id is the workspace that owns this event
36+
(None for global events). Extra keyword arguments become the context dict.
37+
"""
38+
event = AuditEvent(
39+
event_type=event_type,
40+
actor_id=actor_id,
41+
actor_email=actor_email,
42+
actor_user_agent=actor_user_agent,
43+
actor_device_id=actor_device_id,
44+
ip_address=ip_address,
45+
timestamp=datetime.datetime.utcnow(),
46+
target_id=target_id,
47+
target_type=event_type.split(".")[0] if event_type else None,
48+
scope_id=scope_id,
49+
context=detail,
50+
)
51+
current_app.audit_sink.write(event)

server/mergin/audit/events.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Copyright (C) Lutra Consulting Limited
2+
#
3+
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
4+
5+
import datetime
6+
from dataclasses import dataclass, field
7+
from typing import Any, Dict, Optional
8+
9+
# Noun.verb dot-notation string, e.g. "user.login.succeeded".
10+
# Each module defines its own str enum; the sink stores the raw string.
11+
EventType = str
12+
13+
14+
@dataclass(frozen=True)
15+
class AuditEvent:
16+
event_type: EventType
17+
actor_id: Optional[int]
18+
actor_email: Optional[str]
19+
actor_user_agent: Optional[str]
20+
actor_device_id: Optional[str] # X-Device-Id header; set by mobile/QGIS clients
21+
ip_address: Optional[str]
22+
timestamp: datetime.datetime
23+
target_id: Optional[str] # primary entity ID, e.g. str(user.id) or str(project.id)
24+
target_type: Optional[str] # noun from event_type, e.g. "user" or "project"
25+
scope_id: Optional[int] # workspace-level access boundary; None for global events
26+
context: Dict[str, Any] = field(default_factory=dict)

server/mergin/audit/listeners.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Copyright (C) Lutra Consulting Limited
2+
#
3+
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
4+
5+
"""
6+
Utilities for writing SQLAlchemy-based audit listeners in any module.
7+
"""
8+
9+
import logging
10+
11+
from sqlalchemy import inspect as sa_inspect
12+
from flask import has_request_context, has_app_context, request, current_app
13+
from flask_login import current_user
14+
15+
from ..utils import get_ip, get_user_agent, get_device_id
16+
from .app import emit
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
def request_context():
22+
"""Return the three request-derived actor kwargs: user_agent, device_id, ip.
23+
24+
Use **request_context() in explicit emit() calls so adding a new request
25+
field only requires changing this one function.
26+
"""
27+
if not has_request_context():
28+
return dict(actor_user_agent=None, actor_device_id=None, ip_address=None)
29+
return dict(
30+
actor_user_agent=get_user_agent(request),
31+
actor_device_id=get_device_id(request),
32+
ip_address=get_ip(request),
33+
)
34+
35+
36+
def actor_context():
37+
"""Return full actor kwargs for emit() drawn from the current request context.
38+
39+
Used by SQLAlchemy listeners where current_user is the actor.
40+
"""
41+
actor_id = None
42+
actor_email = None
43+
if has_request_context() and current_user.is_authenticated:
44+
actor_id = current_user.id
45+
actor_email = current_user.email
46+
return dict(actor_id=actor_id, actor_email=actor_email, **request_context())
47+
48+
49+
def field_changes(target, skip=frozenset()):
50+
"""Return flat old_<field>/new_<field> context for all changed non-skipped fields."""
51+
ctx = {}
52+
for attr in sa_inspect(target).attrs:
53+
if attr.key in skip:
54+
continue
55+
hist = attr.history
56+
if hist.has_changes():
57+
old = hist.deleted[0] if hist.deleted else None
58+
new = hist.added[0] if hist.added else None
59+
if old != new:
60+
ctx[f"old_{attr.key}"] = old
61+
ctx[f"new_{attr.key}"] = new
62+
return ctx
63+
64+
65+
def emit_safe(event_type, **kwargs):
66+
"""Emit without raising if outside app context or sink not yet configured.
67+
68+
Works both inside HTTP requests (actor context populated) and Celery tasks
69+
(actor fields are None, indicating a system-initiated action).
70+
"""
71+
if not has_app_context() or not hasattr(current_app, "audit_sink"):
72+
return
73+
try:
74+
emit(event_type, **kwargs)
75+
except Exception:
76+
logger.warning("Failed to emit audit event %s", event_type, exc_info=True)

server/mergin/audit/sinks.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Copyright (C) Lutra Consulting Limited
2+
#
3+
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
4+
5+
from abc import ABC, abstractmethod
6+
7+
from .events import AuditEvent
8+
9+
10+
class AbstractSink(ABC):
11+
"""Interface all audit sinks must implement."""
12+
13+
@abstractmethod
14+
def write(self, event: AuditEvent) -> None: ...
15+
16+
17+
class NullSink(AbstractSink):
18+
"""Default sink — discards all events."""
19+
20+
def write(self, event: AuditEvent) -> None:
21+
pass

server/mergin/auth/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from .commands import add_commands
1313
from .config import Configuration
14+
from .listeners import register_listeners
1415
from .models import User
1516

1617
# signal for other versions to listen to
@@ -37,6 +38,7 @@ def register(app):
3738
app.blueprints["/"].name = "auth"
3839
app.blueprints["auth"] = app.blueprints.pop("/")
3940
add_commands(app)
41+
register_listeners()
4042

4143

4244
_permissions = {}

server/mergin/auth/controller.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
ApiLoginForm,
3838
)
3939
from ..app import db
40+
from ..audit import emit
41+
from ..audit.listeners import actor_context, request_context
42+
from .events import AuthEventType
4043
from ..sync.models import Project
4144
from ..sync.utils import files_size
4245

@@ -157,8 +160,20 @@ def login_public(): # noqa: E501
157160
data = user_profile(user)
158161
data["session"] = {"token": token, "expire": expire}
159162
LoginHistory.add_record(user.id, request)
163+
emit(
164+
AuthEventType.USER_LOGIN_SUCCEEDED,
165+
actor_id=user.id,
166+
actor_email=user.email,
167+
**request_context(),
168+
target_id=str(user.id),
169+
)
160170
return data
161171
else:
172+
emit(
173+
AuthEventType.USER_LOGIN_FAILED,
174+
**request_context(),
175+
login=form.login.data,
176+
)
162177
abort(401, "Invalid username or password")
163178
abort(400, _extract_first_error(form.errors))
164179

@@ -169,7 +184,14 @@ def close_user_account():
169184
Closing user account effectively means to inactivate user (will be removed by cron job) and remove explicitly
170185
shared projects as well clean references to created projects.
171186
"""
187+
emit(
188+
AuthEventType.USER_CLOSED,
189+
**actor_context(),
190+
target_id=str(current_user.id),
191+
)
192+
db.session.info["audit_skip_user_update"] = True
172193
current_user.inactivate()
194+
db.session.info.pop("audit_skip_user_update", None)
173195
# emit signal to be caught elsewhere
174196
user_account_closed.send(current_user)
175197
return NoContent, 204
@@ -226,8 +248,20 @@ def login(): # pylint: disable=W0613,W0612
226248
login_user(user)
227249
if not os.path.isfile(current_app.config["MAINTENANCE_FILE"]):
228250
LoginHistory.add_record(user.id, request)
251+
emit(
252+
AuthEventType.USER_LOGIN_SUCCEEDED,
253+
actor_id=user.id,
254+
actor_email=user.email,
255+
**request_context(),
256+
target_id=str(user.id),
257+
)
229258
return "", 200
230259
else:
260+
emit(
261+
AuthEventType.USER_LOGIN_FAILED,
262+
**request_context(),
263+
login=form.login.data,
264+
)
231265
abort(401, "Invalid username or password")
232266
return jsonify(form.errors), 401
233267

@@ -242,15 +276,32 @@ def admin_login(): # pylint: disable=W0613,W0612
242276
if user:
243277
if user.active and user.is_admin:
244278
login_user(user)
279+
emit(
280+
AuthEventType.USER_LOGIN_SUCCEEDED,
281+
actor_id=user.id,
282+
actor_email=user.email,
283+
**request_context(),
284+
target_id=str(user.id),
285+
)
245286
return "", 200
246287
else:
247288
abort(403, "You do not have permissions")
248289
else:
290+
emit(
291+
AuthEventType.USER_LOGIN_FAILED,
292+
**request_context(),
293+
login=form.login.data,
294+
)
249295
abort(401, "Invalid username or password")
250296

251297

252298
@auth_required
253299
def logout(): # pylint: disable=W0613,W0612
300+
emit(
301+
AuthEventType.USER_LOGOUT,
302+
**actor_context(),
303+
target_id=str(current_user.id),
304+
)
254305
logout_user()
255306
return "", 200
256307

@@ -266,6 +317,11 @@ def change_password(): # pylint: disable=W0613,W0612
266317
current_user.assign_password(form.password.data)
267318
db.session.add(current_user)
268319
db.session.commit()
320+
emit(
321+
AuthEventType.USER_PASSWORD_CHANGED,
322+
**actor_context(),
323+
target_id=str(current_user.id),
324+
)
269325
return "", 200
270326
return jsonify(form.errors), 400
271327

@@ -326,6 +382,12 @@ def confirm_new_password(token): # pylint: disable=W0613,W0612
326382
user.assign_password(form.password.data)
327383
db.session.add(user)
328384
db.session.commit()
385+
emit(
386+
AuthEventType.USER_PASSWORD_RESET,
387+
**request_context(),
388+
target_id=str(user.id),
389+
target_email=user.email,
390+
)
329391
return "", 200
330392
return jsonify(form.errors), 400
331393

@@ -454,10 +516,23 @@ def update_user(username): # pylint: disable=W0613,W0612
454516
@auth_required(permissions=["admin"])
455517
def delete_user(username): # pylint: disable=W0613,W0612
456518
user = User.query.filter_by(username=username).first_or_404("User not found")
519+
emit(
520+
AuthEventType.USER_CLOSED,
521+
**actor_context(),
522+
target_id=str(user.id),
523+
target_email=user.email,
524+
)
525+
db.session.info["audit_skip_user_update"] = True
457526
user.inactivate()
458527
user_account_closed.send(user)
459-
# force 'delete' user
528+
emit(
529+
AuthEventType.USER_ANONYMIZED,
530+
**actor_context(),
531+
target_id=str(user.id),
532+
target_email=user.email,
533+
)
460534
user.anonymize()
535+
db.session.info.pop("audit_skip_user_update", None)
461536
return "", 204
462537

463538

server/mergin/auth/events.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Copyright (C) Lutra Consulting Limited
2+
#
3+
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
4+
5+
from enum import Enum
6+
7+
8+
class AuthEventType(str, Enum):
9+
# explicit auth action events
10+
USER_LOGIN_SUCCEEDED = "user.login.succeeded"
11+
USER_LOGIN_FAILED = "user.login.failed"
12+
USER_LOGOUT = "user.logout"
13+
USER_PASSWORD_CHANGED = "user.password.changed"
14+
USER_PASSWORD_RESET = "user.password.reset" # token-based reset (unauthenticated)
15+
# automatic CRUD events (SQLAlchemy listeners)
16+
USER_CREATED = "user.created"
17+
USER_UPDATED = "user.updated"
18+
# lifecycle events (explicit emit)
19+
USER_CLOSED = "user.closed"
20+
USER_ANONYMIZED = "user.anonymized"

0 commit comments

Comments
 (0)