#!/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()