399 lines
18 KiB
Python
399 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""HoloLake enterprise identity, relationship and responsibility receipt service.
|
|
|
|
The service binds only to loopback. Nginx exposes exact routes. Credentials are
|
|
verified against the local Forgejo API and are never stored or logged.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import http.cookiejar
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import uuid
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
|
|
BIND = os.environ.get("GH_ENTERPRISE_IDENTITY_BIND", "127.0.0.1")
|
|
PORT = int(os.environ.get("GH_ENTERPRISE_IDENTITY_PORT", "8032"))
|
|
DB_PATH = os.environ.get(
|
|
"GH_ENTERPRISE_IDENTITY_DB",
|
|
"/var/lib/guanghu-enterprise-identity/identity.sqlite3",
|
|
)
|
|
REGISTRY_PATH = os.environ.get(
|
|
"GH_ENTERPRISE_IDENTITY_REGISTRY",
|
|
"/etc/guanghu/enterprise-identity-registry.json",
|
|
)
|
|
FORGEJO_USER_API = os.environ.get(
|
|
"GH_ENTERPRISE_FORGEJO_USER_API", "http://127.0.0.1:3341/api/v1/user"
|
|
)
|
|
FORGEJO_WEB_BASE = os.environ.get(
|
|
"GH_ENTERPRISE_FORGEJO_WEB_BASE", "https://guanghu.chat/code"
|
|
).rstrip("/")
|
|
RECEIPT_KEY = os.environ.get("GH_ENTERPRISE_RECEIPT_KEY", "")
|
|
MAX_BODY = 16_384
|
|
USERNAME = re.compile(r"^[A-Za-z0-9_-]{1,40}$")
|
|
DECISIONS = {"ACCEPT", "REJECT", "DEFER", "ACCEPT_WITH_CHANGES"}
|
|
|
|
|
|
def now() -> int:
|
|
return int(time.time())
|
|
|
|
|
|
def canonical(value: object) -> bytes:
|
|
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
|
|
|
|
|
|
def load_registry() -> dict:
|
|
registry = json.loads(Path(REGISTRY_PATH).read_text(encoding="utf-8"))
|
|
if registry.get("schema") != "guanghu.enterprise-identity-registry/v1":
|
|
raise ValueError("enterprise identity registry schema invalid")
|
|
humans = registry.get("humans")
|
|
if not isinstance(humans, list) or not humans:
|
|
raise ValueError("enterprise identity registry is empty")
|
|
numbers = [item.get("human_number") for item in humans]
|
|
usernames = [item.get("username") for item in humans]
|
|
if len(numbers) != len(set(numbers)) or len(usernames) != len(set(usernames)):
|
|
raise ValueError("enterprise identity registry identities must be unique")
|
|
for item in humans:
|
|
if not USERNAME.fullmatch(str(item.get("username", ""))):
|
|
raise ValueError("enterprise username invalid")
|
|
for persona in item.get("personas", []):
|
|
if persona.get("species") != "AGE" or str(persona.get("current_persona_identity", "")).startswith("AGE-"):
|
|
raise ValueError("AGE is a species and cannot be used as a persona identity number")
|
|
return registry
|
|
|
|
|
|
def database() -> sqlite3.Connection:
|
|
path = Path(DB_PATH)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
db = sqlite3.connect(path)
|
|
db.row_factory = sqlite3.Row
|
|
db.executescript(
|
|
"""
|
|
PRAGMA journal_mode=WAL;
|
|
PRAGMA foreign_keys=ON;
|
|
CREATE TABLE IF NOT EXISTS relationship_receipts (
|
|
receipt_id TEXT PRIMARY KEY,
|
|
idempotency_key TEXT NOT NULL UNIQUE,
|
|
human_number TEXT NOT NULL,
|
|
username TEXT NOT NULL,
|
|
registry_version TEXT NOT NULL,
|
|
decision TEXT NOT NULL,
|
|
observed_at INTEGER NOT NULL,
|
|
receipt_hash TEXT NOT NULL,
|
|
receipt_signature TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS responsibility_receipts (
|
|
receipt_id TEXT PRIMARY KEY,
|
|
idempotency_key TEXT NOT NULL UNIQUE,
|
|
human_number TEXT NOT NULL,
|
|
username TEXT NOT NULL,
|
|
responsibility_domain TEXT NOT NULL,
|
|
responsibility_version TEXT NOT NULL,
|
|
decision TEXT NOT NULL,
|
|
note TEXT NOT NULL,
|
|
observed_at INTEGER NOT NULL,
|
|
receipt_hash TEXT NOT NULL,
|
|
receipt_signature TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS credential_rotation_receipts (
|
|
receipt_id TEXT PRIMARY KEY,
|
|
human_number TEXT NOT NULL,
|
|
username TEXT NOT NULL,
|
|
observed_at INTEGER NOT NULL,
|
|
receipt_hash TEXT NOT NULL,
|
|
receipt_signature TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS audit (
|
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
observed_at INTEGER NOT NULL,
|
|
kind TEXT NOT NULL,
|
|
human_number_hash TEXT NOT NULL,
|
|
receipt_id TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
return db
|
|
|
|
|
|
def find_human(registry: dict, number: str) -> dict | None:
|
|
encoded = number.encode("utf-8")
|
|
return next(
|
|
(
|
|
item
|
|
for item in registry["humans"]
|
|
if hmac.compare_digest(item["human_number"].encode("utf-8"), encoded)
|
|
),
|
|
None,
|
|
)
|
|
|
|
|
|
def parse_basic(header: str) -> tuple[str, str] | None:
|
|
if not header.startswith("Basic "):
|
|
return None
|
|
try:
|
|
decoded = base64.b64decode(header[6:], validate=True).decode("utf-8")
|
|
username, password = decoded.split(":", 1)
|
|
except (ValueError, UnicodeDecodeError):
|
|
return None
|
|
if not USERNAME.fullmatch(username) or not password or len(password) > 512:
|
|
return None
|
|
return username, password
|
|
|
|
|
|
def verify_forgejo(username: str, password: str) -> bool:
|
|
request = urllib.request.Request(FORGEJO_USER_API)
|
|
credential = base64.b64encode(f"{username}:{password}".encode()).decode()
|
|
request.add_header("Authorization", f"Basic {credential}")
|
|
request.add_header("Accept", "application/json")
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=10) as response:
|
|
body = json.load(response)
|
|
return response.status == 200 and hmac.compare_digest(str(body.get("login", "")), username)
|
|
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, ValueError):
|
|
return False
|
|
|
|
|
|
def rotate_forgejo_password(username: str, current_password: str, new_password: str) -> bool:
|
|
"""Use Forgejo's own first-login session to rotate a forced-change password.
|
|
|
|
This needs no standing admin token: the old credential opens a normal user
|
|
session and Forgejo itself admits only the forced password-change form.
|
|
"""
|
|
jar = http.cookiejar.CookieJar()
|
|
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
|
try:
|
|
opener.open(FORGEJO_WEB_BASE + "/user/login", timeout=15).read()
|
|
login = urllib.parse.urlencode(
|
|
{"user_name": username, "password": current_password}
|
|
).encode()
|
|
login_request = urllib.request.Request(
|
|
FORGEJO_WEB_BASE + "/user/login", data=login
|
|
)
|
|
login_request.add_header("Content-Type", "application/x-www-form-urlencoded")
|
|
with opener.open(login_request, timeout=15) as response:
|
|
response.read()
|
|
if not urllib.parse.urlparse(response.geturl()).path.endswith(
|
|
"/user/settings/change_password"
|
|
):
|
|
return False
|
|
change = urllib.parse.urlencode(
|
|
{"password": new_password, "retype": new_password}
|
|
).encode()
|
|
change_request = urllib.request.Request(
|
|
FORGEJO_WEB_BASE + "/user/settings/change_password", data=change
|
|
)
|
|
change_request.add_header("Content-Type", "application/x-www-form-urlencoded")
|
|
with opener.open(change_request, timeout=15) as response:
|
|
response.read()
|
|
return verify_forgejo(username, new_password)
|
|
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, ValueError):
|
|
return False
|
|
|
|
|
|
def signed_receipt(payload: dict) -> dict:
|
|
if len(RECEIPT_KEY) < 32:
|
|
raise RuntimeError("receipt signing key unavailable")
|
|
body = canonical(payload)
|
|
receipt_hash = hashlib.sha256(body).hexdigest()
|
|
signature = hmac.new(RECEIPT_KEY.encode(), body, hashlib.sha256).hexdigest()
|
|
return {**payload, "receipt_hash": receipt_hash, "receipt_signature": signature}
|
|
|
|
|
|
def public_projection(registry: dict, human: dict, db: sqlite3.Connection | None = None) -> dict:
|
|
projection = {
|
|
"status": "RESOLVED",
|
|
"canonical_id": human["human_number"],
|
|
"subject": {
|
|
"id": human["human_number"],
|
|
"name": human["display_name"],
|
|
"domain": human["responsibility_domain"],
|
|
},
|
|
"work_entry": {
|
|
"domain": registry["work_entry_domain"],
|
|
"channel": registry["work_entry_channel"],
|
|
},
|
|
"repository_binding": {
|
|
"host": "guanghu.chat",
|
|
"username": human["username"],
|
|
"repository": human["repository"],
|
|
"private": True,
|
|
},
|
|
"persona_relationships": human["personas"],
|
|
"persona_identity_governance": registry["persona_identity_governance"],
|
|
"registry_version": registry["version"],
|
|
}
|
|
if db is not None:
|
|
relationship = db.execute(
|
|
"SELECT decision,observed_at,receipt_hash FROM relationship_receipts WHERE human_number=? ORDER BY observed_at DESC LIMIT 1",
|
|
(human["human_number"],),
|
|
).fetchone()
|
|
responsibility = db.execute(
|
|
"SELECT decision,observed_at,receipt_hash,responsibility_version FROM responsibility_receipts WHERE human_number=? ORDER BY observed_at DESC LIMIT 1",
|
|
(human["human_number"],),
|
|
).fetchone()
|
|
projection["relationship_confirmation"] = dict(relationship) if relationship else None
|
|
projection["responsibility_receipt"] = dict(responsibility) if responsibility else None
|
|
return projection
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
server_version = "GuanghuEnterpriseIdentity/1.0"
|
|
|
|
def log_message(self, fmt: str, *args: object) -> None:
|
|
# Never include headers or request bodies in logs.
|
|
print("[enterprise-identity] " + fmt % args)
|
|
|
|
def respond(self, status: int, body: dict) -> None:
|
|
encoded = json.dumps(body, ensure_ascii=False).encode()
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(encoded)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
self.end_headers()
|
|
self.wfile.write(encoded)
|
|
|
|
def body(self) -> dict:
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
if length < 1 or length > MAX_BODY:
|
|
raise ValueError("request body size invalid")
|
|
value = json.loads(self.rfile.read(length).decode())
|
|
if not isinstance(value, dict):
|
|
raise ValueError("JSON object required")
|
|
return value
|
|
|
|
def authenticated_human(self, registry: dict, payload: dict) -> tuple[dict, str] | None:
|
|
credentials = parse_basic(self.headers.get("Authorization", ""))
|
|
number = str(payload.get("human_number", ""))
|
|
human = find_human(registry, number)
|
|
if not credentials or not human:
|
|
return None
|
|
username, password = credentials
|
|
if not hmac.compare_digest(username, human["username"]) or not verify_forgejo(username, password):
|
|
return None
|
|
return human, username
|
|
|
|
def do_GET(self) -> None:
|
|
try:
|
|
registry = load_registry()
|
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
return self.respond(503, {"ok": False, "error": f"registry unavailable: {error}"})
|
|
parsed = urllib.parse.urlparse(self.path)
|
|
if parsed.path == "/health":
|
|
return self.respond(200, {"ok": True, "service": "guanghu-enterprise-identity", "registry_version": registry["version"], "age_species": "AGE", "valid_age_individual_numbers": 0})
|
|
if parsed.path == "/v1/resolve":
|
|
number = urllib.parse.parse_qs(parsed.query).get("id", [""])[0]
|
|
human = find_human(registry, number)
|
|
return self.respond(200 if human else 404, public_projection(registry, human) if human else {"status": "NOT_FOUND"})
|
|
return self.respond(404, {"ok": False, "error": "not found"})
|
|
|
|
def do_POST(self) -> None:
|
|
try:
|
|
registry = load_registry()
|
|
payload = self.body()
|
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
return self.respond(400, {"ok": False, "error": str(error)})
|
|
if self.path == "/v1/change-password":
|
|
credentials = parse_basic(self.headers.get("Authorization", ""))
|
|
human = find_human(registry, str(payload.get("human_number", "")))
|
|
new_password = str(payload.get("new_password", ""))
|
|
if not credentials or not human:
|
|
return self.respond(401, {"ok": False, "error": "enterprise account authentication failed"})
|
|
username, current_password = credentials
|
|
if not hmac.compare_digest(username, human["username"]):
|
|
return self.respond(401, {"ok": False, "error": "enterprise account authentication failed"})
|
|
if (
|
|
len(new_password) < 14
|
|
or len(new_password) > 128
|
|
or hmac.compare_digest(current_password, new_password)
|
|
or new_password.isdigit()
|
|
or new_password.isalpha()
|
|
):
|
|
return self.respond(400, {"ok": False, "error": "new password does not meet the first-login policy"})
|
|
if not rotate_forgejo_password(username, current_password, new_password):
|
|
return self.respond(401, {"ok": False, "error": "first-login password rotation failed"})
|
|
observed = now()
|
|
receipt_id = "GH-CRED-" + uuid.uuid4().hex.upper()
|
|
receipt = signed_receipt({
|
|
"receipt_id": receipt_id,
|
|
"human_number": human["human_number"],
|
|
"username": username,
|
|
"observed_at": observed,
|
|
"password_changed": True,
|
|
})
|
|
db = database()
|
|
try:
|
|
db.execute(
|
|
"INSERT INTO credential_rotation_receipts VALUES (?,?,?,?,?,?)",
|
|
(receipt_id, human["human_number"], username, observed, receipt["receipt_hash"], receipt["receipt_signature"]),
|
|
)
|
|
db.execute(
|
|
"INSERT INTO audit(observed_at,kind,human_number_hash,receipt_id) VALUES (?,?,?,?)",
|
|
(observed, "CREDENTIAL_ROTATION", hashlib.sha256(human["human_number"].encode()).hexdigest(), receipt_id),
|
|
)
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
return self.respond(200, {"ok": True, "receipt": receipt})
|
|
authenticated = self.authenticated_human(registry, payload)
|
|
if not authenticated:
|
|
return self.respond(401, {"ok": False, "error": "enterprise account authentication failed"})
|
|
human, username = 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"})
|
|
db = database()
|
|
try:
|
|
if self.path == "/v1/relationship-confirmations":
|
|
decision = str(payload.get("decision", ""))
|
|
if decision not in {"CONFIRM", "REJECT"}:
|
|
return self.respond(400, {"ok": False, "error": "relationship decision invalid"})
|
|
existing = db.execute("SELECT * FROM relationship_receipts WHERE idempotency_key=?", (idempotency_key,)).fetchone()
|
|
if existing:
|
|
return self.respond(200, {"ok": True, "idempotent": True, "receipt": dict(existing)})
|
|
observed = now()
|
|
receipt_id = "GH-REL-" + uuid.uuid4().hex.upper()
|
|
receipt = signed_receipt({"receipt_id":receipt_id,"human_number":human["human_number"],"username":username,"registry_version":registry["version"],"decision":decision,"observed_at":observed})
|
|
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})
|
|
if self.path == "/v1/responsibility-receipts":
|
|
decision = str(payload.get("decision", ""))
|
|
note = str(payload.get("note", ""))[:1000]
|
|
version = str(payload.get("responsibility_version", ""))
|
|
if decision not in DECISIONS or not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}\.[0-9]+", version):
|
|
return self.respond(400, {"ok": False, "error": "responsibility decision or version invalid"})
|
|
existing = db.execute("SELECT * FROM responsibility_receipts WHERE idempotency_key=?", (idempotency_key,)).fetchone()
|
|
if existing:
|
|
return self.respond(200, {"ok": True, "idempotent": True, "receipt": dict(existing)})
|
|
observed = now()
|
|
receipt_id = "GH-RESP-" + uuid.uuid4().hex.upper()
|
|
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})
|
|
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})
|
|
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"})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
load_registry()
|
|
if len(RECEIPT_KEY) < 32:
|
|
raise SystemExit("GH_ENTERPRISE_RECEIPT_KEY must contain at least 32 characters")
|
|
ThreadingHTTPServer((BIND, PORT), Handler).serve_forever()
|