Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ docker-compose.local.yml
# SSO
*.pem
*.crt

# Local Claude Code skills
.claude/commands/
4 changes: 4 additions & 0 deletions server/mergin/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ def create_app(public_keys: List[str] = None) -> Flask:
"""Factory function to create Flask app instance"""
from itsdangerous import BadTimeSignature, BadSignature

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

# register audit module
register_audit(app.app)

# register auth blueprint
register_auth(app.app)

Expand Down
5 changes: 5 additions & 0 deletions server/mergin/audit/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Copyright (C) Lutra Consulting Limited
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial

from .app import emit, register
51 changes: 51 additions & 0 deletions server/mergin/audit/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Copyright (C) Lutra Consulting Limited
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial

import datetime

from flask import Flask, current_app

from .events import AuditEvent, EventType
from .sinks import NullSink


def register(app: Flask) -> None:
"""Wire the audit module into a Flask app.

Stores the sink in app.extensions["audit"] so emit() has one consistent lookup path.
"""
app.extensions["audit"] = {"sink": NullSink()}


def emit(
event_type: EventType,
actor_id=None,
actor_email=None,
actor_ua=None,
actor_device=None,
actor_ip=None,
user_id=None,
project_id=None,
workspace_id=None,
**metadata,
) -> None:
"""Emit one audit event to the configured sink.

Set at least one of user_id, project_id, workspace_id to identify the target.
Extra keyword arguments become the metadata dict.
"""
event = AuditEvent(
event_type=event_type,
actor_id=actor_id,
actor_email=actor_email,
actor_ua=actor_ua,
actor_device=actor_device,
actor_ip=actor_ip,
happened_at=datetime.datetime.utcnow(),
user_id=user_id,
project_id=project_id,
workspace_id=workspace_id,
metadata=metadata,
)
current_app.extensions["audit"]["sink"].write(event)
29 changes: 29 additions & 0 deletions server/mergin/audit/events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Copyright (C) Lutra Consulting Limited
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial

import datetime
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, Optional

# Noun.verb dot-notation string, e.g. "user.login.succeeded".
# Each module defines its own str enum; the sink stores the raw string.
EventType = str


@dataclass(frozen=True)
class AuditEvent:
event_type: EventType
actor_ip: Optional[str]
happened_at: datetime.datetime
actor_id: Optional[int]
actor_email: Optional[str]
actor_ua: Optional[str]
actor_device: Optional[str] # X-Device-Id header; set by mobile/QGIS clients
user_id: Optional[int] # set when the target is a user
project_id: Optional[uuid.UUID] # set when the target is a project
workspace_id: Optional[
int
] # workspace the event belongs to; set for project and workspace events
metadata: Dict[str, Any] = field(default_factory=dict)
90 changes: 90 additions & 0 deletions server/mergin/audit/listeners.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Copyright (C) Lutra Consulting Limited
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial

"""
Utilities for writing SQLAlchemy-based audit listeners in any module.
"""

import logging

from sqlalchemy import inspect as sa_inspect
from sqlalchemy.orm import ColumnProperty
from flask import has_request_context, has_app_context, request, current_app
from flask_login import current_user

from ..utils import get_ip, get_user_agent, get_device_id
from .app import emit

logger = logging.getLogger(__name__)


def request_context():
"""Return the three request-derived actor kwargs: user_agent, device_id, ip.

Use **request_context() in explicit emit() calls so adding a new request
field only requires changing this one function.
"""
if not has_request_context():
return dict(actor_ua=None, actor_device=None, actor_ip=None)
return dict(
actor_ua=get_user_agent(request),
actor_device=get_device_id(request),
actor_ip=get_ip(request),
)


def actor_context():
"""Return full actor kwargs for emit() drawn from the current request context.

Used by SQLAlchemy listeners where current_user is the actor.
"""
actor_id = None
actor_email = None
if has_request_context() and hasattr(
current_app._get_current_object(), "login_manager"
):
try:
if current_user.is_authenticated:
actor_id = current_user.id
actor_email = current_user.email
except Exception:
pass
return dict(actor_id=actor_id, actor_email=actor_email, **request_context())


def field_changes(target, skip=frozenset()):
"""Return flat old_<field>/new_<field> context for all changed non-skipped column fields.

Only column attributes are included — relationships are skipped because their
history entries are ORM instances, not JSON-serializable values.
"""
mapper = sa_inspect(type(target))
ctx = {}
for attr in sa_inspect(target).attrs:
if attr.key in skip:
continue
if not isinstance(mapper.attrs[attr.key], ColumnProperty):
continue
hist = attr.history
if hist.has_changes():
old = hist.deleted[0] if hist.deleted else None
new = hist.added[0] if hist.added else None
if old != new:
ctx[f"old_{attr.key}"] = old
ctx[f"new_{attr.key}"] = new
return ctx


def emit_safe(event_type, **kwargs):
"""Emit without raising if outside app context or sink not yet configured.

Works both inside HTTP requests (actor context populated) and Celery tasks
(actor fields are None, indicating a system-initiated action).
"""
if not has_app_context() or "audit" not in current_app.extensions:
return
try:
emit(event_type, **kwargs)
except Exception:
logger.warning("Failed to emit audit event %s", event_type, exc_info=True)
21 changes: 21 additions & 0 deletions server/mergin/audit/sinks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Copyright (C) Lutra Consulting Limited
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial

from abc import ABC, abstractmethod

from .events import AuditEvent


class AbstractSink(ABC):
"""Interface all audit sinks must implement."""

@abstractmethod
def write(self, event: AuditEvent) -> None: ...


class NullSink(AbstractSink):
"""Default sink — discards all events."""

def write(self, event: AuditEvent) -> None:
pass
2 changes: 2 additions & 0 deletions server/mergin/auth/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from .commands import add_commands
from .config import Configuration
from .listeners import register_listeners
from .models import User

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


_permissions = {}
Expand Down
Loading
Loading