Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c0d57d2
Fixed session cookie gap for deactivated users
varmar05 Jun 15, 2026
26a0698
Add configurable bcrypt cost factor
varmar05 Jun 15, 2026
ce00ed3
Add temporary account lockout
varmar05 Jun 17, 2026
3373220
Fix tests
varmar05 Jun 19, 2026
f2708ce
Add missing import
varmar05 Jun 19, 2026
022e1a9
Support for whitelisting extensions/mimetype
harminius Jun 22, 2026
c001c7a
rm debugging residue
harminius Jun 22, 2026
8c65b22
Add audit log foundation
varmar05 Jul 1, 2026
956e01b
Remove locked_until from response and other minor fixes
varmar05 Jul 1, 2026
8efd371
Merge pull request #640 from MerginMaps/auth_fixes
MarcelGeo Jul 1, 2026
64a3844
address @MarcelGeo comments - use check_skip_validation -> also skips…
harminius Jul 2, 2026
4ddde36
Merge pull request #643 from MerginMaps/whitelisting_extensions_mimet…
MarcelGeo Jul 3, 2026
96491c7
Add audit logging for auth and sync events
varmar05 Jul 6, 2026
691faa5
Add login_method to password login events and project transfer audit …
varmar05 Jul 7, 2026
cc70bba
Align AuditEvent fields with DB sink column names
varmar05 Jul 21, 2026
f83a49d
Display whitespaces in delete dialogs
harminius Jul 22, 2026
f0ca92f
Merge pull request #653 from MerginMaps/dialog_display_whitespaces
MarcelGeo Jul 22, 2026
1d005d2
Add missing project.version.created emits
varmar05 Jul 22, 2026
c57d2e7
Merge branch 'master' into backport_master
varmar05 Jul 23, 2026
4419742
Merge pull request #654 from MerginMaps/backport_master
MarcelGeo Jul 23, 2026
de772e5
Send email notification with self-service unlock link on account lockout
varmar05 Jul 24, 2026
56626f6
fix account lockout component
varmar05 Jul 27, 2026
5f7aefe
address review comments
varmar05 Jul 27, 2026
ce69919
Merge pull request #656 from MerginMaps/account_locked_emails
varmar05 Jul 28, 2026
d1c7c20
Merge branch 'develop' into audit_logs
varmar05 Jul 28, 2026
1dc2d1c
Add user.locked and user.unlocked audit events
varmar05 Jul 29, 2026
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/
6 changes: 6 additions & 0 deletions deployment/community/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ SECURITY_EMAIL_SALT=fixme
#SECURITY_PASSWORD_SALT=NODEFAULT
SECURITY_PASSWORD_SALT=fixme

#SECURITY_UNLOCK_SALT=NODEFAULT
SECURITY_UNLOCK_SALT=fixme

#WTF_CSRF_ENABLED=True

#WTF_CSRF_TIME_LIMIT=3600 * 24 # in seconds
Expand Down Expand Up @@ -109,6 +112,9 @@ LOCAL_PROJECTS=/data

#BLACKLIST='.mergin/, .DS_Store, .directory' # cast=Csv()

# extra file extensions to permit beyond the default block-list, e.g. '.py, .sh'
#UPLOAD_EXTENSIONS_WHITELIST=

#FILE_EXPIRATION=48 * 3600 # for clean up of old files where diffs were applied, in seconds

#LOCKFILE_EXPIRATION=300 # in seconds
Expand Down
3 changes: 3 additions & 0 deletions deployment/enterprise/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ SECURITY_EMAIL_SALT=fixme
#SECURITY_PASSWORD_SALT=NODEFAULT
SECURITY_PASSWORD_SALT=fixme

#SECURITY_UNLOCK_SALT=NODEFAULT
SECURITY_UNLOCK_SALT=fixme

#WTF_CSRF_ENABLED=True

#WTF_CSRF_TIME_LIMIT=3600 * 24 # in seconds
Expand Down
1 change: 1 addition & 0 deletions server/.test.env
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ GEODIFF_WORKING_DIR=/tmp/geodiff
SECURITY_BEARER_SALT='bearer'
SECURITY_EMAIL_SALT='email'
SECURITY_PASSWORD_SALT='password'
SECURITY_UNLOCK_SALT='unlock'
DIAGNOSTIC_LOGS_DIR=/tmp/diagnostic_logs
GEVENT_WORKER=0
OTEL_ENABLED=0
1 change: 1 addition & 0 deletions server/mergin/.env
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ SECRET_KEY='top-secret'
SECURITY_BEARER_SALT='top-secret'
SECURITY_EMAIL_SALT='top-secret'
SECURITY_PASSWORD_SALT='top-secret'
SECURITY_UNLOCK_SALT='top-secret'
MAIL_DEFAULT_SENDER=''
FLASK_DEBUG=0
8 changes: 7 additions & 1 deletion 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 All @@ -188,7 +192,9 @@ def create_app(public_keys: List[str] = None) -> Flask:
# adjust login manager
@login_manager.user_loader
def load_user(user_id): # pylint: disable=W0613,W0612
return User.query.get(user_id)
user = User.query.get(user_id)
if user and user.active:
return user

@login_manager.header_loader
def load_user_from_header(header_val): # pylint: disable=W0613,W0612
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
28 changes: 28 additions & 0 deletions server/mergin/auth/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,8 @@ paths:
$ref: "#/components/responses/BadStatusResp"
"401":
$ref: "#/components/responses/UnauthorizedError"
"423":
$ref: "#/components/responses/LockedResp"
/app/auth/logout:
get:
summary: Logout
Expand Down Expand Up @@ -470,6 +472,26 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFoundResp"
/app/auth/unlock-account/{token}:
post:
summary: Unlock account
description: Clear an active lockout for the user encoded in the token
operationId: mergin.auth.controller.unlock_account
parameters:
- name: token
in: path
description: User token for account unlock verification
required: true
schema:
type: string
example: InRlc3RAbHV0cmFjb25zdWx0aW5nLmNvLnVrIg.YN2KRg.Vj1LSzSvQx9DcNnQFgZ0baS7LPU
responses:
"200":
description: OK
"400":
$ref: "#/components/responses/BadStatusResp"
"404":
$ref: "#/components/responses/NotFoundResp"
/app/auth/confirm-email/{token}:
post:
summary: Email verified
Expand Down Expand Up @@ -617,6 +639,8 @@ paths:
$ref: "#/components/responses/NotFoundResp"
"415":
$ref: "#/components/responses/UnsupportedMediaType"
"423":
$ref: "#/components/responses/LockedResp"
x-openapi-router-controller: mergin.auth.controller
/app/admin/login:
post:
Expand Down Expand Up @@ -646,6 +670,8 @@ paths:
$ref: "#/components/responses/UnauthorizedError"
"403":
$ref: "#/components/responses/Forbidden"
"423":
$ref: "#/components/responses/LockedResp"
/v2/users:
post:
tags:
Expand Down Expand Up @@ -718,6 +744,8 @@ components:
description: Request could not be processed becuase of conflict in resources
UnprocessableEntity:
description: Request was correct and yet server could not process it
LockedResp:
description: Account is temporarily locked due to too many failed login attempts.
NoContent:
description: Success. No content returned.
schemas:
Expand Down
Loading
Loading