ltcai 0.1.31 → 0.2.1

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/server.py CHANGED
@@ -49,6 +49,27 @@ from llm_router import AsyncOpenAI, LLMRouter, OPENAI_COMPATIBLE_PROVIDERS, HF_M
49
49
  from knowledge_graph import KnowledgeGraphStore
50
50
  from knowledge_graph_api import create_knowledge_graph_router
51
51
  from local_knowledge_api import LocalKnowledgeWatcher, create_local_knowledge_router
52
+ from latticeai.core.security import (
53
+ hash_password as _hash_password,
54
+ verify_password as _verify_password,
55
+ host_is_loopback as _host_is_loopback_impl,
56
+ client_ip as _client_ip_impl,
57
+ bytes_match_extension as _bytes_match_extension_impl,
58
+ redact_secret_text as _redact_secret_text,
59
+ check_ip_rate_limit as _check_ip_rate_limit,
60
+ enforce_rate_limit as _enforce_rate_limit,
61
+ )
62
+ from latticeai.core.sessions import SessionStore as _SessionStore
63
+ from latticeai.core.audit import (
64
+ get_audit_log as _get_audit_log,
65
+ append_audit_event as _append_audit_event,
66
+ classify_sensitive_message as _classify_sensitive_message,
67
+ mask_sensitive_text as _mask_sensitive_text,
68
+ build_sensitivity_report as _build_sensitivity_report,
69
+ build_admin_audit_report as _build_admin_audit_report,
70
+ )
71
+ from latticeai.api.auth import create_auth_router
72
+ from latticeai.api.admin import create_admin_router
52
73
  import mcp_registry
53
74
  from mcp_registry import (
54
75
  MCP_REGISTRY, _THIRD_PARTY_SKILL_SOURCES, _KNOWN_REPO_LICENSES,
@@ -191,12 +212,7 @@ IS_PUBLIC_MODE = APP_MODE == "public"
191
212
  DEFAULT_HOST = env_value("LATTICEAI_HOST", "127.0.0.1")
192
213
  DEFAULT_PORT = int(env_value("LATTICEAI_PORT", "4825"))
193
214
  def _host_is_loopback(host: str) -> bool:
194
- if host in {"localhost", "127.0.0.1", "::1"}:
195
- return True
196
- try:
197
- return ipaddress.ip_address(host).is_loopback
198
- except ValueError:
199
- return False
215
+ return _host_is_loopback_impl(host)
200
216
 
201
217
  NETWORK_EXPOSED = not _host_is_loopback(DEFAULT_HOST)
202
218
  ENABLE_TELEGRAM = env_bool("LATTICEAI_ENABLE_TELEGRAM", default=not IS_PUBLIC_MODE)
@@ -246,19 +262,12 @@ async def _get_sso_discovery() -> Optional[Dict]:
246
262
  return None
247
263
  return _sso_discovery_cache
248
264
 
249
- # ── Password hashing (stdlib scrypt, no extra deps) ────────────────────────────
265
+ # ── Password hashing delegated to latticeai.core.security ────────────────────
250
266
  def hash_password(password: str) -> str:
251
- salt = secrets.token_hex(16)
252
- key = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1)
253
- return f"{salt}:{key.hex()}"
267
+ return _hash_password(password)
254
268
 
255
269
  def verify_password(password: str, hashed: str) -> bool:
256
- try:
257
- salt, key_hex = hashed.split(":", 1)
258
- key = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1)
259
- return secrets.compare_digest(key.hex(), key_hex)
260
- except Exception:
261
- return False
270
+ return _verify_password(password, hashed)
262
271
 
263
272
  def verify_and_migrate_password(email: str, plain: str, stored: str, users: Dict) -> bool:
264
273
  """평문 비밀번호를 투명하게 해시로 마이그레이션. 마이그레이션 발생 시 audit log 남김."""
@@ -275,89 +284,24 @@ def verify_and_migrate_password(email: str, plain: str, stored: str, users: Dict
275
284
  return True
276
285
  return False
277
286
 
278
- # ── Session store (file-backed, survives restarts) ────────────────────────────
279
- # 24-hour TTL with sliding-window refresh — every authenticated request bumps
280
- # created_at, so an active user stays logged in while idle sessions auto-expire.
281
- _SESSION_TTL = 60 * 60 * 24 # 24 hours
282
- _SESSION_REFRESH_THRESHOLD = 60 * 15 # only persist if >15 min since last bump (write amplification guard)
283
- _sessions_lock = threading.Lock()
284
-
285
- def _sessions_file() -> Path:
286
- data_dir = Path(os.getenv("LATTICEAI_DATA_DIR") or (Path.home() / ".ltcai"))
287
- data_dir.mkdir(parents=True, exist_ok=True)
288
- return data_dir / "sessions.json"
289
-
290
- def _load_sessions() -> Dict[str, tuple]:
291
- try:
292
- f = _sessions_file()
293
- if f.exists():
294
- raw = json.loads(f.read_text())
295
- return {k: tuple(v) for k, v in raw.items()}
296
- except Exception as e:
297
- logging.warning("_load_sessions failed (starting empty): %s", e)
298
- return {}
299
-
300
- def _persist_sessions(sessions: Dict[str, tuple]) -> None:
301
- try:
302
- _sessions_file().write_text(json.dumps({k: list(v) for k, v in sessions.items()}, ensure_ascii=False))
303
- except Exception as e:
304
- logging.warning("_persist_sessions failed: %s", e)
305
-
306
- _sessions: Dict[str, tuple] = _load_sessions()
307
-
308
- # ── Rate limiting ─────────────────────────────────────────────────────────────
309
- _rate_windows: dict[tuple[str, str], list[float]] = {}
310
- _rate_lock = threading.Lock()
287
+ # ── Session store delegated to latticeai.core.sessions ──────────────────────
288
+ _SESSION_TTL = 60 * 60 * 24
289
+ _session_store = _SessionStore()
311
290
 
312
291
  def _check_rate_limit(ip: str, action: str, max_calls: int, window_secs: float) -> None:
313
- key = (ip, action)
314
- now = time.time()
315
- cutoff = now - window_secs
316
- with _rate_lock:
317
- calls = [t for t in _rate_windows.get(key, []) if t > cutoff]
318
- if len(calls) >= max_calls:
319
- raise HTTPException(status_code=429, detail="요청이 너무 많습니다. 잠시 후 다시 시도하세요.")
320
- calls.append(now)
321
- _rate_windows[key] = calls
292
+ _check_ip_rate_limit(ip, action, max_calls=max_calls, window_secs=window_secs)
322
293
 
323
294
  def _client_ip(request: Request) -> str:
324
- for header in ("CF-Connecting-IP", "X-Forwarded-For"):
325
- val = request.headers.get(header)
326
- if val:
327
- return val.split(",")[0].strip()
328
- return request.client.host if request.client else "unknown"
329
-
330
- # ─────────────────────────────────────────────────────────────────────────────
295
+ return _client_ip_impl(request)
331
296
 
332
297
  def create_session(email: str) -> str:
333
- token = secrets.token_urlsafe(32)
334
- with _sessions_lock:
335
- _sessions[token] = (email, time.time())
336
- _persist_sessions(_sessions)
337
- return token
298
+ return _session_store.create(email)
338
299
 
339
300
  def get_session_email(token: str) -> Optional[str]:
340
- """Return email for a valid session, sliding the expiry forward on activity."""
341
- now = time.time()
342
- with _sessions_lock:
343
- entry = _sessions.get(token)
344
- if entry is None:
345
- return None
346
- email, created_at = entry
347
- if now - created_at > _SESSION_TTL:
348
- _sessions.pop(token, None)
349
- _persist_sessions(_sessions)
350
- return None
351
- # Sliding refresh: only update if the timestamp drifted enough to be worth a disk write
352
- if now - created_at > _SESSION_REFRESH_THRESHOLD:
353
- _sessions[token] = (email, now)
354
- _persist_sessions(_sessions)
355
- return email
301
+ return _session_store.get_email(token)
356
302
 
357
303
  def invalidate_session(token: str) -> None:
358
- with _sessions_lock:
359
- _sessions.pop(token, None)
360
- _persist_sessions(_sessions)
304
+ _session_store.invalidate(token)
361
305
 
362
306
  # ── User Management Logic ──────────────────────────────────────────────────
363
307
  BASE_DIR = Path(__file__).resolve().parent
@@ -667,34 +611,10 @@ async def install_mcp(mcp_id: str) -> Dict:
667
611
  _history_lock = threading.Lock()
668
612
 
669
613
  def get_audit_log() -> List[Dict]:
670
- if not os.path.exists(AUDIT_FILE):
671
- return []
672
- try:
673
- with open(AUDIT_FILE, "r", encoding="utf-8") as f:
674
- data = json.load(f)
675
- return data if isinstance(data, list) else []
676
- except Exception as e:
677
- logging.warning("get_audit_log failed: %s", e)
678
- return []
614
+ return _get_audit_log(AUDIT_FILE)
679
615
 
680
616
  def append_audit_event(event_type: str, **payload) -> None:
681
- try:
682
- event = {
683
- "event_type": event_type,
684
- "timestamp": datetime.now().isoformat(),
685
- **payload,
686
- }
687
- with _history_lock:
688
- events = get_audit_log()
689
- events.append(event)
690
- if len(events) > 5000:
691
- events = events[-5000:]
692
- tmp_path = str(AUDIT_FILE) + ".tmp"
693
- with open(tmp_path, "w", encoding="utf-8") as f:
694
- json.dump(events, f, ensure_ascii=False, indent=2)
695
- os.replace(tmp_path, AUDIT_FILE)
696
- except Exception as e:
697
- logging.warning("append_audit_event failed: %s", e)
617
+ _append_audit_event(AUDIT_FILE, event_type, **payload)
698
618
 
699
619
  def save_to_history(
700
620
  role: str,
@@ -759,18 +679,7 @@ def save_to_history(
759
679
  logging.warning("save_to_history failed: %s", e)
760
680
 
761
681
  def redact_secret_text(text: str) -> str:
762
- if not text:
763
- return ""
764
- patterns = [
765
- r"(?i)(api[_ -]?key|secret|token|password|passwd)\s*[:=]\s*['\"]?([A-Za-z0-9_\-\.]{12,})['\"]?",
766
- r"\b(sk-[A-Za-z0-9_\-]{16,})\b",
767
- r"\b(xai-[A-Za-z0-9_\-]{16,})\b",
768
- r"\b(gsk_[A-Za-z0-9_\-]{16,})\b",
769
- ]
770
- redacted = str(text)
771
- for pattern in patterns:
772
- redacted = re.sub(pattern, lambda m: f"{m.group(1)}=[REDACTED]" if len(m.groups()) > 1 else "[REDACTED]", redacted)
773
- return redacted
682
+ return _redact_secret_text(text)
774
683
 
775
684
  def get_history():
776
685
  if not os.path.exists(HISTORY_FILE):
@@ -969,69 +878,14 @@ def require_user(request: Request) -> str:
969
878
  return email or ""
970
879
 
971
880
 
972
- # ── Rate limiting ─────────────────────────────────────────────────────────────
973
- # Per-user token bucket. Disabled when LATTICEAI_RATE_LIMIT=0 (default: enabled).
881
+ # ── Rate limiting & file validation — delegated to latticeai.core.security ────
974
882
  _RATE_LIMIT_ENABLED = os.getenv("LATTICEAI_RATE_LIMIT", "1") != "0"
975
- _rate_buckets: Dict[str, Dict[str, float]] = {}
976
- _rate_lock = threading.Lock()
977
-
978
- # (capacity, refill_per_second) per endpoint family
979
- _RATE_LIMITS = {
980
- "chat": (30, 0.5), # 30 burst, 30/min sustained
981
- "agent": (10, 0.1), # 10 burst, 6/min sustained (agent is expensive)
982
- "upload": (20, 0.2), # 20 burst, 12/min sustained
983
- }
984
-
985
883
 
986
884
  def enforce_rate_limit(email: str, bucket_key: str) -> None:
987
- """Raise HTTP 429 if user exceeds the bucket. No-op when disabled or unauth'd."""
988
- if not _RATE_LIMIT_ENABLED or not email:
989
- return
990
- cap, refill = _RATE_LIMITS.get(bucket_key, (60, 1.0))
991
- key = f"{email}:{bucket_key}"
992
- now = time.time()
993
- with _rate_lock:
994
- bucket = _rate_buckets.get(key)
995
- if bucket is None:
996
- _rate_buckets[key] = {"tokens": cap - 1, "ts": now}
997
- return
998
- elapsed = now - bucket["ts"]
999
- bucket["tokens"] = min(cap, bucket["tokens"] + elapsed * refill)
1000
- bucket["ts"] = now
1001
- if bucket["tokens"] < 1:
1002
- retry_after = max(1, int((1 - bucket["tokens"]) / refill))
1003
- raise HTTPException(
1004
- status_code=429,
1005
- detail=f"Rate limit exceeded for {bucket_key}. Retry after {retry_after}s.",
1006
- headers={"Retry-After": str(retry_after)},
1007
- )
1008
- bucket["tokens"] -= 1
1009
-
1010
-
1011
- # ── File magic-number validation ──────────────────────────────────────────────
1012
- # Map of extension → list of byte-prefix signatures (any-match). Files without
1013
- # distinctive magic (.txt, .md, .csv) skip the check.
1014
- _FILE_MAGIC: Dict[str, List[bytes]] = {
1015
- ".pdf": [b"%PDF-"],
1016
- ".docx": [b"PK\x03\x04"],
1017
- ".xlsx": [b"PK\x03\x04"],
1018
- ".pptx": [b"PK\x03\x04"],
1019
- ".zip": [b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"],
1020
- ".png": [b"\x89PNG\r\n\x1a\n"],
1021
- ".jpg": [b"\xff\xd8\xff"],
1022
- ".jpeg": [b"\xff\xd8\xff"],
1023
- ".gif": [b"GIF87a", b"GIF89a"],
1024
- }
1025
-
885
+ _enforce_rate_limit(email, bucket_key, enabled=_RATE_LIMIT_ENABLED)
1026
886
 
1027
887
  def _bytes_match_extension(data: bytes, ext: str) -> bool:
1028
- """Return True if the file bytes match the claimed extension (or extension has no magic)."""
1029
- ext = (ext or "").lower()
1030
- signatures = _FILE_MAGIC.get(ext)
1031
- if not signatures:
1032
- return True # text-like formats — no reliable magic
1033
- head = data[:16]
1034
- return any(head.startswith(sig) for sig in signatures)
888
+ return _bytes_match_extension_impl(data, ext)
1035
889
 
1036
890
  def require_admin(request: Request) -> tuple[str, Dict]:
1037
891
  users = load_users()
@@ -1125,221 +979,26 @@ def set_user_api_key(email: str, provider: str, key: str) -> None:
1125
979
  users[email] = user
1126
980
  save_users(users)
1127
981
 
1128
- SENSITIVE_PATTERNS = [
1129
- {"key": "rrn", "label": "주민등록번호", "severity": "high", "pattern": r"\b\d{6}[- ]?[1-4]\d{6}\b"},
1130
- {"key": "card", "label": "카드번호", "severity": "high", "pattern": r"\b(?:\d[ -]?){13,19}\b"},
1131
- {"key": "account", "label": "계좌번호", "severity": "medium", "pattern": r"(?:계좌|account|bank).{0,12}\d[\d -]{8,24}"},
1132
- {"key": "password", "label": "비밀번호/인증정보", "severity": "high", "pattern": r"(?:password|passwd|비밀번호|암호|token|api[_ -]?key|secret)\s*[:=]\s*[^\s,;]{4,}"},
1133
- {"key": "email", "label": "이메일", "severity": "low", "pattern": r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"},
1134
- {"key": "phone", "label": "전화번호", "severity": "medium", "pattern": r"\b(?:01[016789]|02|0[3-6][1-5])[- ]?\d{3,4}[- ]?\d{4}\b"},
1135
- {"key": "address", "label": "주소", "severity": "medium", "pattern": r"(?:[가-힣]+(?:시|도)\s*)?[가-힣]+(?:시|군|구)\s+[가-힣0-9\s-]+(?:로|길)\s*\d*"},
1136
- {"key": "health", "label": "건강/의료정보", "severity": "medium", "pattern": r"(?:진단|병명|처방|복용|수술|장애|임신|혈액형|알레르기|medical|diagnosis)"},
1137
- ]
1138
-
1139
- SEVERITY_SCORE = {"low": 1, "medium": 2, "high": 3}
1140
-
1141
- def mask_sensitive_text(text: str, matches: List[Dict]) -> str:
1142
- masked = text
1143
- for item in sorted(matches, key=lambda match: match["start"], reverse=True):
1144
- value = masked[item["start"]:item["end"]]
1145
- if len(value) <= 4:
1146
- replacement = "*" * len(value)
1147
- else:
1148
- replacement = value[:2] + "*" * min(len(value) - 4, 12) + value[-2:]
1149
- masked = masked[:item["start"]] + replacement + masked[item["end"]:]
1150
- return masked
1151
-
982
+ # ── Sensitivity analysis — delegated to latticeai.core.audit ──────────────────
1152
983
  def classify_sensitive_message(item: Dict, index: int) -> Dict:
1153
- content = str(item.get("content", ""))
1154
- found = []
1155
- seen = set()
1156
- for rule in SENSITIVE_PATTERNS:
1157
- for match in re.finditer(rule["pattern"], content, flags=re.IGNORECASE):
1158
- key = (rule["key"], match.start(), match.end())
1159
- if key in seen:
1160
- continue
1161
- seen.add(key)
1162
- found.append({
1163
- "type": rule["key"],
1164
- "label": rule["label"],
1165
- "severity": rule["severity"],
1166
- "start": match.start(),
1167
- "end": match.end(),
1168
- })
1169
- severity = "none"
1170
- if found:
1171
- severity = max(found, key=lambda item: SEVERITY_SCORE[item["severity"]])["severity"]
1172
- preview_text = content[:240]
1173
- preview_matches = [match for match in found if match["start"] < len(preview_text)]
1174
- return {
1175
- "index": index,
1176
- "role": item.get("role", ""),
1177
- "user_email": item.get("user_email"),
1178
- "user_nickname": item.get("user_nickname") or item.get("user_email") or "Unknown",
1179
- "timestamp": item.get("timestamp"),
1180
- "sensitivity": severity,
1181
- "labels": sorted({match["label"] for match in found}),
1182
- "risk_fields": found,
1183
- "compliance_fields": [] if found else ["민감정보 미검출"],
1184
- "preview": mask_sensitive_text(preview_text, preview_matches),
1185
- }
984
+ return _classify_sensitive_message(item, index)
1186
985
 
1187
986
  def build_sensitivity_report(history: List[Dict]) -> Dict:
1188
- items = [classify_sensitive_message(item, index) for index, item in enumerate(history)]
1189
- risky_items = [item for item in items if item["risk_fields"]]
1190
- compliant_items = [item for item in items if not item["risk_fields"]]
1191
- field_counts = {}
1192
- user_counts = {}
1193
- severity_counts = {"high": 0, "medium": 0, "low": 0, "none": len(compliant_items)}
1194
- for item in risky_items:
1195
- severity_counts[item["sensitivity"]] += 1
1196
- user_key = item.get("user_email") or item.get("user_nickname") or "Unknown"
1197
- user_counts[user_key] = user_counts.get(user_key, 0) + 1
1198
- for field in item["risk_fields"]:
1199
- field_counts[field["label"]] = field_counts.get(field["label"], 0) + 1
1200
- return {
1201
- "summary": {
1202
- "total_messages": len(items),
1203
- "risky_messages": len(risky_items),
1204
- "compliant_messages": len(compliant_items),
1205
- "risk_rate": round((len(risky_items) / len(items)) * 100, 1) if items else 0,
1206
- "severity_counts": severity_counts,
1207
- "field_counts": field_counts,
1208
- "user_counts": user_counts,
1209
- },
1210
- "risk_fields": risky_items[-30:],
1211
- "compliance_fields": compliant_items[-30:],
1212
- }
1213
-
1214
- AUDIT_DELETE_EVENTS = {"conversation_delete", "history_delete", "user_delete"}
1215
-
1216
- def _audit_user_bucket(email: Optional[str], nickname: Optional[str] = None, users: Optional[Dict] = None) -> Dict:
1217
- user = (users or {}).get(email or "", {})
1218
- return {
1219
- "email": email or "Unknown",
1220
- "nickname": nickname or user.get("nickname") or user.get("name") or email or "Unknown",
1221
- "role": get_user_role(email, users or {}) if email else "unknown",
1222
- "disabled": bool(user.get("disabled")) if user else False,
1223
- "user_messages": 0,
1224
- "assistant_messages": 0,
1225
- "document_uploads": 0,
1226
- "clear_events": 0,
1227
- "delete_events": 0,
1228
- "sensitive_events": 0,
1229
- "high_sensitive_events": 0,
1230
- "total_content_chars": 0,
1231
- "last_activity_at": None,
1232
- }
1233
-
1234
- def _public_audit_event(event: Dict) -> Dict:
1235
- allowed = {
1236
- "event_type",
1237
- "timestamp",
1238
- "role",
1239
- "user_email",
1240
- "user_nickname",
1241
- "source",
1242
- "conversation_id",
1243
- "command",
1244
- "scope",
1245
- "target_email",
1246
- "filename",
1247
- "mime_type",
1248
- "ext",
1249
- "bytes",
1250
- "extracted_chars",
1251
- "graph_node",
1252
- "keep_last",
1253
- "removed",
1254
- "kept",
1255
- "started_at",
1256
- "sensitivity",
1257
- "sensitive_labels",
1258
- "content_preview",
1259
- "content_chars",
1260
- }
1261
- return {key: event.get(key) for key in allowed if key in event}
987
+ return _build_sensitivity_report(history)
1262
988
 
989
+ # ── Admin audit report — delegated to latticeai.core.audit ───────────────────
1263
990
  def build_admin_audit_report(users: Dict) -> Dict:
1264
- events = get_audit_log()
1265
- per_user: Dict[str, Dict] = {}
1266
-
1267
- def ensure_user(email: Optional[str], nickname: Optional[str] = None) -> Dict:
1268
- key = email or nickname or "Unknown"
1269
- if key not in per_user:
1270
- per_user[key] = _audit_user_bucket(email, nickname, users)
1271
- elif nickname and per_user[key].get("nickname") in {"Unknown", email, None}:
1272
- per_user[key]["nickname"] = nickname
1273
- return per_user[key]
1274
-
1275
- for email, user in users.items():
1276
- ensure_user(email, user.get("nickname") or user.get("name"))
1277
-
1278
- summary = {
1279
- "total_events": len(events),
1280
- "chat_events": 0,
1281
- "user_messages": 0,
1282
- "assistant_messages": 0,
1283
- "document_uploads": 0,
1284
- "clear_events": 0,
1285
- "delete_events": 0,
1286
- "sensitive_events": 0,
1287
- "high_sensitive_events": 0,
1288
- }
1289
-
1290
- sensitive_events = []
1291
- deletion_events = []
1292
- for event in events:
1293
- event_type = event.get("event_type")
1294
- email = event.get("user_email")
1295
- user = ensure_user(email, event.get("user_nickname"))
1296
- timestamp = event.get("timestamp")
1297
- if timestamp and (not user["last_activity_at"] or timestamp > user["last_activity_at"]):
1298
- user["last_activity_at"] = timestamp
1299
-
1300
- user["total_content_chars"] += int(event.get("content_chars") or event.get("extracted_chars") or 0)
1301
- sensitivity = event.get("sensitivity") or "none"
1302
- labels = event.get("sensitive_labels") or []
1303
- is_sensitive = sensitivity != "none" or bool(labels)
1304
-
1305
- if event_type == "chat_message":
1306
- summary["chat_events"] += 1
1307
- if event.get("role") == "user":
1308
- summary["user_messages"] += 1
1309
- user["user_messages"] += 1
1310
- elif event.get("role") == "assistant":
1311
- summary["assistant_messages"] += 1
1312
- user["assistant_messages"] += 1
1313
- elif event_type == "document_upload":
1314
- summary["document_uploads"] += 1
1315
- user["document_uploads"] += 1
1316
- elif event_type == "clear_command":
1317
- summary["clear_events"] += 1
1318
- user["clear_events"] += 1
1319
- elif event_type in AUDIT_DELETE_EVENTS:
1320
- summary["delete_events"] += 1
1321
- user["delete_events"] += 1
1322
- deletion_events.append(_public_audit_event(event))
1323
-
1324
- if is_sensitive:
1325
- summary["sensitive_events"] += 1
1326
- user["sensitive_events"] += 1
1327
- sensitive_events.append(_public_audit_event(event))
1328
- if sensitivity == "high":
1329
- summary["high_sensitive_events"] += 1
1330
- user["high_sensitive_events"] += 1
1331
-
1332
- return {
1333
- "summary": summary,
1334
- "per_user": sorted(
1335
- per_user.values(),
1336
- key=lambda item: (item.get("last_activity_at") or "", item.get("user_messages", 0) + item.get("assistant_messages", 0)),
1337
- reverse=True,
1338
- ),
1339
- "recent_events": [_public_audit_event(event) for event in events[-80:]][::-1],
1340
- "sensitive_events": sensitive_events[-80:][::-1],
1341
- "deletion_events": deletion_events[-80:][::-1],
1342
- }
991
+ graph_stats = None
992
+ try:
993
+ if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
994
+ graph_stats = KNOWLEDGE_GRAPH.stats()
995
+ except Exception:
996
+ pass
997
+ return _build_admin_audit_report(
998
+ AUDIT_FILE, users,
999
+ get_user_role=get_user_role,
1000
+ graph_stats=graph_stats,
1001
+ )
1343
1002
 
1344
1003
  router = LLMRouter()
1345
1004
  gardener = PReinforceGardener()
@@ -1475,329 +1134,42 @@ if _ICONS_DIR.exists():
1475
1134
  ensure_agent_root()
1476
1135
 
1477
1136
  OPEN_REGISTRATION = env_bool("LATTICEAI_OPEN_REGISTRATION", default=not NETWORK_EXPOSED and not IS_PUBLIC_MODE)
1137
+ INVITE_CODE = env_value("LATTICEAI_INVITE_CODE", "gemma-lattice-ai")
1138
+ INVITE_GATE_ENABLED = env_bool("LATTICEAI_INVITE_GATE_ENABLED", default=False)
1478
1139
 
1479
- @app.post("/register")
1480
- async def register(req: UserRegister, request: Request):
1481
- # 5 registration attempts per IP per hour
1482
- _check_rate_limit(_client_ip(request), "register", max_calls=5, window_secs=3600)
1483
- if not OPEN_REGISTRATION:
1484
- raise HTTPException(status_code=403, detail="회원가입이 비활성화되어 있습니다. 관리자에게 문의하세요.")
1485
- users = load_users()
1486
- if req.email in users:
1487
- raise HTTPException(status_code=400, detail="이미 존재하는 이메일입니다.")
1488
- # First user to register on a fresh server becomes admin automatically
1489
- role = "admin" if not users else "user"
1490
- users[req.email] = {
1491
- "password": hash_password(req.password),
1492
- "name": req.name,
1493
- "nickname": req.nickname,
1494
- "role": role,
1495
- "disabled": False,
1496
- }
1497
- save_users(users)
1498
- msg = "회원가입 성공! 첫 번째 사용자로 관리자 권한이 부여되었습니다." if role == "admin" else "회원가입 성공!"
1499
- return {"status": "ok", "message": msg, "role": role}
1500
-
1501
- @app.post("/login")
1502
- async def login(req: UserLogin, request: Request):
1503
- # 10 login attempts per IP per 5 minutes
1504
- _check_rate_limit(_client_ip(request), "login", max_calls=10, window_secs=300)
1505
- users = load_users()
1506
- user = users.get(req.email)
1507
- if not user or not verify_and_migrate_password(req.email, req.password, user.get("password", ""), users):
1508
- raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 틀렸습니다.")
1509
- if user.get("disabled"):
1510
- raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
1511
- role = get_user_role(req.email, users)
1512
- token = create_session(req.email)
1513
- response = JSONResponse(content={
1514
- "status": "ok",
1515
- "nickname": user["nickname"],
1516
- "name": user["name"],
1517
- "email": req.email,
1518
- "role": role,
1519
- "is_admin": role == "admin",
1520
- })
1521
- response.set_cookie(key="session_token", value=token, httponly=True, samesite="lax", max_age=_SESSION_TTL)
1522
- return response
1523
-
1524
- @app.get("/auth/sso/config")
1525
- async def sso_config():
1526
- return public_sso_config()
1527
-
1528
- @app.get("/auth/sso/login")
1529
- async def sso_login():
1530
- from urllib.parse import urlencode
1531
- from fastapi.responses import RedirectResponse as _Redirect
1532
- settings = get_sso_settings()
1533
- discovery = await _get_sso_discovery()
1534
- if not settings.get("enabled") or not discovery:
1535
- raise HTTPException(status_code=503, detail="SSO가 설정되지 않았습니다.")
1536
- state = secrets.token_urlsafe(16)
1537
- _sso_states[state] = time.time()
1538
- params = urlencode({
1539
- "client_id": settings["client_id"],
1540
- "response_type": "code",
1541
- "redirect_uri": settings["redirect_uri"],
1542
- "scope": settings.get("scopes") or "openid email profile",
1543
- "state": state,
1544
- })
1545
- return _Redirect(f"{discovery['authorization_endpoint']}?{params}")
1546
-
1547
- @app.get("/auth/sso/callback")
1548
- async def sso_callback(code: str = "", state: str = "", error: str = ""):
1549
- from fastapi.responses import RedirectResponse as _Redirect
1550
- import base64 as _b64
1551
- if error:
1552
- return _Redirect(f"/?sso_error={error}")
1553
- ts = _sso_states.pop(state, None)
1554
- if ts is None or time.time() - ts > 300:
1555
- raise HTTPException(status_code=400, detail="유효하지 않은 SSO 상태입니다.")
1556
- settings = get_sso_settings()
1557
- discovery = await _get_sso_discovery()
1558
- if not settings.get("enabled") or not discovery:
1559
- raise HTTPException(status_code=503, detail="SSO 설정 오류입니다.")
1560
- import httpx as _httpx
1561
- async with _httpx.AsyncClient() as c:
1562
- r = await c.post(discovery["token_endpoint"], data={
1563
- "grant_type": "authorization_code",
1564
- "code": code,
1565
- "redirect_uri": settings["redirect_uri"],
1566
- "client_id": settings["client_id"],
1567
- "client_secret": settings["client_secret"],
1568
- }, headers={"Accept": "application/json"}, timeout=15)
1569
- tokens = r.json()
1570
- id_token = tokens.get("id_token")
1571
- if not id_token:
1572
- raise HTTPException(status_code=400, detail="ID 토큰을 받지 못했습니다.")
1573
- # Decode JWT payload (no signature verification — trust IdP redirect)
1574
- padded = id_token.split(".")[1] + "=="
1575
- payload = json.loads(_b64.urlsafe_b64decode(padded))
1576
- email = payload.get("email") or payload.get("preferred_username") or payload.get("upn") or ""
1577
- if not email:
1578
- raise HTTPException(status_code=400, detail="이메일을 확인할 수 없습니다.")
1579
- users = load_users()
1580
- if email not in users:
1581
- is_first = len(users) == 0
1582
- users[email] = {
1583
- "password": "",
1584
- "name": payload.get("name", email.split("@")[0]),
1585
- "nickname": payload.get("given_name", email.split("@")[0]),
1586
- "role": "admin" if is_first else "user",
1587
- "disabled": False,
1588
- "sso": True,
1589
- }
1590
- save_users(users)
1591
- if users[email].get("disabled"):
1592
- raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
1593
- token = create_session(email)
1594
- resp = _Redirect("/chat", status_code=302)
1595
- resp.set_cookie("session_token", token, httponly=True, samesite="lax", max_age=_SESSION_TTL)
1596
- return resp
1597
-
1598
- @app.post("/logout")
1599
- async def logout(request: Request):
1600
- token = _extract_bearer_token(request)
1601
- if token:
1602
- invalidate_session(token)
1603
- response = JSONResponse(content={"status": "ok"})
1604
- response.delete_cookie("session_token")
1605
- return response
1606
-
1607
- class ChangePasswordRequest(BaseModel):
1608
- current_password: str
1609
- new_password: str
1610
-
1611
- @app.post("/account/change-password")
1612
- async def change_password(req: ChangePasswordRequest, request: Request):
1613
- email = require_user(request)
1614
- if not email:
1615
- raise HTTPException(status_code=401, detail="인증이 필요합니다.")
1616
- if len(req.new_password) < 4:
1617
- raise HTTPException(status_code=400, detail="새 비밀번호는 4자 이상이어야 합니다.")
1618
- users = load_users()
1619
- user = users.get(email)
1620
- if not user:
1621
- raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
1622
- if not verify_and_migrate_password(email, req.current_password, user.get("password", ""), users):
1623
- raise HTTPException(status_code=401, detail="현재 비밀번호가 틀렸습니다.")
1624
- users[email]["password"] = hash_password(req.new_password)
1625
- save_users(users)
1626
- return {"status": "ok", "message": "비밀번호가 변경되었습니다."}
1627
-
1628
- class UpdateProfileRequest(BaseModel):
1629
- name: Optional[str] = None
1630
- nickname: Optional[str] = None
1631
-
1632
- @app.patch("/account/profile")
1633
- async def update_profile(req: UpdateProfileRequest, request: Request):
1634
- email = require_user(request)
1635
- if not email:
1636
- raise HTTPException(status_code=401, detail="인증이 필요합니다.")
1637
- if req.name is not None and not req.name.strip():
1638
- raise HTTPException(status_code=400, detail="이름을 입력해주세요.")
1639
- if req.nickname is not None and not req.nickname.strip():
1640
- raise HTTPException(status_code=400, detail="닉네임을 입력해주세요.")
1641
- users = load_users()
1642
- user = users.get(email)
1643
- if not user:
1644
- raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
1645
- if req.name is not None:
1646
- users[email]["name"] = req.name.strip()
1647
- if req.nickname is not None:
1648
- users[email]["nickname"] = req.nickname.strip()
1649
- save_users(users)
1650
- return {"status": "ok", "name": users[email]["name"], "nickname": users[email]["nickname"]}
1651
-
1652
- @app.get("/account/profile")
1653
- async def get_profile(request: Request):
1654
- email = require_user(request)
1655
- if not email:
1656
- raise HTTPException(status_code=401, detail="인증이 필요합니다.")
1657
- users = load_users()
1658
- user = users.get(email)
1659
- if not user:
1660
- raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
1661
- role = get_user_role(email, users)
1662
- return {"email": email, "name": user.get("name", ""), "nickname": user.get("nickname", ""),
1663
- "role": role, "is_admin": role == "admin"}
1664
-
1665
- @app.get("/admin/summary")
1666
- async def admin_summary(request: Request):
1667
- _, users = require_admin(request)
1668
- history = get_history()
1669
- user_messages = [item for item in history if item.get("role") == "user"]
1670
- assistant_messages = [item for item in history if item.get("role") == "assistant"]
1671
- last_timestamp = history[-1].get("timestamp") if history else None
1672
- return {
1673
- "total_users": len(users),
1674
- "active_users": sum(1 for user in users.values() if not user.get("disabled")),
1675
- "admin_users": sum(1 for email in users if get_user_role(email, users) == "admin"),
1676
- "total_messages": len(history),
1677
- "user_messages": len(user_messages),
1678
- "assistant_messages": len(assistant_messages),
1679
- "last_message_at": last_timestamp,
1680
- }
1681
-
1682
- @app.get("/admin/stats")
1683
- async def admin_stats(request: Request):
1684
- require_admin(request)
1685
- history = get_history()
1686
- from collections import defaultdict
1687
- daily: dict = defaultdict(lambda: {"user": 0, "assistant": 0})
1688
- for item in history:
1689
- ts = item.get("timestamp", "")
1690
- day = ts[:10] if ts else "unknown"
1691
- role = item.get("role", "")
1692
- if role in ("user", "assistant"):
1693
- daily[day][role] += 1
1694
- sorted_days = sorted(daily.keys())[-14:]
1695
- return {
1696
- "daily": [{"date": d, "user": daily[d]["user"], "assistant": daily[d]["assistant"]} for d in sorted_days]
1697
- }
1698
-
1699
- @app.get("/admin/users")
1700
- async def admin_users(request: Request):
1701
- _, users = require_admin(request)
1702
- return [public_user(email, user, users) for email, user in users.items()]
1703
-
1704
- @app.get("/admin/sensitivity")
1705
- async def admin_sensitivity(request: Request):
1706
- require_admin(request)
1707
- return build_sensitivity_report(get_history())
1140
+ # ── Auth & Admin routers (latticeai.api) ─────────────────────────────────────
1141
+ app.include_router(create_auth_router(
1142
+ load_users=load_users, save_users=save_users,
1143
+ hash_password=hash_password, verify_and_migrate=verify_and_migrate_password,
1144
+ create_session=create_session, get_session_email=get_session_email,
1145
+ invalidate_session=invalidate_session, extract_bearer_token=_extract_bearer_token,
1146
+ get_user_role=get_user_role, require_user=require_user,
1147
+ check_ip_rate_limit=_check_rate_limit, client_ip=_client_ip,
1148
+ get_sso_settings=get_sso_settings, get_sso_discovery=_get_sso_discovery,
1149
+ public_sso_config=public_sso_config,
1150
+ open_registration=OPEN_REGISTRATION, session_ttl=_SESSION_TTL,
1151
+ ))
1708
1152
 
1709
- @app.get("/admin/audit")
1710
- async def admin_audit(request: Request):
1711
- _, users = require_admin(request)
1712
- report = build_admin_audit_report(users)
1153
+ def _graph_stats_safe():
1713
1154
  try:
1714
- report["graph"] = KNOWLEDGE_GRAPH.stats() if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else {"disabled": True}
1155
+ return KNOWLEDGE_GRAPH.stats() if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else {"disabled": True}
1715
1156
  except Exception as e:
1716
- logging.warning("knowledge graph stats for audit failed: %s", e)
1717
- report["graph"] = {"error": str(e)}
1718
- return report
1719
-
1720
- @app.get("/vpc/status")
1721
- async def vpc_status(request: Request):
1722
- require_user(request)
1723
- return load_vpc_config()
1724
-
1725
- @app.patch("/admin/vpc")
1726
- async def admin_update_vpc(req: VpcConfigUpdate, request: Request):
1727
- require_admin(request)
1728
- config = load_vpc_config()
1729
- update = req.dict(exclude_unset=True)
1730
- if "private_subnets" in update and update["private_subnets"] is not None:
1731
- update["private_subnets"] = [item.strip() for item in update["private_subnets"] if item.strip()]
1732
- config.update(update)
1733
- save_vpc_config(config)
1734
- return config
1735
-
1736
- @app.patch("/admin/users/{email:path}")
1737
- async def admin_update_user(email: str, req: AdminUserUpdate, request: Request):
1738
- admin_email, users = require_admin(request)
1739
- if email not in users:
1740
- raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
1741
- before = public_user(email, users[email], users)
1742
- if req.role is not None:
1743
- if req.role not in {"admin", "user"}:
1744
- raise HTTPException(status_code=400, detail="role은 admin 또는 user만 가능합니다.")
1745
- users[email]["role"] = req.role
1746
- if req.disabled is not None:
1747
- if email == admin_email and req.disabled:
1748
- raise HTTPException(status_code=400, detail="자기 자신은 비활성화할 수 없습니다.")
1749
- users[email]["disabled"] = req.disabled
1750
- save_users(users)
1751
- after = public_user(email, users[email], users)
1752
- append_audit_event("user_update", user_email=admin_email, target_email=email, before=before, after=after)
1753
- return after
1754
-
1755
- @app.delete("/admin/users/{email:path}")
1756
- async def admin_delete_user(email: str, request: Request):
1757
- admin_email, users = require_admin(request)
1758
- if email == admin_email:
1759
- raise HTTPException(status_code=400, detail="자기 자신은 삭제할 수 없습니다.")
1760
- if email not in users:
1761
- raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
1762
- deleted = public_user(email, users[email], users)
1763
- append_audit_event("user_delete", user_email=admin_email, target_email=email, deleted_user=deleted)
1764
- del users[email]
1765
- save_users(users)
1766
- return {"status": "ok", "deleted": deleted}
1767
-
1768
- @app.get("/admin/invite-link")
1769
- async def admin_invite_link(request: Request):
1770
- require_admin(request)
1771
- host = request.headers.get("host", f"localhost:{DEFAULT_PORT}")
1772
- scheme = "https" if request.headers.get("x-forwarded-proto") == "https" else "http"
1773
- if INVITE_GATE_ENABLED:
1774
- url = f"{scheme}://{host}/?code={INVITE_CODE}"
1775
- else:
1776
- url = f"{scheme}://{host}/"
1777
- return {"invite_url": url, "invite_code": INVITE_CODE, "gate_enabled": INVITE_GATE_ENABLED}
1778
-
1779
- @app.get("/admin/sso")
1780
- async def admin_sso(request: Request):
1781
- require_admin(request)
1782
- return public_sso_config()
1783
-
1784
- @app.patch("/admin/sso")
1785
- async def admin_update_sso(req: SsoConfigUpdate, request: Request):
1786
- admin_email, _ = require_admin(request)
1787
- update = req.dict(exclude_unset=True)
1788
- saved = save_sso_config(update)
1789
- append_audit_event(
1790
- "sso_config_update",
1791
- user_email=admin_email,
1792
- provider_name=saved.get("provider_name"),
1793
- discovery_url=saved.get("discovery_url"),
1794
- enabled=bool(saved.get("enabled")),
1795
- )
1796
- return public_sso_config(saved)
1797
-
1798
- # ── Invitation Logic ────────────────────────────────────────────────────────
1799
- INVITE_CODE = env_value("LATTICEAI_INVITE_CODE", "gemma-lattice-ai")
1800
- INVITE_GATE_ENABLED = env_bool("LATTICEAI_INVITE_GATE_ENABLED", default=False)
1157
+ return {"error": str(e)}
1158
+
1159
+ app.include_router(create_admin_router(
1160
+ require_admin=require_admin, require_user=require_user,
1161
+ load_users=load_users, save_users=save_users,
1162
+ get_user_role=get_user_role, get_history=get_history,
1163
+ public_user=public_user, load_vpc_config=load_vpc_config,
1164
+ save_vpc_config=save_vpc_config,
1165
+ build_admin_audit_report=build_admin_audit_report,
1166
+ build_sensitivity_report=build_sensitivity_report,
1167
+ append_audit_event=append_audit_event,
1168
+ public_sso_config=public_sso_config, save_sso_config=save_sso_config,
1169
+ get_graph_stats=_graph_stats_safe, enable_graph=ENABLE_GRAPH,
1170
+ invite_code=INVITE_CODE, invite_gate_enabled=INVITE_GATE_ENABLED,
1171
+ default_port=DEFAULT_PORT,
1172
+ ))
1801
1173
 
1802
1174
  @app.get("/")
1803
1175
  async def root(request: Request, code: Optional[str] = None, authorized: Optional[str] = Cookie(None)):
@@ -6374,7 +5746,8 @@ async def mcp_recommend(req: McpRecommendRequest, request: Request):
6374
5746
 
6375
5747
  @app.post("/mcp/install")
6376
5748
  async def mcp_install(req: McpInstallRequest, request: Request):
6377
- require_user(request)
5749
+ admin_email, _ = require_admin(request)
5750
+ append_audit_event("mcp_install", user_email=admin_email, mcp_id=req.mcp_id)
6378
5751
  return await install_mcp(req.mcp_id)
6379
5752
 
6380
5753
 
@@ -6471,8 +5844,9 @@ async def mcp_custom_list(request: Request):
6471
5844
 
6472
5845
  @app.post("/mcp/custom")
6473
5846
  async def mcp_custom_add(req: McpCustomRequest, request: Request):
6474
- """Save a custom MCP entry (user-defined)."""
6475
- require_user(request)
5847
+ """Save a custom MCP entry (admin-only)."""
5848
+ admin_email, _ = require_admin(request)
5849
+ append_audit_event("mcp_custom_add", user_email=admin_email, name=req.name, package=req.package)
6476
5850
  if not req.name.strip():
6477
5851
  raise HTTPException(status_code=400, detail="name은 필수입니다.")
6478
5852
  if not req.package.strip():
@@ -6534,8 +5908,9 @@ async def skills_marketplace(request: Request, category: Optional[str] = None, a
6534
5908
 
6535
5909
  @app.post("/skills/install")
6536
5910
  async def skills_install(req: SkillInstallRequest, request: Request):
6537
- """skill을 로컬 skills 디렉터리에 설치 (Apache-2.0 / MIT)"""
6538
- require_user(request)
5911
+ """skill을 로컬 skills 디렉터리에 설치 (Apache-2.0 / MIT, 관리자 전용)"""
5912
+ admin_email, _ = require_admin(request)
5913
+ append_audit_event("skill_install", user_email=admin_email, plugin=req.plugin, skill=req.skill)
6539
5914
  return await install_skill(req.plugin, req.skill)
6540
5915
 
6541
5916