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.
Files changed (59) hide show
  1. gitinstall/__init__.py +61 -0
  2. gitinstall/_sdk.py +541 -0
  3. gitinstall/academic.py +831 -0
  4. gitinstall/admin.html +327 -0
  5. gitinstall/auto_update.py +384 -0
  6. gitinstall/autopilot.py +349 -0
  7. gitinstall/badge.py +476 -0
  8. gitinstall/checkpoint.py +330 -0
  9. gitinstall/cicd.py +499 -0
  10. gitinstall/clawhub.html +718 -0
  11. gitinstall/config_schema.py +353 -0
  12. gitinstall/db.py +984 -0
  13. gitinstall/db_backend.py +445 -0
  14. gitinstall/dep_chain.py +337 -0
  15. gitinstall/dependency_audit.py +1153 -0
  16. gitinstall/detector.py +542 -0
  17. gitinstall/doctor.py +493 -0
  18. gitinstall/education.py +869 -0
  19. gitinstall/enterprise.py +802 -0
  20. gitinstall/error_fixer.py +953 -0
  21. gitinstall/event_bus.py +251 -0
  22. gitinstall/executor.py +577 -0
  23. gitinstall/feature_flags.py +138 -0
  24. gitinstall/fetcher.py +921 -0
  25. gitinstall/huggingface.py +922 -0
  26. gitinstall/hw_detect.py +988 -0
  27. gitinstall/i18n.py +664 -0
  28. gitinstall/installer_registry.py +362 -0
  29. gitinstall/knowledge_base.py +379 -0
  30. gitinstall/license_check.py +605 -0
  31. gitinstall/llm.py +569 -0
  32. gitinstall/log.py +236 -0
  33. gitinstall/main.py +1408 -0
  34. gitinstall/mcp_agent.py +841 -0
  35. gitinstall/mcp_server.py +386 -0
  36. gitinstall/monorepo.py +810 -0
  37. gitinstall/multi_source.py +425 -0
  38. gitinstall/onboard.py +276 -0
  39. gitinstall/planner.py +222 -0
  40. gitinstall/planner_helpers.py +323 -0
  41. gitinstall/planner_known_projects.py +1010 -0
  42. gitinstall/planner_templates.py +996 -0
  43. gitinstall/remote_gpu.py +633 -0
  44. gitinstall/resilience.py +608 -0
  45. gitinstall/run_tests.py +572 -0
  46. gitinstall/skills.py +476 -0
  47. gitinstall/tool_schemas.py +324 -0
  48. gitinstall/trending.py +279 -0
  49. gitinstall/uninstaller.py +415 -0
  50. gitinstall/validate_top100.py +607 -0
  51. gitinstall/watchdog.py +180 -0
  52. gitinstall/web.py +1277 -0
  53. gitinstall/web_ui.html +2277 -0
  54. gitinstall-1.1.0.dist-info/METADATA +275 -0
  55. gitinstall-1.1.0.dist-info/RECORD +59 -0
  56. gitinstall-1.1.0.dist-info/WHEEL +5 -0
  57. gitinstall-1.1.0.dist-info/entry_points.txt +3 -0
  58. gitinstall-1.1.0.dist-info/licenses/LICENSE +21 -0
  59. gitinstall-1.1.0.dist-info/top_level.txt +1 -0
@@ -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
+ }