feat: separate enterprise work and personal routes
This commit is contained in:
parent
e6d45eaea8
commit
7414810862
9 changed files with 546 additions and 41 deletions
|
|
@ -19,6 +19,8 @@ API 路由对客户端开放:
|
|||
- 编号解析:把 TCS-GL 人类编号路由到工作域、企业账号和私有仓库;
|
||||
- 关系确认:由人类确认自己与人格体的认领关系;
|
||||
- 责任回执:独立记录对域责任的接受、拒绝、延期或修改后接受;
|
||||
- 回执入仓:关系与责任签名回执使用提交者自己的 Forgejo 会话写入本人私有工作仓库的
|
||||
`.guanghu/receipts/`,稳定路径与读回校验保证重试不重复;服务不持有管理员仓库令牌;
|
||||
- 仓库验证:登录凭证只透传给同机 Forgejo 验证,不写入数据库或日志。
|
||||
- 首次换密:使用用户自己的一次性凭证进入 Forgejo 强制换密会话,不持有长期管理员令牌;
|
||||
换密回执只记录账号、时间和成功状态,不记录旧密码或新密码。
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ FORGEJO_USER_API = os.environ.get(
|
|||
FORGEJO_WEB_BASE = os.environ.get(
|
||||
"GH_ENTERPRISE_FORGEJO_WEB_BASE", "https://guanghu.chat/code"
|
||||
).rstrip("/")
|
||||
FORGEJO_API_BASE = os.environ.get(
|
||||
"GH_ENTERPRISE_FORGEJO_API_BASE", "http://127.0.0.1:3341/api/v1"
|
||||
).rstrip("/")
|
||||
RECEIPT_KEY = os.environ.get("GH_ENTERPRISE_RECEIPT_KEY", "")
|
||||
MAX_BODY = 16_384
|
||||
USERNAME = re.compile(r"^[A-Za-z0-9_-]{1,40}$")
|
||||
|
|
@ -210,6 +213,90 @@ def signed_receipt(payload: dict) -> dict:
|
|||
return {**payload, "receipt_hash": receipt_hash, "receipt_signature": signature}
|
||||
|
||||
|
||||
def stable_receipt_id(prefix: str, human_number: str, idempotency_key: str) -> str:
|
||||
"""Keep one receipt path across safe client retries without exposing the key."""
|
||||
material = f"{prefix}\n{human_number}\n{idempotency_key}".encode()
|
||||
digest = hmac.new(RECEIPT_KEY.encode(), material, hashlib.sha256).hexdigest()[:32]
|
||||
return f"{prefix}-{digest.upper()}"
|
||||
|
||||
|
||||
def repository_receipt_path(kind: str, receipt_id: str) -> str:
|
||||
if kind not in {"relationship", "responsibility"} or not re.fullmatch(
|
||||
r"GH-(?:REL|RESP)-[A-F0-9]{32}", receipt_id
|
||||
):
|
||||
raise ValueError("repository receipt path input invalid")
|
||||
return f".guanghu/receipts/{kind}/{receipt_id}.json"
|
||||
|
||||
|
||||
def project_receipt_to_repository(
|
||||
human: dict, username: str, password: str, kind: str, receipt: dict
|
||||
) -> dict:
|
||||
"""Commit a signed receipt with the human's own Forgejo authority.
|
||||
|
||||
No administrator token or server-side repository credential is held. A
|
||||
retry that finds the deterministic path already present must read back the
|
||||
exact bytes before treating the projection as idempotent.
|
||||
"""
|
||||
repository = str(human["repository"])
|
||||
if repository.split("/", 1)[0] != username:
|
||||
raise RuntimeError("repository owner does not match authenticated user")
|
||||
path = repository_receipt_path(kind, str(receipt["receipt_id"]))
|
||||
endpoint = (
|
||||
f"{FORGEJO_API_BASE}/repos/{urllib.parse.quote(repository, safe='/')}"
|
||||
f"/contents/{urllib.parse.quote(path, safe='/')}"
|
||||
)
|
||||
content = canonical(receipt) + b"\n"
|
||||
authorization = "Basic " + base64.b64encode(f"{username}:{password}".encode()).decode()
|
||||
create_body = canonical(
|
||||
{
|
||||
"branch": "main",
|
||||
"content": base64.b64encode(content).decode(),
|
||||
"message": f"receipt({kind}): {receipt['receipt_id']}",
|
||||
}
|
||||
)
|
||||
request = urllib.request.Request(endpoint, data=create_body, method="POST")
|
||||
request.add_header("Authorization", authorization)
|
||||
request.add_header("Accept", "application/json")
|
||||
request.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
result = json.load(response)
|
||||
if response.status != 201:
|
||||
raise RuntimeError(f"repository projection returned {response.status}")
|
||||
commit = str(result.get("commit", {}).get("sha", ""))
|
||||
if not re.fullmatch(r"[0-9a-f]{40,64}", commit):
|
||||
raise RuntimeError("repository projection commit missing")
|
||||
return {
|
||||
"state": "COMMITTED",
|
||||
"repository": repository,
|
||||
"path": path,
|
||||
"commit": commit,
|
||||
}
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code != 422:
|
||||
raise RuntimeError(f"repository projection failed: {error.code}") from error
|
||||
read_request = urllib.request.Request(endpoint + "?ref=main")
|
||||
read_request.add_header("Authorization", authorization)
|
||||
read_request.add_header("Accept", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(read_request, timeout=15) as response:
|
||||
existing = json.load(response)
|
||||
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, ValueError) as error:
|
||||
raise RuntimeError("repository projection readback failed") from error
|
||||
try:
|
||||
existing_content = base64.b64decode(str(existing["content"]), validate=True)
|
||||
except (KeyError, ValueError) as error:
|
||||
raise RuntimeError("repository projection readback invalid") from error
|
||||
if not hmac.compare_digest(existing_content, content):
|
||||
raise RuntimeError("repository receipt path already contains different bytes")
|
||||
return {
|
||||
"state": "IDEMPOTENT_READBACK",
|
||||
"repository": repository,
|
||||
"path": path,
|
||||
"commit": str(existing.get("sha", "")),
|
||||
}
|
||||
|
||||
|
||||
def public_projection(registry: dict, human: dict, db: sqlite3.Connection | None = None) -> dict:
|
||||
projection = {
|
||||
"status": "RESOLVED",
|
||||
|
|
@ -273,7 +360,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||
raise ValueError("JSON object required")
|
||||
return value
|
||||
|
||||
def authenticated_human(self, registry: dict, payload: dict) -> tuple[dict, str] | None:
|
||||
def authenticated_human(self, registry: dict, payload: dict) -> tuple[dict, str, str] | None:
|
||||
credentials = parse_basic(self.headers.get("Authorization", ""))
|
||||
number = str(payload.get("human_number", ""))
|
||||
human = find_human(registry, number)
|
||||
|
|
@ -282,7 +369,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||
username, password = credentials
|
||||
if not hmac.compare_digest(username, human["username"]) or not verify_forgejo(username, password):
|
||||
return None
|
||||
return human, username
|
||||
return human, username, password
|
||||
|
||||
def do_GET(self) -> None:
|
||||
try:
|
||||
|
|
@ -349,7 +436,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||
authenticated = self.authenticated_human(registry, payload)
|
||||
if not authenticated:
|
||||
return self.respond(401, {"ok": False, "error": "enterprise account authentication failed"})
|
||||
human, username = authenticated
|
||||
human, username, password = authenticated
|
||||
idempotency_key = str(payload.get("idempotency_key", ""))
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]{16,96}", idempotency_key):
|
||||
return self.respond(400, {"ok": False, "error": "valid idempotency_key required"})
|
||||
|
|
@ -363,12 +450,16 @@ class Handler(BaseHTTPRequestHandler):
|
|||
if existing:
|
||||
return self.respond(200, {"ok": True, "idempotent": True, "receipt": dict(existing)})
|
||||
observed = now()
|
||||
receipt_id = "GH-REL-" + uuid.uuid4().hex.upper()
|
||||
receipt_id = stable_receipt_id("GH-REL", human["human_number"], idempotency_key)
|
||||
receipt = signed_receipt({"receipt_id":receipt_id,"human_number":human["human_number"],"username":username,"registry_version":registry["version"],"decision":decision,"observed_at":observed})
|
||||
try:
|
||||
projection = project_receipt_to_repository(human, username, password, "relationship", receipt)
|
||||
except RuntimeError as error:
|
||||
return self.respond(503, {"ok": False, "error": str(error)})
|
||||
db.execute("INSERT INTO relationship_receipts VALUES (?,?,?,?,?,?,?,?,?)", (receipt_id,idempotency_key,human["human_number"],username,registry["version"],decision,observed,receipt["receipt_hash"],receipt["receipt_signature"]))
|
||||
db.execute("INSERT INTO audit(observed_at,kind,human_number_hash,receipt_id) VALUES (?,?,?,?)", (observed,"RELATIONSHIP_CONFIRMATION",hashlib.sha256(human["human_number"].encode()).hexdigest(),receipt_id))
|
||||
db.commit()
|
||||
return self.respond(201, {"ok": True, "receipt": receipt})
|
||||
return self.respond(201, {"ok": True, "receipt": receipt, "repository_projection": projection})
|
||||
if self.path == "/v1/responsibility-receipts":
|
||||
decision = str(payload.get("decision", ""))
|
||||
note = str(payload.get("note", ""))[:1000]
|
||||
|
|
@ -379,12 +470,16 @@ class Handler(BaseHTTPRequestHandler):
|
|||
if existing:
|
||||
return self.respond(200, {"ok": True, "idempotent": True, "receipt": dict(existing)})
|
||||
observed = now()
|
||||
receipt_id = "GH-RESP-" + uuid.uuid4().hex.upper()
|
||||
receipt_id = stable_receipt_id("GH-RESP", human["human_number"], idempotency_key)
|
||||
receipt = signed_receipt({"receipt_id":receipt_id,"human_number":human["human_number"],"username":username,"responsibility_domain":human["responsibility_domain"],"responsibility_version":version,"decision":decision,"note":note,"observed_at":observed})
|
||||
try:
|
||||
projection = project_receipt_to_repository(human, username, password, "responsibility", receipt)
|
||||
except RuntimeError as error:
|
||||
return self.respond(503, {"ok": False, "error": str(error)})
|
||||
db.execute("INSERT INTO responsibility_receipts VALUES (?,?,?,?,?,?,?,?,?,?,?)", (receipt_id,idempotency_key,human["human_number"],username,human["responsibility_domain"],version,decision,note,observed,receipt["receipt_hash"],receipt["receipt_signature"]))
|
||||
db.execute("INSERT INTO audit(observed_at,kind,human_number_hash,receipt_id) VALUES (?,?,?,?)", (observed,"RESPONSIBILITY_RECEIPT",hashlib.sha256(human["human_number"].encode()).hexdigest(),receipt_id))
|
||||
db.commit()
|
||||
return self.respond(201, {"ok": True, "receipt": receipt})
|
||||
return self.respond(201, {"ok": True, "receipt": receipt, "repository_projection": projection})
|
||||
if self.path == "/v1/me/entry":
|
||||
return self.respond(200, {"ok": True, "entry": public_projection(registry, human, db)})
|
||||
return self.respond(404, {"ok": False, "error": "not found"})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import json
|
|||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
REGISTRY = ROOT / "registry" / "enterprise-identity-registry.json"
|
||||
|
|
@ -60,6 +62,53 @@ class EnterpriseIdentityTests(unittest.TestCase):
|
|||
self.assertIn("/user/settings/change_password", source)
|
||||
self.assertNotIn("FORGEJO_ADMIN_TOKEN", source)
|
||||
|
||||
def test_receipt_id_and_repository_path_are_stable_without_exposing_idempotency_key(self):
|
||||
first = service.stable_receipt_id("GH-RESP", "TCS-GL-0007∞", "responsibility-1234567890")
|
||||
second = service.stable_receipt_id("GH-RESP", "TCS-GL-0007∞", "responsibility-1234567890")
|
||||
self.assertEqual(first, second)
|
||||
self.assertRegex(first, r"^GH-RESP-[A-F0-9]{32}$")
|
||||
self.assertNotIn("1234567890", first)
|
||||
self.assertEqual(
|
||||
service.repository_receipt_path("responsibility", first),
|
||||
f".guanghu/receipts/responsibility/{first}.json",
|
||||
)
|
||||
|
||||
def test_repository_projection_uses_the_humans_own_forgejo_authority(self):
|
||||
registry = service.load_registry()
|
||||
human = service.find_human(registry, "TCS-GL-0007∞")
|
||||
receipt_id = service.stable_receipt_id("GH-REL", human["human_number"], "relationship-1234567890")
|
||||
receipt = service.signed_receipt(
|
||||
{"receipt_id": receipt_id, "human_number": human["human_number"], "username": "feimao"}
|
||||
)
|
||||
|
||||
class Response:
|
||||
status = 201
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_): return False
|
||||
def read(self):
|
||||
return json.dumps({"commit": {"sha": "a" * 40}}).encode()
|
||||
|
||||
with mock.patch.object(service.urllib.request, "urlopen", return_value=Response()) as opened:
|
||||
projection = service.project_receipt_to_repository(
|
||||
human, "feimao", "one-use-secret", "relationship", receipt
|
||||
)
|
||||
request = opened.call_args.args[0]
|
||||
self.assertEqual(projection["repository"], "feimao/guanghu-zero-sense-work")
|
||||
self.assertIn("/repos/feimao/guanghu-zero-sense-work/contents/", request.full_url)
|
||||
self.assertTrue(request.headers["Authorization"].startswith("Basic "))
|
||||
self.assertNotIn("one-use-secret", request.data.decode())
|
||||
|
||||
def test_repository_projection_refuses_cross_owner_repository(self):
|
||||
human = {"repository": "juzi/guanghu-zero-sense-work"}
|
||||
with self.assertRaisesRegex(RuntimeError, "owner"):
|
||||
service.project_receipt_to_repository(
|
||||
human,
|
||||
"feimao",
|
||||
"secret",
|
||||
"relationship",
|
||||
{"receipt_id": "GH-REL-" + "A" * 32},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Reference in a new issue