diff --git a/server/mergin/app.py b/server/mergin/app.py index e5eb42d5..42308a73 100644 --- a/server/mergin/app.py +++ b/server/mergin/app.py @@ -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 @@ -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) diff --git a/server/mergin/audit/__init__.py b/server/mergin/audit/__init__.py new file mode 100644 index 00000000..145b1f84 --- /dev/null +++ b/server/mergin/audit/__init__.py @@ -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 diff --git a/server/mergin/audit/app.py b/server/mergin/audit/app.py new file mode 100644 index 00000000..90cc2597 --- /dev/null +++ b/server/mergin/audit/app.py @@ -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) diff --git a/server/mergin/audit/events.py b/server/mergin/audit/events.py new file mode 100644 index 00000000..7f0df84b --- /dev/null +++ b/server/mergin/audit/events.py @@ -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) diff --git a/server/mergin/audit/listeners.py b/server/mergin/audit/listeners.py new file mode 100644 index 00000000..fbf94e86 --- /dev/null +++ b/server/mergin/audit/listeners.py @@ -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_/new_ 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) diff --git a/server/mergin/audit/sinks.py b/server/mergin/audit/sinks.py new file mode 100644 index 00000000..14b85002 --- /dev/null +++ b/server/mergin/audit/sinks.py @@ -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 diff --git a/server/mergin/auth/app.py b/server/mergin/auth/app.py index acfccf43..585b1fa5 100644 --- a/server/mergin/auth/app.py +++ b/server/mergin/auth/app.py @@ -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 @@ -37,6 +38,7 @@ def register(app): app.blueprints["/"].name = "auth" app.blueprints["auth"] = app.blueprints.pop("/") add_commands(app) + register_listeners() _permissions = {} diff --git a/server/mergin/auth/controller.py b/server/mergin/auth/controller.py index 06859255..aa242353 100644 --- a/server/mergin/auth/controller.py +++ b/server/mergin/auth/controller.py @@ -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 @@ -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)) @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/server/mergin/auth/events.py b/server/mergin/auth/events.py new file mode 100644 index 00000000..fd8787b5 --- /dev/null +++ b/server/mergin/auth/events.py @@ -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" diff --git a/server/mergin/auth/listeners.py b/server/mergin/auth/listeners.py new file mode 100644 index 00000000..364de3a0 --- /dev/null +++ b/server/mergin/auth/listeners.py @@ -0,0 +1,46 @@ +# Copyright (C) Lutra Consulting Limited +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial + +from sqlalchemy import event +from sqlalchemy.orm import object_session + +from ..audit.listeners import actor_context, field_changes, emit_safe +from .events import AuthEventType +from .models import User + +# Fields excluded from user.updated audit events — high-frequency operational +# fields (last_signed_in, registration_date) or sensitive values that must never appear in logs (passwd). +# frozenset prevents accidental mutation of module-level state. +_SKIP = frozenset({"passwd", "last_signed_in", "registration_date"}) + + +def _on_user_created(_mapper, _connection, target): + emit_safe( + AuthEventType.USER_CREATED, + **actor_context(), + target_id=str(target.id), + target_email=target.email, + ) + + +def _on_user_updated(_mapper, _connection, target): + if object_session(target).info.get("audit_skip_user_update"): + return + changes = field_changes(target, _SKIP) + if not changes: + return + emit_safe( + AuthEventType.USER_UPDATED, + **actor_context(), + target_id=str(target.id), + target_email=target.email, + **changes, + ) + + +def register_listeners(): + if event.contains(User, "after_insert", _on_user_created): + return + event.listen(User, "after_insert", _on_user_created) + event.listen(User, "after_update", _on_user_updated) diff --git a/server/mergin/auth/models.py b/server/mergin/auth/models.py index 760ab740..30d380dc 100644 --- a/server/mergin/auth/models.py +++ b/server/mergin/auth/models.py @@ -12,7 +12,8 @@ from ..app import db from ..sync.models import ProjectUser -from ..sync.utils import get_user_agent, get_ip, get_device_id, is_reserved_word +from ..sync.utils import is_reserved_word +from ..utils import get_ip, get_user_agent, get_device_id MAX_USERNAME_LENGTH = 50 diff --git a/server/mergin/auth/tasks.py b/server/mergin/auth/tasks.py index 3c408d35..cdba01f0 100644 --- a/server/mergin/auth/tasks.py +++ b/server/mergin/auth/tasks.py @@ -7,6 +7,8 @@ from ..celery import celery from ..app import db +from ..audit.listeners import emit_safe +from .events import AuthEventType from .models import User from .config import Configuration @@ -21,5 +23,12 @@ def anonymize_removed_users(): User.inactive_since <= before_expiration, User.username.op("~")("^(?!deleted_\d{13})"), ).all() + db.session.info["audit_skip_user_update"] = True for user in users: + emit_safe( + AuthEventType.USER_ANONYMIZED, + target_id=str(user.id), + target_email=user.email, + ) user.anonymize() + db.session.info.pop("audit_skip_user_update", None) diff --git a/server/mergin/sync/app.py b/server/mergin/sync/app.py index e97f7cc3..1b6a923d 100644 --- a/server/mergin/sync/app.py +++ b/server/mergin/sync/app.py @@ -7,6 +7,7 @@ from .commands import add_commands from .config import Configuration from .db_events import register_events +from .listeners import register_listeners def register(app: Flask): @@ -40,3 +41,4 @@ def register(app: Flask): add_commands(app) register_events() + register_listeners() diff --git a/server/mergin/sync/db_events.py b/server/mergin/sync/db_events.py index 48a1756d..a303108c 100644 --- a/server/mergin/sync/db_events.py +++ b/server/mergin/sync/db_events.py @@ -29,4 +29,4 @@ def register_events(): def remove_events(): event.remove(db.session, "before_commit", check) - event.listen(ProjectVersion, "after_insert", optimize_gpgk_storage) + event.remove(ProjectVersion, "after_insert", optimize_gpgk_storage) diff --git a/server/mergin/sync/events.py b/server/mergin/sync/events.py new file mode 100644 index 00000000..aa9fb2e1 --- /dev/null +++ b/server/mergin/sync/events.py @@ -0,0 +1,27 @@ +# Copyright (C) Lutra Consulting Limited +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial + +from enum import Enum + + +class SyncEventType(str, Enum): + # automatic CRUD events (SQLAlchemy listeners) + PROJECT_CREATED = "project.created" + PROJECT_UPDATED = "project.updated" + # lifecycle events (explicit emit) + PROJECT_REMOVED = "project.removed" + PROJECT_DELETED = "project.deleted" + # lifecycle events (explicit emit) + PROJECT_RESTORED = "project.restored" + # access control events (explicit emit) + PROJECT_ACCESS_GRANTED = "project.access.granted" + PROJECT_ACCESS_UPDATED = "project.access.updated" + PROJECT_ACCESS_REVOKED = "project.access.revoked" + PROJECT_ACCESS_REQUEST_ACCEPTED = "project.access.request.accepted" + PROJECT_ACCESS_REQUEST_DECLINED = "project.access.request.declined" + # data events (explicit emit) + PROJECT_VERSION_CREATED = "project.version.created" + # explicit action events + PROJECT_FILE_UPLOADED = "project.file.uploaded" + PROJECT_FILE_DOWNLOADED = "project.file.downloaded" diff --git a/server/mergin/sync/listeners.py b/server/mergin/sync/listeners.py new file mode 100644 index 00000000..bbe985cc --- /dev/null +++ b/server/mergin/sync/listeners.py @@ -0,0 +1,59 @@ +# Copyright (C) Lutra Consulting Limited +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial + +from sqlalchemy import event +from sqlalchemy.orm import object_session + +from ..audit.listeners import actor_context, field_changes, emit_safe +from .events import SyncEventType +from .models import Project + +# Fields excluded from project.updated audit events — either auto-computed on every +# version push (disk_usage, latest_version, tags), operational metadata (updated, +# storage_params, locked_until), or covered by dedicated events with their own emit +# (removed_at/removed_by are suppressed via audit_skip_project_update instead). +# frozenset prevents accidental mutation of module-level state. +_SKIP = frozenset( + { + "disk_usage", + "latest_version", + "updated", + "storage_params", + "tags", + "locked_until", + } +) + + +def _on_project_created(_mapper, _connection, target): + emit_safe( + SyncEventType.PROJECT_CREATED, + **actor_context(), + target_id=str(target.id), + scope_id=target.workspace_id, + project_name=target.name, + ) + + +def _on_project_updated(_mapper, _connection, target): + if object_session(target).info.get("audit_skip_project_update"): + return + changes = field_changes(target, _SKIP) + if not changes: + return + emit_safe( + SyncEventType.PROJECT_UPDATED, + **actor_context(), + target_id=str(target.id), + scope_id=target.workspace_id, + project_name=target.name, + **changes, + ) + + +def register_listeners(): + if event.contains(Project, "after_insert", _on_project_created): + return + event.listen(Project, "after_insert", _on_project_created) + event.listen(Project, "after_update", _on_project_updated) diff --git a/server/mergin/sync/private_api_controller.py b/server/mergin/sync/private_api_controller.py index fbe7b5cf..7de62abe 100644 --- a/server/mergin/sync/private_api_controller.py +++ b/server/mergin/sync/private_api_controller.py @@ -11,8 +11,12 @@ from sqlalchemy import text from ..app import db +from ..audit import emit +from ..audit.listeners import actor_context from ..auth import auth_required +from ..auth.models import User from .forms import AccessPermissionForm +from .events import SyncEventType from .models import ( Project, AccessRequest, @@ -99,8 +103,17 @@ def decline_project_access_request(request_id): # noqa: E501 project_role == ProjectRole.OWNER or current_user.id == access_request.requested_by ): + requester = User.query.get(access_request.requested_by) access_request.resolve(RequestStatus.DECLINED, current_user.id) db.session.commit() + emit( + SyncEventType.PROJECT_ACCESS_REQUEST_DECLINED, + **actor_context(), + target_id=str(project.id), + scope_id=project.workspace_id, + target_email=requester.email if requester else None, + project_name=project.name, + ) return "", 200 abort(403, "You don't have permissions to remove project access request") @@ -124,7 +137,17 @@ def accept_project_access_request(request_id): project = access_request.project project_role = ProjectPermissions.get_user_project_role(project, current_user) if project_role == ProjectRole.OWNER: + requester = User.query.get(access_request.requested_by) access_request.accept(permission) + emit( + SyncEventType.PROJECT_ACCESS_REQUEST_ACCEPTED, + **actor_context(), + target_id=str(project.id), + scope_id=project.workspace_id, + target_email=requester.email if requester else None, + project_name=project.name, + role=permission, + ) return "", 200 abort(403, "You don't have permissions to accept project access request") @@ -228,6 +251,13 @@ def restore_project(id): # noqa: E501 project.removed_at = None project.removed_by = None db.session.commit() + emit( + SyncEventType.PROJECT_RESTORED, + **actor_context(), + target_id=str(project.id), + scope_id=project.workspace_id, + project_name=project.name, + ) return "", 201 @@ -240,7 +270,16 @@ def force_project_delete(id): # noqa: E501 ) if not project.removed_at: abort(400, "Failed to remove: Project is still active") + emit( + SyncEventType.PROJECT_DELETED, + **actor_context(), + target_id=str(project.id), + scope_id=project.workspace_id, + project_name=project.name, + ) + db.session.info["audit_skip_project_update"] = True project.delete() + db.session.info.pop("audit_skip_project_update", None) return "", 204 diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py index 8dbe1237..70fcbe15 100644 --- a/server/mergin/sync/public_api_controller.py +++ b/server/mergin/sync/public_api_controller.py @@ -78,16 +78,13 @@ ) from .utils import ( generate_checksum, - get_ip, - get_user_agent, generate_location, is_valid_uuid, - get_device_id, is_versioned_file, prepare_download_response, - get_device_id, wkb2wkt, ) +from ..utils import get_ip, get_user_agent, get_device_id from .errors import StorageLimitHit, ProjectLocked from ..utils import format_time_delta diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index ebd909ad..b9b27a5c 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -33,6 +33,9 @@ UploadError, ) from .files import ChangesSchema, DeltaChangeRespSchema, ProjectFileSchema +from .events import SyncEventType +from ..audit import emit +from ..audit.listeners import actor_context from .forms import project_name_validation from .models import ( FileDiff, @@ -58,12 +61,10 @@ from .schemas_v2 import ProjectSchema as ProjectSchemaV2 from .storages.disk import move_to_tmp, save_to_file from .utils import ( - get_device_id, - get_ip, - get_user_agent, get_chunk_location, prepare_download_response, ) +from ..utils import get_ip, get_user_agent, get_device_id from .tasks import remove_transaction_chunks from .workspace import WorkspaceRole from ..utils import parse_order_params, get_schema_fields_map @@ -76,9 +77,18 @@ def schedule_delete_project(id): rest. """ project = require_project_by_uuid(id, ProjectPermissions.Delete) + emit( + SyncEventType.PROJECT_REMOVED, + **actor_context(), + target_id=str(project.id), + scope_id=project.workspace_id, + project_name=project.name, + ) + db.session.info["audit_skip_project_update"] = True project.removed_at = datetime.utcnow() project.removed_by = current_user.id db.session.commit() + db.session.info.pop("audit_skip_project_update", None) return NoContent, 204 @@ -87,7 +97,16 @@ def schedule_delete_project(id): def delete_project_now(id): """Delete the project immediately""" project = require_project_by_uuid(id, ProjectPermissions.Delete, scheduled=True) + emit( + SyncEventType.PROJECT_DELETED, + **actor_context(), + target_id=str(project.id), + scope_id=project.workspace_id, + project_name=project.name, + ) + db.session.info["audit_skip_project_update"] = True project.delete() + db.session.info.pop("audit_skip_project_update", None) return NoContent, 204 @@ -152,6 +171,14 @@ def add_project_collaborator(id): project.set_role(user.id, ProjectRole(request.json["role"])) db.session.commit() + emit( + SyncEventType.PROJECT_ACCESS_GRANTED, + **actor_context(), + target_id=str(project.id), + scope_id=project.workspace_id, + target_email=user.email, + role=request.json["role"], + ) data = ProjectMemberSchema().dump(project.get_member(user.id)) return data, 201 @@ -161,11 +188,21 @@ def update_project_collaborator(id, user_id): """Update project collaborator""" project = require_project_by_uuid(id, ProjectPermissions.Update) user = User.query.filter_by(id=user_id, active=True).first_or_404() - if not project.get_role(user_id): + old_role = project.get_role(user_id) + if not old_role: abort(404) project.set_role(user.id, ProjectRole(request.json["role"])) db.session.commit() + emit( + SyncEventType.PROJECT_ACCESS_UPDATED, + **actor_context(), + target_id=str(project.id), + scope_id=project.workspace_id, + target_email=user.email, + old_role=old_role.value, + new_role=request.json["role"], + ) data = ProjectMemberSchema().dump(project.get_member(user.id)) return data, 200 @@ -174,11 +211,21 @@ def update_project_collaborator(id, user_id): def remove_project_collaborator(id, user_id): """Remove project collaborator""" project = require_project_by_uuid(id, ProjectPermissions.Update) - if not project.get_role(user_id): + removed_role = project.get_role(user_id) + if not removed_role: abort(404) + user = User.query.get(user_id) project.unset_role(user_id) db.session.commit() + emit( + SyncEventType.PROJECT_ACCESS_REVOKED, + **actor_context(), + target_id=str(project.id), + scope_id=project.workspace_id, + target_email=user.email if user else None, + role=removed_role.value, + ) return NoContent, 204 @@ -342,6 +389,16 @@ def create_project_version(id): os.renames(temp_files_dir, version_dir) db.session.commit() + emit( + SyncEventType.PROJECT_VERSION_CREATED, + **actor_context(), + target_id=str(project.id), + scope_id=project.workspace_id, + version=v_next_version, + files_added=len(to_be_added_files), + files_updated=len(to_be_updated_files), + files_removed=len(to_be_removed_files), + ) # remove used chunks only after commit — chunks belong to the now-committed version if to_be_added_files or to_be_updated_files: diff --git a/server/mergin/sync/tasks.py b/server/mergin/sync/tasks.py index 480222e6..1bd2a480 100644 --- a/server/mergin/sync/tasks.py +++ b/server/mergin/sync/tasks.py @@ -11,12 +11,14 @@ from zipfile import ZIP_DEFLATED, ZipFile from flask import current_app +from .events import SyncEventType from .models import Project, ProjectVersion, FileHistory from .storages.disk import move_to_tmp from .config import Configuration from .utils import get_chunk_location, remove_outdated_files from ..celery import celery from ..app import db +from ..audit.listeners import emit_safe @celery.task @@ -64,8 +66,16 @@ def remove_projects_backups(): if not len(projects): break + db.session.info["audit_skip_project_update"] = True for p in projects: + emit_safe( + SyncEventType.PROJECT_DELETED, + target_id=str(p.id), + scope_id=p.workspace_id, + project_name=p.name, + ) p.delete() + db.session.info.pop("audit_skip_project_update", None) @celery.task diff --git a/server/mergin/sync/utils.py b/server/mergin/sync/utils.py index 48966457..30d20192 100644 --- a/server/mergin/sync/utils.py +++ b/server/mergin/sync/utils.py @@ -103,32 +103,6 @@ def get_blacklisted_files(blacklist): return [p for p in blacklist if not p.endswith("/")] -def get_user_agent(request): - """Return user agent from request headers - - In case of browser client a parsed version from werkzeug utils is returned else raw value of header. - """ - if request.user_agent.browser and request.user_agent.platform: - client = request.user_agent.browser.capitalize() - version = request.user_agent.version - system = request.user_agent.platform.capitalize() - return f"{client}/{version} ({system})" - else: - return request.user_agent.string - - -def get_ip(request): - """Returns request's IP address based on X_FORWARDED_FOR header - from proxy webserver (which should always be the case) - """ - forwarded_ips = request.environ.get( - "HTTP_X_FORWARDED_FOR", request.environ.get("REMOTE_ADDR", "untrackable") - ) - # seems like we get list of IP addresses from AWS infra (beginning with external IP address of client, followed by some internal IP) - ip = forwarded_ips.split(",")[0] - return ip - - def generate_location(): """Return random location where project is saved on disk @@ -257,11 +231,6 @@ def split_project_path(project_path): return workspace_name, project_name -def get_device_id(request: Request) -> Optional[str]: - """Get device uuid from http header X-Device-Id""" - return request.headers.get("X-Device-Id") - - def files_size(): """Get total size of all files""" from mergin.app import db diff --git a/server/mergin/tests/fixtures.py b/server/mergin/tests/fixtures.py index 5d719878..9d0aa240 100644 --- a/server/mergin/tests/fixtures.py +++ b/server/mergin/tests/fixtures.py @@ -17,7 +17,8 @@ from ..stats.app import register from ..stats.models import MerginInfo from . import test_project, test_workspace_id, test_project_dir, TMP_DIR -from .utils import login_as_admin, initialize, cleanup, file_info +from .utils import login_as_admin, initialize, cleanup, file_info, ListSink +from ..audit.sinks import NullSink from ..sync.files import files_changes_from_upload thisdir = os.path.dirname(os.path.realpath(__file__)) @@ -98,6 +99,15 @@ def client(app): return client +@pytest.fixture(scope="function") +def audit_capture(app): + """Replace the app's audit sink with an in-memory ListSink for the duration of the test.""" + sink = ListSink() + app.audit_sink = sink + yield sink + app.audit_sink = NullSink() + + @pytest.fixture(scope="function") def diff_project(app): """Modify testing project to contain some history with diffs. Geodiff lib is used to handle changes. diff --git a/server/mergin/tests/test_audit.py b/server/mergin/tests/test_audit.py new file mode 100644 index 00000000..22f1b2c3 --- /dev/null +++ b/server/mergin/tests/test_audit.py @@ -0,0 +1,99 @@ +# Copyright (C) Lutra Consulting Limited +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial + +import json +from flask import url_for + +from ..app import db +from ..auth.events import AuthEventType +from ..auth.models import User +from ..sync.events import SyncEventType +from . import json_headers, DEFAULT_USER, test_workspace_id +from .utils import add_user, create_project, create_workspace, login + + +def test_login_succeeded_emits_event(client, audit_capture): + """Successful login via the session endpoint emits USER_LOGIN_SUCCEEDED with actor context.""" + login(client, DEFAULT_USER[0], DEFAULT_USER[1]) + + events = audit_capture.of_type(AuthEventType.USER_LOGIN_SUCCEEDED) + assert len(events) == 1 + e = events[0] + assert e.actor_email == f"{DEFAULT_USER[0]}@mergin.com" + assert e.ip_address is not None + + +def test_login_failed_emits_event_with_login_in_context(client, audit_capture): + """Failed login emits USER_LOGIN_FAILED and records the attempted login name in context.""" + client.post( + url_for("/.mergin_auth_controller_login"), + data=json.dumps({"login": "mergin", "password": "wrongpassword"}), + headers=json_headers, + ) + + events = audit_capture.of_type(AuthEventType.USER_LOGIN_FAILED) + assert len(events) == 1 + e = events[0] + assert e.context["login"] == "mergin" + assert e.actor_id is None # unauthenticated — no actor resolved + + +def test_user_created_listener_fires(audit_capture): + """SQLAlchemy after_insert listener emits USER_CREATED when a new user is committed.""" + user = add_user(username="newuser", password="password123") + user_id = user.id + + events = audit_capture.of_type(AuthEventType.USER_CREATED) + assert len(events) == 1 + e = events[0] + assert e.context["target_email"] == "newuser@mergin.com" + assert e.target_id == str(user_id) + assert e.target_type == "user" + + +def test_user_updated_listener_captures_field_changes(audit_capture): + """after_update listener emits USER_UPDATED with old/new values for changed fields, + and skips fields in the _SKIP set (e.g. passwd).""" + user = add_user(username="editme", password="pass1") + db.session.refresh(user) # load attributes so history captures old values + user.email = "changed@mergin.com" + user.passwd = "newpassword" # should be skipped + db.session.commit() + + events = audit_capture.of_type(AuthEventType.USER_UPDATED) + assert len(events) == 1 + ctx = events[0].context + assert ctx["new_email"] == "changed@mergin.com" + assert ctx["old_email"] == "editme@mergin.com" + assert "new_passwd" not in ctx # passwd is in _SKIP + assert "old_passwd" not in ctx + + +def test_project_created_listener_fires(audit_capture): + """SQLAlchemy after_insert listener emits PROJECT_CREATED when a project is committed.""" + user = add_user(username="projowner", password="pass123") + ws = create_workspace() + project = create_project("myproject", ws, user) + project_id = project.id + + events = audit_capture.of_type(SyncEventType.PROJECT_CREATED) + assert len(events) == 1 + e = events[0] + assert e.context["project_name"] == "myproject" + assert e.scope_id == test_workspace_id + assert e.target_id == str(project_id) + assert e.target_type == "project" + + +def test_listener_emits_with_null_actor_outside_request(audit_capture): + """Listeners fire from Celery-like contexts (no request) with actor fields null, + indicating a system-initiated action rather than a user action.""" + add_user(username="systemcreated", password="pass123") + + events = audit_capture.of_type(AuthEventType.USER_CREATED) + assert len(events) == 1 + e = events[0] + assert e.actor_id is None + assert e.actor_email is None + assert e.ip_address is None diff --git a/server/mergin/tests/utils.py b/server/mergin/tests/utils.py index 57f67e80..be5d8f37 100644 --- a/server/mergin/tests/utils.py +++ b/server/mergin/tests/utils.py @@ -4,6 +4,7 @@ import json import shutil +from typing import List import pysqlite3 import uuid import math @@ -405,3 +406,20 @@ def logout(client): """Test helper to log out the client""" resp = client.get(url_for("/.mergin_auth_controller_logout")) assert resp.status_code == 200 + + +class ListSink: + """In-memory audit sink for use in automated tests. + + Install via the audit_capture fixture; do not use in production code. + """ + + def __init__(self): + self.events: List = [] + + def write(self, event) -> None: + self.events.append(event) + + def of_type(self, event_type) -> List: + """Return all captured events matching event_type.""" + return [e for e in self.events if e.event_type == event_type] diff --git a/server/mergin/utils.py b/server/mergin/utils.py index aa878ffe..550bd1ce 100644 --- a/server/mergin/utils.py +++ b/server/mergin/utils.py @@ -116,6 +116,33 @@ def parse_order_params( return order_by_params +def get_user_agent(request) -> str: + """Return user agent from request headers. + + For browser clients returns a parsed summary; otherwise the raw header value. + """ + if request.user_agent.browser and request.user_agent.platform: + client = request.user_agent.browser.capitalize() + version = request.user_agent.version + system = request.user_agent.platform.capitalize() + return f"{client}/{version} ({system})" + return request.user_agent.string + + +def get_ip(request) -> str: + """Return the client IP address, respecting X-Forwarded-For from a proxy.""" + forwarded_ips = request.environ.get( + "HTTP_X_FORWARDED_FOR", request.environ.get("REMOTE_ADDR", "untrackable") + ) + # AWS infra may send a comma-separated list; the first entry is the real client IP + return forwarded_ips.split(",")[0] + + +def get_device_id(request) -> Optional[str]: + """Return the device UUID from the X-Device-Id header, or None if absent.""" + return request.headers.get("X-Device-Id") + + def format_time_delta(delta: timedelta) -> str: """Format timedelta difference approximately in days or hours""" days = round(delta.total_seconds() / (24 * 3600))