lockbot 2.3.2__tar.gz → 2.4.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 (64) hide show
  1. {lockbot-2.3.2/python/lockbot.egg-info → lockbot-2.4.0}/PKG-INFO +2 -1
  2. {lockbot-2.3.2 → lockbot-2.4.0}/pyproject.toml +2 -1
  3. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/admin/router.py +71 -1
  4. lockbot-2.4.0/python/lockbot/backend/app/audit/models.py +37 -0
  5. lockbot-2.4.0/python/lockbot/backend/app/audit/router.py +141 -0
  6. lockbot-2.4.0/python/lockbot/backend/app/audit/service.py +72 -0
  7. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/auth/dependencies.py +11 -3
  8. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/auth/router.py +39 -3
  9. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/bots/router.py +112 -0
  10. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/config.py +4 -0
  11. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/main.py +23 -0
  12. lockbot-2.4.0/python/lockbot/backend/app/rate_limit.py +20 -0
  13. lockbot-2.4.0/python/lockbot/core/platforms/__init__.py +0 -0
  14. {lockbot-2.3.2 → lockbot-2.4.0/python/lockbot.egg-info}/PKG-INFO +2 -1
  15. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot.egg-info/SOURCES.txt +5 -0
  16. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot.egg-info/requires.txt +1 -0
  17. {lockbot-2.3.2 → lockbot-2.4.0}/LICENSE +0 -0
  18. {lockbot-2.3.2 → lockbot-2.4.0}/MANIFEST.in +0 -0
  19. {lockbot-2.3.2 → lockbot-2.4.0}/README.md +0 -0
  20. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/__init__.py +0 -0
  21. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/__init__.py +0 -0
  22. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/__init__.py +0 -0
  23. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/admin/__init__.py +0 -0
  24. {lockbot-2.3.2/python/lockbot/backend/app/auth → lockbot-2.4.0/python/lockbot/backend/app/audit}/__init__.py +0 -0
  25. {lockbot-2.3.2/python/lockbot/backend/app/bots → lockbot-2.4.0/python/lockbot/backend/app/auth}/__init__.py +0 -0
  26. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/auth/models.py +0 -0
  27. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/auth/schemas.py +0 -0
  28. {lockbot-2.3.2/python/lockbot/backend/app/logs → lockbot-2.4.0/python/lockbot/backend/app/bots}/__init__.py +0 -0
  29. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/bots/encryption.py +0 -0
  30. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/bots/manager.py +0 -0
  31. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/bots/models.py +0 -0
  32. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/bots/schemas.py +0 -0
  33. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/bots/webhook_handler.py +0 -0
  34. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/database.py +0 -0
  35. {lockbot-2.3.2/python/lockbot/backend/app/settings → lockbot-2.4.0/python/lockbot/backend/app/logs}/__init__.py +0 -0
  36. {lockbot-2.3.2/python/lockbot/core → lockbot-2.4.0/python/lockbot/backend/app/settings}/__init__.py +0 -0
  37. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/settings/models.py +0 -0
  38. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/settings/router.py +0 -0
  39. {lockbot-2.3.2/python/lockbot/core/platforms → lockbot-2.4.0/python/lockbot/core}/__init__.py +0 -0
  40. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/base_bot.py +0 -0
  41. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/bot_instance.py +0 -0
  42. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/config.py +0 -0
  43. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/device_bot.py +0 -0
  44. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/device_usage_alert.py +0 -0
  45. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/device_usage_utils.py +0 -0
  46. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/entry.py +0 -0
  47. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/env.py +0 -0
  48. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/handler.py +0 -0
  49. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/i18n/__init__.py +0 -0
  50. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/i18n/en.py +0 -0
  51. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/i18n/zh.py +0 -0
  52. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/io.py +0 -0
  53. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/message_adapter.py +0 -0
  54. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/msg_utils.py +0 -0
  55. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/node_bot.py +0 -0
  56. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/platforms/infoflow.py +0 -0
  57. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/queue_bot.py +0 -0
  58. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/request.py +0 -0
  59. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/utils.py +0 -0
  60. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot.egg-info/dependency_links.txt +0 -0
  61. {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot.egg-info/top_level.txt +0 -0
  62. {lockbot-2.3.2 → lockbot-2.4.0}/setup.cfg +0 -0
  63. {lockbot-2.3.2 → lockbot-2.4.0}/tools/create_super_admin.py +0 -0
  64. {lockbot-2.3.2 → lockbot-2.4.0}/tools/gen_keys.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lockbot
3
- Version: 2.3.2
3
+ Version: 2.4.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,6 +33,7 @@ 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
39
  Requires-Dist: ruff>=0.1.0; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lockbot"
7
- version = "2.3.2"
7
+ version = "2.4.0"
8
8
  description = "Cluster resource management bot for IM platforms"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -41,6 +41,7 @@ dependencies = [
41
41
  "bcrypt",
42
42
  "python-multipart",
43
43
  "httpx",
44
+ "slowapi",
44
45
  ]
45
46
 
46
47
  [project.optional-dependencies]
@@ -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)
@@ -85,9 +85,17 @@ def can_assign_role(operator: User, target: User, new_role: str) -> tuple[bool,
85
85
 
86
86
  Returns (allowed, http_status_code, error_message).
87
87
  This consolidates all role assignment permission checks in one place.
88
+
89
+ NOTE: super_admin can only be created/promoted via CLI tool (create_super_admin),
90
+ not through the web API, to prevent permission escalation and ensure a single
91
+ source of truth for the highest privilege level.
88
92
  """
93
+ # super_admin cannot be assigned via API
94
+ if new_role == "super_admin":
95
+ return False, 403, "Super admin can only be managed via CLI tool"
96
+
89
97
  # Validate role
90
- valid_roles = ("super_admin", "admin", "user")
98
+ valid_roles = ("admin", "user")
91
99
  if new_role not in valid_roles:
92
100
  return False, 400, f"Invalid role, must be one of {valid_roles}"
93
101
 
@@ -95,8 +103,8 @@ def can_assign_role(operator: User, target: User, new_role: str) -> tuple[bool,
95
103
  if not can_manage_user(operator, target.role):
96
104
  return False, 403, "Cannot manage this user"
97
105
 
98
- # Only super_admin can assign admin or super_admin role
99
- if new_role in ("admin", "super_admin") and operator.role != "super_admin":
106
+ # Only super_admin can assign admin role
107
+ if new_role == "admin" and operator.role != "super_admin":
100
108
  return False, 403, "Only super admin can assign this role"
101
109
 
102
110
  return True, 200, ""
@@ -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
@@ -15,6 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
15
15
  from fastapi.responses import PlainTextResponse
16
16
  from sqlalchemy.orm import Session
17
17
 
18
+ from lockbot.backend.app.audit.service import write_audit_log
18
19
  from lockbot.backend.app.auth.dependencies import get_current_user, require_admin, require_super_admin
19
20
  from lockbot.backend.app.auth.models import User
20
21
  from lockbot.backend.app.bots import encryption
@@ -23,6 +24,7 @@ from lockbot.backend.app.bots.models import Bot
23
24
  from lockbot.backend.app.bots.schemas import BotCreate, BotDetail, BotOut, BotStatusOut, BotUpdate
24
25
  from lockbot.backend.app.bots.webhook_handler import handle_webhook
25
26
  from lockbot.backend.app.database import get_db
27
+ from lockbot.backend.app.rate_limit import limiter
26
28
  from lockbot.core.config import Config
27
29
  from lockbot.core.i18n import t
28
30
  from lockbot.core.msg_utils import check_signature
@@ -172,6 +174,7 @@ def list_bots(
172
174
 
173
175
  @router.post("", response_model=BotOut, status_code=status.HTTP_201_CREATED)
174
176
  def create_bot(
177
+ request: Request,
175
178
  body: BotCreate,
176
179
  user: User = Depends(get_current_user),
177
180
  db: Session = Depends(get_db),
@@ -204,6 +207,17 @@ def create_bot(
204
207
  config_overrides=json.dumps(body.config_overrides or {}, ensure_ascii=False),
205
208
  )
206
209
  db.add(bot)
210
+ db.flush()
211
+ write_audit_log(
212
+ db,
213
+ user,
214
+ "bot.create",
215
+ target_type="bot",
216
+ target_id=bot.id,
217
+ target_name=bot.name,
218
+ detail={"bot_type": bot.bot_type, "platform": bot.platform},
219
+ ip=request.client.host if request.client else None,
220
+ )
207
221
  db.commit()
208
222
  db.refresh(bot)
209
223
 
@@ -287,6 +301,7 @@ def get_bot(
287
301
  @router.put("/{bot_id}", response_model=BotOut)
288
302
  def update_bot(
289
303
  bot_id: int,
304
+ request: Request,
290
305
  body: BotUpdate,
291
306
  user: User = Depends(get_current_user),
292
307
  db: Session = Depends(get_db),
@@ -300,6 +315,7 @@ def update_bot(
300
315
  if bot.status == "running":
301
316
  raise HTTPException(status_code=409, detail="Cannot update a running bot. Stop it first.")
302
317
 
318
+ changes = {}
303
319
  if body.name is not None:
304
320
  # Check for duplicate name (excluding current bot and deleted bots)
305
321
  dup = (
@@ -314,20 +330,36 @@ def update_bot(
314
330
  )
315
331
  if dup:
316
332
  raise HTTPException(status_code=409, detail="Bot name already exists")
333
+ changes["name"] = [bot.name, body.name]
317
334
  bot.name = body.name
318
335
  if body.group_id is not None:
319
336
  bot.group_id = body.group_id
320
337
  if body.webhook_url is not None:
338
+ changes["webhook_url"] = True
321
339
  bot.webhook_url = encryption.encrypt(body.webhook_url)
322
340
  if body.aes_key is not None:
341
+ changes["aes_key"] = True
323
342
  bot.aes_key = encryption.encrypt(body.aes_key)
324
343
  if body.token is not None:
344
+ changes["token"] = True
325
345
  bot.token = encryption.encrypt(body.token)
326
346
  if body.cluster_configs is not None:
347
+ changes["cluster_configs"] = True
327
348
  bot.cluster_configs = json.dumps(body.cluster_configs, ensure_ascii=False)
328
349
  if body.config_overrides is not None:
350
+ changes["config_overrides"] = True
329
351
  bot.config_overrides = json.dumps(body.config_overrides, ensure_ascii=False)
330
352
 
353
+ write_audit_log(
354
+ db,
355
+ user,
356
+ "bot.update",
357
+ target_type="bot",
358
+ target_id=bot_id,
359
+ target_name=bot.name,
360
+ detail={"changed": list(changes.keys())},
361
+ ip=request.client.host if request.client else None,
362
+ )
331
363
  db.commit()
332
364
  db.refresh(bot)
333
365
  return bot
@@ -336,6 +368,7 @@ def update_bot(
336
368
  @router.delete("/{bot_id}", status_code=status.HTTP_204_NO_CONTENT)
337
369
  def delete_bot(
338
370
  bot_id: int,
371
+ request: Request,
339
372
  user: User = Depends(get_current_user),
340
373
  db: Session = Depends(get_db),
341
374
  ):
@@ -352,8 +385,18 @@ def delete_bot(
352
385
  bot.status = "stopped"
353
386
  bot.pid = None
354
387
 
388
+ bot_name = bot.name
355
389
  bot.is_deleted = True
356
390
  bot.deleted_at = datetime.utcnow()
391
+ write_audit_log(
392
+ db,
393
+ user,
394
+ "bot.delete",
395
+ target_type="bot",
396
+ target_id=bot_id,
397
+ target_name=bot_name,
398
+ ip=request.client.host if request.client else None,
399
+ )
357
400
  db.commit()
358
401
 
359
402
 
@@ -361,6 +404,7 @@ def delete_bot(
361
404
  def transfer_bot_owner(
362
405
  bot_id: int,
363
406
  body: dict,
407
+ request: Request,
364
408
  user: User = Depends(require_super_admin),
365
409
  db: Session = Depends(get_db),
366
410
  ):
@@ -380,6 +424,16 @@ def transfer_bot_owner(
380
424
  old_owner = db.get(User, bot.user_id)
381
425
  old_name = old_owner.username if old_owner else "unknown"
382
426
  bot.user_id = target_user.id
427
+ write_audit_log(
428
+ db,
429
+ user,
430
+ "bot.transfer",
431
+ target_type="bot",
432
+ target_id=bot_id,
433
+ target_name=bot.name,
434
+ detail={"from": old_name, "to": target_username},
435
+ ip=request.client.host if request.client else None,
436
+ )
383
437
  db.commit()
384
438
 
385
439
  _write_log(bot_id, f"Owner transferred from {old_name} to {target_username} by {user.username}")
@@ -393,7 +447,9 @@ MAX_CONSECUTIVE_FAILURES = 3
393
447
 
394
448
 
395
449
  @router.post("/{bot_id}/start", response_model=BotStatusOut)
450
+ @limiter.limit("10/minute")
396
451
  def start_bot(
452
+ request: Request,
397
453
  bot_id: int,
398
454
  user: User = Depends(get_current_user),
399
455
  db: Session = Depends(get_db),
@@ -451,6 +507,15 @@ def start_bot(
451
507
  bot.status = "running"
452
508
  bot.pid = pid
453
509
  bot.consecutive_failures = 0
510
+ write_audit_log(
511
+ db,
512
+ user,
513
+ "bot.start",
514
+ target_type="bot",
515
+ target_id=bot_id,
516
+ target_name=bot.name,
517
+ ip=request.client.host if request.client else None,
518
+ )
454
519
  db.commit()
455
520
  db.refresh(bot)
456
521
  _write_log(bot_id, "Bot 已启动")
@@ -459,7 +524,9 @@ def start_bot(
459
524
 
460
525
 
461
526
  @router.post("/{bot_id}/stop", response_model=BotStatusOut)
527
+ @limiter.limit("10/minute")
462
528
  def stop_bot(
529
+ request: Request,
463
530
  bot_id: int,
464
531
  user: User = Depends(get_current_user),
465
532
  db: Session = Depends(get_db),
@@ -479,6 +546,15 @@ def stop_bot(
479
546
  bot.status = "stopped"
480
547
  bot.pid = None
481
548
  bot.consecutive_failures = 0
549
+ write_audit_log(
550
+ db,
551
+ user,
552
+ "bot.stop",
553
+ target_type="bot",
554
+ target_id=bot_id,
555
+ target_name=bot.name,
556
+ ip=request.client.host if request.client else None,
557
+ )
482
558
  db.commit()
483
559
  _write_log(bot_id, "Bot 已停止")
484
560
 
@@ -486,7 +562,9 @@ def stop_bot(
486
562
 
487
563
 
488
564
  @router.post("/{bot_id}/restart", response_model=BotStatusOut)
565
+ @limiter.limit("10/minute")
489
566
  def restart_bot(
567
+ request: Request,
490
568
  bot_id: int,
491
569
  user: User = Depends(get_current_user),
492
570
  db: Session = Depends(get_db),
@@ -507,6 +585,15 @@ def restart_bot(
507
585
 
508
586
  bot.status = "running"
509
587
  bot.pid = pid
588
+ write_audit_log(
589
+ db,
590
+ user,
591
+ "bot.restart",
592
+ target_type="bot",
593
+ target_id=bot_id,
594
+ target_name=bot.name,
595
+ ip=request.client.host if request.client else None,
596
+ )
510
597
  db.commit()
511
598
  db.refresh(bot)
512
599
  _write_log(bot_id, "Bot 已重启")
@@ -520,6 +607,7 @@ def restart_bot(
520
607
  @router.put("/{bot_id}/language")
521
608
  def set_bot_language(
522
609
  bot_id: int,
610
+ request: Request,
523
611
  body: dict,
524
612
  user: User = Depends(get_current_user),
525
613
  db: Session = Depends(get_db),
@@ -535,6 +623,17 @@ def set_bot_language(
535
623
  overrides = json.loads(bot.config_overrides or "{}")
536
624
  overrides["LANGUAGE"] = lang
537
625
  bot.config_overrides = json.dumps(overrides, ensure_ascii=False)
626
+
627
+ write_audit_log(
628
+ db,
629
+ user,
630
+ "bot.set_language",
631
+ target_type="bot",
632
+ target_id=bot_id,
633
+ target_name=bot.name,
634
+ detail={"language": lang},
635
+ ip=request.client.host if request.client else None,
636
+ )
538
637
  db.commit()
539
638
 
540
639
  # Hot-apply to running instance (no restart)
@@ -789,6 +888,7 @@ def _validate_device_state(state, cluster_configs, warnings, config):
789
888
  @router.put("/{bot_id}/state")
790
889
  def update_bot_state(
791
890
  bot_id: int,
891
+ request: Request,
792
892
  state: dict,
793
893
  user: User = Depends(require_admin),
794
894
  db: Session = Depends(get_db),
@@ -834,6 +934,17 @@ def update_bot_state(
834
934
  result = {"message": "State updated"}
835
935
  if warnings:
836
936
  result["warnings"] = warnings
937
+ write_audit_log(
938
+ db,
939
+ user,
940
+ "bot.edit_state",
941
+ target_type="bot",
942
+ target_id=bot_id,
943
+ target_name=bot.name,
944
+ detail={"warnings": warnings} if warnings else None,
945
+ ip=request.client.host if request.client else None,
946
+ )
947
+ db.commit()
837
948
  return result
838
949
 
839
950
 
@@ -885,6 +996,7 @@ def get_bot_logs(
885
996
 
886
997
 
887
998
  @router.post("/webhook/{bot_id}")
999
+ @limiter.limit("120/minute")
888
1000
  async def webhook(bot_id: int, request: Request, db: Session = Depends(get_db)):
889
1001
  """
890
1002
  IM callback endpoint.
@@ -27,6 +27,10 @@ ENCRYPTION_KEY = os.environ.get("ENCRYPTION_KEY", "")
27
27
  # Activity monitoring
28
28
  INACTIVE_THRESHOLD_DAYS = 7
29
29
 
30
+ # Rate limiting — Redis storage URI (optional, falls back to in-memory)
31
+ # Example: redis://localhost:6379
32
+ REDIS_URL = os.environ.get("REDIS_URL", "")
33
+
30
34
  # Feature flags
31
35
  ALLOW_REGISTER = os.environ.get("ALLOW_REGISTER", "false").lower() in ("true", "1", "yes")
32
36
 
@@ -12,10 +12,12 @@ from fastapi.middleware.cors import CORSMiddleware
12
12
  from fastapi.staticfiles import StaticFiles
13
13
 
14
14
  # Import models to register them with Base.metadata
15
+ import lockbot.backend.app.audit.models # noqa: F401
15
16
  import lockbot.backend.app.auth.models # noqa: F401
16
17
  import lockbot.backend.app.bots.models # noqa: F401
17
18
  import lockbot.backend.app.settings.models # noqa: F401
18
19
  from lockbot.backend.app.admin.router import router as admin_router
20
+ from lockbot.backend.app.audit.router import router as audit_router
19
21
  from lockbot.backend.app.auth.router import router as auth_router
20
22
  from lockbot.backend.app.bots.router import router as bots_router
21
23
  from lockbot.backend.app.database import Base, SessionLocal, engine
@@ -106,6 +108,16 @@ def _migrate_users_token_version():
106
108
  logger.info("Migrated users: added 'token_version' column")
107
109
 
108
110
 
111
+ def _migrate_audit_logs():
112
+ """Create audit_logs table if it doesn't exist (backward-compatible migration)."""
113
+ from sqlalchemy import inspect as sa_inspect
114
+
115
+ insp = sa_inspect(engine)
116
+ if "audit_logs" not in insp.get_table_names():
117
+ Base.metadata.tables["audit_logs"].create(bind=engine)
118
+ logger.info("Created audit_logs table")
119
+
120
+
109
121
  def _seed_dev_admin():
110
122
  """Create admin user in dev mode if it doesn't exist."""
111
123
  from lockbot.backend.app.config import (
@@ -189,6 +201,7 @@ async def lifespan(app: FastAPI):
189
201
  _migrate_users_must_change_password()
190
202
  _migrate_users_token_version()
191
203
  _migrate_bot_soft_delete()
204
+ _migrate_audit_logs()
192
205
  _seed_dev_admin()
193
206
  _seed_dev_users()
194
207
  _reset_running_bots()
@@ -258,12 +271,21 @@ def _reset_running_bots():
258
271
  def create_app() -> FastAPI:
259
272
  from importlib.metadata import version as _pkg_version
260
273
 
274
+ from slowapi import _rate_limit_exceeded_handler
275
+ from slowapi.errors import RateLimitExceeded
276
+
277
+ from lockbot.backend.app.rate_limit import limiter
278
+
261
279
  try:
262
280
  _ver = _pkg_version("lockbot")
263
281
  except Exception:
264
282
  _ver = "unknown"
265
283
  app = FastAPI(title="LockBot Platform", version=_ver, lifespan=lifespan)
266
284
 
285
+ # Rate limiting
286
+ app.state.limiter = limiter
287
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
288
+
267
289
  app.add_middleware(
268
290
  CORSMiddleware,
269
291
  allow_origins=["*"],
@@ -276,6 +298,7 @@ def create_app() -> FastAPI:
276
298
  app.include_router(bots_router)
277
299
  app.include_router(admin_router)
278
300
  app.include_router(settings_router)
301
+ app.include_router(audit_router)
279
302
 
280
303
  # Serve frontend static files (built by vite)
281
304
  # In Docker: /app/frontend/dist — locally: project_root/frontend/dist
@@ -0,0 +1,20 @@
1
+ """
2
+ Rate limiter singleton.
3
+
4
+ Uses in-memory storage by default. Set the REDIS_URL environment variable
5
+ to automatically upgrade to Redis-backed storage for distributed deployments.
6
+
7
+ Example:
8
+ REDIS_URL=redis://localhost:6379 → Redis storage
9
+ (unset) → in-memory storage
10
+ """
11
+
12
+ from slowapi import Limiter
13
+ from slowapi.util import get_remote_address
14
+
15
+ from lockbot.backend.app.config import REDIS_URL
16
+
17
+ limiter = Limiter(
18
+ key_func=get_remote_address,
19
+ storage_uri=REDIS_URL if REDIS_URL else "memory://",
20
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lockbot
3
- Version: 2.3.2
3
+ Version: 2.4.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,6 +33,7 @@ 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
39
  Requires-Dist: ruff>=0.1.0; extra == "dev"
@@ -13,8 +13,13 @@ python/lockbot/backend/app/__init__.py
13
13
  python/lockbot/backend/app/config.py
14
14
  python/lockbot/backend/app/database.py
15
15
  python/lockbot/backend/app/main.py
16
+ python/lockbot/backend/app/rate_limit.py
16
17
  python/lockbot/backend/app/admin/__init__.py
17
18
  python/lockbot/backend/app/admin/router.py
19
+ python/lockbot/backend/app/audit/__init__.py
20
+ python/lockbot/backend/app/audit/models.py
21
+ python/lockbot/backend/app/audit/router.py
22
+ python/lockbot/backend/app/audit/service.py
18
23
  python/lockbot/backend/app/auth/__init__.py
19
24
  python/lockbot/backend/app/auth/dependencies.py
20
25
  python/lockbot/backend/app/auth/models.py
@@ -10,6 +10,7 @@ cryptography
10
10
  bcrypt
11
11
  python-multipart
12
12
  httpx
13
+ slowapi
13
14
 
14
15
  [dev]
15
16
  pytest>=7.0
File without changes
File without changes
File without changes
File without changes
File without changes