lockbot 2.3.3__tar.gz → 2.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {lockbot-2.3.3/python/lockbot.egg-info → lockbot-2.5.0}/PKG-INFO +3 -2
  2. {lockbot-2.3.3 → lockbot-2.5.0}/pyproject.toml +3 -2
  3. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/backend/app/admin/router.py +71 -1
  4. lockbot-2.5.0/python/lockbot/backend/app/audit/models.py +37 -0
  5. lockbot-2.5.0/python/lockbot/backend/app/audit/router.py +141 -0
  6. lockbot-2.5.0/python/lockbot/backend/app/audit/service.py +72 -0
  7. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/backend/app/auth/router.py +39 -3
  8. lockbot-2.5.0/python/lockbot/backend/app/bots/manager.py +154 -0
  9. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/backend/app/bots/router.py +116 -1
  10. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/backend/app/bots/schemas.py +42 -1
  11. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/backend/app/config.py +4 -0
  12. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/backend/app/main.py +27 -3
  13. lockbot-2.5.0/python/lockbot/backend/app/rate_limit.py +20 -0
  14. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/core/base_bot.py +22 -31
  15. lockbot-2.5.0/python/lockbot/core/bot_instance.py +79 -0
  16. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/core/device_bot.py +36 -13
  17. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/core/i18n/en.py +2 -0
  18. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/core/i18n/zh.py +1 -0
  19. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/core/node_bot.py +34 -10
  20. lockbot-2.5.0/python/lockbot/core/platforms/__init__.py +0 -0
  21. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/core/queue_bot.py +44 -9
  22. lockbot-2.5.0/python/lockbot/core/scheduler.py +202 -0
  23. {lockbot-2.3.3 → lockbot-2.5.0/python/lockbot.egg-info}/PKG-INFO +3 -2
  24. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot.egg-info/SOURCES.txt +6 -0
  25. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot.egg-info/requires.txt +2 -1
  26. {lockbot-2.3.3 → lockbot-2.5.0}/tools/create_super_admin.py +9 -9
  27. {lockbot-2.3.3 → lockbot-2.5.0}/tools/gen_keys.py +3 -1
  28. lockbot-2.3.3/python/lockbot/backend/app/bots/manager.py +0 -106
  29. lockbot-2.3.3/python/lockbot/core/bot_instance.py +0 -49
  30. {lockbot-2.3.3 → lockbot-2.5.0}/LICENSE +0 -0
  31. {lockbot-2.3.3 → lockbot-2.5.0}/MANIFEST.in +0 -0
  32. {lockbot-2.3.3 → lockbot-2.5.0}/README.md +0 -0
  33. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/__init__.py +0 -0
  34. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/backend/__init__.py +0 -0
  35. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/backend/app/__init__.py +0 -0
  36. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/backend/app/admin/__init__.py +0 -0
  37. {lockbot-2.3.3/python/lockbot/backend/app/auth → lockbot-2.5.0/python/lockbot/backend/app/audit}/__init__.py +0 -0
  38. {lockbot-2.3.3/python/lockbot/backend/app/bots → lockbot-2.5.0/python/lockbot/backend/app/auth}/__init__.py +0 -0
  39. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/backend/app/auth/dependencies.py +0 -0
  40. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/backend/app/auth/models.py +0 -0
  41. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/backend/app/auth/schemas.py +0 -0
  42. {lockbot-2.3.3/python/lockbot/backend/app/logs → lockbot-2.5.0/python/lockbot/backend/app/bots}/__init__.py +0 -0
  43. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/backend/app/bots/encryption.py +0 -0
  44. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/backend/app/bots/models.py +0 -0
  45. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/backend/app/bots/webhook_handler.py +0 -0
  46. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/backend/app/database.py +0 -0
  47. {lockbot-2.3.3/python/lockbot/backend/app/settings → lockbot-2.5.0/python/lockbot/backend/app/logs}/__init__.py +0 -0
  48. {lockbot-2.3.3/python/lockbot/core → lockbot-2.5.0/python/lockbot/backend/app/settings}/__init__.py +0 -0
  49. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/backend/app/settings/models.py +0 -0
  50. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/backend/app/settings/router.py +0 -0
  51. {lockbot-2.3.3/python/lockbot/core/platforms → lockbot-2.5.0/python/lockbot/core}/__init__.py +0 -0
  52. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/core/config.py +0 -0
  53. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/core/device_usage_alert.py +0 -0
  54. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/core/device_usage_utils.py +0 -0
  55. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/core/entry.py +0 -0
  56. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/core/env.py +0 -0
  57. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/core/handler.py +0 -0
  58. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/core/i18n/__init__.py +0 -0
  59. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/core/io.py +0 -0
  60. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/core/message_adapter.py +0 -0
  61. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/core/msg_utils.py +0 -0
  62. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/core/platforms/infoflow.py +0 -0
  63. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/core/request.py +0 -0
  64. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot/core/utils.py +0 -0
  65. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot.egg-info/dependency_links.txt +0 -0
  66. {lockbot-2.3.3 → lockbot-2.5.0}/python/lockbot.egg-info/top_level.txt +0 -0
  67. {lockbot-2.3.3 → lockbot-2.5.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lockbot
3
- Version: 2.3.3
3
+ Version: 2.5.0
4
4
  Summary: Cluster resource management bot for IM platforms
5
5
  Author-email: Jianbang Yang <yangjianbang112@gmail.com>
6
6
  License: MIT
@@ -33,9 +33,10 @@ Requires-Dist: cryptography
33
33
  Requires-Dist: bcrypt
34
34
  Requires-Dist: python-multipart
35
35
  Requires-Dist: httpx
36
+ Requires-Dist: slowapi
36
37
  Provides-Extra: dev
37
38
  Requires-Dist: pytest>=7.0; extra == "dev"
38
- Requires-Dist: ruff>=0.1.0; extra == "dev"
39
+ Requires-Dist: ruff==0.15.10; extra == "dev"
39
40
  Dynamic: license-file
40
41
 
41
42
  # lockbot
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lockbot"
7
- version = "2.3.3"
7
+ version = "2.5.0"
8
8
  description = "Cluster resource management bot for IM platforms"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -41,12 +41,13 @@ dependencies = [
41
41
  "bcrypt",
42
42
  "python-multipart",
43
43
  "httpx",
44
+ "slowapi",
44
45
  ]
45
46
 
46
47
  [project.optional-dependencies]
47
48
  dev = [
48
49
  "pytest>=7.0",
49
- "ruff>=0.1.0",
50
+ "ruff==0.15.10",
50
51
  ]
51
52
 
52
53
  [project.urls]
@@ -11,11 +11,12 @@ import tempfile
11
11
  import zipfile
12
12
  from datetime import datetime
13
13
 
14
- from fastapi import APIRouter, Depends, HTTPException
14
+ from fastapi import APIRouter, Depends, HTTPException, Request
15
15
  from fastapi.responses import FileResponse, Response
16
16
  from sqlalchemy.orm import Session
17
17
  from starlette.background import BackgroundTask
18
18
 
19
+ from lockbot.backend.app.audit.service import write_audit_log
19
20
  from lockbot.backend.app.auth.dependencies import (
20
21
  can_assign_role,
21
22
  can_create_user_with_role,
@@ -40,6 +41,7 @@ router = APIRouter(prefix="/api/admin", tags=["admin"])
40
41
 
41
42
  @router.post("/users", response_model=PasswordResetOut, status_code=201)
42
43
  def admin_create_user(
44
+ request: Request,
43
45
  body: AdminCreateUser,
44
46
  operator: User = Depends(require_admin),
45
47
  db: Session = Depends(get_db),
@@ -67,6 +69,17 @@ def admin_create_user(
67
69
  must_change_password=True,
68
70
  )
69
71
  db.add(user)
72
+ db.flush()
73
+ write_audit_log(
74
+ db,
75
+ operator,
76
+ "user.create",
77
+ target_type="user",
78
+ target_id=user.id,
79
+ target_name=user.username,
80
+ detail={"role": body.role},
81
+ ip=request.client.host if request.client else None,
82
+ )
70
83
  db.commit()
71
84
  db.refresh(user)
72
85
  return PasswordResetOut(id=user.id, username=user.username, new_password=raw_password)
@@ -108,6 +121,7 @@ def list_users(
108
121
  def admin_edit_user(
109
122
  user_id: int,
110
123
  body: AdminEditUser,
124
+ request: Request,
111
125
  operator: User = Depends(require_admin),
112
126
  db: Session = Depends(get_db),
113
127
  ):
@@ -120,16 +134,19 @@ def admin_edit_user(
120
134
  if not can_manage_user(operator, target.role):
121
135
  raise HTTPException(status_code=403, detail="Cannot manage this user")
122
136
 
137
+ changes: dict = {}
123
138
  if body.username is not None and body.username != target.username:
124
139
  dup = db.query(User).filter(User.username == body.username).first()
125
140
  if dup:
126
141
  raise HTTPException(status_code=409, detail="Username already taken")
142
+ changes["username"] = {"old": target.username, "new": body.username}
127
143
  target.username = body.username
128
144
 
129
145
  if body.email is not None and body.email != target.email:
130
146
  dup = db.query(User).filter(User.email == body.email).first()
131
147
  if dup:
132
148
  raise HTTPException(status_code=409, detail="Email already taken")
149
+ changes["email"] = {"old": target.email, "new": body.email}
133
150
  target.email = body.email
134
151
 
135
152
  if body.role is not None:
@@ -139,12 +156,24 @@ def admin_edit_user(
139
156
  raise HTTPException(status_code=status_code, detail=error_msg)
140
157
  # Force logout when role changes
141
158
  if target.role != body.role:
159
+ changes["role"] = {"old": target.role, "new": body.role}
142
160
  target.token_version = target.effective_token_version + 1
143
161
  target.role = body.role
144
162
 
145
163
  if body.max_running_bots is not None:
164
+ changes["max_running_bots"] = {"old": target.max_running_bots, "new": body.max_running_bots}
146
165
  target.max_running_bots = body.max_running_bots
147
166
 
167
+ write_audit_log(
168
+ db,
169
+ operator,
170
+ "user.edit",
171
+ target_type="user",
172
+ target_id=target.id,
173
+ target_name=target.username,
174
+ detail=changes,
175
+ ip=request.client.host if request.client else None,
176
+ )
148
177
  db.commit()
149
178
  db.refresh(target)
150
179
  return target
@@ -154,6 +183,7 @@ def admin_edit_user(
154
183
  def set_max_bots(
155
184
  user_id: int,
156
185
  body: dict,
186
+ request: Request,
157
187
  operator: User = Depends(require_admin),
158
188
  db: Session = Depends(get_db),
159
189
  ):
@@ -162,7 +192,18 @@ def set_max_bots(
162
192
  raise HTTPException(status_code=404, detail="User not found")
163
193
  if not can_manage_user(operator, user.role):
164
194
  raise HTTPException(status_code=403, detail="Cannot manage this user")
195
+ old_val = user.max_running_bots
165
196
  user.max_running_bots = body["max_running_bots"]
197
+ write_audit_log(
198
+ db,
199
+ operator,
200
+ "user.set_max_bots",
201
+ target_type="user",
202
+ target_id=user.id,
203
+ target_name=user.username,
204
+ detail={"old": old_val, "new": user.max_running_bots},
205
+ ip=request.client.host if request.client else None,
206
+ )
166
207
  db.commit()
167
208
  db.refresh(user)
168
209
  return {"id": user.id, "max_running_bots": user.max_running_bots}
@@ -171,6 +212,7 @@ def set_max_bots(
171
212
  @router.post("/users/{user_id}/reset-password", response_model=PasswordResetOut)
172
213
  def admin_reset_password(
173
214
  user_id: int,
215
+ request: Request,
174
216
  operator: User = Depends(require_admin),
175
217
  db: Session = Depends(get_db),
176
218
  ):
@@ -185,6 +227,15 @@ def admin_reset_password(
185
227
  target.must_change_password = True
186
228
  # Force logout - invalidate all existing tokens
187
229
  target.token_version = target.effective_token_version + 1
230
+ write_audit_log(
231
+ db,
232
+ operator,
233
+ "user.reset_password",
234
+ target_type="user",
235
+ target_id=target.id,
236
+ target_name=target.username,
237
+ ip=request.client.host if request.client else None,
238
+ )
188
239
  db.commit()
189
240
  db.refresh(target)
190
241
  return PasswordResetOut(id=target.id, username=target.username, new_password=raw_password)
@@ -193,6 +244,7 @@ def admin_reset_password(
193
244
  @router.post("/users/{user_id}/force-logout")
194
245
  def force_logout_user(
195
246
  user_id: int,
247
+ request: Request,
196
248
  operator: User = Depends(require_admin),
197
249
  db: Session = Depends(get_db),
198
250
  ):
@@ -203,6 +255,15 @@ def force_logout_user(
203
255
  if not can_manage_user(operator, target.role):
204
256
  raise HTTPException(status_code=403, detail="Cannot manage this user")
205
257
  target.token_version = target.effective_token_version + 1
258
+ write_audit_log(
259
+ db,
260
+ operator,
261
+ "user.force_logout",
262
+ target_type="user",
263
+ target_id=target.id,
264
+ target_name=target.username,
265
+ ip=request.client.host if request.client else None,
266
+ )
206
267
  db.commit()
207
268
  return {"id": target.id, "username": target.username, "message": "User logged out successfully"}
208
269
 
@@ -235,7 +296,9 @@ def platform_stats(
235
296
 
236
297
  @router.get("/backup")
237
298
  def download_backup(
299
+ request: Request,
238
300
  _admin: User = Depends(require_super_admin),
301
+ db: Session = Depends(get_db),
239
302
  ):
240
303
  """Download full SQLite database backup (super_admin only)."""
241
304
  from lockbot.backend.app.config import DATABASE_URL
@@ -258,6 +321,9 @@ def download_backup(
258
321
  os.unlink(tmp_path)
259
322
  raise
260
323
 
324
+ write_audit_log(db, _admin, "admin.backup", ip=request.client.host if request.client else None)
325
+ db.commit()
326
+
261
327
  filename = f"lockbot_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db"
262
328
  return FileResponse(
263
329
  path=tmp_path,
@@ -272,6 +338,7 @@ _DEFAULT_DATA_DIR = os.environ.get("DATA_DIR", "/data")
272
338
 
273
339
  @router.get("/bot-states")
274
340
  def download_all_bot_states(
341
+ request: Request,
275
342
  _admin: User = Depends(require_super_admin),
276
343
  db: Session = Depends(get_db),
277
344
  ):
@@ -299,6 +366,9 @@ def download_all_bot_states(
299
366
  buf.seek(0)
300
367
  content = buf.read()
301
368
 
369
+ write_audit_log(db, _admin, "admin.bot_states", ip=request.client.host if request.client else None)
370
+ db.commit()
371
+
302
372
  filename = f"lockbot_states_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
303
373
  return Response(
304
374
  content=content,
@@ -0,0 +1,37 @@
1
+ """
2
+ AuditLog model — records who did what, when, and to which resource.
3
+ """
4
+
5
+ from datetime import datetime
6
+
7
+ from sqlalchemy import DateTime, Integer, String, Text, func
8
+ from sqlalchemy.orm import Mapped, mapped_column
9
+
10
+ from lockbot.backend.app.database import Base
11
+
12
+
13
+ class AuditLog(Base):
14
+ __tablename__ = "audit_logs"
15
+
16
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
17
+
18
+ # Operator info (denormalized so records survive user deletion)
19
+ operator_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
20
+ operator_username: Mapped[str] = mapped_column(String(64), nullable=False)
21
+ operator_role: Mapped[str] = mapped_column(String(16), nullable=False)
22
+
23
+ # Action: namespace.verb, e.g. "auth.login", "bot.start", "user.create"
24
+ action: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
25
+
26
+ # Target resource (optional)
27
+ target_type: Mapped[str | None] = mapped_column(String(16), nullable=True) # "user" | "bot"
28
+ target_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
29
+ target_name: Mapped[str | None] = mapped_column(String(128), nullable=True) # denormalized
30
+
31
+ # Extra context (JSON string)
32
+ detail: Mapped[str | None] = mapped_column(Text, nullable=True)
33
+
34
+ ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
35
+ result: Mapped[str] = mapped_column(String(16), nullable=False, default="success") # success | failure
36
+
37
+ created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
@@ -0,0 +1,141 @@
1
+ """
2
+ Audit log query API.
3
+
4
+ Visibility rules:
5
+ - super_admin : sees ALL records
6
+ - admin : sees records where operator_id is in the set of
7
+ {own id} ∪ {ids of users with role="user"}
8
+ (admins cannot see each other's actions)
9
+ - user : sees own records (operator_id == self) PLUS anonymous
10
+ failed-login attempts where operator_username == self
11
+ (so users can detect brute-force attempts on their account)
12
+ """
13
+
14
+ import json
15
+ from datetime import datetime
16
+ from typing import Any
17
+
18
+ from fastapi import APIRouter, Depends, Query
19
+ from pydantic import BaseModel
20
+ from sqlalchemy import and_, or_
21
+ from sqlalchemy.orm import Session
22
+
23
+ from lockbot.backend.app.audit.models import AuditLog
24
+ from lockbot.backend.app.auth.dependencies import get_current_user
25
+ from lockbot.backend.app.auth.models import User
26
+ from lockbot.backend.app.database import get_db
27
+
28
+ router = APIRouter(prefix="/api/audit", tags=["audit"])
29
+
30
+
31
+ class AuditLogOut(BaseModel):
32
+ id: int
33
+ operator_id: int | None
34
+ operator_username: str
35
+ operator_role: str
36
+ action: str
37
+ target_type: str | None
38
+ target_id: int | None
39
+ target_name: str | None
40
+ detail: Any | None # parsed JSON or raw string
41
+ ip_address: str | None
42
+ result: str
43
+ created_at: datetime
44
+
45
+ model_config = {"from_attributes": True}
46
+
47
+
48
+ class AuditLogsPage(BaseModel):
49
+ total: int
50
+ items: list[AuditLogOut]
51
+
52
+
53
+ def _parse_detail(raw: str | None) -> Any:
54
+ if raw is None:
55
+ return None
56
+ try:
57
+ return json.loads(raw)
58
+ except Exception:
59
+ return raw
60
+
61
+
62
+ def _to_out(log: AuditLog) -> AuditLogOut:
63
+ return AuditLogOut(
64
+ id=log.id,
65
+ operator_id=log.operator_id,
66
+ operator_username=log.operator_username,
67
+ operator_role=log.operator_role,
68
+ action=log.action,
69
+ target_type=log.target_type,
70
+ target_id=log.target_id,
71
+ target_name=log.target_name,
72
+ detail=_parse_detail(log.detail),
73
+ ip_address=log.ip_address,
74
+ result=log.result,
75
+ created_at=log.created_at,
76
+ )
77
+
78
+
79
+ @router.get("/logs", response_model=AuditLogsPage)
80
+ def list_audit_logs(
81
+ # Filters
82
+ action: str | None = Query(None, description="Filter by action, e.g. 'bot.start'"),
83
+ operator_id: int | None = Query(None),
84
+ operator_username: str | None = Query(None, description="Filter by operator username (partial match)"),
85
+ target_type: str | None = Query(None),
86
+ target_id: int | None = Query(None),
87
+ result: str | None = Query(None, description="'success' or 'failure'"),
88
+ start_date: datetime | None = Query(None),
89
+ end_date: datetime | None = Query(None),
90
+ # Pagination
91
+ page: int = Query(1, ge=1),
92
+ limit: int = Query(50, ge=1, le=200),
93
+ # Auth
94
+ operator: User = Depends(get_current_user),
95
+ db: Session = Depends(get_db),
96
+ ):
97
+ q = db.query(AuditLog)
98
+
99
+ # --- Visibility scope ---
100
+ if operator.role == "user":
101
+ # Own authenticated actions + anonymous failed-login attempts targeting this account
102
+ q = q.filter(
103
+ or_(
104
+ AuditLog.operator_id == operator.id,
105
+ and_(
106
+ AuditLog.operator_id.is_(None),
107
+ AuditLog.operator_username == operator.username,
108
+ AuditLog.action == "auth.login",
109
+ AuditLog.result == "failure",
110
+ ),
111
+ )
112
+ )
113
+ elif operator.role != "super_admin":
114
+ # admin: own actions + actions of users they manage (role="user")
115
+ # Also include anonymous records (operator_id IS NULL, e.g. failed logins)
116
+ managed_ids = [u.id for u in db.query(User.id).filter(User.role == "user").all()]
117
+ visible_ids = list({operator.id, *managed_ids})
118
+ q = q.filter(or_(AuditLog.operator_id.in_(visible_ids), AuditLog.operator_id.is_(None)))
119
+
120
+ # --- Filters ---
121
+ if action:
122
+ q = q.filter(AuditLog.action == action)
123
+ if operator_id is not None:
124
+ q = q.filter(AuditLog.operator_id == operator_id)
125
+ if operator_username:
126
+ q = q.filter(AuditLog.operator_username.ilike(f"%{operator_username}%"))
127
+ if target_type:
128
+ q = q.filter(AuditLog.target_type == target_type)
129
+ if target_id is not None:
130
+ q = q.filter(AuditLog.target_id == target_id)
131
+ if result:
132
+ q = q.filter(AuditLog.result == result)
133
+ if start_date:
134
+ q = q.filter(AuditLog.created_at >= start_date)
135
+ if end_date:
136
+ q = q.filter(AuditLog.created_at <= end_date)
137
+
138
+ total = q.count()
139
+ items = q.order_by(AuditLog.created_at.desc()).offset((page - 1) * limit).limit(limit).all()
140
+
141
+ return AuditLogsPage(total=total, items=[_to_out(i) for i in items])
@@ -0,0 +1,72 @@
1
+ """
2
+ Audit log service — write_audit_log() helper.
3
+
4
+ Failures are logged as warnings and never propagate to the caller,
5
+ so audit recording never breaks the main business flow.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ from types import SimpleNamespace
11
+ from typing import Any
12
+
13
+ from sqlalchemy.orm import Session
14
+
15
+ from lockbot.backend.app.audit.models import AuditLog
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def _anon_operator(username: str, role: str = "unknown") -> Any:
21
+ """Build a lightweight non-ORM operator for anonymous/failed operations."""
22
+ return SimpleNamespace(id=None, username=username, role=role)
23
+
24
+
25
+ def write_audit_log(
26
+ db: Session,
27
+ operator: Any,
28
+ action: str,
29
+ *,
30
+ target_type: str | None = None,
31
+ target_id: int | None = None,
32
+ target_name: str | None = None,
33
+ detail: dict | str | None = None,
34
+ ip: str | None = None,
35
+ result: str = "success",
36
+ ) -> None:
37
+ """
38
+ Write one audit log entry.
39
+
40
+ :param db: SQLAlchemy session (already open, caller commits).
41
+ :param operator: Object with .id, .username, .role (User ORM or SimpleNamespace).
42
+ :param action: Dot-namespaced verb, e.g. "auth.login", "bot.start", "user.create".
43
+ :param target_type: Optional resource type: "user" | "bot".
44
+ :param target_id: Optional resource primary key.
45
+ :param target_name: Optional denormalized resource name.
46
+ :param detail: Optional dict or JSON string with extra context.
47
+ :param ip: Client IP address (from request.client.host).
48
+ :param result: "success" (default) or "failure".
49
+ """
50
+ try:
51
+ detail_str: str | None = None
52
+ if isinstance(detail, dict):
53
+ detail_str = json.dumps(detail, ensure_ascii=False)
54
+ elif isinstance(detail, str):
55
+ detail_str = detail
56
+
57
+ log = AuditLog(
58
+ operator_id=operator.id,
59
+ operator_username=operator.username,
60
+ operator_role=operator.role,
61
+ action=action,
62
+ target_type=target_type,
63
+ target_id=target_id,
64
+ target_name=target_name,
65
+ detail=detail_str,
66
+ ip_address=ip,
67
+ result=result,
68
+ )
69
+ db.add(log)
70
+ db.flush() # Write within the same transaction as the main operation
71
+ except Exception:
72
+ logger.warning("Failed to write audit log for action=%s operator=%s", action, operator.username, exc_info=True)
@@ -6,11 +6,12 @@ import secrets
6
6
  import string
7
7
 
8
8
  import bcrypt as _bcrypt
9
- from fastapi import APIRouter, Depends, HTTPException, status
9
+ from fastapi import APIRouter, Depends, HTTPException, Request, status
10
10
  from sqlalchemy import or_
11
11
  from sqlalchemy.orm import Session
12
12
 
13
13
  import lockbot.backend.app.config as _config
14
+ from lockbot.backend.app.audit.service import _anon_operator, write_audit_log
14
15
  from lockbot.backend.app.auth.dependencies import create_access_token, get_current_user
15
16
  from lockbot.backend.app.auth.models import User
16
17
  from lockbot.backend.app.auth.schemas import (
@@ -23,6 +24,7 @@ from lockbot.backend.app.auth.schemas import (
23
24
  UserRegister,
24
25
  )
25
26
  from lockbot.backend.app.database import get_db
27
+ from lockbot.backend.app.rate_limit import limiter
26
28
 
27
29
  router = APIRouter(prefix="/api/auth", tags=["auth"])
28
30
 
@@ -50,7 +52,8 @@ def _generate_password(length: int = 12) -> str:
50
52
 
51
53
 
52
54
  @router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
53
- def register(body: UserRegister, db: Session = Depends(get_db)):
55
+ @limiter.limit("5/minute")
56
+ def register(request: Request, body: UserRegister, db: Session = Depends(get_db)):
54
57
  if not _config.ALLOW_REGISTER:
55
58
  raise HTTPException(status_code=403, detail="Registration is disabled. Contact admin.")
56
59
 
@@ -70,10 +73,24 @@ def register(body: UserRegister, db: Session = Depends(get_db)):
70
73
 
71
74
 
72
75
  @router.post("/login", response_model=TokenOut)
73
- def login(body: UserLogin, db: Session = Depends(get_db)):
76
+ @limiter.limit("10/minute")
77
+ def login(request: Request, body: UserLogin, db: Session = Depends(get_db)):
78
+ ip = request.client.host if request.client else None
74
79
  user = db.query(User).filter(User.username == body.username).first()
75
80
  if not user or not _verify_password(body.password, user.password_hash):
81
+ write_audit_log(
82
+ db,
83
+ _anon_operator(body.username),
84
+ "auth.login",
85
+ ip=ip,
86
+ result="failure",
87
+ detail={"reason": "invalid credentials"},
88
+ )
89
+ db.commit()
76
90
  raise HTTPException(status_code=401, detail="Invalid credentials")
91
+
92
+ write_audit_log(db, user, "auth.login", ip=ip)
93
+ db.commit()
77
94
  return TokenOut(
78
95
  access_token=create_access_token(user.id, user.effective_token_version, user.must_change_password),
79
96
  must_change_password=user.must_change_password,
@@ -87,6 +104,7 @@ def me(user: User = Depends(get_current_user)):
87
104
 
88
105
  @router.put("/change-password", response_model=TokenOut)
89
106
  def change_password(
107
+ request: Request,
90
108
  body: ChangePassword,
91
109
  user: User = Depends(get_current_user),
92
110
  db: Session = Depends(get_db),
@@ -97,6 +115,7 @@ def change_password(
97
115
  raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
98
116
  user.password_hash = _hash_password(body.new_password)
99
117
  user.must_change_password = False
118
+ write_audit_log(db, user, "auth.change_password", ip=request.client.host if request.client else None)
100
119
  db.commit()
101
120
  return TokenOut(
102
121
  access_token=create_access_token(user.id, user.effective_token_version, False),
@@ -106,6 +125,7 @@ def change_password(
106
125
 
107
126
  @router.put("/force-change-password", response_model=TokenOut)
108
127
  def force_change_password(
128
+ request: Request,
109
129
  body: ForceChangePassword,
110
130
  user: User = Depends(get_current_user),
111
131
  db: Session = Depends(get_db),
@@ -118,6 +138,13 @@ def force_change_password(
118
138
  raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
119
139
  user.password_hash = _hash_password(body.new_password)
120
140
  user.must_change_password = False
141
+ write_audit_log(
142
+ db,
143
+ user,
144
+ "auth.change_password",
145
+ ip=request.client.host if request.client else None,
146
+ detail={"type": "force_change"},
147
+ )
121
148
  db.commit()
122
149
  return TokenOut(
123
150
  access_token=create_access_token(user.id, user.effective_token_version, False),
@@ -127,6 +154,7 @@ def force_change_password(
127
154
 
128
155
  @router.put("/change-email", response_model=UserOut)
129
156
  def change_email(
157
+ request: Request,
130
158
  body: ChangeEmail,
131
159
  user: User = Depends(get_current_user),
132
160
  db: Session = Depends(get_db),
@@ -134,7 +162,15 @@ def change_email(
134
162
  exists = db.query(User).filter(User.email == body.new_email).filter(User.id != user.id).first()
135
163
  if exists:
136
164
  raise HTTPException(status_code=409, detail="Email already taken")
165
+ old_email = user.email
137
166
  user.email = body.new_email
167
+ write_audit_log(
168
+ db,
169
+ user,
170
+ "auth.change_email",
171
+ ip=request.client.host if request.client else None,
172
+ detail={"old_email": old_email, "new_email": body.new_email},
173
+ )
138
174
  db.commit()
139
175
  db.refresh(user)
140
176
  return user