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
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 (NullSink by default; custom sinks overrides app.audit_sink)
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.

Sets NullSink as the default.
"""
app.audit_sink = NullSink()


def emit(
event_type: EventType,
actor_id=None,
actor_email=None,
actor_user_agent=None,
actor_device_id=None,
ip_address=None,
target_id=None,
scope_id=None,
**detail,
) -> None:
"""Emit one audit event to the configured sink.

target_type is auto-derived from the noun segment of event_type (e.g. "user"
from "user.login.succeeded"). scope_id is the workspace that owns this event
(None for global events). Extra keyword arguments become the context dict.
"""
event = AuditEvent(
event_type=event_type,
actor_id=actor_id,
actor_email=actor_email,
actor_user_agent=actor_user_agent,
actor_device_id=actor_device_id,
ip_address=ip_address,
timestamp=datetime.datetime.utcnow(),
target_id=target_id,
target_type=event_type.split(".")[0] if event_type else None,
scope_id=scope_id,
context=detail,
)
current_app.audit_sink.write(event)
26 changes: 26 additions & 0 deletions server/mergin/audit/events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Copyright (C) Lutra Consulting Limited
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial

import datetime
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_id: Optional[int]
actor_email: Optional[str]
actor_user_agent: Optional[str]
actor_device_id: Optional[str] # X-Device-Id header; set by mobile/QGIS clients
ip_address: Optional[str]
timestamp: datetime.datetime
target_id: Optional[str] # primary entity ID, e.g. str(user.id) or str(project.id)
target_type: Optional[str] # noun from event_type, e.g. "user" or "project"
scope_id: Optional[int] # workspace-level access boundary; None for global events
context: Dict[str, Any] = field(default_factory=dict)
76 changes: 76 additions & 0 deletions server/mergin/audit/listeners.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# 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 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_user_agent=None, actor_device_id=None, ip_address=None)
return dict(
actor_user_agent=get_user_agent(request),
actor_device_id=get_device_id(request),
ip_address=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 current_user.is_authenticated:
actor_id = current_user.id
actor_email = current_user.email
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 fields."""
ctx = {}
for attr in sa_inspect(target).attrs:
if attr.key in skip:
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 not hasattr(current_app, "audit_sink"):
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
77 changes: 76 additions & 1 deletion server/mergin/auth/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
ApiLoginForm,
)
from ..app import db
from ..audit import emit
from ..audit.listeners import actor_context, request_context
from .events import AuthEventType
from ..sync.models import Project
from ..sync.utils import files_size

Expand Down Expand Up @@ -157,8 +160,20 @@ def login_public(): # noqa: E501
data = user_profile(user)
data["session"] = {"token": token, "expire": expire}
LoginHistory.add_record(user.id, request)
emit(
AuthEventType.USER_LOGIN_SUCCEEDED,
actor_id=user.id,
actor_email=user.email,
**request_context(),
target_id=str(user.id),
)
return data
else:
emit(
AuthEventType.USER_LOGIN_FAILED,
**request_context(),
login=form.login.data,
)
abort(401, "Invalid username or password")
abort(400, _extract_first_error(form.errors))

Expand All @@ -169,7 +184,14 @@ def close_user_account():
Closing user account effectively means to inactivate user (will be removed by cron job) and remove explicitly
shared projects as well clean references to created projects.
"""
emit(
AuthEventType.USER_CLOSED,
**actor_context(),
target_id=str(current_user.id),
)
db.session.info["audit_skip_user_update"] = True
current_user.inactivate()
db.session.info.pop("audit_skip_user_update", None)
# emit signal to be caught elsewhere
user_account_closed.send(current_user)
return NoContent, 204
Expand Down Expand Up @@ -226,8 +248,20 @@ def login(): # pylint: disable=W0613,W0612
login_user(user)
if not os.path.isfile(current_app.config["MAINTENANCE_FILE"]):
LoginHistory.add_record(user.id, request)
emit(
AuthEventType.USER_LOGIN_SUCCEEDED,
actor_id=user.id,
actor_email=user.email,
**request_context(),
target_id=str(user.id),
)
return "", 200
else:
emit(
AuthEventType.USER_LOGIN_FAILED,
**request_context(),
login=form.login.data,
)
abort(401, "Invalid username or password")
return jsonify(form.errors), 401

Expand All @@ -242,15 +276,32 @@ def admin_login(): # pylint: disable=W0613,W0612
if user:
if user.active and user.is_admin:
login_user(user)
emit(
AuthEventType.USER_LOGIN_SUCCEEDED,
actor_id=user.id,
actor_email=user.email,
**request_context(),
target_id=str(user.id),
)
return "", 200
else:
abort(403, "You do not have permissions")
else:
emit(
AuthEventType.USER_LOGIN_FAILED,
**request_context(),
login=form.login.data,
)
abort(401, "Invalid username or password")


@auth_required
def logout(): # pylint: disable=W0613,W0612
emit(
AuthEventType.USER_LOGOUT,
**actor_context(),
target_id=str(current_user.id),
)
logout_user()
return "", 200

Expand All @@ -266,6 +317,11 @@ def change_password(): # pylint: disable=W0613,W0612
current_user.assign_password(form.password.data)
db.session.add(current_user)
db.session.commit()
emit(
AuthEventType.USER_PASSWORD_CHANGED,
**actor_context(),
target_id=str(current_user.id),
)
return "", 200
return jsonify(form.errors), 400

Expand Down Expand Up @@ -326,6 +382,12 @@ def confirm_new_password(token): # pylint: disable=W0613,W0612
user.assign_password(form.password.data)
db.session.add(user)
db.session.commit()
emit(
AuthEventType.USER_PASSWORD_RESET,
**request_context(),
target_id=str(user.id),
target_email=user.email,
)
return "", 200
return jsonify(form.errors), 400

Expand Down Expand Up @@ -454,10 +516,23 @@ def update_user(username): # pylint: disable=W0613,W0612
@auth_required(permissions=["admin"])
def delete_user(username): # pylint: disable=W0613,W0612
user = User.query.filter_by(username=username).first_or_404("User not found")
emit(
AuthEventType.USER_CLOSED,
**actor_context(),
target_id=str(user.id),
target_email=user.email,
)
db.session.info["audit_skip_user_update"] = True
user.inactivate()
user_account_closed.send(user)
# force 'delete' user
emit(
AuthEventType.USER_ANONYMIZED,
**actor_context(),
target_id=str(user.id),
target_email=user.email,
)
user.anonymize()
db.session.info.pop("audit_skip_user_update", None)
return "", 204


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

from enum import Enum


class AuthEventType(str, Enum):
# explicit auth action events
USER_LOGIN_SUCCEEDED = "user.login.succeeded"
USER_LOGIN_FAILED = "user.login.failed"
USER_LOGOUT = "user.logout"
USER_PASSWORD_CHANGED = "user.password.changed"
USER_PASSWORD_RESET = "user.password.reset" # token-based reset (unauthenticated)
# automatic CRUD events (SQLAlchemy listeners)
USER_CREATED = "user.created"
USER_UPDATED = "user.updated"
# lifecycle events (explicit emit)
USER_CLOSED = "user.closed"
USER_ANONYMIZED = "user.anonymized"
Loading
Loading