Files
granthi-sync/server/granthi_link.py
T

312 lines
12 KiB
Python
Raw Normal View History

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