feat: share repos with people, and invite people who have no account yet

Closes the last piece of the product picture: give one person access to some
of your repos and not others.

- POST /v1/grants add|remove|list -- runs on the CALLER'S OWN token. Verified
  against the live forge that a repo owner's scoped token adds and removes
  collaborators (204), so sharing needs no elevated rights anywhere.
- POST /v1/invite -- records a promise against a VERIFIED email and creates
  nothing until it is redeemed. Applied on first link.
- client: share / shared / invite.

Three properties the tests pin:
  * the FORGE decides who may share (listing collaborators requires repo
    admin, so its 200 is the authorisation answer, not ours);
  * an unverified email collects nothing, and its invite stays pending rather
    than being consumed;
  * a failed grant never blocks a sign-in -- nobody is locked out of their own
    account because a repo they were promised has since been deleted.

Applying an invite uses the admin credential deliberately: the inviter
authorised it at invite time and their session is long gone by redemption.

198 tests (was 184).
This commit is contained in:
claude
2026-08-23 14:10:18 -04:00
parent 24a7fa6cdb
commit 1a151a22e2
4 changed files with 519 additions and 6 deletions
+207 -3
View File
@@ -90,6 +90,10 @@ DEFAULT_RATE_RULES = {"/v1/link": (5, 3600), "/v1/repos": (60, 3600),
# Reads are cheap but still authenticated work.
"/v1/devices": (120, 3600),
"/v1/audit": (120, 3600),
# Sharing is an ordinary act; inviting reaches a person
# who does not exist yet, so it is the tighter of the two.
"/v1/grants": (120, 3600),
"/v1/invite": (60, 3600),
# Revocation is a safety action, so its limit is set
# high rather than tight. It is NOT 0: in this limiter
# a limit of 0 DISABLES the endpoint outright (see
@@ -368,6 +372,42 @@ class IdentityStore:
return sub, dict(ident.get("devices") or {})
return None, {}
# -- pending invites ---------------------------------------------------
# Keyed by VERIFIED email. An invite is a promise of access made before the
# person has a forge account; it is applied the first time they link. The
# key must be an identity the IdP vouches for, or anyone could claim
# someone else's invite by asserting their address.
def add_invite(self, email, grants, invited_by):
data = self._load()
book = data.setdefault("invites", {})
entry = book.setdefault(email.strip().lower(), [])
for g in grants:
# Re-inviting the same repo updates the permission rather than
# stacking duplicates that would be applied twice.
entry[:] = [e for e in entry if e["repo"] != g["repo"]]
entry.append({"repo": g["repo"], "permission": g["permission"],
"invited_by": invited_by,
"invited_at": datetime.now(timezone.utc).isoformat(
timespec="seconds")})
self._write(data)
def take_invites(self, email):
"""Read AND clear the invites for an email, atomically under the
caller's lock. Returns [] when there are none."""
if not email:
return []
data = self._load()
book = data.get("invites") or {}
pending = book.pop(email.strip().lower(), [])
if pending:
self._write(data)
return pending
def peek_invites(self, email):
book = self._load().get("invites") or {}
return list(book.get((email or "").strip().lower(), []))
def mark_revoked(self, sub, device_id, when):
data = self._load()
ident = data["identities"].get(str(sub)) or {}
@@ -767,12 +807,16 @@ class LinkService:
"linked_at": datetime.now(timezone.utc).isoformat(
timespec="seconds"),
})
granted = self.apply_invites(login, userinfo, client_ip)
self.audit.write("device.link", login=login, device_id=device_id,
device_name=device_name, token_name=token_name,
client_ip=client_ip)
return 200, {"gitea_base": self.public_gitea, "login": login,
"token": gitea_token, "token_name": token_name,
"device_id": device_id}
resp = {"gitea_base": self.public_gitea, "login": login,
"token": gitea_token, "token_name": token_name,
"device_id": device_id}
if granted:
resp["granted_repos"] = granted
return 200, resp
# -- device registry endpoints -----------------------------------------
@@ -862,6 +906,162 @@ class LinkService:
return None
return f"forge refused token deletion (HTTP {status}): {resp}"
# -- sharing -----------------------------------------------------------
PERMISSIONS = ("read", "write", "admin")
def _repo_admin_check(self, token, full_name):
"""Can this caller administer that repo? Ask the FORGE.
Listing collaborators requires repo admin, so a 200 here is Gitea's own
answer to 'may you share this?'. Deciding it ourselves from the repo
name would be a second opinion about someone else's authorisation --
and the wrong one the first time a repo is transferred.
"""
status, _ = http_json(
"GET", f"{self.gitea}/api/v1/repos/{full_name}/collaborators",
headers={"Authorization": f"token {token}"})
return status == 200
def grants(self, body, client_ip=None):
"""Share a repo with someone who already has a forge account."""
login = self.whoami(body.get("token"))
if not login:
return 401, {"error": "invalid or revoked token"}
token = body.get("token")
action = (body.get("action") or "add").lower()
if action not in ("add", "remove", "list"):
return 400, {"error": "action must be add, remove or list"}
repo = str(body.get("repo") or "").strip()
if not repo:
return 400, {"error": "repo required"}
full = repo if "/" in repo else f"{login}/{repo}"
if not self._repo_admin_check(token, full):
self.audit.write("grant.denied", login=login, repo=full,
client_ip=client_ip,
reason="caller cannot administer this repo")
return 403, {"error": f"you cannot administer {full}"}
if action == "list":
status, resp = http_json(
"GET", f"{self.gitea}/api/v1/repos/{full}/collaborators",
headers={"Authorization": f"token {token}"})
people = [u.get("login") for u in resp] if isinstance(resp, list) else []
return 200, {"repo": full, "collaborators": people}
who = str(body.get("login") or "").strip()
if not who:
return 400, {"error": "login required"}
permission = (body.get("permission") or "write").lower()
if permission not in self.PERMISSIONS:
return 400, {"error": f"permission must be one of "
f"{', '.join(self.PERMISSIONS)}"}
if action == "add":
status, resp = http_json(
"PUT",
f"{self.gitea}/api/v1/repos/{full}/collaborators/"
f"{urllib.parse.quote(who, safe='')}",
headers={"Authorization": f"token {token}"},
body={"permission": permission})
ok = status in (200, 204)
else:
status, resp = http_json(
"DELETE",
f"{self.gitea}/api/v1/repos/{full}/collaborators/"
f"{urllib.parse.quote(who, safe='')}",
headers={"Authorization": f"token {token}"})
ok = status in (200, 204, 404) # already gone is the wanted state
if not ok:
self.audit.write("grant.failed", login=login, repo=full,
grantee=who, action=action, client_ip=client_ip,
reason=f"HTTP {status}")
return 502, {"error": f"forge refused (HTTP {status}): {resp}"}
self.audit.write(f"grant.{action}", login=login, repo=full,
grantee=who, permission=permission,
client_ip=client_ip)
return 200, {"repo": full, "login": who, "action": action,
"permission": permission}
def invite(self, body, client_ip=None):
"""Promise access to someone who has no forge account yet.
Nothing is created for them here -- no account, no token. The grant is
recorded against their VERIFIED email and applied the first time they
link. If they never link, nothing ever existed.
"""
login = self.whoami(body.get("token"))
if not login:
return 401, {"error": "invalid or revoked token"}
email = str(body.get("email") or "").strip().lower()
if "@" not in email:
return 400, {"error": "a valid email is required"}
repos = body.get("repos")
if not isinstance(repos, list) or not repos:
return 400, {"error": "repos must be a non-empty list"}
wanted = []
for entry in repos:
if isinstance(entry, str):
entry = {"name": entry}
if not isinstance(entry, dict) or not entry.get("name"):
return 400, {"error": "each repo needs a name"}
name = str(entry["name"]).strip()
full = name if "/" in name else f"{login}/{name}"
permission = (entry.get("permission") or "write").lower()
if permission not in self.PERMISSIONS:
return 400, {"error": f"permission must be one of "
f"{', '.join(self.PERMISSIONS)}"}
if not self._repo_admin_check(body.get("token"), full):
self.audit.write("invite.denied", login=login, repo=full,
invitee=email, client_ip=client_ip,
reason="caller cannot administer this repo")
return 403, {"error": f"you cannot administer {full}"}
wanted.append({"repo": full, "permission": permission})
with self.state.lock:
self.state.add_invite(email, wanted, login)
self.audit.write("invite", login=login, invitee=email,
repos=[w["repo"] for w in wanted], client_ip=client_ip)
return 200, {"invited": email,
"repos": wanted,
"note": "applied the first time they sign in with a "
"verified email that matches"}
def apply_invites(self, login, userinfo, client_ip=None):
"""Turn recorded invites into real collaborator rows at link time.
Uses the ADMIN credential deliberately: the inviter authorised this
when they issued the invite, and their session is long gone by now.
Nothing here can fail the link -- someone signing in must not be
blocked because a repo they were promised has since been deleted.
"""
email = (userinfo.get("email") or "").strip().lower()
if not email or not userinfo.get("email_verified"):
# Unverified email must never collect an invite: the address is
# the only thing tying the promise to this person.
return []
with self.state.lock:
pending = self.state.take_invites(email)
applied = []
for grant in pending:
status, resp = http_json(
"PUT",
f"{self.gitea}/api/v1/repos/{grant['repo']}/collaborators/"
f"{urllib.parse.quote(login, safe='')}",
headers={"Authorization": _basic(self.cfg["admin_login"],
self.cfg["admin_password"])},
body={"permission": grant.get("permission", "write")})
if status in (200, 204):
applied.append(grant["repo"])
self.audit.write("invite.applied", login=login,
repo=grant["repo"],
permission=grant.get("permission"),
client_ip=client_ip)
else:
self.audit.write("invite.apply.failed", login=login,
repo=grant["repo"], client_ip=client_ip,
reason=f"HTTP {status}: {resp}")
LOG.warning("invite for %s on %s could not be applied "
"(HTTP %s)", login, grant["repo"], status)
return applied
def audit_read(self, body, client_ip=None):
login = self.whoami(body.get("token"))
if not login:
@@ -982,6 +1182,10 @@ class Handler(BaseHTTPRequestHandler):
status, resp = self.service.devices(body, peer)
elif self.path == "/v1/devices/revoke":
status, resp = self.service.revoke_device(body, peer)
elif self.path == "/v1/grants":
status, resp = self.service.grants(body, peer)
elif self.path == "/v1/invite":
status, resp = self.service.invite(body, peer)
elif self.path == "/v1/audit":
status, resp = self.service.audit_read(body, peer)
else: