ltcai 0.1.31 → 0.2.0
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.
- package/README.md +233 -193
- package/docs/CHANGELOG.md +44 -0
- package/latticeai/__init__.py +1 -0
- package/latticeai/__pycache__/__init__.cpython-314.pyc +0 -0
- package/latticeai/api/__init__.py +1 -0
- package/latticeai/api/__pycache__/admin.cpython-314.pyc +0 -0
- package/latticeai/api/__pycache__/auth.cpython-314.pyc +0 -0
- package/latticeai/api/admin.py +187 -0
- package/latticeai/api/auth.py +233 -0
- package/latticeai/core/__init__.py +1 -0
- package/latticeai/core/__pycache__/__init__.cpython-314.pyc +0 -0
- package/latticeai/core/__pycache__/audit.cpython-314.pyc +0 -0
- package/latticeai/core/__pycache__/security.cpython-314.pyc +0 -0
- package/latticeai/core/__pycache__/sessions.cpython-314.pyc +0 -0
- package/latticeai/core/audit.py +245 -0
- package/latticeai/core/security.py +131 -0
- package/latticeai/core/sessions.py +72 -0
- package/package.json +2 -1
- package/server.py +94 -719
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Admin API router: dashboard, users, VPC, SSO, audit, permissions."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AdminUserUpdate(BaseModel):
|
|
12
|
+
role: Optional[str] = None
|
|
13
|
+
disabled: Optional[bool] = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class VpcConfigUpdate(BaseModel):
|
|
17
|
+
provider: Optional[str] = None
|
|
18
|
+
region: Optional[str] = None
|
|
19
|
+
cidr_block: Optional[str] = None
|
|
20
|
+
private_subnets: Optional[List[str]] = None
|
|
21
|
+
endpoint: Optional[str] = None
|
|
22
|
+
vpn_status: Optional[str] = None
|
|
23
|
+
peering_status: Optional[str] = None
|
|
24
|
+
notes: Optional[str] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SsoConfigUpdate(BaseModel):
|
|
28
|
+
enabled: Optional[bool] = None
|
|
29
|
+
provider_name: Optional[str] = None
|
|
30
|
+
discovery_url: Optional[str] = None
|
|
31
|
+
client_id: Optional[str] = None
|
|
32
|
+
client_secret: Optional[str] = None
|
|
33
|
+
redirect_uri: Optional[str] = None
|
|
34
|
+
scopes: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_admin_router(
|
|
38
|
+
*,
|
|
39
|
+
require_admin: Callable,
|
|
40
|
+
require_user: Callable,
|
|
41
|
+
load_users: Callable,
|
|
42
|
+
save_users: Callable,
|
|
43
|
+
get_user_role: Callable,
|
|
44
|
+
get_history: Callable,
|
|
45
|
+
public_user: Callable,
|
|
46
|
+
load_vpc_config: Callable,
|
|
47
|
+
save_vpc_config: Callable,
|
|
48
|
+
build_admin_audit_report: Callable,
|
|
49
|
+
build_sensitivity_report: Callable,
|
|
50
|
+
append_audit_event: Callable,
|
|
51
|
+
public_sso_config: Callable,
|
|
52
|
+
save_sso_config: Callable,
|
|
53
|
+
get_graph_stats: Callable,
|
|
54
|
+
enable_graph: bool,
|
|
55
|
+
invite_code: str,
|
|
56
|
+
invite_gate_enabled: bool,
|
|
57
|
+
default_port: int,
|
|
58
|
+
) -> APIRouter:
|
|
59
|
+
router = APIRouter()
|
|
60
|
+
|
|
61
|
+
@router.get("/admin/summary")
|
|
62
|
+
async def admin_summary(request: Request):
|
|
63
|
+
_, users = require_admin(request)
|
|
64
|
+
history = get_history()
|
|
65
|
+
user_msgs = [i for i in history if i.get("role") == "user"]
|
|
66
|
+
asst_msgs = [i for i in history if i.get("role") == "assistant"]
|
|
67
|
+
return {
|
|
68
|
+
"total_users": len(users),
|
|
69
|
+
"active_users": sum(1 for u in users.values() if not u.get("disabled")),
|
|
70
|
+
"admin_users": sum(1 for e in users if get_user_role(e, users) == "admin"),
|
|
71
|
+
"total_messages": len(history),
|
|
72
|
+
"user_messages": len(user_msgs),
|
|
73
|
+
"assistant_messages": len(asst_msgs),
|
|
74
|
+
"last_message_at": history[-1].get("timestamp") if history else None,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@router.get("/admin/stats")
|
|
78
|
+
async def admin_stats(request: Request):
|
|
79
|
+
require_admin(request)
|
|
80
|
+
history = get_history()
|
|
81
|
+
daily: dict = defaultdict(lambda: {"user": 0, "assistant": 0})
|
|
82
|
+
for item in history:
|
|
83
|
+
ts = item.get("timestamp", "")
|
|
84
|
+
day = ts[:10] if ts else "unknown"
|
|
85
|
+
role = item.get("role", "")
|
|
86
|
+
if role in ("user", "assistant"):
|
|
87
|
+
daily[day][role] += 1
|
|
88
|
+
sorted_days = sorted(daily.keys())[-14:]
|
|
89
|
+
return {"daily": [{"date": d, "user": daily[d]["user"], "assistant": daily[d]["assistant"]} for d in sorted_days]}
|
|
90
|
+
|
|
91
|
+
@router.get("/admin/users")
|
|
92
|
+
async def admin_users(request: Request):
|
|
93
|
+
_, users = require_admin(request)
|
|
94
|
+
return [public_user(email, user, users) for email, user in users.items()]
|
|
95
|
+
|
|
96
|
+
@router.get("/admin/sensitivity")
|
|
97
|
+
async def admin_sensitivity(request: Request):
|
|
98
|
+
require_admin(request)
|
|
99
|
+
return build_sensitivity_report(get_history())
|
|
100
|
+
|
|
101
|
+
@router.get("/admin/audit")
|
|
102
|
+
async def admin_audit(request: Request):
|
|
103
|
+
_, users = require_admin(request)
|
|
104
|
+
report = build_admin_audit_report(users)
|
|
105
|
+
try:
|
|
106
|
+
report["graph"] = get_graph_stats() if enable_graph else {"disabled": True}
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logging.warning("knowledge graph stats for audit failed: %s", e)
|
|
109
|
+
report["graph"] = {"error": str(e)}
|
|
110
|
+
return report
|
|
111
|
+
|
|
112
|
+
@router.get("/vpc/status")
|
|
113
|
+
async def vpc_status(request: Request):
|
|
114
|
+
require_user(request)
|
|
115
|
+
return load_vpc_config()
|
|
116
|
+
|
|
117
|
+
@router.patch("/admin/vpc")
|
|
118
|
+
async def admin_update_vpc(req: VpcConfigUpdate, request: Request):
|
|
119
|
+
require_admin(request)
|
|
120
|
+
config = load_vpc_config()
|
|
121
|
+
update = req.dict(exclude_unset=True)
|
|
122
|
+
if "private_subnets" in update and update["private_subnets"] is not None:
|
|
123
|
+
update["private_subnets"] = [s.strip() for s in update["private_subnets"] if s.strip()]
|
|
124
|
+
config.update(update)
|
|
125
|
+
save_vpc_config(config)
|
|
126
|
+
return config
|
|
127
|
+
|
|
128
|
+
@router.patch("/admin/users/{email:path}")
|
|
129
|
+
async def admin_update_user(email: str, req: AdminUserUpdate, request: Request):
|
|
130
|
+
admin_email, users = require_admin(request)
|
|
131
|
+
if email not in users:
|
|
132
|
+
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
|
|
133
|
+
before = public_user(email, users[email], users)
|
|
134
|
+
if req.role is not None:
|
|
135
|
+
if req.role not in {"admin", "user"}:
|
|
136
|
+
raise HTTPException(status_code=400, detail="role은 admin 또는 user만 가능합니다.")
|
|
137
|
+
users[email]["role"] = req.role
|
|
138
|
+
if req.disabled is not None:
|
|
139
|
+
if email == admin_email and req.disabled:
|
|
140
|
+
raise HTTPException(status_code=400, detail="자기 자신은 비활성화할 수 없습니다.")
|
|
141
|
+
users[email]["disabled"] = req.disabled
|
|
142
|
+
save_users(users)
|
|
143
|
+
after = public_user(email, users[email], users)
|
|
144
|
+
append_audit_event("user_update", user_email=admin_email, target_email=email, before=before, after=after)
|
|
145
|
+
return after
|
|
146
|
+
|
|
147
|
+
@router.delete("/admin/users/{email:path}")
|
|
148
|
+
async def admin_delete_user(email: str, request: Request):
|
|
149
|
+
admin_email, users = require_admin(request)
|
|
150
|
+
if email == admin_email:
|
|
151
|
+
raise HTTPException(status_code=400, detail="자기 자신은 삭제할 수 없습니다.")
|
|
152
|
+
if email not in users:
|
|
153
|
+
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
|
|
154
|
+
deleted = public_user(email, users[email], users)
|
|
155
|
+
append_audit_event("user_delete", user_email=admin_email, target_email=email, deleted_user=deleted)
|
|
156
|
+
del users[email]
|
|
157
|
+
save_users(users)
|
|
158
|
+
return {"status": "ok", "deleted": deleted}
|
|
159
|
+
|
|
160
|
+
@router.get("/admin/invite-link")
|
|
161
|
+
async def admin_invite_link(request: Request):
|
|
162
|
+
require_admin(request)
|
|
163
|
+
host = request.headers.get("host", f"localhost:{default_port}")
|
|
164
|
+
scheme = "https" if request.headers.get("x-forwarded-proto") == "https" else "http"
|
|
165
|
+
url = f"{scheme}://{host}/?code={invite_code}" if invite_gate_enabled else f"{scheme}://{host}/"
|
|
166
|
+
return {"invite_url": url, "invite_code": invite_code, "gate_enabled": invite_gate_enabled}
|
|
167
|
+
|
|
168
|
+
@router.get("/admin/sso")
|
|
169
|
+
async def admin_sso(request: Request):
|
|
170
|
+
require_admin(request)
|
|
171
|
+
return public_sso_config()
|
|
172
|
+
|
|
173
|
+
@router.patch("/admin/sso")
|
|
174
|
+
async def admin_update_sso(req: SsoConfigUpdate, request: Request):
|
|
175
|
+
admin_email, _ = require_admin(request)
|
|
176
|
+
update = req.dict(exclude_unset=True)
|
|
177
|
+
saved = save_sso_config(update)
|
|
178
|
+
append_audit_event(
|
|
179
|
+
"sso_config_update",
|
|
180
|
+
user_email=admin_email,
|
|
181
|
+
provider_name=saved.get("provider_name"),
|
|
182
|
+
discovery_url=saved.get("discovery_url"),
|
|
183
|
+
enabled=bool(saved.get("enabled")),
|
|
184
|
+
)
|
|
185
|
+
return public_sso_config(saved)
|
|
186
|
+
|
|
187
|
+
return router
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Authentication API router: register, login, logout, SSO, profile."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import secrets
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Callable, Dict, Optional
|
|
9
|
+
from urllib.parse import urlencode
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
12
|
+
from fastapi.responses import JSONResponse, RedirectResponse
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UserRegister(BaseModel):
|
|
17
|
+
email: str
|
|
18
|
+
password: str
|
|
19
|
+
name: str
|
|
20
|
+
nickname: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UserLogin(BaseModel):
|
|
24
|
+
email: str
|
|
25
|
+
password: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ChangePasswordRequest(BaseModel):
|
|
29
|
+
current_password: str
|
|
30
|
+
new_password: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UpdateProfileRequest(BaseModel):
|
|
34
|
+
name: Optional[str] = None
|
|
35
|
+
nickname: Optional[str] = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_sso_states: Dict[str, float] = {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create_auth_router(
|
|
42
|
+
*,
|
|
43
|
+
load_users: Callable[[], Dict],
|
|
44
|
+
save_users: Callable[[Dict], None],
|
|
45
|
+
hash_password: Callable[[str], str],
|
|
46
|
+
verify_and_migrate: Callable[[str, str, str, Dict], bool],
|
|
47
|
+
create_session: Callable[[str], str],
|
|
48
|
+
get_session_email: Callable[[str], Optional[str]],
|
|
49
|
+
invalidate_session: Callable[[str], None],
|
|
50
|
+
extract_bearer_token: Callable[[Request], Optional[str]],
|
|
51
|
+
get_user_role: Callable[[str, Optional[Dict]], str],
|
|
52
|
+
require_user: Callable[[Request], str],
|
|
53
|
+
check_ip_rate_limit: Callable[..., None],
|
|
54
|
+
client_ip: Callable[[Request], str],
|
|
55
|
+
get_sso_settings: Callable[[], Dict],
|
|
56
|
+
get_sso_discovery: Callable[[], Any],
|
|
57
|
+
public_sso_config: Callable[..., Dict],
|
|
58
|
+
open_registration: bool,
|
|
59
|
+
session_ttl: int,
|
|
60
|
+
) -> APIRouter:
|
|
61
|
+
router = APIRouter()
|
|
62
|
+
|
|
63
|
+
@router.post("/register")
|
|
64
|
+
async def register(req: UserRegister, request: Request):
|
|
65
|
+
check_ip_rate_limit(client_ip(request), "register", max_calls=5, window_secs=3600)
|
|
66
|
+
if not open_registration:
|
|
67
|
+
raise HTTPException(status_code=403, detail="회원가입이 비활성화되어 있습니다. 관리자에게 문의하세요.")
|
|
68
|
+
users = load_users()
|
|
69
|
+
if req.email in users:
|
|
70
|
+
raise HTTPException(status_code=400, detail="이미 존재하는 이메일입니다.")
|
|
71
|
+
role = "admin" if not users else "user"
|
|
72
|
+
users[req.email] = {
|
|
73
|
+
"password": hash_password(req.password),
|
|
74
|
+
"name": req.name,
|
|
75
|
+
"nickname": req.nickname,
|
|
76
|
+
"role": role,
|
|
77
|
+
"disabled": False,
|
|
78
|
+
}
|
|
79
|
+
save_users(users)
|
|
80
|
+
msg = "회원가입 성공! 첫 번째 사용자로 관리자 권한이 부여되었습니다." if role == "admin" else "회원가입 성공!"
|
|
81
|
+
return {"status": "ok", "message": msg, "role": role}
|
|
82
|
+
|
|
83
|
+
@router.post("/login")
|
|
84
|
+
async def login(req: UserLogin, request: Request):
|
|
85
|
+
check_ip_rate_limit(client_ip(request), "login", max_calls=10, window_secs=300)
|
|
86
|
+
users = load_users()
|
|
87
|
+
user = users.get(req.email)
|
|
88
|
+
if not user or not verify_and_migrate(req.email, req.password, user.get("password", ""), users):
|
|
89
|
+
raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 틀렸습니다.")
|
|
90
|
+
if user.get("disabled"):
|
|
91
|
+
raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
|
|
92
|
+
role = get_user_role(req.email, users)
|
|
93
|
+
token = create_session(req.email)
|
|
94
|
+
response = JSONResponse(content={
|
|
95
|
+
"status": "ok",
|
|
96
|
+
"nickname": user["nickname"],
|
|
97
|
+
"name": user["name"],
|
|
98
|
+
"email": req.email,
|
|
99
|
+
"role": role,
|
|
100
|
+
"is_admin": role == "admin",
|
|
101
|
+
})
|
|
102
|
+
response.set_cookie(key="session_token", value=token, httponly=True, samesite="lax", max_age=session_ttl)
|
|
103
|
+
return response
|
|
104
|
+
|
|
105
|
+
@router.get("/auth/sso/config")
|
|
106
|
+
async def sso_config_endpoint():
|
|
107
|
+
return public_sso_config()
|
|
108
|
+
|
|
109
|
+
@router.get("/auth/sso/login")
|
|
110
|
+
async def sso_login():
|
|
111
|
+
settings = get_sso_settings()
|
|
112
|
+
discovery = await get_sso_discovery()
|
|
113
|
+
if not settings.get("enabled") or not discovery:
|
|
114
|
+
raise HTTPException(status_code=503, detail="SSO가 설정되지 않았습니다.")
|
|
115
|
+
state = secrets.token_urlsafe(16)
|
|
116
|
+
_sso_states[state] = time.time()
|
|
117
|
+
params = urlencode({
|
|
118
|
+
"client_id": settings["client_id"],
|
|
119
|
+
"response_type": "code",
|
|
120
|
+
"redirect_uri": settings["redirect_uri"],
|
|
121
|
+
"scope": settings.get("scopes") or "openid email profile",
|
|
122
|
+
"state": state,
|
|
123
|
+
})
|
|
124
|
+
return RedirectResponse(f"{discovery['authorization_endpoint']}?{params}")
|
|
125
|
+
|
|
126
|
+
@router.get("/auth/sso/callback")
|
|
127
|
+
async def sso_callback(code: str = "", state: str = "", error: str = ""):
|
|
128
|
+
if error:
|
|
129
|
+
return RedirectResponse(f"/?sso_error={error}")
|
|
130
|
+
ts = _sso_states.pop(state, None)
|
|
131
|
+
if ts is None or time.time() - ts > 300:
|
|
132
|
+
raise HTTPException(status_code=400, detail="유효하지 않은 SSO 상태입니다.")
|
|
133
|
+
settings = get_sso_settings()
|
|
134
|
+
discovery = await get_sso_discovery()
|
|
135
|
+
if not settings.get("enabled") or not discovery:
|
|
136
|
+
raise HTTPException(status_code=503, detail="SSO 설정 오류입니다.")
|
|
137
|
+
import httpx as _httpx
|
|
138
|
+
async with _httpx.AsyncClient() as c:
|
|
139
|
+
r = await c.post(discovery["token_endpoint"], data={
|
|
140
|
+
"grant_type": "authorization_code",
|
|
141
|
+
"code": code,
|
|
142
|
+
"redirect_uri": settings["redirect_uri"],
|
|
143
|
+
"client_id": settings["client_id"],
|
|
144
|
+
"client_secret": settings["client_secret"],
|
|
145
|
+
}, headers={"Accept": "application/json"}, timeout=15)
|
|
146
|
+
tokens = r.json()
|
|
147
|
+
id_token = tokens.get("id_token")
|
|
148
|
+
if not id_token:
|
|
149
|
+
raise HTTPException(status_code=400, detail="ID 토큰을 받지 못했습니다.")
|
|
150
|
+
padded = id_token.split(".")[1] + "=="
|
|
151
|
+
payload = json.loads(base64.urlsafe_b64decode(padded))
|
|
152
|
+
email = payload.get("email") or payload.get("preferred_username") or payload.get("upn") or ""
|
|
153
|
+
if not email:
|
|
154
|
+
raise HTTPException(status_code=400, detail="이메일을 확인할 수 없습니다.")
|
|
155
|
+
users = load_users()
|
|
156
|
+
if email not in users:
|
|
157
|
+
is_first = len(users) == 0
|
|
158
|
+
users[email] = {
|
|
159
|
+
"password": "",
|
|
160
|
+
"name": payload.get("name", email.split("@")[0]),
|
|
161
|
+
"nickname": payload.get("given_name", email.split("@")[0]),
|
|
162
|
+
"role": "admin" if is_first else "user",
|
|
163
|
+
"disabled": False,
|
|
164
|
+
"sso": True,
|
|
165
|
+
}
|
|
166
|
+
save_users(users)
|
|
167
|
+
if users[email].get("disabled"):
|
|
168
|
+
raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
|
|
169
|
+
token = create_session(email)
|
|
170
|
+
resp = RedirectResponse("/chat", status_code=302)
|
|
171
|
+
resp.set_cookie("session_token", token, httponly=True, samesite="lax", max_age=session_ttl)
|
|
172
|
+
return resp
|
|
173
|
+
|
|
174
|
+
@router.post("/logout")
|
|
175
|
+
async def logout(request: Request):
|
|
176
|
+
token = extract_bearer_token(request)
|
|
177
|
+
if token:
|
|
178
|
+
invalidate_session(token)
|
|
179
|
+
response = JSONResponse(content={"status": "ok"})
|
|
180
|
+
response.delete_cookie("session_token")
|
|
181
|
+
return response
|
|
182
|
+
|
|
183
|
+
@router.post("/account/change-password")
|
|
184
|
+
async def change_password(req: ChangePasswordRequest, request: Request):
|
|
185
|
+
email = require_user(request)
|
|
186
|
+
if not email:
|
|
187
|
+
raise HTTPException(status_code=401, detail="인증이 필요합니다.")
|
|
188
|
+
if len(req.new_password) < 4:
|
|
189
|
+
raise HTTPException(status_code=400, detail="새 비밀번호는 4자 이상이어야 합니다.")
|
|
190
|
+
users = load_users()
|
|
191
|
+
user = users.get(email)
|
|
192
|
+
if not user:
|
|
193
|
+
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
|
|
194
|
+
if not verify_and_migrate(email, req.current_password, user.get("password", ""), users):
|
|
195
|
+
raise HTTPException(status_code=401, detail="현재 비밀번호가 틀렸습니다.")
|
|
196
|
+
users[email]["password"] = hash_password(req.new_password)
|
|
197
|
+
save_users(users)
|
|
198
|
+
return {"status": "ok", "message": "비밀번호가 변경되었습니다."}
|
|
199
|
+
|
|
200
|
+
@router.patch("/account/profile")
|
|
201
|
+
async def update_profile(req: UpdateProfileRequest, request: Request):
|
|
202
|
+
email = require_user(request)
|
|
203
|
+
if not email:
|
|
204
|
+
raise HTTPException(status_code=401, detail="인증이 필요합니다.")
|
|
205
|
+
if req.name is not None and not req.name.strip():
|
|
206
|
+
raise HTTPException(status_code=400, detail="이름을 입력해주세요.")
|
|
207
|
+
if req.nickname is not None and not req.nickname.strip():
|
|
208
|
+
raise HTTPException(status_code=400, detail="닉네임을 입력해주세요.")
|
|
209
|
+
users = load_users()
|
|
210
|
+
user = users.get(email)
|
|
211
|
+
if not user:
|
|
212
|
+
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
|
|
213
|
+
if req.name is not None:
|
|
214
|
+
users[email]["name"] = req.name.strip()
|
|
215
|
+
if req.nickname is not None:
|
|
216
|
+
users[email]["nickname"] = req.nickname.strip()
|
|
217
|
+
save_users(users)
|
|
218
|
+
return {"status": "ok", "name": users[email]["name"], "nickname": users[email]["nickname"]}
|
|
219
|
+
|
|
220
|
+
@router.get("/account/profile")
|
|
221
|
+
async def get_profile(request: Request):
|
|
222
|
+
email = require_user(request)
|
|
223
|
+
if not email:
|
|
224
|
+
raise HTTPException(status_code=401, detail="인증이 필요합니다.")
|
|
225
|
+
users = load_users()
|
|
226
|
+
user = users.get(email)
|
|
227
|
+
if not user:
|
|
228
|
+
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
|
|
229
|
+
role = get_user_role(email, users)
|
|
230
|
+
return {"email": email, "name": user.get("name", ""), "nickname": user.get("nickname", ""),
|
|
231
|
+
"role": role, "is_admin": role == "admin"}
|
|
232
|
+
|
|
233
|
+
return router
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core utilities: security, sessions, audit."""
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Audit logging, sensitivity analysis, and admin reporting."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
_history_lock = threading.Lock()
|
|
13
|
+
|
|
14
|
+
SENSITIVE_PATTERNS = [
|
|
15
|
+
{"key": "rrn", "label": "주민등록번호", "severity": "high", "pattern": r"\b\d{6}[- ]?[1-4]\d{6}\b"},
|
|
16
|
+
{"key": "card", "label": "카드번호", "severity": "high", "pattern": r"\b(?:\d[ -]?){13,19}\b"},
|
|
17
|
+
{"key": "account", "label": "계좌번호", "severity": "medium", "pattern": r"(?:계좌|account|bank).{0,12}\d[\d -]{8,24}"},
|
|
18
|
+
{"key": "password", "label": "비밀번호/인증정보", "severity": "high", "pattern": r"(?:password|passwd|비밀번호|암호|token|api[_ -]?key|secret)\s*[:=]\s*[^\s,;]{4,}"},
|
|
19
|
+
{"key": "email", "label": "이메일", "severity": "low", "pattern": r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"},
|
|
20
|
+
{"key": "phone", "label": "전화번호", "severity": "medium", "pattern": r"\b(?:01[016789]|02|0[3-6][1-5])[- ]?\d{3,4}[- ]?\d{4}\b"},
|
|
21
|
+
{"key": "address", "label": "주소", "severity": "medium", "pattern": r"(?:[가-힣]+(?:시|도)\s*)?[가-힣]+(?:시|군|구)\s+[가-힣0-9\s-]+(?:로|길)\s*\d*"},
|
|
22
|
+
{"key": "health", "label": "건강/의료정보", "severity": "medium", "pattern": r"(?:진단|병명|처방|복용|수술|장애|임신|혈액형|알레르기|medical|diagnosis)"},
|
|
23
|
+
]
|
|
24
|
+
SEVERITY_SCORE = {"low": 1, "medium": 2, "high": 3}
|
|
25
|
+
AUDIT_DELETE_EVENTS = {"conversation_delete", "history_delete", "user_delete"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_audit_log(audit_file: Path) -> List[Dict]:
|
|
29
|
+
if not os.path.exists(audit_file):
|
|
30
|
+
return []
|
|
31
|
+
try:
|
|
32
|
+
with open(audit_file, "r", encoding="utf-8") as f:
|
|
33
|
+
data = json.load(f)
|
|
34
|
+
return data if isinstance(data, list) else []
|
|
35
|
+
except Exception as e:
|
|
36
|
+
logging.warning("get_audit_log failed: %s", e)
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def append_audit_event(audit_file: Path, event_type: str, **payload) -> None:
|
|
41
|
+
try:
|
|
42
|
+
event = {
|
|
43
|
+
"event_type": event_type,
|
|
44
|
+
"timestamp": datetime.now().isoformat(),
|
|
45
|
+
**payload,
|
|
46
|
+
}
|
|
47
|
+
with _history_lock:
|
|
48
|
+
events = get_audit_log(audit_file)
|
|
49
|
+
events.append(event)
|
|
50
|
+
if len(events) > 5000:
|
|
51
|
+
events = events[-5000:]
|
|
52
|
+
tmp_path = str(audit_file) + ".tmp"
|
|
53
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
54
|
+
json.dump(events, f, ensure_ascii=False, indent=2)
|
|
55
|
+
os.replace(tmp_path, audit_file)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logging.warning("append_audit_event failed: %s", e)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def mask_sensitive_text(text: str, matches: List[Dict]) -> str:
|
|
61
|
+
masked = text
|
|
62
|
+
for item in sorted(matches, key=lambda m: m["start"], reverse=True):
|
|
63
|
+
value = masked[item["start"]:item["end"]]
|
|
64
|
+
if len(value) <= 4:
|
|
65
|
+
replacement = "*" * len(value)
|
|
66
|
+
else:
|
|
67
|
+
replacement = value[:2] + "*" * min(len(value) - 4, 12) + value[-2:]
|
|
68
|
+
masked = masked[:item["start"]] + replacement + masked[item["end"]:]
|
|
69
|
+
return masked
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def classify_sensitive_message(item: Dict, index: int) -> Dict:
|
|
73
|
+
content = str(item.get("content", ""))
|
|
74
|
+
found = []
|
|
75
|
+
seen: set = set()
|
|
76
|
+
for rule in SENSITIVE_PATTERNS:
|
|
77
|
+
for match in re.finditer(rule["pattern"], content, flags=re.IGNORECASE):
|
|
78
|
+
key = (rule["key"], match.start(), match.end())
|
|
79
|
+
if key in seen:
|
|
80
|
+
continue
|
|
81
|
+
seen.add(key)
|
|
82
|
+
found.append({
|
|
83
|
+
"type": rule["key"],
|
|
84
|
+
"label": rule["label"],
|
|
85
|
+
"severity": rule["severity"],
|
|
86
|
+
"start": match.start(),
|
|
87
|
+
"end": match.end(),
|
|
88
|
+
})
|
|
89
|
+
severity = "none"
|
|
90
|
+
if found:
|
|
91
|
+
severity = max(found, key=lambda m: SEVERITY_SCORE[m["severity"]])["severity"]
|
|
92
|
+
preview_text = content[:240]
|
|
93
|
+
preview_matches = [m for m in found if m["start"] < len(preview_text)]
|
|
94
|
+
return {
|
|
95
|
+
"index": index,
|
|
96
|
+
"role": item.get("role", ""),
|
|
97
|
+
"user_email": item.get("user_email"),
|
|
98
|
+
"user_nickname": item.get("user_nickname") or item.get("user_email") or "Unknown",
|
|
99
|
+
"timestamp": item.get("timestamp"),
|
|
100
|
+
"sensitivity": severity,
|
|
101
|
+
"labels": sorted({m["label"] for m in found}),
|
|
102
|
+
"risk_fields": found,
|
|
103
|
+
"compliance_fields": [] if found else ["민감정보 미검출"],
|
|
104
|
+
"preview": mask_sensitive_text(preview_text, preview_matches),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_sensitivity_report(history: List[Dict]) -> Dict:
|
|
109
|
+
items = [classify_sensitive_message(item, i) for i, item in enumerate(history)]
|
|
110
|
+
risky = [x for x in items if x["risk_fields"]]
|
|
111
|
+
compliant = [x for x in items if not x["risk_fields"]]
|
|
112
|
+
field_counts: Dict[str, int] = {}
|
|
113
|
+
user_counts: Dict[str, int] = {}
|
|
114
|
+
severity_counts = {"high": 0, "medium": 0, "low": 0, "none": len(compliant)}
|
|
115
|
+
for item in risky:
|
|
116
|
+
severity_counts[item["sensitivity"]] += 1
|
|
117
|
+
user_key = item.get("user_email") or item.get("user_nickname") or "Unknown"
|
|
118
|
+
user_counts[user_key] = user_counts.get(user_key, 0) + 1
|
|
119
|
+
for field in item["risk_fields"]:
|
|
120
|
+
field_counts[field["label"]] = field_counts.get(field["label"], 0) + 1
|
|
121
|
+
return {
|
|
122
|
+
"summary": {
|
|
123
|
+
"total_messages": len(items),
|
|
124
|
+
"risky_messages": len(risky),
|
|
125
|
+
"compliant_messages": len(compliant),
|
|
126
|
+
"risk_rate": round((len(risky) / len(items)) * 100, 1) if items else 0,
|
|
127
|
+
"severity_counts": severity_counts,
|
|
128
|
+
"field_counts": field_counts,
|
|
129
|
+
"user_counts": user_counts,
|
|
130
|
+
},
|
|
131
|
+
"risk_fields": risky[-30:],
|
|
132
|
+
"compliance_fields": compliant[-30:],
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def build_admin_audit_report(
|
|
137
|
+
audit_file: Path,
|
|
138
|
+
users: Dict,
|
|
139
|
+
*,
|
|
140
|
+
get_user_role: Callable[[str, Optional[Dict]], str],
|
|
141
|
+
graph_stats: Optional[Dict] = None,
|
|
142
|
+
) -> Dict:
|
|
143
|
+
events = get_audit_log(audit_file)
|
|
144
|
+
|
|
145
|
+
def _user_bucket(email: Optional[str], nickname: Optional[str] = None) -> Dict:
|
|
146
|
+
user = users.get(email or "", {})
|
|
147
|
+
return {
|
|
148
|
+
"email": email or "Unknown",
|
|
149
|
+
"nickname": nickname or user.get("nickname") or user.get("name") or email or "Unknown",
|
|
150
|
+
"role": get_user_role(email, users) if email else "unknown",
|
|
151
|
+
"disabled": bool(user.get("disabled")) if user else False,
|
|
152
|
+
"user_messages": 0, "assistant_messages": 0, "document_uploads": 0,
|
|
153
|
+
"clear_events": 0, "delete_events": 0, "sensitive_events": 0,
|
|
154
|
+
"high_sensitive_events": 0, "total_content_chars": 0, "last_activity_at": None,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
per_user: Dict[str, Dict] = {}
|
|
158
|
+
|
|
159
|
+
def ensure(email: Optional[str], nickname: Optional[str] = None) -> Dict:
|
|
160
|
+
key = email or nickname or "Unknown"
|
|
161
|
+
if key not in per_user:
|
|
162
|
+
per_user[key] = _user_bucket(email, nickname)
|
|
163
|
+
elif nickname and per_user[key].get("nickname") in {"Unknown", email, None}:
|
|
164
|
+
per_user[key]["nickname"] = nickname
|
|
165
|
+
return per_user[key]
|
|
166
|
+
|
|
167
|
+
for email, user in users.items():
|
|
168
|
+
ensure(email, user.get("nickname") or user.get("name"))
|
|
169
|
+
|
|
170
|
+
summary: Dict[str, Any] = {
|
|
171
|
+
"total_events": len(events), "chat_events": 0, "user_messages": 0,
|
|
172
|
+
"assistant_messages": 0, "document_uploads": 0, "clear_events": 0,
|
|
173
|
+
"delete_events": 0, "sensitive_events": 0, "high_sensitive_events": 0,
|
|
174
|
+
}
|
|
175
|
+
sensitive_events: List[Dict] = []
|
|
176
|
+
deletion_events: List[Dict] = []
|
|
177
|
+
|
|
178
|
+
for event in events:
|
|
179
|
+
event_type = event.get("event_type")
|
|
180
|
+
email = event.get("user_email")
|
|
181
|
+
u = ensure(email, event.get("user_nickname"))
|
|
182
|
+
ts = event.get("timestamp")
|
|
183
|
+
if ts and (not u["last_activity_at"] or ts > u["last_activity_at"]):
|
|
184
|
+
u["last_activity_at"] = ts
|
|
185
|
+
u["total_content_chars"] += int(event.get("content_chars") or event.get("extracted_chars") or 0)
|
|
186
|
+
sensitivity = event.get("sensitivity") or "none"
|
|
187
|
+
labels = event.get("sensitive_labels") or []
|
|
188
|
+
is_sensitive = sensitivity != "none" or bool(labels)
|
|
189
|
+
|
|
190
|
+
if event_type == "chat_message":
|
|
191
|
+
summary["chat_events"] += 1
|
|
192
|
+
if event.get("role") == "user":
|
|
193
|
+
summary["user_messages"] += 1
|
|
194
|
+
u["user_messages"] += 1
|
|
195
|
+
elif event.get("role") == "assistant":
|
|
196
|
+
summary["assistant_messages"] += 1
|
|
197
|
+
u["assistant_messages"] += 1
|
|
198
|
+
elif event_type == "document_upload":
|
|
199
|
+
summary["document_uploads"] += 1
|
|
200
|
+
u["document_uploads"] += 1
|
|
201
|
+
elif event_type == "clear_command":
|
|
202
|
+
summary["clear_events"] += 1
|
|
203
|
+
u["clear_events"] += 1
|
|
204
|
+
elif event_type in AUDIT_DELETE_EVENTS:
|
|
205
|
+
summary["delete_events"] += 1
|
|
206
|
+
u["delete_events"] += 1
|
|
207
|
+
deletion_events.append(_public_audit_event(event))
|
|
208
|
+
|
|
209
|
+
if is_sensitive:
|
|
210
|
+
summary["sensitive_events"] += 1
|
|
211
|
+
u["sensitive_events"] += 1
|
|
212
|
+
if sensitivity == "high":
|
|
213
|
+
summary["high_sensitive_events"] += 1
|
|
214
|
+
u["high_sensitive_events"] += 1
|
|
215
|
+
sensitive_events.append(_public_audit_event(event))
|
|
216
|
+
|
|
217
|
+
allowed_keys = {
|
|
218
|
+
"event_type", "timestamp", "role", "user_email", "user_nickname", "source",
|
|
219
|
+
"conversation_id", "command", "scope", "target_email", "filename", "mime_type",
|
|
220
|
+
"ext", "bytes", "extracted_chars", "graph_node", "keep_last", "removed", "kept",
|
|
221
|
+
"started_at", "sensitivity", "sensitive_labels", "content_preview", "content_chars",
|
|
222
|
+
}
|
|
223
|
+
recent = [_public_audit_event(e) for e in events[-50:]]
|
|
224
|
+
|
|
225
|
+
result: Dict[str, Any] = {
|
|
226
|
+
"summary": summary,
|
|
227
|
+
"per_user": sorted(per_user.values(), key=lambda u: u.get("last_activity_at") or "", reverse=True),
|
|
228
|
+
"recent_events": list(reversed(recent)),
|
|
229
|
+
"sensitive_events": sensitive_events[-30:],
|
|
230
|
+
"deletion_events": deletion_events[-30:],
|
|
231
|
+
}
|
|
232
|
+
if graph_stats:
|
|
233
|
+
result["summary"]["graph_nodes"] = graph_stats.get("total_nodes", 0)
|
|
234
|
+
result["summary"]["graph_edges"] = graph_stats.get("total_edges", 0)
|
|
235
|
+
return result
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _public_audit_event(event: Dict) -> Dict:
|
|
239
|
+
allowed = {
|
|
240
|
+
"event_type", "timestamp", "role", "user_email", "user_nickname", "source",
|
|
241
|
+
"conversation_id", "command", "scope", "target_email", "filename", "mime_type",
|
|
242
|
+
"ext", "bytes", "extracted_chars", "graph_node", "keep_last", "removed", "kept",
|
|
243
|
+
"started_at", "sensitivity", "sensitive_labels", "content_preview", "content_chars",
|
|
244
|
+
}
|
|
245
|
+
return {k: event.get(k) for k in allowed if k in event}
|