Skip to content
Open
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/
Comment thread
varmar05 marked this conversation as resolved.
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
59 changes: 59 additions & 0 deletions server/mergin/audit/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Copyright (C) Lutra Consulting Limited
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial

import datetime
import logging

from flask import Flask, current_app, has_app_context

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

logger = logging.getLogger(__name__)


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,
target_user_id=None,
target_project_id=None,
target_workspace_id=None,
**metadata,
) -> None:
"""Emit one audit event to the configured sink.

Set at least one of target_user_id, target_project_id, target_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(),
target_user_id=target_user_id,
target_project_id=target_project_id,
target_workspace_id=target_workspace_id,
metadata=metadata,
)
if not has_app_context() or "audit" not in current_app.extensions:
return
try:
current_app.extensions["audit"]["sink"].write(event)
except Exception:
logger.warning("Failed to emit audit event %s", event_type, exc_info=True)
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

# 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: str | None
happened_at: datetime.datetime
actor_id: int | None
actor_email: str | None
actor_ua: str | None
actor_device: str | None # X-Device-Id header; set by mobile/QGIS clients
target_user_id: int | None # set when the target is a user
target_project_id: uuid.UUID | None # set when the target is a project
target_workspace_id: (
int | None
) # workspace the event belongs to; set for project and workspace events
metadata: dict[str, Any] = field(default_factory=dict)
89 changes: 89 additions & 0 deletions server/mergin/audit/listeners.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# 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 contextlib import contextmanager

from sqlalchemy import inspect as sa_inspect
from sqlalchemy.orm import ColumnProperty
from flask import has_request_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__)


@contextmanager
def audit_session_flags(session, **flags):
"""Context manager that sets db.session.info flags for the duration of a block
and removes them in a finally clause so they never leak on exceptions."""
session.info.update(flags)
try:
yield
finally:
for key in flags:
session.info.pop(key, None)


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
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
12 changes: 12 additions & 0 deletions server/mergin/auth/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@

from .commands import add_commands
from .config import Configuration
from .listeners import register_listeners
from .models import User, _check_dummy_password
from ..audit import emit
from ..audit.listeners import actor_context, request_context
from .events import AuthEventType

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


_permissions = {}
Expand Down Expand Up @@ -125,6 +130,13 @@ def authenticate(login, password):
db.session.commit()
if duration is not None:
send_account_locked_email(current_app, user, duration)
emit(
AuthEventType.USER_LOCKED,
**request_context(),
target_user_id=user.id,
target_email=user.email,
locked_until=user.locked_until.isoformat(),
)
return None


Expand Down
1 change: 1 addition & 0 deletions server/mergin/auth/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def create(username, password, is_admin, email): # pylint: disable=W0612

user = User(username=username, passwd=password, is_admin=is_admin, email=email)
user.active = True
db.session.info["audit_user_creation_source"] = "cli"
db.session.add(user)
db.session.commit()
click.secho("User created", fg="green")
Loading
Loading