Merge lp:~cjwatson/launchpad/git-permissions-model into lp:launchpad

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
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.

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)