Merge ~pappacena/launchpad:ocirecipe-subscription into launchpad: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
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

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 :

Pushed requested change.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/blueprints/model/specification.py b/lib/lp/blueprints/model/specification.py
2index 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])
17diff --git a/lib/lp/blueprints/tests/test_specification.py b/lib/lp/blueprints/tests/test_specification.py
18index 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):
54diff --git a/lib/lp/bugs/model/bug.py b/lib/lp/bugs/model/bug.py
55index 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
80diff --git a/lib/lp/code/browser/branchsubscription.py b/lib/lp/code/browser/branchsubscription.py
81index 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
96diff --git a/lib/lp/code/browser/gitsubscription.py b/lib/lp/code/browser/gitsubscription.py
97index 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
112diff --git a/lib/lp/code/model/branch.py b/lib/lp/code/model/branch.py
113index 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],
128diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
129index 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],
144diff --git a/lib/lp/code/model/tests/test_branchsubscription.py b/lib/lp/code/model/tests/test_branchsubscription.py
145index 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)
170diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml
171index 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
192diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
193index 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
244diff --git a/lib/lp/oci/interfaces/ocirecipesubscription.py b/lib/lp/oci/interfaces/ocirecipesubscription.py
245new file mode 100644
246index 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?"""
293diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
294index 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)
547diff --git a/lib/lp/oci/model/ocirecipesubscription.py b/lib/lp/oci/model/ocirecipesubscription.py
548new file mode 100644
549index 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)
615diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py
616index 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
772diff --git a/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py b/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py
773index 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 = (
786diff --git a/lib/lp/registry/personmerge.py b/lib/lp/registry/personmerge.py
787index 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
843diff --git a/lib/lp/registry/services/sharingservice.py b/lib/lp/registry/services/sharingservice.py
844index 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):
902diff --git a/lib/lp/registry/services/tests/test_sharingservice.py b/lib/lp/registry/services/tests/test_sharingservice.py
903index 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.
1006diff --git a/lib/lp/security.py b/lib/lp/security.py
1007index 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
1075diff --git a/lib/lp/snappy/model/snap.py b/lib/lp/snappy/model/snap.py
1076index 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],