granthi-sync v1: granthi-link provisioning service + client daemon + tests
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"gitea_base": "http://127.0.0.1:3041",
|
||||
"public_gitea_base": "http://100.111.127.127:3041",
|
||||
"zitadel_userinfo": "https://id.shre.ai/oidc/v1/userinfo",
|
||||
"admin_token": "MINT-VIA: docker exec -u git gitea-beta-gitea-1 gitea admin user generate-access-token --username nirpa --scopes write:admin,write:user,write:repository --raw",
|
||||
"admin_login": "nirpa",
|
||||
"admin_password": "FROM /opt/gitea-beta/.admin-creds (required: Gitea 1.27 token minting only works via basic auth + Sudo header)",
|
||||
"binds": [["127.0.0.1", 3042], ["100.111.127.127", 3042]],
|
||||
"test_mode": false
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=granthi-link provisioning service (granthi-sync spine)
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /opt/granthi-link/granthi_link.py /opt/granthi-link/config.json
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
User=root
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=full
|
||||
ReadWritePaths=/opt/granthi-link
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,311 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user