Merge lp:~cjwatson/launchpad/git-permissions-ui-edit into lp:launchpad

Proposed by Colin Watson on 2018-11-09
Status: Merged
Merged at revision: 18855
Proposed branch: lp:~cjwatson/launchpad/git-permissions-ui-edit
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/git-grantee-widgets
Diff against target: 1630 lines (+1417/-7)
7 files modified
lib/lp/code/browser/configure.zcml (+6/-0)
lib/lp/code/browser/gitrepository.py (+525/-5)
lib/lp/code/browser/tests/test_gitrepository.py (+641/-2)
lib/lp/code/interfaces/gitrule.py (+4/-0)
lib/lp/code/model/gitrule.py (+7/-0)
lib/lp/code/model/tests/test_gitrule.py (+2/-0)
lib/lp/code/templates/gitrepository-permissions.pt (+232/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/git-permissions-ui-edit
Reviewer Review Type Date Requested Status
William Grant code 2018-11-09 Approve on 2019-01-09
Review via email: mp+358582@code.launchpad.net

This proposal supersedes a proposal from 2018-10-31.

Commit message

Add a Git repository permissions view.

Description of the change

This was very substantially complicated by not wanting to invest the time in a JS-based interface just yet (and wanting to have a non-JS fallback, in any case). Given that, we need to be able to cram everything into a single form which can be submitted in one go and specify the new state of the entire permissions structure. The UI-level separation between protected branches and protected tags also makes things hard, particularly when it comes to lining up columns consistently. This is the best I was able to do given those constraints; some bits are quite ugly (especially rule positions), but I think it's tolerable, and it should allow for future JS-based enhancement.

Example screenshot: https://people.canonical.com/~cjwatson/tmp/lp-git-repository-permissions.png

To post a comment you must log in.
William Grant (wgrant) :
review: Approve (code)
18812. By Colin Watson on 2019-01-09

Merge devel, with a tweak to TestGitRepositoryPermissionsView._matchesRuleGrant.

18813. By Colin Watson on 2019-01-09

Remove some unnecessary property caching.

18814. By Colin Watson on 2019-01-09

Clarify GitRepositoryPermissionsView._getRuleGrants.

18815. By Colin Watson on 2019-01-09

Reduce hardcoding of refs/heads/ and refs/tags/.

18816. By Colin Watson on 2019-01-09

Tighten TestGitRepositoryPermissionsView.test_save_delete_rule.

18817. By Colin Watson on 2019-01-09

Split GitRepositoryPermissionsView.updateRepositoryFromData into parse and apply methods.

18818. By Colin Watson on 2019-01-10

Make layout of permissions tables a bit more readable.

18819. By Colin Watson on 2019-01-10

Clarify "Effective permissions" documentation slightly.

Colin Watson (cjwatson) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/code/browser/configure.zcml'
2--- lib/lp/code/browser/configure.zcml 2018-11-08 11:40:25 +0000
3+++ lib/lp/code/browser/configure.zcml 2019-01-10 10:50:57 +0000
4@@ -871,6 +871,12 @@
5 </class>
6 <browser:page
7 for="lp.code.interfaces.gitrepository.IGitRepository"
8+ class="lp.code.browser.gitrepository.GitRepositoryPermissionsView"
9+ name="+permissions"
10+ permission="launchpad.Edit"
11+ template="../templates/gitrepository-permissions.pt"/>
12+ <browser:page
13+ for="lp.code.interfaces.gitrepository.IGitRepository"
14 class="lp.code.browser.gitrepository.GitRepositoryDeletionView"
15 permission="launchpad.Edit"
16 name="+delete"
17
18=== modified file 'lib/lp/code/browser/gitrepository.py'
19--- lib/lp/code/browser/gitrepository.py 2018-11-08 15:53:56 +0000
20+++ lib/lp/code/browser/gitrepository.py 2019-01-10 10:50:57 +0000
21@@ -1,4 +1,4 @@
22-# Copyright 2015-2018 Canonical Ltd. This software is licensed under the
23+# Copyright 2015-2019 Canonical Ltd. This software is licensed under the
24 # GNU Affero General Public License version 3 (see the file LICENSE).
25
26 """Git repository views."""
27@@ -17,10 +17,14 @@
28 'GitRepositoryEditReviewerView',
29 'GitRepositoryEditView',
30 'GitRepositoryNavigation',
31+ 'GitRepositoryPermissionsView',
32 'GitRepositoryURL',
33 'GitRepositoryView',
34 ]
35
36+import base64
37+from collections import defaultdict
38+
39 from lazr.lifecycle.event import ObjectModifiedEvent
40 from lazr.lifecycle.snapshot import Snapshot
41 from lazr.restful.interface import (
42@@ -34,14 +38,21 @@
43 from zope.component import getUtility
44 from zope.event import notify
45 from zope.formlib import form
46+from zope.formlib.textwidgets import IntWidget
47+from zope.formlib.widget import CustomWidgetFactory
48 from zope.interface import (
49 implementer,
50 Interface,
51 providedBy,
52 )
53 from zope.publisher.interfaces.browser import IBrowserPublisher
54-from zope.schema import Choice
55+from zope.schema import (
56+ Bool,
57+ Choice,
58+ Int,
59+ )
60 from zope.schema.vocabulary import (
61+ getVocabularyRegistry,
62 SimpleTerm,
63 SimpleVocabulary,
64 )
65@@ -53,7 +64,10 @@
66 LaunchpadEditFormView,
67 LaunchpadFormView,
68 )
69-from lp.app.errors import NotFoundError
70+from lp.app.errors import (
71+ NotFoundError,
72+ UnexpectedFormData,
73+ )
74 from lp.app.vocabularies import InformationTypeVocabulary
75 from lp.app.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription
76 from lp.code.browser.branch import CodeEditOwnerMixin
77@@ -62,11 +76,19 @@
78 )
79 from lp.code.browser.codeimport import CodeImportTargetMixin
80 from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
81+from lp.code.browser.widgets.gitgrantee import (
82+ GitGranteeDisplayWidget,
83+ GitGranteeField,
84+ GitGranteeWidget,
85+ )
86 from lp.code.browser.widgets.gitrepositorytarget import (
87 GitRepositoryTargetDisplayWidget,
88 GitRepositoryTargetWidget,
89 )
90-from lp.code.enums import GitRepositoryType
91+from lp.code.enums import (
92+ GitGranteeType,
93+ GitRepositoryType,
94+ )
95 from lp.code.errors import (
96 GitDefaultConflict,
97 GitRepositoryCreationForbidden,
98@@ -76,6 +98,7 @@
99 from lp.code.interfaces.gitnamespace import get_git_namespace
100 from lp.code.interfaces.gitref import IGitRefBatchNavigator
101 from lp.code.interfaces.gitrepository import IGitRepository
102+from lp.code.vocabularies.gitrule import GitPermissionsVocabulary
103 from lp.registry.interfaces.person import (
104 IPerson,
105 IPersonSet,
106@@ -84,6 +107,7 @@
107 from lp.services.config import config
108 from lp.services.database.constants import UTC_NOW
109 from lp.services.features import getFeatureFlag
110+from lp.services.fields import UniqueField
111 from lp.services.propertycache import cachedproperty
112 from lp.services.webapp import (
113 canonical_url,
114@@ -105,6 +129,7 @@
115 from lp.services.webapp.escaping import structured
116 from lp.services.webapp.interfaces import ICanonicalUrlData
117 from lp.services.webapp.publisher import DataDownloadView
118+from lp.services.webapp.snapshot import notify_modified
119 from lp.services.webhooks.browser import WebhookTargetNavigationMixin
120 from lp.snappy.browser.hassnaps import HasSnapsViewMixin
121
122@@ -211,7 +236,14 @@
123 usedfor = IGitRepository
124 facet = "branches"
125 title = "Edit Git repository"
126- links = ["edit", "reviewer", "webhooks", "activity", "delete"]
127+ links = [
128+ "edit",
129+ "reviewer",
130+ "permissions",
131+ "activity",
132+ "webhooks",
133+ "delete",
134+ ]
135
136 @enabled_with_permission("launchpad.Edit")
137 def edit(self):
138@@ -224,6 +256,11 @@
139 return Link("+reviewer", text, icon="edit")
140
141 @enabled_with_permission("launchpad.Edit")
142+ def permissions(self):
143+ text = "Manage permissions"
144+ return Link("+permissions", text, icon="edit")
145+
146+ @enabled_with_permission("launchpad.Edit")
147 def webhooks(self):
148 text = "Manage webhooks"
149 return Link(
150@@ -709,6 +746,489 @@
151 return self, ()
152
153
154+def encode_form_field_id(value):
155+ """Encode text for use in form field names.
156+
157+ We use a modified version of base32 which fits into CSS identifiers and
158+ so doesn't cause FormattersAPI.zope_css_id to do unhelpful things.
159+ """
160+ return base64.b32encode(
161+ value.encode("UTF-8")).decode("UTF-8").replace("=", "_")
162+
163+
164+def decode_form_field_id(encoded):
165+ """Inverse of `encode_form_field_id`."""
166+ return base64.b32decode(
167+ encoded.replace("_", "=").encode("UTF-8")).decode("UTF-8")
168+
169+
170+class GitRulePatternField(UniqueField):
171+
172+ errormessage = _("%s is already in use by another rule")
173+ attribute = "ref_pattern"
174+ _content_iface = IGitRepository
175+
176+ def __init__(self, ref_prefix, rule=None, *args, **kwargs):
177+ self.ref_prefix = ref_prefix
178+ self.rule = rule
179+ super(GitRulePatternField, self).__init__(*args, **kwargs)
180+
181+ def _getByAttribute(self, ref_pattern):
182+ """See `UniqueField`."""
183+ if self._content_iface.providedBy(self.context):
184+ return self.context.getRule(self.ref_prefix + ref_pattern)
185+ else:
186+ return None
187+
188+ def unchanged(self, input):
189+ """See `UniqueField`."""
190+ return (
191+ self.rule is not None and
192+ self.ref_prefix + input == self.rule.ref_pattern)
193+
194+ def set(self, object, value):
195+ """See `IField`."""
196+ if value is not None:
197+ value = value.strip()
198+ super(GitRulePatternField, self).set(object, value)
199+
200+
201+class GitRepositoryPermissionsView(LaunchpadFormView):
202+ """A view to manage repository permissions."""
203+
204+ heads_prefix = u"refs/heads/"
205+ tags_prefix = u"refs/tags/"
206+
207+ @property
208+ def label(self):
209+ return "Manage permissions for %s" % self.context.identity
210+
211+ page_title = "Manage permissions"
212+
213+ @property
214+ def repository(self):
215+ return self.context
216+
217+ @cachedproperty
218+ def rules(self):
219+ return self.repository.getRules()
220+
221+ @property
222+ def branch_rules(self):
223+ return [
224+ rule for rule in self.rules
225+ if rule.ref_pattern.startswith(self.heads_prefix)]
226+
227+ @property
228+ def tag_rules(self):
229+ return [
230+ rule for rule in self.rules
231+ if rule.ref_pattern.startswith(self.tags_prefix)]
232+
233+ @property
234+ def other_rules(self):
235+ return [
236+ rule for rule in self.rules
237+ if not rule.ref_pattern.startswith(self.heads_prefix) and
238+ not rule.ref_pattern.startswith(self.tags_prefix)]
239+
240+ def _getRuleGrants(self, rule):
241+ def grantee_key(grant):
242+ if grant.grantee is not None:
243+ return (grant.grantee_type, grant.grantee.name)
244+ else:
245+ return (grant.grantee_type,)
246+
247+ return sorted(rule.grants, key=grantee_key)
248+
249+ def _parseRefPattern(self, ref_pattern):
250+ """Parse a pattern into a prefix and the displayed portion."""
251+ for prefix in (self.heads_prefix, self.tags_prefix):
252+ if ref_pattern.startswith(prefix):
253+ return prefix, ref_pattern[len(prefix):]
254+ return u"", ref_pattern
255+
256+ def _getFieldName(self, name, ref_pattern, grantee=None):
257+ """Get the combined field name for a ref pattern and optional grantee.
258+
259+ In order to be able to render a permissions table, we encode the ref
260+ pattern and the grantee in the form field name.
261+ """
262+ suffix = "." + encode_form_field_id(ref_pattern)
263+ if grantee is not None:
264+ if IPerson.providedBy(grantee):
265+ suffix += "." + str(grantee.id)
266+ else:
267+ suffix += "._" + grantee.name.lower()
268+ return name + suffix
269+
270+ def _parseFieldName(self, field_name):
271+ """Parse a combined field name as described in `_getFieldName`.
272+
273+ :raises UnexpectedFormData: if the field name cannot be parsed or
274+ the grantee cannot be found.
275+ """
276+ field_bits = field_name.split(".")
277+ if len(field_bits) < 2:
278+ raise UnexpectedFormData(
279+ "Cannot parse field name: %s" % field_name)
280+ field_type = field_bits[0]
281+ try:
282+ ref_pattern = decode_form_field_id(field_bits[1])
283+ except TypeError:
284+ raise UnexpectedFormData(
285+ "Cannot parse field name: %s" % field_name)
286+ if len(field_bits) > 2:
287+ grantee_id = field_bits[2]
288+ if grantee_id.startswith("_"):
289+ grantee_id = grantee_id[1:]
290+ try:
291+ grantee = GitGranteeType.getTermByToken(grantee_id).value
292+ except LookupError:
293+ grantee = None
294+ else:
295+ try:
296+ grantee_id = int(grantee_id)
297+ except ValueError:
298+ grantee = None
299+ else:
300+ grantee = getUtility(IPersonSet).get(grantee_id)
301+ if grantee is None or grantee == GitGranteeType.PERSON:
302+ raise UnexpectedFormData("No such grantee: %s" % grantee_id)
303+ else:
304+ grantee = None
305+ return field_type, ref_pattern, grantee
306+
307+ def _getPermissionsTerm(self, grant):
308+ """Return a term from `GitPermissionsVocabulary` for this grant."""
309+ vocabulary = getVocabularyRegistry().get(grant, "GitPermissions")
310+ try:
311+ return vocabulary.getTerm(grant.permissions)
312+ except LookupError:
313+ # This should never happen, because GitPermissionsVocabulary
314+ # adds a custom term for the context grant if necessary.
315+ raise AssertionError(
316+ "Could not find GitPermissions term for %r" % grant)
317+
318+ def setUpFields(self):
319+ """See `LaunchpadFormView`."""
320+ position_fields = []
321+ pattern_fields = []
322+ delete_fields = []
323+ readonly_grantee_fields = []
324+ grantee_fields = []
325+ permissions_fields = []
326+
327+ default_permissions_by_prefix = {
328+ self.heads_prefix: "can_push",
329+ self.tags_prefix: "can_create",
330+ "": "can_push",
331+ }
332+
333+ for rule_index, rule in enumerate(self.rules):
334+ # Remove the usual branch/tag prefixes from patterns. The full
335+ # pattern goes into form field names, so no data is lost here.
336+ ref_pattern = rule.ref_pattern
337+ ref_prefix, short_pattern = self._parseRefPattern(ref_pattern)
338+ position_fields.append(
339+ Int(
340+ __name__=self._getFieldName("position", ref_pattern),
341+ required=True, readonly=False, default=rule_index + 1))
342+ pattern_fields.append(
343+ GitRulePatternField(
344+ __name__=self._getFieldName("pattern", ref_pattern),
345+ required=True, readonly=False, ref_prefix=ref_prefix,
346+ rule=rule, default=short_pattern))
347+ delete_fields.append(
348+ Bool(
349+ __name__=self._getFieldName("delete", ref_pattern),
350+ readonly=False, default=False))
351+ for grant in self._getRuleGrants(rule):
352+ grantee = grant.combined_grantee
353+ readonly_grantee_fields.append(
354+ GitGranteeField(
355+ __name__=self._getFieldName(
356+ "grantee", ref_pattern, grantee),
357+ required=False, readonly=True, default=grantee,
358+ rule=rule))
359+ permissions_fields.append(
360+ Choice(
361+ __name__=self._getFieldName(
362+ "permissions", ref_pattern, grantee),
363+ source=GitPermissionsVocabulary(grant),
364+ readonly=False,
365+ default=self._getPermissionsTerm(grant).value))
366+ delete_fields.append(
367+ Bool(
368+ __name__=self._getFieldName(
369+ "delete", ref_pattern, grantee),
370+ readonly=False, default=False))
371+ grantee_fields.append(
372+ GitGranteeField(
373+ __name__=self._getFieldName("grantee", ref_pattern),
374+ required=False, readonly=False, rule=rule))
375+ permissions_vocabulary = GitPermissionsVocabulary(rule)
376+ permissions_fields.append(
377+ Choice(
378+ __name__=self._getFieldName(
379+ "permissions", ref_pattern),
380+ source=permissions_vocabulary, readonly=False,
381+ default=permissions_vocabulary.getTermByToken(
382+ default_permissions_by_prefix[ref_prefix]).value))
383+ for ref_prefix in (self.heads_prefix, self.tags_prefix):
384+ position_fields.append(
385+ Int(
386+ __name__=self._getFieldName("new-position", ref_prefix),
387+ required=False, readonly=True))
388+ pattern_fields.append(
389+ GitRulePatternField(
390+ __name__=self._getFieldName("new-pattern", ref_prefix),
391+ required=False, readonly=False, ref_prefix=ref_prefix))
392+
393+ self.form_fields = (
394+ form.FormFields(
395+ *position_fields,
396+ custom_widget=CustomWidgetFactory(IntWidget, displayWidth=2)) +
397+ form.FormFields(*pattern_fields) +
398+ form.FormFields(*delete_fields) +
399+ form.FormFields(
400+ *readonly_grantee_fields,
401+ custom_widget=CustomWidgetFactory(GitGranteeDisplayWidget)) +
402+ form.FormFields(
403+ *grantee_fields,
404+ custom_widget=CustomWidgetFactory(GitGranteeWidget)) +
405+ form.FormFields(*permissions_fields))
406+
407+ def setUpWidgets(self, context=None):
408+ """See `LaunchpadFormView`."""
409+ super(GitRepositoryPermissionsView, self).setUpWidgets(
410+ context=context)
411+ for widget in self.widgets:
412+ widget.display_label = False
413+ widget.hint = None
414+
415+ @property
416+ def cancel_url(self):
417+ return canonical_url(self.context)
418+
419+ def getRuleWidgets(self, rule):
420+ widgets_by_name = {widget.name: widget for widget in self.widgets}
421+ ref_pattern = rule.ref_pattern
422+ position_field_name = (
423+ "field." + self._getFieldName("position", ref_pattern))
424+ pattern_field_name = (
425+ "field." + self._getFieldName("pattern", ref_pattern))
426+ delete_field_name = (
427+ "field." + self._getFieldName("delete", ref_pattern))
428+ grant_widgets = []
429+ for grant in self._getRuleGrants(rule):
430+ grantee = grant.combined_grantee
431+ grantee_field_name = (
432+ "field." + self._getFieldName("grantee", ref_pattern, grantee))
433+ permissions_field_name = (
434+ "field." +
435+ self._getFieldName("permissions", ref_pattern, grantee))
436+ delete_grant_field_name = (
437+ "field." + self._getFieldName("delete", ref_pattern, grantee))
438+ grant_widgets.append({
439+ "grantee": widgets_by_name[grantee_field_name],
440+ "permissions": widgets_by_name[permissions_field_name],
441+ "delete": widgets_by_name[delete_grant_field_name],
442+ })
443+ new_grantee_field_name = (
444+ "field." + self._getFieldName("grantee", ref_pattern))
445+ new_permissions_field_name = (
446+ "field." + self._getFieldName("permissions", ref_pattern))
447+ new_grant_widgets = {
448+ "grantee": widgets_by_name[new_grantee_field_name],
449+ "permissions": widgets_by_name[new_permissions_field_name],
450+ }
451+ return {
452+ "position": widgets_by_name[position_field_name],
453+ "pattern": widgets_by_name[pattern_field_name],
454+ "delete": widgets_by_name.get(delete_field_name),
455+ "grants": grant_widgets,
456+ "new_grant": new_grant_widgets,
457+ }
458+
459+ def getNewRuleWidgets(self, ref_prefix):
460+ widgets_by_name = {widget.name: widget for widget in self.widgets}
461+ new_position_field_name = (
462+ "field." + self._getFieldName("new-position", ref_prefix))
463+ new_pattern_field_name = (
464+ "field." + self._getFieldName("new-pattern", ref_prefix))
465+ return {
466+ "position": widgets_by_name[new_position_field_name],
467+ "pattern": widgets_by_name[new_pattern_field_name],
468+ }
469+
470+ def parseData(self, data):
471+ """Rearrange form data to make it easier to process."""
472+ parsed_data = {
473+ "rules": {},
474+ "grants": defaultdict(list),
475+ }
476+
477+ for field_name in sorted(
478+ name for name in data if name.split(".")[0] == "pattern"):
479+ _, ref_pattern, _ = self._parseFieldName(field_name)
480+ prefix, _ = self._parseRefPattern(ref_pattern)
481+ position_field_name = self._getFieldName("position", ref_pattern)
482+ delete_field_name = self._getFieldName("delete", ref_pattern)
483+ if data.get(delete_field_name):
484+ position = None
485+ action = "delete"
486+ else:
487+ position = max(0, data[position_field_name] - 1)
488+ action = "change"
489+ parsed_data["rules"].setdefault(ref_pattern, {
490+ "position": position,
491+ "pattern": ref_pattern,
492+ "new_pattern": prefix + data[field_name],
493+ "action": action,
494+ })
495+
496+ for field_name in sorted(
497+ name for name in data if name.split(".")[0] == "new-pattern"):
498+ _, prefix, _ = self._parseFieldName(field_name)
499+ position_field_name = self._getFieldName("position", prefix)
500+ if not data[field_name]:
501+ continue
502+ if position_field_name in data:
503+ position = max(0, data[position_field_name] - 1)
504+ else:
505+ position = None
506+ parsed_data["rules"].setdefault(prefix, {
507+ "position": position,
508+ "pattern": prefix + data[field_name],
509+ "action": "add",
510+ })
511+
512+ for field_name in sorted(
513+ name for name in data if name.split(".")[0] == "permissions"):
514+ _, ref_pattern, grantee = self._parseFieldName(field_name)
515+ grantee_field_name = self._getFieldName("grantee", ref_pattern)
516+ delete_field_name = self._getFieldName(
517+ "delete", ref_pattern, grantee)
518+ if grantee is None:
519+ grantee = data.get(grantee_field_name)
520+ if grantee is None:
521+ continue
522+ action = "add"
523+ elif data.get(delete_field_name):
524+ action = "delete"
525+ else:
526+ action = "change"
527+ parsed_data["grants"][ref_pattern].append({
528+ "grantee": grantee,
529+ "permissions": data[field_name],
530+ "action": action,
531+ })
532+
533+ return parsed_data
534+
535+ def updateRepositoryFromData(self, repository, parsed_data):
536+ # Fetch rules before making any changes, since their ref_patterns
537+ # may change as a result of this update.
538+ rule_map = {rule.ref_pattern: rule for rule in self.repository.rules}
539+ grant_map = {
540+ (grant.rule.ref_pattern, grant.combined_grantee): grant
541+ for grant in self.repository.grants}
542+
543+ # Patterns must be processed in rule order so that position changes
544+ # work in a reasonably natural way. Process new rules last.
545+ ordered_rules = []
546+ for ref_pattern, parsed_rule in parsed_data["rules"].items():
547+ rule = rule_map.get(ref_pattern)
548+ if parsed_rule["action"] == "add":
549+ ordered_rules.append((ref_pattern, parsed_rule, -1))
550+ elif rule is not None:
551+ # Ignore attempts to change or delete rules that have
552+ # already been deleted by somebody else.
553+ ordered_rules.append((ref_pattern, parsed_rule, rule.position))
554+ ordered_rules.sort(
555+ key=lambda item: (item[1]["action"] != "add", item[2]))
556+
557+ for ref_pattern, parsed_rule, position in ordered_rules:
558+ rule = rule_map.get(parsed_rule["pattern"])
559+ action = parsed_rule["action"]
560+ if action not in ("add", "change", "delete"):
561+ raise AssertionError(
562+ "unknown action: %s" % parsed_rule["action"])
563+
564+ if action == "add" and rule is None:
565+ rule = repository.addRule(
566+ parsed_rule["pattern"], self.user,
567+ position=parsed_rule["position"])
568+ if ref_pattern == self.tags_prefix:
569+ # Tags are a special case: on creation, they
570+ # automatically get a grant of create permissions to
571+ # the repository owner (suppressing the normal
572+ # ability of the repository owner to push protected
573+ # references).
574+ rule.addGrant(
575+ GitGranteeType.REPOSITORY_OWNER, self.user,
576+ can_create=True)
577+ elif action == "change" and rule is not None:
578+ self.repository.moveRule(
579+ rule, parsed_rule["position"], self.user)
580+ if parsed_rule["new_pattern"] != rule.ref_pattern:
581+ with notify_modified(rule, ["ref_pattern"]):
582+ rule.ref_pattern = parsed_rule["new_pattern"]
583+ elif action == "delete" and rule is not None:
584+ rule.destroySelf(self.user)
585+ rule_map[parsed_rule["pattern"]] = None
586+ else:
587+ raise AssertionError(
588+ "unknown action: %s" % parsed_rule["action"])
589+
590+ for ref_pattern, parsed_grants in sorted(
591+ parsed_data["grants"].items()):
592+ if ref_pattern not in rule_map:
593+ self.addError(structured(
594+ "Cannot edit grants for nonexistent rule %s", ref_pattern))
595+ return
596+ rule = rule_map.get(ref_pattern)
597+ if rule is None:
598+ # Already deleted.
599+ continue
600+
601+ for parsed_grant in parsed_grants:
602+ grant = grant_map.get((ref_pattern, parsed_grant["grantee"]))
603+ action = parsed_grant["action"]
604+ if action not in ("add", "change", "delete"):
605+ raise AssertionError(
606+ "unknown action: %s" % parsed_rule["action"])
607+
608+ if action == "add" and grant is None:
609+ rule.addGrant(
610+ parsed_grant["grantee"], self.user,
611+ permissions=parsed_grant["permissions"])
612+ elif (action in ("add", "change") and grant is not None and
613+ parsed_grant["permissions"] != grant.permissions):
614+ # Make the requested changes. This can happen in the
615+ # add case if somebody else added the grant since the
616+ # form was last rendered, in which case updating it with
617+ # the permissions from this request seems best.
618+ with notify_modified(
619+ grant,
620+ ["can_create", "can_push", "can_force_push"]):
621+ grant.permissions = parsed_grant["permissions"]
622+ elif action == "delete" and grant is not None:
623+ grant.destroySelf(self.user)
624+ grant_map[(ref_pattern, parsed_grant["grantee"])] = None
625+
626+ @action("Save", name="save")
627+ def save_action(self, action, data):
628+ with notify_modified(self.repository, []):
629+ parsed_data = self.parseData(data)
630+ self.updateRepositoryFromData(self.repository, parsed_data)
631+
632+ self.request.response.addNotification(
633+ "Saved permissions for %s" % self.context.identity)
634+ self.next_url = canonical_url(self.context, view_name="+permissions")
635+
636+
637 class GitRepositoryDeletionView(LaunchpadFormView):
638
639 schema = IGitRepository
640
641=== modified file 'lib/lp/code/browser/tests/test_gitrepository.py'
642--- lib/lp/code/browser/tests/test_gitrepository.py 2018-11-08 15:33:03 +0000
643+++ lib/lp/code/browser/tests/test_gitrepository.py 2019-01-10 10:50:57 +0000
644@@ -1,4 +1,4 @@
645-# Copyright 2015-2018 Canonical Ltd. This software is licensed under the
646+# Copyright 2015-2019 Canonical Ltd. This software is licensed under the
647 # GNU Affero General Public License version 3 (see the file LICENSE).
648
649 """Unit tests for GitRepositoryView."""
650@@ -7,16 +7,27 @@
651
652 __metaclass__ = type
653
654+import base64
655 from datetime import datetime
656 import doctest
657+from itertools import chain
658+from operator import attrgetter
659+import re
660 from textwrap import dedent
661
662 from fixtures import FakeLogger
663 import pytz
664+import soupmatchers
665 from storm.store import Store
666 from testtools.matchers import (
667+ AfterPreprocessing,
668 DocTestMatches,
669 Equals,
670+ Is,
671+ MatchesDict,
672+ MatchesListwise,
673+ MatchesSetwise,
674+ MatchesStructure,
675 )
676 import transaction
677 from zope.component import getUtility
678@@ -26,11 +37,16 @@
679 from zope.security.proxy import removeSecurityProxy
680
681 from lp.app.enums import InformationType
682+from lp.app.errors import UnexpectedFormData
683 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
684 from lp.app.interfaces.services import IService
685+from lp.code.browser.gitrepository import encode_form_field_id
686 from lp.code.enums import (
687 BranchMergeProposalStatus,
688 CodeReviewVote,
689+ GitActivityType,
690+ GitGranteeType,
691+ GitPermissionType,
692 GitRepositoryType,
693 )
694 from lp.code.interfaces.revision import IRevisionSet
695@@ -40,7 +56,10 @@
696 VCSType,
697 )
698 from lp.registry.interfaces.accesspolicy import IAccessPolicySource
699-from lp.registry.interfaces.person import PersonVisibility
700+from lp.registry.interfaces.person import (
701+ IPerson,
702+ PersonVisibility,
703+ )
704 from lp.services.beautifulsoup import BeautifulSoup
705 from lp.services.database.constants import UTC_NOW
706 from lp.services.features.testing import FeatureFixture
707@@ -67,6 +86,7 @@
708 from lp.testing.pages import (
709 extract_text,
710 find_tag_by_id,
711+ find_tags_by_class,
712 get_feedback_messages,
713 setupBrowser,
714 setupBrowserForUser,
715@@ -1097,6 +1117,625 @@
716 browser.headers["Content-Disposition"])
717
718
719+class TestGitRepositoryPermissionsView(BrowserTestCase):
720+
721+ layer = DatabaseFunctionalLayer
722+
723+ def test_rules_properties(self):
724+ repository = self.factory.makeGitRepository()
725+ heads_rule = self.factory.makeGitRule(
726+ repository=repository, ref_pattern="refs/heads/*")
727+ tags_rule = self.factory.makeGitRule(
728+ repository=repository, ref_pattern="refs/tags/*")
729+ catch_all_rule = self.factory.makeGitRule(
730+ repository=repository, ref_pattern="*")
731+ login_person(repository.owner)
732+ view = create_initialized_view(repository, name="+permissions")
733+ self.assertEqual([heads_rule], view.branch_rules)
734+ self.assertEqual([tags_rule], view.tag_rules)
735+ self.assertEqual([catch_all_rule], view.other_rules)
736+
737+ def test__getRuleGrants(self):
738+ rule = self.factory.makeGitRule()
739+ grantees = sorted(
740+ [self.factory.makePerson() for _ in range(3)],
741+ key=attrgetter("name"))
742+ for grantee in (grantees[1], grantees[0], grantees[2]):
743+ self.factory.makeGitRuleGrant(rule=rule, grantee=grantee)
744+ self.factory.makeGitRuleGrant(
745+ rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER)
746+ login_person(rule.repository.owner)
747+ view = create_initialized_view(rule.repository, name="+permissions")
748+ self.assertThat(view._getRuleGrants(rule), MatchesListwise([
749+ MatchesStructure.byEquality(
750+ grantee_type=GitGranteeType.REPOSITORY_OWNER),
751+ MatchesStructure.byEquality(grantee=grantees[0]),
752+ MatchesStructure.byEquality(grantee=grantees[1]),
753+ MatchesStructure.byEquality(grantee=grantees[2]),
754+ ]))
755+
756+ def test__parseRefPattern(self):
757+ repository = self.factory.makeGitRepository()
758+ login_person(repository.owner)
759+ view = create_initialized_view(repository, name="+permissions")
760+ self.assertEqual(
761+ ("refs/heads/", "stable/*"),
762+ view._parseRefPattern("refs/heads/stable/*"))
763+ self.assertEqual(
764+ ("refs/tags/", "1.0"), view._parseRefPattern("refs/tags/1.0"))
765+ self.assertEqual(
766+ ("", "refs/other/*"), view._parseRefPattern("refs/other/*"))
767+ self.assertEqual(("", "*"), view._parseRefPattern("*"))
768+
769+ def test__getFieldName_no_grantee(self):
770+ repository = self.factory.makeGitRepository()
771+ login_person(repository.owner)
772+ view = create_initialized_view(repository, name="+permissions")
773+ encoded_ref_pattern = base64.b32encode(
774+ b"refs/heads/*").replace("=", "_").decode("UTF-8")
775+ self.assertEqual(
776+ "field.%s" % encoded_ref_pattern,
777+ view._getFieldName("field", "refs/heads/*"))
778+
779+ def test__getFieldName_grantee_repository_owner(self):
780+ repository = self.factory.makeGitRepository()
781+ login_person(repository.owner)
782+ view = create_initialized_view(repository, name="+permissions")
783+ encoded_ref_pattern = base64.b32encode(
784+ b"refs/tags/*").replace("=", "_").decode("UTF-8")
785+ self.assertEqual(
786+ "field.%s._repository_owner" % encoded_ref_pattern,
787+ view._getFieldName(
788+ "field", "refs/tags/*",
789+ grantee=GitGranteeType.REPOSITORY_OWNER))
790+
791+ def test__getFieldName_grantee_person(self):
792+ repository = self.factory.makeGitRepository()
793+ grantee = self.factory.makePerson()
794+ login_person(repository.owner)
795+ view = create_initialized_view(repository, name="+permissions")
796+ encoded_ref_pattern = base64.b32encode(
797+ b"refs/*").replace("=", "_").decode("UTF-8")
798+ self.assertEqual(
799+ "field.%s.%s" % (encoded_ref_pattern, grantee.id),
800+ view._getFieldName("field", "refs/*", grantee=grantee))
801+
802+ def test__parseFieldName_too_few_components(self):
803+ repository = self.factory.makeGitRepository()
804+ login_person(repository.owner)
805+ view = create_initialized_view(repository, name="+permissions")
806+ self.assertRaises(UnexpectedFormData, view._parseFieldName, "field")
807+
808+ def test__parseFieldName_bad_ref_pattern(self):
809+ repository = self.factory.makeGitRepository()
810+ login_person(repository.owner)
811+ view = create_initialized_view(repository, name="+permissions")
812+ self.assertRaises(
813+ UnexpectedFormData, view._parseFieldName, "field.nonsense")
814+
815+ def test__parseFieldName_no_grantee(self):
816+ repository = self.factory.makeGitRepository()
817+ login_person(repository.owner)
818+ view = create_initialized_view(repository, name="+permissions")
819+ encoded_ref_pattern = base64.b32encode(
820+ b"refs/heads/*").replace("=", "_").decode("UTF-8")
821+ self.assertEqual(
822+ ("permissions", "refs/heads/*", None),
823+ view._parseFieldName("permissions.%s" % encoded_ref_pattern))
824+
825+ def test__parseFieldName_grantee_unknown_type(self):
826+ repository = self.factory.makeGitRepository()
827+ login_person(repository.owner)
828+ view = create_initialized_view(repository, name="+permissions")
829+ encoded_ref_pattern = base64.b32encode(
830+ b"refs/tags/*").replace("=", "_").decode("UTF-8")
831+ self.assertRaises(
832+ UnexpectedFormData, view._parseFieldName,
833+ "field.%s._nonsense" % encoded_ref_pattern)
834+ self.assertRaises(
835+ UnexpectedFormData, view._parseFieldName,
836+ "field.%s._person" % encoded_ref_pattern)
837+
838+ def test__parseFieldName_grantee_repository_owner(self):
839+ repository = self.factory.makeGitRepository()
840+ login_person(repository.owner)
841+ view = create_initialized_view(repository, name="+permissions")
842+ encoded_ref_pattern = base64.b32encode(
843+ b"refs/tags/*").replace("=", "_").decode("UTF-8")
844+ self.assertEqual(
845+ ("pattern", "refs/tags/*", GitGranteeType.REPOSITORY_OWNER),
846+ view._parseFieldName(
847+ "pattern.%s._repository_owner" % encoded_ref_pattern))
848+
849+ def test__parseFieldName_grantee_unknown_person(self):
850+ repository = self.factory.makeGitRepository()
851+ grantee = self.factory.makePerson()
852+ login_person(repository.owner)
853+ view = create_initialized_view(repository, name="+permissions")
854+ encoded_ref_pattern = base64.b32encode(
855+ b"refs/*").replace("=", "_").decode("UTF-8")
856+ self.assertRaises(
857+ UnexpectedFormData, view._parseFieldName,
858+ "delete.%s.%s" % (encoded_ref_pattern, grantee.id * 2))
859+
860+ def test__parseFieldName_grantee_person(self):
861+ repository = self.factory.makeGitRepository()
862+ grantee = self.factory.makePerson()
863+ login_person(repository.owner)
864+ view = create_initialized_view(repository, name="+permissions")
865+ encoded_ref_pattern = base64.b32encode(
866+ b"refs/*").replace("=", "_").decode("UTF-8")
867+ self.assertEqual(
868+ ("delete", "refs/*", grantee),
869+ view._parseFieldName(
870+ "delete.%s.%s" % (encoded_ref_pattern, grantee.id)))
871+
872+ def test__getPermissionsTerm_standard(self):
873+ grant = self.factory.makeGitRuleGrant(
874+ ref_pattern="refs/heads/*", can_create=True, can_push=True)
875+ login_person(grant.repository.owner)
876+ view = create_initialized_view(grant.repository, name="+permissions")
877+ self.assertThat(
878+ view._getPermissionsTerm(grant), MatchesStructure.byEquality(
879+ value={
880+ GitPermissionType.CAN_CREATE, GitPermissionType.CAN_PUSH},
881+ token="can_push",
882+ title="Can push"))
883+
884+ def test__getPermissionsTerm_custom(self):
885+ grant = self.factory.makeGitRuleGrant(
886+ ref_pattern="refs/heads/*", can_force_push=True)
887+ login_person(grant.repository.owner)
888+ view = create_initialized_view(grant.repository, name="+permissions")
889+ self.assertThat(
890+ view._getPermissionsTerm(grant), MatchesStructure.byEquality(
891+ value={GitPermissionType.CAN_FORCE_PUSH},
892+ token="custom",
893+ title="Custom permissions: force-push"))
894+
895+ def _matchesCells(self, row_tag, cell_matchers):
896+ return AfterPreprocessing(
897+ str, soupmatchers.HTMLContains(*(
898+ soupmatchers.Within(row_tag, cell_matcher)
899+ for cell_matcher in cell_matchers)))
900+
901+ def _matchesRule(self, position, pattern, short_pattern):
902+ rule_tag = soupmatchers.Tag(
903+ "rule row", "tr", attrs={"class": "git-rule"})
904+ suffix = "." + encode_form_field_id(pattern)
905+ position_field_name = "field.position" + suffix
906+ pattern_field_name = "field.pattern" + suffix
907+ delete_field_name = "field.delete" + suffix
908+ return self._matchesCells(rule_tag, [
909+ soupmatchers.Within(
910+ soupmatchers.Tag(
911+ "position cell", "td",
912+ attrs={"class": "git-rule-position"}),
913+ soupmatchers.Tag(
914+ "position widget", "input",
915+ attrs={"name": position_field_name, "value": position})),
916+ soupmatchers.Within(
917+ soupmatchers.Tag(
918+ "pattern cell", "td", attrs={"class": "git-rule-pattern"}),
919+ soupmatchers.Tag(
920+ "pattern widget", "input",
921+ attrs={
922+ "name": pattern_field_name,
923+ "value": short_pattern,
924+ })),
925+ soupmatchers.Within(
926+ soupmatchers.Tag(
927+ "delete cell", "td", attrs={"class": "git-rule-delete"}),
928+ soupmatchers.Tag(
929+ "delete widget", "input",
930+ attrs={"name": delete_field_name})),
931+ ])
932+
933+ def _matchesNewRule(self, ref_prefix):
934+ new_rule_tag = soupmatchers.Tag(
935+ "new rule row", "tr", attrs={"class": "git-new-rule"})
936+ suffix = "." + encode_form_field_id(ref_prefix)
937+ new_position_field_name = "field.new-position" + suffix
938+ new_pattern_field_name = "field.new-pattern" + suffix
939+ return self._matchesCells(new_rule_tag, [
940+ soupmatchers.Within(
941+ soupmatchers.Tag(
942+ "position cell", "td",
943+ attrs={"class": "git-rule-position"}),
944+ soupmatchers.Tag(
945+ "position widget", "input",
946+ attrs={"name": new_position_field_name, "value": ""})),
947+ soupmatchers.Within(
948+ soupmatchers.Tag(
949+ "pattern cell", "td", attrs={"class": "git-rule-pattern"}),
950+ soupmatchers.Tag(
951+ "pattern widget", "input",
952+ attrs={"name": new_pattern_field_name, "value": ""})),
953+ ])
954+
955+ def _matchesRuleGrant(self, pattern, grantee, permissions_token,
956+ permissions_title):
957+ rule_grant_tag = soupmatchers.Tag(
958+ "rule grant row", "tr", attrs={"class": "git-rule-grant"})
959+ suffix = "." + encode_form_field_id(pattern)
960+ if IPerson.providedBy(grantee):
961+ suffix += "." + str(grantee.id)
962+ grantee_url = canonical_url(grantee, path_only_if_possible=True)
963+ grantee_widget_matcher = soupmatchers.Tag(
964+ "grantee widget", "a", attrs={"href": grantee_url},
965+ text=grantee.display_name)
966+ else:
967+ suffix += "._" + grantee.name.lower()
968+ grantee_widget_matcher = soupmatchers.Tag(
969+ "grantee widget", "label",
970+ text=re.compile(re.escape(grantee.title)))
971+ permissions_field_name = "field.permissions" + suffix
972+ delete_field_name = "field.delete" + suffix
973+ return self._matchesCells(rule_grant_tag, [
974+ soupmatchers.Within(
975+ soupmatchers.Tag(
976+ "grantee cell", "td",
977+ attrs={"class": "git-rule-grant-grantee"}),
978+ grantee_widget_matcher),
979+ soupmatchers.Within(
980+ soupmatchers.Tag(
981+ "permissions cell", "td",
982+ attrs={"class": "git-rule-grant-permissions"}),
983+ soupmatchers.Within(
984+ soupmatchers.Tag(
985+ "permissions widget", "select",
986+ attrs={"name": permissions_field_name}),
987+ soupmatchers.Tag(
988+ "selected permissions option", "option",
989+ attrs={
990+ "selected": "selected",
991+ "value": permissions_token,
992+ },
993+ text=permissions_title))),
994+ soupmatchers.Within(
995+ soupmatchers.Tag(
996+ "delete cell", "td", attrs={"class": "git-rule-delete"}),
997+ soupmatchers.Tag(
998+ "delete widget", "input",
999+ attrs={"name": delete_field_name})),
1000+ ])
1001+
1002+ def _matchesNewRuleGrant(self, pattern, permissions_token):
1003+ rule_grant_tag = soupmatchers.Tag(
1004+ "rule grant row", "tr", attrs={"class": "git-new-rule-grant"})
1005+ suffix = "." + encode_form_field_id(pattern)
1006+ grantee_field_name = "field.grantee" + suffix
1007+ permissions_field_name = "field.permissions" + suffix
1008+ return self._matchesCells(rule_grant_tag, [
1009+ soupmatchers.Within(
1010+ soupmatchers.Tag(
1011+ "grantee cell", "td",
1012+ attrs={"class": "git-rule-grant-grantee"}),
1013+ soupmatchers.Tag(
1014+ "grantee widget", "input",
1015+ attrs={"name": grantee_field_name})),
1016+ soupmatchers.Within(
1017+ soupmatchers.Tag(
1018+ "permissions cell", "td",
1019+ attrs={"class": "git-rule-grant-permissions"}),
1020+ soupmatchers.Within(
1021+ soupmatchers.Tag(
1022+ "permissions widget", "select",
1023+ attrs={"name": permissions_field_name}),
1024+ soupmatchers.Tag(
1025+ "selected permissions option", "option",
1026+ attrs={
1027+ "selected": "selected",
1028+ "value": permissions_token,
1029+ }))),
1030+ ])
1031+
1032+ def test_rules_tables(self):
1033+ repository = self.factory.makeGitRepository()
1034+ heads_rule = self.factory.makeGitRule(
1035+ repository=repository, ref_pattern="refs/heads/stable/*")
1036+ heads_grantee_1 = self.factory.makePerson(
1037+ name=self.factory.getUniqueString("person-name-a"))
1038+ heads_grantee_2 = self.factory.makePerson(
1039+ name=self.factory.getUniqueString("person-name-b"))
1040+ self.factory.makeGitRuleGrant(
1041+ rule=heads_rule, grantee=heads_grantee_1, can_push=True)
1042+ self.factory.makeGitRuleGrant(
1043+ rule=heads_rule, grantee=heads_grantee_2, can_force_push=True)
1044+ tags_rule = self.factory.makeGitRule(
1045+ repository=repository, ref_pattern="refs/tags/*")
1046+ self.factory.makeGitRuleGrant(
1047+ rule=tags_rule, grantee=GitGranteeType.REPOSITORY_OWNER)
1048+ login_person(repository.owner)
1049+ view = create_initialized_view(
1050+ repository, name="+permissions", principal=repository.owner)
1051+ rules_tables = find_tags_by_class(view(), "git-rules-table")
1052+ rows = list(chain.from_iterable([
1053+ rules_table.findAll("tr", {"class": True})
1054+ for rules_table in rules_tables]))
1055+ self.assertThat(rows, MatchesListwise([
1056+ self._matchesRule("1", "refs/heads/stable/*", "stable/*"),
1057+ self._matchesRuleGrant(
1058+ "refs/heads/stable/*", heads_grantee_1, "can_push_existing",
1059+ "Can push if the branch already exists"),
1060+ self._matchesRuleGrant(
1061+ "refs/heads/stable/*", heads_grantee_2, "custom",
1062+ "Custom permissions: force-push"),
1063+ self._matchesNewRuleGrant("refs/heads/stable/*", "can_push"),
1064+ self._matchesNewRule("refs/heads/"),
1065+ self._matchesRule("2", "refs/tags/*", "*"),
1066+ self._matchesRuleGrant(
1067+ "refs/tags/*", GitGranteeType.REPOSITORY_OWNER,
1068+ "cannot_create", "Cannot create"),
1069+ self._matchesNewRuleGrant("refs/tags/*", "can_create"),
1070+ self._matchesNewRule("refs/tags/"),
1071+ ]))
1072+
1073+ def assertHasRules(self, repository, ref_patterns):
1074+ self.assertThat(list(repository.rules), MatchesListwise([
1075+ MatchesStructure.byEquality(ref_pattern=ref_pattern)
1076+ for ref_pattern in ref_patterns
1077+ ]))
1078+
1079+ def assertHasSavedNotification(self, view, repository):
1080+ self.assertThat(view.request.response.notifications, MatchesListwise([
1081+ MatchesStructure.byEquality(
1082+ message="Saved permissions for %s" % repository.identity),
1083+ ]))
1084+
1085+ def test_save_add_rules(self):
1086+ repository = self.factory.makeGitRepository()
1087+ self.factory.makeGitRule(
1088+ repository=repository, ref_pattern="refs/heads/stable/*")
1089+ removeSecurityProxy(repository.getActivity()).remove()
1090+ login_person(repository.owner)
1091+ encoded_heads_prefix = encode_form_field_id("refs/heads/")
1092+ encoded_tags_prefix = encode_form_field_id("refs/tags/")
1093+ form = {
1094+ "field.new-pattern." + encoded_heads_prefix: "*",
1095+ "field.new-pattern." + encoded_tags_prefix: "1.0",
1096+ "field.actions.save": "Save",
1097+ }
1098+ view = create_initialized_view(
1099+ repository, name="+permissions", form=form,
1100+ principal=repository.owner)
1101+ self.assertHasRules(
1102+ repository,
1103+ ["refs/tags/1.0", "refs/heads/stable/*", "refs/heads/*"])
1104+ self.assertThat(list(repository.getActivity()), MatchesListwise([
1105+ # Adding a tag rule automatically adds a repository owner grant.
1106+ MatchesStructure(
1107+ changer=Equals(repository.owner),
1108+ changee=Is(None),
1109+ what_changed=Equals(GitActivityType.GRANT_ADDED),
1110+ new_value=MatchesDict({
1111+ "changee_type": Equals("Repository owner"),
1112+ "ref_pattern": Equals("refs/tags/1.0"),
1113+ "can_create": Is(True),
1114+ "can_push": Is(False),
1115+ "can_force_push": Is(False),
1116+ })),
1117+ MatchesStructure(
1118+ changer=Equals(repository.owner),
1119+ what_changed=Equals(GitActivityType.RULE_ADDED),
1120+ new_value=MatchesDict({
1121+ "ref_pattern": Equals("refs/tags/1.0"),
1122+ "position": Equals(0),
1123+ })),
1124+ MatchesStructure(
1125+ changer=Equals(repository.owner),
1126+ what_changed=Equals(GitActivityType.RULE_ADDED),
1127+ new_value=MatchesDict({
1128+ "ref_pattern": Equals("refs/heads/*"),
1129+ # Initially inserted at 1, although refs/tags/1.0 was
1130+ # later inserted before it.
1131+ "position": Equals(1),
1132+ })),
1133+ ]))
1134+ self.assertHasSavedNotification(view, repository)
1135+
1136+ def test_save_add_duplicate_rule(self):
1137+ repository = self.factory.makeGitRepository()
1138+ self.factory.makeGitRule(
1139+ repository=repository, ref_pattern="refs/heads/stable/*")
1140+ transaction.commit()
1141+ login_person(repository.owner)
1142+ encoded_heads_prefix = encode_form_field_id("refs/heads/")
1143+ form = {
1144+ "field.new-pattern." + encoded_heads_prefix: "stable/*",
1145+ "field.actions.save": "Save",
1146+ }
1147+ view = create_initialized_view(
1148+ repository, name="+permissions", form=form,
1149+ principal=repository.owner)
1150+ self.assertThat(view.errors, MatchesListwise([
1151+ MatchesStructure(
1152+ field_name=Equals("new-pattern." + encoded_heads_prefix),
1153+ errors=MatchesStructure.byEquality(
1154+ args=("stable/* is already in use by another rule",))),
1155+ ]))
1156+ self.assertHasRules(repository, ["refs/heads/stable/*"])
1157+
1158+ def test_save_move_rule(self):
1159+ repository = self.factory.makeGitRepository()
1160+ self.factory.makeGitRule(
1161+ repository=repository, ref_pattern="refs/heads/stable/*")
1162+ self.factory.makeGitRule(
1163+ repository=repository, ref_pattern="refs/heads/*/next")
1164+ encoded_patterns = [
1165+ encode_form_field_id(rule.ref_pattern)
1166+ for rule in repository.rules]
1167+ removeSecurityProxy(repository.getActivity()).remove()
1168+ login_person(repository.owner)
1169+ # Positions are 1-based in the UI.
1170+ form = {
1171+ "field.position." + encoded_patterns[0]: "2",
1172+ "field.pattern." + encoded_patterns[0]: "stable/*",
1173+ "field.position." + encoded_patterns[1]: "1",
1174+ "field.pattern." + encoded_patterns[1]: "*/more-next",
1175+ "field.actions.save": "Save",
1176+ }
1177+ view = create_initialized_view(
1178+ repository, name="+permissions", form=form,
1179+ principal=repository.owner)
1180+ self.assertHasRules(
1181+ repository, ["refs/heads/*/more-next", "refs/heads/stable/*"])
1182+ self.assertThat(list(repository.getActivity()), MatchesListwise([
1183+ MatchesStructure(
1184+ changer=Equals(repository.owner),
1185+ what_changed=Equals(GitActivityType.RULE_CHANGED),
1186+ old_value=MatchesDict({
1187+ "ref_pattern": Equals("refs/heads/*/next"),
1188+ "position": Equals(0),
1189+ }),
1190+ new_value=MatchesDict({
1191+ "ref_pattern": Equals("refs/heads/*/more-next"),
1192+ "position": Equals(0),
1193+ })),
1194+ # Only one rule is recorded as moving; the other is already in
1195+ # its new position by the time it's processed.
1196+ MatchesStructure(
1197+ changer=Equals(repository.owner),
1198+ what_changed=Equals(GitActivityType.RULE_MOVED),
1199+ old_value=MatchesDict({
1200+ "ref_pattern": Equals("refs/heads/stable/*"),
1201+ "position": Equals(0),
1202+ }),
1203+ new_value=MatchesDict({
1204+ "ref_pattern": Equals("refs/heads/stable/*"),
1205+ "position": Equals(1),
1206+ })),
1207+ ]))
1208+ self.assertHasSavedNotification(view, repository)
1209+
1210+ def test_save_change_grants(self):
1211+ repository = self.factory.makeGitRepository()
1212+ stable_rule = self.factory.makeGitRule(
1213+ repository=repository, ref_pattern="refs/heads/stable/*")
1214+ next_rule = self.factory.makeGitRule(
1215+ repository=repository, ref_pattern="refs/heads/*/next")
1216+ grantees = [self.factory.makePerson() for _ in range(3)]
1217+ self.factory.makeGitRuleGrant(
1218+ rule=stable_rule, grantee=GitGranteeType.REPOSITORY_OWNER,
1219+ can_create=True)
1220+ self.factory.makeGitRuleGrant(
1221+ rule=stable_rule,
1222+ grantee=grantees[0], can_create=True, can_push=True)
1223+ self.factory.makeGitRuleGrant(
1224+ rule=next_rule, grantee=grantees[1],
1225+ can_create=True, can_push=True, can_force_push=True)
1226+ encoded_patterns = [
1227+ encode_form_field_id(rule.ref_pattern)
1228+ for rule in repository.rules]
1229+ removeSecurityProxy(repository.getActivity()).remove()
1230+ login_person(repository.owner)
1231+ form = {
1232+ "field.permissions.%s._repository_owner" % encoded_patterns[0]: (
1233+ "can_push"),
1234+ "field.permissions.%s.%s" % (
1235+ encoded_patterns[0], grantees[0].id): "can_push",
1236+ "field.delete.%s.%s" % (encoded_patterns[0], grantees[0].id): "on",
1237+ "field.grantee.%s" % encoded_patterns[1]: "person",
1238+ "field.grantee.%s.person" % encoded_patterns[1]: grantees[2].name,
1239+ "field.permissions.%s" % encoded_patterns[1]: "can_push_existing",
1240+ "field.actions.save": "Save",
1241+ }
1242+ view = create_initialized_view(
1243+ repository, name="+permissions", form=form,
1244+ principal=repository.owner)
1245+ self.assertHasRules(
1246+ repository, ["refs/heads/stable/*", "refs/heads/*/next"])
1247+ self.assertThat(stable_rule.grants, MatchesSetwise(
1248+ MatchesStructure.byEquality(
1249+ grantee_type=GitGranteeType.REPOSITORY_OWNER,
1250+ can_create=True, can_push=True, can_force_push=False)))
1251+ self.assertThat(next_rule.grants, MatchesSetwise(
1252+ MatchesStructure.byEquality(
1253+ grantee=grantees[1],
1254+ can_create=True, can_push=True, can_force_push=True),
1255+ MatchesStructure.byEquality(
1256+ grantee=grantees[2],
1257+ can_create=False, can_push=True, can_force_push=False)))
1258+ self.assertThat(repository.getActivity(), MatchesSetwise(
1259+ MatchesStructure(
1260+ changer=Equals(repository.owner),
1261+ changee=Is(None),
1262+ what_changed=Equals(GitActivityType.GRANT_CHANGED),
1263+ old_value=Equals({
1264+ "changee_type": "Repository owner",
1265+ "ref_pattern": "refs/heads/stable/*",
1266+ "can_create": True,
1267+ "can_push": False,
1268+ "can_force_push": False,
1269+ }),
1270+ new_value=Equals({
1271+ "changee_type": "Repository owner",
1272+ "ref_pattern": "refs/heads/stable/*",
1273+ "can_create": True,
1274+ "can_push": True,
1275+ "can_force_push": False,
1276+ })),
1277+ MatchesStructure(
1278+ changer=Equals(repository.owner),
1279+ changee=Equals(grantees[0]),
1280+ what_changed=Equals(GitActivityType.GRANT_REMOVED),
1281+ old_value=Equals({
1282+ "changee_type": "Person",
1283+ "ref_pattern": "refs/heads/stable/*",
1284+ "can_create": True,
1285+ "can_push": True,
1286+ "can_force_push": False,
1287+ })),
1288+ MatchesStructure(
1289+ changer=Equals(repository.owner),
1290+ changee=Equals(grantees[2]),
1291+ what_changed=Equals(GitActivityType.GRANT_ADDED),
1292+ new_value=Equals({
1293+ "changee_type": "Person",
1294+ "ref_pattern": "refs/heads/*/next",
1295+ "can_create": False,
1296+ "can_push": True,
1297+ "can_force_push": False,
1298+ }))))
1299+ self.assertHasSavedNotification(view, repository)
1300+
1301+ def test_save_delete_rule(self):
1302+ repository = self.factory.makeGitRepository()
1303+ self.factory.makeGitRule(
1304+ repository=repository, ref_pattern="refs/heads/stable/*")
1305+ heads_rule = self.factory.makeGitRule(
1306+ repository=repository, ref_pattern="refs/heads/*")
1307+ self.factory.makeGitRuleGrant(
1308+ rule=heads_rule, grantee=GitGranteeType.REPOSITORY_OWNER,
1309+ can_push=True)
1310+ removeSecurityProxy(repository.getActivity()).remove()
1311+ login_person(repository.owner)
1312+ encoded_pattern = encode_form_field_id("refs/heads/*")
1313+ form = {
1314+ "field.pattern." + encoded_pattern: "*",
1315+ "field.delete." + encoded_pattern: "on",
1316+ # Form data entries relating to grants for the deleted rule are
1317+ # ignored.
1318+ "field.permissions.%s._repository_owner" % encoded_pattern: (
1319+ "can_push"),
1320+ "field.actions.save": "Save",
1321+ }
1322+ view = create_initialized_view(
1323+ repository, name="+permissions", form=form,
1324+ principal=repository.owner)
1325+ self.assertHasRules(repository, ["refs/heads/stable/*"])
1326+ self.assertThat(list(repository.getActivity()), MatchesListwise([
1327+ MatchesStructure(
1328+ changer=Equals(repository.owner),
1329+ what_changed=Equals(GitActivityType.RULE_REMOVED),
1330+ old_value=MatchesDict({
1331+ "ref_pattern": Equals("refs/heads/*"),
1332+ "position": Equals(1),
1333+ })),
1334+ ]))
1335+ self.assertHasSavedNotification(view, repository)
1336+
1337+
1338 class TestGitRepositoryDeletionView(BrowserTestCase):
1339
1340 layer = DatabaseFunctionalLayer
1341
1342=== modified file 'lib/lp/code/interfaces/gitrule.py'
1343--- lib/lp/code/interfaces/gitrule.py 2018-10-23 16:17:39 +0000
1344+++ lib/lp/code/interfaces/gitrule.py 2019-01-10 10:50:57 +0000
1345@@ -158,6 +158,10 @@
1346 vocabulary="ValidPersonOrTeam",
1347 description=_("The person being granted access."))
1348
1349+ combined_grantee = Attribute(
1350+ "The overall grantee of this grant: either a `GitGranteeType` (other "
1351+ "than `PERSON`) or an `IPerson`.")
1352+
1353 date_created = Datetime(
1354 title=_("Date created"), required=True, readonly=True,
1355 description=_("The time when this grant was created."))
1356
1357=== modified file 'lib/lp/code/model/gitrule.py'
1358--- lib/lp/code/model/gitrule.py 2018-10-29 14:27:36 +0000
1359+++ lib/lp/code/model/gitrule.py 2019-01-10 10:50:57 +0000
1360@@ -310,6 +310,13 @@
1361 self.date_created = date_created
1362 self.date_last_modified = date_created
1363
1364+ @property
1365+ def combined_grantee(self):
1366+ if self.grantee_type == GitGranteeType.PERSON:
1367+ return self.grantee
1368+ else:
1369+ return self.grantee_type
1370+
1371 def __repr__(self):
1372 if self.grantee_type == GitGranteeType.PERSON:
1373 grantee_name = "~%s" % self.grantee.name
1374
1375=== modified file 'lib/lp/code/model/tests/test_gitrule.py'
1376--- lib/lp/code/model/tests/test_gitrule.py 2018-10-21 17:38:05 +0000
1377+++ lib/lp/code/model/tests/test_gitrule.py 2019-01-10 10:50:57 +0000
1378@@ -594,6 +594,7 @@
1379 rule=Equals(rule),
1380 grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER),
1381 grantee=Is(None),
1382+ combined_grantee=Equals(GitGranteeType.REPOSITORY_OWNER),
1383 can_create=Is(True),
1384 can_push=Is(False),
1385 can_force_push=Is(True),
1386@@ -614,6 +615,7 @@
1387 rule=Equals(rule),
1388 grantee_type=Equals(GitGranteeType.PERSON),
1389 grantee=Equals(grantee),
1390+ combined_grantee=Equals(grantee),
1391 can_create=Is(False),
1392 can_push=Is(True),
1393 can_force_push=Is(False),
1394
1395=== added file 'lib/lp/code/templates/gitrepository-permissions.pt'
1396--- lib/lp/code/templates/gitrepository-permissions.pt 1970-01-01 00:00:00 +0000
1397+++ lib/lp/code/templates/gitrepository-permissions.pt 2019-01-10 10:50:57 +0000
1398@@ -0,0 +1,232 @@
1399+<html
1400+ xmlns="http://www.w3.org/1999/xhtml"
1401+ xmlns:tal="http://xml.zope.org/namespaces/tal"
1402+ xmlns:metal="http://xml.zope.org/namespaces/metal"
1403+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
1404+ metal:use-macro="view/macro:page/main_only"
1405+ i18n:domain="launchpad">
1406+<body>
1407+
1408+ <metal:block fill-slot="head_epilogue">
1409+ <style type="text/css">
1410+ table.git-rules-table tr.even {
1411+ background-color: #eee;
1412+ }
1413+ tr.git-rule td, tr.git-new-rule td {
1414+ padding-top: 1em;
1415+ }
1416+ tr.git-new-rule-grant td, tr.git-new-rule td {
1417+ padding-bottom: 1em;
1418+ }
1419+ /* Position, pattern, and delete add up to 100%. */
1420+ tr .git-rule-position {
1421+ width: 10%;
1422+ }
1423+ tr .git-rule-pattern {
1424+ width: 85%;
1425+ }
1426+ tr .git-rule-delete {
1427+ width: 5%;
1428+ }
1429+ /* Grantee and permissions add up to pattern (85%). */
1430+ tr .git-rule-grant-grantee {
1431+ width: 40%;
1432+ }
1433+ tr .git-rule-grant-permissions {
1434+ width: 45%;
1435+ }
1436+ </style>
1437+ </metal:block>
1438+
1439+ <metal:macros fill-slot="bogus">
1440+ <metal:macro define-macro="rule-rows">
1441+ <tal:rule repeat="rule rules">
1442+ <tal:rule_widgets
1443+ define="rule_widgets python:view.getRuleWidgets(rule);
1444+ parity python:'even' if repeat['rule'].even() else 'odd'">
1445+ <tr tal:attributes="class string:git-rule ${parity}">
1446+ <td class="git-rule-position"
1447+ tal:define="widget nocall:rule_widgets/position">
1448+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
1449+ </td>
1450+ <td class="git-rule-pattern"
1451+ tal:define="widget nocall:rule_widgets/pattern" colspan="2">
1452+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
1453+ </td>
1454+ <td class="git-rule-delete"
1455+ tal:define="widget nocall:rule_widgets/delete">
1456+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
1457+ </td>
1458+ </tr>
1459+ <tr tal:attributes="class string:git-rule-grant ${parity}"
1460+ tal:repeat="grant_widgets rule_widgets/grants">
1461+ <td></td>
1462+ <td class="git-rule-grant-grantee"
1463+ tal:define="widget nocall:grant_widgets/grantee">
1464+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
1465+ </td>
1466+ <td class="git-rule-grant-permissions"
1467+ tal:define="widget nocall:grant_widgets/permissions">
1468+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
1469+ </td>
1470+ <td class="git-rule-delete"
1471+ tal:define="widget nocall:grant_widgets/delete">
1472+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
1473+ </td>
1474+ </tr>
1475+ <tr tal:attributes="class string:git-new-rule-grant ${parity}"
1476+ tal:define="new_grant_widgets rule_widgets/new_grant">
1477+ <td></td>
1478+ <td class="git-rule-grant-grantee"
1479+ tal:define="widget nocall:new_grant_widgets/grantee">
1480+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
1481+ </td>
1482+ <td class="git-rule-grant-permissions"
1483+ tal:define="widget nocall:new_grant_widgets/permissions">
1484+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
1485+ </td>
1486+ <td></td>
1487+ </tr>
1488+ </tal:rule_widgets>
1489+ </tal:rule>
1490+ <tal:allows-new-rule
1491+ condition="ref_prefix"
1492+ define="parity python:'odd' if len(rules) % 2 else 'even'">
1493+ <tr tal:attributes="class string:git-new-rule ${parity}"
1494+ tal:define="new_rule_widgets python:view.getNewRuleWidgets(ref_prefix)">
1495+ <td class="git-rule-position"
1496+ tal:define="widget nocall:new_rule_widgets/position">
1497+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
1498+ </td>
1499+ <td class="git-rule-pattern"
1500+ tal:define="widget nocall:new_rule_widgets/pattern" colspan="2">
1501+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
1502+ </td>
1503+ <td></td>
1504+ </tr>
1505+ </tal:allows-new-rule>
1506+ </metal:macro>
1507+
1508+ <metal:macro define-macro="rules-table">
1509+ <table class="listing git-rules-table"
1510+ style="max-width: 60em; margin-bottom: 1em;">
1511+ <thead>
1512+ <tr>
1513+ <th class="git-rule-position">Position</th>
1514+ <th class="git-rule-pattern" colspan="2">Rule</th>
1515+ <th class="git-rule-delete">Delete?</th>
1516+ </tr>
1517+ </thead>
1518+ <tbody>
1519+ <metal:rules use-macro="template/macros/rule-rows" />
1520+ </tbody>
1521+ </table>
1522+ </metal:macro>
1523+ </metal:macros>
1524+
1525+ <div metal:fill-slot="main">
1526+ <p>
1527+ By default, repository owners may create, push, force-push, or delete
1528+ any branch or tag in their repositories, and nobody else may modify
1529+ them in any way.
1530+ </p>
1531+ <p>
1532+ If any of the rules below matches a branch or tag, then it is
1533+ <em>protected</em>. By default, protecting a branch implicitly
1534+ prevents repository owners from force-pushing to it or deleting it,
1535+ while protecting a tag prevents repository owners from moving it.
1536+ Protecting a branch or tag also allows you to grant other permissions.
1537+ </p>
1538+ <p>
1539+ You may create rules that match a single branch or tag, or wildcard
1540+ rules that match a pattern: for example, <code>*</code> matches
1541+ everything, while <code>stable/*</code> matches
1542+ <code>stable/1.0</code> but not <code>master</code>.
1543+ </p>
1544+
1545+ <metal:grants-form use-macro="context/@@launchpad_form/form">
1546+ <div class="form" metal:fill-slot="widgets">
1547+ <h3>Protected branches (under <code>refs/heads/</code>)</h3>
1548+ <tal:branches define="rules view/branch_rules;
1549+ ref_prefix string:refs/heads/">
1550+ <metal:table use-macro="template/macros/rules-table" />
1551+ </tal:branches>
1552+
1553+ <h3>Protected tags (under <code>refs/tags/</code>)</h3>
1554+ <tal:tags define="rules view/tag_rules;
1555+ ref_prefix string:refs/tags/">
1556+ <metal:table use-macro="template/macros/rules-table" />
1557+ </tal:tags>
1558+
1559+ <tal:has-other condition="view/other_rules">
1560+ <h3>Other protected references</h3>
1561+ <tal:other define="rules view/other_rules; ref_prefix nothing">
1562+ <metal:table use-macro="template/macros/rules-table" />
1563+ </tal:other>
1564+ </tal:has-other>
1565+
1566+ <p class="actions">
1567+ <input tal:replace="structure view/save_action/render" />
1568+ or <a tal:attributes="href view/cancel_url">Cancel</a>
1569+ </p>
1570+ </div>
1571+
1572+ <metal:buttons fill-slot="buttons" />
1573+ </metal:grants-form>
1574+
1575+ <h2>Wildcards</h2>
1576+ <p>The special characters used in wildcard rules are:</p>
1577+ <table class="listing narrow-listing">
1578+ <thead>
1579+ <tr>
1580+ <th>Pattern</th>
1581+ <th>Meaning</th>
1582+ </tr>
1583+ </thead>
1584+ <tbody>
1585+ <tr>
1586+ <td><code>*</code></td>
1587+ <td>matches zero or more characters</td>
1588+ </tr>
1589+ <tr>
1590+ <td><code>?</code></td>
1591+ <td>matches any single character</td>
1592+ </tr>
1593+ <tr>
1594+ <td><code>[chars]</code></td>
1595+ <td>matches any character in <em>chars</em></td>
1596+ </tr>
1597+ <tr>
1598+ <td><code>[!chars]</code></td>
1599+ <td>matches any character not in <em>chars</em></td>
1600+ </tr>
1601+ </tbody>
1602+ </table>
1603+
1604+ <h2>Effective permissions</h2>
1605+ <p>
1606+ Launchpad works out the effective permissions that a user has on a
1607+ protected branch or tag as follows:
1608+ </p>
1609+ <ol>
1610+ <li>Take all the rules that match the branch or tag.</li>
1611+ <li>
1612+ For each matching rule, select any grants whose grantee matches the
1613+ user, as long as the same grantee has not already been seen in an
1614+ earlier matching rule. (A user can be matched by more than one
1615+ grantee: for example, they might be in multiple teams.)
1616+ </li>
1617+ <li>
1618+ If the user is an owner of the repository and there was no previous
1619+ “Repository owner” grant, then add an implicit grant allowing them
1620+ to create or push.
1621+ </li>
1622+ <li>
1623+ The effective permission set is the union of the permissions granted
1624+ by all the selected grants.
1625+ </li>
1626+ </ol>
1627+ </div>
1628+
1629+</body>
1630+</html>