From c0d57d29ea9d2906d0b3aef29d09333d669be077 Mon Sep 17 00:00:00 2001 From: Martin Varga Date: Mon, 15 Jun 2026 14:26:16 +0200 Subject: [PATCH 01/19] Fixed session cookie gap for deactivated users - `load_user` now returns None for inactive users, so their session cookies are rejected by Flask-Login - @auth_required adds `is_active` check - anonymize() now explicitly sets active=False for defence-in-depth --- server/mergin/app.py | 4 +++- server/mergin/auth/app.py | 6 +++++- server/mergin/auth/models.py | 1 + server/mergin/tests/test_auth.py | 20 +++++++++++++++++++- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/server/mergin/app.py b/server/mergin/app.py index e5eb42d5..77d5a5ac 100644 --- a/server/mergin/app.py +++ b/server/mergin/app.py @@ -188,7 +188,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 diff --git a/server/mergin/auth/app.py b/server/mergin/auth/app.py index acfccf43..2f57bc73 100644 --- a/server/mergin/auth/app.py +++ b/server/mergin/auth/app.py @@ -61,7 +61,11 @@ def auth_required(f=None, permissions=None): @functools.wraps(f) def wrapped_func(*args, **kwargs): - if not current_user or not current_user.is_authenticated: + if ( + not current_user + or not current_user.is_authenticated + or not current_user.is_active + ): return "Authentication information is missing or invalid.", 401 if permissions: for check_permission in permissions: diff --git a/server/mergin/auth/models.py b/server/mergin/auth/models.py index 760ab740..390fbbe0 100644 --- a/server/mergin/auth/models.py +++ b/server/mergin/auth/models.py @@ -185,6 +185,7 @@ def anonymize(self): """Anonymize user object in database - remove personal information""" ts = round(datetime.datetime.utcnow().timestamp() * 1000) del_str = f"deleted_{ts}" + self.active = False self.username = del_str self.email = None self.passwd = None diff --git a/server/mergin/tests/test_auth.py b/server/mergin/tests/test_auth.py index ba7730c3..dc08cd57 100644 --- a/server/mergin/tests/test_auth.py +++ b/server/mergin/tests/test_auth.py @@ -94,6 +94,23 @@ def test_logout(client): assert resp.status_code == 200 +def test_deactivated_user_session_rejected(client): + """Session cookie for a deactivated account must be rejected.""" + user = add_user("testdeactivate", "testpassword") + login(client, "testdeactivate", "testpassword") + + # session works before deactivation + resp = client.get(f"/v1/user/{user.username}") + assert resp.status_code == 200 + + user.active = False + db.session.commit() + + # same session must now be rejected + resp = client.get(f"/v1/user/{user.username}") + assert resp.status_code == 401 + + # user registration tests test_user_reg_data = [ ("test@test.com", "#pwd1234", 201), # success @@ -469,7 +486,8 @@ def test_update_user(client): data=json.dumps(data), headers=json_headers, ) - assert resp.status_code == 403 + # user is deactivated, so session is rejected before permission check + assert resp.status_code == 401 def test_update_user_profile(client): From 26a0698e25aa0dd95e4719d6dc20bfd5bd15d566 Mon Sep 17 00:00:00 2001 From: Martin Varga Date: Mon, 15 Jun 2026 14:37:49 +0200 Subject: [PATCH 02/19] Add configurable bcrypt cost factor Existing passwords will be rehashed organically if needed. --- server/mergin/auth/app.py | 5 +++++ server/mergin/auth/config.py | 1 + server/mergin/auth/models.py | 15 ++++++++++++++- server/mergin/tests/test_auth.py | 22 ++++++++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/server/mergin/auth/app.py b/server/mergin/auth/app.py index 2f57bc73..d62462da 100644 --- a/server/mergin/auth/app.py +++ b/server/mergin/auth/app.py @@ -91,12 +91,17 @@ def wrapped_func(*args, **kwargs): def authenticate(login, password): + from ..app import db + if "@" in login: query = func.lower(User.email) == func.lower(login) else: query = func.lower(User.username) == func.lower(login) user = User.query.filter(query).one_or_none() if user and user.check_password(password): + if user.needs_rehash(): + user.assign_password(password) + db.session.commit() return user diff --git a/server/mergin/auth/config.py b/server/mergin/auth/config.py index 07b5a05d..a04a4e82 100644 --- a/server/mergin/auth/config.py +++ b/server/mergin/auth/config.py @@ -13,3 +13,4 @@ class Configuration(object): "BEARER_TOKEN_EXPIRATION", default=3600 * 12, cast=int ) # in seconds ACCOUNT_EXPIRATION = config("ACCOUNT_EXPIRATION", default=5, cast=int) # in days + BCRYPT_LOG_ROUNDS = config("BCRYPT_LOG_ROUNDS", default=12, cast=int) diff --git a/server/mergin/auth/models.py b/server/mergin/auth/models.py index 390fbbe0..d20325e2 100644 --- a/server/mergin/auth/models.py +++ b/server/mergin/auth/models.py @@ -64,12 +64,25 @@ def check_password(self, password): def assign_password(self, password): if isinstance(password, str): password = password.encode("utf-8") + rounds = current_app.config.get("BCRYPT_LOG_ROUNDS", 12) self.passwd = ( - bcrypt.hashpw(password, bcrypt.gensalt()).decode("utf-8") + bcrypt.hashpw(password, bcrypt.gensalt(rounds)).decode("utf-8") if password else None ) + def needs_rehash(self): + """Return True if the stored hash was generated with a different cost factor than configured.""" + if self.passwd is None: + return False + rounds = current_app.config.get("BCRYPT_LOG_ROUNDS", 12) + try: + # bcrypt hash format: $2b$$ + hash_rounds = int(self.passwd.split("$")[2]) + return hash_rounds != rounds + except (IndexError, ValueError): + return False + @property def is_authenticated(self): """For Flask-Login""" diff --git a/server/mergin/tests/test_auth.py b/server/mergin/tests/test_auth.py index dc08cd57..ea91b3ca 100644 --- a/server/mergin/tests/test_auth.py +++ b/server/mergin/tests/test_auth.py @@ -94,6 +94,28 @@ def test_logout(client): assert resp.status_code == 200 +def test_bcrypt_lazy_rehash(app): + """Password is transparently rehashed on login when the cost factor changes.""" + import bcrypt + from ..auth.app import authenticate + + user = add_user("rehashuser", "rehashpassword") + # Store a hash with a low cost factor (4 is the minimum bcrypt allows) + low_rounds_hash = bcrypt.hashpw(b"rehashpassword", bcrypt.gensalt(4)).decode( + "utf-8" + ) + user.passwd = low_rounds_hash + db.session.commit() + + app.config["BCRYPT_LOG_ROUNDS"] = 5 + result = authenticate("rehashuser", "rehashpassword") + assert result is not None + + db.session.refresh(user) + hash_rounds = int(user.passwd.split("$")[2]) + assert hash_rounds == 5 + + def test_deactivated_user_session_rejected(client): """Session cookie for a deactivated account must be rejected.""" user = add_user("testdeactivate", "testpassword") From ce00ed3e1d3edd2d75bf1b2dbb5539c501bef955 Mon Sep 17 00:00:00 2001 From: Martin Varga Date: Wed, 17 Jun 2026 15:08:21 +0200 Subject: [PATCH 03/19] Add temporary account lockout --- server/mergin/auth/api.yaml | 8 +++ server/mergin/auth/app.py | 17 ++++- server/mergin/auth/config.py | 2 + server/mergin/auth/controller.py | 16 ++++- server/mergin/auth/errors.py | 21 ++++++ server/mergin/auth/models.py | 46 +++++++++++++ server/mergin/tests/test_auth.py | 69 +++++++++++++++++++ .../a3c8f2e1d947_add_login_lockout_fields.py | 42 +++++++++++ 8 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 server/mergin/auth/errors.py create mode 100644 server/migrations/community/a3c8f2e1d947_add_login_lockout_fields.py diff --git a/server/mergin/auth/api.yaml b/server/mergin/auth/api.yaml index fa482a36..a4c7b637 100644 --- a/server/mergin/auth/api.yaml +++ b/server/mergin/auth/api.yaml @@ -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 @@ -617,6 +619,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: @@ -646,6 +650,8 @@ paths: $ref: "#/components/responses/UnauthorizedError" "403": $ref: "#/components/responses/Forbidden" + "423": + $ref: "#/components/responses/LockedResp" /v2/users: post: tags: @@ -718,6 +724,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: diff --git a/server/mergin/auth/app.py b/server/mergin/auth/app.py index d62462da..f9d1a038 100644 --- a/server/mergin/auth/app.py +++ b/server/mergin/auth/app.py @@ -12,6 +12,7 @@ from .commands import add_commands from .config import Configuration from .models import User +from .errors import AccountLockedError # signal for other versions to listen to user_account_closed = signal("user_account_closed") @@ -98,11 +99,25 @@ def authenticate(login, password): else: query = func.lower(User.username) == func.lower(login) user = User.query.filter(query).one_or_none() - if user and user.check_password(password): + if user is None: + return None + needs_commit = False + if user.is_locked_out(): + raise AccountLockedError(user.locked_until) + if user.check_password(password): + if user.failed_login_attempts or user.locked_until: + user.reset_lockout() + needs_commit = True if user.needs_rehash(): user.assign_password(password) + needs_commit = True + if needs_commit: db.session.commit() return user + else: + user.record_failed_login() + db.session.commit() + return None def generate_confirmation_token(app, email, salt): diff --git a/server/mergin/auth/config.py b/server/mergin/auth/config.py index a04a4e82..3d2215ee 100644 --- a/server/mergin/auth/config.py +++ b/server/mergin/auth/config.py @@ -14,3 +14,5 @@ class Configuration(object): ) # in seconds ACCOUNT_EXPIRATION = config("ACCOUNT_EXPIRATION", default=5, cast=int) # in days BCRYPT_LOG_ROUNDS = config("BCRYPT_LOG_ROUNDS", default=12, cast=int) + # Comma-separated "attempts:seconds" pairs, e.g. "5:300,10:3600" + LOCKOUT_POLICY = config("LOCKOUT_POLICY", default="5:300,10:3600") diff --git a/server/mergin/auth/controller.py b/server/mergin/auth/controller.py index 06859255..ed104641 100644 --- a/server/mergin/auth/controller.py +++ b/server/mergin/auth/controller.py @@ -25,6 +25,7 @@ ) from .bearer import encode_token from .models import User, LoginHistory +from .errors import AccountLockedError from .schemas import UserSchema, UserSearchSchema, UserProfileSchema, UserInfoSchema from .forms import ( LoginForm, @@ -137,7 +138,10 @@ def login_public(): # noqa: E501 """ form = ApiLoginForm() if form.validate(): - user = authenticate(form.login.data, form.password.data) + try: + user = authenticate(form.login.data, form.password.data) + except AccountLockedError as e: + return e.response(423) if user and user.active: expire = datetime.now(pytz.utc) + timedelta( seconds=current_app.config["BEARER_TOKEN_EXPIRATION"] @@ -221,7 +225,10 @@ def search_users(): # pylint: disable=W0613,W0612 def login(): # pylint: disable=W0613,W0612 form = LoginForm() if form.validate(): - user = authenticate(form.login.data, form.password.data) + try: + user = authenticate(form.login.data, form.password.data) + except AccountLockedError as e: + return e.response(423) if user and user.active: login_user(user) if not os.path.isfile(current_app.config["MAINTENANCE_FILE"]): @@ -238,7 +245,10 @@ def admin_login(): # pylint: disable=W0613,W0612 if not form.validate(): return jsonify(form.errors), 400 - user = authenticate(form.login.data, form.password.data) + try: + user = authenticate(form.login.data, form.password.data) + except AccountLockedError as e: + abort(423, f"Account temporarily locked until {e.locked_until.isoformat()}") if user: if user.active and user.is_admin: login_user(user) diff --git a/server/mergin/auth/errors.py b/server/mergin/auth/errors.py new file mode 100644 index 00000000..a337bb77 --- /dev/null +++ b/server/mergin/auth/errors.py @@ -0,0 +1,21 @@ +# Copyright (C) Lutra Consulting Limited +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial + +import datetime +from typing import Dict + +from ..app import ResponseError + + +class AccountLockedError(Exception, ResponseError): + code = "AccountLocked" + detail = "Account temporarily locked due to too many failed login attempts" + + def __init__(self, locked_until: datetime.datetime): + self.locked_until = locked_until + + def to_dict(self) -> Dict: + data = super().to_dict() + data["locked_until"] = self.locked_until.isoformat() + return data diff --git a/server/mergin/auth/models.py b/server/mergin/auth/models.py index d20325e2..234fee44 100644 --- a/server/mergin/auth/models.py +++ b/server/mergin/auth/models.py @@ -13,10 +13,20 @@ 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 .errors import AccountLockedError MAX_USERNAME_LENGTH = 50 +def _parse_lockout_policy(policy_str: str) -> list: + """Parse "5:300,10:3600" into [(5, 300), (10, 3600)] sorted ascending by threshold.""" + result = [] + for part in policy_str.split(","): + threshold, seconds = part.strip().split(":") + result.append((int(threshold), int(seconds))) + return sorted(result, key=lambda x: x[0]) + + class User(db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), info={"label": "Username"}) @@ -33,6 +43,10 @@ class User(db.Model): default=datetime.datetime.utcnow, ) last_signed_in = db.Column(db.DateTime(), nullable=True) + failed_login_attempts = db.Column( + db.Integer, default=0, nullable=False, server_default="0" + ) + locked_until = db.Column(db.DateTime(), nullable=True) receive_notifications = db.Column( db.Boolean, default=True, nullable=False, index=True ) @@ -83,6 +97,38 @@ def needs_rehash(self): except (IndexError, ValueError): return False + def is_locked_out(self) -> bool: + """Return True if the account is currently under a temporary lockout.""" + if self.locked_until is None: + return False + now = datetime.datetime.utcnow() + if self.locked_until <= now: + # lockout has expired — clear it so subsequent queries see a clean state + self.locked_until = None + return False + return True + + def record_failed_login(self) -> None: + """Increment the failed-login counter and apply a lockout if a threshold is crossed.""" + self.failed_login_attempts = (self.failed_login_attempts or 0) + 1 + policy = _parse_lockout_policy( + current_app.config.get("LOCKOUT_POLICY", "5:300,10:3600") + ) + # find the highest applicable tier + duration = None + for threshold, seconds in policy: + if self.failed_login_attempts >= threshold: + duration = seconds + if duration is not None: + self.locked_until = datetime.datetime.utcnow() + datetime.timedelta( + seconds=duration + ) + + def reset_lockout(self) -> None: + """Clear lockout state after a successful login.""" + self.failed_login_attempts = 0 + self.locked_until = None + @property def is_authenticated(self): """For Flask-Login""" diff --git a/server/mergin/tests/test_auth.py b/server/mergin/tests/test_auth.py index ea91b3ca..dd52ebba 100644 --- a/server/mergin/tests/test_auth.py +++ b/server/mergin/tests/test_auth.py @@ -94,6 +94,75 @@ def test_logout(client): assert resp.status_code == 200 +def test_login_lockout(client): + """Test account lockout: progressive tiers, freeze during lock, reset on success. + + policy: 3 failures → 60s lock, 4 failures → 3600s lock + counter is never reset between lockouts, so tier-2 is reached after one + extra failure following the first expired tier-1 lock + """ + client.application.config["LOCKOUT_POLICY"] = "3:60,4:3600" + user = add_user("lockoutuser", "correctpassword") + + def assert_locked(): + resp = client.post( + url_for("/.mergin_auth_controller_login"), + json={"login": "lockoutuser", "password": "wrong"}, + ) + assert resp.status_code == 423 + assert resp.json["code"] == "AccountLocked" + assert "locked_until" in resp.json + + # tier 1: 3 failures → 60s lock + for _ in range(3): + resp = client.post( + url_for("/.mergin_auth_controller_login"), + json={"login": "lockoutuser", "password": "wrong"}, + ) + assert resp.status_code == 401 + + assert_locked() + + # correct password is also blocked while locked + resp = client.post( + url_for("/.mergin_auth_controller_login"), + json={"login": "lockoutuser", "password": "correctpassword"}, + ) + assert resp.status_code == 423 + + # counter stays frozen during lockout + assert user.failed_login_attempts == 3 + assert user.locked_until is not None + + # tier 2 escalation: one more failure after tier-1 expiry + # counter was at 3; one new failure pushes it to 4, crossing tier-2 threshold + + # expire_lock + user.locked_until = datetime.now(tz=timezone.utc) - timedelta(seconds=1) + db.session.commit() + + resp = client.post( + url_for("/.mergin_auth_controller_login"), + json={"login": "lockoutuser", "password": "wrong"}, + ) + # returns 401 (wrong password), but now locked for 3600s + assert resp.status_code == 401 + assert_locked() + assert user.locked_until > datetime.now(tz=timezone.utc) + timedelta(seconds=60) + assert user.failed_login_attempts == 4 + + # successful login after expiry resets everything + user.locked_until = datetime.now(tz=timezone.utc) - timedelta(seconds=1) + db.session.commit() + resp = client.post( + url_for("/.mergin_auth_controller_login"), + json={"login": "lockoutuser", "password": "correctpassword"}, + ) + assert resp.status_code == 200 + assert user.failed_login_attempts == 0 + assert user.locked_until is None + + def test_bcrypt_lazy_rehash(app): """Password is transparently rehashed on login when the cost factor changes.""" import bcrypt diff --git a/server/migrations/community/a3c8f2e1d947_add_login_lockout_fields.py b/server/migrations/community/a3c8f2e1d947_add_login_lockout_fields.py new file mode 100644 index 00000000..a4a453e1 --- /dev/null +++ b/server/migrations/community/a3c8f2e1d947_add_login_lockout_fields.py @@ -0,0 +1,42 @@ +"""Add failed_login_attempts and locked_until to user table + +Revision ID: a3c8f2e1d947 +Revises: f1d9e4a7b823 +Create Date: 2026-06-15 00:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "a3c8f2e1d947" +down_revision = "a1b2c3d4e5f6" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "user", + sa.Column( + "failed_login_attempts", + sa.Integer(), + nullable=False, + server_default="0", + ), + ) + op.add_column( + "user", + sa.Column( + "locked_until", + sa.DateTime(), + nullable=True, + ), + ) + + +def downgrade(): + op.drop_column("user", "locked_until") + op.drop_column("user", "failed_login_attempts") From 33732207e74bfd3d238ef33879a09d6b60c542c7 Mon Sep 17 00:00:00 2001 From: Martin Varga Date: Fri, 19 Jun 2026 13:26:38 +0200 Subject: [PATCH 04/19] Fix tests --- server/mergin/tests/test_auth.py | 8 ++++---- .../community/a3c8f2e1d947_add_login_lockout_fields.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server/mergin/tests/test_auth.py b/server/mergin/tests/test_auth.py index dd52ebba..a3ee168f 100644 --- a/server/mergin/tests/test_auth.py +++ b/server/mergin/tests/test_auth.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta import time import itsdangerous import pytest @@ -138,7 +138,7 @@ def assert_locked(): # counter was at 3; one new failure pushes it to 4, crossing tier-2 threshold # expire_lock - user.locked_until = datetime.now(tz=timezone.utc) - timedelta(seconds=1) + user.locked_until = datetime.utcnow() - timedelta(seconds=1) db.session.commit() resp = client.post( @@ -148,11 +148,11 @@ def assert_locked(): # returns 401 (wrong password), but now locked for 3600s assert resp.status_code == 401 assert_locked() - assert user.locked_until > datetime.now(tz=timezone.utc) + timedelta(seconds=60) + assert user.locked_until > datetime.utcnow() + timedelta(seconds=60) assert user.failed_login_attempts == 4 # successful login after expiry resets everything - user.locked_until = datetime.now(tz=timezone.utc) - timedelta(seconds=1) + user.locked_until = datetime.utcnow() - timedelta(seconds=1) db.session.commit() resp = client.post( url_for("/.mergin_auth_controller_login"), diff --git a/server/migrations/community/a3c8f2e1d947_add_login_lockout_fields.py b/server/migrations/community/a3c8f2e1d947_add_login_lockout_fields.py index a4a453e1..bcd7f76a 100644 --- a/server/migrations/community/a3c8f2e1d947_add_login_lockout_fields.py +++ b/server/migrations/community/a3c8f2e1d947_add_login_lockout_fields.py @@ -12,7 +12,7 @@ # revision identifiers, used by Alembic. revision = "a3c8f2e1d947" -down_revision = "a1b2c3d4e5f6" +down_revision = "f1d9e4a7b823" branch_labels = None depends_on = None From f2708ce1cdd082d964cd6b0ea5ff85e516edfb02 Mon Sep 17 00:00:00 2001 From: Martin Varga Date: Fri, 19 Jun 2026 13:46:06 +0200 Subject: [PATCH 05/19] Add missing import --- server/mergin/tests/test_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/mergin/tests/test_auth.py b/server/mergin/tests/test_auth.py index a3ee168f..2294de49 100644 --- a/server/mergin/tests/test_auth.py +++ b/server/mergin/tests/test_auth.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import time import itsdangerous import pytest From 022e1a9a0780d5ba4033ade06f1bb6ee2e94744f Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Mon, 22 Jun 2026 15:34:07 +0200 Subject: [PATCH 06/19] Support for whitelisting extensions/mimetype --- deployment/community/.env.template | 4 ++++ server/mergin/sync/config.py | 8 ++++++++ server/mergin/sync/files.py | 2 +- server/mergin/sync/utils.py | 4 ++++ server/mergin/tests/test_utils.py | 29 +++++++++++++++++++++++++++++ 5 files changed, 46 insertions(+), 1 deletion(-) diff --git a/deployment/community/.env.template b/deployment/community/.env.template index ea6b8ccc..8754d263 100644 --- a/deployment/community/.env.template +++ b/deployment/community/.env.template @@ -109,6 +109,10 @@ LOCAL_PROJECTS=/data #BLACKLIST='.mergin/, .DS_Store, .directory' # cast=Csv() +# extra file types to permit beyond the default block-list (e.g. scripts) +#UPLOAD_EXTENSIONS_WHITELIST='' # cast=Csv() +#UPLOAD_MIME_TYPES_WHITELIST='' # cast=Csv() + #FILE_EXPIRATION=48 * 3600 # for clean up of old files where diffs were applied, in seconds #LOCKFILE_EXPIRATION=300 # in seconds diff --git a/server/mergin/sync/config.py b/server/mergin/sync/config.py index 8a5081ec..3313bc7f 100644 --- a/server/mergin/sync/config.py +++ b/server/mergin/sync/config.py @@ -82,5 +82,13 @@ class Configuration(object): ) # files that should be ignored during extension and MIME type checks UPLOAD_FILES_WHITELIST = config("UPLOAD_FILES_WHITELIST", default="", cast=Csv()) + # extra extensions to permit beyond the default block-list + UPLOAD_EXTENSIONS_WHITELIST = config( + "UPLOAD_EXTENSIONS_WHITELIST", default="", cast=Csv() + ) + # extra MIME types to permit beyond the default block-list + UPLOAD_MIME_TYPES_WHITELIST = config( + "UPLOAD_MIME_TYPES_WHITELIST", default="", cast=Csv() + ) # max batch size for fetch projects in batch endpoint MAX_BATCH_SIZE = config("MAX_BATCH_SIZE", default=100, cast=int) diff --git a/server/mergin/sync/files.py b/server/mergin/sync/files.py index d22358d5..9326b30f 100644 --- a/server/mergin/sync/files.py +++ b/server/mergin/sync/files.py @@ -224,7 +224,7 @@ def validate(self, data, **kwargs): if not is_supported_extension(file_path): raise ValidationError( - f"Unsupported file type detected: '{file_path}'. " + f"stop Unsupported file type detected: '{file_path}'. " f"Please remove the file or try compressing it into a ZIP file before uploading.", ) # new checks must restrict only new files not to block existing projects diff --git a/server/mergin/sync/utils.py b/server/mergin/sync/utils.py index 48966457..5843595a 100644 --- a/server/mergin/sync/utils.py +++ b/server/mergin/sync/utils.py @@ -315,6 +315,8 @@ def is_supported_extension(filepath) -> bool: if check_skip_validation(filepath): return True ext = os.path.splitext(filepath)[1].lower() + if ext in {e.lower() for e in Configuration.UPLOAD_EXTENSIONS_WHITELIST}: + return True return ext and ext not in FORBIDDEN_EXTENSIONS @@ -493,6 +495,8 @@ def is_supported_type(filepath) -> bool: if check_skip_validation(filepath): return True mime_type = get_mimetype(filepath) + if mime_type in Configuration.UPLOAD_MIME_TYPES_WHITELIST: + return True return mime_type.startswith("image/") or mime_type not in FORBIDDEN_MIME_TYPES diff --git a/server/mergin/tests/test_utils.py b/server/mergin/tests/test_utils.py index 1f447875..8e4192a1 100644 --- a/server/mergin/tests/test_utils.py +++ b/server/mergin/tests/test_utils.py @@ -402,3 +402,32 @@ def test_mime_type_validation_skip(): # Should be forbidden assert not is_supported_type("other.js") + + +def test_allowed_extensions_override(): + """Extensions in UPLOAD_EXTENSIONS_WHITELIST are accepted even though they are in FORBIDDEN_EXTENSIONS.""" + with patch( + "mergin.sync.utils.Configuration.UPLOAD_EXTENSIONS_WHITELIST", [".py", ".sh"] + ): + # forbidden by default, now explicitly allowed + assert is_supported_extension("model.py") + assert is_supported_extension("scripts/deploy.sh") + # match is case-insensitive + assert is_supported_extension("MODEL.PY") + # extensions not in the override stay blocked + assert not is_supported_extension("malware.exe") + assert not is_supported_extension("app.js") + + +def test_allowed_mime_types_override(): + """MIME types in UPLOAD_MIME_TYPES_WHITELIST are accepted even though they are in FORBIDDEN_MIME_TYPES.""" + with patch("mergin.sync.utils.get_mimetype", return_value="text/x-shellscript"): + # blocked by default + with patch("mergin.sync.utils.Configuration.UPLOAD_MIME_TYPES_WHITELIST", []): + assert not is_supported_type("deploy.sh") + # explicitly allowed + with patch( + "mergin.sync.utils.Configuration.UPLOAD_MIME_TYPES_WHITELIST", + ["text/x-shellscript"], + ): + assert is_supported_type("deploy.sh") From c001c7ad5d9ff1c94ad4fa5e6d93b92a665d9e9f Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Mon, 22 Jun 2026 15:49:50 +0200 Subject: [PATCH 07/19] rm debugging residue --- server/mergin/sync/files.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/mergin/sync/files.py b/server/mergin/sync/files.py index 9326b30f..d22358d5 100644 --- a/server/mergin/sync/files.py +++ b/server/mergin/sync/files.py @@ -224,7 +224,7 @@ def validate(self, data, **kwargs): if not is_supported_extension(file_path): raise ValidationError( - f"stop Unsupported file type detected: '{file_path}'. " + f"Unsupported file type detected: '{file_path}'. " f"Please remove the file or try compressing it into a ZIP file before uploading.", ) # new checks must restrict only new files not to block existing projects From 8c65b22f650138ad630cf47195b69ab99d064d7d Mon Sep 17 00:00:00 2001 From: Martin Varga Date: Wed, 1 Jul 2026 08:50:34 +0200 Subject: [PATCH 08/19] Add audit log foundation Introduce a pluggable audit module with NullSink default and emit() API. Wire SQLAlchemy listeners for user and project lifecycle events, explicit emits for auth and sync endpoints (login, password, access grants/revocations, version push, soft/hard delete, restore). Add actor_context()/request_context() helpers, device_id capture, scope_id for workspace-scoped filtering, and target_type auto-derivation. This is groundwork for adding more events and to be extended in EE with custom sinks, query API, and retention policy. Co-Authored-By: Claude Sonnet 4.6 --- server/mergin/app.py | 4 + server/mergin/audit/__init__.py | 5 + server/mergin/audit/app.py | 51 ++++++++++ server/mergin/audit/events.py | 26 +++++ server/mergin/audit/listeners.py | 76 ++++++++++++++ server/mergin/audit/sinks.py | 21 ++++ server/mergin/auth/app.py | 2 + server/mergin/auth/controller.py | 77 ++++++++++++++- server/mergin/auth/events.py | 20 ++++ server/mergin/auth/listeners.py | 46 +++++++++ server/mergin/auth/models.py | 3 +- server/mergin/auth/tasks.py | 9 ++ server/mergin/sync/app.py | 2 + server/mergin/sync/db_events.py | 2 +- server/mergin/sync/events.py | 27 +++++ server/mergin/sync/listeners.py | 59 +++++++++++ server/mergin/sync/private_api_controller.py | 39 ++++++++ server/mergin/sync/public_api_controller.py | 5 +- .../mergin/sync/public_api_v2_controller.py | 67 ++++++++++++- server/mergin/sync/tasks.py | 10 ++ server/mergin/sync/utils.py | 31 ------ server/mergin/tests/fixtures.py | 12 ++- server/mergin/tests/test_audit.py | 99 +++++++++++++++++++ server/mergin/tests/utils.py | 18 ++++ server/mergin/utils.py | 27 +++++ 25 files changed, 694 insertions(+), 44 deletions(-) create mode 100644 server/mergin/audit/__init__.py create mode 100644 server/mergin/audit/app.py create mode 100644 server/mergin/audit/events.py create mode 100644 server/mergin/audit/listeners.py create mode 100644 server/mergin/audit/sinks.py create mode 100644 server/mergin/auth/events.py create mode 100644 server/mergin/auth/listeners.py create mode 100644 server/mergin/sync/events.py create mode 100644 server/mergin/sync/listeners.py create mode 100644 server/mergin/tests/test_audit.py 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)) From 956e01b879559fd0efda552d682bbd5fd64102e6 Mon Sep 17 00:00:00 2001 From: Martin Varga Date: Wed, 1 Jul 2026 15:29:35 +0200 Subject: [PATCH 09/19] Remove locked_until from response and other minor fixes --- server/mergin/auth/app.py | 9 +++++++-- server/mergin/auth/controller.py | 2 +- server/mergin/auth/errors.py | 11 ----------- server/mergin/auth/models.py | 10 ++-------- server/mergin/tests/test_auth.py | 1 - 5 files changed, 10 insertions(+), 23 deletions(-) diff --git a/server/mergin/auth/app.py b/server/mergin/auth/app.py index f9d1a038..00575a5f 100644 --- a/server/mergin/auth/app.py +++ b/server/mergin/auth/app.py @@ -101,9 +101,14 @@ def authenticate(login, password): user = User.query.filter(query).one_or_none() if user is None: return None - needs_commit = False if user.is_locked_out(): - raise AccountLockedError(user.locked_until) + raise AccountLockedError() + needs_commit = False + # reset non-null locked_until as it has already expired + if user.locked_until is not None: + user.locked_until = None + needs_commit = True + if user.check_password(password): if user.failed_login_attempts or user.locked_until: user.reset_lockout() diff --git a/server/mergin/auth/controller.py b/server/mergin/auth/controller.py index ed104641..474696cc 100644 --- a/server/mergin/auth/controller.py +++ b/server/mergin/auth/controller.py @@ -248,7 +248,7 @@ def admin_login(): # pylint: disable=W0613,W0612 try: user = authenticate(form.login.data, form.password.data) except AccountLockedError as e: - abort(423, f"Account temporarily locked until {e.locked_until.isoformat()}") + return e.response(423) if user: if user.active and user.is_admin: login_user(user) diff --git a/server/mergin/auth/errors.py b/server/mergin/auth/errors.py index a337bb77..99907948 100644 --- a/server/mergin/auth/errors.py +++ b/server/mergin/auth/errors.py @@ -2,20 +2,9 @@ # # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial -import datetime -from typing import Dict - from ..app import ResponseError class AccountLockedError(Exception, ResponseError): code = "AccountLocked" detail = "Account temporarily locked due to too many failed login attempts" - - def __init__(self, locked_until: datetime.datetime): - self.locked_until = locked_until - - def to_dict(self) -> Dict: - data = super().to_dict() - data["locked_until"] = self.locked_until.isoformat() - return data diff --git a/server/mergin/auth/models.py b/server/mergin/auth/models.py index 234fee44..e8cfbd90 100644 --- a/server/mergin/auth/models.py +++ b/server/mergin/auth/models.py @@ -13,7 +13,6 @@ 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 .errors import AccountLockedError MAX_USERNAME_LENGTH = 50 @@ -93,7 +92,7 @@ def needs_rehash(self): try: # bcrypt hash format: $2b$$ hash_rounds = int(self.passwd.split("$")[2]) - return hash_rounds != rounds + return hash_rounds < rounds except (IndexError, ValueError): return False @@ -101,12 +100,7 @@ def is_locked_out(self) -> bool: """Return True if the account is currently under a temporary lockout.""" if self.locked_until is None: return False - now = datetime.datetime.utcnow() - if self.locked_until <= now: - # lockout has expired — clear it so subsequent queries see a clean state - self.locked_until = None - return False - return True + return self.locked_until > datetime.datetime.utcnow() def record_failed_login(self) -> None: """Increment the failed-login counter and apply a lockout if a threshold is crossed.""" diff --git a/server/mergin/tests/test_auth.py b/server/mergin/tests/test_auth.py index 2294de49..3554152b 100644 --- a/server/mergin/tests/test_auth.py +++ b/server/mergin/tests/test_auth.py @@ -111,7 +111,6 @@ def assert_locked(): ) assert resp.status_code == 423 assert resp.json["code"] == "AccountLocked" - assert "locked_until" in resp.json # tier 1: 3 failures → 60s lock for _ in range(3): From 64a3844aa1e639a8be4ac83a02063c9cfe8f2349 Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Thu, 2 Jul 2026 15:36:34 +0200 Subject: [PATCH 10/19] address @MarcelGeo comments - use check_skip_validation -> also skips mimetype check -> mime_type config var is not needed --- deployment/community/.env.template | 5 ++--- server/mergin/sync/config.py | 4 ---- server/mergin/sync/utils.py | 9 ++++----- server/mergin/tests/test_utils.py | 13 ++++++------- 4 files changed, 12 insertions(+), 19 deletions(-) diff --git a/deployment/community/.env.template b/deployment/community/.env.template index 8754d263..95497b68 100644 --- a/deployment/community/.env.template +++ b/deployment/community/.env.template @@ -109,9 +109,8 @@ LOCAL_PROJECTS=/data #BLACKLIST='.mergin/, .DS_Store, .directory' # cast=Csv() -# extra file types to permit beyond the default block-list (e.g. scripts) -#UPLOAD_EXTENSIONS_WHITELIST='' # cast=Csv() -#UPLOAD_MIME_TYPES_WHITELIST='' # 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 diff --git a/server/mergin/sync/config.py b/server/mergin/sync/config.py index 3313bc7f..a5c8167a 100644 --- a/server/mergin/sync/config.py +++ b/server/mergin/sync/config.py @@ -86,9 +86,5 @@ class Configuration(object): UPLOAD_EXTENSIONS_WHITELIST = config( "UPLOAD_EXTENSIONS_WHITELIST", default="", cast=Csv() ) - # extra MIME types to permit beyond the default block-list - UPLOAD_MIME_TYPES_WHITELIST = config( - "UPLOAD_MIME_TYPES_WHITELIST", default="", cast=Csv() - ) # max batch size for fetch projects in batch endpoint MAX_BATCH_SIZE = config("MAX_BATCH_SIZE", default=100, cast=int) diff --git a/server/mergin/sync/utils.py b/server/mergin/sync/utils.py index 5843595a..6dd7abe1 100644 --- a/server/mergin/sync/utils.py +++ b/server/mergin/sync/utils.py @@ -315,8 +315,6 @@ def is_supported_extension(filepath) -> bool: if check_skip_validation(filepath): return True ext = os.path.splitext(filepath)[1].lower() - if ext in {e.lower() for e in Configuration.UPLOAD_EXTENSIONS_WHITELIST}: - return True return ext and ext not in FORBIDDEN_EXTENSIONS @@ -465,7 +463,10 @@ def check_skip_validation(file_path: str) -> bool: Some files are allowed even if they have forbidden extension or mime type. """ file_name = os.path.basename(file_path) - return file_name in Configuration.UPLOAD_FILES_WHITELIST + if file_name in Configuration.UPLOAD_FILES_WHITELIST: + return True + ext = os.path.splitext(file_path)[1].lower() + return ext in {e.lower() for e in Configuration.UPLOAD_EXTENSIONS_WHITELIST} FORBIDDEN_MIME_TYPES = { @@ -495,8 +496,6 @@ def is_supported_type(filepath) -> bool: if check_skip_validation(filepath): return True mime_type = get_mimetype(filepath) - if mime_type in Configuration.UPLOAD_MIME_TYPES_WHITELIST: - return True return mime_type.startswith("image/") or mime_type not in FORBIDDEN_MIME_TYPES diff --git a/server/mergin/tests/test_utils.py b/server/mergin/tests/test_utils.py index 8e4192a1..288577b0 100644 --- a/server/mergin/tests/test_utils.py +++ b/server/mergin/tests/test_utils.py @@ -419,15 +419,14 @@ def test_allowed_extensions_override(): assert not is_supported_extension("app.js") -def test_allowed_mime_types_override(): - """MIME types in UPLOAD_MIME_TYPES_WHITELIST are accepted even though they are in FORBIDDEN_MIME_TYPES.""" +def test_extension_whitelist_skips_mime_check(): + """A whitelisted extension also bypasses the MIME check via check_skip_validation.""" with patch("mergin.sync.utils.get_mimetype", return_value="text/x-shellscript"): - # blocked by default - with patch("mergin.sync.utils.Configuration.UPLOAD_MIME_TYPES_WHITELIST", []): + # blocked when the extension is not whitelisted + with patch("mergin.sync.utils.Configuration.UPLOAD_EXTENSIONS_WHITELIST", []): assert not is_supported_type("deploy.sh") - # explicitly allowed + # allowed once the extension is whitelisted with patch( - "mergin.sync.utils.Configuration.UPLOAD_MIME_TYPES_WHITELIST", - ["text/x-shellscript"], + "mergin.sync.utils.Configuration.UPLOAD_EXTENSIONS_WHITELIST", [".sh"] ): assert is_supported_type("deploy.sh") From 96491c7fc050f727e8e521069fd52ba94f71933d Mon Sep 17 00:00:00 2001 From: Martin Varga Date: Mon, 6 Jul 2026 16:44:57 +0200 Subject: [PATCH 11/19] Add audit logging for auth and sync events Implements structured audit event emission for all relevant user and project actions. Update AuditEvent dataclass with new target columns: project_id, workspace_id and user_id. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 + server/mergin/audit/app.py | 18 +- server/mergin/audit/events.py | 13 +- server/mergin/auth/controller.py | 54 ++- server/mergin/auth/events.py | 17 +- server/mergin/auth/listeners.py | 14 +- server/mergin/auth/models.py | 3 + server/mergin/auth/tasks.py | 6 +- server/mergin/sync/events.py | 24 +- server/mergin/sync/listeners.py | 14 +- server/mergin/sync/private_api_controller.py | 41 +- server/mergin/sync/public_api_controller.py | 28 +- .../mergin/sync/public_api_v2_controller.py | 39 +- server/mergin/sync/tasks.py | 6 +- server/mergin/tests/test_audit.py | 99 ----- server/mergin/tests/test_audit_events.py | 410 ++++++++++++++++++ server/mergin/tests/utils.py | 6 + 17 files changed, 587 insertions(+), 208 deletions(-) delete mode 100644 server/mergin/tests/test_audit.py create mode 100644 server/mergin/tests/test_audit_events.py diff --git a/.gitignore b/.gitignore index 5ca81560..486ef6cc 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ docker-compose.local.yml # SSO *.pem *.crt + +# Local Claude Code skills +.claude/commands/ diff --git a/server/mergin/audit/app.py b/server/mergin/audit/app.py index 90cc2597..cba22b89 100644 --- a/server/mergin/audit/app.py +++ b/server/mergin/audit/app.py @@ -25,15 +25,15 @@ def emit( actor_user_agent=None, actor_device_id=None, ip_address=None, - target_id=None, - scope_id=None, + user_id=None, + project_id=None, + workspace_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. + 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, @@ -42,10 +42,10 @@ def emit( 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, + 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) diff --git a/server/mergin/audit/events.py b/server/mergin/audit/events.py index 7f0df84b..81aeefde 100644 --- a/server/mergin/audit/events.py +++ b/server/mergin/audit/events.py @@ -3,6 +3,7 @@ # 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 @@ -14,13 +15,15 @@ @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 - 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 + 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) diff --git a/server/mergin/auth/controller.py b/server/mergin/auth/controller.py index aa242353..aa4d9a82 100644 --- a/server/mergin/auth/controller.py +++ b/server/mergin/auth/controller.py @@ -165,7 +165,7 @@ def login_public(): # noqa: E501 actor_id=user.id, actor_email=user.email, **request_context(), - target_id=str(user.id), + user_id=user.id, ) return data else: @@ -173,6 +173,7 @@ def login_public(): # noqa: E501 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)) @@ -185,13 +186,11 @@ def close_user_account(): shared projects as well clean references to created projects. """ emit( - AuthEventType.USER_CLOSED, + AuthEventType.USER_MARKED_FOR_DELETION, **actor_context(), - target_id=str(current_user.id), + user_id=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 @@ -253,7 +252,7 @@ def login(): # pylint: disable=W0613,W0612 actor_id=user.id, actor_email=user.email, **request_context(), - target_id=str(user.id), + user_id=user.id, ) return "", 200 else: @@ -261,6 +260,7 @@ def login(): # pylint: disable=W0613,W0612 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 @@ -281,7 +281,7 @@ def admin_login(): # pylint: disable=W0613,W0612 actor_id=user.id, actor_email=user.email, **request_context(), - target_id=str(user.id), + user_id=user.id, ) return "", 200 else: @@ -291,17 +291,13 @@ def admin_login(): # pylint: disable=W0613,W0612 AuthEventType.USER_LOGIN_FAILED, **request_context(), login=form.login.data, + reason="invalid_credentials", ) 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 @@ -320,7 +316,7 @@ def change_password(): # pylint: disable=W0613,W0612 emit( AuthEventType.USER_PASSWORD_CHANGED, **actor_context(), - target_id=str(current_user.id), + user_id=current_user.id, ) return "", 200 return jsonify(form.errors), 400 @@ -385,7 +381,7 @@ def confirm_new_password(token): # pylint: disable=W0613,W0612 emit( AuthEventType.USER_PASSWORD_RESET, **request_context(), - target_id=str(user.id), + user_id=user.id, target_email=user.email, ) return "", 200 @@ -503,13 +499,27 @@ 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)) @@ -517,22 +527,20 @@ def update_user(username): # pylint: disable=W0613,W0612 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, + AuthEventType.USER_MARKED_FOR_DELETION, **actor_context(), - target_id=str(user.id), + user_id=user.id, target_email=user.email, ) - db.session.info["audit_skip_user_update"] = True user.inactivate() user_account_closed.send(user) emit( - AuthEventType.USER_ANONYMIZED, + AuthEventType.USER_DELETED, **actor_context(), - target_id=str(user.id), + user_id=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 index fd8787b5..f98d2e3b 100644 --- a/server/mergin/auth/events.py +++ b/server/mergin/auth/events.py @@ -6,15 +6,20 @@ class AuthEventType(str, Enum): - # explicit auth action events + # authentication 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) + # general CRUD (SQLAlchemy listeners) USER_CREATED = "user.created" USER_UPDATED = "user.updated" - # lifecycle events (explicit emit) - USER_CLOSED = "user.closed" - USER_ANONYMIZED = "user.anonymized" + # lifecycle events (explicit emit only; active/inactive_since excluded from user.updated) + USER_MARKED_FOR_DELETION = ( + "user.marked_for_deletion" # user or admin triggers deletion flow + ) + USER_DEACTIVATED = "user.deactivated" # admin sets active=False without deletion + USER_RESTORED = ( + "user.restored" # admin re-activates after deactivation or marked_for_deletion + ) + USER_DELETED = "user.deleted" # personal data permanently erased diff --git a/server/mergin/auth/listeners.py b/server/mergin/auth/listeners.py index 364de3a0..f230a9e9 100644 --- a/server/mergin/auth/listeners.py +++ b/server/mergin/auth/listeners.py @@ -9,17 +9,21 @@ 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). +# Fields excluded from user.updated audit events: +# - sensitive values that must never appear in logs (passwd) +# - high-frequency operational fields (last_signed_in, registration_date) +# - lifecycle state fields covered by dedicated events (active, inactive_since) # frozenset prevents accidental mutation of module-level state. -_SKIP = frozenset({"passwd", "last_signed_in", "registration_date"}) +_SKIP = frozenset( + {"passwd", "last_signed_in", "registration_date", "active", "inactive_since"} +) def _on_user_created(_mapper, _connection, target): emit_safe( AuthEventType.USER_CREATED, **actor_context(), - target_id=str(target.id), + user_id=target.id, target_email=target.email, ) @@ -33,7 +37,7 @@ def _on_user_updated(_mapper, _connection, target): emit_safe( AuthEventType.USER_UPDATED, **actor_context(), - target_id=str(target.id), + user_id=target.id, target_email=target.email, **changes, ) diff --git a/server/mergin/auth/models.py b/server/mergin/auth/models.py index 30d380dc..db1e00ee 100644 --- a/server/mergin/auth/models.py +++ b/server/mergin/auth/models.py @@ -186,12 +186,15 @@ def anonymize(self): """Anonymize user object in database - remove personal information""" ts = round(datetime.datetime.utcnow().timestamp() * 1000) del_str = f"deleted_{ts}" + # Suppress user.updated — these changes are covered by the USER_DELETED event. + db.session.info["audit_skip_user_update"] = True self.username = del_str self.email = None self.passwd = None self.first_name = None self.last_name = None db.session.commit() + db.session.info.pop("audit_skip_user_update", None) @classmethod def get_by_login(cls, login: str) -> Optional[User]: diff --git a/server/mergin/auth/tasks.py b/server/mergin/auth/tasks.py index cdba01f0..79fc8867 100644 --- a/server/mergin/auth/tasks.py +++ b/server/mergin/auth/tasks.py @@ -23,12 +23,10 @@ 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), + AuthEventType.USER_DELETED, + user_id=user.id, target_email=user.email, ) user.anonymize() - db.session.info.pop("audit_skip_user_update", None) diff --git a/server/mergin/sync/events.py b/server/mergin/sync/events.py index aa9fb2e1..16adf104 100644 --- a/server/mergin/sync/events.py +++ b/server/mergin/sync/events.py @@ -7,21 +7,19 @@ class SyncEventType(str, Enum): # automatic CRUD events (SQLAlchemy listeners) - PROJECT_CREATED = "project.created" + PROJECT_CREATED = "project.created" # also emitted on clone PROJECT_UPDATED = "project.updated" # lifecycle events (explicit emit) - PROJECT_REMOVED = "project.removed" - PROJECT_DELETED = "project.deleted" - # lifecycle events (explicit emit) + PROJECT_MARKED_FOR_DELETION = "project.marked_for_deletion" 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" + PROJECT_DELETED = "project.deleted" + # membership events (explicit emit) + PROJECT_MEMBER_ADDED = "project.member.added" + PROJECT_MEMBER_UPDATED = "project.member.updated" + PROJECT_MEMBER_DELETED = "project.member.deleted" + # access request events (explicit emit) + PROJECT_ACCESS_REQUEST_CREATED = "project.access_request.created" + PROJECT_ACCESS_REQUEST_ACCEPTED = "project.access_request.accepted" + PROJECT_ACCESS_REQUEST_REJECTED = "project.access_request.rejected" # 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 index bbe985cc..ec5d3984 100644 --- a/server/mergin/sync/listeners.py +++ b/server/mergin/sync/listeners.py @@ -27,12 +27,14 @@ def _on_project_created(_mapper, _connection, target): + if object_session(target).info.get("audit_skip_project_create"): + return emit_safe( SyncEventType.PROJECT_CREATED, **actor_context(), - target_id=str(target.id), - scope_id=target.workspace_id, - project_name=target.name, + project_id=target.id, + workspace_id=target.workspace_id, + project_name=f"{target.workspace.name}/{target.name}", ) @@ -45,9 +47,9 @@ def _on_project_updated(_mapper, _connection, target): emit_safe( SyncEventType.PROJECT_UPDATED, **actor_context(), - target_id=str(target.id), - scope_id=target.workspace_id, - project_name=target.name, + project_id=target.id, + workspace_id=target.workspace_id, + project_name=f"{target.workspace.name}/{target.name}", **changes, ) diff --git a/server/mergin/sync/private_api_controller.py b/server/mergin/sync/private_api_controller.py index 7de62abe..afb74773 100644 --- a/server/mergin/sync/private_api_controller.py +++ b/server/mergin/sync/private_api_controller.py @@ -66,6 +66,13 @@ def create_project_access_request(namespace, project_name): # noqa: E501 access_request = AccessRequest(project, current_user.id) db.session.add(access_request) db.session.commit() + emit( + SyncEventType.PROJECT_ACCESS_REQUEST_CREATED, + **actor_context(), + project_id=project.id, + workspace_id=project.workspace_id, + project_name=f"{project.workspace.name}/{project.name}", + ) # notify project owners owners = current_app.project_handler.get_email_receivers(project) for owner in owners: @@ -107,12 +114,12 @@ def decline_project_access_request(request_id): # noqa: E501 access_request.resolve(RequestStatus.DECLINED, current_user.id) db.session.commit() emit( - SyncEventType.PROJECT_ACCESS_REQUEST_DECLINED, + SyncEventType.PROJECT_ACCESS_REQUEST_REJECTED, **actor_context(), - target_id=str(project.id), - scope_id=project.workspace_id, + project_id=project.id, + workspace_id=project.workspace_id, target_email=requester.email if requester else None, - project_name=project.name, + project_name=f"{project.workspace.name}/{project.name}", ) return "", 200 abort(403, "You don't have permissions to remove project access request") @@ -142,10 +149,18 @@ def accept_project_access_request(request_id): emit( SyncEventType.PROJECT_ACCESS_REQUEST_ACCEPTED, **actor_context(), - target_id=str(project.id), - scope_id=project.workspace_id, + project_id=project.id, + workspace_id=project.workspace_id, + target_email=requester.email if requester else None, + project_name=f"{project.workspace.name}/{project.name}", + role=permission, + ) + emit( + SyncEventType.PROJECT_MEMBER_ADDED, + **actor_context(), + project_id=project.id, + workspace_id=project.workspace_id, target_email=requester.email if requester else None, - project_name=project.name, role=permission, ) return "", 200 @@ -254,9 +269,9 @@ def restore_project(id): # noqa: E501 emit( SyncEventType.PROJECT_RESTORED, **actor_context(), - target_id=str(project.id), - scope_id=project.workspace_id, - project_name=project.name, + project_id=project.id, + workspace_id=project.workspace_id, + project_name=f"{project.workspace.name}/{project.name}", ) return "", 201 @@ -273,9 +288,9 @@ def force_project_delete(id): # noqa: E501 emit( SyncEventType.PROJECT_DELETED, **actor_context(), - target_id=str(project.id), - scope_id=project.workspace_id, - project_name=project.name, + project_id=project.id, + workspace_id=project.workspace_id, + project_name=f"{project.workspace.name}/{project.name}", ) db.session.info["audit_skip_project_update"] = True project.delete() diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py index 70fcbe15..1bebafb8 100644 --- a/server/mergin/sync/public_api_controller.py +++ b/server/mergin/sync/public_api_controller.py @@ -35,8 +35,11 @@ from mergin.sync.forms import project_name_validation from .interfaces import WorkspaceRole 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 .events import SyncEventType from .models import ( FileSyncErrorType, FileDiff, @@ -221,6 +224,9 @@ def add_project(namespace): # noqa: E501 template_name = request.json.get("template", None) if template_name: + # Set flag before the template query — p is already in the session via the + # workspace backref, so any query triggers autoflush and fires after_insert. + db.session.info["audit_skip_project_create"] = True template = ( Project.query.filter(Project.creator.has(username="TEMPLATES")) .filter(Project.name == template_name) @@ -240,7 +246,6 @@ def add_project(namespace): # noqa: E501 change=PushChangeType.CREATE, ) ) - else: template = None version_name = 0 @@ -264,6 +269,16 @@ def add_project(namespace): # noqa: E501 db.session.add(p) db.session.add(version) db.session.commit() + if template_name: + db.session.info.pop("audit_skip_project_create", None) + emit( + SyncEventType.PROJECT_CREATED, + **actor_context(), + project_id=p.id, + workspace_id=p.workspace_id, + project_name=f"{workspace.name}/{p.name}", + created_from_template=template_name, + ) project_version_created.send(version) return NoContent, 200 @@ -1256,6 +1271,7 @@ def clone_project(namespace, project_name): # noqa: E501 ) p.updated = datetime.utcnow() db.session.add(p) + db.session.info["audit_skip_project_create"] = True files_to_exclude = current_app.config.get("EXCLUDED_CLONE_FILENAMES", []) try: @@ -1296,6 +1312,16 @@ def clone_project(namespace, project_name): # noqa: E501 ) db.session.add(project_version) db.session.commit() + db.session.info.pop("audit_skip_project_create", None) + emit( + SyncEventType.PROJECT_CREATED, + **actor_context(), + project_id=p.id, + workspace_id=p.workspace_id, + project_name=f"{ws.name}/{p.name}", + cloned_from_id=str(cloned_project.id), + cloned_from_name=f"{cp_workspace_name}/{cloned_project.name}", + ) project_version_created.send(project_version) return NoContent, 200 diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index b9b27a5c..cb6c9238 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -78,11 +78,11 @@ def schedule_delete_project(id): """ project = require_project_by_uuid(id, ProjectPermissions.Delete) emit( - SyncEventType.PROJECT_REMOVED, + SyncEventType.PROJECT_MARKED_FOR_DELETION, **actor_context(), - target_id=str(project.id), - scope_id=project.workspace_id, - project_name=project.name, + project_id=project.id, + workspace_id=project.workspace_id, + project_name=f"{project.workspace.name}/{project.name}", ) db.session.info["audit_skip_project_update"] = True project.removed_at = datetime.utcnow() @@ -100,9 +100,9 @@ def delete_project_now(id): emit( SyncEventType.PROJECT_DELETED, **actor_context(), - target_id=str(project.id), - scope_id=project.workspace_id, - project_name=project.name, + project_id=project.id, + workspace_id=project.workspace_id, + project_name=f"{project.workspace.name}/{project.name}", ) db.session.info["audit_skip_project_update"] = True project.delete() @@ -172,10 +172,10 @@ def add_project_collaborator(id): project.set_role(user.id, ProjectRole(request.json["role"])) db.session.commit() emit( - SyncEventType.PROJECT_ACCESS_GRANTED, + SyncEventType.PROJECT_MEMBER_ADDED, **actor_context(), - target_id=str(project.id), - scope_id=project.workspace_id, + project_id=project.id, + workspace_id=project.workspace_id, target_email=user.email, role=request.json["role"], ) @@ -195,10 +195,10 @@ def update_project_collaborator(id, user_id): project.set_role(user.id, ProjectRole(request.json["role"])) db.session.commit() emit( - SyncEventType.PROJECT_ACCESS_UPDATED, + SyncEventType.PROJECT_MEMBER_UPDATED, **actor_context(), - target_id=str(project.id), - scope_id=project.workspace_id, + project_id=project.id, + workspace_id=project.workspace_id, target_email=user.email, old_role=old_role.value, new_role=request.json["role"], @@ -219,10 +219,10 @@ def remove_project_collaborator(id, user_id): project.unset_role(user_id) db.session.commit() emit( - SyncEventType.PROJECT_ACCESS_REVOKED, + SyncEventType.PROJECT_MEMBER_DELETED, **actor_context(), - target_id=str(project.id), - scope_id=project.workspace_id, + project_id=project.id, + workspace_id=project.workspace_id, target_email=user.email if user else None, role=removed_role.value, ) @@ -392,12 +392,9 @@ def create_project_version(id): emit( SyncEventType.PROJECT_VERSION_CREATED, **actor_context(), - target_id=str(project.id), - scope_id=project.workspace_id, + project_id=project.id, + workspace_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 diff --git a/server/mergin/sync/tasks.py b/server/mergin/sync/tasks.py index 1bd2a480..d726de2d 100644 --- a/server/mergin/sync/tasks.py +++ b/server/mergin/sync/tasks.py @@ -70,9 +70,9 @@ def remove_projects_backups(): for p in projects: emit_safe( SyncEventType.PROJECT_DELETED, - target_id=str(p.id), - scope_id=p.workspace_id, - project_name=p.name, + project_id=p.id, + workspace_id=p.workspace_id, + project_name=f"{p.workspace.name}/{p.name}", ) p.delete() db.session.info.pop("audit_skip_project_update", None) diff --git a/server/mergin/tests/test_audit.py b/server/mergin/tests/test_audit.py deleted file mode 100644 index 22f1b2c3..00000000 --- a/server/mergin/tests/test_audit.py +++ /dev/null @@ -1,99 +0,0 @@ -# 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/test_audit_events.py b/server/mergin/tests/test_audit_events.py new file mode 100644 index 00000000..24d3abf3 --- /dev/null +++ b/server/mergin/tests/test_audit_events.py @@ -0,0 +1,410 @@ +# Copyright (C) Lutra Consulting Limited +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial + +"""Contract tests: one test per defined EventType to verify the event is emitted +with the required fields. Each test exercises the minimum code path needed to +trigger the event — it is not a functional test of that path.""" + +from ..app import db +from ..auth.app import generate_confirmation_token +from ..auth.events import AuthEventType +from ..auth.models import User +from ..sync.events import SyncEventType +from ..sync.models import AccessRequest, Project, ProjectRole +from . import DEFAULT_USER, test_project, test_workspace_id +from .utils import add_user, create_project, create_workspace, login + + +# --------------------------------------------------------------------------- +# Auth events +# --------------------------------------------------------------------------- + + +def test_user_login_succeeded(client, audit_capture): + login(client, DEFAULT_USER[0], DEFAULT_USER[1]) + + e = audit_capture.one(AuthEventType.USER_LOGIN_SUCCEEDED) + assert e.actor_email == f"{DEFAULT_USER[0]}@mergin.com" + assert e.user_id == e.actor_id # actor and target are the same person on login + + +def test_user_login_failed_invalid_credentials(client, audit_capture): + client.post( + "/app/auth/login", json={"login": "mergin", "password": "wrongpassword"} + ) + + e = audit_capture.one(AuthEventType.USER_LOGIN_FAILED) + assert e.context["reason"] == "invalid_credentials" + assert e.context["login"] == "mergin" + assert e.actor_id is None + + +def test_user_login_failed_account_inactive(client, audit_capture): + user = add_user("inactive_user", "pass123") + user.active = False + db.session.commit() + + client.post( + "/app/auth/login", json={"login": "inactive_user", "password": "pass123"} + ) + + assert ( + audit_capture.one(AuthEventType.USER_LOGIN_FAILED).context["reason"] + == "account_inactive" + ) + + +def test_user_password_changed(client, audit_capture): + user = add_user("pwduser", "oldpass123") + login(client, "pwduser", "oldpass123") + + client.post( + "/app/auth/change-password", + json={ + "old_password": "oldpass123", + "password": "New#pass456", + "confirm": "New#pass456", + }, + ) + + e = audit_capture.one(AuthEventType.USER_PASSWORD_CHANGED) + assert e.user_id == user.id + assert e.actor_id == user.id + + +def test_user_password_reset(app, client, audit_capture): + user = User.query.filter_by(username=DEFAULT_USER[0]).first() + token = generate_confirmation_token( + app, user.email, app.config["SECURITY_PASSWORD_SALT"] + ) + + client.post( + f"/app/auth/reset-password/{token}", + json={"password": "NewPass#123", "confirm": "NewPass#123"}, + ) + + e = audit_capture.one(AuthEventType.USER_PASSWORD_RESET) + assert e.user_id == user.id + assert e.context["target_email"] == user.email + + +def test_user_created(audit_capture): + user = add_user("newuser", "pass123") + + e = audit_capture.one(AuthEventType.USER_CREATED) + assert e.user_id == user.id + assert e.context["target_email"] == "newuser@mergin.com" + + +def test_user_updated(audit_capture): + user = add_user("editme", "pass123") + db.session.refresh(user) + user.email = "updated@mergin.com" + user.passwd = "newpassword" # in _SKIP — must never appear in audit + db.session.commit() + + e = audit_capture.one(AuthEventType.USER_UPDATED) + assert e.context["new_email"] == "updated@mergin.com" + assert e.context["old_email"] == "editme@mergin.com" + assert "new_passwd" not in e.context + assert "old_passwd" not in e.context + + +def test_listener_null_actor_outside_request(audit_capture): + """Listeners fired from a Celery-like context (no active request) emit null actor + fields — the event is recorded as a system action with no user attributed.""" + add_user(username="systemcreated", password="pass123") + + e = audit_capture.one(AuthEventType.USER_CREATED) + assert e.actor_id is None + assert e.actor_email is None + assert e.ip_address is None + + +def test_user_marked_for_deletion_by_user(client, audit_capture): + user = add_user("selfdelete", "pass123") + login(client, "selfdelete", "pass123") + + client.delete("/v1/user") + + e = audit_capture.one(AuthEventType.USER_MARKED_FOR_DELETION) + assert e.user_id == user.id + assert e.actor_id == user.id + + +def test_user_marked_for_deletion_by_admin(client, audit_capture): + user = add_user("admindelete", "pass123") + + client.delete(f"/app/admin/user/{user.username}") + + assert audit_capture.one(AuthEventType.USER_MARKED_FOR_DELETION).user_id == user.id + + +def test_user_deactivated(client, audit_capture): + user = add_user("todeactivate", "pass123") + + client.patch(f"/app/admin/user/{user.username}", json={"active": False}) + + assert audit_capture.one(AuthEventType.USER_DEACTIVATED).user_id == user.id + + +def test_user_restored(client, audit_capture): + user = add_user("torestore", "pass123") + user.active = False + db.session.commit() + + client.patch(f"/app/admin/user/{user.username}", json={"active": True}) + + assert audit_capture.one(AuthEventType.USER_RESTORED).user_id == user.id + + +def test_user_deleted(client, audit_capture): + user = add_user("todelete", "pass123") + + client.delete(f"/app/admin/user/{user.username}") + + e = audit_capture.one(AuthEventType.USER_DELETED) + assert e.user_id == user.id + assert e.context["target_email"] == "todelete@mergin.com" + + +# --------------------------------------------------------------------------- +# Sync / project events +# --------------------------------------------------------------------------- + + +def test_project_created(audit_capture): + user = add_user("projowner", "pass123") + ws = create_workspace() + project = create_project("myproject", ws, user) + + e = audit_capture.one(SyncEventType.PROJECT_CREATED) + assert e.project_id == project.id + assert e.workspace_id == test_workspace_id + assert e.context["project_name"] == "mergin/myproject" + + +def test_project_created_from_template(client, audit_capture): + # Re-assign test_project's creator to the reserved TEMPLATES user so it + # becomes a template project (this is how the app identifies templates). + template_user = add_user("TEMPLATES", "pass123") + template = Project.query.filter_by( + workspace_id=test_workspace_id, name=test_project + ).first() + template.creator = template_user + db.session.commit() + + client.post( + f"/v1/project/{template.workspace.name}", + json={"name": "from_template", "template": test_project}, + ) + + # filter to the new project only (template re-assignment fires project.updated) + new_project = Project.query.filter_by(name="from_template").first() + events = [ + e + for e in audit_capture.of_type(SyncEventType.PROJECT_CREATED) + if e.project_id == new_project.id + ] + assert len(events) == 1 + assert events[0].context.get("created_from_template") == test_project + + +def test_project_created_from_clone(client, audit_capture): + project = Project.query.filter_by( + workspace_id=test_workspace_id, name=test_project + ).first() + ws = project.workspace + + client.post( + f"/v1/project/clone/{ws.name}/{test_project}", + json={"namespace": ws.name, "project": "cloned_project"}, + ) + + e = audit_capture.one(SyncEventType.PROJECT_CREATED) + assert e.context.get("cloned_from_id") == str(project.id) + assert e.context.get("cloned_from_name") == f"{ws.name}/{test_project}" + + +def test_project_updated(audit_capture): + user = add_user("projupdater", "pass123") + ws = create_workspace() + project = create_project("updateme", ws, user) + db.session.refresh(project) + + project.public = True + db.session.commit() + + e = audit_capture.one(SyncEventType.PROJECT_UPDATED) + assert e.context["new_public"] is True + assert e.context["old_public"] is False + + +def test_project_marked_for_deletion(client, audit_capture): + project = Project.query.filter_by( + workspace_id=test_workspace_id, name=test_project + ).first() + + client.post(f"/v2/projects/{project.id}/scheduleDelete") + + e = audit_capture.one(SyncEventType.PROJECT_MARKED_FOR_DELETION) + assert e.project_id == project.id + assert e.workspace_id == project.workspace_id + + +def test_project_restored(client, audit_capture): + project = Project.query.filter_by( + workspace_id=test_workspace_id, name=test_project + ).first() + client.post(f"/v2/projects/{project.id}/scheduleDelete") + audit_capture.events.clear() + + client.post(f"/app/project/removed-project/restore/{project.id}") + + assert audit_capture.one(SyncEventType.PROJECT_RESTORED).project_id == project.id + + +def test_project_deleted(client, audit_capture): + project = Project.query.filter_by( + workspace_id=test_workspace_id, name=test_project + ).first() + + client.delete(f"/v2/projects/{project.id}") + + assert audit_capture.one(SyncEventType.PROJECT_DELETED).project_id == project.id + + +def test_project_member_added(client, audit_capture): + project = Project.query.filter_by( + workspace_id=test_workspace_id, name=test_project + ).first() + user = add_user("newmember", "pass123") + + client.post( + f"/v2/projects/{project.id}/collaborators", + json={"user": user.email, "role": ProjectRole.READER.value}, + ) + + e = audit_capture.one(SyncEventType.PROJECT_MEMBER_ADDED) + assert e.project_id == project.id + assert e.context["target_email"] == user.email + assert e.context["role"] == ProjectRole.READER.value + + +def test_project_member_updated(client, audit_capture): + project = Project.query.filter_by( + workspace_id=test_workspace_id, name=test_project + ).first() + user = add_user("updatemember", "pass123") + project.set_role(user.id, ProjectRole.READER) + db.session.commit() + + client.patch( + f"/v2/projects/{project.id}/collaborators/{user.id}", + json={"role": ProjectRole.EDITOR.value}, + ) + + e = audit_capture.one(SyncEventType.PROJECT_MEMBER_UPDATED) + assert e.context["old_role"] == ProjectRole.READER.value + assert e.context["new_role"] == ProjectRole.EDITOR.value + + +def test_project_member_deleted(client, audit_capture): + project = Project.query.filter_by( + workspace_id=test_workspace_id, name=test_project + ).first() + user = add_user("removemember", "pass123") + project.set_role(user.id, ProjectRole.READER) + db.session.commit() + + client.delete(f"/v2/projects/{project.id}/collaborators/{user.id}") + + assert ( + audit_capture.one(SyncEventType.PROJECT_MEMBER_DELETED).context["target_email"] + == user.email + ) + + +def test_project_access_request_created(client, audit_capture): + project = Project.query.filter_by( + workspace_id=test_workspace_id, name=test_project + ).first() + user = add_user("requester", "pass123") + login(client, "requester", "pass123") + + client.post(f"/app/project/access-request/{project.workspace.name}/{project.name}") + + e = audit_capture.one(SyncEventType.PROJECT_ACCESS_REQUEST_CREATED) + assert e.project_id == project.id + assert e.actor_id == user.id + + +def test_project_access_request_accepted(client, audit_capture): + project = Project.query.filter_by( + workspace_id=test_workspace_id, name=test_project + ).first() + requester = add_user("acceptrequester", "pass123") + access_request = AccessRequest(project, requester.id) + db.session.add(access_request) + db.session.commit() + + client.post( + f"/app/project/access-request/accept/{access_request.id}", + json={"permissions": "read"}, + ) + + assert ( + audit_capture.one(SyncEventType.PROJECT_ACCESS_REQUEST_ACCEPTED).context[ + "target_email" + ] + == requester.email + ) + # accepting also fires project.member.added + assert ( + audit_capture.one(SyncEventType.PROJECT_MEMBER_ADDED).context["target_email"] + == requester.email + ) + + +def test_project_access_request_rejected(client, audit_capture): + project = Project.query.filter_by( + workspace_id=test_workspace_id, name=test_project + ).first() + requester = add_user("rejectrequester", "pass123") + access_request = AccessRequest(project, requester.id) + db.session.add(access_request) + db.session.commit() + + client.delete(f"/app/project/access-request/{access_request.id}") + + assert ( + audit_capture.one(SyncEventType.PROJECT_ACCESS_REQUEST_REJECTED).context[ + "target_email" + ] + == requester.email + ) + + +def test_project_version_created(client, audit_capture): + from .utils import file_info + from . import test_project_dir + + project = Project.query.filter_by( + workspace_id=test_workspace_id, name=test_project + ).first() + # A remove-only push needs no chunk uploads so it's self-contained. + data = { + "version": "v1", + "changes": { + "added": [], + "updated": [], + "removed": [file_info(test_project_dir, "test3.txt")], + }, + } + resp = client.post(f"/v2/projects/{project.id}/versions", json=data) + assert resp.status_code == 201 + + e = audit_capture.one(SyncEventType.PROJECT_VERSION_CREATED) + assert e.project_id == project.id + assert e.context["version"] == "v2" diff --git a/server/mergin/tests/utils.py b/server/mergin/tests/utils.py index be5d8f37..576f7da5 100644 --- a/server/mergin/tests/utils.py +++ b/server/mergin/tests/utils.py @@ -423,3 +423,9 @@ def write(self, event) -> None: 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] + + def one(self, event_type): + """Assert exactly one event of event_type was captured and return it.""" + events = self.of_type(event_type) + assert len(events) == 1, f"Expected 1 {event_type} event, got {len(events)}" + return events[0] From 691faa5dede79dcb492e1b4b1477e3295d5c1e68 Mon Sep 17 00:00:00 2001 From: Martin Varga Date: Tue, 7 Jul 2026 09:53:15 +0200 Subject: [PATCH 12/19] Add login_method to password login events and project transfer audit events Co-Authored-By: Claude Sonnet 4.6 --- server/mergin/auth/controller.py | 6 ++++++ server/mergin/sync/events.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/server/mergin/auth/controller.py b/server/mergin/auth/controller.py index aa4d9a82..abbd5859 100644 --- a/server/mergin/auth/controller.py +++ b/server/mergin/auth/controller.py @@ -166,6 +166,7 @@ def login_public(): # noqa: E501 actor_email=user.email, **request_context(), user_id=user.id, + login_method="password", ) return data else: @@ -174,6 +175,7 @@ def login_public(): # noqa: E501 **request_context(), login=form.login.data, reason="account_inactive" if user else "invalid_credentials", + login_method="password", ) abort(401, "Invalid username or password") abort(400, _extract_first_error(form.errors)) @@ -253,6 +255,7 @@ def login(): # pylint: disable=W0613,W0612 actor_email=user.email, **request_context(), user_id=user.id, + login_method="password", ) return "", 200 else: @@ -261,6 +264,7 @@ def login(): # pylint: disable=W0613,W0612 **request_context(), login=form.login.data, reason="account_inactive" if user else "invalid_credentials", + login_method="password", ) abort(401, "Invalid username or password") return jsonify(form.errors), 401 @@ -282,6 +286,7 @@ def admin_login(): # pylint: disable=W0613,W0612 actor_email=user.email, **request_context(), user_id=user.id, + login_method="password", ) return "", 200 else: @@ -292,6 +297,7 @@ def admin_login(): # pylint: disable=W0613,W0612 **request_context(), login=form.login.data, reason="invalid_credentials", + login_method="password", ) abort(401, "Invalid username or password") diff --git a/server/mergin/sync/events.py b/server/mergin/sync/events.py index 16adf104..a6e04e6d 100644 --- a/server/mergin/sync/events.py +++ b/server/mergin/sync/events.py @@ -9,6 +9,12 @@ class SyncEventType(str, Enum): # automatic CRUD events (SQLAlchemy listeners) PROJECT_CREATED = "project.created" # also emitted on clone PROJECT_UPDATED = "project.updated" + # transfer request events + PROJECT_TRANSFER_REQUEST_CREATED = "project.transfer_request.created" + PROJECT_TRANSFER_REQUEST_ACCEPTED = "project.transfer_request.accepted" + PROJECT_TRANSFER_REQUEST_REJECTED = "project.transfer_request.rejected" + # transfer event + PROJECT_TRANSFERRED = "project.transferred" # lifecycle events (explicit emit) PROJECT_MARKED_FOR_DELETION = "project.marked_for_deletion" PROJECT_RESTORED = "project.restored" From cc70bba40297b58ffa994aa023d7fd7df3bd5508 Mon Sep 17 00:00:00 2001 From: Martin Varga Date: Tue, 21 Jul 2026 12:21:43 +0200 Subject: [PATCH 13/19] Align AuditEvent fields with DB sink column names Also simplify test_get_projects_by_uuids to avoid the fake-workspace-id hack. Co-Authored-By: Claude Sonnet 4.6 --- server/mergin/app.py | 2 +- server/mergin/audit/app.py | 24 ++++----- server/mergin/audit/events.py | 8 +-- server/mergin/audit/listeners.py | 32 ++++++++---- server/mergin/tests/fixtures.py | 6 +-- server/mergin/tests/test_audit_events.py | 52 +++++++++---------- .../mergin/tests/test_project_controller.py | 15 ++---- 7 files changed, 72 insertions(+), 67 deletions(-) diff --git a/server/mergin/app.py b/server/mergin/app.py index 42308a73..a7c41d1d 100644 --- a/server/mergin/app.py +++ b/server/mergin/app.py @@ -181,7 +181,7 @@ 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 module register_audit(app.app) # register auth blueprint diff --git a/server/mergin/audit/app.py b/server/mergin/audit/app.py index cba22b89..81ba4ee5 100644 --- a/server/mergin/audit/app.py +++ b/server/mergin/audit/app.py @@ -13,39 +13,39 @@ def register(app: Flask) -> None: """Wire the audit module into a Flask app. - Sets NullSink as the default. + Stores the sink in app.extensions["audit"] so emit() has one consistent lookup path. """ - app.audit_sink = NullSink() + app.extensions["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, + actor_ua=None, + actor_device=None, + actor_ip=None, user_id=None, project_id=None, workspace_id=None, - **detail, + **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 context dict. + Extra keyword arguments become the metadata 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, + 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, - context=detail, + metadata=metadata, ) - current_app.audit_sink.write(event) + current_app.extensions["audit"]["sink"].write(event) diff --git a/server/mergin/audit/events.py b/server/mergin/audit/events.py index 81aeefde..5f29261e 100644 --- a/server/mergin/audit/events.py +++ b/server/mergin/audit/events.py @@ -15,15 +15,15 @@ @dataclass(frozen=True) class AuditEvent: event_type: EventType - ip_address: Optional[str] + actor_ip: 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 + 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 - context: Dict[str, Any] = field(default_factory=dict) + metadata: Dict[str, Any] = field(default_factory=dict) diff --git a/server/mergin/audit/listeners.py b/server/mergin/audit/listeners.py index fbf94e86..753eb913 100644 --- a/server/mergin/audit/listeners.py +++ b/server/mergin/audit/listeners.py @@ -9,6 +9,7 @@ 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 @@ -25,11 +26,11 @@ def request_context(): 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_ua=None, actor_device=None, actor_ip=None) return dict( - actor_user_agent=get_user_agent(request), - actor_device_id=get_device_id(request), - ip_address=get_ip(request), + actor_ua=get_user_agent(request), + actor_device=get_device_id(request), + actor_ip=get_ip(request), ) @@ -40,18 +41,31 @@ def actor_context(): """ 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 + 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_/new_ context for all changed non-skipped fields.""" + """Return flat old_/new_ 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 @@ -68,7 +82,7 @@ def emit_safe(event_type, **kwargs): 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"): + if not has_app_context() or "audit" not in current_app.extensions: return try: emit(event_type, **kwargs) diff --git a/server/mergin/tests/fixtures.py b/server/mergin/tests/fixtures.py index 9d0aa240..c1a5ab8f 100644 --- a/server/mergin/tests/fixtures.py +++ b/server/mergin/tests/fixtures.py @@ -18,7 +18,6 @@ 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, ListSink -from ..audit.sinks import NullSink from ..sync.files import files_changes_from_upload thisdir = os.path.dirname(os.path.realpath(__file__)) @@ -103,9 +102,10 @@ def client(app): 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 + old = app.extensions["audit"]["sink"] + app.extensions["audit"]["sink"] = sink yield sink - app.audit_sink = NullSink() + app.extensions["audit"]["sink"] = old @pytest.fixture(scope="function") diff --git a/server/mergin/tests/test_audit_events.py b/server/mergin/tests/test_audit_events.py index 24d3abf3..27edcc14 100644 --- a/server/mergin/tests/test_audit_events.py +++ b/server/mergin/tests/test_audit_events.py @@ -35,8 +35,8 @@ def test_user_login_failed_invalid_credentials(client, audit_capture): ) e = audit_capture.one(AuthEventType.USER_LOGIN_FAILED) - assert e.context["reason"] == "invalid_credentials" - assert e.context["login"] == "mergin" + assert e.metadata["reason"] == "invalid_credentials" + assert e.metadata["login"] == "mergin" assert e.actor_id is None @@ -50,7 +50,7 @@ def test_user_login_failed_account_inactive(client, audit_capture): ) assert ( - audit_capture.one(AuthEventType.USER_LOGIN_FAILED).context["reason"] + audit_capture.one(AuthEventType.USER_LOGIN_FAILED).metadata["reason"] == "account_inactive" ) @@ -86,7 +86,7 @@ def test_user_password_reset(app, client, audit_capture): e = audit_capture.one(AuthEventType.USER_PASSWORD_RESET) assert e.user_id == user.id - assert e.context["target_email"] == user.email + assert e.metadata["target_email"] == user.email def test_user_created(audit_capture): @@ -94,7 +94,7 @@ def test_user_created(audit_capture): e = audit_capture.one(AuthEventType.USER_CREATED) assert e.user_id == user.id - assert e.context["target_email"] == "newuser@mergin.com" + assert e.metadata["target_email"] == "newuser@mergin.com" def test_user_updated(audit_capture): @@ -105,10 +105,10 @@ def test_user_updated(audit_capture): db.session.commit() e = audit_capture.one(AuthEventType.USER_UPDATED) - assert e.context["new_email"] == "updated@mergin.com" - assert e.context["old_email"] == "editme@mergin.com" - assert "new_passwd" not in e.context - assert "old_passwd" not in e.context + assert e.metadata["new_email"] == "updated@mergin.com" + assert e.metadata["old_email"] == "editme@mergin.com" + assert "new_passwd" not in e.metadata + assert "old_passwd" not in e.metadata def test_listener_null_actor_outside_request(audit_capture): @@ -119,7 +119,7 @@ def test_listener_null_actor_outside_request(audit_capture): e = audit_capture.one(AuthEventType.USER_CREATED) assert e.actor_id is None assert e.actor_email is None - assert e.ip_address is None + assert e.actor_ip is None def test_user_marked_for_deletion_by_user(client, audit_capture): @@ -166,7 +166,7 @@ def test_user_deleted(client, audit_capture): e = audit_capture.one(AuthEventType.USER_DELETED) assert e.user_id == user.id - assert e.context["target_email"] == "todelete@mergin.com" + assert e.metadata["target_email"] == "todelete@mergin.com" # --------------------------------------------------------------------------- @@ -182,7 +182,7 @@ def test_project_created(audit_capture): e = audit_capture.one(SyncEventType.PROJECT_CREATED) assert e.project_id == project.id assert e.workspace_id == test_workspace_id - assert e.context["project_name"] == "mergin/myproject" + assert e.metadata["project_name"] == "mergin/myproject" def test_project_created_from_template(client, audit_capture): @@ -208,7 +208,7 @@ def test_project_created_from_template(client, audit_capture): if e.project_id == new_project.id ] assert len(events) == 1 - assert events[0].context.get("created_from_template") == test_project + assert events[0].metadata.get("created_from_template") == test_project def test_project_created_from_clone(client, audit_capture): @@ -223,8 +223,8 @@ def test_project_created_from_clone(client, audit_capture): ) e = audit_capture.one(SyncEventType.PROJECT_CREATED) - assert e.context.get("cloned_from_id") == str(project.id) - assert e.context.get("cloned_from_name") == f"{ws.name}/{test_project}" + assert e.metadata.get("cloned_from_id") == str(project.id) + assert e.metadata.get("cloned_from_name") == f"{ws.name}/{test_project}" def test_project_updated(audit_capture): @@ -237,8 +237,8 @@ def test_project_updated(audit_capture): db.session.commit() e = audit_capture.one(SyncEventType.PROJECT_UPDATED) - assert e.context["new_public"] is True - assert e.context["old_public"] is False + assert e.metadata["new_public"] is True + assert e.metadata["old_public"] is False def test_project_marked_for_deletion(client, audit_capture): @@ -288,8 +288,8 @@ def test_project_member_added(client, audit_capture): e = audit_capture.one(SyncEventType.PROJECT_MEMBER_ADDED) assert e.project_id == project.id - assert e.context["target_email"] == user.email - assert e.context["role"] == ProjectRole.READER.value + assert e.metadata["target_email"] == user.email + assert e.metadata["role"] == ProjectRole.READER.value def test_project_member_updated(client, audit_capture): @@ -306,8 +306,8 @@ def test_project_member_updated(client, audit_capture): ) e = audit_capture.one(SyncEventType.PROJECT_MEMBER_UPDATED) - assert e.context["old_role"] == ProjectRole.READER.value - assert e.context["new_role"] == ProjectRole.EDITOR.value + assert e.metadata["old_role"] == ProjectRole.READER.value + assert e.metadata["new_role"] == ProjectRole.EDITOR.value def test_project_member_deleted(client, audit_capture): @@ -321,7 +321,7 @@ def test_project_member_deleted(client, audit_capture): client.delete(f"/v2/projects/{project.id}/collaborators/{user.id}") assert ( - audit_capture.one(SyncEventType.PROJECT_MEMBER_DELETED).context["target_email"] + audit_capture.one(SyncEventType.PROJECT_MEMBER_DELETED).metadata["target_email"] == user.email ) @@ -355,14 +355,14 @@ def test_project_access_request_accepted(client, audit_capture): ) assert ( - audit_capture.one(SyncEventType.PROJECT_ACCESS_REQUEST_ACCEPTED).context[ + audit_capture.one(SyncEventType.PROJECT_ACCESS_REQUEST_ACCEPTED).metadata[ "target_email" ] == requester.email ) # accepting also fires project.member.added assert ( - audit_capture.one(SyncEventType.PROJECT_MEMBER_ADDED).context["target_email"] + audit_capture.one(SyncEventType.PROJECT_MEMBER_ADDED).metadata["target_email"] == requester.email ) @@ -379,7 +379,7 @@ def test_project_access_request_rejected(client, audit_capture): client.delete(f"/app/project/access-request/{access_request.id}") assert ( - audit_capture.one(SyncEventType.PROJECT_ACCESS_REQUEST_REJECTED).context[ + audit_capture.one(SyncEventType.PROJECT_ACCESS_REQUEST_REJECTED).metadata[ "target_email" ] == requester.email @@ -407,4 +407,4 @@ def test_project_version_created(client, audit_capture): e = audit_capture.one(SyncEventType.PROJECT_VERSION_CREATED) assert e.project_id == project.id - assert e.context["version"] == "v2" + assert e.metadata["version"] == "v2" diff --git a/server/mergin/tests/test_project_controller.py b/server/mergin/tests/test_project_controller.py index d1f1afd6..bf6af511 100644 --- a/server/mergin/tests/test_project_controller.py +++ b/server/mergin/tests/test_project_controller.py @@ -2071,21 +2071,12 @@ def test_get_projects_by_uuids(client): user = User.query.filter_by(username="mergin").first() test_workspace = create_workspace() p1 = create_project("foo", test_workspace, user) - user2 = add_user("user2", "ilovemergin") - test_workspace_2 = create_workspace() - test_workspace_2._id = ( - 2 # FIXME: This should be refactored due to only one workspace in CE - ) - p2 = create_project("foo", test_workspace_2, user2) - uuids = ",".join([str(p1.id), str(p2.id), "1234"]) + uuids = ",".join([str(p1.id), "1234"]) resp = client.get(f"/v1/project/by_uuids?uuids={uuids}") assert resp.status_code == 200 - assert str(p1.id) in resp.json # user has access to - assert ( - str(p2.id) not in resp.json - ) # belongs to user2, and user does not have access - assert "1234" not in resp.json # invalid id + assert str(p1.id) in resp.json + assert "1234" not in resp.json # invalid id is excluded uuids = ",".join([str(uuid.uuid4()) for _ in range(0, 11)]) resp = client.get(f"/v1/project/by_uuids?uuids={uuids}") From f83a49ddd534afc8a843b24a459096be54d69f7f Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Wed, 22 Jul 2026 12:17:49 +0200 Subject: [PATCH 14/19] Display whitespaces in delete dialogs --- .../lib/src/modules/dialog/components/ConfirmDialog.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web-app/packages/lib/src/modules/dialog/components/ConfirmDialog.vue b/web-app/packages/lib/src/modules/dialog/components/ConfirmDialog.vue index 087af8d8..e69030ea 100644 --- a/web-app/packages/lib/src/modules/dialog/components/ConfirmDialog.vue +++ b/web-app/packages/lib/src/modules/dialog/components/ConfirmDialog.vue @@ -9,7 +9,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial Cover for confirm dialog {{ text }} {{ description }} - {{ hint }} + {{ + hint + }}
Date: Wed, 22 Jul 2026 15:00:24 +0200 Subject: [PATCH 15/19] Add missing project.version.created emits --- server/mergin/sync/public_api_controller.py | 29 +++++ server/mergin/tests/test_audit_events.py | 113 ++++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py index 1bebafb8..957adff0 100644 --- a/server/mergin/sync/public_api_controller.py +++ b/server/mergin/sync/public_api_controller.py @@ -279,6 +279,13 @@ def add_project(namespace): # noqa: E501 project_name=f"{workspace.name}/{p.name}", created_from_template=template_name, ) + emit( + SyncEventType.PROJECT_VERSION_CREATED, + **actor_context(), + project_id=p.id, + workspace_id=p.workspace_id, + version=ProjectVersion.to_v_name(version_name), + ) project_version_created.send(version) return NoContent, 200 @@ -998,6 +1005,13 @@ def project_push(namespace, project_name): f"A project version {ProjectVersion.to_v_name(next_version)} for project: {project.id} created. " f"Transaction id: {upload.transaction_id}. No upload." ) + emit( + SyncEventType.PROJECT_VERSION_CREATED, + **actor_context(), + project_id=project.id, + workspace_id=project.workspace_id, + version=ProjectVersion.to_v_name(next_version), + ) project_version_created.send(pv) push_finished.send(pv) return jsonify(ProjectSchema().dump(project)), 200 @@ -1154,6 +1168,13 @@ def push_finish(transaction_id): logging.info( f"Push finished for project: {project.id}, project version: {v_next_version}, transaction id: {transaction_id}." ) + emit( + SyncEventType.PROJECT_VERSION_CREATED, + **actor_context(), + project_id=project.id, + workspace_id=project.workspace_id, + version=v_next_version, + ) project_version_created.send(pv) push_finished.send(pv) except (psycopg2.Error, OSError, IntegrityError) as err: @@ -1322,6 +1343,14 @@ def clone_project(namespace, project_name): # noqa: E501 cloned_from_id=str(cloned_project.id), cloned_from_name=f"{cp_workspace_name}/{cloned_project.name}", ) + if version >= 1: + emit( + SyncEventType.PROJECT_VERSION_CREATED, + **actor_context(), + project_id=p.id, + workspace_id=p.workspace_id, + version=ProjectVersion.to_v_name(version), + ) project_version_created.send(project_version) return NoContent, 200 diff --git a/server/mergin/tests/test_audit_events.py b/server/mergin/tests/test_audit_events.py index 27edcc14..44395fcc 100644 --- a/server/mergin/tests/test_audit_events.py +++ b/server/mergin/tests/test_audit_events.py @@ -408,3 +408,116 @@ def test_project_version_created(client, audit_capture): e = audit_capture.one(SyncEventType.PROJECT_VERSION_CREATED) assert e.project_id == project.id assert e.metadata["version"] == "v2" + + +def test_project_version_created_v1_no_upload(client, audit_capture): + """V1 push with only removals takes the no-upload fast path in project_push.""" + from .utils import file_info + from . import test_project_dir + + project = Project.query.filter_by( + workspace_id=test_workspace_id, name=test_project + ).first() + data = { + "version": "v1", + "changes": { + "added": [], + "updated": [], + "removed": [file_info(test_project_dir, "test3.txt")], + }, + } + resp = client.post( + f"/v1/project/push/{project.workspace.name}/{project.name}", json=data + ) + assert resp.status_code == 200 + + e = audit_capture.one(SyncEventType.PROJECT_VERSION_CREATED) + assert e.project_id == project.id + assert e.metadata["version"] == "v2" + + +def test_project_version_created_v1_push_finish(client, audit_capture): + """V1 push with file uploads goes through push_finish.""" + import os + from .utils import file_info + from . import test_project_dir + + project = Project.query.filter_by( + workspace_id=test_workspace_id, name=test_project + ).first() + filename = "test.qgs" + filepath = os.path.join(test_project_dir, filename) + # "updated" because the fixture already uploaded all test_project_dir files at v1. + data = { + "version": "v1", + "changes": { + "added": [], + "updated": [file_info(test_project_dir, filename)], + "removed": [], + }, + } + resp = client.post( + f"/v1/project/push/{project.workspace.name}/{project.name}", json=data + ) + assert resp.status_code == 200 + upload_id = resp.json["transaction"] + for chunk_id in data["changes"]["updated"][0]["chunks"]: + with open(filepath, "rb") as f: + client.post( + f"/v1/project/push/chunk/{upload_id}/{chunk_id}", + data=f.read(1024), + headers={"Content-Type": "application/octet-stream"}, + ) + resp = client.post(f"/v1/project/push/finish/{upload_id}") + assert resp.status_code == 200 + + e = audit_capture.one(SyncEventType.PROJECT_VERSION_CREATED) + assert e.project_id == project.id + assert e.metadata["version"] == "v2" + + +def test_project_version_created_from_template(client, audit_capture): + """Creating a project from a template emits PROJECT_VERSION_CREATED for the v1.""" + template_user = add_user("TEMPLATES", "pass123") + template = Project.query.filter_by( + workspace_id=test_workspace_id, name=test_project + ).first() + template.creator = template_user + db.session.commit() + audit_capture.events.clear() + + client.post( + f"/v1/project/{template.workspace.name}", + json={"name": "from_template_audit", "template": test_project}, + ) + + new_project = Project.query.filter_by(name="from_template_audit").first() + events = [ + e + for e in audit_capture.of_type(SyncEventType.PROJECT_VERSION_CREATED) + if e.project_id == new_project.id + ] + assert len(events) == 1 + assert events[0].metadata["version"] == "v1" + + +def test_project_version_created_from_clone(client, audit_capture): + """Cloning a non-empty project emits PROJECT_VERSION_CREATED for the v1.""" + project = Project.query.filter_by( + workspace_id=test_workspace_id, name=test_project + ).first() + ws = project.workspace + + client.post( + f"/v1/project/clone/{ws.name}/{test_project}", + json={"namespace": ws.name, "project": "cloned_audit"}, + ) + + cloned = Project.query.filter_by(name="cloned_audit").first() + events = [ + e + for e in audit_capture.of_type(SyncEventType.PROJECT_VERSION_CREATED) + if e.project_id == cloned.id + ] + assert len(events) == 1 + assert events[0].metadata["version"] == "v1" From de772e51836b32d0055dd1ea2a58fcf762382ef0 Mon Sep 17 00:00:00 2001 From: Martin Varga Date: Fri, 24 Jul 2026 11:35:54 +0200 Subject: [PATCH 16/19] Send email notification with self-service unlock link on account lockout Co-Authored-By: Claude Sonnet 5 --- deployment/community/.env.template | 3 + deployment/enterprise/.env.template | 3 + server/.test.env | 1 + server/mergin/.env | 1 + server/mergin/auth/api.yaml | 20 ++++ server/mergin/auth/app.py | 62 +++++++++++- server/mergin/auth/config.py | 1 + server/mergin/auth/controller.py | 21 ++++ server/mergin/auth/models.py | 8 +- .../templates/email/account_locked.html | 15 +++ .../templates/email/components/base.html | 2 + server/mergin/tests/test_auth.py | 95 ++++++++++++++++++- web-app/packages/app/src/router.ts | 8 ++ .../packages/lib/src/modules/user/routes.ts | 2 + .../packages/lib/src/modules/user/userApi.ts | 4 + .../modules/user/views/AccountUnlockView.vue | 65 +++++++++++++ .../lib/src/modules/user/views/index.ts | 1 + 17 files changed, 307 insertions(+), 5 deletions(-) create mode 100644 server/mergin/templates/email/account_locked.html create mode 100644 web-app/packages/lib/src/modules/user/views/AccountUnlockView.vue diff --git a/deployment/community/.env.template b/deployment/community/.env.template index 95497b68..cfb526ac 100644 --- a/deployment/community/.env.template +++ b/deployment/community/.env.template @@ -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 diff --git a/deployment/enterprise/.env.template b/deployment/enterprise/.env.template index 49a235cc..ebdb8716 100644 --- a/deployment/enterprise/.env.template +++ b/deployment/enterprise/.env.template @@ -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 diff --git a/server/.test.env b/server/.test.env index 7545a7ce..0ab2ce8a 100644 --- a/server/.test.env +++ b/server/.test.env @@ -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 \ No newline at end of file diff --git a/server/mergin/.env b/server/mergin/.env index 0ff3dc42..ec45d5a1 100644 --- a/server/mergin/.env +++ b/server/mergin/.env @@ -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 diff --git a/server/mergin/auth/api.yaml b/server/mergin/auth/api.yaml index a4c7b637..fdcba129 100644 --- a/server/mergin/auth/api.yaml +++ b/server/mergin/auth/api.yaml @@ -472,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 diff --git a/server/mergin/auth/app.py b/server/mergin/auth/app.py index 00575a5f..3b3788f8 100644 --- a/server/mergin/auth/app.py +++ b/server/mergin/auth/app.py @@ -120,8 +120,10 @@ def authenticate(login, password): db.session.commit() return user else: - user.record_failed_login() + duration = user.record_failed_login() db.session.commit() + if duration is not None: + send_account_locked_email(current_app, user, duration) return None @@ -163,3 +165,61 @@ def send_confirmation_email(app, user, url, template, header, **kwargs): "sender": app.config["MAIL_DEFAULT_SENDER"], } send_email_async.delay(**email_data) + + +def generate_unlock_token(app, user): + """Sign a token binding the current lock episode (email + locked_until) to the user.""" + serializer = URLSafeTimedSerializer(app.config["SECRET_KEY"]) + payload = { + "email": user.email, + "locked_until": user.locked_until.replace(microsecond=0).isoformat(), + } + return serializer.dumps(payload, salt=app.config["SECURITY_UNLOCK_SALT"]) + + +def confirm_unlock_token(token, expiration=24 * 3600): + serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"]) + try: + payload = serializer.loads( + token, salt=current_app.config["SECURITY_UNLOCK_SALT"], max_age=expiration + ) + except Exception: + return None + return payload + + +def _format_lockout_duration(seconds: int) -> str: + """Humanize a lockout duration, e.g. 300 -> "5 minutes", 3600 -> "1 hour".""" + minutes, secs = divmod(int(seconds), 60) + hours, minutes = divmod(minutes, 60) + parts = [] + if hours: + parts.append(f"{hours} hour{'s' if hours != 1 else ''}") + if minutes: + parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}") + if not parts: + parts.append(f"{secs} second{'s' if secs != 1 else ''}") + return " ".join(parts) + + +def send_account_locked_email(app, user, duration_seconds): + """Notify user their account was locked out and give them a link to unlock it.""" + from ..celery import send_email_async + + token = generate_unlock_token(app, user) + confirm_url = f"unlock-account/{token}" + html = render_template( + "email/account_locked.html", + subject="Account locked", + confirm_url=confirm_url, + user=user, + lockout_duration=_format_lockout_duration(duration_seconds), + locked_until=user.locked_until, + ) + email_data = { + "subject": "Account locked", + "html": html, + "recipients": [user.email], + "sender": app.config["MAIL_DEFAULT_SENDER"], + } + send_email_async.delay(**email_data) diff --git a/server/mergin/auth/config.py b/server/mergin/auth/config.py index 3d2215ee..0e9c81ca 100644 --- a/server/mergin/auth/config.py +++ b/server/mergin/auth/config.py @@ -9,6 +9,7 @@ class Configuration(object): SECURITY_BEARER_SALT = config("SECURITY_BEARER_SALT") SECURITY_EMAIL_SALT = config("SECURITY_EMAIL_SALT") SECURITY_PASSWORD_SALT = config("SECURITY_PASSWORD_SALT") + SECURITY_UNLOCK_SALT = config("SECURITY_UNLOCK_SALT") BEARER_TOKEN_EXPIRATION = config( "BEARER_TOKEN_EXPIRATION", default=3600 * 12, cast=int ) # in seconds diff --git a/server/mergin/auth/controller.py b/server/mergin/auth/controller.py index 474696cc..b0c06a21 100644 --- a/server/mergin/auth/controller.py +++ b/server/mergin/auth/controller.py @@ -18,6 +18,7 @@ send_confirmation_email, confirm_token, generate_confirmation_token, + confirm_unlock_token, user_created, user_account_closed, edit_profile_enabled, @@ -43,6 +44,7 @@ EMAIL_CONFIRMATION_EXPIRATION = 12 * 3600 +ACCOUNT_UNLOCK_TOKEN_EXPIRATION = 24 * 3600 # public endpoints @@ -363,6 +365,25 @@ def confirm_email(token): # pylint: disable=W0613,W0612 return "", 200 +def unlock_account(token): # pylint: disable=W0613,W0612 + payload = confirm_unlock_token(token, expiration=ACCOUNT_UNLOCK_TOKEN_EXPIRATION) + if not payload: + abort(400, "Invalid or expired link") + + user = User.query.filter_by(email=payload["email"]).first_or_404() + stale = ( + not user.is_locked_out() + or user.locked_until.replace(microsecond=0).isoformat() + != payload["locked_until"] + ) + if stale: + abort(400, "This unlock link is no longer valid") + + user.reset_lockout() + db.session.commit() + return "", 200 + + @auth_required @edit_profile_enabled def update_user_profile(): # pylint: disable=W0613,W0612 diff --git a/server/mergin/auth/models.py b/server/mergin/auth/models.py index e8cfbd90..e3f2ae90 100644 --- a/server/mergin/auth/models.py +++ b/server/mergin/auth/models.py @@ -102,8 +102,11 @@ def is_locked_out(self) -> bool: return False return self.locked_until > datetime.datetime.utcnow() - def record_failed_login(self) -> None: - """Increment the failed-login counter and apply a lockout if a threshold is crossed.""" + def record_failed_login(self) -> Optional[int]: + """Increment the failed-login counter and apply a lockout if a threshold is crossed. + + Returns the lockout duration in seconds if a new lock was just applied, else None. + """ self.failed_login_attempts = (self.failed_login_attempts or 0) + 1 policy = _parse_lockout_policy( current_app.config.get("LOCKOUT_POLICY", "5:300,10:3600") @@ -117,6 +120,7 @@ def record_failed_login(self) -> None: self.locked_until = datetime.datetime.utcnow() + datetime.timedelta( seconds=duration ) + return duration def reset_lockout(self) -> None: """Clear lockout state after a successful login.""" diff --git a/server/mergin/templates/email/account_locked.html b/server/mergin/templates/email/account_locked.html new file mode 100644 index 00000000..e94efc87 --- /dev/null +++ b/server/mergin/templates/email/account_locked.html @@ -0,0 +1,15 @@ + + +{% set base_url = config['MERGIN_BASE_URL'] %} +{% extends "email/components/content.html" %} +{% block html %} +

Dear {{ user.username }},


+

Your account has been temporarily locked for {{ lockout_duration }} after several failed login attempts. If this wasn't you, someone may be trying to access your account - consider changing your password once you're back in.

+

You will be able to log in again at {{ locked_until.strftime('%Y-%m-%d %H:%M') }} UTC, or you can unlock your account right now by following this link:

+

{{ base_url }}/{{ confirm_url }}

+{% endblock %} +{% block notifications_footer %}{% endblock %} diff --git a/server/mergin/templates/email/components/base.html b/server/mergin/templates/email/components/base.html index ec1d066c..4fc8d222 100644 --- a/server/mergin/templates/email/components/base.html +++ b/server/mergin/templates/email/components/base.html @@ -221,6 +221,7 @@

+ {% block notifications_footer %}
@@ -230,6 +231,7 @@

+ {% endblock %} diff --git a/server/mergin/tests/test_auth.py b/server/mergin/tests/test_auth.py index 3554152b..483c33c6 100644 --- a/server/mergin/tests/test_auth.py +++ b/server/mergin/tests/test_auth.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial from datetime import datetime, timedelta, timezone +from types import SimpleNamespace import time import itsdangerous import pytest @@ -13,7 +14,11 @@ from ..auth.bearer import decode_token, encode_token from ..auth.forms import ResetPasswordForm -from ..auth.app import generate_confirmation_token, confirm_token +from ..auth.app import ( + generate_confirmation_token, + confirm_token, + generate_unlock_token, +) from ..auth.models import User, LoginHistory from ..auth.tasks import anonymize_removed_users from ..app import db @@ -94,7 +99,8 @@ def test_logout(client): assert resp.status_code == 200 -def test_login_lockout(client): +@patch("mergin.celery.send_email_async.apply_async") +def test_login_lockout(send_email_mock, client): """Test account lockout: progressive tiers, freeze during lock, reset on success. policy: 3 failures → 60s lock, 4 failures → 3600s lock @@ -120,6 +126,9 @@ def assert_locked(): ) assert resp.status_code == 401 + # lockout email dispatched exactly once, at the moment the lock triggers + assert send_email_mock.call_count == 1 + assert_locked() # correct password is also blocked while locked @@ -133,6 +142,9 @@ def assert_locked(): assert user.failed_login_attempts == 3 assert user.locked_until is not None + # no further emails while already locked out (attempts above were all 423s) + assert send_email_mock.call_count == 1 + # tier 2 escalation: one more failure after tier-1 expiry # counter was at 3; one new failure pushes it to 4, crossing tier-2 threshold @@ -150,6 +162,9 @@ def assert_locked(): assert user.locked_until > datetime.utcnow() + timedelta(seconds=60) assert user.failed_login_attempts == 4 + # second lockout email dispatched for the tier-2 re-lock + assert send_email_mock.call_count == 2 + # successful login after expiry resets everything user.locked_until = datetime.utcnow() - timedelta(seconds=1) db.session.commit() @@ -161,6 +176,82 @@ def assert_locked(): assert user.failed_login_attempts == 0 assert user.locked_until is None + # no email on successful login + assert send_email_mock.call_count == 2 + + +@patch("mergin.celery.send_email_async.apply_async") +def test_unlock_account(send_email_mock, client, app): + """Test the self-service unlock-account link: valid use, reuse, natural + expiry, and cross-tier reuse, per the token-binding design.""" + client.application.config["LOCKOUT_POLICY"] = "3:60,4:3600" + user = add_user("unlockuser", "correctpassword") + + def unlock(token): + return client.post( + url_for("/.mergin_auth_controller_unlock_account", token=token) + ) + + def lock_out(): + for _ in range(3): + client.post( + url_for("/.mergin_auth_controller_login"), + json={"login": "unlockuser", "password": "wrong"}, + ) + + # unknown user -> 404 + fake_user = SimpleNamespace(email="nope@x.com", locked_until=datetime.utcnow()) + resp = unlock(generate_unlock_token(app, fake_user)) + assert resp.status_code == 404 + + # tamper with a valid-looking token -> 400 + resp = unlock("not-a-real-token") + assert resp.status_code == 400 + + # trigger tier-1 lock and capture its token + lock_out() + assert user.is_locked_out() + tier1_token = generate_unlock_token(app, user) + + # valid token unlocks successfully + resp = unlock(tier1_token) + assert resp.status_code == 200 + assert user.failed_login_attempts == 0 + assert user.locked_until is None + + # reuse of the same (now-consumed) token fails + resp = unlock(tier1_token) + assert resp.status_code == 400 + + # naturally-expired lock: token itself still cryptographically valid, + # but the lock episode it points to is no longer active + lock_out() + assert user.is_locked_out() + stale_token = generate_unlock_token(app, user) + user.locked_until = datetime.utcnow() - timedelta(seconds=1) + db.session.commit() + resp = unlock(stale_token) + assert resp.status_code == 400 + + # cross-tier reuse: a token minted for one lock episode must not unlock + # a later, different lock episode for the same user + user.locked_until = None + user.failed_login_attempts = 0 + db.session.commit() + lock_out() + tier1_token_2 = generate_unlock_token(app, user) + # escalate to tier 2 with a new locked_until + user.locked_until = datetime.utcnow() - timedelta(seconds=1) + db.session.commit() + client.post( + url_for("/.mergin_auth_controller_login"), + json={"login": "unlockuser", "password": "wrong"}, + ) + assert user.failed_login_attempts == 4 + assert user.locked_until > datetime.utcnow() + timedelta(seconds=60) + resp = unlock(tier1_token_2) + assert resp.status_code == 400 + def test_bcrypt_lazy_rehash(app): """Password is transparently rehashed on login when the cost factor changes.""" diff --git a/web-app/packages/app/src/router.ts b/web-app/packages/app/src/router.ts index 4fb00555..04888ba0 100644 --- a/web-app/packages/app/src/router.ts +++ b/web-app/packages/app/src/router.ts @@ -3,6 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial import { + AccountUnlockView, ChangePasswordView, FileBrowserView, FileVersionDetailView, @@ -81,6 +82,13 @@ export const createRouter = (pinia: Pinia) => { props: true, meta: { public: true } }, + { + path: '/unlock-account/:token', + name: UserRouteName.UnlockAccount, + component: AccountUnlockView, + props: true, + meta: { public: true } + }, { path: '/dashboard', name: DashboardRouteName.Dashboard, diff --git a/web-app/packages/lib/src/modules/user/routes.ts b/web-app/packages/lib/src/modules/user/routes.ts index d2a52edd..6a562e55 100644 --- a/web-app/packages/lib/src/modules/user/routes.ts +++ b/web-app/packages/lib/src/modules/user/routes.ts @@ -16,6 +16,7 @@ export enum UserRouteName { Login = 'login', ConfirmEmail = 'confirm_email', ChangePassword = 'change_password', + UnlockAccount = 'unlock_account', UserProfile = 'user_profile' } @@ -29,6 +30,7 @@ export const getUserTitle = (route: RouteLocationNormalizedLoaded) => { ], [UserRouteName.ConfirmEmail]: ['Confirm email address', DEFAULT_PAGE_TITLE], [UserRouteName.ChangePassword]: ['Change password', DEFAULT_PAGE_TITLE], + [UserRouteName.UnlockAccount]: ['Account unlock', DEFAULT_PAGE_TITLE], [UserRouteName.UserProfile]: ['Your profile'] } return titles[name] diff --git a/web-app/packages/lib/src/modules/user/userApi.ts b/web-app/packages/lib/src/modules/user/userApi.ts index 1ecab091..bd9cb169 100644 --- a/web-app/packages/lib/src/modules/user/userApi.ts +++ b/web-app/packages/lib/src/modules/user/userApi.ts @@ -72,6 +72,10 @@ export const UserApi = { return UserModule.httpService.get('/app/auth/resend-confirm-email') }, + unlockAccount: (token: string): Promise> => { + return UserModule.httpService.post(`/app/auth/unlock-account/${token}`) + }, + login: (data: LoginData): Promise> => UserModule.httpService.post('/app/auth/login', data), diff --git a/web-app/packages/lib/src/modules/user/views/AccountUnlockView.vue b/web-app/packages/lib/src/modules/user/views/AccountUnlockView.vue new file mode 100644 index 00000000..66186fc8 --- /dev/null +++ b/web-app/packages/lib/src/modules/user/views/AccountUnlockView.vue @@ -0,0 +1,65 @@ + + + + + + + diff --git a/web-app/packages/lib/src/modules/user/views/index.ts b/web-app/packages/lib/src/modules/user/views/index.ts index 44df67bf..2498d990 100644 --- a/web-app/packages/lib/src/modules/user/views/index.ts +++ b/web-app/packages/lib/src/modules/user/views/index.ts @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial +export { default as AccountUnlockView } from './AccountUnlockView.vue' export { default as ChangePasswordView } from './ChangePasswordView.vue' export { default as LoginViewTemplate } from './LoginViewTemplate.vue' export { default as ProfileViewTemplate } from './ProfileViewTemplate.vue' From 56626f645a2b39cba891303329a0f9a4c5100a64 Mon Sep 17 00:00:00 2001 From: Martin Varga Date: Mon, 27 Jul 2026 08:42:16 +0200 Subject: [PATCH 17/19] fix account lockout component --- .../packages/lib/src/modules/user/routes.ts | 2 +- .../modules/user/views/AccountUnlockView.vue | 42 ++++++++----------- 2 files changed, 18 insertions(+), 26 deletions(-) diff --git a/web-app/packages/lib/src/modules/user/routes.ts b/web-app/packages/lib/src/modules/user/routes.ts index 6a562e55..d31a7062 100644 --- a/web-app/packages/lib/src/modules/user/routes.ts +++ b/web-app/packages/lib/src/modules/user/routes.ts @@ -30,7 +30,7 @@ export const getUserTitle = (route: RouteLocationNormalizedLoaded) => { ], [UserRouteName.ConfirmEmail]: ['Confirm email address', DEFAULT_PAGE_TITLE], [UserRouteName.ChangePassword]: ['Change password', DEFAULT_PAGE_TITLE], - [UserRouteName.UnlockAccount]: ['Account unlock', DEFAULT_PAGE_TITLE], + [UserRouteName.UnlockAccount]: ['Unlock your account', DEFAULT_PAGE_TITLE], [UserRouteName.UserProfile]: ['Your profile'] } return titles[name] diff --git a/web-app/packages/lib/src/modules/user/views/AccountUnlockView.vue b/web-app/packages/lib/src/modules/user/views/AccountUnlockView.vue index 66186fc8..05bec944 100644 --- a/web-app/packages/lib/src/modules/user/views/AccountUnlockView.vue +++ b/web-app/packages/lib/src/modules/user/views/AccountUnlockView.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial