Merge lp:~cjwatson/launchpad/git-honour-access-tokens into lp:launchpad
- git-honour-access-tokens
- Merge into devel
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 |
Related bugs: |
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.
Description of the change
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 : | # |
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
1 | === modified file 'lib/lp/code/model/codeimportjob.py' | |||
2 | --- lib/lp/code/model/codeimportjob.py 2019-05-01 15:47:37 +0000 | |||
3 | +++ lib/lp/code/model/codeimportjob.py 2019-05-10 16:51:16 +0000 | |||
4 | @@ -463,6 +463,10 @@ | |||
5 | 463 | 463 | ||
6 | 464 | def verifyPrimaryCaveat(self, caveat_value, context, **kwargs): | 464 | def verifyPrimaryCaveat(self, caveat_value, context, **kwargs): |
7 | 465 | """See `MacaroonIssuerBase`.""" | 465 | """See `MacaroonIssuerBase`.""" |
8 | 466 | # Code import jobs only support free-floating macaroons for Git | ||
9 | 467 | # authentication, not ones bound to a user. | ||
10 | 468 | if kwargs.get("user"): | ||
11 | 469 | return False | ||
12 | 466 | if context is None: | 470 | if context is None: |
13 | 467 | # We're only verifying that the macaroon could be valid for some | 471 | # We're only verifying that the macaroon could be valid for some |
14 | 468 | # context. | 472 | # context. |
15 | 469 | 473 | ||
16 | === modified file 'lib/lp/code/xmlrpc/git.py' | |||
17 | --- lib/lp/code/xmlrpc/git.py 2019-05-09 15:44:59 +0000 | |||
18 | +++ lib/lp/code/xmlrpc/git.py 2019-05-10 16:51:16 +0000 | |||
19 | @@ -60,7 +60,10 @@ | |||
20 | 60 | InvalidName, | 60 | InvalidName, |
21 | 61 | NoSuchSourcePackageName, | 61 | NoSuchSourcePackageName, |
22 | 62 | ) | 62 | ) |
24 | 63 | from lp.registry.interfaces.person import NoSuchPerson | 63 | from lp.registry.interfaces.person import ( |
25 | 64 | IPersonSet, | ||
26 | 65 | NoSuchPerson, | ||
27 | 66 | ) | ||
28 | 64 | from lp.registry.interfaces.product import ( | 67 | from lp.registry.interfaces.product import ( |
29 | 65 | InvalidProductName, | 68 | InvalidProductName, |
30 | 66 | NoSuchProduct, | 69 | NoSuchProduct, |
31 | @@ -125,7 +128,7 @@ | |||
32 | 125 | super(GitAPI, self).__init__(*args, **kwargs) | 128 | super(GitAPI, self).__init__(*args, **kwargs) |
33 | 126 | self.repository_set = getUtility(IGitRepositorySet) | 129 | self.repository_set = getUtility(IGitRepositorySet) |
34 | 127 | 130 | ||
36 | 128 | def _verifyMacaroon(self, macaroon_raw, repository=None): | 131 | def _verifyMacaroon(self, macaroon_raw, repository=None, user=None): |
37 | 129 | try: | 132 | try: |
38 | 130 | macaroon = Macaroon.deserialize(macaroon_raw) | 133 | macaroon = Macaroon.deserialize(macaroon_raw) |
39 | 131 | # XXX cjwatson 2019-04-23: Restrict exceptions once | 134 | # XXX cjwatson 2019-04-23: Restrict exceptions once |
40 | @@ -137,7 +140,7 @@ | |||
41 | 137 | except ComponentLookupError: | 140 | except ComponentLookupError: |
42 | 138 | return False | 141 | return False |
43 | 139 | return issuer.verifyMacaroon( | 142 | return issuer.verifyMacaroon( |
45 | 140 | macaroon, repository, require_context=False) | 143 | macaroon, repository, require_context=False, user=user) |
46 | 141 | 144 | ||
47 | 142 | def _performLookup(self, requester, path, auth_params): | 145 | def _performLookup(self, requester, path, auth_params): |
48 | 143 | repository, extra_path = getUtility(IGitLookup).getByPath(path) | 146 | repository, extra_path = getUtility(IGitLookup).getByPath(path) |
49 | @@ -149,7 +152,10 @@ | |||
50 | 149 | writable = None | 152 | writable = None |
51 | 150 | 153 | ||
52 | 151 | if macaroon_raw is not None: | 154 | if macaroon_raw is not None: |
54 | 152 | verified = self._verifyMacaroon(macaroon_raw, naked_repository) | 155 | verify_user = ( |
55 | 156 | None if requester == LAUNCHPAD_SERVICES else requester) | ||
56 | 157 | verified = self._verifyMacaroon( | ||
57 | 158 | macaroon_raw, naked_repository, user=verify_user) | ||
58 | 153 | if not verified: | 159 | if not verified: |
59 | 154 | # Macaroon authentication failed. Don't fall back to the | 160 | # Macaroon authentication failed. Don't fall back to the |
60 | 155 | # requester's permissions, since macaroons typically have | 161 | # requester's permissions, since macaroons typically have |
61 | @@ -201,6 +207,7 @@ | |||
62 | 201 | if not grants.is_empty(): | 207 | if not grants.is_empty(): |
63 | 202 | writable = True | 208 | writable = True |
64 | 203 | private = repository.private | 209 | private = repository.private |
65 | 210 | |||
66 | 204 | return { | 211 | return { |
67 | 205 | "path": hosting_path, | 212 | "path": hosting_path, |
68 | 206 | "writable": writable, | 213 | "writable": writable, |
69 | @@ -390,17 +397,18 @@ | |||
70 | 390 | getUtility(IGitRefScanJobSource).create( | 397 | getUtility(IGitRefScanJobSource).create( |
71 | 391 | removeSecurityProxy(repository)) | 398 | removeSecurityProxy(repository)) |
72 | 392 | 399 | ||
73 | 400 | @return_fault | ||
74 | 393 | def authenticateWithPassword(self, username, password): | 401 | def authenticateWithPassword(self, username, password): |
75 | 394 | """See `IGitAPI`.""" | 402 | """See `IGitAPI`.""" |
85 | 395 | # XXX cjwatson 2016-10-06: We only support free-floating macaroons | 403 | user = getUtility(IPersonSet).getByName(username) if username else None |
86 | 396 | # at the moment, not ones bound to a user. | 404 | verified = self._verifyMacaroon(password, user=user) |
87 | 397 | if not username: | 405 | if verified: |
88 | 398 | verified = self._verifyMacaroon(password) | 406 | auth_params = {"macaroon": password} |
89 | 399 | if verified: | 407 | if user is not None: |
90 | 400 | auth_params = {"macaroon": password} | 408 | auth_params["uid"] = user.id |
91 | 401 | if _is_issuer_internal(verified): | 409 | elif _is_issuer_internal(verified): |
92 | 402 | auth_params["user"] = LAUNCHPAD_SERVICES | 410 | auth_params["user"] = LAUNCHPAD_SERVICES |
93 | 403 | return auth_params | 411 | return auth_params |
94 | 404 | # Only macaroons are supported for password authentication. | 412 | # Only macaroons are supported for password authentication. |
95 | 405 | return faults.Unauthorized() | 413 | return faults.Unauthorized() |
96 | 406 | 414 | ||
97 | @@ -428,7 +436,10 @@ | |||
98 | 428 | try: | 436 | try: |
99 | 429 | macaroon_raw = auth_params.get("macaroon") | 437 | macaroon_raw = auth_params.get("macaroon") |
100 | 430 | if macaroon_raw is not None: | 438 | if macaroon_raw is not None: |
102 | 431 | verified = self._verifyMacaroon(macaroon_raw, repository) | 439 | verify_user = ( |
103 | 440 | None if requester == LAUNCHPAD_SERVICES else requester) | ||
104 | 441 | verified = self._verifyMacaroon( | ||
105 | 442 | macaroon_raw, repository, user=verify_user) | ||
106 | 432 | if not verified: | 443 | if not verified: |
107 | 433 | # Macaroon authentication failed. Don't fall back to | 444 | # Macaroon authentication failed. Don't fall back to |
108 | 434 | # the requester's permissions, since macaroons typically | 445 | # the requester's permissions, since macaroons typically |
109 | 435 | 446 | ||
110 | === modified file 'lib/lp/code/xmlrpc/tests/test_git.py' | |||
111 | --- lib/lp/code/xmlrpc/tests/test_git.py 2019-05-09 15:44:59 +0000 | |||
112 | +++ lib/lp/code/xmlrpc/tests/test_git.py 2019-05-10 16:51:16 +0000 | |||
113 | @@ -1081,6 +1081,46 @@ | |||
114 | 1081 | repository.registrant, path, permission="read", | 1081 | repository.registrant, path, permission="read", |
115 | 1082 | macaroon_raw=macaroons[0].serialize()) | 1082 | macaroon_raw=macaroons[0].serialize()) |
116 | 1083 | 1083 | ||
117 | 1084 | def test_translatePath_user_macaroon(self): | ||
118 | 1085 | # A user with a suitable macaroon can write to the corresponding | ||
119 | 1086 | # repository, but not others, even if they own them. | ||
120 | 1087 | self.pushConfig("codehosting", git_macaroon_secret_key="some-secret") | ||
121 | 1088 | requester = self.factory.makePerson() | ||
122 | 1089 | repositories = [ | ||
123 | 1090 | self.factory.makeGitRepository(owner=requester) for _ in range(2)] | ||
124 | 1091 | repositories.append(self.factory.makeGitRepository( | ||
125 | 1092 | owner=requester, information_type=InformationType.PRIVATESECURITY)) | ||
126 | 1093 | issuer = getUtility(IMacaroonIssuer, "git-repository") | ||
127 | 1094 | with person_logged_in(requester): | ||
128 | 1095 | macaroons = [ | ||
129 | 1096 | removeSecurityProxy(issuer).issueMacaroon( | ||
130 | 1097 | repository, user=requester) | ||
131 | 1098 | for repository in repositories] | ||
132 | 1099 | paths = [ | ||
133 | 1100 | u"/%s" % repository.unique_name for repository in repositories] | ||
134 | 1101 | for i, repository in enumerate(repositories): | ||
135 | 1102 | for j, macaroon in enumerate(macaroons): | ||
136 | 1103 | login(ANONYMOUS) | ||
137 | 1104 | if i == j: | ||
138 | 1105 | self.assertTranslates( | ||
139 | 1106 | requester, paths[i], repository, True, | ||
140 | 1107 | permission="write", macaroon_raw=macaroon.serialize(), | ||
141 | 1108 | private=(i == 2)) | ||
142 | 1109 | else: | ||
143 | 1110 | self.assertUnauthorized( | ||
144 | 1111 | requester, paths[i], permission="write", | ||
145 | 1112 | macaroon_raw=macaroon.serialize()) | ||
146 | 1113 | login(ANONYMOUS) | ||
147 | 1114 | self.assertUnauthorized( | ||
148 | 1115 | requester, paths[i], permission="write", | ||
149 | 1116 | macaroon_raw=Macaroon( | ||
150 | 1117 | location=config.vhost.mainsite.hostname, | ||
151 | 1118 | identifier="another", key="another-secret").serialize()) | ||
152 | 1119 | login(ANONYMOUS) | ||
153 | 1120 | self.assertUnauthorized( | ||
154 | 1121 | requester, paths[i], permission="write", | ||
155 | 1122 | macaroon_raw="nonsense") | ||
156 | 1123 | |||
157 | 1084 | def test_notify(self): | 1124 | def test_notify(self): |
158 | 1085 | # The notify call creates a GitRefScanJob. | 1125 | # The notify call creates a GitRefScanJob. |
159 | 1086 | repository = self.factory.makeGitRepository() | 1126 | repository = self.factory.makeGitRepository() |
160 | @@ -1157,6 +1197,35 @@ | |||
161 | 1157 | self.git_api.authenticateWithPassword("", "nonsense"), | 1197 | self.git_api.authenticateWithPassword("", "nonsense"), |
162 | 1158 | faults.Unauthorized) | 1198 | faults.Unauthorized) |
163 | 1159 | 1199 | ||
164 | 1200 | def test_authenticateWithPassword_user_macaroon(self): | ||
165 | 1201 | # A user with a suitable macaroon can authenticate using it, in | ||
166 | 1202 | # which case we return both the macaroon and the uid for use by | ||
167 | 1203 | # later calls. | ||
168 | 1204 | self.pushConfig("codehosting", git_macaroon_secret_key="some-secret") | ||
169 | 1205 | requester = self.factory.makePerson() | ||
170 | 1206 | issuer = getUtility(IMacaroonIssuer, "git-repository") | ||
171 | 1207 | macaroon = removeSecurityProxy(issuer).issueMacaroon( | ||
172 | 1208 | self.factory.makeGitRepository(owner=requester), user=requester) | ||
173 | 1209 | self.assertEqual( | ||
174 | 1210 | {"macaroon": macaroon.serialize(), "uid": requester.id}, | ||
175 | 1211 | self.git_api.authenticateWithPassword( | ||
176 | 1212 | requester.name, macaroon.serialize())) | ||
177 | 1213 | self.assertIsInstance( | ||
178 | 1214 | self.git_api.authenticateWithPassword("", macaroon.serialize()), | ||
179 | 1215 | faults.Unauthorized) | ||
180 | 1216 | self.assertIsInstance( | ||
181 | 1217 | self.git_api.authenticateWithPassword( | ||
182 | 1218 | "nonexistent", macaroon.serialize()), | ||
183 | 1219 | faults.Unauthorized) | ||
184 | 1220 | other_macaroon = Macaroon(identifier="another", key="another-secret") | ||
185 | 1221 | self.assertIsInstance( | ||
186 | 1222 | self.git_api.authenticateWithPassword( | ||
187 | 1223 | requester.name, other_macaroon.serialize()), | ||
188 | 1224 | faults.Unauthorized) | ||
189 | 1225 | self.assertIsInstance( | ||
190 | 1226 | self.git_api.authenticateWithPassword(requester.name, "nonsense"), | ||
191 | 1227 | faults.Unauthorized) | ||
192 | 1228 | |||
193 | 1160 | def test_checkRefPermissions_code_import(self): | 1229 | def test_checkRefPermissions_code_import(self): |
194 | 1161 | # A code import worker with a suitable macaroon has repository owner | 1230 | # A code import worker with a suitable macaroon has repository owner |
195 | 1162 | # privileges on a repository associated with a running code import | 1231 | # privileges on a repository associated with a running code import |
196 | @@ -1267,6 +1336,53 @@ | |||
197 | 1267 | LAUNCHPAD_SERVICES, ref.repository, [path], {path: []}, | 1336 | LAUNCHPAD_SERVICES, ref.repository, [path], {path: []}, |
198 | 1268 | macaroon_raw=macaroon.serialize()) | 1337 | macaroon_raw=macaroon.serialize()) |
199 | 1269 | 1338 | ||
200 | 1339 | def test_checkRefPermissions_user_macaroon(self): | ||
201 | 1340 | # A user with a suitable macaroon has their ordinary privileges on | ||
202 | 1341 | # the corresponding repository, but not others, even if they own | ||
203 | 1342 | # them. | ||
204 | 1343 | self.pushConfig("codehosting", git_macaroon_secret_key="some-secret") | ||
205 | 1344 | requester = self.factory.makePerson() | ||
206 | 1345 | repositories = [ | ||
207 | 1346 | self.factory.makeGitRepository(owner=requester) for _ in range(2)] | ||
208 | 1347 | repositories.append(self.factory.makeGitRepository( | ||
209 | 1348 | owner=requester, information_type=InformationType.PRIVATESECURITY)) | ||
210 | 1349 | ref_path = b"refs/heads/master" | ||
211 | 1350 | issuer = getUtility(IMacaroonIssuer, "git-repository") | ||
212 | 1351 | with person_logged_in(requester): | ||
213 | 1352 | macaroons = [ | ||
214 | 1353 | removeSecurityProxy(issuer).issueMacaroon( | ||
215 | 1354 | repository, user=requester) | ||
216 | 1355 | for repository in repositories] | ||
217 | 1356 | for i, repository in enumerate(repositories): | ||
218 | 1357 | for j, macaroon in enumerate(macaroons): | ||
219 | 1358 | login(ANONYMOUS) | ||
220 | 1359 | if i == j: | ||
221 | 1360 | expected_permissions = ["create", "push", "force_push"] | ||
222 | 1361 | else: | ||
223 | 1362 | expected_permissions = [] | ||
224 | 1363 | self.assertHasRefPermissions( | ||
225 | 1364 | requester, repository, [ref_path], | ||
226 | 1365 | {ref_path: expected_permissions}, | ||
227 | 1366 | macaroon_raw=macaroon.serialize()) | ||
228 | 1367 | login(ANONYMOUS) | ||
229 | 1368 | self.assertHasRefPermissions( | ||
230 | 1369 | None, repository, [ref_path], {ref_path: []}, | ||
231 | 1370 | macaroon_raw=macaroon.serialize()) | ||
232 | 1371 | login(ANONYMOUS) | ||
233 | 1372 | self.assertHasRefPermissions( | ||
234 | 1373 | self.factory.makePerson(), repository, [ref_path], | ||
235 | 1374 | {ref_path: []}, macaroon_raw=macaroon.serialize()) | ||
236 | 1375 | login(ANONYMOUS) | ||
237 | 1376 | self.assertHasRefPermissions( | ||
238 | 1377 | requester, repository, [ref_path], {ref_path: []}, | ||
239 | 1378 | macaroon_raw=Macaroon( | ||
240 | 1379 | location=config.vhost.mainsite.hostname, | ||
241 | 1380 | identifier="another", key="another-secret").serialize()) | ||
242 | 1381 | login(ANONYMOUS) | ||
243 | 1382 | self.assertHasRefPermissions( | ||
244 | 1383 | requester, repository, [ref_path], {ref_path: []}, | ||
245 | 1384 | macaroon_raw="nonsense") | ||
246 | 1385 | |||
247 | 1270 | 1386 | ||
248 | 1271 | class TestGitAPISecurity(TestGitAPIMixin, TestCaseWithFactory): | 1387 | class TestGitAPISecurity(TestGitAPIMixin, TestCaseWithFactory): |
249 | 1272 | """Slow tests for `IGitAPI`. | 1388 | """Slow tests for `IGitAPI`. |
This should all be much clearer now, so please take another look.