Merge ~pappacena/launchpad:ocirecipe-subscription into launchpad:master
- Git
- lp:~pappacena/launchpad
- ocirecipe-subscription
- Merge into master
Proposed by
Thiago F. Pappacena
Status: | Merged |
---|---|
Approved by: | Thiago F. Pappacena |
Approved revision: | 14f54da031cc4055d84fb9a0c3fdd6413d7a8235 |
Merge reported by: | Otto Co-Pilot |
Merged at revision: | not available |
Proposed branch: | ~pappacena/launchpad:ocirecipe-subscription |
Merge into: | launchpad:master |
Prerequisite: | ~pappacena/launchpad:ocirecipe-private-reconcile-pillar |
Diff against target: |
1089 lines (+579/-60) (has conflicts) 20 files modified
lib/lp/blueprints/model/specification.py (+3/-2) lib/lp/blueprints/tests/test_specification.py (+6/-6) lib/lp/bugs/model/bug.py (+4/-4) lib/lp/code/browser/branchsubscription.py (+3/-2) lib/lp/code/browser/gitsubscription.py (+2/-2) lib/lp/code/model/branch.py (+3/-2) lib/lp/code/model/gitrepository.py (+3/-2) lib/lp/code/model/tests/test_branchsubscription.py (+4/-4) lib/lp/oci/configure.zcml (+11/-0) lib/lp/oci/interfaces/ocirecipe.py (+20/-0) lib/lp/oci/interfaces/ocirecipesubscription.py (+43/-0) lib/lp/oci/model/ocirecipe.py (+167/-2) lib/lp/oci/model/ocirecipesubscription.py (+62/-0) lib/lp/oci/tests/test_ocirecipe.py (+124/-1) lib/lp/oci/tests/test_ocirecipebuildbehaviour.py (+2/-0) lib/lp/registry/personmerge.py (+25/-3) lib/lp/registry/services/sharingservice.py (+23/-4) lib/lp/registry/services/tests/test_sharingservice.py (+31/-23) lib/lp/security.py (+41/-1) lib/lp/snappy/model/snap.py (+2/-2) Conflict in lib/lp/registry/personmerge.py |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Colin Watson (community) | Approve | ||
Review via email: mp+399544@code.launchpad.net |
Commit message
Adding OCI recipe subscription model
Description of the change
To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) : | # |
review:
Approve
Revision history for this message
Thiago F. Pappacena (pappacena) wrote : | # |
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/lib/lp/blueprints/model/specification.py b/lib/lp/blueprints/model/specification.py |
2 | index 9f3b00a..404481a 100644 |
3 | --- a/lib/lp/blueprints/model/specification.py |
4 | +++ b/lib/lp/blueprints/model/specification.py |
5 | @@ -755,8 +755,9 @@ class Specification(SQLBase, BugLinkTargetMixin, InformationTypeMixin): |
6 | # Grant the subscriber access if they can't see the |
7 | # specification. |
8 | service = getUtility(IService, 'sharing') |
9 | - _, _, _, _, shared_specs = service.getVisibleArtifacts( |
10 | - person, specifications=[self], ignore_permissions=True) |
11 | + shared_specs = service.getVisibleArtifacts( |
12 | + person, specifications=[self], |
13 | + ignore_permissions=True)["specifications"] |
14 | if not shared_specs: |
15 | service.ensureAccessGrants( |
16 | [person], subscribed_by, specifications=[self]) |
17 | diff --git a/lib/lp/blueprints/tests/test_specification.py b/lib/lp/blueprints/tests/test_specification.py |
18 | index 86c4e4a..dcc17b6 100644 |
19 | --- a/lib/lp/blueprints/tests/test_specification.py |
20 | +++ b/lib/lp/blueprints/tests/test_specification.py |
21 | @@ -492,8 +492,8 @@ class SpecificationTests(TestCaseWithFactory): |
22 | product=product, information_type=InformationType.PROPRIETARY) |
23 | spec.subscribe(user, subscribed_by=owner) |
24 | service = getUtility(IService, 'sharing') |
25 | - _, _, _, _, shared_specs = service.getVisibleArtifacts( |
26 | - user, specifications=[spec]) |
27 | + shared_specs = service.getVisibleArtifacts( |
28 | + user, specifications=[spec])["specifications"] |
29 | self.assertEqual([spec], shared_specs) |
30 | # The spec is also returned by getSharedSpecifications(), |
31 | # which lists only specifications for which the use has |
32 | @@ -509,8 +509,8 @@ class SpecificationTests(TestCaseWithFactory): |
33 | service.sharePillarInformation( |
34 | product, user_2, owner, permissions) |
35 | spec.subscribe(user_2, subscribed_by=owner) |
36 | - _, _, _, _, shared_specs = service.getVisibleArtifacts( |
37 | - user_2, specifications=[spec]) |
38 | + shared_specs = service.getVisibleArtifacts( |
39 | + user_2, specifications=[spec])["specifications"] |
40 | self.assertEqual([spec], shared_specs) |
41 | self.assertEqual( |
42 | [], service.getSharedSpecifications(product, user_2, owner)) |
43 | @@ -529,8 +529,8 @@ class SpecificationTests(TestCaseWithFactory): |
44 | spec.subscribe(user, subscribed_by=owner) |
45 | spec.unsubscribe(user, unsubscribed_by=owner) |
46 | service = getUtility(IService, 'sharing') |
47 | - _, _, _, _, shared_specs = service.getVisibleArtifacts( |
48 | - user, specifications=[spec]) |
49 | + shared_specs = service.getVisibleArtifacts( |
50 | + user, specifications=[spec])["specifications"] |
51 | self.assertEqual([], shared_specs) |
52 | |
53 | def test_notificationRecipientAddresses_filters_based_on_sharing(self): |
54 | diff --git a/lib/lp/bugs/model/bug.py b/lib/lp/bugs/model/bug.py |
55 | index f2148e0..8f1480c 100644 |
56 | --- a/lib/lp/bugs/model/bug.py |
57 | +++ b/lib/lp/bugs/model/bug.py |
58 | @@ -875,8 +875,8 @@ class Bug(SQLBase, InformationTypeMixin): |
59 | # there is at least one bugtask for which access can be checked. |
60 | if self.default_bugtask: |
61 | service = getUtility(IService, 'sharing') |
62 | - bugs, _, _, _, _ = service.getVisibleArtifacts( |
63 | - person, bugs=[self], ignore_permissions=True) |
64 | + bugs = service.getVisibleArtifacts( |
65 | + person, bugs=[self], ignore_permissions=True)["bugs"] |
66 | if not bugs: |
67 | service.ensureAccessGrants( |
68 | [person], subscribed_by, bugs=[self], |
69 | @@ -1819,8 +1819,8 @@ class Bug(SQLBase, InformationTypeMixin): |
70 | if information_type in PRIVATE_INFORMATION_TYPES: |
71 | service = getUtility(IService, 'sharing') |
72 | for person in (who, self.owner): |
73 | - bugs, _, _, _, _ = service.getVisibleArtifacts( |
74 | - person, bugs=[self], ignore_permissions=True) |
75 | + bugs = service.getVisibleArtifacts( |
76 | + person, bugs=[self], ignore_permissions=True)["bugs"] |
77 | if not bugs: |
78 | # subscribe() isn't sufficient if a subscription |
79 | # already exists, as it will do nothing even if |
80 | diff --git a/lib/lp/code/browser/branchsubscription.py b/lib/lp/code/browser/branchsubscription.py |
81 | index 933c092..7e19479 100644 |
82 | --- a/lib/lp/code/browser/branchsubscription.py |
83 | +++ b/lib/lp/code/browser/branchsubscription.py |
84 | @@ -271,8 +271,9 @@ class BranchSubscriptionEditView(LaunchpadEditFormView): |
85 | url = canonical_url(self.branch) |
86 | # If the subscriber can no longer see the branch, redirect them away. |
87 | service = getUtility(IService, 'sharing') |
88 | - _, branches, _, _, _ = service.getVisibleArtifacts( |
89 | - self.person, branches=[self.branch], ignore_permissions=True) |
90 | + branches = service.getVisibleArtifacts( |
91 | + self.person, branches=[self.branch], |
92 | + ignore_permissions=True)["branches"] |
93 | if not branches: |
94 | url = canonical_url(self.branch.target) |
95 | return url |
96 | diff --git a/lib/lp/code/browser/gitsubscription.py b/lib/lp/code/browser/gitsubscription.py |
97 | index 2f2c69e..f0b693a 100644 |
98 | --- a/lib/lp/code/browser/gitsubscription.py |
99 | +++ b/lib/lp/code/browser/gitsubscription.py |
100 | @@ -275,9 +275,9 @@ class GitSubscriptionEditView(LaunchpadEditFormView): |
101 | # If the subscriber can no longer see the repository, redirect them |
102 | # away. |
103 | service = getUtility(IService, "sharing") |
104 | - _, _, repositories, _, _ = service.getVisibleArtifacts( |
105 | + repositories = service.getVisibleArtifacts( |
106 | self.person, gitrepositories=[self.repository], |
107 | - ignore_permissions=True) |
108 | + ignore_permissions=True)["gitrepositories"] |
109 | if not repositories: |
110 | url = canonical_url(self.repository.target) |
111 | return url |
112 | diff --git a/lib/lp/code/model/branch.py b/lib/lp/code/model/branch.py |
113 | index 27746c7..f6de617 100644 |
114 | --- a/lib/lp/code/model/branch.py |
115 | +++ b/lib/lp/code/model/branch.py |
116 | @@ -1041,8 +1041,9 @@ class Branch(SQLBase, WebhookTargetMixin, BzrIdentityMixin): |
117 | subscription.review_level = code_review_level |
118 | # Grant the subscriber access if they can't see the branch. |
119 | service = getUtility(IService, 'sharing') |
120 | - _, branches, _, _, _ = service.getVisibleArtifacts( |
121 | - person, branches=[self], ignore_permissions=True) |
122 | + branches = service.getVisibleArtifacts( |
123 | + person, branches=[self], |
124 | + ignore_permissions=True)["branches"] |
125 | if not branches: |
126 | service.ensureAccessGrants( |
127 | [person], subscribed_by, branches=[self], |
128 | diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py |
129 | index de94b66..2c66571 100644 |
130 | --- a/lib/lp/code/model/gitrepository.py |
131 | +++ b/lib/lp/code/model/gitrepository.py |
132 | @@ -1006,8 +1006,9 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin): |
133 | subscription.review_level = code_review_level |
134 | # Grant the subscriber access if they can't see the repository. |
135 | service = getUtility(IService, "sharing") |
136 | - _, _, repositories, _, _ = service.getVisibleArtifacts( |
137 | - person, gitrepositories=[self], ignore_permissions=True) |
138 | + repositories = service.getVisibleArtifacts( |
139 | + person, gitrepositories=[self], |
140 | + ignore_permissions=True)["gitrepositories"] |
141 | if not repositories: |
142 | service.ensureAccessGrants( |
143 | [person], subscribed_by, gitrepositories=[self], |
144 | diff --git a/lib/lp/code/model/tests/test_branchsubscription.py b/lib/lp/code/model/tests/test_branchsubscription.py |
145 | index b5b234e..9b13adf 100644 |
146 | --- a/lib/lp/code/model/tests/test_branchsubscription.py |
147 | +++ b/lib/lp/code/model/tests/test_branchsubscription.py |
148 | @@ -134,8 +134,8 @@ class TestBranchSubscriptions(TestCaseWithFactory): |
149 | None, CodeReviewNotificationLevel.NOEMAIL, owner) |
150 | # The stacked on branch should be visible. |
151 | service = getUtility(IService, 'sharing') |
152 | - _, visible_branches, _, _, _ = service.getVisibleArtifacts( |
153 | - grantee, branches=[private_stacked_on_branch]) |
154 | + visible_branches = service.getVisibleArtifacts( |
155 | + grantee, branches=[private_stacked_on_branch])["branches"] |
156 | self.assertContentEqual( |
157 | [private_stacked_on_branch], visible_branches) |
158 | self.assertIn( |
159 | @@ -162,8 +162,8 @@ class TestBranchSubscriptions(TestCaseWithFactory): |
160 | grantee, BranchSubscriptionNotificationLevel.NOEMAIL, |
161 | None, CodeReviewNotificationLevel.NOEMAIL, owner) |
162 | # The stacked on branch should not be visible. |
163 | - _, visible_branches, _, _, _ = service.getVisibleArtifacts( |
164 | - grantee, branches=[private_stacked_on_branch]) |
165 | + visible_branches = service.getVisibleArtifacts( |
166 | + grantee, branches=[private_stacked_on_branch])["branches"] |
167 | self.assertContentEqual([], visible_branches) |
168 | self.assertIn( |
169 | grantee, branch.subscribers) |
170 | diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml |
171 | index 1f0e284..82cf7ff 100644 |
172 | --- a/lib/lp/oci/configure.zcml |
173 | +++ b/lib/lp/oci/configure.zcml |
174 | @@ -41,6 +41,17 @@ |
175 | interface="lp.oci.interfaces.ocirecipe.IOCIRecipeSet"/> |
176 | </securedutility> |
177 | |
178 | + <!-- OCIRecipeSubscription --> |
179 | + |
180 | + <class class="lp.oci.model.ocirecipesubscription.OCIRecipeSubscription"> |
181 | + <require |
182 | + permission="launchpad.View" |
183 | + interface="lp.oci.interfaces.ocirecipesubscription.IOCIRecipeSubscription"/> |
184 | + <require |
185 | + permission="launchpad.Edit" |
186 | + set_schema="lp.oci.interfaces.ocirecipesubscription.IOCIRecipeSubscription"/> |
187 | + </class> |
188 | + |
189 | <!-- OCIRecipeRequestBuildsJob related classes --> |
190 | <class class="lp.oci.model.ocirecipe.OCIRecipeBuildRequest"> |
191 | <require |
192 | diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py |
193 | index 93dab0f..0b006d2 100644 |
194 | --- a/lib/lp/oci/interfaces/ocirecipe.py |
195 | +++ b/lib/lp/oci/interfaces/ocirecipe.py |
196 | @@ -73,6 +73,7 @@ from lp.oci.enums import OCIRecipeBuildRequestStatus |
197 | from lp.registry.interfaces.distribution import IDistribution |
198 | from lp.registry.interfaces.distroseries import IDistroSeries |
199 | from lp.registry.interfaces.ociproject import IOCIProject |
200 | +from lp.registry.interfaces.person import IPerson |
201 | from lp.registry.interfaces.role import IHasOwner |
202 | from lp.services.database.constants import DEFAULT |
203 | from lp.services.fields import ( |
204 | @@ -305,6 +306,10 @@ class IOCIRecipeView(Interface): |
205 | description=_("Use the credentials on a Distribution for " |
206 | "registry upload")) |
207 | |
208 | + subscribers = CollectionField( |
209 | + title=_("Persons subscribed to this snap recipe."), |
210 | + readonly=True, value_type=Reference(IPerson)) |
211 | + |
212 | def requestBuild(requester, architecture): |
213 | """Request that the OCI recipe is built. |
214 | |
215 | @@ -340,6 +345,18 @@ class IOCIRecipeView(Interface): |
216 | """Get an OCIRecipeBuildRequest object for the given job_id. |
217 | """ |
218 | |
219 | + def visibleByUser(user): |
220 | + """Can the specified user see this snap recipe?""" |
221 | + |
222 | + def getSubscription(person): |
223 | + """Returns the OCIRecipeSubscription for the given user.""" |
224 | + |
225 | + def subscribe(person, subscribed_by): |
226 | + """Subscribe a person to this snap recipe.""" |
227 | + |
228 | + def unsubscribe(person, unsubscribed_by): |
229 | + """Unsubscribe a person to this snap recipe.""" |
230 | + |
231 | |
232 | class IOCIRecipeEdit(IWebhookTarget): |
233 | """`IOCIRecipe` methods that require launchpad.Edit permission.""" |
234 | @@ -519,6 +536,9 @@ class IOCIRecipeSet(Interface): |
235 | def exists(owner, oci_project, name): |
236 | """Check to see if an existing OCI Recipe exists.""" |
237 | |
238 | + def findByIds(ocirecipe_ids, visible_by_user=None): |
239 | + """Returns the OCI recipes with the given IDs.""" |
240 | + |
241 | def getByName(owner, oci_project, name): |
242 | """Return the appropriate `OCIRecipe` for the given objects.""" |
243 | |
244 | diff --git a/lib/lp/oci/interfaces/ocirecipesubscription.py b/lib/lp/oci/interfaces/ocirecipesubscription.py |
245 | new file mode 100644 |
246 | index 0000000..01bfe4e |
247 | --- /dev/null |
248 | +++ b/lib/lp/oci/interfaces/ocirecipesubscription.py |
249 | @@ -0,0 +1,43 @@ |
250 | +# Copyright 2020-2021 Canonical Ltd. This software is licensed under the |
251 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
252 | + |
253 | +"""OCIRecipe subscription model.""" |
254 | + |
255 | +from __future__ import absolute_import, print_function, unicode_literals |
256 | + |
257 | +__metaclass__ = type |
258 | +__all__ = [ |
259 | + 'IOCIRecipeSubscription' |
260 | +] |
261 | + |
262 | +from lazr.restful.fields import Reference |
263 | +from zope.interface import Interface |
264 | +from zope.schema import ( |
265 | + Datetime, |
266 | + Int, |
267 | + ) |
268 | + |
269 | +from lp import _ |
270 | +from lp.oci.interfaces.ocirecipe import IOCIRecipe |
271 | +from lp.services.fields import PersonChoice |
272 | + |
273 | + |
274 | +class IOCIRecipeSubscription(Interface): |
275 | + """A person subscription to a specific OCIRecipe recipe.""" |
276 | + |
277 | + id = Int(title=_('ID'), readonly=True, required=True) |
278 | + person = PersonChoice( |
279 | + title=_('Person'), required=True, vocabulary='ValidPersonOrTeam', |
280 | + readonly=True, |
281 | + description=_("The person subscribed to the related OCI recipe.")) |
282 | + recipe = Reference( |
283 | + IOCIRecipe, title=_("OCI recipe"), required=True, readonly=True) |
284 | + subscribed_by = PersonChoice( |
285 | + title=_('Subscribed by'), required=True, |
286 | + vocabulary='ValidPersonOrTeam', readonly=True, |
287 | + description=_("The person who created this subscription.")) |
288 | + date_created = Datetime( |
289 | + title=_('Date subscribed'), required=True, readonly=True) |
290 | + |
291 | + def canBeUnsubscribedByUser(user): |
292 | + """Can the user unsubscribe the subscriber from the OCI recipe?""" |
293 | diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py |
294 | index e8d1e85..622e26e 100644 |
295 | --- a/lib/lp/oci/model/ocirecipe.py |
296 | +++ b/lib/lp/oci/model/ocirecipe.py |
297 | @@ -19,9 +19,14 @@ import six |
298 | from storm.databases.postgres import JSON |
299 | from storm.expr import ( |
300 | And, |
301 | + Coalesce, |
302 | Desc, |
303 | + Exists, |
304 | + Join, |
305 | Not, |
306 | + Or, |
307 | Select, |
308 | + SQL, |
309 | ) |
310 | from storm.locals import ( |
311 | Bool, |
312 | @@ -48,8 +53,13 @@ from lp.app.enums import ( |
313 | InformationType, |
314 | PUBLIC_INFORMATION_TYPES, |
315 | ) |
316 | +from lp.app.errors import ( |
317 | + SubscriptionPrivacyViolation, |
318 | + UserCannotUnsubscribePerson, |
319 | + ) |
320 | from lp.app.interfaces.launchpad import ILaunchpadCelebrities |
321 | from lp.app.interfaces.security import IAuthorization |
322 | +from lp.app.interfaces.services import IService |
323 | from lp.app.validators.validation import validate_oci_branch_name |
324 | from lp.buildmaster.enums import BuildStatus |
325 | from lp.buildmaster.interfaces.buildqueue import IBuildQueueSet |
326 | @@ -87,18 +97,27 @@ from lp.oci.model.ocipushrule import ( |
327 | ) |
328 | from lp.oci.model.ocirecipebuild import OCIRecipeBuild |
329 | from lp.oci.model.ocirecipejob import OCIRecipeJob |
330 | +from lp.oci.model.ocirecipesubscription import OCIRecipeSubscription |
331 | from lp.registry.errors import PrivatePersonLinkageError |
332 | +from lp.registry.interfaces.accesspolicy import ( |
333 | + IAccessArtifactGrantSource, |
334 | + IAccessArtifactSource, |
335 | + ) |
336 | from lp.registry.interfaces.distribution import IDistributionSet |
337 | from lp.registry.interfaces.person import ( |
338 | IPersonSet, |
339 | validate_public_person, |
340 | ) |
341 | from lp.registry.interfaces.role import IPersonRoles |
342 | -from lp.registry.model.accesspolicy import reconcile_access_for_artifacts |
343 | +from lp.registry.model.accesspolicy import ( |
344 | + AccessPolicyGrant, |
345 | + reconcile_access_for_artifacts, |
346 | + ) |
347 | from lp.registry.model.distribution import Distribution |
348 | from lp.registry.model.distroseries import DistroSeries |
349 | from lp.registry.model.person import Person |
350 | from lp.registry.model.series import ACTIVE_STATUSES |
351 | +from lp.registry.model.teammembership import TeamParticipation |
352 | from lp.services.database.bulk import load_related |
353 | from lp.services.database.constants import ( |
354 | DEFAULT, |
355 | @@ -111,6 +130,9 @@ from lp.services.database.interfaces import ( |
356 | IStore, |
357 | ) |
358 | from lp.services.database.stormexpr import ( |
359 | + Array, |
360 | + ArrayAgg, |
361 | + ArrayIntersects, |
362 | Greatest, |
363 | NullsLast, |
364 | ) |
365 | @@ -288,7 +310,7 @@ class OCIRecipe(Storm, WebhookTargetMixin): |
366 | for k, v in (value or {}).items()} |
367 | |
368 | def _reconcileAccess(self): |
369 | - """Reconcile the snap's sharing information. |
370 | + """Reconcile the OCI recipe's sharing information. |
371 | |
372 | Takes the privacy and pillar and makes the related AccessArtifact |
373 | and AccessPolicyArtifacts match. |
374 | @@ -296,11 +318,94 @@ class OCIRecipe(Storm, WebhookTargetMixin): |
375 | reconcile_access_for_artifacts([self], self.information_type, |
376 | [self.pillar]) |
377 | |
378 | + def visibleByUser(self, user): |
379 | + """See `IOCIRecipe`.""" |
380 | + if self.information_type in PUBLIC_INFORMATION_TYPES: |
381 | + return True |
382 | + if user is None: |
383 | + return False |
384 | + store = IStore(self) |
385 | + return not store.find( |
386 | + OCIRecipe, |
387 | + OCIRecipe.id == self.id, |
388 | + get_ocirecipe_privacy_filter(user)).is_empty() |
389 | + |
390 | + def getSubscription(self, person): |
391 | + """See `IOCIRecipe`.""" |
392 | + if person is None: |
393 | + return None |
394 | + return Store.of(self).find( |
395 | + OCIRecipeSubscription, |
396 | + OCIRecipeSubscription.person == person, |
397 | + OCIRecipeSubscription.recipe == self).one() |
398 | + |
399 | + def userCanBeSubscribed(self, person): |
400 | + """Checks if the given person can subscribe to this OCI recipe.""" |
401 | + return not ( |
402 | + self.private and |
403 | + person.is_team and |
404 | + person.anyone_can_join()) |
405 | + |
406 | + @property |
407 | + def subscribers(self): |
408 | + return Store.of(self).find( |
409 | + Person, |
410 | + OCIRecipeSubscription.person_id == Person.id, |
411 | + OCIRecipeSubscription.recipe == self) |
412 | + |
413 | + def subscribe(self, person, subscribed_by, ignore_permissions=False): |
414 | + """See `IOCIRecipe`.""" |
415 | + if not self.userCanBeSubscribed(person): |
416 | + raise SubscriptionPrivacyViolation( |
417 | + "Open and delegated teams cannot be subscribed to private " |
418 | + "OCI recipes.") |
419 | + subscription = self.getSubscription(person) |
420 | + if subscription is None: |
421 | + subscription = OCIRecipeSubscription( |
422 | + person=person, recipe=self, subscribed_by=subscribed_by) |
423 | + Store.of(subscription).flush() |
424 | + service = getUtility(IService, "sharing") |
425 | + ocirecipes = service.getVisibleArtifacts( |
426 | + person, ocirecipes=[self], ignore_permissions=True)["ocirecipes"] |
427 | + if not ocirecipes: |
428 | + service.ensureAccessGrants( |
429 | + [person], subscribed_by, ocirecipes=[self], |
430 | + ignore_permissions=ignore_permissions) |
431 | + |
432 | + def unsubscribe(self, person, unsubscribed_by, ignore_permissions=False): |
433 | + """See `IOCIRecipe`.""" |
434 | + subscription = self.getSubscription(person) |
435 | + if subscription is None: |
436 | + return |
437 | + if (not ignore_permissions |
438 | + and not subscription.canBeUnsubscribedByUser(unsubscribed_by)): |
439 | + raise UserCannotUnsubscribePerson( |
440 | + '%s does not have permission to unsubscribe %s.' % ( |
441 | + unsubscribed_by.displayname, |
442 | + person.displayname)) |
443 | + artifact = getUtility(IAccessArtifactSource).find([self]) |
444 | + getUtility(IAccessArtifactGrantSource).revokeByArtifact( |
445 | + artifact, [person]) |
446 | + store = Store.of(subscription) |
447 | + store.remove(subscription) |
448 | + IStore(self).flush() |
449 | + |
450 | + def _deleteAccessGrants(self): |
451 | + """Delete access grants for this snap recipe prior to deleting it.""" |
452 | + getUtility(IAccessArtifactSource).delete([self]) |
453 | + |
454 | + def _deleteOCIRecipeSubscriptions(self): |
455 | + subscriptions = Store.of(self).find( |
456 | + OCIRecipeSubscription, OCIRecipeSubscription.recipe == self) |
457 | + subscriptions.remove() |
458 | + |
459 | def destroySelf(self): |
460 | """See `IOCIRecipe`.""" |
461 | # XXX twom 2019-11-26 This needs to expand as more build artifacts |
462 | # are added |
463 | store = IStore(OCIRecipe) |
464 | + self._deleteOCIRecipeSubscriptions() |
465 | + self._deleteAccessGrants() |
466 | store.find(OCIRecipeArch, OCIRecipeArch.recipe == self).remove() |
467 | buildqueue_records = store.find( |
468 | BuildQueue, |
469 | @@ -715,6 +820,10 @@ class OCIRecipeSet: |
470 | store.add(oci_recipe) |
471 | oci_recipe._reconcileAccess() |
472 | |
473 | + # Automatically subscribe the owner to the OCI recipe. |
474 | + oci_recipe.subscribe(oci_recipe.owner, registrant, |
475 | + ignore_permissions=True) |
476 | + |
477 | if processors is None: |
478 | processors = [ |
479 | p for p in oci_recipe.available_processors |
480 | @@ -734,6 +843,13 @@ class OCIRecipeSet: |
481 | """See `IOCIRecipeSet`.""" |
482 | return self._getByName(owner, oci_project, name) is not None |
483 | |
484 | + def findByIds(self, ocirecipe_ids, visible_by_user=None): |
485 | + """See `IOCIRecipeSet`.""" |
486 | + clauses = [OCIRecipe.id.is_in(ocirecipe_ids)] |
487 | + if visible_by_user is not None: |
488 | + clauses.append(get_ocirecipe_privacy_filter(visible_by_user)) |
489 | + return IStore(OCIRecipe).find(OCIRecipe, *clauses) |
490 | + |
491 | def getByName(self, owner, oci_project, name): |
492 | """See `IOCIRecipeSet`.""" |
493 | oci_recipe = self._getByName(owner, oci_project, name) |
494 | @@ -850,3 +966,52 @@ class OCIRecipeBuildRequest: |
495 | |
496 | def __hash__(self): |
497 | return hash((self.__class__, self.id)) |
498 | + |
499 | + |
500 | +def get_ocirecipe_privacy_filter(user): |
501 | + """Returns the filter for all OCI recipes that the given user has access |
502 | + to, including private OCI recipes where the user has proper permission. |
503 | + |
504 | + :param user: An IPerson, or a class attribute that references an IPerson |
505 | + in the database. |
506 | + :return: A storm condition. |
507 | + """ |
508 | + # XXX pappacena 2021-03-11: Once we do the migration to back fill |
509 | + # information_type, we should be able to change this. |
510 | + private_recipe = SQL( |
511 | + "COALESCE(information_type NOT IN ?, false)", |
512 | + params=[tuple(i.value for i in PUBLIC_INFORMATION_TYPES)]) |
513 | + if user is None: |
514 | + return private_recipe == False |
515 | + |
516 | + artifact_grant_query = Coalesce( |
517 | + ArrayIntersects( |
518 | + SQL("%s.access_grants" % OCIRecipe.__storm_table__), |
519 | + Select( |
520 | + ArrayAgg(TeamParticipation.teamID), |
521 | + tables=TeamParticipation, |
522 | + where=(TeamParticipation.person == user) |
523 | + )), False) |
524 | + |
525 | + policy_grant_query = Coalesce( |
526 | + ArrayIntersects( |
527 | + Array(SQL("%s.access_policy" % OCIRecipe.__storm_table__)), |
528 | + Select( |
529 | + ArrayAgg(AccessPolicyGrant.policy_id), |
530 | + tables=(AccessPolicyGrant, |
531 | + Join(TeamParticipation, |
532 | + TeamParticipation.teamID == |
533 | + AccessPolicyGrant.grantee_id)), |
534 | + where=(TeamParticipation.person == user) |
535 | + )), False) |
536 | + |
537 | + admin_team_id = getUtility(ILaunchpadCelebrities).admin.id |
538 | + user_is_admin = Exists(Select( |
539 | + TeamParticipation.personID, |
540 | + tables=[TeamParticipation], |
541 | + where=And( |
542 | + TeamParticipation.teamID == admin_team_id, |
543 | + TeamParticipation.person == user))) |
544 | + return Or( |
545 | + private_recipe == False, artifact_grant_query, policy_grant_query, |
546 | + user_is_admin) |
547 | diff --git a/lib/lp/oci/model/ocirecipesubscription.py b/lib/lp/oci/model/ocirecipesubscription.py |
548 | new file mode 100644 |
549 | index 0000000..6c17b07 |
550 | --- /dev/null |
551 | +++ b/lib/lp/oci/model/ocirecipesubscription.py |
552 | @@ -0,0 +1,62 @@ |
553 | +# Copyright 2020-2021 Canonical Ltd. This software is licensed under the |
554 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
555 | + |
556 | +"""OCIRecipe subscription model.""" |
557 | + |
558 | +from __future__ import absolute_import, print_function, unicode_literals |
559 | + |
560 | +__metaclass__ = type |
561 | +__all__ = [ |
562 | + 'OCIRecipeSubscription' |
563 | +] |
564 | + |
565 | +import pytz |
566 | +from storm.properties import ( |
567 | + DateTime, |
568 | + Int, |
569 | + ) |
570 | +from storm.references import Reference |
571 | +from zope.interface import implementer |
572 | + |
573 | +from lp.oci.interfaces.ocirecipesubscription import IOCIRecipeSubscription |
574 | +from lp.registry.interfaces.person import validate_person |
575 | +from lp.registry.interfaces.role import IPersonRoles |
576 | +from lp.services.database.constants import UTC_NOW |
577 | +from lp.services.database.stormbase import StormBase |
578 | + |
579 | + |
580 | +@implementer(IOCIRecipeSubscription) |
581 | +class OCIRecipeSubscription(StormBase): |
582 | + """A relationship between a person and an OCI recipe.""" |
583 | + |
584 | + __storm_table__ = 'OCIRecipeSubscription' |
585 | + |
586 | + id = Int(primary=True) |
587 | + |
588 | + person_id = Int( |
589 | + "person", allow_none=False, validator=validate_person) |
590 | + person = Reference(person_id, "Person.id") |
591 | + |
592 | + recipe_id = Int("recipe", allow_none=False) |
593 | + recipe = Reference(recipe_id, "OCIRecipe.id") |
594 | + |
595 | + date_created = DateTime(allow_none=False, default=UTC_NOW, tzinfo=pytz.UTC) |
596 | + |
597 | + subscribed_by_id = Int( |
598 | + "subscribed_by", allow_none=False, validator=validate_person) |
599 | + subscribed_by = Reference(subscribed_by_id, "Person.id") |
600 | + |
601 | + def __init__(self, recipe, person, subscribed_by): |
602 | + super(OCIRecipeSubscription, self).__init__() |
603 | + self.recipe = recipe |
604 | + self.person = person |
605 | + self.subscribed_by = subscribed_by |
606 | + |
607 | + def canBeUnsubscribedByUser(self, user): |
608 | + """See `IOCIRecipeSubscription`.""" |
609 | + if user is None: |
610 | + return False |
611 | + return (user.inTeam(self.recipe.owner) or |
612 | + user.inTeam(self.person) or |
613 | + user.inTeam(self.subscribed_by) or |
614 | + IPersonRoles(user).in_admin) |
615 | diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py |
616 | index c499c98..c3fa762 100644 |
617 | --- a/lib/lp/oci/tests/test_ocirecipe.py |
618 | +++ b/lib/lp/oci/tests/test_ocirecipe.py |
619 | @@ -5,6 +5,7 @@ |
620 | |
621 | from __future__ import absolute_import, print_function, unicode_literals |
622 | |
623 | +from datetime import datetime |
624 | import json |
625 | |
626 | from fixtures import FakeLogger |
627 | @@ -73,6 +74,10 @@ from lp.registry.interfaces.accesspolicy import ( |
628 | IAccessPolicySource, |
629 | ) |
630 | from lp.registry.interfaces.series import SeriesStatus |
631 | +from lp.registry.model.accesspolicy import ( |
632 | + AccessArtifact, |
633 | + AccessArtifactGrant, |
634 | + ) |
635 | from lp.services.config import config |
636 | from lp.services.database.constants import ( |
637 | ONE_DAY_AGO, |
638 | @@ -861,7 +866,7 @@ class TestOCIRecipeAccessControl(TestCaseWithFactory, OCIConfigHelperMixin): |
639 | recipes = [] |
640 | for i in range(10): |
641 | recipes.append(self.factory.makeOCIRecipe( |
642 | - registrant=person, |
643 | + registrant=person, owner=person, |
644 | oci_project=oci_project, |
645 | information_type=InformationType.USERDATA)) |
646 | |
647 | @@ -889,6 +894,124 @@ class TestOCIRecipeAccessControl(TestCaseWithFactory, OCIConfigHelperMixin): |
648 | self.assertEqual( |
649 | {i.policy.pillar for i in policy_artifacts}, {final_project}) |
650 | |
651 | + def getGrants(self, ocirecipe, person=None): |
652 | + conditions = [AccessArtifact.ocirecipe == ocirecipe] |
653 | + if person is not None: |
654 | + conditions.append(AccessArtifactGrant.grantee == person) |
655 | + return IStore(AccessArtifactGrant).find( |
656 | + AccessArtifactGrant, |
657 | + AccessArtifactGrant.abstract_artifact_id == AccessArtifact.id, |
658 | + *conditions) |
659 | + |
660 | + def test_reconcile_set_public(self): |
661 | + owner = self.factory.makePerson() |
662 | + recipe = self.factory.makeOCIRecipe( |
663 | + registrant=owner, owner=owner, |
664 | + information_type=InformationType.USERDATA) |
665 | + another_user = self.factory.makePerson() |
666 | + with admin_logged_in(): |
667 | + recipe.subscribe(another_user, recipe.owner) |
668 | + self.assertEqual(1, self.getGrants(recipe, another_user).count()) |
669 | + self.assertThat( |
670 | + recipe.getSubscription(another_user), |
671 | + MatchesStructure( |
672 | + person=Equals(another_user), |
673 | + recipe=Equals(recipe), |
674 | + subscribed_by=Equals(recipe.owner), |
675 | + date_created=IsInstance(datetime))) |
676 | + |
677 | + recipe.information_type = InformationType.PUBLIC |
678 | + self.assertEqual(0, self.getGrants(recipe, another_user).count()) |
679 | + self.assertThat( |
680 | + recipe.getSubscription(another_user), |
681 | + MatchesStructure( |
682 | + person=Equals(another_user), |
683 | + recipe=Equals(recipe), |
684 | + subscribed_by=Equals(recipe.owner), |
685 | + date_created=IsInstance(datetime))) |
686 | + |
687 | + def test_owner_is_subscribed_automatically(self): |
688 | + recipe = self.factory.makeOCIRecipe() |
689 | + owner = recipe.owner |
690 | + registrant = recipe.registrant |
691 | + self.assertTrue(recipe.visibleByUser(owner)) |
692 | + self.assertIn(owner, recipe.subscribers) |
693 | + with person_logged_in(owner): |
694 | + self.assertThat(recipe.getSubscription(owner), MatchesStructure( |
695 | + person=Equals(owner), |
696 | + subscribed_by=Equals(registrant), |
697 | + date_created=IsInstance(datetime))) |
698 | + |
699 | + def test_owner_can_grant_access(self): |
700 | + owner = self.factory.makePerson() |
701 | + recipe = self.factory.makeOCIRecipe( |
702 | + registrant=owner, owner=owner, |
703 | + information_type=InformationType.USERDATA) |
704 | + other_person = self.factory.makePerson() |
705 | + with person_logged_in(other_person): |
706 | + self.assertRaises(Unauthorized, getattr, recipe, 'subscribe') |
707 | + with person_logged_in(owner): |
708 | + recipe.subscribe(other_person, owner) |
709 | + self.assertIn(other_person, recipe.subscribers) |
710 | + |
711 | + def test_private_is_invisible_by_default(self): |
712 | + owner = self.factory.makePerson() |
713 | + person = self.factory.makePerson() |
714 | + recipe = self.factory.makeOCIRecipe( |
715 | + registrant=owner, owner=owner, |
716 | + information_type=InformationType.USERDATA) |
717 | + with person_logged_in(owner): |
718 | + self.assertFalse(recipe.visibleByUser(person)) |
719 | + |
720 | + def test_private_is_visible_by_team_member(self): |
721 | + person = self.factory.makePerson() |
722 | + team = self.factory.makeTeam( |
723 | + members=[person], membership_policy=TeamMembershipPolicy.MODERATED) |
724 | + recipe = self.factory.makeOCIRecipe( |
725 | + owner=team, registrant=person, |
726 | + information_type=InformationType.USERDATA) |
727 | + with person_logged_in(team): |
728 | + self.assertTrue(recipe.visibleByUser(person)) |
729 | + |
730 | + def test_subscribing_changes_visibility(self): |
731 | + person = self.factory.makePerson() |
732 | + owner = self.factory.makePerson() |
733 | + recipe = self.factory.makeOCIRecipe( |
734 | + registrant=owner, owner=owner, |
735 | + information_type=InformationType.USERDATA) |
736 | + |
737 | + with person_logged_in(owner): |
738 | + self.assertFalse(recipe.visibleByUser(person)) |
739 | + recipe.subscribe(person, recipe.owner) |
740 | + self.assertThat( |
741 | + recipe.getSubscription(person), |
742 | + MatchesStructure( |
743 | + person=Equals(person), |
744 | + recipe=Equals(recipe), |
745 | + subscribed_by=Equals(recipe.owner), |
746 | + date_created=IsInstance(datetime))) |
747 | + # Calling again should be a no-op. |
748 | + recipe.subscribe(person, recipe.owner) |
749 | + self.assertTrue(recipe.visibleByUser(person)) |
750 | + |
751 | + recipe.unsubscribe(person, recipe.owner) |
752 | + self.assertFalse(recipe.visibleByUser(person)) |
753 | + self.assertIsNone(recipe.getSubscription(person)) |
754 | + |
755 | + def test_owner_can_unsubscribe_anyone(self): |
756 | + person = self.factory.makePerson() |
757 | + owner = self.factory.makePerson() |
758 | + admin = self.factory.makeAdministrator() |
759 | + recipe = self.factory.makeOCIRecipe( |
760 | + registrant=owner, owner=owner, |
761 | + information_type=InformationType.USERDATA) |
762 | + with person_logged_in(admin): |
763 | + recipe.subscribe(person, admin) |
764 | + self.assertTrue(recipe.visibleByUser(person)) |
765 | + with person_logged_in(owner): |
766 | + recipe.unsubscribe(person, owner) |
767 | + self.assertFalse(recipe.visibleByUser(person)) |
768 | + |
769 | |
770 | class TestOCIRecipeProcessors(TestCaseWithFactory): |
771 | |
772 | diff --git a/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py b/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py |
773 | index 8a51b42..e7b33ee 100644 |
774 | --- a/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py |
775 | +++ b/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py |
776 | @@ -424,7 +424,9 @@ class TestAsyncOCIRecipeBuildBehaviour( |
777 | [ref] = self.factory.makeGitRefs() |
778 | ref.repository.transitionToInformationType( |
779 | InformationType.PRIVATESECURITY, ref.repository.owner) |
780 | + owner = self.factory.makePerson() |
781 | recipe = self.factory.makeOCIRecipe( |
782 | + owner=owner, registrant=owner, |
783 | information_type=InformationType.USERDATA) |
784 | job = self.makeJob(git_ref=ref, recipe=recipe) |
785 | expected_archives, expected_trusted_keys = ( |
786 | diff --git a/lib/lp/registry/personmerge.py b/lib/lp/registry/personmerge.py |
787 | index a4a79e9..9f9ac4a 100644 |
788 | --- a/lib/lp/registry/personmerge.py |
789 | +++ b/lib/lp/registry/personmerge.py |
790 | @@ -696,18 +696,36 @@ def _mergeOCIRecipe(cur, from_person, to_person): |
791 | existing_names = [ |
792 | r.name for r in getUtility(IOCIRecipeSet).findByOwner(to_person)] |
793 | for recipe in oci_recipes: |
794 | - new_name = recipe.name |
795 | + naked_recipe = removeSecurityProxy(recipe) |
796 | + new_name = naked_recipe.name |
797 | count = 1 |
798 | while new_name in existing_names: |
799 | - new_name = '%s-%s' % (recipe.name, count) |
800 | + new_name = '%s-%s' % (naked_recipe.name, count) |
801 | count += 1 |
802 | - naked_recipe = removeSecurityProxy(recipe) |
803 | naked_recipe.owner = to_person |
804 | naked_recipe.name = new_name |
805 | if not oci_recipes.is_empty(): |
806 | IStore(oci_recipes[0]).flush() |
807 | |
808 | |
809 | +def _mergeOCIRecipeSubscription(cur, from_id, to_id): |
810 | + # Update only the OCIRecipeSubscription that will not conflict. |
811 | + cur.execute(''' |
812 | + UPDATE OCIRecipeSubscription |
813 | + SET person=%(to_id)d |
814 | + WHERE person=%(from_id)d AND recipe NOT IN |
815 | + ( |
816 | + SELECT recipe |
817 | + FROM OCIRecipeSubscription |
818 | + WHERE person = %(to_id)d |
819 | + ) |
820 | + ''' % vars()) |
821 | + # and delete those left over. |
822 | + cur.execute(''' |
823 | + DELETE FROM OCIRecipeSubscription WHERE person=%(from_id)d |
824 | + ''' % vars()) |
825 | + |
826 | + |
827 | def _purgeUnmergableTeamArtifacts(from_team, to_team, reviewer): |
828 | """Purge team artifacts that cannot be merged, but can be removed.""" |
829 | # A team cannot have more than one mailing list. |
830 | @@ -941,8 +959,12 @@ def merge_people(from_person, to_person, reviewer, delete=False): |
831 | _mergeOCIRecipe(cur, from_person, to_person) |
832 | skip.append(('ocirecipe', 'owner')) |
833 | |
834 | +<<<<<<< lib/lp/registry/personmerge.py |
835 | # XXX pappacena 2021-03-05: We need to implement the proper handling for |
836 | # this once we have OCIRecipeSubscription implemented. |
837 | +======= |
838 | + _mergeOCIRecipeSubscription(cur, from_id, to_id) |
839 | +>>>>>>> lib/lp/registry/personmerge.py |
840 | skip.append(('ocirecipesubscription', 'person')) |
841 | |
842 | # Sanity check. If we have a reference that participates in a |
843 | diff --git a/lib/lp/registry/services/sharingservice.py b/lib/lp/registry/services/sharingservice.py |
844 | index 727b91c..a0e755b 100644 |
845 | --- a/lib/lp/registry/services/sharingservice.py |
846 | +++ b/lib/lp/registry/services/sharingservice.py |
847 | @@ -39,7 +39,10 @@ from lp.bugs.interfaces.bugtask import IBugTaskSet |
848 | from lp.bugs.interfaces.bugtasksearch import BugTaskSearchParams |
849 | from lp.code.interfaces.branchcollection import IAllBranches |
850 | from lp.code.interfaces.gitcollection import IAllGitRepositories |
851 | -from lp.oci.interfaces.ocirecipe import IOCIRecipe |
852 | +from lp.oci.interfaces.ocirecipe import ( |
853 | + IOCIRecipe, |
854 | + IOCIRecipeSet, |
855 | + ) |
856 | from lp.oci.model.ocirecipe import OCIRecipe |
857 | from lp.registry.enums import ( |
858 | BranchSharingPolicy, |
859 | @@ -357,6 +360,7 @@ class SharingService: |
860 | branch_ids = [] |
861 | gitrepository_ids = [] |
862 | snap_ids = [] |
863 | + ocirecipes_ids = [] |
864 | for bug in bugs or []: |
865 | if (not ignore_permissions |
866 | and not check_permission('launchpad.View', bug)): |
867 | @@ -381,6 +385,11 @@ class SharingService: |
868 | if (not ignore_permissions |
869 | and not check_permission('launchpad.View', spec)): |
870 | raise Unauthorized |
871 | + for ocirecipe in ocirecipes or []: |
872 | + if (not ignore_permissions |
873 | + and not check_permission('launchpad.View', ocirecipe)): |
874 | + raise Unauthorized |
875 | + ocirecipes_ids.append(ocirecipe.id) |
876 | |
877 | # Load the bugs. |
878 | visible_bugs = [] |
879 | @@ -421,9 +430,19 @@ class SharingService: |
880 | spec for spec in specifications |
881 | if spec.id in visible_private_spec_ids or not spec.private] |
882 | |
883 | - return ( |
884 | - visible_bugs, visible_branches, visible_gitrepositories, |
885 | - visible_snaps, visible_specs) |
886 | + # Load the OCI recipes. |
887 | + visible_ocirecipes = [] |
888 | + if ocirecipes: |
889 | + visible_ocirecipes = list(getUtility(IOCIRecipeSet).findByIds( |
890 | + ocirecipes_ids, visible_by_user=person)) |
891 | + |
892 | + return { |
893 | + "bugs": visible_bugs, |
894 | + "branches": visible_branches, |
895 | + "gitrepositories": visible_gitrepositories, |
896 | + "snaps": visible_snaps, |
897 | + "specifications": visible_specs, |
898 | + "ocirecipes": visible_ocirecipes} |
899 | |
900 | def getInvisibleArtifacts(self, person, bugs=None, branches=None, |
901 | gitrepositories=None): |
902 | diff --git a/lib/lp/registry/services/tests/test_sharingservice.py b/lib/lp/registry/services/tests/test_sharingservice.py |
903 | index 6f52b89..5dbee9b 100644 |
904 | --- a/lib/lp/registry/services/tests/test_sharingservice.py |
905 | +++ b/lib/lp/registry/services/tests/test_sharingservice.py |
906 | @@ -1104,12 +1104,14 @@ class TestSharingService(TestCaseWithFactory, OCIConfigHelperMixin): |
907 | |
908 | # Check that grantees have expected access grants and subscriptions. |
909 | for person in [team_grantee, person_grantee]: |
910 | - (visible_bugs, visible_branches, visible_gitrepositories, |
911 | - visible_snaps, visible_specs) = ( |
912 | - self.service.getVisibleArtifacts( |
913 | + artifacts = self.service.getVisibleArtifacts( |
914 | person, bugs=bugs, branches=branches, |
915 | gitrepositories=gitrepositories, |
916 | - specifications=specifications)) |
917 | + specifications=specifications) |
918 | + visible_bugs = artifacts["bugs"] |
919 | + visible_branches = artifacts["branches"] |
920 | + visible_specs = artifacts["specifications"] |
921 | + |
922 | self.assertContentEqual(bugs or [], visible_bugs) |
923 | self.assertContentEqual(branches or [], visible_branches) |
924 | # XXX cjwatson 2015-02-05: check Git repositories when |
925 | @@ -1136,11 +1138,14 @@ class TestSharingService(TestCaseWithFactory, OCIConfigHelperMixin): |
926 | for person in [team_grantee, person_grantee]: |
927 | for bug in bugs or []: |
928 | self.assertNotIn(person, bug.getDirectSubscribers()) |
929 | - (visible_bugs, visible_branches, visible_gitrepositories, |
930 | - visible_snaps, visible_specs) = ( |
931 | - self.service.getVisibleArtifacts( |
932 | + artifacts = self.service.getVisibleArtifacts( |
933 | person, bugs=bugs, branches=branches, |
934 | - gitrepositories=gitrepositories)) |
935 | + gitrepositories=gitrepositories) |
936 | + visible_bugs = artifacts["bugs"] |
937 | + visible_branches = artifacts["branches"] |
938 | + visible_gitrepositories = artifacts["gitrepositories"] |
939 | + visible_specs = artifacts["specifications"] |
940 | + |
941 | self.assertContentEqual([], visible_bugs) |
942 | self.assertContentEqual([], visible_branches) |
943 | self.assertContentEqual([], visible_gitrepositories) |
944 | @@ -1847,11 +1852,14 @@ class TestSharingService(TestCaseWithFactory, OCIConfigHelperMixin): |
945 | grantee, ignore, bugs, branches, gitrepositories, specs = ( |
946 | self._make_Artifacts()) |
947 | # Check the results. |
948 | - (shared_bugs, shared_branches, shared_gitrepositories, |
949 | - shared_snaps, shared_specs) = ( |
950 | - self.service.getVisibleArtifacts( |
951 | + artifacts = self.service.getVisibleArtifacts( |
952 | grantee, bugs=bugs, branches=branches, |
953 | - gitrepositories=gitrepositories, specifications=specs)) |
954 | + gitrepositories=gitrepositories, specifications=specs) |
955 | + shared_bugs = artifacts["bugs"] |
956 | + shared_branches = artifacts["branches"] |
957 | + shared_gitrepositories = artifacts["gitrepositories"] |
958 | + shared_specs = artifacts["specifications"] |
959 | + |
960 | self.assertContentEqual(bugs[:5], shared_bugs) |
961 | self.assertContentEqual(branches[:5], shared_branches) |
962 | self.assertContentEqual(gitrepositories[:5], shared_gitrepositories) |
963 | @@ -1862,11 +1870,14 @@ class TestSharingService(TestCaseWithFactory, OCIConfigHelperMixin): |
964 | # user has a policy grant for the pillar of the specification. |
965 | _, owner, bugs, branches, gitrepositories, specs = ( |
966 | self._make_Artifacts()) |
967 | - (shared_bugs, shared_branches, shared_gitrepositories, |
968 | - shared_snaps, shared_specs) = ( |
969 | - self.service.getVisibleArtifacts( |
970 | + artifacts = self.service.getVisibleArtifacts( |
971 | owner, bugs=bugs, branches=branches, |
972 | - gitrepositories=gitrepositories, specifications=specs)) |
973 | + gitrepositories=gitrepositories, specifications=specs) |
974 | + shared_bugs = artifacts["bugs"] |
975 | + shared_branches = artifacts["branches"] |
976 | + shared_gitrepositories = artifacts["gitrepositories"] |
977 | + shared_specs = artifacts["specifications"] |
978 | + |
979 | self.assertContentEqual(bugs, shared_bugs) |
980 | self.assertContentEqual(branches, shared_branches) |
981 | self.assertContentEqual(gitrepositories, shared_gitrepositories) |
982 | @@ -1906,19 +1917,16 @@ class TestSharingService(TestCaseWithFactory, OCIConfigHelperMixin): |
983 | information_type=InformationType.USERDATA) |
984 | bugs.append(bug) |
985 | |
986 | - (shared_bugs, shared_branches, shared_gitrepositories, |
987 | - visible_snaps, shared_specs) = ( |
988 | - self.service.getVisibleArtifacts(grantee, bugs=bugs)) |
989 | + artifacts = self.service.getVisibleArtifacts(grantee, bugs=bugs) |
990 | + shared_bugs = artifacts["bugs"] |
991 | self.assertContentEqual(bugs, shared_bugs) |
992 | |
993 | # Change some bugs. |
994 | for x in range(0, 5): |
995 | change_callback(bugs[x], owner) |
996 | # Check the results. |
997 | - (shared_bugs, shared_branches, shared_gitrepositories, |
998 | - visible_snaps, shared_specs) = ( |
999 | - self.service.getVisibleArtifacts(grantee, bugs=bugs)) |
1000 | - self.assertContentEqual(bugs[5:], shared_bugs) |
1001 | + artifacts = self.service.getVisibleArtifacts(grantee, bugs=bugs) |
1002 | + self.assertContentEqual(bugs[5:], artifacts["bugs"]) |
1003 | |
1004 | def test_getVisibleArtifacts_bug_policy_change(self): |
1005 | # getVisibleArtifacts excludes bugs after change of information type. |
1006 | diff --git a/lib/lp/security.py b/lib/lp/security.py |
1007 | index a300279..03b35bd 100644 |
1008 | --- a/lib/lp/security.py |
1009 | +++ b/lib/lp/security.py |
1010 | @@ -107,6 +107,7 @@ from lp.oci.interfaces.ocirecipe import ( |
1011 | IOCIRecipeBuildRequest, |
1012 | ) |
1013 | from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild |
1014 | +from lp.oci.interfaces.ocirecipesubscription import IOCIRecipeSubscription |
1015 | from lp.oci.interfaces.ociregistrycredentials import IOCIRegistryCredentials |
1016 | from lp.registry.enums import PersonVisibility |
1017 | from lp.registry.interfaces.announcement import IAnnouncement |
1018 | @@ -3464,9 +3465,17 @@ class ViewOCIRecipeBuildRequest(DelegatedAuthorization): |
1019 | |
1020 | |
1021 | class ViewOCIRecipe(AnonymousAuthorization): |
1022 | - """Anyone can view an `IOCIRecipe`.""" |
1023 | + """Anyone can view public `IOCIRecipe`, but only subscribers can view |
1024 | + private ones. |
1025 | + """ |
1026 | usedfor = IOCIRecipe |
1027 | |
1028 | + def checkUnauthenticated(self): |
1029 | + return self.obj.visibleByUser(None) |
1030 | + |
1031 | + def checkAuthenticated(self, user): |
1032 | + return self.obj.visibleByUser(user.person) |
1033 | + |
1034 | |
1035 | class EditOCIRecipe(AuthorizationBase): |
1036 | permission = 'launchpad.Edit' |
1037 | @@ -3496,6 +3505,37 @@ class AdminOCIRecipe(AuthorizationBase): |
1038 | and EditSnap(self.obj).checkAuthenticated(user)) |
1039 | |
1040 | |
1041 | +class OCIRecipeSubscriptionEdit(AuthorizationBase): |
1042 | + permission = 'launchpad.Edit' |
1043 | + usedfor = IOCIRecipeSubscription |
1044 | + |
1045 | + def checkAuthenticated(self, user): |
1046 | + """Is the user able to edit an OCI recipe subscription? |
1047 | + |
1048 | + Any team member can edit a OCI recipe subscription for their |
1049 | + team. |
1050 | + Launchpad Admins can also edit any OCI recipe subscription. |
1051 | + The owner of the subscribed OCI recipe can edit the subscription. If |
1052 | + the OCI recipe owner is a team, then members of the team can edit |
1053 | + the subscription. |
1054 | + """ |
1055 | + return (user.inTeam(self.obj.recipe.owner) or |
1056 | + user.inTeam(self.obj.person) or |
1057 | + user.inTeam(self.obj.subscribed_by) or |
1058 | + user.in_admin) |
1059 | + |
1060 | + |
1061 | +class OCIRecipeSubscriptionView(AuthorizationBase): |
1062 | + permission = 'launchpad.View' |
1063 | + usedfor = IOCIRecipeSubscription |
1064 | + |
1065 | + def checkUnauthenticated(self): |
1066 | + return self.obj.recipe.visibleByUser(None) |
1067 | + |
1068 | + def checkAuthenticated(self, user): |
1069 | + return self.obj.recipe.visibleByUser(user.person) |
1070 | + |
1071 | + |
1072 | class ViewOCIRecipeBuild(AnonymousAuthorization): |
1073 | """Anyone can view an `IOCIRecipe`.""" |
1074 | usedfor = IOCIRecipeBuild |
1075 | diff --git a/lib/lp/snappy/model/snap.py b/lib/lp/snappy/model/snap.py |
1076 | index 08463eb..58acb8a 100644 |
1077 | --- a/lib/lp/snappy/model/snap.py |
1078 | +++ b/lib/lp/snappy/model/snap.py |
1079 | @@ -1204,8 +1204,8 @@ class Snap(Storm, WebhookTargetMixin): |
1080 | person=person, snap=self, subscribed_by=subscribed_by) |
1081 | Store.of(subscription).flush() |
1082 | service = getUtility(IService, "sharing") |
1083 | - _, _, _, snaps, _ = service.getVisibleArtifacts( |
1084 | - person, snaps=[self], ignore_permissions=True) |
1085 | + snaps = service.getVisibleArtifacts( |
1086 | + person, snaps=[self], ignore_permissions=True)["snaps"] |
1087 | if not snaps: |
1088 | service.ensureAccessGrants( |
1089 | [person], subscribed_by, snaps=[self], |
Pushed requested change.