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:
+207
-3
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user