314 lines
12 KiB
Python
314 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""granthi-link: provisioning service for the granthi-sync product spine.
|
|
|
|
Bridges shre-id (Zitadel) identity to a Granthi (Gitea) forge:
|
|
|
|
POST /v1/link {zitadel_access_token, device_name}
|
|
-> validates the token against Zitadel userinfo
|
|
-> ensures a Gitea user exists (admin API)
|
|
-> mints a scoped Gitea token for that user (admin basic
|
|
auth + `Sudo:` header -- the only mechanism that works
|
|
on Gitea 1.27; token-authenticated sudo returns 401)
|
|
-> {gitea_base, login, token, token_name}
|
|
|
|
POST /v1/repos {token, name, private} -> creates a user repo with the
|
|
USER token, returns clone URLs rebased onto the public
|
|
gitea_base (container ROOT_URL may not resolve for
|
|
clients on the tailnet).
|
|
|
|
GET /health -> {"status": "ok", ...}
|
|
|
|
Design decisions (documented per spec):
|
|
* User creation uses source_id 0 (local). Gitea 1.27.1 has no
|
|
/api/v1/admin/identity-auth-sources endpoint (404); the shre-id OAuth2
|
|
source is ID 1 (discovered via `gitea admin auth list`), but users
|
|
created against an OAuth2 source cannot basic-auth and admin-created
|
|
users get no external_login_user row anyway, so SSO linking happens on
|
|
first OIDC web login (by email) regardless. Local + random password is
|
|
the simplest correct v1.
|
|
* Token minting NEEDS the admin password (basic auth + Sudo header).
|
|
The admin API token alone cannot mint user tokens on 1.27. The config
|
|
therefore carries admin_login/admin_password alongside admin_token;
|
|
config must be root-owned 0600.
|
|
* test_mode: when config "test_mode" is true, a /v1/link body may carry
|
|
"test_userinfo" (dict) instead of a Zitadel round-trip. This exists so
|
|
E2E can exercise the ensure-user+mint path without a human OAuth login.
|
|
NEVER enable in production config.
|
|
|
|
Stdlib only. Python 3.9+.
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import secrets
|
|
import signal
|
|
import string
|
|
import sys
|
|
import threading
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
VERSION = "1.0.0"
|
|
LOG = logging.getLogger("granthi-link")
|
|
|
|
DEFAULT_CONFIG = "/opt/granthi-link/config.json"
|
|
|
|
LOGIN_SAFE = re.compile(r"[^a-zA-Z0-9._-]+")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# HTTP helper (patchable in tests)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def http_json(method, url, headers=None, body=None, timeout=20):
|
|
"""Minimal JSON-over-HTTP helper. Returns (status, parsed_or_text)."""
|
|
data = None
|
|
hdrs = dict(headers or {})
|
|
# Cloudflare in front of id.shre.ai 403s the default Python-urllib UA.
|
|
hdrs.setdefault("User-Agent", f"granthi-link/{VERSION}")
|
|
if body is not None:
|
|
data = json.dumps(body).encode()
|
|
hdrs.setdefault("Content-Type", "application/json")
|
|
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
raw = resp.read()
|
|
status = resp.status
|
|
except urllib.error.HTTPError as e:
|
|
raw = e.read()
|
|
status = e.code
|
|
except (urllib.error.URLError, OSError) as e:
|
|
return 599, {"error": str(e)}
|
|
try:
|
|
return status, json.loads(raw) if raw else {}
|
|
except ValueError:
|
|
return status, {"raw": raw.decode(errors="replace")}
|
|
|
|
|
|
def _basic(login, password):
|
|
tok = base64.b64encode(f"{login}:{password}".encode()).decode()
|
|
return f"Basic {tok}"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Core logic (class so tests can instantiate with a stub config)
|
|
# --------------------------------------------------------------------------
|
|
|
|
class LinkService:
|
|
def __init__(self, config):
|
|
self.cfg = config
|
|
# gitea_base: URL this service uses to reach the forge (loopback ok)
|
|
# public_gitea_base: URL handed to clients (tailnet / public)
|
|
self.gitea = config["gitea_base"].rstrip("/")
|
|
self.public_gitea = config.get("public_gitea_base", self.gitea).rstrip("/")
|
|
self.userinfo_url = config.get(
|
|
"zitadel_userinfo", "https://id.shre.ai/oidc/v1/userinfo")
|
|
|
|
# -- identity ----------------------------------------------------------
|
|
def validate_token(self, access_token):
|
|
status, info = http_json(
|
|
"GET", self.userinfo_url,
|
|
headers={"Authorization": f"Bearer {access_token}"})
|
|
if status != 200:
|
|
return None, f"zitadel userinfo rejected token (HTTP {status})"
|
|
if not isinstance(info, dict) or "sub" not in info:
|
|
return None, "zitadel userinfo returned no sub"
|
|
return info, None
|
|
|
|
@staticmethod
|
|
def derive_login(userinfo):
|
|
cand = userinfo.get("preferred_username") or ""
|
|
if not cand:
|
|
email = userinfo.get("email") or ""
|
|
cand = email.split("@", 1)[0]
|
|
cand = cand.split("@", 1)[0] # strip org domain from zitadel logins
|
|
cand = LOGIN_SAFE.sub("-", cand).strip("-._").lower()
|
|
return cand or None
|
|
|
|
# -- gitea admin -------------------------------------------------------
|
|
def _admin_hdr(self):
|
|
return {"Authorization": f"token {self.cfg['admin_token']}"}
|
|
|
|
def user_exists(self, login):
|
|
status, _ = http_json(
|
|
"GET", f"{self.gitea}/api/v1/users/{login}", headers=self._admin_hdr())
|
|
return status == 200
|
|
|
|
def create_user(self, login, userinfo):
|
|
pw_alphabet = string.ascii_letters + string.digits
|
|
password = "".join(secrets.choice(pw_alphabet) for _ in range(30))
|
|
email = userinfo.get("email") or f"{login}@users.noreply.granthi.shre.ai"
|
|
body = {
|
|
"username": login,
|
|
"email": email,
|
|
"password": password,
|
|
"must_change_password": False,
|
|
"visibility": "private",
|
|
"full_name": userinfo.get("name", ""),
|
|
# source_id intentionally omitted -> local user; see module doc.
|
|
}
|
|
status, resp = http_json(
|
|
"POST", f"{self.gitea}/api/v1/admin/users",
|
|
headers=self._admin_hdr(), body=body)
|
|
if status != 201:
|
|
return f"gitea admin user create failed (HTTP {status}): {resp}"
|
|
return None
|
|
|
|
def mint_token(self, login, device_name):
|
|
"""Admin basic auth + Sudo header. Verified on Gitea 1.27.1:
|
|
token-auth sudo (header or ?sudo=) -> 401; basic+Sudo -> 201."""
|
|
safe_dev = LOGIN_SAFE.sub("-", device_name or "device")[:40]
|
|
token_name = "granthi-sync-{}-{}".format(
|
|
safe_dev, datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ"))
|
|
status, resp = http_json(
|
|
"POST", f"{self.gitea}/api/v1/users/{login}/tokens",
|
|
headers={
|
|
"Authorization": _basic(
|
|
self.cfg["admin_login"], self.cfg["admin_password"]),
|
|
"Sudo": login,
|
|
},
|
|
body={"name": token_name,
|
|
"scopes": ["write:repository", "write:user"]})
|
|
if status != 201:
|
|
return None, None, f"token mint failed (HTTP {status}): {resp}"
|
|
return resp.get("sha1"), token_name, None
|
|
|
|
# -- endpoints ---------------------------------------------------------
|
|
def link(self, body):
|
|
device_name = body.get("device_name") or "device"
|
|
if self.cfg.get("test_mode") and isinstance(body.get("test_userinfo"), dict):
|
|
LOG.warning("TEST-MODE link request (stubbed userinfo)")
|
|
userinfo = body["test_userinfo"]
|
|
else:
|
|
token = body.get("zitadel_access_token")
|
|
if not token:
|
|
return 400, {"error": "zitadel_access_token required"}
|
|
userinfo, err = self.validate_token(token)
|
|
if err:
|
|
return 401, {"error": err}
|
|
login = self.derive_login(userinfo)
|
|
if not login:
|
|
return 422, {"error": "could not derive a login from userinfo"}
|
|
if not self.user_exists(login):
|
|
err = self.create_user(login, userinfo)
|
|
if err:
|
|
return 502, {"error": err}
|
|
LOG.info("created gitea user %s", login)
|
|
gitea_token, token_name, err = self.mint_token(login, device_name)
|
|
if err:
|
|
return 502, {"error": err}
|
|
LOG.info("minted token %s for %s", token_name, login)
|
|
return 200, {"gitea_base": self.public_gitea, "login": login,
|
|
"token": gitea_token, "token_name": token_name}
|
|
|
|
def repos(self, body):
|
|
token = body.get("token")
|
|
name = body.get("name")
|
|
if not token or not name:
|
|
return 400, {"error": "token and name required"}
|
|
status, resp = http_json(
|
|
"POST", f"{self.gitea}/api/v1/user/repos",
|
|
headers={"Authorization": f"token {token}"},
|
|
body={"name": name, "private": bool(body.get("private", True)),
|
|
"default_branch": "main", "auto_init": False})
|
|
if status == 409:
|
|
return 409, {"error": f"repo {name} already exists"}
|
|
if status != 201:
|
|
return 502, {"error": f"repo create failed (HTTP {status}): {resp}"}
|
|
full_name = resp.get("full_name", "")
|
|
return 200, {
|
|
"name": resp.get("name"), "full_name": full_name,
|
|
"private": resp.get("private"),
|
|
"clone_url": f"{self.public_gitea}/{full_name}.git",
|
|
"html_url": f"{self.public_gitea}/{full_name}",
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# HTTP server plumbing
|
|
# --------------------------------------------------------------------------
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
service = None # set by serve()
|
|
server_version = f"granthi-link/{VERSION}"
|
|
|
|
def _send(self, status, obj):
|
|
payload = json.dumps(obj).encode()
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
|
|
def do_GET(self):
|
|
if self.path == "/health":
|
|
self._send(200, {"status": "ok", "service": "granthi-link",
|
|
"version": VERSION})
|
|
else:
|
|
self._send(404, {"error": "not found"})
|
|
|
|
def do_POST(self):
|
|
try:
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
body = json.loads(self.rfile.read(length) or b"{}")
|
|
except (ValueError, TypeError):
|
|
return self._send(400, {"error": "invalid JSON body"})
|
|
if self.path == "/v1/link":
|
|
status, resp = self.service.link(body)
|
|
elif self.path == "/v1/repos":
|
|
status, resp = self.service.repos(body)
|
|
else:
|
|
return self._send(404, {"error": "not found"})
|
|
self._send(status, resp)
|
|
|
|
def log_message(self, fmt, *args): # route to logging, redact nothing else
|
|
LOG.info("%s %s", self.address_string(), fmt % args)
|
|
|
|
|
|
def serve(config):
|
|
service = LinkService(config)
|
|
Handler.service = service
|
|
servers = []
|
|
for host, port in config.get("binds", [["127.0.0.1", 3042]]):
|
|
srv = ThreadingHTTPServer((host, int(port)), Handler)
|
|
servers.append(srv)
|
|
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
|
t.start()
|
|
LOG.info("listening on %s:%s", host, port)
|
|
|
|
stop = threading.Event()
|
|
|
|
def on_term(signum, frame):
|
|
LOG.info("signal %s, shutting down", signum)
|
|
stop.set()
|
|
|
|
signal.signal(signal.SIGTERM, on_term)
|
|
signal.signal(signal.SIGINT, on_term)
|
|
stop.wait()
|
|
for srv in servers:
|
|
srv.shutdown()
|
|
|
|
|
|
def main():
|
|
logging.basicConfig(level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(message)s")
|
|
path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_CONFIG
|
|
with open(path) as f:
|
|
config = json.load(f)
|
|
for key in ("gitea_base", "admin_token", "admin_login", "admin_password"):
|
|
if key not in config:
|
|
sys.exit(f"config missing required key: {key}")
|
|
st = os.stat(path)
|
|
if st.st_mode & 0o077:
|
|
LOG.warning("config %s is group/world readable -- chmod 600 it", path)
|
|
serve(config)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|