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 (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,
user_id=None,
project_id=None,
workspace_id=None,
**detail,
) -> 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 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,
happened_at=datetime.datetime.utcnow(),
user_id=user_id,
project_id=project_id,
workspace_id=workspace_id,
context=detail,
)
current_app.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
ip_address: Optional[str]
happened_at: datetime.datetime
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
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
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
91 changes: 87 additions & 4 deletions 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,21 @@ 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(),
user_id=user.id,
)
return data
else:
emit(
AuthEventType.USER_LOGIN_FAILED,
**request_context(),
login=form.login.data,
reason="account_inactive" if user else "invalid_credentials",
)
abort(401, "Invalid username or password")
abort(400, _extract_first_error(form.errors))

Expand All @@ -169,6 +185,11 @@ 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_MARKED_FOR_DELETION,
**actor_context(),
user_id=current_user.id,
)
current_user.inactivate()
# emit signal to be caught elsewhere
user_account_closed.send(current_user)
Expand Down Expand Up @@ -226,8 +247,21 @@ 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(),
user_id=user.id,
)
return "", 200
else:
emit(
AuthEventType.USER_LOGIN_FAILED,
**request_context(),
login=form.login.data,
reason="account_inactive" if user else "invalid_credentials",
)
abort(401, "Invalid username or password")
return jsonify(form.errors), 401

Expand All @@ -242,10 +276,23 @@ 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(),
user_id=user.id,
)
return "", 200
else:
abort(403, "You do not have permissions")
else:
emit(
AuthEventType.USER_LOGIN_FAILED,
**request_context(),
login=form.login.data,
reason="invalid_credentials",
)
abort(401, "Invalid username or password")


Expand All @@ -266,6 +313,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(),
user_id=current_user.id,
)
return "", 200
return jsonify(form.errors), 400

Expand Down Expand Up @@ -326,6 +378,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(),
user_id=user.id,
target_email=user.email,
)
return "", 200
return jsonify(form.errors), 400

Expand Down Expand Up @@ -441,22 +499,47 @@ def update_user(username): # pylint: disable=W0613,W0612
abort(400, "Unable to assign super admin role")

user = User.query.filter_by(username=username).first_or_404("User not found")
old_active = user.active
form.update_obj(user)

# remove inactive since flag for ban or re-activation
user.inactive_since = None

db.session.add(user)
db.session.commit()

if old_active and not user.active:
emit(
AuthEventType.USER_DEACTIVATED,
**actor_context(),
user_id=user.id,
target_email=user.email,
)
elif not old_active and user.active:
emit(
AuthEventType.USER_RESTORED,
**actor_context(),
user_id=user.id,
target_email=user.email,
)

return jsonify(UserSchema().dump(user))


@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_MARKED_FOR_DELETION,
**actor_context(),
user_id=user.id,
target_email=user.email,
)
user.inactivate()
user_account_closed.send(user)
# force 'delete' user
emit(
AuthEventType.USER_DELETED,
**actor_context(),
user_id=user.id,
target_email=user.email,
)
user.anonymize()
return "", 204

Expand Down
Loading
Loading