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
+177 -1
View File
@@ -51,6 +51,14 @@ class StubUpstream(BaseHTTPRequestHandler):
if not login:
return self._json(401, {"message": "unauthorized"})
return self._json(200, {"login": login})
if self.path.endswith("/collaborators") and self.path.startswith("/api/v1/repos/"):
full = self.path.split("/api/v1/repos/")[1].rsplit("/collaborators", 1)[0]
tok = self.headers.get("Authorization", "").replace("token ", "")
caller = st["tokens_by_sha"].get(tok)
if st["repo_owner"].get(full) != caller:
return self._json(403, {"message": "must have admin rights"})
return self._json(200, [{"login": w}
for w in st["collab"].get(full, {})])
if self.path.startswith("/api/v1/users/") and not self.path.endswith("/tokens"):
login = self.path.rsplit("/", 1)[1]
if login in st["hide_once"]:
@@ -96,8 +104,36 @@ class StubUpstream(BaseHTTPRequestHandler):
f"alice/{body['name']}"})
self._json(404, {})
def do_PUT(self):
"""Gitea adds a collaborator with PUT, and the admin path uses basic
auth because the inviter's session is long gone by then."""
st = self.state
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length) or b"{}")
if "/collaborators/" in self.path and self.path.startswith("/api/v1/repos/"):
full = self.path.split("/api/v1/repos/")[1].split("/collaborators/")[0]
who = self.path.rsplit("/", 1)[1]
if full not in st["repo_owner"]:
return self._json(404, {"message": "no such repo"})
tok = self.headers.get("Authorization", "")
caller = st["tokens_by_sha"].get(tok.replace("token ", ""))
if not tok.startswith("Basic ") and st["repo_owner"].get(full) != caller:
return self._json(403, {"message": "forbidden"})
st["collab"].setdefault(full, {})[who] = body.get("permission", "write")
return self._json(204, {})
self._json(404, {})
def do_DELETE(self):
st = self.state
if "/collaborators/" in self.path and self.path.startswith("/api/v1/repos/"):
full = self.path.split("/api/v1/repos/")[1].split("/collaborators/")[0]
who = self.path.rsplit("/", 1)[1]
tok = self.headers.get("Authorization", "").replace("token ", "")
caller = st["tokens_by_sha"].get(tok)
if st["repo_owner"].get(full) != caller:
return self._json(403, {"message": "forbidden"})
st["collab"].get(full, {}).pop(who, None)
return self._json(204, {})
if self.path.startswith("/api/v1/users/") and "/tokens/" in self.path:
if not self.headers.get("Authorization", "").startswith("Basic "):
# matches real Gitea: token auth cannot delete tokens
@@ -125,7 +161,8 @@ class ServiceTestBase(unittest.TestCase):
StubUpstream.state = {"users": {}, "created": [], "repos": set(),
"token_reqs": [], "hide_once": set(),
"tokens": {}, "tokens_by_sha": {},
"revoke_fails": False}
"revoke_fails": False,
"repo_owner": {}, "collab": {}}
self.upstream = ThreadingHTTPServer(("127.0.0.1", 0), StubUpstream)
threading.Thread(target=self.upstream.serve_forever, daemon=True).start()
self.addCleanup(self.upstream.shutdown)
@@ -784,6 +821,145 @@ class TestDeviceEndpointsOverHttp(HandlerTestBase):
for route, (limit, _window) in granthi_link.DEFAULT_RATE_RULES.items():
self.assertGreater(limit, 0, f"{route} is disabled by its rule")
class TestSharing(ServiceTestBase):
"""Give a person access to one repo -- and to nothing else."""
def setUp(self):
super().setUp()
self.enable_test_mode()
self.alice = self.stub_link("s1", "alice", email="[email protected]",
verified=True, device_id="dev-a")[1]
self.bob = self.stub_link("s2", "bob", email="[email protected]",
verified=True, device_id="dev-b")[1]
StubUpstream.state["repo_owner"]["alice/notes"] = "alice"
StubUpstream.state["repo_owner"]["bob/private"] = "bob"
def test_owner_can_share_and_unshare(self):
status, resp = self.svc.grants({"token": self.alice["token"],
"repo": "notes", "login": "bob"})
self.assertEqual(status, 200, resp)
self.assertIn("bob", StubUpstream.state["collab"]["alice/notes"])
status, _ = self.svc.grants({"token": self.alice["token"],
"action": "remove",
"repo": "notes", "login": "bob"})
self.assertEqual(status, 200)
self.assertNotIn("bob", StubUpstream.state["collab"]["alice/notes"])
def test_a_non_owner_cannot_share_someone_elses_repo(self):
"""The forge decides who may share, not this service."""
status, resp = self.svc.grants({"token": self.bob["token"],
"repo": "alice/notes",
"login": "bob"})
self.assertEqual(status, 403)
self.assertEqual(StubUpstream.state["collab"].get("alice/notes", {}), {})
def test_permission_is_validated(self):
status, _ = self.svc.grants({"token": self.alice["token"],
"repo": "notes", "login": "bob",
"permission": "owner"})
self.assertEqual(status, 400)
def test_listing_shows_who_has_access(self):
self.svc.grants({"token": self.alice["token"], "repo": "notes",
"login": "bob"})
status, resp = self.svc.grants({"token": self.alice["token"],
"action": "list", "repo": "notes"})
self.assertEqual((status, resp["collaborators"]), (200, ["bob"]))
def test_removing_someone_who_never_had_access_is_not_an_error(self):
status, _ = self.svc.grants({"token": self.alice["token"],
"action": "remove", "repo": "notes",
"login": "nobody"})
self.assertEqual(status, 200)
class TestInvites(ServiceTestBase):
"""A promise of access for someone with no account yet."""
def setUp(self):
super().setUp()
self.enable_test_mode()
self.alice = self.stub_link("s1", "alice", email="[email protected]",
verified=True, device_id="dev-a")[1]
StubUpstream.state["repo_owner"]["alice/notes"] = "alice"
def invite(self, email, repos=("notes",), token=None):
return self.svc.invite({"token": token or self.alice["token"],
"email": email, "repos": list(repos)})
def test_invite_creates_nothing_until_they_sign_in(self):
status, resp = self.invite("[email protected]")
self.assertEqual(status, 200, resp)
# no account, no collaborator row yet
self.assertNotIn("carol", StubUpstream.state["users"])
self.assertEqual(StubUpstream.state["collab"].get("alice/notes", {}), {})
def test_access_lands_on_first_link_with_a_verified_email(self):
self.invite("[email protected]")
status, resp = self.stub_link("s9", "carol", email="[email protected]",
verified=True, device_id="dev-c")
self.assertEqual(status, 200, resp)
self.assertEqual(resp.get("granted_repos"), ["alice/notes"])
self.assertIn("carol", StubUpstream.state["collab"]["alice/notes"])
def test_an_unverified_email_collects_nothing(self):
"""The address is the only thing tying the promise to this person."""
self.invite("[email protected]")
status, resp = self.stub_link("s10", "dave", email="[email protected]",
verified=False, device_id="dev-d")
self.assertEqual(status, 200)
self.assertIsNone(resp.get("granted_repos"))
self.assertEqual(StubUpstream.state["collab"].get("alice/notes", {}), {})
# and the invite is still waiting, not consumed
self.assertTrue(self.svc.state.peek_invites("[email protected]"))
def test_an_invite_is_applied_once(self):
self.invite("[email protected]")
self.stub_link("s11", "erin", email="[email protected]", verified=True,
device_id="dev-e")
status, resp = self.stub_link("s11", "erin", email="[email protected]",
verified=True, device_id="dev-e2")
self.assertIsNone(resp.get("granted_repos"))
def test_cannot_invite_to_a_repo_you_do_not_administer(self):
StubUpstream.state["repo_owner"]["bob/secret"] = "bob"
status, _ = self.invite("[email protected]", repos=("bob/secret",))
self.assertEqual(status, 403)
self.assertEqual(self.svc.state.peek_invites("[email protected]"), [])
def test_reinviting_updates_the_permission_instead_of_stacking(self):
self.svc.invite({"token": self.alice["token"], "email": "[email protected]",
"repos": [{"name": "notes", "permission": "read"}]})
self.svc.invite({"token": self.alice["token"], "email": "[email protected]",
"repos": [{"name": "notes", "permission": "write"}]})
pending = self.svc.state.peek_invites("[email protected]")
self.assertEqual(len(pending), 1)
self.assertEqual(pending[0]["permission"], "write")
def test_a_bad_email_is_refused(self):
status, _ = self.svc.invite({"token": self.alice["token"],
"email": "not-an-email",
"repos": ["notes"]})
self.assertEqual(status, 400)
def test_a_failed_grant_does_not_block_the_person_signing_in(self):
"""Someone must not be locked out because a repo they were promised
has since been deleted."""
self.invite("[email protected]", repos=("notes",))
StubUpstream.state["repo_owner"].pop("alice/notes")
status, resp = self.stub_link("s12", "gina", email="[email protected]",
verified=True, device_id="dev-g")
self.assertEqual(status, 200)
self.assertIn("token", resp)
def test_invite_and_application_are_both_audited(self):
self.invite("[email protected]")
self.stub_link("s13", "hana", email="[email protected]", verified=True,
device_id="dev-h")
_, mine = self.svc.audit_read({"token": self.alice["token"]})
self.assertIn("invite", [e["event"] for e in mine["events"]])
if __name__ == "__main__":
unittest.main()