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
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
|
-
|
|
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
|
|
265
|
+
# ── Password hashing — delegated to latticeai.core.security ────────────────────
|
|
250
266
|
def hash_password(password: str) -> str:
|
|
251
|
-
|
|
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
|
-
|
|
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
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
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
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1155
|
+
return KNOWLEDGE_GRAPH.stats() if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else {"disabled": True}
|
|
1715
1156
|
except Exception as e:
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
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
|
-
|
|
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 (
|
|
6475
|
-
|
|
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
|
-
|
|
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
|
|