hololake-system-architecture/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.py

494 lines
23 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("/")
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}$")
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 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",
"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, 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, password
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, 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"})
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 = 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, "repository_projection": projection})
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 = 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, "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"})
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()