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

Proposed by Colin Watson
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 Approve
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.
Revision history for this message
William Grant (wgrant) :
review: Approve (code)
Revision history for this message
Colin Watson (cjwatson) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/lp/code/browser/configure.zcml'
--- lib/lp/code/browser/configure.zcml 2018-11-08 11:40:25 +0000
+++ lib/lp/code/browser/configure.zcml 2019-01-10 10:50:57 +0000
@@ -871,6 +871,12 @@
871 </class>871 </class>
872 <browser:page872 <browser:page
873 for="lp.code.interfaces.gitrepository.IGitRepository"873 for="lp.code.interfaces.gitrepository.IGitRepository"
874 class="lp.code.browser.gitrepository.GitRepositoryPermissionsView"
875 name="+permissions"
876 permission="launchpad.Edit"
877 template="../templates/gitrepository-permissions.pt"/>
878 <browser:page
879 for="lp.code.interfaces.gitrepository.IGitRepository"
874 class="lp.code.browser.gitrepository.GitRepositoryDeletionView"880 class="lp.code.browser.gitrepository.GitRepositoryDeletionView"
875 permission="launchpad.Edit"881 permission="launchpad.Edit"
876 name="+delete"882 name="+delete"
877883
=== modified file 'lib/lp/code/browser/gitrepository.py'
--- lib/lp/code/browser/gitrepository.py 2018-11-08 15:53:56 +0000
+++ lib/lp/code/browser/gitrepository.py 2019-01-10 10:50:57 +0000
@@ -1,4 +1,4 @@
1# Copyright 2015-2018 Canonical Ltd. This software is licensed under the1# Copyright 2015-2019 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Git repository views."""4"""Git repository views."""
@@ -17,10 +17,14 @@
17 'GitRepositoryEditReviewerView',17 'GitRepositoryEditReviewerView',
18 'GitRepositoryEditView',18 'GitRepositoryEditView',
19 'GitRepositoryNavigation',19 'GitRepositoryNavigation',
20 'GitRepositoryPermissionsView',
20 'GitRepositoryURL',21 'GitRepositoryURL',
21 'GitRepositoryView',22 'GitRepositoryView',
22 ]23 ]
2324
25import base64
26from collections import defaultdict
27
24from lazr.lifecycle.event import ObjectModifiedEvent28from lazr.lifecycle.event import ObjectModifiedEvent
25from lazr.lifecycle.snapshot import Snapshot29from lazr.lifecycle.snapshot import Snapshot
26from lazr.restful.interface import (30from lazr.restful.interface import (
@@ -34,14 +38,21 @@
34from zope.component import getUtility38from zope.component import getUtility
35from zope.event import notify39from zope.event import notify
36from zope.formlib import form40from zope.formlib import form
41from zope.formlib.textwidgets import IntWidget
42from zope.formlib.widget import CustomWidgetFactory
37from zope.interface import (43from zope.interface import (
38 implementer,44 implementer,
39 Interface,45 Interface,
40 providedBy,46 providedBy,
41 )47 )
42from zope.publisher.interfaces.browser import IBrowserPublisher48from zope.publisher.interfaces.browser import IBrowserPublisher
43from zope.schema import Choice49from zope.schema import (
50 Bool,
51 Choice,
52 Int,
53 )
44from zope.schema.vocabulary import (54from zope.schema.vocabulary import (
55 getVocabularyRegistry,
45 SimpleTerm,56 SimpleTerm,
46 SimpleVocabulary,57 SimpleVocabulary,
47 )58 )
@@ -53,7 +64,10 @@
53 LaunchpadEditFormView,64 LaunchpadEditFormView,
54 LaunchpadFormView,65 LaunchpadFormView,
55 )66 )
56from lp.app.errors import NotFoundError67from lp.app.errors import (
68 NotFoundError,
69 UnexpectedFormData,
70 )
57from lp.app.vocabularies import InformationTypeVocabulary71from lp.app.vocabularies import InformationTypeVocabulary
58from lp.app.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription72from lp.app.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription
59from lp.code.browser.branch import CodeEditOwnerMixin73from lp.code.browser.branch import CodeEditOwnerMixin
@@ -62,11 +76,19 @@
62 )76 )
63from lp.code.browser.codeimport import CodeImportTargetMixin77from lp.code.browser.codeimport import CodeImportTargetMixin
64from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin78from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
79from lp.code.browser.widgets.gitgrantee import (
80 GitGranteeDisplayWidget,
81 GitGranteeField,
82 GitGranteeWidget,
83 )
65from lp.code.browser.widgets.gitrepositorytarget import (84from lp.code.browser.widgets.gitrepositorytarget import (
66 GitRepositoryTargetDisplayWidget,85 GitRepositoryTargetDisplayWidget,
67 GitRepositoryTargetWidget,86 GitRepositoryTargetWidget,
68 )87 )
69from lp.code.enums import GitRepositoryType88from lp.code.enums import (
89 GitGranteeType,
90 GitRepositoryType,
91 )
70from lp.code.errors import (92from lp.code.errors import (
71 GitDefaultConflict,93 GitDefaultConflict,
72 GitRepositoryCreationForbidden,94 GitRepositoryCreationForbidden,
@@ -76,6 +98,7 @@
76from lp.code.interfaces.gitnamespace import get_git_namespace98from lp.code.interfaces.gitnamespace import get_git_namespace
77from lp.code.interfaces.gitref import IGitRefBatchNavigator99from lp.code.interfaces.gitref import IGitRefBatchNavigator
78from lp.code.interfaces.gitrepository import IGitRepository100from lp.code.interfaces.gitrepository import IGitRepository
101from lp.code.vocabularies.gitrule import GitPermissionsVocabulary
79from lp.registry.interfaces.person import (102from lp.registry.interfaces.person import (
80 IPerson,103 IPerson,
81 IPersonSet,104 IPersonSet,
@@ -84,6 +107,7 @@
84from lp.services.config import config107from lp.services.config import config
85from lp.services.database.constants import UTC_NOW108from lp.services.database.constants import UTC_NOW
86from lp.services.features import getFeatureFlag109from lp.services.features import getFeatureFlag
110from lp.services.fields import UniqueField
87from lp.services.propertycache import cachedproperty111from lp.services.propertycache import cachedproperty
88from lp.services.webapp import (112from lp.services.webapp import (
89 canonical_url,113 canonical_url,
@@ -105,6 +129,7 @@
105from lp.services.webapp.escaping import structured129from lp.services.webapp.escaping import structured
106from lp.services.webapp.interfaces import ICanonicalUrlData130from lp.services.webapp.interfaces import ICanonicalUrlData
107from lp.services.webapp.publisher import DataDownloadView131from lp.services.webapp.publisher import DataDownloadView
132from lp.services.webapp.snapshot import notify_modified
108from lp.services.webhooks.browser import WebhookTargetNavigationMixin133from lp.services.webhooks.browser import WebhookTargetNavigationMixin
109from lp.snappy.browser.hassnaps import HasSnapsViewMixin134from lp.snappy.browser.hassnaps import HasSnapsViewMixin
110135
@@ -211,7 +236,14 @@
211 usedfor = IGitRepository236 usedfor = IGitRepository
212 facet = "branches"237 facet = "branches"
213 title = "Edit Git repository"238 title = "Edit Git repository"
214 links = ["edit", "reviewer", "webhooks", "activity", "delete"]239 links = [
240 "edit",
241 "reviewer",
242 "permissions",
243 "activity",
244 "webhooks",
245 "delete",
246 ]
215247
216 @enabled_with_permission("launchpad.Edit")248 @enabled_with_permission("launchpad.Edit")
217 def edit(self):249 def edit(self):
@@ -224,6 +256,11 @@
224 return Link("+reviewer", text, icon="edit")256 return Link("+reviewer", text, icon="edit")
225257
226 @enabled_with_permission("launchpad.Edit")258 @enabled_with_permission("launchpad.Edit")
259 def permissions(self):
260 text = "Manage permissions"
261 return Link("+permissions", text, icon="edit")
262
263 @enabled_with_permission("launchpad.Edit")
227 def webhooks(self):264 def webhooks(self):
228 text = "Manage webhooks"265 text = "Manage webhooks"
229 return Link(266 return Link(
@@ -709,6 +746,489 @@
709 return self, ()746 return self, ()
710747
711748
749def encode_form_field_id(value):
750 """Encode text for use in form field names.
751
752 We use a modified version of base32 which fits into CSS identifiers and
753 so doesn't cause FormattersAPI.zope_css_id to do unhelpful things.
754 """
755 return base64.b32encode(
756 value.encode("UTF-8")).decode("UTF-8").replace("=", "_")
757
758
759def decode_form_field_id(encoded):
760 """Inverse of `encode_form_field_id`."""
761 return base64.b32decode(
762 encoded.replace("_", "=").encode("UTF-8")).decode("UTF-8")
763
764
765class GitRulePatternField(UniqueField):
766
767 errormessage = _("%s is already in use by another rule")
768 attribute = "ref_pattern"
769 _content_iface = IGitRepository
770
771 def __init__(self, ref_prefix, rule=None, *args, **kwargs):
772 self.ref_prefix = ref_prefix
773 self.rule = rule
774 super(GitRulePatternField, self).__init__(*args, **kwargs)
775
776 def _getByAttribute(self, ref_pattern):
777 """See `UniqueField`."""
778 if self._content_iface.providedBy(self.context):
779 return self.context.getRule(self.ref_prefix + ref_pattern)
780 else:
781 return None
782
783 def unchanged(self, input):
784 """See `UniqueField`."""
785 return (
786 self.rule is not None and
787 self.ref_prefix + input == self.rule.ref_pattern)
788
789 def set(self, object, value):
790 """See `IField`."""
791 if value is not None:
792 value = value.strip()
793 super(GitRulePatternField, self).set(object, value)
794
795
796class GitRepositoryPermissionsView(LaunchpadFormView):
797 """A view to manage repository permissions."""
798
799 heads_prefix = u"refs/heads/"
800 tags_prefix = u"refs/tags/"
801
802 @property
803 def label(self):
804 return "Manage permissions for %s" % self.context.identity
805
806 page_title = "Manage permissions"
807
808 @property
809 def repository(self):
810 return self.context
811
812 @cachedproperty
813 def rules(self):
814 return self.repository.getRules()
815
816 @property
817 def branch_rules(self):
818 return [
819 rule for rule in self.rules
820 if rule.ref_pattern.startswith(self.heads_prefix)]
821
822 @property
823 def tag_rules(self):
824 return [
825 rule for rule in self.rules
826 if rule.ref_pattern.startswith(self.tags_prefix)]
827
828 @property
829 def other_rules(self):
830 return [
831 rule for rule in self.rules
832 if not rule.ref_pattern.startswith(self.heads_prefix) and
833 not rule.ref_pattern.startswith(self.tags_prefix)]
834
835 def _getRuleGrants(self, rule):
836 def grantee_key(grant):
837 if grant.grantee is not None:
838 return (grant.grantee_type, grant.grantee.name)
839 else:
840 return (grant.grantee_type,)
841
842 return sorted(rule.grants, key=grantee_key)
843
844 def _parseRefPattern(self, ref_pattern):
845 """Parse a pattern into a prefix and the displayed portion."""
846 for prefix in (self.heads_prefix, self.tags_prefix):
847 if ref_pattern.startswith(prefix):
848 return prefix, ref_pattern[len(prefix):]
849 return u"", ref_pattern
850
851 def _getFieldName(self, name, ref_pattern, grantee=None):
852 """Get the combined field name for a ref pattern and optional grantee.
853
854 In order to be able to render a permissions table, we encode the ref
855 pattern and the grantee in the form field name.
856 """
857 suffix = "." + encode_form_field_id(ref_pattern)
858 if grantee is not None:
859 if IPerson.providedBy(grantee):
860 suffix += "." + str(grantee.id)
861 else:
862 suffix += "._" + grantee.name.lower()
863 return name + suffix
864
865 def _parseFieldName(self, field_name):
866 """Parse a combined field name as described in `_getFieldName`.
867
868 :raises UnexpectedFormData: if the field name cannot be parsed or
869 the grantee cannot be found.
870 """
871 field_bits = field_name.split(".")
872 if len(field_bits) < 2:
873 raise UnexpectedFormData(
874 "Cannot parse field name: %s" % field_name)
875 field_type = field_bits[0]
876 try:
877 ref_pattern = decode_form_field_id(field_bits[1])
878 except TypeError:
879 raise UnexpectedFormData(
880 "Cannot parse field name: %s" % field_name)
881 if len(field_bits) > 2:
882 grantee_id = field_bits[2]
883 if grantee_id.startswith("_"):
884 grantee_id = grantee_id[1:]
885 try:
886 grantee = GitGranteeType.getTermByToken(grantee_id).value
887 except LookupError:
888 grantee = None
889 else:
890 try:
891 grantee_id = int(grantee_id)
892 except ValueError:
893 grantee = None
894 else:
895 grantee = getUtility(IPersonSet).get(grantee_id)
896 if grantee is None or grantee == GitGranteeType.PERSON:
897 raise UnexpectedFormData("No such grantee: %s" % grantee_id)
898 else:
899 grantee = None
900 return field_type, ref_pattern, grantee
901
902 def _getPermissionsTerm(self, grant):
903 """Return a term from `GitPermissionsVocabulary` for this grant."""
904 vocabulary = getVocabularyRegistry().get(grant, "GitPermissions")
905 try:
906 return vocabulary.getTerm(grant.permissions)
907 except LookupError:
908 # This should never happen, because GitPermissionsVocabulary
909 # adds a custom term for the context grant if necessary.
910 raise AssertionError(
911 "Could not find GitPermissions term for %r" % grant)
912
913 def setUpFields(self):
914 """See `LaunchpadFormView`."""
915 position_fields = []
916 pattern_fields = []
917 delete_fields = []
918 readonly_grantee_fields = []
919 grantee_fields = []
920 permissions_fields = []
921
922 default_permissions_by_prefix = {
923 self.heads_prefix: "can_push",
924 self.tags_prefix: "can_create",
925 "": "can_push",
926 }
927
928 for rule_index, rule in enumerate(self.rules):
929 # Remove the usual branch/tag prefixes from patterns. The full
930 # pattern goes into form field names, so no data is lost here.
931 ref_pattern = rule.ref_pattern
932 ref_prefix, short_pattern = self._parseRefPattern(ref_pattern)
933 position_fields.append(
934 Int(
935 __name__=self._getFieldName("position", ref_pattern),
936 required=True, readonly=False, default=rule_index + 1))
937 pattern_fields.append(
938 GitRulePatternField(
939 __name__=self._getFieldName("pattern", ref_pattern),
940 required=True, readonly=False, ref_prefix=ref_prefix,
941 rule=rule, default=short_pattern))
942 delete_fields.append(
943 Bool(
944 __name__=self._getFieldName("delete", ref_pattern),
945 readonly=False, default=False))
946 for grant in self._getRuleGrants(rule):
947 grantee = grant.combined_grantee
948 readonly_grantee_fields.append(
949 GitGranteeField(
950 __name__=self._getFieldName(
951 "grantee", ref_pattern, grantee),
952 required=False, readonly=True, default=grantee,
953 rule=rule))
954 permissions_fields.append(
955 Choice(
956 __name__=self._getFieldName(
957 "permissions", ref_pattern, grantee),
958 source=GitPermissionsVocabulary(grant),
959 readonly=False,
960 default=self._getPermissionsTerm(grant).value))
961 delete_fields.append(
962 Bool(
963 __name__=self._getFieldName(
964 "delete", ref_pattern, grantee),
965 readonly=False, default=False))
966 grantee_fields.append(
967 GitGranteeField(
968 __name__=self._getFieldName("grantee", ref_pattern),
969 required=False, readonly=False, rule=rule))
970 permissions_vocabulary = GitPermissionsVocabulary(rule)
971 permissions_fields.append(
972 Choice(
973 __name__=self._getFieldName(
974 "permissions", ref_pattern),
975 source=permissions_vocabulary, readonly=False,
976 default=permissions_vocabulary.getTermByToken(
977 default_permissions_by_prefix[ref_prefix]).value))
978 for ref_prefix in (self.heads_prefix, self.tags_prefix):
979 position_fields.append(
980 Int(
981 __name__=self._getFieldName("new-position", ref_prefix),
982 required=False, readonly=True))
983 pattern_fields.append(
984 GitRulePatternField(
985 __name__=self._getFieldName("new-pattern", ref_prefix),
986 required=False, readonly=False, ref_prefix=ref_prefix))
987
988 self.form_fields = (
989 form.FormFields(
990 *position_fields,
991 custom_widget=CustomWidgetFactory(IntWidget, displayWidth=2)) +
992 form.FormFields(*pattern_fields) +
993 form.FormFields(*delete_fields) +
994 form.FormFields(
995 *readonly_grantee_fields,
996 custom_widget=CustomWidgetFactory(GitGranteeDisplayWidget)) +
997 form.FormFields(
998 *grantee_fields,
999 custom_widget=CustomWidgetFactory(GitGranteeWidget)) +
1000 form.FormFields(*permissions_fields))
1001
1002 def setUpWidgets(self, context=None):
1003 """See `LaunchpadFormView`."""
1004 super(GitRepositoryPermissionsView, self).setUpWidgets(
1005 context=context)
1006 for widget in self.widgets:
1007 widget.display_label = False
1008 widget.hint = None
1009
1010 @property
1011 def cancel_url(self):
1012 return canonical_url(self.context)
1013
1014 def getRuleWidgets(self, rule):
1015 widgets_by_name = {widget.name: widget for widget in self.widgets}
1016 ref_pattern = rule.ref_pattern
1017 position_field_name = (
1018 "field." + self._getFieldName("position", ref_pattern))
1019 pattern_field_name = (
1020 "field." + self._getFieldName("pattern", ref_pattern))
1021 delete_field_name = (
1022 "field." + self._getFieldName("delete", ref_pattern))
1023 grant_widgets = []
1024 for grant in self._getRuleGrants(rule):
1025 grantee = grant.combined_grantee
1026 grantee_field_name = (
1027 "field." + self._getFieldName("grantee", ref_pattern, grantee))
1028 permissions_field_name = (
1029 "field." +
1030 self._getFieldName("permissions", ref_pattern, grantee))
1031 delete_grant_field_name = (
1032 "field." + self._getFieldName("delete", ref_pattern, grantee))
1033 grant_widgets.append({
1034 "grantee": widgets_by_name[grantee_field_name],
1035 "permissions": widgets_by_name[permissions_field_name],
1036 "delete": widgets_by_name[delete_grant_field_name],
1037 })
1038 new_grantee_field_name = (
1039 "field." + self._getFieldName("grantee", ref_pattern))
1040 new_permissions_field_name = (
1041 "field." + self._getFieldName("permissions", ref_pattern))
1042 new_grant_widgets = {
1043 "grantee": widgets_by_name[new_grantee_field_name],
1044 "permissions": widgets_by_name[new_permissions_field_name],
1045 }
1046 return {
1047 "position": widgets_by_name[position_field_name],
1048 "pattern": widgets_by_name[pattern_field_name],
1049 "delete": widgets_by_name.get(delete_field_name),
1050 "grants": grant_widgets,
1051 "new_grant": new_grant_widgets,
1052 }
1053
1054 def getNewRuleWidgets(self, ref_prefix):
1055 widgets_by_name = {widget.name: widget for widget in self.widgets}
1056 new_position_field_name = (
1057 "field." + self._getFieldName("new-position", ref_prefix))
1058 new_pattern_field_name = (
1059 "field." + self._getFieldName("new-pattern", ref_prefix))
1060 return {
1061 "position": widgets_by_name[new_position_field_name],
1062 "pattern": widgets_by_name[new_pattern_field_name],
1063 }
1064
1065 def parseData(self, data):
1066 """Rearrange form data to make it easier to process."""
1067 parsed_data = {
1068 "rules": {},
1069 "grants": defaultdict(list),
1070 }
1071
1072 for field_name in sorted(
1073 name for name in data if name.split(".")[0] == "pattern"):
1074 _, ref_pattern, _ = self._parseFieldName(field_name)
1075 prefix, _ = self._parseRefPattern(ref_pattern)
1076 position_field_name = self._getFieldName("position", ref_pattern)
1077 delete_field_name = self._getFieldName("delete", ref_pattern)
1078 if data.get(delete_field_name):
1079 position = None
1080 action = "delete"
1081 else:
1082 position = max(0, data[position_field_name] - 1)
1083 action = "change"
1084 parsed_data["rules"].setdefault(ref_pattern, {
1085 "position": position,
1086 "pattern": ref_pattern,
1087 "new_pattern": prefix + data[field_name],
1088 "action": action,
1089 })
1090
1091 for field_name in sorted(
1092 name for name in data if name.split(".")[0] == "new-pattern"):
1093 _, prefix, _ = self._parseFieldName(field_name)
1094 position_field_name = self._getFieldName("position", prefix)
1095 if not data[field_name]:
1096 continue
1097 if position_field_name in data:
1098 position = max(0, data[position_field_name] - 1)
1099 else:
1100 position = None
1101 parsed_data["rules"].setdefault(prefix, {
1102 "position": position,
1103 "pattern": prefix + data[field_name],
1104 "action": "add",
1105 })
1106
1107 for field_name in sorted(
1108 name for name in data if name.split(".")[0] == "permissions"):
1109 _, ref_pattern, grantee = self._parseFieldName(field_name)
1110 grantee_field_name = self._getFieldName("grantee", ref_pattern)
1111 delete_field_name = self._getFieldName(
1112 "delete", ref_pattern, grantee)
1113 if grantee is None:
1114 grantee = data.get(grantee_field_name)
1115 if grantee is None:
1116 continue
1117 action = "add"
1118 elif data.get(delete_field_name):
1119 action = "delete"
1120 else:
1121 action = "change"
1122 parsed_data["grants"][ref_pattern].append({
1123 "grantee": grantee,
1124 "permissions": data[field_name],
1125 "action": action,
1126 })
1127
1128 return parsed_data
1129
1130 def updateRepositoryFromData(self, repository, parsed_data):
1131 # Fetch rules before making any changes, since their ref_patterns
1132 # may change as a result of this update.
1133 rule_map = {rule.ref_pattern: rule for rule in self.repository.rules}
1134 grant_map = {
1135 (grant.rule.ref_pattern, grant.combined_grantee): grant
1136 for grant in self.repository.grants}
1137
1138 # Patterns must be processed in rule order so that position changes
1139 # work in a reasonably natural way. Process new rules last.
1140 ordered_rules = []
1141 for ref_pattern, parsed_rule in parsed_data["rules"].items():
1142 rule = rule_map.get(ref_pattern)
1143 if parsed_rule["action"] == "add":
1144 ordered_rules.append((ref_pattern, parsed_rule, -1))
1145 elif rule is not None:
1146 # Ignore attempts to change or delete rules that have
1147 # already been deleted by somebody else.
1148 ordered_rules.append((ref_pattern, parsed_rule, rule.position))
1149 ordered_rules.sort(
1150 key=lambda item: (item[1]["action"] != "add", item[2]))
1151
1152 for ref_pattern, parsed_rule, position in ordered_rules:
1153 rule = rule_map.get(parsed_rule["pattern"])
1154 action = parsed_rule["action"]
1155 if action not in ("add", "change", "delete"):
1156 raise AssertionError(
1157 "unknown action: %s" % parsed_rule["action"])
1158
1159 if action == "add" and rule is None:
1160 rule = repository.addRule(
1161 parsed_rule["pattern"], self.user,
1162 position=parsed_rule["position"])
1163 if ref_pattern == self.tags_prefix:
1164 # Tags are a special case: on creation, they
1165 # automatically get a grant of create permissions to
1166 # the repository owner (suppressing the normal
1167 # ability of the repository owner to push protected
1168 # references).
1169 rule.addGrant(
1170 GitGranteeType.REPOSITORY_OWNER, self.user,
1171 can_create=True)
1172 elif action == "change" and rule is not None:
1173 self.repository.moveRule(
1174 rule, parsed_rule["position"], self.user)
1175 if parsed_rule["new_pattern"] != rule.ref_pattern:
1176 with notify_modified(rule, ["ref_pattern"]):
1177 rule.ref_pattern = parsed_rule["new_pattern"]
1178 elif action == "delete" and rule is not None:
1179 rule.destroySelf(self.user)
1180 rule_map[parsed_rule["pattern"]] = None
1181 else:
1182 raise AssertionError(
1183 "unknown action: %s" % parsed_rule["action"])
1184
1185 for ref_pattern, parsed_grants in sorted(
1186 parsed_data["grants"].items()):
1187 if ref_pattern not in rule_map:
1188 self.addError(structured(
1189 "Cannot edit grants for nonexistent rule %s", ref_pattern))
1190 return
1191 rule = rule_map.get(ref_pattern)
1192 if rule is None:
1193 # Already deleted.
1194 continue
1195
1196 for parsed_grant in parsed_grants:
1197 grant = grant_map.get((ref_pattern, parsed_grant["grantee"]))
1198 action = parsed_grant["action"]
1199 if action not in ("add", "change", "delete"):
1200 raise AssertionError(
1201 "unknown action: %s" % parsed_rule["action"])
1202
1203 if action == "add" and grant is None:
1204 rule.addGrant(
1205 parsed_grant["grantee"], self.user,
1206 permissions=parsed_grant["permissions"])
1207 elif (action in ("add", "change") and grant is not None and
1208 parsed_grant["permissions"] != grant.permissions):
1209 # Make the requested changes. This can happen in the
1210 # add case if somebody else added the grant since the
1211 # form was last rendered, in which case updating it with
1212 # the permissions from this request seems best.
1213 with notify_modified(
1214 grant,
1215 ["can_create", "can_push", "can_force_push"]):
1216 grant.permissions = parsed_grant["permissions"]
1217 elif action == "delete" and grant is not None:
1218 grant.destroySelf(self.user)
1219 grant_map[(ref_pattern, parsed_grant["grantee"])] = None
1220
1221 @action("Save", name="save")
1222 def save_action(self, action, data):
1223 with notify_modified(self.repository, []):
1224 parsed_data = self.parseData(data)
1225 self.updateRepositoryFromData(self.repository, parsed_data)
1226
1227 self.request.response.addNotification(
1228 "Saved permissions for %s" % self.context.identity)
1229 self.next_url = canonical_url(self.context, view_name="+permissions")
1230
1231
712class GitRepositoryDeletionView(LaunchpadFormView):1232class GitRepositoryDeletionView(LaunchpadFormView):
7131233
714 schema = IGitRepository1234 schema = IGitRepository
7151235
=== modified file 'lib/lp/code/browser/tests/test_gitrepository.py'
--- lib/lp/code/browser/tests/test_gitrepository.py 2018-11-08 15:33:03 +0000
+++ lib/lp/code/browser/tests/test_gitrepository.py 2019-01-10 10:50:57 +0000
@@ -1,4 +1,4 @@
1# Copyright 2015-2018 Canonical Ltd. This software is licensed under the1# Copyright 2015-2019 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Unit tests for GitRepositoryView."""4"""Unit tests for GitRepositoryView."""
@@ -7,16 +7,27 @@
77
8__metaclass__ = type8__metaclass__ = type
99
10import base64
10from datetime import datetime11from datetime import datetime
11import doctest12import doctest
13from itertools import chain
14from operator import attrgetter
15import re
12from textwrap import dedent16from textwrap import dedent
1317
14from fixtures import FakeLogger18from fixtures import FakeLogger
15import pytz19import pytz
20import soupmatchers
16from storm.store import Store21from storm.store import Store
17from testtools.matchers import (22from testtools.matchers import (
23 AfterPreprocessing,
18 DocTestMatches,24 DocTestMatches,
19 Equals,25 Equals,
26 Is,
27 MatchesDict,
28 MatchesListwise,
29 MatchesSetwise,
30 MatchesStructure,
20 )31 )
21import transaction32import transaction
22from zope.component import getUtility33from zope.component import getUtility
@@ -26,11 +37,16 @@
26from zope.security.proxy import removeSecurityProxy37from zope.security.proxy import removeSecurityProxy
2738
28from lp.app.enums import InformationType39from lp.app.enums import InformationType
40from lp.app.errors import UnexpectedFormData
29from lp.app.interfaces.launchpad import ILaunchpadCelebrities41from lp.app.interfaces.launchpad import ILaunchpadCelebrities
30from lp.app.interfaces.services import IService42from lp.app.interfaces.services import IService
43from lp.code.browser.gitrepository import encode_form_field_id
31from lp.code.enums import (44from lp.code.enums import (
32 BranchMergeProposalStatus,45 BranchMergeProposalStatus,
33 CodeReviewVote,46 CodeReviewVote,
47 GitActivityType,
48 GitGranteeType,
49 GitPermissionType,
34 GitRepositoryType,50 GitRepositoryType,
35 )51 )
36from lp.code.interfaces.revision import IRevisionSet52from lp.code.interfaces.revision import IRevisionSet
@@ -40,7 +56,10 @@
40 VCSType,56 VCSType,
41 )57 )
42from lp.registry.interfaces.accesspolicy import IAccessPolicySource58from lp.registry.interfaces.accesspolicy import IAccessPolicySource
43from lp.registry.interfaces.person import PersonVisibility59from lp.registry.interfaces.person import (
60 IPerson,
61 PersonVisibility,
62 )
44from lp.services.beautifulsoup import BeautifulSoup63from lp.services.beautifulsoup import BeautifulSoup
45from lp.services.database.constants import UTC_NOW64from lp.services.database.constants import UTC_NOW
46from lp.services.features.testing import FeatureFixture65from lp.services.features.testing import FeatureFixture
@@ -67,6 +86,7 @@
67from lp.testing.pages import (86from lp.testing.pages import (
68 extract_text,87 extract_text,
69 find_tag_by_id,88 find_tag_by_id,
89 find_tags_by_class,
70 get_feedback_messages,90 get_feedback_messages,
71 setupBrowser,91 setupBrowser,
72 setupBrowserForUser,92 setupBrowserForUser,
@@ -1097,6 +1117,625 @@
1097 browser.headers["Content-Disposition"])1117 browser.headers["Content-Disposition"])
10981118
10991119
1120class TestGitRepositoryPermissionsView(BrowserTestCase):
1121
1122 layer = DatabaseFunctionalLayer
1123
1124 def test_rules_properties(self):
1125 repository = self.factory.makeGitRepository()
1126 heads_rule = self.factory.makeGitRule(
1127 repository=repository, ref_pattern="refs/heads/*")
1128 tags_rule = self.factory.makeGitRule(
1129 repository=repository, ref_pattern="refs/tags/*")
1130 catch_all_rule = self.factory.makeGitRule(
1131 repository=repository, ref_pattern="*")
1132 login_person(repository.owner)
1133 view = create_initialized_view(repository, name="+permissions")
1134 self.assertEqual([heads_rule], view.branch_rules)
1135 self.assertEqual([tags_rule], view.tag_rules)
1136 self.assertEqual([catch_all_rule], view.other_rules)
1137
1138 def test__getRuleGrants(self):
1139 rule = self.factory.makeGitRule()
1140 grantees = sorted(
1141 [self.factory.makePerson() for _ in range(3)],
1142 key=attrgetter("name"))
1143 for grantee in (grantees[1], grantees[0], grantees[2]):
1144 self.factory.makeGitRuleGrant(rule=rule, grantee=grantee)
1145 self.factory.makeGitRuleGrant(
1146 rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER)
1147 login_person(rule.repository.owner)
1148 view = create_initialized_view(rule.repository, name="+permissions")
1149 self.assertThat(view._getRuleGrants(rule), MatchesListwise([
1150 MatchesStructure.byEquality(
1151 grantee_type=GitGranteeType.REPOSITORY_OWNER),
1152 MatchesStructure.byEquality(grantee=grantees[0]),
1153 MatchesStructure.byEquality(grantee=grantees[1]),
1154 MatchesStructure.byEquality(grantee=grantees[2]),
1155 ]))
1156
1157 def test__parseRefPattern(self):
1158 repository = self.factory.makeGitRepository()
1159 login_person(repository.owner)
1160 view = create_initialized_view(repository, name="+permissions")
1161 self.assertEqual(
1162 ("refs/heads/", "stable/*"),
1163 view._parseRefPattern("refs/heads/stable/*"))
1164 self.assertEqual(
1165 ("refs/tags/", "1.0"), view._parseRefPattern("refs/tags/1.0"))
1166 self.assertEqual(
1167 ("", "refs/other/*"), view._parseRefPattern("refs/other/*"))
1168 self.assertEqual(("", "*"), view._parseRefPattern("*"))
1169
1170 def test__getFieldName_no_grantee(self):
1171 repository = self.factory.makeGitRepository()
1172 login_person(repository.owner)
1173 view = create_initialized_view(repository, name="+permissions")
1174 encoded_ref_pattern = base64.b32encode(
1175 b"refs/heads/*").replace("=", "_").decode("UTF-8")
1176 self.assertEqual(
1177 "field.%s" % encoded_ref_pattern,
1178 view._getFieldName("field", "refs/heads/*"))
1179
1180 def test__getFieldName_grantee_repository_owner(self):
1181 repository = self.factory.makeGitRepository()
1182 login_person(repository.owner)
1183 view = create_initialized_view(repository, name="+permissions")
1184 encoded_ref_pattern = base64.b32encode(
1185 b"refs/tags/*").replace("=", "_").decode("UTF-8")
1186 self.assertEqual(
1187 "field.%s._repository_owner" % encoded_ref_pattern,
1188 view._getFieldName(
1189 "field", "refs/tags/*",
1190 grantee=GitGranteeType.REPOSITORY_OWNER))
1191
1192 def test__getFieldName_grantee_person(self):
1193 repository = self.factory.makeGitRepository()
1194 grantee = self.factory.makePerson()
1195 login_person(repository.owner)
1196 view = create_initialized_view(repository, name="+permissions")
1197 encoded_ref_pattern = base64.b32encode(
1198 b"refs/*").replace("=", "_").decode("UTF-8")
1199 self.assertEqual(
1200 "field.%s.%s" % (encoded_ref_pattern, grantee.id),
1201 view._getFieldName("field", "refs/*", grantee=grantee))
1202
1203 def test__parseFieldName_too_few_components(self):
1204 repository = self.factory.makeGitRepository()
1205 login_person(repository.owner)
1206 view = create_initialized_view(repository, name="+permissions")
1207 self.assertRaises(UnexpectedFormData, view._parseFieldName, "field")
1208
1209 def test__parseFieldName_bad_ref_pattern(self):
1210 repository = self.factory.makeGitRepository()
1211 login_person(repository.owner)
1212 view = create_initialized_view(repository, name="+permissions")
1213 self.assertRaises(
1214 UnexpectedFormData, view._parseFieldName, "field.nonsense")
1215
1216 def test__parseFieldName_no_grantee(self):
1217 repository = self.factory.makeGitRepository()
1218 login_person(repository.owner)
1219 view = create_initialized_view(repository, name="+permissions")
1220 encoded_ref_pattern = base64.b32encode(
1221 b"refs/heads/*").replace("=", "_").decode("UTF-8")
1222 self.assertEqual(
1223 ("permissions", "refs/heads/*", None),
1224 view._parseFieldName("permissions.%s" % encoded_ref_pattern))
1225
1226 def test__parseFieldName_grantee_unknown_type(self):
1227 repository = self.factory.makeGitRepository()
1228 login_person(repository.owner)
1229 view = create_initialized_view(repository, name="+permissions")
1230 encoded_ref_pattern = base64.b32encode(
1231 b"refs/tags/*").replace("=", "_").decode("UTF-8")
1232 self.assertRaises(
1233 UnexpectedFormData, view._parseFieldName,
1234 "field.%s._nonsense" % encoded_ref_pattern)
1235 self.assertRaises(
1236 UnexpectedFormData, view._parseFieldName,
1237 "field.%s._person" % encoded_ref_pattern)
1238
1239 def test__parseFieldName_grantee_repository_owner(self):
1240 repository = self.factory.makeGitRepository()
1241 login_person(repository.owner)
1242 view = create_initialized_view(repository, name="+permissions")
1243 encoded_ref_pattern = base64.b32encode(
1244 b"refs/tags/*").replace("=", "_").decode("UTF-8")
1245 self.assertEqual(
1246 ("pattern", "refs/tags/*", GitGranteeType.REPOSITORY_OWNER),
1247 view._parseFieldName(
1248 "pattern.%s._repository_owner" % encoded_ref_pattern))
1249
1250 def test__parseFieldName_grantee_unknown_person(self):
1251 repository = self.factory.makeGitRepository()
1252 grantee = self.factory.makePerson()
1253 login_person(repository.owner)
1254 view = create_initialized_view(repository, name="+permissions")
1255 encoded_ref_pattern = base64.b32encode(
1256 b"refs/*").replace("=", "_").decode("UTF-8")
1257 self.assertRaises(
1258 UnexpectedFormData, view._parseFieldName,
1259 "delete.%s.%s" % (encoded_ref_pattern, grantee.id * 2))
1260
1261 def test__parseFieldName_grantee_person(self):
1262 repository = self.factory.makeGitRepository()
1263 grantee = self.factory.makePerson()
1264 login_person(repository.owner)
1265 view = create_initialized_view(repository, name="+permissions")
1266 encoded_ref_pattern = base64.b32encode(
1267 b"refs/*").replace("=", "_").decode("UTF-8")
1268 self.assertEqual(
1269 ("delete", "refs/*", grantee),
1270 view._parseFieldName(
1271 "delete.%s.%s" % (encoded_ref_pattern, grantee.id)))
1272
1273 def test__getPermissionsTerm_standard(self):
1274 grant = self.factory.makeGitRuleGrant(
1275 ref_pattern="refs/heads/*", can_create=True, can_push=True)
1276 login_person(grant.repository.owner)
1277 view = create_initialized_view(grant.repository, name="+permissions")
1278 self.assertThat(
1279 view._getPermissionsTerm(grant), MatchesStructure.byEquality(
1280 value={
1281 GitPermissionType.CAN_CREATE, GitPermissionType.CAN_PUSH},
1282 token="can_push",
1283 title="Can push"))
1284
1285 def test__getPermissionsTerm_custom(self):
1286 grant = self.factory.makeGitRuleGrant(
1287 ref_pattern="refs/heads/*", can_force_push=True)
1288 login_person(grant.repository.owner)
1289 view = create_initialized_view(grant.repository, name="+permissions")
1290 self.assertThat(
1291 view._getPermissionsTerm(grant), MatchesStructure.byEquality(
1292 value={GitPermissionType.CAN_FORCE_PUSH},
1293 token="custom",
1294 title="Custom permissions: force-push"))
1295
1296 def _matchesCells(self, row_tag, cell_matchers):
1297 return AfterPreprocessing(
1298 str, soupmatchers.HTMLContains(*(
1299 soupmatchers.Within(row_tag, cell_matcher)
1300 for cell_matcher in cell_matchers)))
1301
1302 def _matchesRule(self, position, pattern, short_pattern):
1303 rule_tag = soupmatchers.Tag(
1304 "rule row", "tr", attrs={"class": "git-rule"})
1305 suffix = "." + encode_form_field_id(pattern)
1306 position_field_name = "field.position" + suffix
1307 pattern_field_name = "field.pattern" + suffix
1308 delete_field_name = "field.delete" + suffix
1309 return self._matchesCells(rule_tag, [
1310 soupmatchers.Within(
1311 soupmatchers.Tag(
1312 "position cell", "td",
1313 attrs={"class": "git-rule-position"}),
1314 soupmatchers.Tag(
1315 "position widget", "input",
1316 attrs={"name": position_field_name, "value": position})),
1317 soupmatchers.Within(
1318 soupmatchers.Tag(
1319 "pattern cell", "td", attrs={"class": "git-rule-pattern"}),
1320 soupmatchers.Tag(
1321 "pattern widget", "input",
1322 attrs={
1323 "name": pattern_field_name,
1324 "value": short_pattern,
1325 })),
1326 soupmatchers.Within(
1327 soupmatchers.Tag(
1328 "delete cell", "td", attrs={"class": "git-rule-delete"}),
1329 soupmatchers.Tag(
1330 "delete widget", "input",
1331 attrs={"name": delete_field_name})),
1332 ])
1333
1334 def _matchesNewRule(self, ref_prefix):
1335 new_rule_tag = soupmatchers.Tag(
1336 "new rule row", "tr", attrs={"class": "git-new-rule"})
1337 suffix = "." + encode_form_field_id(ref_prefix)
1338 new_position_field_name = "field.new-position" + suffix
1339 new_pattern_field_name = "field.new-pattern" + suffix
1340 return self._matchesCells(new_rule_tag, [
1341 soupmatchers.Within(
1342 soupmatchers.Tag(
1343 "position cell", "td",
1344 attrs={"class": "git-rule-position"}),
1345 soupmatchers.Tag(
1346 "position widget", "input",
1347 attrs={"name": new_position_field_name, "value": ""})),
1348 soupmatchers.Within(
1349 soupmatchers.Tag(
1350 "pattern cell", "td", attrs={"class": "git-rule-pattern"}),
1351 soupmatchers.Tag(
1352 "pattern widget", "input",
1353 attrs={"name": new_pattern_field_name, "value": ""})),
1354 ])
1355
1356 def _matchesRuleGrant(self, pattern, grantee, permissions_token,
1357 permissions_title):
1358 rule_grant_tag = soupmatchers.Tag(
1359 "rule grant row", "tr", attrs={"class": "git-rule-grant"})
1360 suffix = "." + encode_form_field_id(pattern)
1361 if IPerson.providedBy(grantee):
1362 suffix += "." + str(grantee.id)
1363 grantee_url = canonical_url(grantee, path_only_if_possible=True)
1364 grantee_widget_matcher = soupmatchers.Tag(
1365 "grantee widget", "a", attrs={"href": grantee_url},
1366 text=grantee.display_name)
1367 else:
1368 suffix += "._" + grantee.name.lower()
1369 grantee_widget_matcher = soupmatchers.Tag(
1370 "grantee widget", "label",
1371 text=re.compile(re.escape(grantee.title)))
1372 permissions_field_name = "field.permissions" + suffix
1373 delete_field_name = "field.delete" + suffix
1374 return self._matchesCells(rule_grant_tag, [
1375 soupmatchers.Within(
1376 soupmatchers.Tag(
1377 "grantee cell", "td",
1378 attrs={"class": "git-rule-grant-grantee"}),
1379 grantee_widget_matcher),
1380 soupmatchers.Within(
1381 soupmatchers.Tag(
1382 "permissions cell", "td",
1383 attrs={"class": "git-rule-grant-permissions"}),
1384 soupmatchers.Within(
1385 soupmatchers.Tag(
1386 "permissions widget", "select",
1387 attrs={"name": permissions_field_name}),
1388 soupmatchers.Tag(
1389 "selected permissions option", "option",
1390 attrs={
1391 "selected": "selected",
1392 "value": permissions_token,
1393 },
1394 text=permissions_title))),
1395 soupmatchers.Within(
1396 soupmatchers.Tag(
1397 "delete cell", "td", attrs={"class": "git-rule-delete"}),
1398 soupmatchers.Tag(
1399 "delete widget", "input",
1400 attrs={"name": delete_field_name})),
1401 ])
1402
1403 def _matchesNewRuleGrant(self, pattern, permissions_token):
1404 rule_grant_tag = soupmatchers.Tag(
1405 "rule grant row", "tr", attrs={"class": "git-new-rule-grant"})
1406 suffix = "." + encode_form_field_id(pattern)
1407 grantee_field_name = "field.grantee" + suffix
1408 permissions_field_name = "field.permissions" + suffix
1409 return self._matchesCells(rule_grant_tag, [
1410 soupmatchers.Within(
1411 soupmatchers.Tag(
1412 "grantee cell", "td",
1413 attrs={"class": "git-rule-grant-grantee"}),
1414 soupmatchers.Tag(
1415 "grantee widget", "input",
1416 attrs={"name": grantee_field_name})),
1417 soupmatchers.Within(
1418 soupmatchers.Tag(
1419 "permissions cell", "td",
1420 attrs={"class": "git-rule-grant-permissions"}),
1421 soupmatchers.Within(
1422 soupmatchers.Tag(
1423 "permissions widget", "select",
1424 attrs={"name": permissions_field_name}),
1425 soupmatchers.Tag(
1426 "selected permissions option", "option",
1427 attrs={
1428 "selected": "selected",
1429 "value": permissions_token,
1430 }))),
1431 ])
1432
1433 def test_rules_tables(self):
1434 repository = self.factory.makeGitRepository()
1435 heads_rule = self.factory.makeGitRule(
1436 repository=repository, ref_pattern="refs/heads/stable/*")
1437 heads_grantee_1 = self.factory.makePerson(
1438 name=self.factory.getUniqueString("person-name-a"))
1439 heads_grantee_2 = self.factory.makePerson(
1440 name=self.factory.getUniqueString("person-name-b"))
1441 self.factory.makeGitRuleGrant(
1442 rule=heads_rule, grantee=heads_grantee_1, can_push=True)
1443 self.factory.makeGitRuleGrant(
1444 rule=heads_rule, grantee=heads_grantee_2, can_force_push=True)
1445 tags_rule = self.factory.makeGitRule(
1446 repository=repository, ref_pattern="refs/tags/*")
1447 self.factory.makeGitRuleGrant(
1448 rule=tags_rule, grantee=GitGranteeType.REPOSITORY_OWNER)
1449 login_person(repository.owner)
1450 view = create_initialized_view(
1451 repository, name="+permissions", principal=repository.owner)
1452 rules_tables = find_tags_by_class(view(), "git-rules-table")
1453 rows = list(chain.from_iterable([
1454 rules_table.findAll("tr", {"class": True})
1455 for rules_table in rules_tables]))
1456 self.assertThat(rows, MatchesListwise([
1457 self._matchesRule("1", "refs/heads/stable/*", "stable/*"),
1458 self._matchesRuleGrant(
1459 "refs/heads/stable/*", heads_grantee_1, "can_push_existing",
1460 "Can push if the branch already exists"),
1461 self._matchesRuleGrant(
1462 "refs/heads/stable/*", heads_grantee_2, "custom",
1463 "Custom permissions: force-push"),
1464 self._matchesNewRuleGrant("refs/heads/stable/*", "can_push"),
1465 self._matchesNewRule("refs/heads/"),
1466 self._matchesRule("2", "refs/tags/*", "*"),
1467 self._matchesRuleGrant(
1468 "refs/tags/*", GitGranteeType.REPOSITORY_OWNER,
1469 "cannot_create", "Cannot create"),
1470 self._matchesNewRuleGrant("refs/tags/*", "can_create"),
1471 self._matchesNewRule("refs/tags/"),
1472 ]))
1473
1474 def assertHasRules(self, repository, ref_patterns):
1475 self.assertThat(list(repository.rules), MatchesListwise([
1476 MatchesStructure.byEquality(ref_pattern=ref_pattern)
1477 for ref_pattern in ref_patterns
1478 ]))
1479
1480 def assertHasSavedNotification(self, view, repository):
1481 self.assertThat(view.request.response.notifications, MatchesListwise([
1482 MatchesStructure.byEquality(
1483 message="Saved permissions for %s" % repository.identity),
1484 ]))
1485
1486 def test_save_add_rules(self):
1487 repository = self.factory.makeGitRepository()
1488 self.factory.makeGitRule(
1489 repository=repository, ref_pattern="refs/heads/stable/*")
1490 removeSecurityProxy(repository.getActivity()).remove()
1491 login_person(repository.owner)
1492 encoded_heads_prefix = encode_form_field_id("refs/heads/")
1493 encoded_tags_prefix = encode_form_field_id("refs/tags/")
1494 form = {
1495 "field.new-pattern." + encoded_heads_prefix: "*",
1496 "field.new-pattern." + encoded_tags_prefix: "1.0",
1497 "field.actions.save": "Save",
1498 }
1499 view = create_initialized_view(
1500 repository, name="+permissions", form=form,
1501 principal=repository.owner)
1502 self.assertHasRules(
1503 repository,
1504 ["refs/tags/1.0", "refs/heads/stable/*", "refs/heads/*"])
1505 self.assertThat(list(repository.getActivity()), MatchesListwise([
1506 # Adding a tag rule automatically adds a repository owner grant.
1507 MatchesStructure(
1508 changer=Equals(repository.owner),
1509 changee=Is(None),
1510 what_changed=Equals(GitActivityType.GRANT_ADDED),
1511 new_value=MatchesDict({
1512 "changee_type": Equals("Repository owner"),
1513 "ref_pattern": Equals("refs/tags/1.0"),
1514 "can_create": Is(True),
1515 "can_push": Is(False),
1516 "can_force_push": Is(False),
1517 })),
1518 MatchesStructure(
1519 changer=Equals(repository.owner),
1520 what_changed=Equals(GitActivityType.RULE_ADDED),
1521 new_value=MatchesDict({
1522 "ref_pattern": Equals("refs/tags/1.0"),
1523 "position": Equals(0),
1524 })),
1525 MatchesStructure(
1526 changer=Equals(repository.owner),
1527 what_changed=Equals(GitActivityType.RULE_ADDED),
1528 new_value=MatchesDict({
1529 "ref_pattern": Equals("refs/heads/*"),
1530 # Initially inserted at 1, although refs/tags/1.0 was
1531 # later inserted before it.
1532 "position": Equals(1),
1533 })),
1534 ]))
1535 self.assertHasSavedNotification(view, repository)
1536
1537 def test_save_add_duplicate_rule(self):
1538 repository = self.factory.makeGitRepository()
1539 self.factory.makeGitRule(
1540 repository=repository, ref_pattern="refs/heads/stable/*")
1541 transaction.commit()
1542 login_person(repository.owner)
1543 encoded_heads_prefix = encode_form_field_id("refs/heads/")
1544 form = {
1545 "field.new-pattern." + encoded_heads_prefix: "stable/*",
1546 "field.actions.save": "Save",
1547 }
1548 view = create_initialized_view(
1549 repository, name="+permissions", form=form,
1550 principal=repository.owner)
1551 self.assertThat(view.errors, MatchesListwise([
1552 MatchesStructure(
1553 field_name=Equals("new-pattern." + encoded_heads_prefix),
1554 errors=MatchesStructure.byEquality(
1555 args=("stable/* is already in use by another rule",))),
1556 ]))
1557 self.assertHasRules(repository, ["refs/heads/stable/*"])
1558
1559 def test_save_move_rule(self):
1560 repository = self.factory.makeGitRepository()
1561 self.factory.makeGitRule(
1562 repository=repository, ref_pattern="refs/heads/stable/*")
1563 self.factory.makeGitRule(
1564 repository=repository, ref_pattern="refs/heads/*/next")
1565 encoded_patterns = [
1566 encode_form_field_id(rule.ref_pattern)
1567 for rule in repository.rules]
1568 removeSecurityProxy(repository.getActivity()).remove()
1569 login_person(repository.owner)
1570 # Positions are 1-based in the UI.
1571 form = {
1572 "field.position." + encoded_patterns[0]: "2",
1573 "field.pattern." + encoded_patterns[0]: "stable/*",
1574 "field.position." + encoded_patterns[1]: "1",
1575 "field.pattern." + encoded_patterns[1]: "*/more-next",
1576 "field.actions.save": "Save",
1577 }
1578 view = create_initialized_view(
1579 repository, name="+permissions", form=form,
1580 principal=repository.owner)
1581 self.assertHasRules(
1582 repository, ["refs/heads/*/more-next", "refs/heads/stable/*"])
1583 self.assertThat(list(repository.getActivity()), MatchesListwise([
1584 MatchesStructure(
1585 changer=Equals(repository.owner),
1586 what_changed=Equals(GitActivityType.RULE_CHANGED),
1587 old_value=MatchesDict({
1588 "ref_pattern": Equals("refs/heads/*/next"),
1589 "position": Equals(0),
1590 }),
1591 new_value=MatchesDict({
1592 "ref_pattern": Equals("refs/heads/*/more-next"),
1593 "position": Equals(0),
1594 })),
1595 # Only one rule is recorded as moving; the other is already in
1596 # its new position by the time it's processed.
1597 MatchesStructure(
1598 changer=Equals(repository.owner),
1599 what_changed=Equals(GitActivityType.RULE_MOVED),
1600 old_value=MatchesDict({
1601 "ref_pattern": Equals("refs/heads/stable/*"),
1602 "position": Equals(0),
1603 }),
1604 new_value=MatchesDict({
1605 "ref_pattern": Equals("refs/heads/stable/*"),
1606 "position": Equals(1),
1607 })),
1608 ]))
1609 self.assertHasSavedNotification(view, repository)
1610
1611 def test_save_change_grants(self):
1612 repository = self.factory.makeGitRepository()
1613 stable_rule = self.factory.makeGitRule(
1614 repository=repository, ref_pattern="refs/heads/stable/*")
1615 next_rule = self.factory.makeGitRule(
1616 repository=repository, ref_pattern="refs/heads/*/next")
1617 grantees = [self.factory.makePerson() for _ in range(3)]
1618 self.factory.makeGitRuleGrant(
1619 rule=stable_rule, grantee=GitGranteeType.REPOSITORY_OWNER,
1620 can_create=True)
1621 self.factory.makeGitRuleGrant(
1622 rule=stable_rule,
1623 grantee=grantees[0], can_create=True, can_push=True)
1624 self.factory.makeGitRuleGrant(
1625 rule=next_rule, grantee=grantees[1],
1626 can_create=True, can_push=True, can_force_push=True)
1627 encoded_patterns = [
1628 encode_form_field_id(rule.ref_pattern)
1629 for rule in repository.rules]
1630 removeSecurityProxy(repository.getActivity()).remove()
1631 login_person(repository.owner)
1632 form = {
1633 "field.permissions.%s._repository_owner" % encoded_patterns[0]: (
1634 "can_push"),
1635 "field.permissions.%s.%s" % (
1636 encoded_patterns[0], grantees[0].id): "can_push",
1637 "field.delete.%s.%s" % (encoded_patterns[0], grantees[0].id): "on",
1638 "field.grantee.%s" % encoded_patterns[1]: "person",
1639 "field.grantee.%s.person" % encoded_patterns[1]: grantees[2].name,
1640 "field.permissions.%s" % encoded_patterns[1]: "can_push_existing",
1641 "field.actions.save": "Save",
1642 }
1643 view = create_initialized_view(
1644 repository, name="+permissions", form=form,
1645 principal=repository.owner)
1646 self.assertHasRules(
1647 repository, ["refs/heads/stable/*", "refs/heads/*/next"])
1648 self.assertThat(stable_rule.grants, MatchesSetwise(
1649 MatchesStructure.byEquality(
1650 grantee_type=GitGranteeType.REPOSITORY_OWNER,
1651 can_create=True, can_push=True, can_force_push=False)))
1652 self.assertThat(next_rule.grants, MatchesSetwise(
1653 MatchesStructure.byEquality(
1654 grantee=grantees[1],
1655 can_create=True, can_push=True, can_force_push=True),
1656 MatchesStructure.byEquality(
1657 grantee=grantees[2],
1658 can_create=False, can_push=True, can_force_push=False)))
1659 self.assertThat(repository.getActivity(), MatchesSetwise(
1660 MatchesStructure(
1661 changer=Equals(repository.owner),
1662 changee=Is(None),
1663 what_changed=Equals(GitActivityType.GRANT_CHANGED),
1664 old_value=Equals({
1665 "changee_type": "Repository owner",
1666 "ref_pattern": "refs/heads/stable/*",
1667 "can_create": True,
1668 "can_push": False,
1669 "can_force_push": False,
1670 }),
1671 new_value=Equals({
1672 "changee_type": "Repository owner",
1673 "ref_pattern": "refs/heads/stable/*",
1674 "can_create": True,
1675 "can_push": True,
1676 "can_force_push": False,
1677 })),
1678 MatchesStructure(
1679 changer=Equals(repository.owner),
1680 changee=Equals(grantees[0]),
1681 what_changed=Equals(GitActivityType.GRANT_REMOVED),
1682 old_value=Equals({
1683 "changee_type": "Person",
1684 "ref_pattern": "refs/heads/stable/*",
1685 "can_create": True,
1686 "can_push": True,
1687 "can_force_push": False,
1688 })),
1689 MatchesStructure(
1690 changer=Equals(repository.owner),
1691 changee=Equals(grantees[2]),
1692 what_changed=Equals(GitActivityType.GRANT_ADDED),
1693 new_value=Equals({
1694 "changee_type": "Person",
1695 "ref_pattern": "refs/heads/*/next",
1696 "can_create": False,
1697 "can_push": True,
1698 "can_force_push": False,
1699 }))))
1700 self.assertHasSavedNotification(view, repository)
1701
1702 def test_save_delete_rule(self):
1703 repository = self.factory.makeGitRepository()
1704 self.factory.makeGitRule(
1705 repository=repository, ref_pattern="refs/heads/stable/*")
1706 heads_rule = self.factory.makeGitRule(
1707 repository=repository, ref_pattern="refs/heads/*")
1708 self.factory.makeGitRuleGrant(
1709 rule=heads_rule, grantee=GitGranteeType.REPOSITORY_OWNER,
1710 can_push=True)
1711 removeSecurityProxy(repository.getActivity()).remove()
1712 login_person(repository.owner)
1713 encoded_pattern = encode_form_field_id("refs/heads/*")
1714 form = {
1715 "field.pattern." + encoded_pattern: "*",
1716 "field.delete." + encoded_pattern: "on",
1717 # Form data entries relating to grants for the deleted rule are
1718 # ignored.
1719 "field.permissions.%s._repository_owner" % encoded_pattern: (
1720 "can_push"),
1721 "field.actions.save": "Save",
1722 }
1723 view = create_initialized_view(
1724 repository, name="+permissions", form=form,
1725 principal=repository.owner)
1726 self.assertHasRules(repository, ["refs/heads/stable/*"])
1727 self.assertThat(list(repository.getActivity()), MatchesListwise([
1728 MatchesStructure(
1729 changer=Equals(repository.owner),
1730 what_changed=Equals(GitActivityType.RULE_REMOVED),
1731 old_value=MatchesDict({
1732 "ref_pattern": Equals("refs/heads/*"),
1733 "position": Equals(1),
1734 })),
1735 ]))
1736 self.assertHasSavedNotification(view, repository)
1737
1738
1100class TestGitRepositoryDeletionView(BrowserTestCase):1739class TestGitRepositoryDeletionView(BrowserTestCase):
11011740
1102 layer = DatabaseFunctionalLayer1741 layer = DatabaseFunctionalLayer
11031742
=== modified file 'lib/lp/code/interfaces/gitrule.py'
--- lib/lp/code/interfaces/gitrule.py 2018-10-23 16:17:39 +0000
+++ lib/lp/code/interfaces/gitrule.py 2019-01-10 10:50:57 +0000
@@ -158,6 +158,10 @@
158 vocabulary="ValidPersonOrTeam",158 vocabulary="ValidPersonOrTeam",
159 description=_("The person being granted access."))159 description=_("The person being granted access."))
160160
161 combined_grantee = Attribute(
162 "The overall grantee of this grant: either a `GitGranteeType` (other "
163 "than `PERSON`) or an `IPerson`.")
164
161 date_created = Datetime(165 date_created = Datetime(
162 title=_("Date created"), required=True, readonly=True,166 title=_("Date created"), required=True, readonly=True,
163 description=_("The time when this grant was created."))167 description=_("The time when this grant was created."))
164168
=== modified file 'lib/lp/code/model/gitrule.py'
--- lib/lp/code/model/gitrule.py 2018-10-29 14:27:36 +0000
+++ lib/lp/code/model/gitrule.py 2019-01-10 10:50:57 +0000
@@ -310,6 +310,13 @@
310 self.date_created = date_created310 self.date_created = date_created
311 self.date_last_modified = date_created311 self.date_last_modified = date_created
312312
313 @property
314 def combined_grantee(self):
315 if self.grantee_type == GitGranteeType.PERSON:
316 return self.grantee
317 else:
318 return self.grantee_type
319
313 def __repr__(self):320 def __repr__(self):
314 if self.grantee_type == GitGranteeType.PERSON:321 if self.grantee_type == GitGranteeType.PERSON:
315 grantee_name = "~%s" % self.grantee.name322 grantee_name = "~%s" % self.grantee.name
316323
=== modified file 'lib/lp/code/model/tests/test_gitrule.py'
--- lib/lp/code/model/tests/test_gitrule.py 2018-10-21 17:38:05 +0000
+++ lib/lp/code/model/tests/test_gitrule.py 2019-01-10 10:50:57 +0000
@@ -594,6 +594,7 @@
594 rule=Equals(rule),594 rule=Equals(rule),
595 grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER),595 grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER),
596 grantee=Is(None),596 grantee=Is(None),
597 combined_grantee=Equals(GitGranteeType.REPOSITORY_OWNER),
597 can_create=Is(True),598 can_create=Is(True),
598 can_push=Is(False),599 can_push=Is(False),
599 can_force_push=Is(True),600 can_force_push=Is(True),
@@ -614,6 +615,7 @@
614 rule=Equals(rule),615 rule=Equals(rule),
615 grantee_type=Equals(GitGranteeType.PERSON),616 grantee_type=Equals(GitGranteeType.PERSON),
616 grantee=Equals(grantee),617 grantee=Equals(grantee),
618 combined_grantee=Equals(grantee),
617 can_create=Is(False),619 can_create=Is(False),
618 can_push=Is(True),620 can_push=Is(True),
619 can_force_push=Is(False),621 can_force_push=Is(False),
620622
=== added file 'lib/lp/code/templates/gitrepository-permissions.pt'
--- lib/lp/code/templates/gitrepository-permissions.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/code/templates/gitrepository-permissions.pt 2019-01-10 10:50:57 +0000
@@ -0,0 +1,232 @@
1<html
2 xmlns="http://www.w3.org/1999/xhtml"
3 xmlns:tal="http://xml.zope.org/namespaces/tal"
4 xmlns:metal="http://xml.zope.org/namespaces/metal"
5 xmlns:i18n="http://xml.zope.org/namespaces/i18n"
6 metal:use-macro="view/macro:page/main_only"
7 i18n:domain="launchpad">
8<body>
9
10 <metal:block fill-slot="head_epilogue">
11 <style type="text/css">
12 table.git-rules-table tr.even {
13 background-color: #eee;
14 }
15 tr.git-rule td, tr.git-new-rule td {
16 padding-top: 1em;
17 }
18 tr.git-new-rule-grant td, tr.git-new-rule td {
19 padding-bottom: 1em;
20 }
21 /* Position, pattern, and delete add up to 100%. */
22 tr .git-rule-position {
23 width: 10%;
24 }
25 tr .git-rule-pattern {
26 width: 85%;
27 }
28 tr .git-rule-delete {
29 width: 5%;
30 }
31 /* Grantee and permissions add up to pattern (85%). */
32 tr .git-rule-grant-grantee {
33 width: 40%;
34 }
35 tr .git-rule-grant-permissions {
36 width: 45%;
37 }
38 </style>
39 </metal:block>
40
41 <metal:macros fill-slot="bogus">
42 <metal:macro define-macro="rule-rows">
43 <tal:rule repeat="rule rules">
44 <tal:rule_widgets
45 define="rule_widgets python:view.getRuleWidgets(rule);
46 parity python:'even' if repeat['rule'].even() else 'odd'">
47 <tr tal:attributes="class string:git-rule ${parity}">
48 <td class="git-rule-position"
49 tal:define="widget nocall:rule_widgets/position">
50 <metal:block use-macro="context/@@launchpad_form/widget_div" />
51 </td>
52 <td class="git-rule-pattern"
53 tal:define="widget nocall:rule_widgets/pattern" colspan="2">
54 <metal:block use-macro="context/@@launchpad_form/widget_div" />
55 </td>
56 <td class="git-rule-delete"
57 tal:define="widget nocall:rule_widgets/delete">
58 <metal:block use-macro="context/@@launchpad_form/widget_div" />
59 </td>
60 </tr>
61 <tr tal:attributes="class string:git-rule-grant ${parity}"
62 tal:repeat="grant_widgets rule_widgets/grants">
63 <td></td>
64 <td class="git-rule-grant-grantee"
65 tal:define="widget nocall:grant_widgets/grantee">
66 <metal:block use-macro="context/@@launchpad_form/widget_div" />
67 </td>
68 <td class="git-rule-grant-permissions"
69 tal:define="widget nocall:grant_widgets/permissions">
70 <metal:block use-macro="context/@@launchpad_form/widget_div" />
71 </td>
72 <td class="git-rule-delete"
73 tal:define="widget nocall:grant_widgets/delete">
74 <metal:block use-macro="context/@@launchpad_form/widget_div" />
75 </td>
76 </tr>
77 <tr tal:attributes="class string:git-new-rule-grant ${parity}"
78 tal:define="new_grant_widgets rule_widgets/new_grant">
79 <td></td>
80 <td class="git-rule-grant-grantee"
81 tal:define="widget nocall:new_grant_widgets/grantee">
82 <metal:block use-macro="context/@@launchpad_form/widget_div" />
83 </td>
84 <td class="git-rule-grant-permissions"
85 tal:define="widget nocall:new_grant_widgets/permissions">
86 <metal:block use-macro="context/@@launchpad_form/widget_div" />
87 </td>
88 <td></td>
89 </tr>
90 </tal:rule_widgets>
91 </tal:rule>
92 <tal:allows-new-rule
93 condition="ref_prefix"
94 define="parity python:'odd' if len(rules) % 2 else 'even'">
95 <tr tal:attributes="class string:git-new-rule ${parity}"
96 tal:define="new_rule_widgets python:view.getNewRuleWidgets(ref_prefix)">
97 <td class="git-rule-position"
98 tal:define="widget nocall:new_rule_widgets/position">
99 <metal:block use-macro="context/@@launchpad_form/widget_div" />
100 </td>
101 <td class="git-rule-pattern"
102 tal:define="widget nocall:new_rule_widgets/pattern" colspan="2">
103 <metal:block use-macro="context/@@launchpad_form/widget_div" />
104 </td>
105 <td></td>
106 </tr>
107 </tal:allows-new-rule>
108 </metal:macro>
109
110 <metal:macro define-macro="rules-table">
111 <table class="listing git-rules-table"
112 style="max-width: 60em; margin-bottom: 1em;">
113 <thead>
114 <tr>
115 <th class="git-rule-position">Position</th>
116 <th class="git-rule-pattern" colspan="2">Rule</th>
117 <th class="git-rule-delete">Delete?</th>
118 </tr>
119 </thead>
120 <tbody>
121 <metal:rules use-macro="template/macros/rule-rows" />
122 </tbody>
123 </table>
124 </metal:macro>
125 </metal:macros>
126
127 <div metal:fill-slot="main">
128 <p>
129 By default, repository owners may create, push, force-push, or delete
130 any branch or tag in their repositories, and nobody else may modify
131 them in any way.
132 </p>
133 <p>
134 If any of the rules below matches a branch or tag, then it is
135 <em>protected</em>. By default, protecting a branch implicitly
136 prevents repository owners from force-pushing to it or deleting it,
137 while protecting a tag prevents repository owners from moving it.
138 Protecting a branch or tag also allows you to grant other permissions.
139 </p>
140 <p>
141 You may create rules that match a single branch or tag, or wildcard
142 rules that match a pattern: for example, <code>*</code> matches
143 everything, while <code>stable/*</code> matches
144 <code>stable/1.0</code> but not <code>master</code>.
145 </p>
146
147 <metal:grants-form use-macro="context/@@launchpad_form/form">
148 <div class="form" metal:fill-slot="widgets">
149 <h3>Protected branches (under <code>refs/heads/</code>)</h3>
150 <tal:branches define="rules view/branch_rules;
151 ref_prefix string:refs/heads/">
152 <metal:table use-macro="template/macros/rules-table" />
153 </tal:branches>
154
155 <h3>Protected tags (under <code>refs/tags/</code>)</h3>
156 <tal:tags define="rules view/tag_rules;
157 ref_prefix string:refs/tags/">
158 <metal:table use-macro="template/macros/rules-table" />
159 </tal:tags>
160
161 <tal:has-other condition="view/other_rules">
162 <h3>Other protected references</h3>
163 <tal:other define="rules view/other_rules; ref_prefix nothing">
164 <metal:table use-macro="template/macros/rules-table" />
165 </tal:other>
166 </tal:has-other>
167
168 <p class="actions">
169 <input tal:replace="structure view/save_action/render" />
170 or <a tal:attributes="href view/cancel_url">Cancel</a>
171 </p>
172 </div>
173
174 <metal:buttons fill-slot="buttons" />
175 </metal:grants-form>
176
177 <h2>Wildcards</h2>
178 <p>The special characters used in wildcard rules are:</p>
179 <table class="listing narrow-listing">
180 <thead>
181 <tr>
182 <th>Pattern</th>
183 <th>Meaning</th>
184 </tr>
185 </thead>
186 <tbody>
187 <tr>
188 <td><code>*</code></td>
189 <td>matches zero or more characters</td>
190 </tr>
191 <tr>
192 <td><code>?</code></td>
193 <td>matches any single character</td>
194 </tr>
195 <tr>
196 <td><code>[chars]</code></td>
197 <td>matches any character in <em>chars</em></td>
198 </tr>
199 <tr>
200 <td><code>[!chars]</code></td>
201 <td>matches any character not in <em>chars</em></td>
202 </tr>
203 </tbody>
204 </table>
205
206 <h2>Effective permissions</h2>
207 <p>
208 Launchpad works out the effective permissions that a user has on a
209 protected branch or tag as follows:
210 </p>
211 <ol>
212 <li>Take all the rules that match the branch or tag.</li>
213 <li>
214 For each matching rule, select any grants whose grantee matches the
215 user, as long as the same grantee has not already been seen in an
216 earlier matching rule. (A user can be matched by more than one
217 grantee: for example, they might be in multiple teams.)
218 </li>
219 <li>
220 If the user is an owner of the repository and there was no previous
221 “Repository owner” grant, then add an implicit grant allowing them
222 to create or push.
223 </li>
224 <li>
225 The effective permission set is the union of the permissions granted
226 by all the selected grants.
227 </li>
228 </ol>
229 </div>
230
231</body>
232</html>