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.
- {lockbot-2.3.2/python/lockbot.egg-info → lockbot-2.4.0}/PKG-INFO +2 -1
- {lockbot-2.3.2 → lockbot-2.4.0}/pyproject.toml +2 -1
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/admin/router.py +71 -1
- lockbot-2.4.0/python/lockbot/backend/app/audit/models.py +37 -0
- lockbot-2.4.0/python/lockbot/backend/app/audit/router.py +141 -0
- lockbot-2.4.0/python/lockbot/backend/app/audit/service.py +72 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/auth/dependencies.py +11 -3
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/auth/router.py +39 -3
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/bots/router.py +112 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/config.py +4 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/main.py +23 -0
- lockbot-2.4.0/python/lockbot/backend/app/rate_limit.py +20 -0
- lockbot-2.4.0/python/lockbot/core/platforms/__init__.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0/python/lockbot.egg-info}/PKG-INFO +2 -1
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot.egg-info/SOURCES.txt +5 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot.egg-info/requires.txt +1 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/LICENSE +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/MANIFEST.in +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/README.md +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/__init__.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/__init__.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/__init__.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/admin/__init__.py +0 -0
- {lockbot-2.3.2/python/lockbot/backend/app/auth → lockbot-2.4.0/python/lockbot/backend/app/audit}/__init__.py +0 -0
- {lockbot-2.3.2/python/lockbot/backend/app/bots → lockbot-2.4.0/python/lockbot/backend/app/auth}/__init__.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/auth/models.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/auth/schemas.py +0 -0
- {lockbot-2.3.2/python/lockbot/backend/app/logs → lockbot-2.4.0/python/lockbot/backend/app/bots}/__init__.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/bots/encryption.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/bots/manager.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/bots/models.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/bots/schemas.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/bots/webhook_handler.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/database.py +0 -0
- {lockbot-2.3.2/python/lockbot/backend/app/settings → lockbot-2.4.0/python/lockbot/backend/app/logs}/__init__.py +0 -0
- {lockbot-2.3.2/python/lockbot/core → lockbot-2.4.0/python/lockbot/backend/app/settings}/__init__.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/settings/models.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/backend/app/settings/router.py +0 -0
- {lockbot-2.3.2/python/lockbot/core/platforms → lockbot-2.4.0/python/lockbot/core}/__init__.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/base_bot.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/bot_instance.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/config.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/device_bot.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/device_usage_alert.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/device_usage_utils.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/entry.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/env.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/handler.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/i18n/__init__.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/i18n/en.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/i18n/zh.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/io.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/message_adapter.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/msg_utils.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/node_bot.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/platforms/infoflow.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/queue_bot.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/request.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot/core/utils.py +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot.egg-info/dependency_links.txt +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/python/lockbot.egg-info/top_level.txt +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/setup.cfg +0 -0
- {lockbot-2.3.2 → lockbot-2.4.0}/tools/create_super_admin.py +0 -0
- {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
|
+
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.
|
|
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 = ("
|
|
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
|
|
99
|
-
if new_role
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
)
|
|
File without changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lockbot
|
|
3
|
-
Version: 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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lockbot-2.3.2/python/lockbot/core → lockbot-2.4.0/python/lockbot/backend/app/settings}/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lockbot-2.3.2/python/lockbot/core/platforms → lockbot-2.4.0/python/lockbot/core}/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|