Merge lp:~twom/launchpad/branch-permissions-for-gitapi into lp:launchpad

Proposed by Tom Wardill
Status: Superseded
Proposed branch: lp:~twom/launchpad/branch-permissions-for-gitapi
Merge into: lp:launchpad
Diff against target: 1527 lines (+1208/-4)
20 files modified
database/schema/patch-2209-85-0.sql (+68/-0)
database/schema/security.cfg (+4/-0)
lib/lp/code/configure.zcml (+37/-0)
lib/lp/code/enums.py (+21/-0)
lib/lp/code/interfaces/gitapi.py (+6/-0)
lib/lp/code/interfaces/gitlookup.py (+12/-0)
lib/lp/code/interfaces/gitrepository.py (+23/-0)
lib/lp/code/interfaces/gitrule.py (+172/-0)
lib/lp/code/model/gitlookup.py (+13/-0)
lib/lp/code/model/gitrepository.py (+62/-0)
lib/lp/code/model/gitrule.py (+199/-0)
lib/lp/code/model/tests/test_gitrepository.py (+102/-1)
lib/lp/code/model/tests/test_gitrule.py (+200/-0)
lib/lp/code/xmlrpc/git.py (+43/-0)
lib/lp/code/xmlrpc/tests/test_git.py (+119/-0)
lib/lp/registry/personmerge.py (+22/-1)
lib/lp/registry/tests/test_personmerge.py (+36/-1)
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:~twom/launchpad/branch-permissions-for-gitapi
Reviewer Review Type Date Requested Status
Launchpad code reviewers Pending
Review via email: mp+355715@code.launchpad.net

This proposal has been superseded by a proposal from 2018-09-26.

Commit message

Add GitRuleGrant api to xmlrpc API

To post a comment you must log in.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'database/schema/patch-2209-85-0.sql'
2--- database/schema/patch-2209-85-0.sql 1970-01-01 00:00:00 +0000
3+++ database/schema/patch-2209-85-0.sql 2018-09-26 15:35:28 +0000
4@@ -0,0 +1,68 @@
5+-- Copyright 2018 Canonical Ltd. This software is licensed under the
6+-- GNU Affero General Public License version 3 (see the file LICENSE).
7+
8+SET client_min_messages=ERROR;
9+
10+CREATE TABLE GitRule (
11+ id serial PRIMARY KEY,
12+ repository integer NOT NULL REFERENCES gitrepository,
13+ position integer NOT NULL,
14+ ref_pattern text NOT NULL,
15+ creator integer NOT NULL REFERENCES person,
16+ date_created timestamp without time zone DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') NOT NULL,
17+ date_last_modified timestamp without time zone DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') NOT NULL,
18+ CONSTRAINT gitrule__repository__position__key UNIQUE (repository, position) DEFERRABLE INITIALLY DEFERRED,
19+ CONSTRAINT gitrule__repository__ref_pattern__key UNIQUE (repository, ref_pattern),
20+ -- Used by repository_matches_rule constraint on GitRuleGrant.
21+ CONSTRAINT gitrule__repository__id__key UNIQUE (repository, id)
22+);
23+
24+COMMENT ON TABLE GitRule IS 'An access rule for a Git repository.';
25+COMMENT ON COLUMN GitRule.repository IS 'The repository that this rule is for.';
26+COMMENT ON COLUMN GitRule.position IS 'The position of this rule in its repository''s rule order.';
27+COMMENT ON COLUMN GitRule.ref_pattern IS 'The pattern of references matched by this rule.';
28+COMMENT ON COLUMN GitRule.creator IS 'The user who created this rule.';
29+COMMENT ON COLUMN GitRule.date_created IS 'The time when this rule was created.';
30+COMMENT ON COLUMN GitRule.date_last_modified IS 'The time when this rule was last modified.';
31+
32+CREATE TABLE GitRuleGrant (
33+ id serial PRIMARY KEY,
34+ repository integer NOT NULL REFERENCES gitrepository,
35+ rule integer NOT NULL REFERENCES gitrule,
36+ grantee_type integer NOT NULL,
37+ grantee integer REFERENCES person,
38+ can_create boolean DEFAULT false NOT NULL,
39+ can_push boolean DEFAULT false NOT NULL,
40+ can_force_push boolean DEFAULT false NOT NULL,
41+ grantor integer NOT NULL REFERENCES person,
42+ date_created timestamp without time zone DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') NOT NULL,
43+ date_last_modified timestamp without time zone DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') NOT NULL,
44+ CONSTRAINT repository_matches_rule FOREIGN KEY (repository, rule) REFERENCES gitrule (repository, id),
45+ -- 2 == PERSON
46+ CONSTRAINT has_grantee CHECK ((grantee_type = 2) = (grantee IS NOT NULL))
47+);
48+
49+CREATE INDEX gitrulegrant__repository__idx
50+ ON GitRuleGrant(repository);
51+CREATE UNIQUE INDEX gitrulegrant__rule__grantee_type__key
52+ ON GitRuleGrant(rule, grantee_type)
53+ -- 2 == PERSON
54+ WHERE grantee_type != 2;
55+CREATE UNIQUE INDEX gitrulegrant__rule__grantee_type__grantee_key
56+ ON GitRuleGrant(rule, grantee_type, grantee)
57+ -- 2 == PERSON
58+ WHERE grantee_type = 2;
59+
60+COMMENT ON TABLE GitRuleGrant IS 'An access grant for a Git repository rule.';
61+COMMENT ON COLUMN GitRuleGrant.repository IS 'The repository that this grant is for.';
62+COMMENT ON COLUMN GitRuleGrant.rule IS 'The rule that this grant is for.';
63+COMMENT ON COLUMN GitRuleGrant.grantee_type IS 'The type of entity being granted access.';
64+COMMENT ON COLUMN GitRuleGrant.grantee IS 'The person or team being granted access.';
65+COMMENT ON COLUMN GitRuleGrant.can_create IS 'Whether creating references is allowed.';
66+COMMENT ON COLUMN GitRuleGrant.can_push IS 'Whether pushing references is allowed.';
67+COMMENT ON COLUMN GitRuleGrant.can_force_push IS 'Whether force-pushing references is allowed.';
68+COMMENT ON COLUMN GitRuleGrant.grantor IS 'The user who created this grant.';
69+COMMENT ON COLUMN GitRuleGrant.date_created IS 'The time when this grant was created.';
70+COMMENT ON COLUMN GitRuleGrant.date_last_modified IS 'The time when this grant was last modified.';
71+
72+INSERT INTO LaunchpadDatabaseRevision VALUES (2209, 85, 0);
73
74=== modified file 'database/schema/security.cfg'
75--- database/schema/security.cfg 2018-09-10 12:45:07 +0000
76+++ database/schema/security.cfg 2018-09-26 15:35:28 +0000
77@@ -190,6 +190,8 @@
78 public.gitjob = SELECT, INSERT, UPDATE, DELETE
79 public.gitref = SELECT, INSERT, UPDATE, DELETE
80 public.gitrepository = SELECT, INSERT, UPDATE, DELETE
81+public.gitrule = SELECT, INSERT, UPDATE, DELETE
82+public.gitrulegrant = SELECT, INSERT, UPDATE, DELETE
83 public.gitsubscription = SELECT, INSERT, UPDATE, DELETE
84 public.hwdevice = SELECT
85 public.hwdeviceclass = SELECT, INSERT, DELETE
86@@ -2210,6 +2212,8 @@
87 public.faq = SELECT, UPDATE
88 public.featureflagchangelogentry = SELECT, UPDATE
89 public.gitrepository = SELECT, UPDATE
90+public.gitrule = SELECT, UPDATE
91+public.gitrulegrant = SELECT, UPDATE, DELETE
92 public.gitsubscription = SELECT, UPDATE, DELETE
93 public.gpgkey = SELECT, UPDATE
94 public.hwsubmission = SELECT, UPDATE
95
96=== modified file 'lib/lp/code/configure.zcml'
97--- lib/lp/code/configure.zcml 2018-05-16 17:33:18 +0000
98+++ lib/lp/code/configure.zcml 2018-09-26 15:35:28 +0000
99@@ -919,6 +919,35 @@
100 <allow interface="lp.code.interfaces.gitref.IGitRefRemoteSet" />
101 </securedutility>
102
103+ <!-- Git repository access rules -->
104+
105+ <class class="lp.code.model.gitrule.GitRule">
106+ <require
107+ permission="launchpad.View"
108+ interface="lp.code.interfaces.gitrule.IGitRuleView
109+ lp.code.interfaces.gitrule.IGitRuleEditableAttributes" />
110+ <require
111+ permission="launchpad.Edit"
112+ interface="lp.code.interfaces.gitrule.IGitRuleEdit"
113+ set_schema="lp.code.interfaces.gitrule.IGitRuleEditableAttributes" />
114+ </class>
115+ <subscriber
116+ for="lp.code.interfaces.gitrule.IGitRule zope.lifecycleevent.interfaces.IObjectModifiedEvent"
117+ handler="lp.code.model.gitrule.git_rule_modified"/>
118+ <class class="lp.code.model.gitrule.GitRuleGrant">
119+ <require
120+ permission="launchpad.View"
121+ interface="lp.code.interfaces.gitrule.IGitRuleGrantView
122+ lp.code.interfaces.gitrule.IGitRuleGrantEditableAttributes" />
123+ <require
124+ permission="launchpad.Edit"
125+ interface="lp.code.interfaces.gitrule.IGitRuleGrantEdit"
126+ set_schema="lp.code.interfaces.gitrule.IGitRuleGrantEditableAttributes" />
127+ </class>
128+ <subscriber
129+ for="lp.code.interfaces.gitrule.IGitRuleGrant zope.lifecycleevent.interfaces.IObjectModifiedEvent"
130+ handler="lp.code.model.gitrule.git_rule_grant_modified"/>
131+
132 <!-- GitCollection -->
133
134 <class class="lp.code.model.gitcollection.GenericGitCollection">
135@@ -975,6 +1004,9 @@
136 <adapter factory="lp.code.model.defaultgit.OwnerProjectDefaultGitRepository" />
137 <adapter factory="lp.code.model.defaultgit.OwnerPackageDefaultGitRepository" />
138
139+ <class class="lp.code.model.gitlookup.GitRuleGrantLookup">
140+ <allow interface="lp.code.interfaces.gitlookup.IGitRuleGrantLookup" />
141+ </class>
142 <class class="lp.code.model.gitlookup.GitLookup">
143 <allow interface="lp.code.interfaces.gitlookup.IGitLookup" />
144 </class>
145@@ -984,6 +1016,11 @@
146 <allow interface="lp.code.interfaces.gitlookup.IGitLookup" />
147 </securedutility>
148 <securedutility
149+ class="lp.code.model.gitlookup.GitRuleGrantLookup"
150+ provides="lp.code.interfaces.gitlookup.IGitRuleGrantLookup">
151+ <allow interface="lp.code.interfaces.gitlookup.IGitRuleGrantLookup" />
152+ </securedutility>
153+ <securedutility
154 class="lp.code.model.gitlookup.GitTraverser"
155 provides="lp.code.interfaces.gitlookup.IGitTraverser">
156 <allow interface="lp.code.interfaces.gitlookup.IGitTraverser" />
157
158=== modified file 'lib/lp/code/enums.py'
159--- lib/lp/code/enums.py 2017-06-15 01:02:11 +0000
160+++ lib/lp/code/enums.py 2018-09-26 15:35:28 +0000
161@@ -21,6 +21,7 @@
162 'CodeImportReviewStatus',
163 'CodeReviewNotificationLevel',
164 'CodeReviewVote',
165+ 'GitGranteeType',
166 'GitObjectType',
167 'GitRepositoryType',
168 'NON_CVS_RCS_TYPES',
169@@ -175,6 +176,26 @@
170 """)
171
172
173+class GitGranteeType(DBEnumeratedType):
174+ """Git Grantee Type
175+
176+ Access grants for Git repositories can be made to various kinds of
177+ grantees.
178+ """
179+
180+ REPOSITORY_OWNER = DBItem(1, """
181+ Repository owner
182+
183+ A grant to the owner of the associated repository.
184+ """)
185+
186+ PERSON = DBItem(2, """
187+ Person
188+
189+ A grant to a particular person or team.
190+ """)
191+
192+
193 class BranchLifecycleStatusFilter(EnumeratedType):
194 """Branch Lifecycle Status Filter
195
196
197=== modified file 'lib/lp/code/interfaces/gitapi.py'
198--- lib/lp/code/interfaces/gitapi.py 2015-03-31 04:18:22 +0000
199+++ lib/lp/code/interfaces/gitapi.py 2018-09-26 15:35:28 +0000
200@@ -67,3 +67,9 @@
201 :returns: An `Unauthorized` fault, as password authentication is
202 not yet supported.
203 """
204+
205+ def listRefRules(self, repository, user):
206+ """Return the list of RefRules for `user` in `repository`
207+
208+ :returns: A List of rules for the user in the specified repository
209+ """
210
211=== modified file 'lib/lp/code/interfaces/gitlookup.py'
212--- lib/lp/code/interfaces/gitlookup.py 2015-03-30 14:47:22 +0000
213+++ lib/lp/code/interfaces/gitlookup.py 2018-09-26 15:35:28 +0000
214@@ -6,6 +6,7 @@
215 __metaclass__ = type
216 __all__ = [
217 'IGitLookup',
218+ 'IGitRuleGrantLookup',
219 'IGitTraversable',
220 'IGitTraverser',
221 ]
222@@ -145,3 +146,14 @@
223 leading part of a path as a repository, such as external code
224 browsers.
225 """
226+
227+
228+class IGitRuleGrantLookup(Interface):
229+ """Utility for looking up a GitRuleGrant by properties"""
230+
231+ def getByRulesAffectingPerson(repository, grantee_id):
232+ """Find all the rules for a repository that affect a Person.
233+
234+ :param repository: An instance of a GitRepository
235+ :param granteed_id: An integer of the id of the Person
236+ """
237
238=== modified file 'lib/lp/code/interfaces/gitrepository.py'
239--- lib/lp/code/interfaces/gitrepository.py 2018-08-23 17:03:05 +0000
240+++ lib/lp/code/interfaces/gitrepository.py 2018-09-26 15:35:28 +0000
241@@ -266,6 +266,10 @@
242 # Really ICodeImport, patched in _schema_circular_imports.py.
243 schema=Interface))
244
245+ rules = Attribute("The access rules for this repository.")
246+
247+ grants = Attribute("The access grants for this repository.")
248+
249 @operation_parameters(
250 path=TextLine(title=_("A string to look up as a path.")))
251 # Really IGitRef, patched in _schema_circular_imports.py.
252@@ -715,6 +719,25 @@
253 This may be helpful in cases where a previous scan crashed.
254 """
255
256+ def addRule(ref_pattern, creator, position=None):
257+ """Add an access rule to this repository.
258+
259+ :param ref_pattern: The reference pattern that the new rule should
260+ match.
261+ :param creator: The `IPerson` who is adding the rule.
262+ :param position: The list position at which to insert the rule, or
263+ None to append it.
264+ """
265+
266+ def moveRule(rule, position):
267+ """Move a rule to a new position in its repository's rule order.
268+
269+ :param rule: The `IGitRule` to move.
270+ :param position: The new position. For example, 0 puts the rule at
271+ the start, while `len(repository.rules)` puts the rule at the
272+ end.
273+ """
274+
275 @export_read_operation()
276 @operation_for_version("devel")
277 def canBeDeleted():
278
279=== added file 'lib/lp/code/interfaces/gitrule.py'
280--- lib/lp/code/interfaces/gitrule.py 1970-01-01 00:00:00 +0000
281+++ lib/lp/code/interfaces/gitrule.py 2018-09-26 15:35:28 +0000
282@@ -0,0 +1,172 @@
283+# Copyright 2018 Canonical Ltd. This software is licensed under the
284+# GNU Affero General Public License version 3 (see the file LICENSE).
285+
286+"""Git repository access rules."""
287+
288+from __future__ import absolute_import, print_function, unicode_literals
289+
290+__metaclass__ = type
291+__all__ = [
292+ 'IGitRule',
293+ 'IGitRuleGrant',
294+ ]
295+
296+from lazr.restful.fields import Reference
297+from zope.interface import (
298+ Attribute,
299+ Interface,
300+ )
301+from zope.schema import (
302+ Bool,
303+ Choice,
304+ Datetime,
305+ Int,
306+ TextLine,
307+ )
308+
309+from lp import _
310+from lp.code.enums import GitGranteeType
311+from lp.code.interfaces.gitrepository import IGitRepository
312+from lp.services.fields import (
313+ PersonChoice,
314+ PublicPersonChoice,
315+ )
316+
317+
318+class IGitRuleView(Interface):
319+ """`IGitRule` attributes that require launchpad.View."""
320+
321+ id = Int(title=_("ID"), readonly=True, required=True)
322+
323+ repository = Reference(
324+ title=_("Repository"), required=True, readonly=True,
325+ schema=IGitRepository,
326+ description=_("The repository that this rule is for."))
327+
328+ position = Int(
329+ title=_("Position"), required=True, readonly=True,
330+ description=_(
331+ "The position of this rule in its repository's rule order."))
332+
333+ creator = PublicPersonChoice(
334+ title=_("Creator"), required=True, readonly=True,
335+ vocabulary="ValidPerson",
336+ description=_("The user who created this rule."))
337+
338+ date_created = Datetime(
339+ title=_("Date created"), required=True, readonly=True,
340+ description=_("The time when this rule was created."))
341+
342+ grants = Attribute("The access grants for this rule.")
343+
344+
345+class IGitRuleEditableAttributes(Interface):
346+ """`IGitRule` attributes that can be edited.
347+
348+ These attributes need launchpad.View to see, and launchpad.Edit to change.
349+ """
350+
351+ ref_pattern = TextLine(
352+ title=_("Pattern"), required=True, readonly=False,
353+ description=_("The pattern of references matched by this rule."))
354+
355+ date_last_modified = Datetime(
356+ title=_("Date last modified"), required=True, readonly=True,
357+ description=_("The time when this rule was last modified."))
358+
359+
360+class IGitRuleEdit(Interface):
361+ """`IGitRule` attributes that require launchpad.Edit."""
362+
363+ def addGrant(grantee, grantor, can_create=False, can_push=False,
364+ can_force_push=False):
365+ """Add an access grant to this rule.
366+
367+ :param grantee: The `IPerson` who is being granted permission, or an
368+ item of `GitGranteeType` other than `GitGranteeType.PERSON` to
369+ grant permission to some other kind of entity.
370+ :param grantor: The `IPerson` who is granting permission.
371+ :param can_create: Whether the grantee can create references
372+ matching this rule.
373+ :param can_push: Whether the grantee can push references matching
374+ this rule.
375+ :param can_force_push: Whether the grantee can force-push references
376+ matching this rule.
377+ """
378+
379+ def destroySelf():
380+ """Delete this rule."""
381+
382+
383+class IGitRule(IGitRuleView, IGitRuleEditableAttributes, IGitRuleEdit):
384+ """An access rule for a Git repository."""
385+
386+
387+class IGitRuleGrantView(Interface):
388+ """`IGitRuleGrant` attributes that require launchpad.View."""
389+
390+ id = Int(title=_("ID"), readonly=True, required=True)
391+
392+ repository = Reference(
393+ title=_("Repository"), required=True, readonly=True,
394+ schema=IGitRepository,
395+ description=_("The repository that this grant is for."))
396+
397+ rule = Reference(
398+ title=_("Rule"), required=True, readonly=True,
399+ schema=IGitRule,
400+ description=_("The rule that this grant is for."))
401+
402+ grantor = PublicPersonChoice(
403+ title=_("Grantor"), required=True, readonly=True,
404+ vocabulary="ValidPerson",
405+ description=_("The user who created this grant."))
406+
407+ grantee_type = Choice(
408+ title=_("Grantee type"), required=True, readonly=True,
409+ vocabulary=GitGranteeType,
410+ description=_("The type of grantee for this grant."))
411+
412+ grantee = PersonChoice(
413+ title=_("Grantee"), required=False, readonly=True,
414+ vocabulary="ValidPersonOrTeam",
415+ description=_("The person being granted access."))
416+
417+ date_created = Datetime(
418+ title=_("Date created"), required=True, readonly=True,
419+ description=_("The time when this grant was created."))
420+
421+
422+class IGitRuleGrantEditableAttributes(Interface):
423+ """`IGitRuleGrant` attributes that can be edited.
424+
425+ These attributes need launchpad.View to see, and launchpad.Edit to change.
426+ """
427+
428+ can_create = Bool(
429+ title=_("Can create"), required=True, readonly=False,
430+ description=_("Whether creating references is allowed."))
431+
432+ can_push = Bool(
433+ title=_("Can push"), required=True, readonly=False,
434+ description=_("Whether pushing references is allowed."))
435+
436+ can_force_push = Bool(
437+ title=_("Can force-push"), required=True, readonly=False,
438+ description=_("Whether force-pushing references is allowed."))
439+
440+ date_last_modified = Datetime(
441+ title=_("Date last modified"), required=True, readonly=True,
442+ description=_("The time when this grant was last modified."))
443+
444+
445+class IGitRuleGrantEdit(Interface):
446+ """`IGitRuleGrant` attributes that require launchpad.Edit."""
447+
448+ def destroySelf():
449+ """Delete this access grant."""
450+
451+
452+class IGitRuleGrant(IGitRuleGrantView, IGitRuleGrantEditableAttributes,
453+ IGitRuleGrantEdit):
454+ """An access grant for a Git repository rule."""
455
456=== modified file 'lib/lp/code/model/gitlookup.py'
457--- lib/lp/code/model/gitlookup.py 2018-07-23 10:28:33 +0000
458+++ lib/lp/code/model/gitlookup.py 2018-09-26 15:35:28 +0000
459@@ -27,6 +27,7 @@
460 )
461 from lp.code.interfaces.gitlookup import (
462 IGitLookup,
463+ IGitRuleGrantLookup,
464 IGitTraversable,
465 IGitTraverser,
466 )
467@@ -34,6 +35,7 @@
468 from lp.code.interfaces.gitrepository import IGitRepositorySet
469 from lp.code.interfaces.hasgitrepositories import IHasGitRepositories
470 from lp.code.model.gitrepository import GitRepository
471+from lp.code.model.gitrule import GitRuleGrant
472 from lp.registry.errors import NoSuchSourcePackageName
473 from lp.registry.interfaces.distribution import IDistribution
474 from lp.registry.interfaces.distributionsourcepackage import (
475@@ -372,3 +374,14 @@
476 if trailing:
477 trailing_segments.insert(0, trailing)
478 return repository, "/".join(trailing_segments)
479+
480+
481+@implementer(IGitRuleGrantLookup)
482+class GitRuleGrantLookup:
483+
484+ def getByRulesAffectingPerson(self, repository, grantee):
485+ grants = IStore(GitRuleGrant).find(
486+ GitRuleGrant,
487+ GitRuleGrant.repository == repository)
488+ grants = [grant for grant in grants if grantee.inTeam(grant.grantee)]
489+ return grants
490
491=== modified file 'lib/lp/code/model/gitrepository.py'
492--- lib/lp/code/model/gitrepository.py 2018-09-06 14:25:46 +0000
493+++ lib/lp/code/model/gitrepository.py 2018-09-26 15:35:28 +0000
494@@ -41,6 +41,7 @@
495 Bool,
496 DateTime,
497 Int,
498+ List,
499 Reference,
500 Unicode,
501 )
502@@ -112,6 +113,10 @@
503 GitRef,
504 GitRefDefault,
505 )
506+from lp.code.model.gitrule import (
507+ GitRule,
508+ GitRuleGrant,
509+ )
510 from lp.code.model.gitsubscription import GitSubscription
511 from lp.registry.enums import PersonVisibility
512 from lp.registry.errors import CannotChangeInformationType
513@@ -1121,6 +1126,61 @@
514 def code_import(self):
515 return getUtility(ICodeImportSet).getByGitRepository(self)
516
517+ @property
518+ def rules(self):
519+ """See `IGitRepository`."""
520+ return Store.of(self).find(
521+ GitRule, GitRule.repository == self).order_by(GitRule.position)
522+
523+ def _syncRulePositions(self, rules):
524+ """Synchronise rule positions with their order in a provided list.
525+
526+ :param rules: A sequence of `IGitRule`s in the desired order.
527+ """
528+ # This approach requires fetching all this repository's rules, which
529+ # is potentially more work than necessary. However, it has the
530+ # benefit of being simple, and because it ensures the correct
531+ # position of all rules it tends to be self-correcting.
532+ for position, rule in enumerate(rules):
533+ if rule.repository != self:
534+ raise AssertionError("%r does not belong to %r" % (rule, self))
535+ if rule.position != position:
536+ removeSecurityProxy(rule).position = position
537+
538+ def addRule(self, ref_pattern, creator, position=None):
539+ """See `IGitRepository`."""
540+ rules = list(self.rules)
541+ rule = GitRule(
542+ repository=self,
543+ # -1 isn't a valid position, but _syncRulePositions will correct
544+ # it in a moment.
545+ position=position if position is not None else -1,
546+ ref_pattern=ref_pattern, creator=creator, date_created=DEFAULT)
547+ if position is None:
548+ rules.append(rule)
549+ else:
550+ rules.insert(position, rule)
551+ self._syncRulePositions(rules)
552+ return rule
553+
554+ def moveRule(self, rule, position):
555+ """See `IGitRepository`."""
556+ if rule.repository != self:
557+ raise ValueError("%r does not belong to %r" % (rule, self))
558+ if position < 0:
559+ raise ValueError("Negative positions are not supported")
560+ if position != rule.position:
561+ rules = list(self.rules)
562+ rules.remove(rule)
563+ rules.insert(position, rule)
564+ self._syncRulePositions(rules)
565+
566+ @property
567+ def grants(self):
568+ """See `IGitRepository`."""
569+ return Store.of(self).find(
570+ GitRuleGrant, GitRuleGrant.repository_id == self.id)
571+
572 def canBeDeleted(self):
573 """See `IGitRepository`."""
574 # Can't delete if the repository is associated with anything.
575@@ -1252,6 +1312,8 @@
576 self._deleteRepositorySubscriptions()
577 self._deleteJobs()
578 getUtility(IWebhookSet).delete(self.webhooks)
579+ self.grants.remove()
580+ self.rules.remove()
581
582 # Now destroy the repository.
583 repository_name = self.unique_name
584
585=== added file 'lib/lp/code/model/gitrule.py'
586--- lib/lp/code/model/gitrule.py 1970-01-01 00:00:00 +0000
587+++ lib/lp/code/model/gitrule.py 2018-09-26 15:35:28 +0000
588@@ -0,0 +1,199 @@
589+# Copyright 2018 Canonical Ltd. This software is licensed under the
590+# GNU Affero General Public License version 3 (see the file LICENSE).
591+
592+"""Git repository access rules."""
593+
594+from __future__ import absolute_import, print_function, unicode_literals
595+
596+__metaclass__ = type
597+__all__ = [
598+ 'GitRule',
599+ 'GitRuleGrant',
600+ ]
601+
602+from lazr.enum import DBItem
603+import pytz
604+from storm.locals import (
605+ Bool,
606+ DateTime,
607+ Int,
608+ Reference,
609+ Store,
610+ Unicode,
611+ )
612+from zope.interface import implementer
613+from zope.security.proxy import removeSecurityProxy
614+
615+from lp.code.enums import GitGranteeType
616+from lp.code.interfaces.gitrule import (
617+ IGitRule,
618+ IGitRuleGrant,
619+ )
620+from lp.registry.interfaces.person import (
621+ validate_person,
622+ validate_public_person,
623+ )
624+from lp.services.database.constants import (
625+ DEFAULT,
626+ UTC_NOW,
627+ )
628+from lp.services.database.enumcol import DBEnum
629+from lp.services.database.stormbase import StormBase
630+
631+
632+def git_rule_modified(rule, event):
633+ """Update date_last_modified when a GitRule is modified.
634+
635+ This method is registered as a subscriber to `IObjectModifiedEvent`
636+ events on Git repository rules.
637+ """
638+ if event.edited_fields:
639+ rule.date_last_modified = UTC_NOW
640+
641+
642+@implementer(IGitRule)
643+class GitRule(StormBase):
644+ """See `IGitRule`."""
645+
646+ __storm_table__ = 'GitRule'
647+
648+ id = Int(primary=True)
649+
650+ repository_id = Int(name='repository', allow_none=False)
651+ repository = Reference(repository_id, 'GitRepository.id')
652+
653+ position = Int(name='position', allow_none=False)
654+
655+ ref_pattern = Unicode(name='ref_pattern', allow_none=False)
656+
657+ creator_id = Int(
658+ name='creator', allow_none=False, validator=validate_public_person)
659+ creator = Reference(creator_id, 'Person.id')
660+
661+ date_created = DateTime(
662+ name='date_created', tzinfo=pytz.UTC, allow_none=False)
663+ date_last_modified = DateTime(
664+ name='date_last_modified', tzinfo=pytz.UTC, allow_none=False)
665+
666+ def __init__(self, repository, position, ref_pattern, creator,
667+ date_created):
668+ super(GitRule, self).__init__()
669+ self.repository = repository
670+ self.position = position
671+ self.ref_pattern = ref_pattern
672+ self.creator = creator
673+ self.date_created = date_created
674+ self.date_last_modified = date_created
675+
676+ def __repr__(self):
677+ return "<GitRule '%s'> for %r" % (self.ref_pattern, self.repository)
678+
679+ @property
680+ def grants(self):
681+ """See `IGitRule`."""
682+ return Store.of(self).find(
683+ GitRuleGrant, GitRuleGrant.rule_id == self.id)
684+
685+ def addGrant(self, grantee, grantor, can_create=False, can_push=False,
686+ can_force_push=False):
687+ """See `IGitRule`."""
688+ return GitRuleGrant(
689+ rule=self, grantee=grantee, can_create=can_create,
690+ can_push=can_push, can_force_push=can_force_push, grantor=grantor,
691+ date_created=DEFAULT)
692+
693+ def destroySelf(self):
694+ """See `IGitRule`."""
695+ for grant in self.grants:
696+ grant.destroySelf()
697+ rules = list(self.repository.rules)
698+ Store.of(self).remove(self)
699+ rules.remove(self)
700+ removeSecurityProxy(self.repository)._syncRulePositions(rules)
701+
702+
703+def git_rule_grant_modified(grant, event):
704+ """Update date_last_modified when a GitRuleGrant is modified.
705+
706+ This method is registered as a subscriber to `IObjectModifiedEvent`
707+ events on Git repository grants.
708+ """
709+ if event.edited_fields:
710+ grant.date_last_modified = UTC_NOW
711+
712+
713+@implementer(IGitRuleGrant)
714+class GitRuleGrant(StormBase):
715+ """See `IGitRuleGrant`."""
716+
717+ __storm_table__ = 'GitRuleGrant'
718+
719+ id = Int(primary=True)
720+
721+ repository_id = Int(name='repository', allow_none=False)
722+ repository = Reference(repository_id, 'GitRepository.id')
723+
724+ rule_id = Int(name='rule', allow_none=False)
725+ rule = Reference(rule_id, 'GitRule.id')
726+
727+ grantee_type = DBEnum(
728+ name='grantee_type', enum=GitGranteeType, allow_none=False)
729+
730+ grantee_id = Int(
731+ name='grantee', allow_none=True, validator=validate_person)
732+ grantee = Reference(grantee_id, 'Person.id')
733+
734+ can_create = Bool(name='can_create', allow_none=False)
735+ can_push = Bool(name='can_push', allow_none=False)
736+ can_force_push = Bool(name='can_force_push', allow_none=False)
737+
738+ grantor_id = Int(
739+ name='grantor', allow_none=False, validator=validate_public_person)
740+ grantor = Reference(grantor_id, 'Person.id')
741+
742+ date_created = DateTime(
743+ name='date_created', tzinfo=pytz.UTC, allow_none=False)
744+ date_last_modified = DateTime(
745+ name='date_last_modified', tzinfo=pytz.UTC, allow_none=False)
746+
747+ def __init__(self, rule, grantee, can_create, can_push, can_force_push,
748+ grantor, date_created):
749+ if isinstance(grantee, DBItem) and grantee.enum == GitGranteeType:
750+ if grantee == GitGranteeType.PERSON:
751+ raise ValueError(
752+ "grantee may not be GitGranteeType.PERSON; pass a person "
753+ "object instead")
754+ grantee_type = grantee
755+ grantee = None
756+ else:
757+ grantee_type = GitGranteeType.PERSON
758+
759+ self.repository = rule.repository
760+ self.rule = rule
761+ self.grantee_type = grantee_type
762+ self.grantee = grantee
763+ self.can_create = can_create
764+ self.can_push = can_push
765+ self.can_force_push = can_force_push
766+ self.grantor = grantor
767+ self.date_created = date_created
768+ self.date_last_modified = date_created
769+
770+ def __repr__(self):
771+ permissions = []
772+ if self.can_create:
773+ permissions.append("create")
774+ if self.can_push:
775+ permissions.append("push")
776+ if self.can_force_push:
777+ permissions.append("force-push")
778+ if self.grantee_type == GitGranteeType.PERSON:
779+ grantee_name = "~%s" % self.grantee.name
780+ else:
781+ grantee_name = self.grantee_type.title.lower()
782+ return "<GitRuleGrant [%s] to %s> for %r" % (
783+ ", ".join(permissions), grantee_name, self.rule)
784+
785+ def destroySelf(self):
786+ """See `IGitRuleGrant`."""
787+ Store.of(self).remove(self)
788
789=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
790--- lib/lp/code/model/tests/test_gitrepository.py 2018-08-31 14:25:40 +0000
791+++ lib/lp/code/model/tests/test_gitrepository.py 2018-09-26 15:35:28 +0000
792@@ -1,4 +1,4 @@
793-# Copyright 2015-2017 Canonical Ltd. This software is licensed under the
794+# Copyright 2015-2018 Canonical Ltd. This software is licensed under the
795 # GNU Affero General Public License version 3 (see the file LICENSE).
796
797 """Tests for Git repositories."""
798@@ -23,6 +23,7 @@
799 from testtools.matchers import (
800 EndsWith,
801 LessThan,
802+ MatchesListwise,
803 MatchesSetwise,
804 MatchesStructure,
805 )
806@@ -537,6 +538,14 @@
807 transaction.commit()
808 self.assertRaises(LostObjectError, getattr, webhook, 'target')
809
810+ def test_related_rules_and_grants_deleted(self):
811+ rule = self.factory.makeGitRule(repository=self.repository)
812+ grant = self.factory.makeGitRuleGrant(rule=rule)
813+ self.repository.destroySelf()
814+ transaction.commit()
815+ self.assertRaises(LostObjectError, getattr, grant, 'rule')
816+ self.assertRaises(LostObjectError, getattr, rule, 'repository')
817+
818
819 class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
820 """Test determination and application of repository deletion
821@@ -2179,6 +2188,98 @@
822 self.assertEqual('Some text', ret)
823
824
825+class TestGitRepositoryRules(TestCaseWithFactory):
826+
827+ layer = DatabaseFunctionalLayer
828+
829+ def test_rules(self):
830+ repository = self.factory.makeGitRepository()
831+ other_repository = self.factory.makeGitRepository()
832+ self.factory.makeGitRule(
833+ repository=repository, ref_pattern="refs/heads/*")
834+ self.factory.makeGitRule(
835+ repository=repository, ref_pattern="refs/heads/stable/*")
836+ self.factory.makeGitRule(
837+ repository=other_repository, ref_pattern="refs/heads/*")
838+ self.assertThat(list(repository.rules), MatchesListwise([
839+ MatchesStructure.byEquality(
840+ repository=repository,
841+ ref_pattern="refs/heads/*"),
842+ MatchesStructure.byEquality(
843+ repository=repository,
844+ ref_pattern="refs/heads/stable/*"),
845+ ]))
846+
847+ def test_addRule_append(self):
848+ repository = self.factory.makeGitRepository()
849+ initial_rule = self.factory.makeGitRule(
850+ repository=repository, ref_pattern="refs/heads/*")
851+ self.assertEqual(0, initial_rule.position)
852+ with person_logged_in(repository.owner):
853+ new_rule = repository.addRule(
854+ "refs/heads/stable/*", repository.owner)
855+ self.assertEqual(1, new_rule.position)
856+ self.assertEqual([initial_rule, new_rule], list(repository.rules))
857+
858+ def test_addRule_insert(self):
859+ repository = self.factory.makeGitRepository()
860+ initial_rules = [
861+ self.factory.makeGitRule(
862+ repository=repository, ref_pattern="refs/heads/*"),
863+ self.factory.makeGitRule(
864+ repository=repository, ref_pattern="refs/heads/protected"),
865+ self.factory.makeGitRule(
866+ repository=repository, ref_pattern="refs/heads/another"),
867+ ]
868+ self.assertEqual([0, 1, 2], [rule.position for rule in initial_rules])
869+ with person_logged_in(repository.owner):
870+ new_rule = repository.addRule(
871+ "refs/heads/stable/*", repository.owner, position=1)
872+ self.assertEqual(1, new_rule.position)
873+ self.assertEqual([0, 2, 3], [rule.position for rule in initial_rules])
874+ self.assertEqual(
875+ [initial_rules[0], new_rule, initial_rules[1], initial_rules[2]],
876+ list(repository.rules))
877+
878+ def test_moveRule(self):
879+ repository = self.factory.makeGitRepository()
880+ rules = [
881+ self.factory.makeGitRule(
882+ repository=repository,
883+ ref_pattern=self.factory.getUniqueUnicode(
884+ prefix="refs/heads/"))
885+ for _ in range(5)]
886+ with person_logged_in(repository.owner):
887+ self.assertEqual(rules, list(repository.rules))
888+ repository.moveRule(rules[0], 4)
889+ self.assertEqual(rules[1:] + [rules[0]], list(repository.rules))
890+ repository.moveRule(rules[0], 0)
891+ self.assertEqual(rules, list(repository.rules))
892+ repository.moveRule(rules[2], 1)
893+ self.assertEqual(
894+ [rules[0], rules[2], rules[1], rules[3], rules[4]],
895+ list(repository.rules))
896+
897+ def test_moveRule_non_negative(self):
898+ rule = self.factory.makeGitRule()
899+ with person_logged_in(rule.repository.owner):
900+ self.assertRaises(ValueError, rule.repository.moveRule, rule, -1)
901+
902+ def test_grants(self):
903+ repository = self.factory.makeGitRepository()
904+ other_repository = self.factory.makeGitRepository()
905+ rule = self.factory.makeGitRule(repository=repository)
906+ other_rule = self.factory.makeGitRule(repository=other_repository)
907+ grants = [
908+ self.factory.makeGitRuleGrant(
909+ rule=rule, grantee=self.factory.makePerson())
910+ for _ in range(2)
911+ ]
912+ self.factory.makeGitRuleGrant(
913+ rule=other_rule, grantee=self.factory.makePerson())
914+ self.assertContentEqual(grants, repository.grants)
915+
916+
917 class TestGitRepositorySet(TestCaseWithFactory):
918
919 layer = DatabaseFunctionalLayer
920
921=== added file 'lib/lp/code/model/tests/test_gitrule.py'
922--- lib/lp/code/model/tests/test_gitrule.py 1970-01-01 00:00:00 +0000
923+++ lib/lp/code/model/tests/test_gitrule.py 2018-09-26 15:35:28 +0000
924@@ -0,0 +1,200 @@
925+# Copyright 2018 Canonical Ltd. This software is licensed under the
926+# GNU Affero General Public License version 3 (see the file LICENSE).
927+
928+"""Tests for Git repository access rules."""
929+
930+from __future__ import absolute_import, print_function, unicode_literals
931+
932+__metaclass__ = type
933+
934+from storm.store import Store
935+from testtools.matchers import (
936+ Equals,
937+ Is,
938+ MatchesSetwise,
939+ MatchesStructure,
940+ )
941+
942+from lp.code.enums import GitGranteeType
943+from lp.code.interfaces.gitrule import (
944+ IGitRule,
945+ IGitRuleGrant,
946+ )
947+from lp.services.database.sqlbase import get_transaction_timestamp
948+from lp.testing import (
949+ person_logged_in,
950+ TestCaseWithFactory,
951+ verifyObject,
952+ )
953+from lp.testing.layers import DatabaseFunctionalLayer
954+
955+
956+class TestGitRule(TestCaseWithFactory):
957+
958+ layer = DatabaseFunctionalLayer
959+
960+ def test_implements_IGitRule(self):
961+ rule = self.factory.makeGitRule()
962+ verifyObject(IGitRule, rule)
963+
964+ def test_properties(self):
965+ owner = self.factory.makeTeam()
966+ member = self.factory.makePerson(member_of=[owner])
967+ repository = self.factory.makeGitRepository(owner=owner)
968+ rule = self.factory.makeGitRule(
969+ repository=repository, ref_pattern="refs/heads/stable/*",
970+ creator=member)
971+ now = get_transaction_timestamp(Store.of(rule))
972+ self.assertThat(rule, MatchesStructure.byEquality(
973+ repository=repository,
974+ ref_pattern="refs/heads/stable/*",
975+ creator=member,
976+ date_created=now,
977+ date_last_modified=now))
978+
979+ def test_repr(self):
980+ repository = self.factory.makeGitRepository()
981+ rule = self.factory.makeGitRule(repository=repository)
982+ self.assertEqual(
983+ "<GitRule 'refs/heads/*'> for %r" % repository, repr(rule))
984+
985+ def test_grants(self):
986+ rule = self.factory.makeGitRule()
987+ other_rule = self.factory.makeGitRule(
988+ repository=rule.repository, ref_pattern="refs/heads/stable/*")
989+ grantees = [self.factory.makePerson() for _ in range(2)]
990+ self.factory.makeGitRuleGrant(
991+ rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER,
992+ can_create=True)
993+ self.factory.makeGitRuleGrant(
994+ rule=rule, grantee=grantees[0], can_push=True)
995+ self.factory.makeGitRuleGrant(
996+ rule=rule, grantee=grantees[1], can_force_push=True)
997+ self.factory.makeGitRuleGrant(
998+ rule=other_rule, grantee=grantees[0], can_push=True)
999+ self.assertThat(rule.grants, MatchesSetwise(
1000+ MatchesStructure(
1001+ rule=Equals(rule),
1002+ grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER),
1003+ grantee=Is(None),
1004+ can_create=Is(True),
1005+ can_push=Is(False),
1006+ can_force_push=Is(False)),
1007+ MatchesStructure(
1008+ rule=Equals(rule),
1009+ grantee_type=Equals(GitGranteeType.PERSON),
1010+ grantee=Equals(grantees[0]),
1011+ can_create=Is(False),
1012+ can_push=Is(True),
1013+ can_force_push=Is(False)),
1014+ MatchesStructure(
1015+ rule=Equals(rule),
1016+ grantee_type=Equals(GitGranteeType.PERSON),
1017+ grantee=Equals(grantees[1]),
1018+ can_create=Is(False),
1019+ can_push=Is(False),
1020+ can_force_push=Is(True))))
1021+
1022+ def test_destroySelf(self):
1023+ repository = self.factory.makeGitRepository()
1024+ rules = [
1025+ self.factory.makeGitRule(
1026+ repository=repository,
1027+ ref_pattern=self.factory.getUniqueUnicode(
1028+ prefix="refs/heads/"))
1029+ for _ in range(4)]
1030+ self.assertEqual([0, 1, 2, 3], [rule.position for rule in rules])
1031+ self.assertEqual(rules, list(repository.rules))
1032+ with person_logged_in(repository.owner):
1033+ rules[1].destroySelf()
1034+ del rules[1]
1035+ self.assertEqual([0, 1, 2], [rule.position for rule in rules])
1036+ self.assertEqual(rules, list(repository.rules))
1037+
1038+ def test_destroySelf_removes_grants(self):
1039+ repository = self.factory.makeGitRepository()
1040+ rule = self.factory.makeGitRule(repository=repository)
1041+ grant = self.factory.makeGitRuleGrant(rule=rule)
1042+ self.assertEqual([grant], list(repository.grants))
1043+ with person_logged_in(repository.owner):
1044+ rule.destroySelf()
1045+ self.assertEqual([], list(repository.grants))
1046+
1047+
1048+class TestGitRuleGrant(TestCaseWithFactory):
1049+
1050+ layer = DatabaseFunctionalLayer
1051+
1052+ def test_implements_IGitRuleGrant(self):
1053+ grant = self.factory.makeGitRuleGrant()
1054+ verifyObject(IGitRuleGrant, grant)
1055+
1056+ def test_properties_owner(self):
1057+ owner = self.factory.makeTeam()
1058+ member = self.factory.makePerson(member_of=[owner])
1059+ rule = self.factory.makeGitRule(owner=owner)
1060+ grant = self.factory.makeGitRuleGrant(
1061+ rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER, grantor=member,
1062+ can_create=True, can_force_push=True)
1063+ now = get_transaction_timestamp(Store.of(grant))
1064+ self.assertThat(grant, MatchesStructure(
1065+ repository=Equals(rule.repository),
1066+ rule=Equals(rule),
1067+ grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER),
1068+ grantee=Is(None),
1069+ can_create=Is(True),
1070+ can_push=Is(False),
1071+ can_force_push=Is(True),
1072+ grantor=Equals(member),
1073+ date_created=Equals(now),
1074+ date_last_modified=Equals(now)))
1075+
1076+ def test_properties_person(self):
1077+ owner = self.factory.makeTeam()
1078+ member = self.factory.makePerson(member_of=[owner])
1079+ rule = self.factory.makeGitRule(owner=owner)
1080+ grantee = self.factory.makePerson()
1081+ grant = self.factory.makeGitRuleGrant(
1082+ rule=rule, grantee=grantee, grantor=member, can_push=True)
1083+ now = get_transaction_timestamp(Store.of(rule))
1084+ self.assertThat(grant, MatchesStructure(
1085+ repository=Equals(rule.repository),
1086+ rule=Equals(rule),
1087+ grantee_type=Equals(GitGranteeType.PERSON),
1088+ grantee=Equals(grantee),
1089+ can_create=Is(False),
1090+ can_push=Is(True),
1091+ can_force_push=Is(False),
1092+ grantor=Equals(member),
1093+ date_created=Equals(now),
1094+ date_last_modified=Equals(now)))
1095+
1096+ def test_repr_owner(self):
1097+ rule = self.factory.makeGitRule()
1098+ grant = self.factory.makeGitRuleGrant(
1099+ rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER,
1100+ can_create=True, can_push=True)
1101+ self.assertEqual(
1102+ "<GitRuleGrant [create, push] to repository owner> for %r" % rule,
1103+ repr(grant))
1104+
1105+ def test_repr_person(self):
1106+ rule = self.factory.makeGitRule()
1107+ grantee = self.factory.makePerson()
1108+ grant = self.factory.makeGitRuleGrant(
1109+ rule=rule, grantee=grantee, can_push=True)
1110+ self.assertEqual(
1111+ "<GitRuleGrant [push] to ~%s> for %r" % (grantee.name, rule),
1112+ repr(grant))
1113+
1114+ def test_destroySelf(self):
1115+ rule = self.factory.makeGitRule()
1116+ grants = [
1117+ self.factory.makeGitRuleGrant(
1118+ rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER,
1119+ can_create=True),
1120+ self.factory.makeGitRuleGrant(rule=rule, can_push=True),
1121+ ]
1122+ with person_logged_in(rule.repository.owner):
1123+ grants[1].destroySelf()
1124+ self.assertThat(rule.grants, MatchesSetwise(Equals(grants[0])))
1125
1126=== modified file 'lib/lp/code/xmlrpc/git.py'
1127--- lib/lp/code/xmlrpc/git.py 2018-08-28 13:58:37 +0000
1128+++ lib/lp/code/xmlrpc/git.py 2018-09-26 15:35:28 +0000
1129@@ -40,6 +40,7 @@
1130 from lp.code.interfaces.gitjob import IGitRefScanJobSource
1131 from lp.code.interfaces.gitlookup import (
1132 IGitLookup,
1133+ IGitRuleGrantLookup,
1134 IGitTraverser,
1135 )
1136 from lp.code.interfaces.gitnamespace import (
1137@@ -325,3 +326,45 @@
1138 else:
1139 # Only macaroons are supported for password authentication.
1140 return faults.Unauthorized()
1141+
1142+ def _isRepositoryOwner(self, requester, repository):
1143+ try:
1144+ return requester.inTeam(repository.owner)
1145+ except Unauthorized:
1146+ return False
1147+
1148+ def _listRefRules(self, requester, translated_path):
1149+ repository = getUtility(IGitLookup).getByHostingPath(translated_path)
1150+ grants = getUtility(IGitRuleGrantLookup).getByRulesAffectingPerson(
1151+ repository, requester)
1152+
1153+ lines = []
1154+ for grant in grants:
1155+ permissions = []
1156+ if grant.can_create:
1157+ permissions.append("create")
1158+ if grant.can_push:
1159+ permissions.append("push")
1160+ if grant.can_force_push:
1161+ permissions.append("force-push")
1162+ lines.append(
1163+ {'ref_pattern': grant.rule.ref_pattern,
1164+ 'permissions': permissions})
1165+
1166+ if self._isRepositoryOwner(requester, repository):
1167+ lines.append({
1168+ 'ref_pattern': '*',
1169+ 'permissions': ['create', 'push', 'force-push']})
1170+ return lines
1171+
1172+ def listRefRules(self, translated_path, auth_params):
1173+ """See `IGitAPI`"""
1174+ requester_id = auth_params.get("uid")
1175+ if requester_id is None:
1176+ requester_id = LAUNCHPAD_ANONYMOUS
1177+
1178+ return run_with_login(
1179+ requester_id,
1180+ self._listRefRules,
1181+ translated_path,
1182+ )
1183
1184=== modified file 'lib/lp/code/xmlrpc/tests/test_git.py'
1185--- lib/lp/code/xmlrpc/tests/test_git.py 2018-08-28 14:07:38 +0000
1186+++ lib/lp/code/xmlrpc/tests/test_git.py 2018-09-26 15:35:28 +0000
1187@@ -260,6 +260,125 @@
1188 self.assertEqual(
1189 initial_count, getUtility(IAllGitRepositories).count())
1190
1191+ def test_listRefRules(self):
1192+ # Test that GitGrantRule (ref rule) can be retrieved for a user
1193+ requester = self.factory.makePerson()
1194+ repository = removeSecurityProxy(
1195+ self.factory.makeGitRepository(
1196+ owner=requester, information_type=InformationType.USERDATA))
1197+
1198+ rule = self.factory.makeGitRule(repository)
1199+ self.factory.makeGitRuleGrant(
1200+ rule=rule, grantee=requester, can_push=True, can_create=True)
1201+
1202+ results = self.git_api.listRefRules(
1203+ repository.getInternalPath(),
1204+ {'uid': requester.id})
1205+ self.assertEqual(len(results), 2)
1206+ self.assertEqual(results[0]['ref_pattern'], 'refs/heads/*')
1207+ self.assertEqual(results[0]['permissions'], ['create', 'push'])
1208+
1209+ def test_listRefRules_no_grants(self):
1210+ # User that has no grants and is not the owner
1211+ requester = self.factory.makePerson()
1212+ owner = self.factory.makePerson()
1213+ repository = removeSecurityProxy(
1214+ self.factory.makeGitRepository(
1215+ owner=owner, information_type=InformationType.USERDATA))
1216+
1217+ rule = self.factory.makeGitRule(repository)
1218+ self.factory.makeGitRuleGrant(
1219+ rule=rule, grantee=owner, can_push=True, can_create=True)
1220+
1221+ results = self.git_api.listRefRules(
1222+ repository.getInternalPath(),
1223+ {'uid': requester.id})
1224+ self.assertEqual(len(results), 0)
1225+
1226+ def test_listRefRules_owner_has_default(self):
1227+ owner = self.factory.makePerson()
1228+ repository = removeSecurityProxy(
1229+ self.factory.makeGitRepository(
1230+ owner=owner, information_type=InformationType.USERDATA))
1231+
1232+ rule = self.factory.makeGitRule(
1233+ repository=repository, ref_pattern=u'refs/heads/master')
1234+ self.factory.makeGitRuleGrant(
1235+ rule=rule, grantee=owner, can_push=True, can_create=True)
1236+
1237+ results = self.git_api.listRefRules(
1238+ repository.getInternalPath(),
1239+ {'uid': owner.id})
1240+ self.assertEqual(len(results), 2)
1241+ # Default grant should be last in pattern
1242+ self.assertEqual(results[-1]['ref_pattern'], '*')
1243+
1244+ def test_listRefRules_owner_is_team(self):
1245+ member = self.factory.makePerson()
1246+ owner = self.factory.makeTeam(members=[member])
1247+ repository = removeSecurityProxy(
1248+ self.factory.makeGitRepository(
1249+ owner=owner, information_type=InformationType.USERDATA))
1250+
1251+ results = self.git_api.listRefRules(
1252+ repository.getInternalPath(),
1253+ {'uid': member.id})
1254+
1255+ # Should have default grant as member of owning team
1256+ self.assertEqual(len(results), 1)
1257+ self.assertEqual(results[-1]['ref_pattern'], '*')
1258+
1259+ def test_listRefRules_owner_is_team_with_grants(self):
1260+ member = self.factory.makePerson()
1261+ owner = self.factory.makeTeam(members=[member])
1262+ repository = removeSecurityProxy(
1263+ self.factory.makeGitRepository(
1264+ owner=owner, information_type=InformationType.USERDATA))
1265+
1266+ rule = self.factory.makeGitRule(
1267+ repository=repository, ref_pattern=u'refs/heads/master')
1268+ self.factory.makeGitRuleGrant(
1269+ rule=rule, grantee=owner, can_push=True, can_create=True)
1270+
1271+ results = self.git_api.listRefRules(
1272+ repository.getInternalPath(),
1273+ {'uid': member.id})
1274+
1275+ # Should have default grant as member of owning team
1276+ self.assertEqual(len(results), 2)
1277+ self.assertEqual(results[-1]['ref_pattern'], '*')
1278+
1279+ def test_listRefRules_owner_is_team_with_grants_to_person(self):
1280+ member = self.factory.makePerson()
1281+ other_member = self.factory.makePerson()
1282+ owner = self.factory.makeTeam(members=[member, other_member])
1283+ repository = removeSecurityProxy(
1284+ self.factory.makeGitRepository(
1285+ owner=owner, information_type=InformationType.USERDATA))
1286+
1287+ rule = self.factory.makeGitRule(
1288+ repository=repository, ref_pattern=u'refs/heads/master')
1289+ self.factory.makeGitRuleGrant(
1290+ rule=rule, grantee=owner, can_push=True, can_create=True)
1291+
1292+ rule = self.factory.makeGitRule(
1293+ repository=repository, ref_pattern=u'refs/heads/tags')
1294+ self.factory.makeGitRuleGrant(
1295+ rule=rule, grantee=member, can_create=True)
1296+
1297+ # This should not appear
1298+ self.factory.makeGitRuleGrant(
1299+ rule=rule, grantee=other_member, can_push=True)
1300+
1301+ results = self.git_api.listRefRules(
1302+ repository.getInternalPath(),
1303+ {'uid': member.id})
1304+
1305+ # Should have default grant as member of owning team
1306+ self.assertEqual(len(results), 3)
1307+ self.assertEqual(results[-1]['ref_pattern'], '*')
1308+ tags_rule = results[1]
1309+ self.assertEqual(tags_rule['permissions'], ['create'])
1310
1311 class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
1312 """Tests for the implementation of `IGitAPI`."""
1313
1314=== modified file 'lib/lp/registry/personmerge.py'
1315--- lib/lp/registry/personmerge.py 2015-09-16 13:30:33 +0000
1316+++ lib/lp/registry/personmerge.py 2018-09-26 15:35:28 +0000
1317@@ -1,4 +1,4 @@
1318-# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
1319+# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
1320 # GNU Affero General Public License version 3 (see the file LICENSE).
1321
1322 """Person/team merger implementation."""
1323@@ -141,6 +141,25 @@
1324 ''' % vars())
1325
1326
1327+def _mergeGitRuleGrant(cur, from_id, to_id):
1328+ # Update only the GitRuleGrants that will not conflict.
1329+ cur.execute('''
1330+ UPDATE GitRuleGrant
1331+ SET grantee=%(to_id)d
1332+ WHERE
1333+ grantee = %(from_id)d
1334+ AND rule NOT IN (
1335+ SELECT rule
1336+ FROM GitRuleGrant
1337+ WHERE grantee = %(to_id)d
1338+ )
1339+ ''' % vars())
1340+ # and delete those left over.
1341+ cur.execute('''
1342+ DELETE FROM GitRuleGrant WHERE grantee = %(from_id)d
1343+ ''' % vars())
1344+
1345+
1346 def _mergeBranches(from_person, to_person):
1347 # This shouldn't use removeSecurityProxy.
1348 branches = getUtility(IBranchCollection).ownedBy(from_person)
1349@@ -771,8 +790,10 @@
1350
1351 _mergeAccessArtifactGrant(cur, from_id, to_id)
1352 _mergeAccessPolicyGrant(cur, from_id, to_id)
1353+ _mergeGitRuleGrant(cur, from_id, to_id)
1354 skip.append(('accessartifactgrant', 'grantee'))
1355 skip.append(('accesspolicygrant', 'grantee'))
1356+ skip.append(('gitrulegrant', 'grantee'))
1357
1358 # Update the Branches that will not conflict, and fudge the names of
1359 # ones that *do* conflict.
1360
1361=== modified file 'lib/lp/registry/tests/test_personmerge.py'
1362--- lib/lp/registry/tests/test_personmerge.py 2016-06-28 21:10:18 +0000
1363+++ lib/lp/registry/tests/test_personmerge.py 2018-09-26 15:35:28 +0000
1364@@ -1,4 +1,4 @@
1365-# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
1366+# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
1367 # GNU Affero General Public License version 3 (see the file LICENSE).
1368
1369 """Tests for merge_people."""
1370@@ -494,6 +494,41 @@
1371 grantee=person,
1372 date_created=person_grant_date))
1373
1374+ def test_merge_gitrulegrants(self):
1375+ # GitRuleGrants are transferred from the duplicate.
1376+ rule = self.factory.makeGitRule()
1377+ person = self.factory.makePerson()
1378+ grant = self.factory.makeGitRuleGrant(rule=rule)
1379+ self._do_premerge(grant.grantee, person)
1380+
1381+ self.assertEqual(grant.grantee, rule.grants.one().grantee)
1382+ with person_logged_in(person):
1383+ self._do_merge(grant.grantee, person)
1384+ self.assertEqual(person, rule.grants.one().grantee)
1385+
1386+ def test_merge_gitrulegrants_conflicts(self):
1387+ # Conflicting GitRuleGrants are deleted.
1388+ rule = self.factory.makeGitRule()
1389+
1390+ person = self.factory.makePerson()
1391+ person_grant = self.factory.makeGitRuleGrant(rule=rule, grantee=person)
1392+ person_grant_date = person_grant.date_created
1393+
1394+ duplicate = self.factory.makePerson()
1395+ self.factory.makeGitRuleGrant(rule=rule, grantee=duplicate)
1396+
1397+ self._do_premerge(duplicate, person)
1398+ with person_logged_in(person):
1399+ self._do_merge(duplicate, person)
1400+
1401+ # Only one grant for the rule exists: the retained person's.
1402+ self.assertThat(
1403+ rule.grants.one(),
1404+ MatchesStructure.byEquality(
1405+ rule=rule,
1406+ grantee=person,
1407+ date_created=person_grant_date))
1408+
1409 def test_mergeAsync(self):
1410 # mergeAsync() creates a new `PersonMergeJob`.
1411 from_person = self.factory.makePerson()
1412
1413=== modified file 'lib/lp/security.py'
1414--- lib/lp/security.py 2018-09-15 11:38:45 +0000
1415+++ lib/lp/security.py 2018-09-26 15:35:28 +0000
1416@@ -87,6 +87,10 @@
1417 IGitRepository,
1418 user_has_special_git_repository_access,
1419 )
1420+from lp.code.interfaces.gitrule import (
1421+ IGitRule,
1422+ IGitRuleGrant,
1423+ )
1424 from lp.code.interfaces.sourcepackagerecipe import ISourcePackageRecipe
1425 from lp.code.interfaces.sourcepackagerecipebuild import (
1426 ISourcePackageRecipeBuild,
1427@@ -2343,6 +2347,42 @@
1428 super(EditGitRef, self).__init__(obj, obj.repository)
1429
1430
1431+class ViewGitRule(DelegatedAuthorization):
1432+ """Anyone who can see a Git repository can see its access rules."""
1433+ permission = 'launchpad.View'
1434+ usedfor = IGitRule
1435+
1436+ def __init__(self, obj):
1437+ super(ViewGitRule, self).__init__(obj, obj.repository)
1438+
1439+
1440+class EditGitRule(DelegatedAuthorization):
1441+ """Anyone who can edit a Git repository can edit its access rules."""
1442+ permission = 'launchpad.Edit'
1443+ usedfor = IGitRule
1444+
1445+ def __init__(self, obj):
1446+ super(EditGitRule, self).__init__(obj, obj.repository)
1447+
1448+
1449+class ViewGitRuleGrant(DelegatedAuthorization):
1450+ """Anyone who can see a Git repository can see its access grants."""
1451+ permission = 'launchpad.View'
1452+ usedfor = IGitRuleGrant
1453+
1454+ def __init__(self, obj):
1455+ super(ViewGitRuleGrant, self).__init__(obj, obj.repository)
1456+
1457+
1458+class EditGitRuleGrant(DelegatedAuthorization):
1459+ """Anyone who can edit a Git repository can edit its access grants."""
1460+ permission = 'launchpad.Edit'
1461+ usedfor = IGitRuleGrant
1462+
1463+ def __init__(self, obj):
1464+ super(EditGitRuleGrant, self).__init__(obj, obj.repository)
1465+
1466+
1467 class AdminDistroSeriesTranslations(AuthorizationBase):
1468 permission = 'launchpad.TranslationsAdmin'
1469 usedfor = IDistroSeries
1470
1471=== modified file 'lib/lp/testing/factory.py'
1472--- lib/lp/testing/factory.py 2018-09-13 15:21:05 +0000
1473+++ lib/lp/testing/factory.py 2018-09-26 15:35:28 +0000
1474@@ -1828,6 +1828,31 @@
1475 path = self.getUniqueString('refs/heads/path').decode('utf-8')
1476 return getUtility(IGitRefRemoteSet).new(repository_url, path)
1477
1478+ def makeGitRule(self, repository=None, ref_pattern=u"refs/heads/*",
1479+ creator=None, position=None, **repository_kwargs):
1480+ """Create a Git repository access rule."""
1481+ if repository is None:
1482+ repository = self.makeGitRepository(**repository_kwargs)
1483+ if creator is None:
1484+ creator = repository.owner
1485+ with person_logged_in(creator):
1486+ return repository.addRule(ref_pattern, creator, position=position)
1487+
1488+ def makeGitRuleGrant(self, rule=None, grantee=None, grantor=None,
1489+ can_create=False, can_push=False,
1490+ can_force_push=False):
1491+ """Create a Git repository access grant."""
1492+ if rule is None:
1493+ rule = self.makeGitRule()
1494+ if grantee is None:
1495+ grantee = self.makePerson()
1496+ if grantor is None:
1497+ grantor = rule.repository.owner
1498+ with person_logged_in(grantor):
1499+ return rule.addGrant(
1500+ grantee, grantor, can_create=can_create, can_push=can_push,
1501+ can_force_push=can_force_push)
1502+
1503 def makeBug(self, target=None, owner=None, bug_watch_url=None,
1504 information_type=None, date_closed=None, title=None,
1505 date_created=None, description=None, comment=None,
1506
1507=== modified file 'scripts/close-account.py'
1508--- scripts/close-account.py 2018-05-08 13:44:21 +0000
1509+++ scripts/close-account.py 2018-09-26 15:35:28 +0000
1510@@ -1,6 +1,6 @@
1511 #!/usr/bin/python -S
1512 #
1513-# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
1514+# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
1515 # GNU Affero General Public License version 3 (see the file LICENSE).
1516
1517 """Remove personal details of a user from the database, leaving a stub."""
1518@@ -162,6 +162,9 @@
1519
1520 # Pending items in queues
1521 ('POExportRequest', 'person'),
1522+
1523+ # Access grants
1524+ ('GitRuleGrant', 'grantee'),
1525 ]
1526 for table, person_id_column in removals:
1527 table_notification(table)