gitinstall 1.1.0__py3-none-any.whl
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.
- gitinstall/__init__.py +61 -0
- gitinstall/_sdk.py +541 -0
- gitinstall/academic.py +831 -0
- gitinstall/admin.html +327 -0
- gitinstall/auto_update.py +384 -0
- gitinstall/autopilot.py +349 -0
- gitinstall/badge.py +476 -0
- gitinstall/checkpoint.py +330 -0
- gitinstall/cicd.py +499 -0
- gitinstall/clawhub.html +718 -0
- gitinstall/config_schema.py +353 -0
- gitinstall/db.py +984 -0
- gitinstall/db_backend.py +445 -0
- gitinstall/dep_chain.py +337 -0
- gitinstall/dependency_audit.py +1153 -0
- gitinstall/detector.py +542 -0
- gitinstall/doctor.py +493 -0
- gitinstall/education.py +869 -0
- gitinstall/enterprise.py +802 -0
- gitinstall/error_fixer.py +953 -0
- gitinstall/event_bus.py +251 -0
- gitinstall/executor.py +577 -0
- gitinstall/feature_flags.py +138 -0
- gitinstall/fetcher.py +921 -0
- gitinstall/huggingface.py +922 -0
- gitinstall/hw_detect.py +988 -0
- gitinstall/i18n.py +664 -0
- gitinstall/installer_registry.py +362 -0
- gitinstall/knowledge_base.py +379 -0
- gitinstall/license_check.py +605 -0
- gitinstall/llm.py +569 -0
- gitinstall/log.py +236 -0
- gitinstall/main.py +1408 -0
- gitinstall/mcp_agent.py +841 -0
- gitinstall/mcp_server.py +386 -0
- gitinstall/monorepo.py +810 -0
- gitinstall/multi_source.py +425 -0
- gitinstall/onboard.py +276 -0
- gitinstall/planner.py +222 -0
- gitinstall/planner_helpers.py +323 -0
- gitinstall/planner_known_projects.py +1010 -0
- gitinstall/planner_templates.py +996 -0
- gitinstall/remote_gpu.py +633 -0
- gitinstall/resilience.py +608 -0
- gitinstall/run_tests.py +572 -0
- gitinstall/skills.py +476 -0
- gitinstall/tool_schemas.py +324 -0
- gitinstall/trending.py +279 -0
- gitinstall/uninstaller.py +415 -0
- gitinstall/validate_top100.py +607 -0
- gitinstall/watchdog.py +180 -0
- gitinstall/web.py +1277 -0
- gitinstall/web_ui.html +2277 -0
- gitinstall-1.1.0.dist-info/METADATA +275 -0
- gitinstall-1.1.0.dist-info/RECORD +59 -0
- gitinstall-1.1.0.dist-info/WHEEL +5 -0
- gitinstall-1.1.0.dist-info/entry_points.txt +3 -0
- gitinstall-1.1.0.dist-info/licenses/LICENSE +21 -0
- gitinstall-1.1.0.dist-info/top_level.txt +1 -0
gitinstall/enterprise.py
ADDED
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
"""
|
|
2
|
+
enterprise.py - 企业功能模块
|
|
3
|
+
================================
|
|
4
|
+
|
|
5
|
+
覆盖企业市场 40% 缺口:
|
|
6
|
+
1. SSO 集成 (SAML 2.0 / OIDC)
|
|
7
|
+
2. RBAC 角色权限管理
|
|
8
|
+
3. 审计日志
|
|
9
|
+
4. 私有仓库访问管理
|
|
10
|
+
5. 合规报告导出
|
|
11
|
+
|
|
12
|
+
零外部依赖,纯 Python 标准库。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import hmac
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import secrets
|
|
23
|
+
import time
|
|
24
|
+
import uuid
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from enum import Enum
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ─────────────────────────────────────────────
|
|
31
|
+
# RBAC(基于角色的访问控制)
|
|
32
|
+
# ─────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
class Permission(Enum):
|
|
35
|
+
"""系统权限枚举"""
|
|
36
|
+
# 安装操作
|
|
37
|
+
INSTALL_PUBLIC = "install:public" # 安装公开仓库项目
|
|
38
|
+
INSTALL_PRIVATE = "install:private" # 安装私有仓库项目
|
|
39
|
+
INSTALL_UNTRUSTED = "install:untrusted" # 安装未经审核的项目
|
|
40
|
+
# 审计
|
|
41
|
+
AUDIT_READ = "audit:read" # 查看审计日志
|
|
42
|
+
AUDIT_EXPORT = "audit:export" # 导出审计报告
|
|
43
|
+
# 管理
|
|
44
|
+
ADMIN_USERS = "admin:users" # 管理用户
|
|
45
|
+
ADMIN_ROLES = "admin:roles" # 管理角色
|
|
46
|
+
ADMIN_SETTINGS = "admin:settings" # 修改系统设置
|
|
47
|
+
ADMIN_REPOS = "admin:repos" # 管理仓库白名单
|
|
48
|
+
# SBOM
|
|
49
|
+
SBOM_GENERATE = "sbom:generate" # 生成 SBOM
|
|
50
|
+
SBOM_EXPORT = "sbom:export" # 导出 SBOM
|
|
51
|
+
# 高级
|
|
52
|
+
BATCH_INSTALL = "batch:install" # 批量安装
|
|
53
|
+
API_ACCESS = "api:access" # API 访问
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# 预定义角色
|
|
57
|
+
BUILTIN_ROLES: dict[str, dict] = {
|
|
58
|
+
"viewer": {
|
|
59
|
+
"name": "查看者",
|
|
60
|
+
"description": "只读权限,可查看项目信息和审计日志",
|
|
61
|
+
"permissions": [
|
|
62
|
+
Permission.INSTALL_PUBLIC.value,
|
|
63
|
+
Permission.AUDIT_READ.value,
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
"developer": {
|
|
67
|
+
"name": "开发者",
|
|
68
|
+
"description": "可安装公开和私有仓库项目",
|
|
69
|
+
"permissions": [
|
|
70
|
+
Permission.INSTALL_PUBLIC.value,
|
|
71
|
+
Permission.INSTALL_PRIVATE.value,
|
|
72
|
+
Permission.AUDIT_READ.value,
|
|
73
|
+
Permission.SBOM_GENERATE.value,
|
|
74
|
+
Permission.API_ACCESS.value,
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
"security": {
|
|
78
|
+
"name": "安全工程师",
|
|
79
|
+
"description": "安全审计和 SBOM 管理权限",
|
|
80
|
+
"permissions": [
|
|
81
|
+
Permission.INSTALL_PUBLIC.value,
|
|
82
|
+
Permission.INSTALL_PRIVATE.value,
|
|
83
|
+
Permission.AUDIT_READ.value,
|
|
84
|
+
Permission.AUDIT_EXPORT.value,
|
|
85
|
+
Permission.SBOM_GENERATE.value,
|
|
86
|
+
Permission.SBOM_EXPORT.value,
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
"admin": {
|
|
90
|
+
"name": "管理员",
|
|
91
|
+
"description": "完全管理权限",
|
|
92
|
+
"permissions": [p.value for p in Permission],
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class User:
|
|
99
|
+
"""企业用户"""
|
|
100
|
+
user_id: str
|
|
101
|
+
username: str
|
|
102
|
+
email: str
|
|
103
|
+
roles: list[str] = field(default_factory=lambda: ["developer"])
|
|
104
|
+
is_active: bool = True
|
|
105
|
+
sso_provider: str = "" # "saml" / "oidc" / "local"
|
|
106
|
+
sso_subject: str = "" # SSO 身份标识
|
|
107
|
+
created_at: float = field(default_factory=time.time)
|
|
108
|
+
last_login: float = 0.0
|
|
109
|
+
metadata: dict = field(default_factory=dict)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class AuditEntry:
|
|
114
|
+
"""审计日志条目"""
|
|
115
|
+
timestamp: float = field(default_factory=time.time)
|
|
116
|
+
event_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
117
|
+
user_id: str = ""
|
|
118
|
+
username: str = ""
|
|
119
|
+
action: str = "" # install, audit, sbom_export, user_create, ...
|
|
120
|
+
resource: str = "" # 操作目标(仓库 URL、用户 ID 等)
|
|
121
|
+
result: str = "success" # success / denied / error
|
|
122
|
+
details: dict = field(default_factory=dict)
|
|
123
|
+
ip_address: str = ""
|
|
124
|
+
user_agent: str = ""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class RBACManager:
|
|
128
|
+
"""RBAC 角色权限管理器"""
|
|
129
|
+
|
|
130
|
+
def __init__(self, config_dir: Optional[str] = None):
|
|
131
|
+
self._config_dir = config_dir or os.path.expanduser("~/.gitinstall/enterprise")
|
|
132
|
+
self._users: dict[str, User] = {}
|
|
133
|
+
self._custom_roles: dict[str, dict] = {}
|
|
134
|
+
self._repo_whitelist: set[str] = set()
|
|
135
|
+
self._repo_blacklist: set[str] = set()
|
|
136
|
+
self._load_config()
|
|
137
|
+
|
|
138
|
+
def _config_path(self, name: str) -> str:
|
|
139
|
+
return os.path.join(self._config_dir, name)
|
|
140
|
+
|
|
141
|
+
def _load_config(self) -> None:
|
|
142
|
+
"""加载持久化配置"""
|
|
143
|
+
if not os.path.isdir(self._config_dir):
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
# 加载用户
|
|
147
|
+
users_path = self._config_path("users.json")
|
|
148
|
+
if os.path.isfile(users_path):
|
|
149
|
+
with open(users_path, encoding="utf-8") as f:
|
|
150
|
+
data = json.load(f)
|
|
151
|
+
for u in data.get("users", []):
|
|
152
|
+
user = User(**{k: v for k, v in u.items() if k in User.__dataclass_fields__})
|
|
153
|
+
self._users[user.user_id] = user
|
|
154
|
+
|
|
155
|
+
# 加载自定义角色
|
|
156
|
+
roles_path = self._config_path("roles.json")
|
|
157
|
+
if os.path.isfile(roles_path):
|
|
158
|
+
with open(roles_path, encoding="utf-8") as f:
|
|
159
|
+
self._custom_roles = json.load(f)
|
|
160
|
+
|
|
161
|
+
# 加载仓库白/黑名单
|
|
162
|
+
repos_path = self._config_path("repos.json")
|
|
163
|
+
if os.path.isfile(repos_path):
|
|
164
|
+
with open(repos_path, encoding="utf-8") as f:
|
|
165
|
+
data = json.load(f)
|
|
166
|
+
self._repo_whitelist = set(data.get("whitelist", []))
|
|
167
|
+
self._repo_blacklist = set(data.get("blacklist", []))
|
|
168
|
+
|
|
169
|
+
def _save_users(self) -> None:
|
|
170
|
+
os.makedirs(self._config_dir, exist_ok=True)
|
|
171
|
+
data = {"users": []}
|
|
172
|
+
for u in self._users.values():
|
|
173
|
+
data["users"].append({
|
|
174
|
+
"user_id": u.user_id, "username": u.username,
|
|
175
|
+
"email": u.email, "roles": u.roles,
|
|
176
|
+
"is_active": u.is_active, "sso_provider": u.sso_provider,
|
|
177
|
+
"sso_subject": u.sso_subject,
|
|
178
|
+
"created_at": u.created_at, "last_login": u.last_login,
|
|
179
|
+
})
|
|
180
|
+
with open(self._config_path("users.json"), "w", encoding="utf-8") as f:
|
|
181
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
182
|
+
|
|
183
|
+
def _save_roles(self) -> None:
|
|
184
|
+
os.makedirs(self._config_dir, exist_ok=True)
|
|
185
|
+
with open(self._config_path("roles.json"), "w", encoding="utf-8") as f:
|
|
186
|
+
json.dump(self._custom_roles, f, indent=2, ensure_ascii=False)
|
|
187
|
+
|
|
188
|
+
def _save_repos(self) -> None:
|
|
189
|
+
os.makedirs(self._config_dir, exist_ok=True)
|
|
190
|
+
with open(self._config_path("repos.json"), "w", encoding="utf-8") as f:
|
|
191
|
+
json.dump({
|
|
192
|
+
"whitelist": sorted(self._repo_whitelist),
|
|
193
|
+
"blacklist": sorted(self._repo_blacklist),
|
|
194
|
+
}, f, indent=2, ensure_ascii=False)
|
|
195
|
+
|
|
196
|
+
# ── 用户管理 ──
|
|
197
|
+
|
|
198
|
+
def create_user(
|
|
199
|
+
self, username: str, email: str,
|
|
200
|
+
roles: Optional[list[str]] = None,
|
|
201
|
+
sso_provider: str = "local",
|
|
202
|
+
sso_subject: str = "",
|
|
203
|
+
) -> User:
|
|
204
|
+
user_id = str(uuid.uuid4())
|
|
205
|
+
user = User(
|
|
206
|
+
user_id=user_id, username=username, email=email,
|
|
207
|
+
roles=roles or ["developer"],
|
|
208
|
+
sso_provider=sso_provider, sso_subject=sso_subject,
|
|
209
|
+
)
|
|
210
|
+
self._users[user_id] = user
|
|
211
|
+
self._save_users()
|
|
212
|
+
return user
|
|
213
|
+
|
|
214
|
+
def get_user(self, user_id: str) -> Optional[User]:
|
|
215
|
+
return self._users.get(user_id)
|
|
216
|
+
|
|
217
|
+
def find_user_by_email(self, email: str) -> Optional[User]:
|
|
218
|
+
for u in self._users.values():
|
|
219
|
+
if u.email == email:
|
|
220
|
+
return u
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
def find_user_by_sso(self, provider: str, subject: str) -> Optional[User]:
|
|
224
|
+
for u in self._users.values():
|
|
225
|
+
if u.sso_provider == provider and u.sso_subject == subject:
|
|
226
|
+
return u
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
def list_users(self) -> list[User]:
|
|
230
|
+
return list(self._users.values())
|
|
231
|
+
|
|
232
|
+
def update_user_roles(self, user_id: str, roles: list[str]) -> bool:
|
|
233
|
+
user = self._users.get(user_id)
|
|
234
|
+
if not user:
|
|
235
|
+
return False
|
|
236
|
+
user.roles = roles
|
|
237
|
+
self._save_users()
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
def deactivate_user(self, user_id: str) -> bool:
|
|
241
|
+
user = self._users.get(user_id)
|
|
242
|
+
if not user:
|
|
243
|
+
return False
|
|
244
|
+
user.is_active = False
|
|
245
|
+
self._save_users()
|
|
246
|
+
return True
|
|
247
|
+
|
|
248
|
+
# ── 权限检查 ──
|
|
249
|
+
|
|
250
|
+
def get_role(self, role_name: str) -> Optional[dict]:
|
|
251
|
+
if role_name in BUILTIN_ROLES:
|
|
252
|
+
return BUILTIN_ROLES[role_name]
|
|
253
|
+
return self._custom_roles.get(role_name)
|
|
254
|
+
|
|
255
|
+
def get_user_permissions(self, user_id: str) -> set[str]:
|
|
256
|
+
user = self._users.get(user_id)
|
|
257
|
+
if not user or not user.is_active:
|
|
258
|
+
return set()
|
|
259
|
+
perms = set()
|
|
260
|
+
for role_name in user.roles:
|
|
261
|
+
role = self.get_role(role_name)
|
|
262
|
+
if role:
|
|
263
|
+
perms.update(role.get("permissions", []))
|
|
264
|
+
return perms
|
|
265
|
+
|
|
266
|
+
def check_permission(self, user_id: str, permission: str) -> bool:
|
|
267
|
+
return permission in self.get_user_permissions(user_id)
|
|
268
|
+
|
|
269
|
+
def check_install_access(self, user_id: str, repo_url: str) -> dict:
|
|
270
|
+
"""检查用户是否有权限安装指定仓库"""
|
|
271
|
+
user = self._users.get(user_id)
|
|
272
|
+
if not user or not user.is_active:
|
|
273
|
+
return {"allowed": False, "reason": "用户不存在或已停用"}
|
|
274
|
+
|
|
275
|
+
perms = self.get_user_permissions(user_id)
|
|
276
|
+
|
|
277
|
+
# 检查黑名单
|
|
278
|
+
normalized = self._normalize_repo(repo_url)
|
|
279
|
+
if normalized in self._repo_blacklist:
|
|
280
|
+
return {"allowed": False, "reason": f"仓库 {normalized} 在黑名单中"}
|
|
281
|
+
|
|
282
|
+
# 白名单模式:如果白名单非空,只允许白名单中的仓库
|
|
283
|
+
if self._repo_whitelist and normalized not in self._repo_whitelist:
|
|
284
|
+
if Permission.INSTALL_UNTRUSTED.value not in perms:
|
|
285
|
+
return {"allowed": False, "reason": f"仓库 {normalized} 不在白名单中"}
|
|
286
|
+
|
|
287
|
+
# 私有仓库判断(简单启发式)
|
|
288
|
+
is_private = "private" in repo_url or "internal" in repo_url
|
|
289
|
+
if is_private and Permission.INSTALL_PRIVATE.value not in perms:
|
|
290
|
+
return {"allowed": False, "reason": "无私有仓库安装权限"}
|
|
291
|
+
|
|
292
|
+
if Permission.INSTALL_PUBLIC.value not in perms:
|
|
293
|
+
return {"allowed": False, "reason": "无安装权限"}
|
|
294
|
+
|
|
295
|
+
return {"allowed": True, "reason": ""}
|
|
296
|
+
|
|
297
|
+
@staticmethod
|
|
298
|
+
def _normalize_repo(url: str) -> str:
|
|
299
|
+
"""标准化仓库 URL: owner/repo"""
|
|
300
|
+
match = re.search(r'(?:github\.com|gitlab\.com)[/:]([^/]+/[^/.]+)', url)
|
|
301
|
+
if match:
|
|
302
|
+
return match.group(1).lower()
|
|
303
|
+
return url.lower().strip("/")
|
|
304
|
+
|
|
305
|
+
# ── 自定义角色 ──
|
|
306
|
+
|
|
307
|
+
def create_role(self, name: str, display_name: str,
|
|
308
|
+
description: str, permissions: list[str]) -> dict:
|
|
309
|
+
# 验证权限值
|
|
310
|
+
valid = {p.value for p in Permission}
|
|
311
|
+
invalid = set(permissions) - valid
|
|
312
|
+
if invalid:
|
|
313
|
+
raise ValueError(f"无效的权限: {invalid}")
|
|
314
|
+
|
|
315
|
+
role = {
|
|
316
|
+
"name": display_name,
|
|
317
|
+
"description": description,
|
|
318
|
+
"permissions": permissions,
|
|
319
|
+
}
|
|
320
|
+
self._custom_roles[name] = role
|
|
321
|
+
self._save_roles()
|
|
322
|
+
return role
|
|
323
|
+
|
|
324
|
+
# ── 仓库管理 ──
|
|
325
|
+
|
|
326
|
+
def add_to_whitelist(self, repo: str) -> None:
|
|
327
|
+
self._repo_whitelist.add(self._normalize_repo(repo))
|
|
328
|
+
self._save_repos()
|
|
329
|
+
|
|
330
|
+
def add_to_blacklist(self, repo: str) -> None:
|
|
331
|
+
self._repo_blacklist.add(self._normalize_repo(repo))
|
|
332
|
+
self._save_repos()
|
|
333
|
+
|
|
334
|
+
def remove_from_whitelist(self, repo: str) -> None:
|
|
335
|
+
self._repo_whitelist.discard(self._normalize_repo(repo))
|
|
336
|
+
self._save_repos()
|
|
337
|
+
|
|
338
|
+
def remove_from_blacklist(self, repo: str) -> None:
|
|
339
|
+
self._repo_blacklist.discard(self._normalize_repo(repo))
|
|
340
|
+
self._save_repos()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ─────────────────────────────────────────────
|
|
344
|
+
# 审计日志管理
|
|
345
|
+
# ─────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
class AuditLogger:
|
|
348
|
+
"""审计日志管理器,支持 JSON Lines 持久化"""
|
|
349
|
+
|
|
350
|
+
def __init__(self, log_dir: Optional[str] = None):
|
|
351
|
+
self._log_dir = log_dir or os.path.expanduser("~/.gitinstall/enterprise/audit")
|
|
352
|
+
os.makedirs(self._log_dir, exist_ok=True)
|
|
353
|
+
|
|
354
|
+
def _log_file(self) -> str:
|
|
355
|
+
"""按日切分日志文件"""
|
|
356
|
+
date_str = time.strftime("%Y-%m-%d")
|
|
357
|
+
return os.path.join(self._log_dir, f"audit-{date_str}.jsonl")
|
|
358
|
+
|
|
359
|
+
def log(self, entry: AuditEntry) -> None:
|
|
360
|
+
"""写入审计日志"""
|
|
361
|
+
record = {
|
|
362
|
+
"timestamp": entry.timestamp,
|
|
363
|
+
"event_id": entry.event_id,
|
|
364
|
+
"user_id": entry.user_id,
|
|
365
|
+
"username": entry.username,
|
|
366
|
+
"action": entry.action,
|
|
367
|
+
"resource": entry.resource,
|
|
368
|
+
"result": entry.result,
|
|
369
|
+
"details": entry.details,
|
|
370
|
+
"ip_address": entry.ip_address,
|
|
371
|
+
}
|
|
372
|
+
line = json.dumps(record, ensure_ascii=False) + "\n"
|
|
373
|
+
with open(self._log_file(), "a", encoding="utf-8") as f:
|
|
374
|
+
f.write(line)
|
|
375
|
+
|
|
376
|
+
def log_install(self, user: User, repo_url: str,
|
|
377
|
+
result: str = "success", details: Optional[dict] = None) -> None:
|
|
378
|
+
self.log(AuditEntry(
|
|
379
|
+
user_id=user.user_id, username=user.username,
|
|
380
|
+
action="install", resource=repo_url,
|
|
381
|
+
result=result, details=details or {},
|
|
382
|
+
))
|
|
383
|
+
|
|
384
|
+
def log_permission_denied(self, user: User, action: str, resource: str) -> None:
|
|
385
|
+
self.log(AuditEntry(
|
|
386
|
+
user_id=user.user_id, username=user.username,
|
|
387
|
+
action=action, resource=resource, result="denied",
|
|
388
|
+
))
|
|
389
|
+
|
|
390
|
+
def query(
|
|
391
|
+
self,
|
|
392
|
+
start_time: Optional[float] = None,
|
|
393
|
+
end_time: Optional[float] = None,
|
|
394
|
+
user_id: Optional[str] = None,
|
|
395
|
+
action: Optional[str] = None,
|
|
396
|
+
limit: int = 100,
|
|
397
|
+
) -> list[dict]:
|
|
398
|
+
"""查询审计日志"""
|
|
399
|
+
results = []
|
|
400
|
+
log_files = sorted(
|
|
401
|
+
[f for f in os.listdir(self._log_dir) if f.startswith("audit-") and f.endswith(".jsonl")],
|
|
402
|
+
reverse=True,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
for log_file in log_files:
|
|
406
|
+
filepath = os.path.join(self._log_dir, log_file)
|
|
407
|
+
with open(filepath, encoding="utf-8") as f:
|
|
408
|
+
for line in f:
|
|
409
|
+
line = line.strip()
|
|
410
|
+
if not line:
|
|
411
|
+
continue
|
|
412
|
+
record = json.loads(line)
|
|
413
|
+
ts = record.get("timestamp", 0)
|
|
414
|
+
if start_time and ts < start_time:
|
|
415
|
+
continue
|
|
416
|
+
if end_time and ts > end_time:
|
|
417
|
+
continue
|
|
418
|
+
if user_id and record.get("user_id") != user_id:
|
|
419
|
+
continue
|
|
420
|
+
if action and record.get("action") != action:
|
|
421
|
+
continue
|
|
422
|
+
results.append(record)
|
|
423
|
+
if len(results) >= limit:
|
|
424
|
+
return results
|
|
425
|
+
return results
|
|
426
|
+
|
|
427
|
+
def export_compliance_report(
|
|
428
|
+
self,
|
|
429
|
+
start_time: float,
|
|
430
|
+
end_time: float,
|
|
431
|
+
output_path: Optional[str] = None,
|
|
432
|
+
) -> str:
|
|
433
|
+
"""导出合规审计报告"""
|
|
434
|
+
entries = self.query(start_time=start_time, end_time=end_time, limit=10000)
|
|
435
|
+
|
|
436
|
+
# 统计
|
|
437
|
+
action_counts: dict[str, int] = {}
|
|
438
|
+
denied_count = 0
|
|
439
|
+
user_activity: dict[str, int] = {}
|
|
440
|
+
|
|
441
|
+
for entry in entries:
|
|
442
|
+
act = entry.get("action", "unknown")
|
|
443
|
+
action_counts[act] = action_counts.get(act, 0) + 1
|
|
444
|
+
if entry.get("result") == "denied":
|
|
445
|
+
denied_count += 1
|
|
446
|
+
uid = entry.get("username", "unknown")
|
|
447
|
+
user_activity[uid] = user_activity.get(uid, 0) + 1
|
|
448
|
+
|
|
449
|
+
report = {
|
|
450
|
+
"report_type": "compliance_audit",
|
|
451
|
+
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
452
|
+
"period": {
|
|
453
|
+
"start": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(start_time)),
|
|
454
|
+
"end": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(end_time)),
|
|
455
|
+
},
|
|
456
|
+
"summary": {
|
|
457
|
+
"total_events": len(entries),
|
|
458
|
+
"denied_events": denied_count,
|
|
459
|
+
"unique_users": len(user_activity),
|
|
460
|
+
"action_breakdown": action_counts,
|
|
461
|
+
},
|
|
462
|
+
"top_users": sorted(user_activity.items(), key=lambda x: x[1], reverse=True)[:20],
|
|
463
|
+
"denied_events": [e for e in entries if e.get("result") == "denied"][:50],
|
|
464
|
+
"entries": entries,
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if not output_path:
|
|
468
|
+
output_path = os.path.join(
|
|
469
|
+
self._log_dir,
|
|
470
|
+
f"compliance-report-{time.strftime('%Y%m%d%H%M%S')}.json",
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
474
|
+
json.dump(report, f, indent=2, ensure_ascii=False)
|
|
475
|
+
|
|
476
|
+
return output_path
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# ─────────────────────────────────────────────
|
|
480
|
+
# SSO 集成 (SAML 2.0 / OIDC)
|
|
481
|
+
# ─────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
@dataclass
|
|
484
|
+
class SSOConfig:
|
|
485
|
+
"""SSO 配置"""
|
|
486
|
+
provider: str = "" # "saml" / "oidc"
|
|
487
|
+
# OIDC
|
|
488
|
+
oidc_issuer: str = "" # https://accounts.google.com
|
|
489
|
+
oidc_client_id: str = ""
|
|
490
|
+
oidc_client_secret: str = ""
|
|
491
|
+
oidc_redirect_uri: str = ""
|
|
492
|
+
oidc_scopes: list[str] = field(default_factory=lambda: ["openid", "email", "profile"])
|
|
493
|
+
# SAML
|
|
494
|
+
saml_idp_metadata_url: str = ""
|
|
495
|
+
saml_entity_id: str = ""
|
|
496
|
+
saml_acs_url: str = ""
|
|
497
|
+
# 通用
|
|
498
|
+
auto_create_user: bool = True # SSO 登录时自动创建用户
|
|
499
|
+
default_role: str = "developer" # 新用户默认角色
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
class OIDCHandler:
|
|
503
|
+
"""
|
|
504
|
+
OIDC (OpenID Connect) 认证处理器。
|
|
505
|
+
|
|
506
|
+
支持 Google Workspace、Azure AD、Okta、Auth0 等标准 OIDC Provider。
|
|
507
|
+
使用 Authorization Code Flow(最安全的 Web 流程)。
|
|
508
|
+
"""
|
|
509
|
+
|
|
510
|
+
def __init__(self, config: SSOConfig):
|
|
511
|
+
self.config = config
|
|
512
|
+
self._well_known: Optional[dict] = None
|
|
513
|
+
|
|
514
|
+
def _fetch_well_known(self) -> dict:
|
|
515
|
+
"""获取 OIDC Discovery 文档"""
|
|
516
|
+
if self._well_known:
|
|
517
|
+
return self._well_known
|
|
518
|
+
url = f"{self.config.oidc_issuer.rstrip('/')}/.well-known/openid-configuration"
|
|
519
|
+
import urllib.request
|
|
520
|
+
req = urllib.request.Request(url, headers={"User-Agent": "gitinstall/1.1"})
|
|
521
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
522
|
+
self._well_known = json.loads(resp.read().decode("utf-8"))
|
|
523
|
+
return self._well_known
|
|
524
|
+
|
|
525
|
+
def get_authorization_url(self, state: Optional[str] = None) -> str:
|
|
526
|
+
"""生成 OIDC 授权 URL"""
|
|
527
|
+
wk = self._fetch_well_known()
|
|
528
|
+
auth_endpoint = wk["authorization_endpoint"]
|
|
529
|
+
if not state:
|
|
530
|
+
state = secrets.token_urlsafe(32)
|
|
531
|
+
params = {
|
|
532
|
+
"response_type": "code",
|
|
533
|
+
"client_id": self.config.oidc_client_id,
|
|
534
|
+
"redirect_uri": self.config.oidc_redirect_uri,
|
|
535
|
+
"scope": " ".join(self.config.oidc_scopes),
|
|
536
|
+
"state": state,
|
|
537
|
+
}
|
|
538
|
+
query = "&".join(f"{k}={_url_encode(v)}" for k, v in params.items())
|
|
539
|
+
return f"{auth_endpoint}?{query}"
|
|
540
|
+
|
|
541
|
+
def exchange_code(self, code: str) -> dict:
|
|
542
|
+
"""用授权码换取 Token"""
|
|
543
|
+
wk = self._fetch_well_known()
|
|
544
|
+
token_endpoint = wk["token_endpoint"]
|
|
545
|
+
|
|
546
|
+
payload = (
|
|
547
|
+
f"grant_type=authorization_code"
|
|
548
|
+
f"&code={_url_encode(code)}"
|
|
549
|
+
f"&redirect_uri={_url_encode(self.config.oidc_redirect_uri)}"
|
|
550
|
+
f"&client_id={_url_encode(self.config.oidc_client_id)}"
|
|
551
|
+
f"&client_secret={_url_encode(self.config.oidc_client_secret)}"
|
|
552
|
+
).encode("utf-8")
|
|
553
|
+
|
|
554
|
+
import urllib.request
|
|
555
|
+
req = urllib.request.Request(
|
|
556
|
+
token_endpoint, data=payload,
|
|
557
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
558
|
+
method="POST",
|
|
559
|
+
)
|
|
560
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
561
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
562
|
+
|
|
563
|
+
def get_userinfo(self, access_token: str) -> dict:
|
|
564
|
+
"""获取用户信息"""
|
|
565
|
+
wk = self._fetch_well_known()
|
|
566
|
+
userinfo_endpoint = wk.get("userinfo_endpoint", "")
|
|
567
|
+
if not userinfo_endpoint:
|
|
568
|
+
# 从 ID Token 解析
|
|
569
|
+
return {}
|
|
570
|
+
|
|
571
|
+
import urllib.request
|
|
572
|
+
req = urllib.request.Request(
|
|
573
|
+
userinfo_endpoint,
|
|
574
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
575
|
+
)
|
|
576
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
577
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
578
|
+
|
|
579
|
+
def authenticate(self, code: str, rbac: RBACManager) -> Optional[User]:
|
|
580
|
+
"""完整 OIDC 认证流程:code → token → userinfo → User"""
|
|
581
|
+
tokens = self.exchange_code(code)
|
|
582
|
+
access_token = tokens.get("access_token", "")
|
|
583
|
+
if not access_token:
|
|
584
|
+
return None
|
|
585
|
+
|
|
586
|
+
userinfo = self.get_userinfo(access_token)
|
|
587
|
+
email = userinfo.get("email", "")
|
|
588
|
+
subject = userinfo.get("sub", "")
|
|
589
|
+
name = userinfo.get("name", email.split("@")[0] if email else "unknown")
|
|
590
|
+
|
|
591
|
+
if not email and not subject:
|
|
592
|
+
return None
|
|
593
|
+
|
|
594
|
+
# 查找已有用户
|
|
595
|
+
user = rbac.find_user_by_sso("oidc", subject)
|
|
596
|
+
if not user and email:
|
|
597
|
+
user = rbac.find_user_by_email(email)
|
|
598
|
+
|
|
599
|
+
# 自动创建
|
|
600
|
+
if not user and self.config.auto_create_user:
|
|
601
|
+
user = rbac.create_user(
|
|
602
|
+
username=name, email=email,
|
|
603
|
+
roles=[self.config.default_role],
|
|
604
|
+
sso_provider="oidc", sso_subject=subject,
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
if user:
|
|
608
|
+
user.last_login = time.time()
|
|
609
|
+
|
|
610
|
+
return user
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _url_encode(s: str) -> str:
|
|
614
|
+
"""最小化URL编码"""
|
|
615
|
+
import urllib.parse
|
|
616
|
+
return urllib.parse.quote(s, safe="")
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
# ─────────────────────────────────────────────
|
|
620
|
+
# 私有仓库访问管理
|
|
621
|
+
# ─────────────────────────────────────────────
|
|
622
|
+
|
|
623
|
+
@dataclass
|
|
624
|
+
class PrivateRepoCredential:
|
|
625
|
+
"""私有仓库凭据"""
|
|
626
|
+
repo_pattern: str # glob 模式,如 "company/*", "github.com/org/*"
|
|
627
|
+
auth_type: str # "token" / "ssh" / "app"
|
|
628
|
+
token: str = "" # GitHub PAT / GitLab Token
|
|
629
|
+
ssh_key_path: str = "" # SSH 私钥路径
|
|
630
|
+
app_id: str = "" # GitHub App ID
|
|
631
|
+
app_private_key: str = "" # GitHub App 私钥路径
|
|
632
|
+
expires_at: float = 0.0 # Token 过期时间
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
class PrivateRepoManager:
|
|
636
|
+
"""私有仓库凭据管理器"""
|
|
637
|
+
|
|
638
|
+
def __init__(self, config_dir: Optional[str] = None):
|
|
639
|
+
self._config_dir = config_dir or os.path.expanduser("~/.gitinstall/enterprise")
|
|
640
|
+
self._credentials: list[PrivateRepoCredential] = []
|
|
641
|
+
self._load()
|
|
642
|
+
|
|
643
|
+
def _creds_path(self) -> str:
|
|
644
|
+
return os.path.join(self._config_dir, "private_repos.json")
|
|
645
|
+
|
|
646
|
+
def _load(self) -> None:
|
|
647
|
+
path = self._creds_path()
|
|
648
|
+
if not os.path.isfile(path):
|
|
649
|
+
return
|
|
650
|
+
with open(path, encoding="utf-8") as f:
|
|
651
|
+
data = json.load(f)
|
|
652
|
+
for c in data.get("credentials", []):
|
|
653
|
+
self._credentials.append(PrivateRepoCredential(
|
|
654
|
+
repo_pattern=c["repo_pattern"],
|
|
655
|
+
auth_type=c["auth_type"],
|
|
656
|
+
token=c.get("token", ""),
|
|
657
|
+
ssh_key_path=c.get("ssh_key_path", ""),
|
|
658
|
+
app_id=c.get("app_id", ""),
|
|
659
|
+
expires_at=c.get("expires_at", 0),
|
|
660
|
+
))
|
|
661
|
+
|
|
662
|
+
def _save(self) -> None:
|
|
663
|
+
os.makedirs(self._config_dir, exist_ok=True)
|
|
664
|
+
data = {"credentials": []}
|
|
665
|
+
for c in self._credentials:
|
|
666
|
+
data["credentials"].append({
|
|
667
|
+
"repo_pattern": c.repo_pattern,
|
|
668
|
+
"auth_type": c.auth_type,
|
|
669
|
+
"token": "***" if c.token else "", # 不明文存储
|
|
670
|
+
"ssh_key_path": c.ssh_key_path,
|
|
671
|
+
"app_id": c.app_id,
|
|
672
|
+
"expires_at": c.expires_at,
|
|
673
|
+
})
|
|
674
|
+
with open(self._creds_path(), "w", encoding="utf-8") as f:
|
|
675
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
676
|
+
|
|
677
|
+
def add_credential(self, cred: PrivateRepoCredential) -> None:
|
|
678
|
+
self._credentials.append(cred)
|
|
679
|
+
self._save()
|
|
680
|
+
|
|
681
|
+
def find_credential(self, repo_url: str) -> Optional[PrivateRepoCredential]:
|
|
682
|
+
"""查找匹配的凭据"""
|
|
683
|
+
import fnmatch
|
|
684
|
+
normalized = _normalize_repo_url(repo_url)
|
|
685
|
+
for cred in self._credentials:
|
|
686
|
+
if fnmatch.fnmatch(normalized, cred.repo_pattern):
|
|
687
|
+
if cred.expires_at and cred.expires_at < time.time():
|
|
688
|
+
continue # 跳过过期凭据
|
|
689
|
+
return cred
|
|
690
|
+
return None
|
|
691
|
+
|
|
692
|
+
def get_clone_env(self, repo_url: str) -> dict[str, str]:
|
|
693
|
+
"""获取克隆私有仓库所需的环境变量"""
|
|
694
|
+
cred = self.find_credential(repo_url)
|
|
695
|
+
if not cred:
|
|
696
|
+
return {}
|
|
697
|
+
env = {}
|
|
698
|
+
if cred.auth_type == "token" and cred.token:
|
|
699
|
+
# 使用 token 的 HTTPS clone
|
|
700
|
+
env["GIT_ASKPASS"] = "echo"
|
|
701
|
+
env["GIT_USERNAME"] = "x-access-token"
|
|
702
|
+
env["GIT_PASSWORD"] = cred.token
|
|
703
|
+
elif cred.auth_type == "ssh" and cred.ssh_key_path:
|
|
704
|
+
env["GIT_SSH_COMMAND"] = f"ssh -i {cred.ssh_key_path} -o StrictHostKeyChecking=no"
|
|
705
|
+
return env
|
|
706
|
+
|
|
707
|
+
def get_authenticated_url(self, repo_url: str) -> str:
|
|
708
|
+
"""生成带认证信息的克隆 URL"""
|
|
709
|
+
cred = self.find_credential(repo_url)
|
|
710
|
+
if not cred:
|
|
711
|
+
return repo_url
|
|
712
|
+
if cred.auth_type == "token" and cred.token:
|
|
713
|
+
# https://x-access-token:TOKEN@github.com/org/repo.git
|
|
714
|
+
match = re.match(r'https?://([^/]+)/(.*)', repo_url)
|
|
715
|
+
if match:
|
|
716
|
+
host, path = match.group(1), match.group(2)
|
|
717
|
+
return f"https://x-access-token:{cred.token}@{host}/{path}"
|
|
718
|
+
elif cred.auth_type == "ssh":
|
|
719
|
+
# 转为 SSH URL
|
|
720
|
+
match = re.match(r'https?://([^/]+)/(.*)', repo_url)
|
|
721
|
+
if match:
|
|
722
|
+
host, path = match.group(1), match.group(2)
|
|
723
|
+
return f"git@{host}:{path}"
|
|
724
|
+
return repo_url
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _normalize_repo_url(url: str) -> str:
|
|
728
|
+
"""标准化仓库 URL"""
|
|
729
|
+
url = re.sub(r'^https?://', '', url)
|
|
730
|
+
url = url.rstrip("/").rstrip(".git")
|
|
731
|
+
return url.lower()
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
# ─────────────────────────────────────────────
|
|
735
|
+
# 企业 API 端点(供 web.py 集成)
|
|
736
|
+
# ─────────────────────────────────────────────
|
|
737
|
+
|
|
738
|
+
def create_enterprise_api_routes() -> dict:
|
|
739
|
+
"""
|
|
740
|
+
返回企业 API 路由定义,供 web.py 注册。
|
|
741
|
+
|
|
742
|
+
Returns:
|
|
743
|
+
{path: handler_info} 字典
|
|
744
|
+
"""
|
|
745
|
+
return {
|
|
746
|
+
"/api/enterprise/users": {
|
|
747
|
+
"GET": "list_users",
|
|
748
|
+
"POST": "create_user",
|
|
749
|
+
"description": "用户管理",
|
|
750
|
+
},
|
|
751
|
+
"/api/enterprise/users/{user_id}": {
|
|
752
|
+
"GET": "get_user",
|
|
753
|
+
"PUT": "update_user",
|
|
754
|
+
"DELETE": "deactivate_user",
|
|
755
|
+
"description": "单用户操作",
|
|
756
|
+
},
|
|
757
|
+
"/api/enterprise/users/{user_id}/roles": {
|
|
758
|
+
"PUT": "update_user_roles",
|
|
759
|
+
"description": "更新用户角色",
|
|
760
|
+
},
|
|
761
|
+
"/api/enterprise/roles": {
|
|
762
|
+
"GET": "list_roles",
|
|
763
|
+
"POST": "create_role",
|
|
764
|
+
"description": "角色管理",
|
|
765
|
+
},
|
|
766
|
+
"/api/enterprise/repos/whitelist": {
|
|
767
|
+
"GET": "list_whitelist",
|
|
768
|
+
"POST": "add_to_whitelist",
|
|
769
|
+
"DELETE": "remove_from_whitelist",
|
|
770
|
+
"description": "仓库白名单",
|
|
771
|
+
},
|
|
772
|
+
"/api/enterprise/repos/blacklist": {
|
|
773
|
+
"GET": "list_blacklist",
|
|
774
|
+
"POST": "add_to_blacklist",
|
|
775
|
+
"DELETE": "remove_from_blacklist",
|
|
776
|
+
"description": "仓库黑名单",
|
|
777
|
+
},
|
|
778
|
+
"/api/enterprise/audit": {
|
|
779
|
+
"GET": "query_audit",
|
|
780
|
+
"description": "审计日志查询",
|
|
781
|
+
},
|
|
782
|
+
"/api/enterprise/audit/export": {
|
|
783
|
+
"POST": "export_audit_report",
|
|
784
|
+
"description": "导出合规报告",
|
|
785
|
+
},
|
|
786
|
+
"/api/enterprise/sso/oidc/authorize": {
|
|
787
|
+
"GET": "oidc_authorize",
|
|
788
|
+
"description": "OIDC 授权重定向",
|
|
789
|
+
},
|
|
790
|
+
"/api/enterprise/sso/oidc/callback": {
|
|
791
|
+
"GET": "oidc_callback",
|
|
792
|
+
"description": "OIDC 回调处理",
|
|
793
|
+
},
|
|
794
|
+
"/api/enterprise/sbom/export": {
|
|
795
|
+
"POST": "export_sbom",
|
|
796
|
+
"description": "导出 SBOM",
|
|
797
|
+
},
|
|
798
|
+
"/api/enterprise/check-access": {
|
|
799
|
+
"POST": "check_install_access",
|
|
800
|
+
"description": "检查安装权限",
|
|
801
|
+
},
|
|
802
|
+
}
|