Merge lp:~cjwatson/launchpad/git-permissions-model into lp:launchpad
- git-permissions-model
- Merge into devel
Proposed by
Colin Watson
Status: | Merged | ||||
---|---|---|---|---|---|
Merged at revision: | 18790 | ||||
Proposed branch: | lp:~cjwatson/launchpad/git-permissions-model | ||||
Merge into: | lp:launchpad | ||||
Diff against target: |
1268 lines (+1055/-5) 13 files modified
lib/lp/code/configure.zcml (+29/-0) lib/lp/code/enums.py (+21/-0) lib/lp/code/interfaces/gitrepository.py (+24/-0) lib/lp/code/interfaces/gitrule.py (+178/-0) lib/lp/code/model/gitrepository.py (+70/-0) lib/lp/code/model/gitrule.py (+208/-0) lib/lp/code/model/tests/test_gitrepository.py (+124/-1) lib/lp/code/model/tests/test_gitrule.py (+221/-0) lib/lp/registry/personmerge.py (+39/-1) lib/lp/registry/tests/test_personmerge.py (+72/-2) lib/lp/security.py (+40/-0) lib/lp/testing/factory.py (+25/-0) scripts/close-account.py (+4/-1) |
||||
To merge this branch: | bzr merge lp:~cjwatson/launchpad/git-permissions-model | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
William Grant | code | Approve | |
Review via email: mp+354201@code.launchpad.net |
Commit message
Add basic GitRule and GitGrant models.
Description of the change
See https:/
To post a comment you must log in.
Revision history for this message
William Grant (wgrant) : | # |
review:
Approve
(code)
Revision history for this message
Colin Watson (cjwatson) : | # |
Revision history for this message
Colin Watson (cjwatson) : | # |
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'database/schema/security.cfg' |
2 | === modified file 'lib/lp/code/configure.zcml' |
3 | --- lib/lp/code/configure.zcml 2018-05-16 17:33:18 +0000 |
4 | +++ lib/lp/code/configure.zcml 2018-10-05 14:49:26 +0000 |
5 | @@ -919,6 +919,35 @@ |
6 | <allow interface="lp.code.interfaces.gitref.IGitRefRemoteSet" /> |
7 | </securedutility> |
8 | |
9 | + <!-- Git repository access rules --> |
10 | + |
11 | + <class class="lp.code.model.gitrule.GitRule"> |
12 | + <require |
13 | + permission="launchpad.View" |
14 | + interface="lp.code.interfaces.gitrule.IGitRuleView |
15 | + lp.code.interfaces.gitrule.IGitRuleEditableAttributes" /> |
16 | + <require |
17 | + permission="launchpad.Edit" |
18 | + interface="lp.code.interfaces.gitrule.IGitRuleEdit" |
19 | + set_schema="lp.code.interfaces.gitrule.IGitRuleEditableAttributes" /> |
20 | + </class> |
21 | + <subscriber |
22 | + for="lp.code.interfaces.gitrule.IGitRule zope.lifecycleevent.interfaces.IObjectModifiedEvent" |
23 | + handler="lp.code.model.gitrule.git_rule_modified"/> |
24 | + <class class="lp.code.model.gitrule.GitRuleGrant"> |
25 | + <require |
26 | + permission="launchpad.View" |
27 | + interface="lp.code.interfaces.gitrule.IGitRuleGrantView |
28 | + lp.code.interfaces.gitrule.IGitRuleGrantEditableAttributes" /> |
29 | + <require |
30 | + permission="launchpad.Edit" |
31 | + interface="lp.code.interfaces.gitrule.IGitRuleGrantEdit" |
32 | + set_schema="lp.code.interfaces.gitrule.IGitRuleGrantEditableAttributes" /> |
33 | + </class> |
34 | + <subscriber |
35 | + for="lp.code.interfaces.gitrule.IGitRuleGrant zope.lifecycleevent.interfaces.IObjectModifiedEvent" |
36 | + handler="lp.code.model.gitrule.git_rule_grant_modified"/> |
37 | + |
38 | <!-- GitCollection --> |
39 | |
40 | <class class="lp.code.model.gitcollection.GenericGitCollection"> |
41 | |
42 | === modified file 'lib/lp/code/enums.py' |
43 | --- lib/lp/code/enums.py 2017-06-15 01:02:11 +0000 |
44 | +++ lib/lp/code/enums.py 2018-10-05 14:49:26 +0000 |
45 | @@ -21,6 +21,7 @@ |
46 | 'CodeImportReviewStatus', |
47 | 'CodeReviewNotificationLevel', |
48 | 'CodeReviewVote', |
49 | + 'GitGranteeType', |
50 | 'GitObjectType', |
51 | 'GitRepositoryType', |
52 | 'NON_CVS_RCS_TYPES', |
53 | @@ -175,6 +176,26 @@ |
54 | """) |
55 | |
56 | |
57 | +class GitGranteeType(DBEnumeratedType): |
58 | + """Git Grantee Type |
59 | + |
60 | + Access grants for Git repositories can be made to various kinds of |
61 | + grantees. |
62 | + """ |
63 | + |
64 | + REPOSITORY_OWNER = DBItem(1, """ |
65 | + Repository owner |
66 | + |
67 | + A grant to the owner of the associated repository. |
68 | + """) |
69 | + |
70 | + PERSON = DBItem(2, """ |
71 | + Person |
72 | + |
73 | + A grant to a particular person or team. |
74 | + """) |
75 | + |
76 | + |
77 | class BranchLifecycleStatusFilter(EnumeratedType): |
78 | """Branch Lifecycle Status Filter |
79 | |
80 | |
81 | === modified file 'lib/lp/code/interfaces/gitrepository.py' |
82 | --- lib/lp/code/interfaces/gitrepository.py 2018-08-23 17:03:05 +0000 |
83 | +++ lib/lp/code/interfaces/gitrepository.py 2018-10-05 14:49:26 +0000 |
84 | @@ -266,6 +266,10 @@ |
85 | # Really ICodeImport, patched in _schema_circular_imports.py. |
86 | schema=Interface)) |
87 | |
88 | + rules = Attribute("The access rules for this repository.") |
89 | + |
90 | + grants = Attribute("The access grants for this repository.") |
91 | + |
92 | @operation_parameters( |
93 | path=TextLine(title=_("A string to look up as a path."))) |
94 | # Really IGitRef, patched in _schema_circular_imports.py. |
95 | @@ -715,6 +719,26 @@ |
96 | This may be helpful in cases where a previous scan crashed. |
97 | """ |
98 | |
99 | + def addRule(ref_pattern, creator, position=None): |
100 | + """Add an access rule to this repository. |
101 | + |
102 | + :param ref_pattern: The reference pattern that the new rule should |
103 | + match. |
104 | + :param creator: The `IPerson` who is adding the rule. |
105 | + :param position: The list position at which to insert the rule, or |
106 | + None to append it. |
107 | + """ |
108 | + |
109 | + def moveRule(rule, position): |
110 | + """Move a rule to a new position in its repository's rule order. |
111 | + |
112 | + :param rule: The `IGitRule` to move. |
113 | + :param position: The new position. For example, 0 puts the rule at |
114 | + the start, while `len(repository.rules)` puts the rule at the |
115 | + end. If the new position is before the end of the list, then |
116 | + other rules are shifted to later positions to make room. |
117 | + """ |
118 | + |
119 | @export_read_operation() |
120 | @operation_for_version("devel") |
121 | def canBeDeleted(): |
122 | |
123 | === added file 'lib/lp/code/interfaces/gitrule.py' |
124 | --- lib/lp/code/interfaces/gitrule.py 1970-01-01 00:00:00 +0000 |
125 | +++ lib/lp/code/interfaces/gitrule.py 2018-10-05 14:49:26 +0000 |
126 | @@ -0,0 +1,178 @@ |
127 | +# Copyright 2018 Canonical Ltd. This software is licensed under the |
128 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
129 | + |
130 | +"""Git repository access rules.""" |
131 | + |
132 | +from __future__ import absolute_import, print_function, unicode_literals |
133 | + |
134 | +__metaclass__ = type |
135 | +__all__ = [ |
136 | + 'IGitRule', |
137 | + 'IGitRuleGrant', |
138 | + ] |
139 | + |
140 | +from lazr.restful.fields import Reference |
141 | +from zope.interface import ( |
142 | + Attribute, |
143 | + Interface, |
144 | + ) |
145 | +from zope.schema import ( |
146 | + Bool, |
147 | + Choice, |
148 | + Datetime, |
149 | + Int, |
150 | + TextLine, |
151 | + ) |
152 | + |
153 | +from lp import _ |
154 | +from lp.code.enums import GitGranteeType |
155 | +from lp.code.interfaces.gitrepository import IGitRepository |
156 | +from lp.services.fields import ( |
157 | + PersonChoice, |
158 | + PublicPersonChoice, |
159 | + ) |
160 | + |
161 | + |
162 | +class IGitRuleView(Interface): |
163 | + """`IGitRule` attributes that require launchpad.View.""" |
164 | + |
165 | + id = Int(title=_("ID"), readonly=True, required=True) |
166 | + |
167 | + repository = Reference( |
168 | + title=_("Repository"), required=True, readonly=True, |
169 | + schema=IGitRepository, |
170 | + description=_("The repository that this rule is for.")) |
171 | + |
172 | + position = Int( |
173 | + title=_("Position"), required=True, readonly=True, |
174 | + description=_( |
175 | + "The position of this rule in its repository's rule order.")) |
176 | + |
177 | + creator = PublicPersonChoice( |
178 | + title=_("Creator"), required=True, readonly=True, |
179 | + vocabulary="ValidPerson", |
180 | + description=_("The user who created this rule.")) |
181 | + |
182 | + date_created = Datetime( |
183 | + title=_("Date created"), required=True, readonly=True, |
184 | + description=_("The time when this rule was created.")) |
185 | + |
186 | + date_last_modified = Datetime( |
187 | + title=_("Date last modified"), required=True, readonly=True, |
188 | + description=_("The time when this rule was last modified.")) |
189 | + |
190 | + is_exact = Bool( |
191 | + title=_("Is this an exact-match rule?"), required=True, readonly=True, |
192 | + description=_( |
193 | + "True if this rule is for an exact reference name, or False if " |
194 | + "it is for a wildcard.")) |
195 | + |
196 | + grants = Attribute("The access grants for this rule.") |
197 | + |
198 | + |
199 | +class IGitRuleEditableAttributes(Interface): |
200 | + """`IGitRule` attributes that can be edited. |
201 | + |
202 | + These attributes need launchpad.View to see, and launchpad.Edit to change. |
203 | + """ |
204 | + |
205 | + ref_pattern = TextLine( |
206 | + title=_("Pattern"), required=True, readonly=False, |
207 | + description=_("The pattern of references matched by this rule.")) |
208 | + |
209 | + |
210 | +class IGitRuleEdit(Interface): |
211 | + """`IGitRule` attributes that require launchpad.Edit.""" |
212 | + |
213 | + def addGrant(grantee, grantor, can_create=False, can_push=False, |
214 | + can_force_push=False): |
215 | + """Add an access grant to this rule. |
216 | + |
217 | + :param grantee: The `IPerson` who is being granted permission, or an |
218 | + item of `GitGranteeType` other than `GitGranteeType.PERSON` to |
219 | + grant permission to some other kind of entity. |
220 | + :param grantor: The `IPerson` who is granting permission. |
221 | + :param can_create: Whether the grantee can create references |
222 | + matching this rule. |
223 | + :param can_push: Whether the grantee can push references matching |
224 | + this rule. |
225 | + :param can_force_push: Whether the grantee can force-push references |
226 | + matching this rule. |
227 | + """ |
228 | + |
229 | + def destroySelf(): |
230 | + """Delete this rule.""" |
231 | + |
232 | + |
233 | +class IGitRule(IGitRuleView, IGitRuleEditableAttributes, IGitRuleEdit): |
234 | + """An access rule for a Git repository.""" |
235 | + |
236 | + |
237 | +class IGitRuleGrantView(Interface): |
238 | + """`IGitRuleGrant` attributes that require launchpad.View.""" |
239 | + |
240 | + id = Int(title=_("ID"), readonly=True, required=True) |
241 | + |
242 | + repository = Reference( |
243 | + title=_("Repository"), required=True, readonly=True, |
244 | + schema=IGitRepository, |
245 | + description=_("The repository that this grant is for.")) |
246 | + |
247 | + rule = Reference( |
248 | + title=_("Rule"), required=True, readonly=True, |
249 | + schema=IGitRule, |
250 | + description=_("The rule that this grant is for.")) |
251 | + |
252 | + grantor = PublicPersonChoice( |
253 | + title=_("Grantor"), required=True, readonly=True, |
254 | + vocabulary="ValidPerson", |
255 | + description=_("The user who created this grant.")) |
256 | + |
257 | + grantee_type = Choice( |
258 | + title=_("Grantee type"), required=True, readonly=True, |
259 | + vocabulary=GitGranteeType, |
260 | + description=_("The type of grantee for this grant.")) |
261 | + |
262 | + grantee = PersonChoice( |
263 | + title=_("Grantee"), required=False, readonly=True, |
264 | + vocabulary="ValidPersonOrTeam", |
265 | + description=_("The person being granted access.")) |
266 | + |
267 | + date_created = Datetime( |
268 | + title=_("Date created"), required=True, readonly=True, |
269 | + description=_("The time when this grant was created.")) |
270 | + |
271 | + date_last_modified = Datetime( |
272 | + title=_("Date last modified"), required=True, readonly=True, |
273 | + description=_("The time when this grant was last modified.")) |
274 | + |
275 | + |
276 | +class IGitRuleGrantEditableAttributes(Interface): |
277 | + """`IGitRuleGrant` attributes that can be edited. |
278 | + |
279 | + These attributes need launchpad.View to see, and launchpad.Edit to change. |
280 | + """ |
281 | + |
282 | + can_create = Bool( |
283 | + title=_("Can create"), required=True, readonly=False, |
284 | + description=_("Whether creating references is allowed.")) |
285 | + |
286 | + can_push = Bool( |
287 | + title=_("Can push"), required=True, readonly=False, |
288 | + description=_("Whether pushing references is allowed.")) |
289 | + |
290 | + can_force_push = Bool( |
291 | + title=_("Can force-push"), required=True, readonly=False, |
292 | + description=_("Whether force-pushing references is allowed.")) |
293 | + |
294 | + |
295 | +class IGitRuleGrantEdit(Interface): |
296 | + """`IGitRuleGrant` attributes that require launchpad.Edit.""" |
297 | + |
298 | + def destroySelf(): |
299 | + """Delete this access grant.""" |
300 | + |
301 | + |
302 | +class IGitRuleGrant(IGitRuleGrantView, IGitRuleGrantEditableAttributes, |
303 | + IGitRuleGrantEdit): |
304 | + """An access grant for a Git repository rule.""" |
305 | |
306 | === modified file 'lib/lp/code/model/gitrepository.py' |
307 | --- lib/lp/code/model/gitrepository.py 2018-09-06 14:25:46 +0000 |
308 | +++ lib/lp/code/model/gitrepository.py 2018-10-05 14:49:26 +0000 |
309 | @@ -112,6 +112,10 @@ |
310 | GitRef, |
311 | GitRefDefault, |
312 | ) |
313 | +from lp.code.model.gitrule import ( |
314 | + GitRule, |
315 | + GitRuleGrant, |
316 | + ) |
317 | from lp.code.model.gitsubscription import GitSubscription |
318 | from lp.registry.enums import PersonVisibility |
319 | from lp.registry.errors import CannotChangeInformationType |
320 | @@ -1121,6 +1125,67 @@ |
321 | def code_import(self): |
322 | return getUtility(ICodeImportSet).getByGitRepository(self) |
323 | |
324 | + @property |
325 | + def rules(self): |
326 | + """See `IGitRepository`.""" |
327 | + return Store.of(self).find( |
328 | + GitRule, GitRule.repository == self).order_by(GitRule.position) |
329 | + |
330 | + def _syncRulePositions(self, rules): |
331 | + """Synchronise rule positions with their order in a provided list. |
332 | + |
333 | + :param rules: A sequence of `IGitRule`s in the desired order. |
334 | + """ |
335 | + # Canonicalise rule ordering: exact-match rules come first in |
336 | + # lexicographical order, followed by wildcard rules in the requested |
337 | + # order. (Note that `sorted` is guaranteed to be stable.) |
338 | + rules = sorted( |
339 | + rules, |
340 | + key=lambda rule: (0, rule.ref_pattern) if rule.is_exact else (1,)) |
341 | + # Ensure the correct position of all rules, which may involve more |
342 | + # work than necessary, but is simple and tends to be |
343 | + # self-correcting. This works because the unique constraint on |
344 | + # GitRule(repository, position) is deferred. |
345 | + for position, rule in enumerate(rules): |
346 | + if rule.repository != self: |
347 | + raise AssertionError("%r does not belong to %r" % (rule, self)) |
348 | + if rule.position != position: |
349 | + removeSecurityProxy(rule).position = position |
350 | + |
351 | + def addRule(self, ref_pattern, creator, position=None): |
352 | + """See `IGitRepository`.""" |
353 | + rules = list(self.rules) |
354 | + rule = GitRule( |
355 | + repository=self, |
356 | + # -1 isn't a valid position, but _syncRulePositions will correct |
357 | + # it in a moment. |
358 | + position=position if position is not None else -1, |
359 | + ref_pattern=ref_pattern, creator=creator, date_created=DEFAULT) |
360 | + if position is None: |
361 | + rules.append(rule) |
362 | + else: |
363 | + rules.insert(position, rule) |
364 | + self._syncRulePositions(rules) |
365 | + return rule |
366 | + |
367 | + def moveRule(self, rule, position): |
368 | + """See `IGitRepository`.""" |
369 | + if rule.repository != self: |
370 | + raise ValueError("%r does not belong to %r" % (rule, self)) |
371 | + if position < 0: |
372 | + raise ValueError("Negative positions are not supported") |
373 | + if position != rule.position: |
374 | + rules = list(self.rules) |
375 | + rules.remove(rule) |
376 | + rules.insert(position, rule) |
377 | + self._syncRulePositions(rules) |
378 | + |
379 | + @property |
380 | + def grants(self): |
381 | + """See `IGitRepository`.""" |
382 | + return Store.of(self).find( |
383 | + GitRuleGrant, GitRuleGrant.repository_id == self.id) |
384 | + |
385 | def canBeDeleted(self): |
386 | """See `IGitRepository`.""" |
387 | # Can't delete if the repository is associated with anything. |
388 | @@ -1252,6 +1317,11 @@ |
389 | self._deleteRepositorySubscriptions() |
390 | self._deleteJobs() |
391 | getUtility(IWebhookSet).delete(self.webhooks) |
392 | + # We intentionally skip the usual destructors; the only other useful |
393 | + # thing they do is to log the removal activity, and we remove the |
394 | + # activity logs for removed repositories anyway. |
395 | + self.grants.remove() |
396 | + self.rules.remove() |
397 | |
398 | # Now destroy the repository. |
399 | repository_name = self.unique_name |
400 | |
401 | === added file 'lib/lp/code/model/gitrule.py' |
402 | --- lib/lp/code/model/gitrule.py 1970-01-01 00:00:00 +0000 |
403 | +++ lib/lp/code/model/gitrule.py 2018-10-05 14:49:26 +0000 |
404 | @@ -0,0 +1,208 @@ |
405 | +# Copyright 2018 Canonical Ltd. This software is licensed under the |
406 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
407 | + |
408 | +"""Git repository access rules.""" |
409 | + |
410 | +from __future__ import absolute_import, print_function, unicode_literals |
411 | + |
412 | +__metaclass__ = type |
413 | +__all__ = [ |
414 | + 'GitRule', |
415 | + 'GitRuleGrant', |
416 | + ] |
417 | + |
418 | +from lazr.enum import DBItem |
419 | +import pytz |
420 | +from storm.locals import ( |
421 | + Bool, |
422 | + DateTime, |
423 | + Int, |
424 | + Reference, |
425 | + Store, |
426 | + Unicode, |
427 | + ) |
428 | +from zope.interface import implementer |
429 | +from zope.security.proxy import removeSecurityProxy |
430 | + |
431 | +from lp.code.enums import GitGranteeType |
432 | +from lp.code.interfaces.gitrule import ( |
433 | + IGitRule, |
434 | + IGitRuleGrant, |
435 | + ) |
436 | +from lp.registry.interfaces.person import ( |
437 | + validate_person, |
438 | + validate_public_person, |
439 | + ) |
440 | +from lp.services.database.constants import ( |
441 | + DEFAULT, |
442 | + UTC_NOW, |
443 | + ) |
444 | +from lp.services.database.enumcol import DBEnum |
445 | +from lp.services.database.stormbase import StormBase |
446 | + |
447 | + |
448 | +def git_rule_modified(rule, event): |
449 | + """Update date_last_modified when a GitRule is modified. |
450 | + |
451 | + This method is registered as a subscriber to `IObjectModifiedEvent` |
452 | + events on Git repository rules. |
453 | + """ |
454 | + if event.edited_fields: |
455 | + removeSecurityProxy(rule).date_last_modified = UTC_NOW |
456 | + |
457 | + |
458 | +@implementer(IGitRule) |
459 | +class GitRule(StormBase): |
460 | + """See `IGitRule`.""" |
461 | + |
462 | + __storm_table__ = 'GitRule' |
463 | + |
464 | + id = Int(primary=True) |
465 | + |
466 | + repository_id = Int(name='repository', allow_none=False) |
467 | + repository = Reference(repository_id, 'GitRepository.id') |
468 | + |
469 | + position = Int(name='position', allow_none=False) |
470 | + |
471 | + ref_pattern = Unicode(name='ref_pattern', allow_none=False) |
472 | + |
473 | + creator_id = Int( |
474 | + name='creator', allow_none=False, validator=validate_public_person) |
475 | + creator = Reference(creator_id, 'Person.id') |
476 | + |
477 | + date_created = DateTime( |
478 | + name='date_created', tzinfo=pytz.UTC, allow_none=False) |
479 | + date_last_modified = DateTime( |
480 | + name='date_last_modified', tzinfo=pytz.UTC, allow_none=False) |
481 | + |
482 | + def __init__(self, repository, position, ref_pattern, creator, |
483 | + date_created): |
484 | + super(GitRule, self).__init__() |
485 | + self.repository = repository |
486 | + self.position = position |
487 | + self.ref_pattern = ref_pattern |
488 | + self.creator = creator |
489 | + self.date_created = date_created |
490 | + self.date_last_modified = date_created |
491 | + |
492 | + def __repr__(self): |
493 | + return "<GitRule '%s' for %s>" % ( |
494 | + self.ref_pattern, self.repository.unique_name) |
495 | + |
496 | + @property |
497 | + def is_exact(self): |
498 | + """See `IGitRule`.""" |
499 | + # turnip's glob_to_re only treats * as special, so any rule whose |
500 | + # pattern does not contain * must be an exact-match rule. |
501 | + return "*" not in self.ref_pattern |
502 | + |
503 | + @property |
504 | + def grants(self): |
505 | + """See `IGitRule`.""" |
506 | + return Store.of(self).find( |
507 | + GitRuleGrant, GitRuleGrant.rule_id == self.id) |
508 | + |
509 | + def addGrant(self, grantee, grantor, can_create=False, can_push=False, |
510 | + can_force_push=False): |
511 | + """See `IGitRule`.""" |
512 | + return GitRuleGrant( |
513 | + rule=self, grantee=grantee, can_create=can_create, |
514 | + can_push=can_push, can_force_push=can_force_push, grantor=grantor, |
515 | + date_created=DEFAULT) |
516 | + |
517 | + def destroySelf(self): |
518 | + """See `IGitRule`.""" |
519 | + for grant in self.grants: |
520 | + grant.destroySelf() |
521 | + rules = list(self.repository.rules) |
522 | + Store.of(self).remove(self) |
523 | + rules.remove(self) |
524 | + removeSecurityProxy(self.repository)._syncRulePositions(rules) |
525 | + |
526 | + |
527 | +def git_rule_grant_modified(grant, event): |
528 | + """Update date_last_modified when a GitRuleGrant is modified. |
529 | + |
530 | + This method is registered as a subscriber to `IObjectModifiedEvent` |
531 | + events on Git repository grants. |
532 | + """ |
533 | + if event.edited_fields: |
534 | + removeSecurityProxy(grant).date_last_modified = UTC_NOW |
535 | + |
536 | + |
537 | +@implementer(IGitRuleGrant) |
538 | +class GitRuleGrant(StormBase): |
539 | + """See `IGitRuleGrant`.""" |
540 | + |
541 | + __storm_table__ = 'GitRuleGrant' |
542 | + |
543 | + id = Int(primary=True) |
544 | + |
545 | + repository_id = Int(name='repository', allow_none=False) |
546 | + repository = Reference(repository_id, 'GitRepository.id') |
547 | + |
548 | + rule_id = Int(name='rule', allow_none=False) |
549 | + rule = Reference(rule_id, 'GitRule.id') |
550 | + |
551 | + grantee_type = DBEnum( |
552 | + name='grantee_type', enum=GitGranteeType, allow_none=False) |
553 | + |
554 | + grantee_id = Int( |
555 | + name='grantee', allow_none=True, validator=validate_person) |
556 | + grantee = Reference(grantee_id, 'Person.id') |
557 | + |
558 | + can_create = Bool(name='can_create', allow_none=False) |
559 | + can_push = Bool(name='can_push', allow_none=False) |
560 | + can_force_push = Bool(name='can_force_push', allow_none=False) |
561 | + |
562 | + grantor_id = Int( |
563 | + name='grantor', allow_none=False, validator=validate_public_person) |
564 | + grantor = Reference(grantor_id, 'Person.id') |
565 | + |
566 | + date_created = DateTime( |
567 | + name='date_created', tzinfo=pytz.UTC, allow_none=False) |
568 | + date_last_modified = DateTime( |
569 | + name='date_last_modified', tzinfo=pytz.UTC, allow_none=False) |
570 | + |
571 | + def __init__(self, rule, grantee, can_create, can_push, can_force_push, |
572 | + grantor, date_created): |
573 | + if isinstance(grantee, DBItem) and grantee.enum == GitGranteeType: |
574 | + if grantee == GitGranteeType.PERSON: |
575 | + raise ValueError( |
576 | + "grantee may not be GitGranteeType.PERSON; pass a person " |
577 | + "object instead") |
578 | + grantee_type = grantee |
579 | + grantee = None |
580 | + else: |
581 | + grantee_type = GitGranteeType.PERSON |
582 | + |
583 | + self.repository = rule.repository |
584 | + self.rule = rule |
585 | + self.grantee_type = grantee_type |
586 | + self.grantee = grantee |
587 | + self.can_create = can_create |
588 | + self.can_push = can_push |
589 | + self.can_force_push = can_force_push |
590 | + self.grantor = grantor |
591 | + self.date_created = date_created |
592 | + self.date_last_modified = date_created |
593 | + |
594 | + def __repr__(self): |
595 | + permissions = [] |
596 | + if self.can_create: |
597 | + permissions.append("create") |
598 | + if self.can_push: |
599 | + permissions.append("push") |
600 | + if self.can_force_push: |
601 | + permissions.append("force-push") |
602 | + if self.grantee_type == GitGranteeType.PERSON: |
603 | + grantee_name = "~%s" % self.grantee.name |
604 | + else: |
605 | + grantee_name = self.grantee_type.title.lower() |
606 | + return "<GitRuleGrant [%s] to %s for %s:%s>" % ( |
607 | + ", ".join(permissions), grantee_name, self.repository.unique_name, |
608 | + self.rule.ref_pattern) |
609 | + |
610 | + def destroySelf(self): |
611 | + """See `IGitRuleGrant`.""" |
612 | + Store.of(self).remove(self) |
613 | |
614 | === modified file 'lib/lp/code/model/tests/test_gitrepository.py' |
615 | --- lib/lp/code/model/tests/test_gitrepository.py 2018-08-31 14:25:40 +0000 |
616 | +++ lib/lp/code/model/tests/test_gitrepository.py 2018-10-05 14:49:26 +0000 |
617 | @@ -1,4 +1,4 @@ |
618 | -# Copyright 2015-2017 Canonical Ltd. This software is licensed under the |
619 | +# Copyright 2015-2018 Canonical Ltd. This software is licensed under the |
620 | # GNU Affero General Public License version 3 (see the file LICENSE). |
621 | |
622 | """Tests for Git repositories.""" |
623 | @@ -23,6 +23,7 @@ |
624 | from testtools.matchers import ( |
625 | EndsWith, |
626 | LessThan, |
627 | + MatchesListwise, |
628 | MatchesSetwise, |
629 | MatchesStructure, |
630 | ) |
631 | @@ -537,6 +538,14 @@ |
632 | transaction.commit() |
633 | self.assertRaises(LostObjectError, getattr, webhook, 'target') |
634 | |
635 | + def test_related_rules_and_grants_deleted(self): |
636 | + rule = self.factory.makeGitRule(repository=self.repository) |
637 | + grant = self.factory.makeGitRuleGrant(rule=rule) |
638 | + self.repository.destroySelf() |
639 | + transaction.commit() |
640 | + self.assertRaises(LostObjectError, getattr, grant, 'rule') |
641 | + self.assertRaises(LostObjectError, getattr, rule, 'repository') |
642 | + |
643 | |
644 | class TestGitRepositoryDeletionConsequences(TestCaseWithFactory): |
645 | """Test determination and application of repository deletion |
646 | @@ -2179,6 +2188,120 @@ |
647 | self.assertEqual('Some text', ret) |
648 | |
649 | |
650 | +class TestGitRepositoryRules(TestCaseWithFactory): |
651 | + |
652 | + layer = DatabaseFunctionalLayer |
653 | + |
654 | + def test_rules(self): |
655 | + repository = self.factory.makeGitRepository() |
656 | + other_repository = self.factory.makeGitRepository() |
657 | + self.factory.makeGitRule( |
658 | + repository=repository, ref_pattern="refs/heads/*") |
659 | + self.factory.makeGitRule( |
660 | + repository=repository, ref_pattern="refs/heads/stable/*") |
661 | + self.factory.makeGitRule( |
662 | + repository=other_repository, ref_pattern="refs/heads/*") |
663 | + self.assertThat(list(repository.rules), MatchesListwise([ |
664 | + MatchesStructure.byEquality( |
665 | + repository=repository, |
666 | + ref_pattern="refs/heads/*"), |
667 | + MatchesStructure.byEquality( |
668 | + repository=repository, |
669 | + ref_pattern="refs/heads/stable/*"), |
670 | + ])) |
671 | + |
672 | + def test_addRule_append(self): |
673 | + repository = self.factory.makeGitRepository() |
674 | + initial_rule = self.factory.makeGitRule( |
675 | + repository=repository, ref_pattern="refs/heads/*") |
676 | + self.assertEqual(0, initial_rule.position) |
677 | + with person_logged_in(repository.owner): |
678 | + new_rule = repository.addRule( |
679 | + "refs/heads/stable/*", repository.owner) |
680 | + self.assertEqual(1, new_rule.position) |
681 | + self.assertEqual([initial_rule, new_rule], list(repository.rules)) |
682 | + |
683 | + def test_addRule_insert(self): |
684 | + repository = self.factory.makeGitRepository() |
685 | + initial_rules = [ |
686 | + self.factory.makeGitRule( |
687 | + repository=repository, ref_pattern="refs/heads/*"), |
688 | + self.factory.makeGitRule( |
689 | + repository=repository, ref_pattern="refs/heads/protected/*"), |
690 | + self.factory.makeGitRule( |
691 | + repository=repository, ref_pattern="refs/heads/another/*"), |
692 | + ] |
693 | + self.assertEqual([0, 1, 2], [rule.position for rule in initial_rules]) |
694 | + with person_logged_in(repository.owner): |
695 | + new_rule = repository.addRule( |
696 | + "refs/heads/stable/*", repository.owner, position=1) |
697 | + self.assertEqual(1, new_rule.position) |
698 | + self.assertEqual([0, 2, 3], [rule.position for rule in initial_rules]) |
699 | + self.assertEqual( |
700 | + [initial_rules[0], new_rule, initial_rules[1], initial_rules[2]], |
701 | + list(repository.rules)) |
702 | + |
703 | + def test_addRule_exact_first(self): |
704 | + repository = self.factory.makeGitRepository() |
705 | + initial_rules = [ |
706 | + self.factory.makeGitRule( |
707 | + repository=repository, ref_pattern="refs/heads/exact"), |
708 | + self.factory.makeGitRule( |
709 | + repository=repository, ref_pattern="refs/heads/*"), |
710 | + ] |
711 | + self.assertEqual([0, 1], [rule.position for rule in initial_rules]) |
712 | + with person_logged_in(repository.owner): |
713 | + exact_rule = repository.addRule( |
714 | + "refs/heads/exact-2", repository.owner) |
715 | + self.assertEqual( |
716 | + [initial_rules[0], exact_rule, initial_rules[1]], |
717 | + list(repository.rules)) |
718 | + with person_logged_in(repository.owner): |
719 | + wildcard_rule = repository.addRule( |
720 | + "refs/heads/wildcard/*", repository.owner, position=0) |
721 | + self.assertEqual( |
722 | + [initial_rules[0], exact_rule, wildcard_rule, initial_rules[1]], |
723 | + list(repository.rules)) |
724 | + |
725 | + def test_moveRule(self): |
726 | + repository = self.factory.makeGitRepository() |
727 | + rules = [ |
728 | + self.factory.makeGitRule( |
729 | + repository=repository, |
730 | + ref_pattern=self.factory.getUniqueUnicode( |
731 | + prefix="refs/heads/*/")) |
732 | + for _ in range(5)] |
733 | + with person_logged_in(repository.owner): |
734 | + self.assertEqual(rules, list(repository.rules)) |
735 | + repository.moveRule(rules[0], 4) |
736 | + self.assertEqual(rules[1:] + [rules[0]], list(repository.rules)) |
737 | + repository.moveRule(rules[0], 0) |
738 | + self.assertEqual(rules, list(repository.rules)) |
739 | + repository.moveRule(rules[2], 1) |
740 | + self.assertEqual( |
741 | + [rules[0], rules[2], rules[1], rules[3], rules[4]], |
742 | + list(repository.rules)) |
743 | + |
744 | + def test_moveRule_non_negative(self): |
745 | + rule = self.factory.makeGitRule() |
746 | + with person_logged_in(rule.repository.owner): |
747 | + self.assertRaises(ValueError, rule.repository.moveRule, rule, -1) |
748 | + |
749 | + def test_grants(self): |
750 | + repository = self.factory.makeGitRepository() |
751 | + other_repository = self.factory.makeGitRepository() |
752 | + rule = self.factory.makeGitRule(repository=repository) |
753 | + other_rule = self.factory.makeGitRule(repository=other_repository) |
754 | + grants = [ |
755 | + self.factory.makeGitRuleGrant( |
756 | + rule=rule, grantee=self.factory.makePerson()) |
757 | + for _ in range(2) |
758 | + ] |
759 | + self.factory.makeGitRuleGrant( |
760 | + rule=other_rule, grantee=self.factory.makePerson()) |
761 | + self.assertContentEqual(grants, repository.grants) |
762 | + |
763 | + |
764 | class TestGitRepositorySet(TestCaseWithFactory): |
765 | |
766 | layer = DatabaseFunctionalLayer |
767 | |
768 | === added file 'lib/lp/code/model/tests/test_gitrule.py' |
769 | --- lib/lp/code/model/tests/test_gitrule.py 1970-01-01 00:00:00 +0000 |
770 | +++ lib/lp/code/model/tests/test_gitrule.py 2018-10-05 14:49:26 +0000 |
771 | @@ -0,0 +1,221 @@ |
772 | +# Copyright 2018 Canonical Ltd. This software is licensed under the |
773 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
774 | + |
775 | +"""Tests for Git repository access rules.""" |
776 | + |
777 | +from __future__ import absolute_import, print_function, unicode_literals |
778 | + |
779 | +__metaclass__ = type |
780 | + |
781 | +from storm.store import Store |
782 | +from testtools.matchers import ( |
783 | + Equals, |
784 | + Is, |
785 | + MatchesSetwise, |
786 | + MatchesStructure, |
787 | + ) |
788 | + |
789 | +from lp.code.enums import GitGranteeType |
790 | +from lp.code.interfaces.gitrule import ( |
791 | + IGitRule, |
792 | + IGitRuleGrant, |
793 | + ) |
794 | +from lp.services.database.sqlbase import get_transaction_timestamp |
795 | +from lp.testing import ( |
796 | + person_logged_in, |
797 | + TestCaseWithFactory, |
798 | + verifyObject, |
799 | + ) |
800 | +from lp.testing.layers import DatabaseFunctionalLayer |
801 | + |
802 | + |
803 | +class TestGitRule(TestCaseWithFactory): |
804 | + |
805 | + layer = DatabaseFunctionalLayer |
806 | + |
807 | + def test_implements_IGitRule(self): |
808 | + rule = self.factory.makeGitRule() |
809 | + verifyObject(IGitRule, rule) |
810 | + |
811 | + def test_properties(self): |
812 | + owner = self.factory.makeTeam() |
813 | + member = self.factory.makePerson(member_of=[owner]) |
814 | + repository = self.factory.makeGitRepository(owner=owner) |
815 | + rule = self.factory.makeGitRule( |
816 | + repository=repository, ref_pattern="refs/heads/stable/*", |
817 | + creator=member) |
818 | + now = get_transaction_timestamp(Store.of(rule)) |
819 | + self.assertThat(rule, MatchesStructure.byEquality( |
820 | + repository=repository, |
821 | + ref_pattern="refs/heads/stable/*", |
822 | + creator=member, |
823 | + date_created=now, |
824 | + date_last_modified=now)) |
825 | + |
826 | + def test_repr(self): |
827 | + repository = self.factory.makeGitRepository() |
828 | + rule = self.factory.makeGitRule(repository=repository) |
829 | + self.assertEqual( |
830 | + "<GitRule 'refs/heads/*' for %s>" % repository.unique_name, |
831 | + repr(rule)) |
832 | + |
833 | + def test_is_exact(self): |
834 | + repository = self.factory.makeGitRepository() |
835 | + for ref_pattern, is_exact in ( |
836 | + ("refs/heads/master", True), |
837 | + ("refs/heads/*", False), |
838 | + # XXX cjwatson 2018-09-25: We may want to support ? and |
839 | + # [...] in the future, since they're potentially useful and |
840 | + # don't collide with valid ref names. |
841 | + ("refs/heads/?", True), |
842 | + ("refs/heads/[abc]", True), |
843 | + (r"refs/heads/.\$", True), |
844 | + ): |
845 | + self.assertEqual( |
846 | + is_exact, |
847 | + self.factory.makeGitRule( |
848 | + repository=repository, |
849 | + ref_pattern=ref_pattern).is_exact) |
850 | + |
851 | + def test_grants(self): |
852 | + rule = self.factory.makeGitRule() |
853 | + other_rule = self.factory.makeGitRule( |
854 | + repository=rule.repository, ref_pattern="refs/heads/stable/*") |
855 | + grantees = [self.factory.makePerson() for _ in range(2)] |
856 | + self.factory.makeGitRuleGrant( |
857 | + rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER, |
858 | + can_create=True) |
859 | + self.factory.makeGitRuleGrant( |
860 | + rule=rule, grantee=grantees[0], can_push=True) |
861 | + self.factory.makeGitRuleGrant( |
862 | + rule=rule, grantee=grantees[1], can_force_push=True) |
863 | + self.factory.makeGitRuleGrant( |
864 | + rule=other_rule, grantee=grantees[0], can_push=True) |
865 | + self.assertThat(rule.grants, MatchesSetwise( |
866 | + MatchesStructure( |
867 | + rule=Equals(rule), |
868 | + grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER), |
869 | + grantee=Is(None), |
870 | + can_create=Is(True), |
871 | + can_push=Is(False), |
872 | + can_force_push=Is(False)), |
873 | + MatchesStructure( |
874 | + rule=Equals(rule), |
875 | + grantee_type=Equals(GitGranteeType.PERSON), |
876 | + grantee=Equals(grantees[0]), |
877 | + can_create=Is(False), |
878 | + can_push=Is(True), |
879 | + can_force_push=Is(False)), |
880 | + MatchesStructure( |
881 | + rule=Equals(rule), |
882 | + grantee_type=Equals(GitGranteeType.PERSON), |
883 | + grantee=Equals(grantees[1]), |
884 | + can_create=Is(False), |
885 | + can_push=Is(False), |
886 | + can_force_push=Is(True)))) |
887 | + |
888 | + def test_destroySelf(self): |
889 | + repository = self.factory.makeGitRepository() |
890 | + rules = [ |
891 | + self.factory.makeGitRule( |
892 | + repository=repository, |
893 | + ref_pattern=self.factory.getUniqueUnicode( |
894 | + prefix="refs/heads/")) |
895 | + for _ in range(4)] |
896 | + self.assertEqual([0, 1, 2, 3], [rule.position for rule in rules]) |
897 | + self.assertEqual(rules, list(repository.rules)) |
898 | + with person_logged_in(repository.owner): |
899 | + rules[1].destroySelf() |
900 | + del rules[1] |
901 | + self.assertEqual([0, 1, 2], [rule.position for rule in rules]) |
902 | + self.assertEqual(rules, list(repository.rules)) |
903 | + |
904 | + def test_destroySelf_removes_grants(self): |
905 | + repository = self.factory.makeGitRepository() |
906 | + rule = self.factory.makeGitRule(repository=repository) |
907 | + grant = self.factory.makeGitRuleGrant(rule=rule) |
908 | + self.assertEqual([grant], list(repository.grants)) |
909 | + with person_logged_in(repository.owner): |
910 | + rule.destroySelf() |
911 | + self.assertEqual([], list(repository.grants)) |
912 | + |
913 | + |
914 | +class TestGitRuleGrant(TestCaseWithFactory): |
915 | + |
916 | + layer = DatabaseFunctionalLayer |
917 | + |
918 | + def test_implements_IGitRuleGrant(self): |
919 | + grant = self.factory.makeGitRuleGrant() |
920 | + verifyObject(IGitRuleGrant, grant) |
921 | + |
922 | + def test_properties_owner(self): |
923 | + owner = self.factory.makeTeam() |
924 | + member = self.factory.makePerson(member_of=[owner]) |
925 | + rule = self.factory.makeGitRule(owner=owner) |
926 | + grant = self.factory.makeGitRuleGrant( |
927 | + rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER, grantor=member, |
928 | + can_create=True, can_force_push=True) |
929 | + now = get_transaction_timestamp(Store.of(grant)) |
930 | + self.assertThat(grant, MatchesStructure( |
931 | + repository=Equals(rule.repository), |
932 | + rule=Equals(rule), |
933 | + grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER), |
934 | + grantee=Is(None), |
935 | + can_create=Is(True), |
936 | + can_push=Is(False), |
937 | + can_force_push=Is(True), |
938 | + grantor=Equals(member), |
939 | + date_created=Equals(now), |
940 | + date_last_modified=Equals(now))) |
941 | + |
942 | + def test_properties_person(self): |
943 | + owner = self.factory.makeTeam() |
944 | + member = self.factory.makePerson(member_of=[owner]) |
945 | + rule = self.factory.makeGitRule(owner=owner) |
946 | + grantee = self.factory.makePerson() |
947 | + grant = self.factory.makeGitRuleGrant( |
948 | + rule=rule, grantee=grantee, grantor=member, can_push=True) |
949 | + now = get_transaction_timestamp(Store.of(rule)) |
950 | + self.assertThat(grant, MatchesStructure( |
951 | + repository=Equals(rule.repository), |
952 | + rule=Equals(rule), |
953 | + grantee_type=Equals(GitGranteeType.PERSON), |
954 | + grantee=Equals(grantee), |
955 | + can_create=Is(False), |
956 | + can_push=Is(True), |
957 | + can_force_push=Is(False), |
958 | + grantor=Equals(member), |
959 | + date_created=Equals(now), |
960 | + date_last_modified=Equals(now))) |
961 | + |
962 | + def test_repr_owner(self): |
963 | + rule = self.factory.makeGitRule() |
964 | + grant = self.factory.makeGitRuleGrant( |
965 | + rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER, |
966 | + can_create=True, can_push=True) |
967 | + self.assertEqual( |
968 | + "<GitRuleGrant [create, push] to repository owner for %s:%s>" % ( |
969 | + rule.repository.unique_name, rule.ref_pattern), |
970 | + repr(grant)) |
971 | + |
972 | + def test_repr_person(self): |
973 | + rule = self.factory.makeGitRule() |
974 | + grantee = self.factory.makePerson() |
975 | + grant = self.factory.makeGitRuleGrant( |
976 | + rule=rule, grantee=grantee, can_push=True) |
977 | + self.assertEqual( |
978 | + "<GitRuleGrant [push] to ~%s for %s:%s>" % ( |
979 | + grantee.name, rule.repository.unique_name, rule.ref_pattern), |
980 | + repr(grant)) |
981 | + |
982 | + def test_destroySelf(self): |
983 | + rule = self.factory.makeGitRule() |
984 | + grants = [ |
985 | + self.factory.makeGitRuleGrant( |
986 | + rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER, |
987 | + can_create=True), |
988 | + self.factory.makeGitRuleGrant(rule=rule, can_push=True), |
989 | + ] |
990 | + with person_logged_in(rule.repository.owner): |
991 | + grants[1].destroySelf() |
992 | + self.assertThat(rule.grants, MatchesSetwise(Equals(grants[0]))) |
993 | |
994 | === modified file 'lib/lp/registry/personmerge.py' |
995 | --- lib/lp/registry/personmerge.py 2018-10-02 12:05:48 +0000 |
996 | +++ lib/lp/registry/personmerge.py 2018-10-05 14:49:26 +0000 |
997 | @@ -1,4 +1,4 @@ |
998 | -# Copyright 2009-2015 Canonical Ltd. This software is licensed under the |
999 | +# Copyright 2009-2018 Canonical Ltd. This software is licensed under the |
1000 | # GNU Affero General Public License version 3 (see the file LICENSE). |
1001 | |
1002 | """Person/team merger implementation.""" |
1003 | @@ -141,6 +141,42 @@ |
1004 | ''' % vars()) |
1005 | |
1006 | |
1007 | +def _mergeGitRuleGrant(cur, from_id, to_id): |
1008 | + # Transfer GitRuleGrants that only exist on from_person. |
1009 | + cur.execute(''' |
1010 | + UPDATE GitRuleGrant |
1011 | + SET grantee=%(to_id)d |
1012 | + WHERE |
1013 | + grantee = %(from_id)d |
1014 | + AND rule NOT IN ( |
1015 | + SELECT rule |
1016 | + FROM GitRuleGrant |
1017 | + WHERE grantee = %(to_id)d |
1018 | + ) |
1019 | + ''' % vars()) |
1020 | + # Merge permissions on GitRuleGrants that exist on both from_person and |
1021 | + # to_person. When multiple grants match a user we take the union of the |
1022 | + # permissions they confer, so it's safe to do that here too. |
1023 | + cur.execute(''' |
1024 | + UPDATE GitRuleGrant |
1025 | + SET |
1026 | + can_create = GitRuleGrant.can_create OR other.can_create, |
1027 | + can_push = GitRuleGrant.can_push OR other.can_push, |
1028 | + can_force_push = |
1029 | + GitRuleGrant.can_force_push OR other.can_force_push |
1030 | + FROM GitRuleGrant AS other |
1031 | + WHERE |
1032 | + GitRuleGrant.grantee = %(to_id)d |
1033 | + AND other.grantee = %(from_id)d |
1034 | + AND GitRuleGrant.rule = other.rule |
1035 | + ''' % vars()) |
1036 | + # Delete the remaining GitRuleGrants for from_person, which have now all |
1037 | + # been either transferred or merged. |
1038 | + cur.execute(''' |
1039 | + DELETE FROM GitRuleGrant WHERE grantee = %(from_id)d |
1040 | + ''' % vars()) |
1041 | + |
1042 | + |
1043 | def _mergeBranches(from_person, to_person): |
1044 | # This shouldn't use removeSecurityProxy. |
1045 | branches = getUtility(IBranchCollection).ownedBy(from_person) |
1046 | @@ -775,8 +811,10 @@ |
1047 | |
1048 | _mergeAccessArtifactGrant(cur, from_id, to_id) |
1049 | _mergeAccessPolicyGrant(cur, from_id, to_id) |
1050 | + _mergeGitRuleGrant(cur, from_id, to_id) |
1051 | skip.append(('accessartifactgrant', 'grantee')) |
1052 | skip.append(('accesspolicygrant', 'grantee')) |
1053 | + skip.append(('gitrulegrant', 'grantee')) |
1054 | |
1055 | # Update the Branches that will not conflict, and fudge the names of |
1056 | # ones that *do* conflict. |
1057 | |
1058 | === modified file 'lib/lp/registry/tests/test_personmerge.py' |
1059 | --- lib/lp/registry/tests/test_personmerge.py 2016-06-28 21:10:18 +0000 |
1060 | +++ lib/lp/registry/tests/test_personmerge.py 2018-10-05 14:49:26 +0000 |
1061 | @@ -1,4 +1,4 @@ |
1062 | -# Copyright 2009-2015 Canonical Ltd. This software is licensed under the |
1063 | +# Copyright 2009-2018 Canonical Ltd. This software is licensed under the |
1064 | # GNU Affero General Public License version 3 (see the file LICENSE). |
1065 | |
1066 | """Tests for merge_people.""" |
1067 | @@ -7,7 +7,10 @@ |
1068 | from operator import attrgetter |
1069 | |
1070 | import pytz |
1071 | -from testtools.matchers import MatchesStructure |
1072 | +from testtools.matchers import ( |
1073 | + MatchesSetwise, |
1074 | + MatchesStructure, |
1075 | + ) |
1076 | import transaction |
1077 | from zope.component import getUtility |
1078 | from zope.security.proxy import removeSecurityProxy |
1079 | @@ -494,6 +497,73 @@ |
1080 | grantee=person, |
1081 | date_created=person_grant_date)) |
1082 | |
1083 | + def test_merge_transfers_non_conflicting_gitrulegrants(self): |
1084 | + # GitRuleGrants are transferred from the duplicate. |
1085 | + rule = self.factory.makeGitRule() |
1086 | + person = self.factory.makePerson() |
1087 | + grant = self.factory.makeGitRuleGrant(rule=rule) |
1088 | + self._do_premerge(grant.grantee, person) |
1089 | + |
1090 | + self.assertEqual(grant.grantee, rule.grants.one().grantee) |
1091 | + with person_logged_in(person): |
1092 | + self._do_merge(grant.grantee, person) |
1093 | + self.assertEqual(person, rule.grants.one().grantee) |
1094 | + |
1095 | + def test_merge_conflicting_gitrulegrants(self): |
1096 | + # Conflicting GitRuleGrants have their permissions merged. |
1097 | + rule = self.factory.makeGitRule() |
1098 | + |
1099 | + person = self.factory.makePerson() |
1100 | + person_grant = self.factory.makeGitRuleGrant( |
1101 | + rule=rule, grantee=person, can_create=True) |
1102 | + person_grant_date = person_grant.date_created |
1103 | + |
1104 | + duplicate = self.factory.makePerson() |
1105 | + self.factory.makeGitRuleGrant( |
1106 | + rule=rule, grantee=duplicate, can_push=True) |
1107 | + |
1108 | + other_person = self.factory.makePerson() |
1109 | + self.factory.makeGitRuleGrant( |
1110 | + rule=rule, grantee=other_person, can_push=True) |
1111 | + |
1112 | + other_rule = self.factory.makeGitRule( |
1113 | + rule.repository, ref_pattern=u"refs/heads/other/*") |
1114 | + self.factory.makeGitRuleGrant( |
1115 | + rule=other_rule, grantee=other_person, can_force_push=True) |
1116 | + |
1117 | + self._do_premerge(duplicate, person) |
1118 | + with person_logged_in(person): |
1119 | + self._do_merge(duplicate, person) |
1120 | + |
1121 | + # Only two grants for the rule exist: the retained person's, with |
1122 | + # the union of permissions from the duplicate and target grants, and |
1123 | + # the grant to an unrelated person. |
1124 | + self.assertThat( |
1125 | + list(rule.grants), |
1126 | + MatchesSetwise( |
1127 | + MatchesStructure.byEquality( |
1128 | + rule=rule, |
1129 | + grantee=person, |
1130 | + date_created=person_grant_date, |
1131 | + can_create=True, |
1132 | + can_push=True, |
1133 | + can_force_push=False), |
1134 | + MatchesStructure.byEquality( |
1135 | + rule=rule, |
1136 | + grantee=other_person, |
1137 | + can_create=False, |
1138 | + can_push=True, |
1139 | + can_force_push=False))) |
1140 | + # A grant for another rule is untouched. |
1141 | + self.assertThat( |
1142 | + other_rule.grants.one(), |
1143 | + MatchesStructure.byEquality( |
1144 | + rule=other_rule, |
1145 | + grantee=other_person, |
1146 | + can_create=False, |
1147 | + can_push=False, |
1148 | + can_force_push=True)) |
1149 | + |
1150 | def test_mergeAsync(self): |
1151 | # mergeAsync() creates a new `PersonMergeJob`. |
1152 | from_person = self.factory.makePerson() |
1153 | |
1154 | === modified file 'lib/lp/security.py' |
1155 | --- lib/lp/security.py 2018-09-15 11:38:45 +0000 |
1156 | +++ lib/lp/security.py 2018-10-05 14:49:26 +0000 |
1157 | @@ -87,6 +87,10 @@ |
1158 | IGitRepository, |
1159 | user_has_special_git_repository_access, |
1160 | ) |
1161 | +from lp.code.interfaces.gitrule import ( |
1162 | + IGitRule, |
1163 | + IGitRuleGrant, |
1164 | + ) |
1165 | from lp.code.interfaces.sourcepackagerecipe import ISourcePackageRecipe |
1166 | from lp.code.interfaces.sourcepackagerecipebuild import ( |
1167 | ISourcePackageRecipeBuild, |
1168 | @@ -2343,6 +2347,42 @@ |
1169 | super(EditGitRef, self).__init__(obj, obj.repository) |
1170 | |
1171 | |
1172 | +class ViewGitRule(DelegatedAuthorization): |
1173 | + """Anyone who can see a Git repository can see its access rules.""" |
1174 | + permission = 'launchpad.View' |
1175 | + usedfor = IGitRule |
1176 | + |
1177 | + def __init__(self, obj): |
1178 | + super(ViewGitRule, self).__init__(obj, obj.repository) |
1179 | + |
1180 | + |
1181 | +class EditGitRule(DelegatedAuthorization): |
1182 | + """Anyone who can edit a Git repository can edit its access rules.""" |
1183 | + permission = 'launchpad.Edit' |
1184 | + usedfor = IGitRule |
1185 | + |
1186 | + def __init__(self, obj): |
1187 | + super(EditGitRule, self).__init__(obj, obj.repository) |
1188 | + |
1189 | + |
1190 | +class ViewGitRuleGrant(DelegatedAuthorization): |
1191 | + """Anyone who can see a Git repository can see its access grants.""" |
1192 | + permission = 'launchpad.View' |
1193 | + usedfor = IGitRuleGrant |
1194 | + |
1195 | + def __init__(self, obj): |
1196 | + super(ViewGitRuleGrant, self).__init__(obj, obj.repository) |
1197 | + |
1198 | + |
1199 | +class EditGitRuleGrant(DelegatedAuthorization): |
1200 | + """Anyone who can edit a Git repository can edit its access grants.""" |
1201 | + permission = 'launchpad.Edit' |
1202 | + usedfor = IGitRuleGrant |
1203 | + |
1204 | + def __init__(self, obj): |
1205 | + super(EditGitRuleGrant, self).__init__(obj, obj.repository) |
1206 | + |
1207 | + |
1208 | class AdminDistroSeriesTranslations(AuthorizationBase): |
1209 | permission = 'launchpad.TranslationsAdmin' |
1210 | usedfor = IDistroSeries |
1211 | |
1212 | === modified file 'lib/lp/testing/factory.py' |
1213 | --- lib/lp/testing/factory.py 2018-09-13 15:21:05 +0000 |
1214 | +++ lib/lp/testing/factory.py 2018-10-05 14:49:26 +0000 |
1215 | @@ -1828,6 +1828,31 @@ |
1216 | path = self.getUniqueString('refs/heads/path').decode('utf-8') |
1217 | return getUtility(IGitRefRemoteSet).new(repository_url, path) |
1218 | |
1219 | + def makeGitRule(self, repository=None, ref_pattern=u"refs/heads/*", |
1220 | + creator=None, position=None, **repository_kwargs): |
1221 | + """Create a Git repository access rule.""" |
1222 | + if repository is None: |
1223 | + repository = self.makeGitRepository(**repository_kwargs) |
1224 | + if creator is None: |
1225 | + creator = repository.owner |
1226 | + with person_logged_in(creator): |
1227 | + return repository.addRule(ref_pattern, creator, position=position) |
1228 | + |
1229 | + def makeGitRuleGrant(self, rule=None, grantee=None, grantor=None, |
1230 | + can_create=False, can_push=False, |
1231 | + can_force_push=False): |
1232 | + """Create a Git repository access grant.""" |
1233 | + if rule is None: |
1234 | + rule = self.makeGitRule() |
1235 | + if grantee is None: |
1236 | + grantee = self.makePerson() |
1237 | + if grantor is None: |
1238 | + grantor = rule.repository.owner |
1239 | + with person_logged_in(grantor): |
1240 | + return rule.addGrant( |
1241 | + grantee, grantor, can_create=can_create, can_push=can_push, |
1242 | + can_force_push=can_force_push) |
1243 | + |
1244 | def makeBug(self, target=None, owner=None, bug_watch_url=None, |
1245 | information_type=None, date_closed=None, title=None, |
1246 | date_created=None, description=None, comment=None, |
1247 | |
1248 | === modified file 'scripts/close-account.py' |
1249 | --- scripts/close-account.py 2018-05-08 13:44:21 +0000 |
1250 | +++ scripts/close-account.py 2018-10-05 14:49:26 +0000 |
1251 | @@ -1,6 +1,6 @@ |
1252 | #!/usr/bin/python -S |
1253 | # |
1254 | -# Copyright 2009-2011 Canonical Ltd. This software is licensed under the |
1255 | +# Copyright 2009-2018 Canonical Ltd. This software is licensed under the |
1256 | # GNU Affero General Public License version 3 (see the file LICENSE). |
1257 | |
1258 | """Remove personal details of a user from the database, leaving a stub.""" |
1259 | @@ -162,6 +162,9 @@ |
1260 | |
1261 | # Pending items in queues |
1262 | ('POExportRequest', 'person'), |
1263 | + |
1264 | + # Access grants |
1265 | + ('GitRuleGrant', 'grantee'), |
1266 | ] |
1267 | for table, person_id_column in removals: |
1268 | table_notification(table) |