Merge lp:~cjwatson/launchpad/git-honour-access-tokens into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 19049
Proposed branch: lp:~cjwatson/launchpad/git-honour-access-tokens
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/git-issue-access-tokens
Diff against target: 249 lines (+145/-14)
3 files modified
lib/lp/code/model/codeimportjob.py (+4/-0)
lib/lp/code/xmlrpc/git.py (+25/-14)
lib/lp/code/xmlrpc/tests/test_git.py (+116/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/git-honour-access-tokens
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+366053@code.launchpad.net

Commit message

Make the Git XML-RPC API honour user macaroons.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) :
Revision history for this message
Colin Watson (cjwatson) :
Revision history for this message
Colin Watson (cjwatson) wrote :

This should all be much clearer now, so please take another look.

Revision history for this message
William Grant (wgrant) :
review: Needs Fixing (code)
Revision history for this message
Colin Watson (cjwatson) :
Revision history for this message
Colin Watson (cjwatson) :
Revision history for this message
William Grant (wgrant) wrote :

In _verifyAuthParams's "User authentication with no macaroon. We do no additional checks here." case it's very unclear that some other kind of authentication has already taken place, and verifyAuthParams isn't in fact the thing that's verifying the auth. Otherwise I think this is all safe enough -- thanks.

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/lp/code/model/codeimportjob.py'
--- lib/lp/code/model/codeimportjob.py 2019-05-01 15:47:37 +0000
+++ lib/lp/code/model/codeimportjob.py 2019-05-10 16:51:16 +0000
@@ -463,6 +463,10 @@
463463
464 def verifyPrimaryCaveat(self, caveat_value, context, **kwargs):464 def verifyPrimaryCaveat(self, caveat_value, context, **kwargs):
465 """See `MacaroonIssuerBase`."""465 """See `MacaroonIssuerBase`."""
466 # Code import jobs only support free-floating macaroons for Git
467 # authentication, not ones bound to a user.
468 if kwargs.get("user"):
469 return False
466 if context is None:470 if context is None:
467 # We're only verifying that the macaroon could be valid for some471 # We're only verifying that the macaroon could be valid for some
468 # context.472 # context.
469473
=== modified file 'lib/lp/code/xmlrpc/git.py'
--- lib/lp/code/xmlrpc/git.py 2019-05-09 15:44:59 +0000
+++ lib/lp/code/xmlrpc/git.py 2019-05-10 16:51:16 +0000
@@ -60,7 +60,10 @@
60 InvalidName,60 InvalidName,
61 NoSuchSourcePackageName,61 NoSuchSourcePackageName,
62 )62 )
63from lp.registry.interfaces.person import NoSuchPerson63from lp.registry.interfaces.person import (
64 IPersonSet,
65 NoSuchPerson,
66 )
64from lp.registry.interfaces.product import (67from lp.registry.interfaces.product import (
65 InvalidProductName,68 InvalidProductName,
66 NoSuchProduct,69 NoSuchProduct,
@@ -125,7 +128,7 @@
125 super(GitAPI, self).__init__(*args, **kwargs)128 super(GitAPI, self).__init__(*args, **kwargs)
126 self.repository_set = getUtility(IGitRepositorySet)129 self.repository_set = getUtility(IGitRepositorySet)
127130
128 def _verifyMacaroon(self, macaroon_raw, repository=None):131 def _verifyMacaroon(self, macaroon_raw, repository=None, user=None):
129 try:132 try:
130 macaroon = Macaroon.deserialize(macaroon_raw)133 macaroon = Macaroon.deserialize(macaroon_raw)
131 # XXX cjwatson 2019-04-23: Restrict exceptions once134 # XXX cjwatson 2019-04-23: Restrict exceptions once
@@ -137,7 +140,7 @@
137 except ComponentLookupError:140 except ComponentLookupError:
138 return False141 return False
139 return issuer.verifyMacaroon(142 return issuer.verifyMacaroon(
140 macaroon, repository, require_context=False)143 macaroon, repository, require_context=False, user=user)
141144
142 def _performLookup(self, requester, path, auth_params):145 def _performLookup(self, requester, path, auth_params):
143 repository, extra_path = getUtility(IGitLookup).getByPath(path)146 repository, extra_path = getUtility(IGitLookup).getByPath(path)
@@ -149,7 +152,10 @@
149 writable = None152 writable = None
150153
151 if macaroon_raw is not None:154 if macaroon_raw is not None:
152 verified = self._verifyMacaroon(macaroon_raw, naked_repository)155 verify_user = (
156 None if requester == LAUNCHPAD_SERVICES else requester)
157 verified = self._verifyMacaroon(
158 macaroon_raw, naked_repository, user=verify_user)
153 if not verified:159 if not verified:
154 # Macaroon authentication failed. Don't fall back to the160 # Macaroon authentication failed. Don't fall back to the
155 # requester's permissions, since macaroons typically have161 # requester's permissions, since macaroons typically have
@@ -201,6 +207,7 @@
201 if not grants.is_empty():207 if not grants.is_empty():
202 writable = True208 writable = True
203 private = repository.private209 private = repository.private
210
204 return {211 return {
205 "path": hosting_path,212 "path": hosting_path,
206 "writable": writable,213 "writable": writable,
@@ -390,17 +397,18 @@
390 getUtility(IGitRefScanJobSource).create(397 getUtility(IGitRefScanJobSource).create(
391 removeSecurityProxy(repository))398 removeSecurityProxy(repository))
392399
400 @return_fault
393 def authenticateWithPassword(self, username, password):401 def authenticateWithPassword(self, username, password):
394 """See `IGitAPI`."""402 """See `IGitAPI`."""
395 # XXX cjwatson 2016-10-06: We only support free-floating macaroons403 user = getUtility(IPersonSet).getByName(username) if username else None
396 # at the moment, not ones bound to a user.404 verified = self._verifyMacaroon(password, user=user)
397 if not username:405 if verified:
398 verified = self._verifyMacaroon(password)406 auth_params = {"macaroon": password}
399 if verified:407 if user is not None:
400 auth_params = {"macaroon": password}408 auth_params["uid"] = user.id
401 if _is_issuer_internal(verified):409 elif _is_issuer_internal(verified):
402 auth_params["user"] = LAUNCHPAD_SERVICES410 auth_params["user"] = LAUNCHPAD_SERVICES
403 return auth_params411 return auth_params
404 # Only macaroons are supported for password authentication.412 # Only macaroons are supported for password authentication.
405 return faults.Unauthorized()413 return faults.Unauthorized()
406414
@@ -428,7 +436,10 @@
428 try:436 try:
429 macaroon_raw = auth_params.get("macaroon")437 macaroon_raw = auth_params.get("macaroon")
430 if macaroon_raw is not None:438 if macaroon_raw is not None:
431 verified = self._verifyMacaroon(macaroon_raw, repository)439 verify_user = (
440 None if requester == LAUNCHPAD_SERVICES else requester)
441 verified = self._verifyMacaroon(
442 macaroon_raw, repository, user=verify_user)
432 if not verified:443 if not verified:
433 # Macaroon authentication failed. Don't fall back to444 # Macaroon authentication failed. Don't fall back to
434 # the requester's permissions, since macaroons typically445 # the requester's permissions, since macaroons typically
435446
=== modified file 'lib/lp/code/xmlrpc/tests/test_git.py'
--- lib/lp/code/xmlrpc/tests/test_git.py 2019-05-09 15:44:59 +0000
+++ lib/lp/code/xmlrpc/tests/test_git.py 2019-05-10 16:51:16 +0000
@@ -1081,6 +1081,46 @@
1081 repository.registrant, path, permission="read",1081 repository.registrant, path, permission="read",
1082 macaroon_raw=macaroons[0].serialize())1082 macaroon_raw=macaroons[0].serialize())
10831083
1084 def test_translatePath_user_macaroon(self):
1085 # A user with a suitable macaroon can write to the corresponding
1086 # repository, but not others, even if they own them.
1087 self.pushConfig("codehosting", git_macaroon_secret_key="some-secret")
1088 requester = self.factory.makePerson()
1089 repositories = [
1090 self.factory.makeGitRepository(owner=requester) for _ in range(2)]
1091 repositories.append(self.factory.makeGitRepository(
1092 owner=requester, information_type=InformationType.PRIVATESECURITY))
1093 issuer = getUtility(IMacaroonIssuer, "git-repository")
1094 with person_logged_in(requester):
1095 macaroons = [
1096 removeSecurityProxy(issuer).issueMacaroon(
1097 repository, user=requester)
1098 for repository in repositories]
1099 paths = [
1100 u"/%s" % repository.unique_name for repository in repositories]
1101 for i, repository in enumerate(repositories):
1102 for j, macaroon in enumerate(macaroons):
1103 login(ANONYMOUS)
1104 if i == j:
1105 self.assertTranslates(
1106 requester, paths[i], repository, True,
1107 permission="write", macaroon_raw=macaroon.serialize(),
1108 private=(i == 2))
1109 else:
1110 self.assertUnauthorized(
1111 requester, paths[i], permission="write",
1112 macaroon_raw=macaroon.serialize())
1113 login(ANONYMOUS)
1114 self.assertUnauthorized(
1115 requester, paths[i], permission="write",
1116 macaroon_raw=Macaroon(
1117 location=config.vhost.mainsite.hostname,
1118 identifier="another", key="another-secret").serialize())
1119 login(ANONYMOUS)
1120 self.assertUnauthorized(
1121 requester, paths[i], permission="write",
1122 macaroon_raw="nonsense")
1123
1084 def test_notify(self):1124 def test_notify(self):
1085 # The notify call creates a GitRefScanJob.1125 # The notify call creates a GitRefScanJob.
1086 repository = self.factory.makeGitRepository()1126 repository = self.factory.makeGitRepository()
@@ -1157,6 +1197,35 @@
1157 self.git_api.authenticateWithPassword("", "nonsense"),1197 self.git_api.authenticateWithPassword("", "nonsense"),
1158 faults.Unauthorized)1198 faults.Unauthorized)
11591199
1200 def test_authenticateWithPassword_user_macaroon(self):
1201 # A user with a suitable macaroon can authenticate using it, in
1202 # which case we return both the macaroon and the uid for use by
1203 # later calls.
1204 self.pushConfig("codehosting", git_macaroon_secret_key="some-secret")
1205 requester = self.factory.makePerson()
1206 issuer = getUtility(IMacaroonIssuer, "git-repository")
1207 macaroon = removeSecurityProxy(issuer).issueMacaroon(
1208 self.factory.makeGitRepository(owner=requester), user=requester)
1209 self.assertEqual(
1210 {"macaroon": macaroon.serialize(), "uid": requester.id},
1211 self.git_api.authenticateWithPassword(
1212 requester.name, macaroon.serialize()))
1213 self.assertIsInstance(
1214 self.git_api.authenticateWithPassword("", macaroon.serialize()),
1215 faults.Unauthorized)
1216 self.assertIsInstance(
1217 self.git_api.authenticateWithPassword(
1218 "nonexistent", macaroon.serialize()),
1219 faults.Unauthorized)
1220 other_macaroon = Macaroon(identifier="another", key="another-secret")
1221 self.assertIsInstance(
1222 self.git_api.authenticateWithPassword(
1223 requester.name, other_macaroon.serialize()),
1224 faults.Unauthorized)
1225 self.assertIsInstance(
1226 self.git_api.authenticateWithPassword(requester.name, "nonsense"),
1227 faults.Unauthorized)
1228
1160 def test_checkRefPermissions_code_import(self):1229 def test_checkRefPermissions_code_import(self):
1161 # A code import worker with a suitable macaroon has repository owner1230 # A code import worker with a suitable macaroon has repository owner
1162 # privileges on a repository associated with a running code import1231 # privileges on a repository associated with a running code import
@@ -1267,6 +1336,53 @@
1267 LAUNCHPAD_SERVICES, ref.repository, [path], {path: []},1336 LAUNCHPAD_SERVICES, ref.repository, [path], {path: []},
1268 macaroon_raw=macaroon.serialize())1337 macaroon_raw=macaroon.serialize())
12691338
1339 def test_checkRefPermissions_user_macaroon(self):
1340 # A user with a suitable macaroon has their ordinary privileges on
1341 # the corresponding repository, but not others, even if they own
1342 # them.
1343 self.pushConfig("codehosting", git_macaroon_secret_key="some-secret")
1344 requester = self.factory.makePerson()
1345 repositories = [
1346 self.factory.makeGitRepository(owner=requester) for _ in range(2)]
1347 repositories.append(self.factory.makeGitRepository(
1348 owner=requester, information_type=InformationType.PRIVATESECURITY))
1349 ref_path = b"refs/heads/master"
1350 issuer = getUtility(IMacaroonIssuer, "git-repository")
1351 with person_logged_in(requester):
1352 macaroons = [
1353 removeSecurityProxy(issuer).issueMacaroon(
1354 repository, user=requester)
1355 for repository in repositories]
1356 for i, repository in enumerate(repositories):
1357 for j, macaroon in enumerate(macaroons):
1358 login(ANONYMOUS)
1359 if i == j:
1360 expected_permissions = ["create", "push", "force_push"]
1361 else:
1362 expected_permissions = []
1363 self.assertHasRefPermissions(
1364 requester, repository, [ref_path],
1365 {ref_path: expected_permissions},
1366 macaroon_raw=macaroon.serialize())
1367 login(ANONYMOUS)
1368 self.assertHasRefPermissions(
1369 None, repository, [ref_path], {ref_path: []},
1370 macaroon_raw=macaroon.serialize())
1371 login(ANONYMOUS)
1372 self.assertHasRefPermissions(
1373 self.factory.makePerson(), repository, [ref_path],
1374 {ref_path: []}, macaroon_raw=macaroon.serialize())
1375 login(ANONYMOUS)
1376 self.assertHasRefPermissions(
1377 requester, repository, [ref_path], {ref_path: []},
1378 macaroon_raw=Macaroon(
1379 location=config.vhost.mainsite.hostname,
1380 identifier="another", key="another-secret").serialize())
1381 login(ANONYMOUS)
1382 self.assertHasRefPermissions(
1383 requester, repository, [ref_path], {ref_path: []},
1384 macaroon_raw="nonsense")
1385
12701386
1271class TestGitAPISecurity(TestGitAPIMixin, TestCaseWithFactory):1387class TestGitAPISecurity(TestGitAPIMixin, TestCaseWithFactory):
1272 """Slow tests for `IGitAPI`.1388 """Slow tests for `IGitAPI`.