Merge lp:~cjwatson/launchpad/snap-build-record-code into lp:launchpad

Proposed by Colin Watson on 2019-04-01
Status: Needs review
Proposed branch: lp:~cjwatson/launchpad/snap-build-record-code
Merge into: lp:launchpad
Diff against target: 524 lines (+244/-23)
11 files modified
lib/lp/code/model/branch.py (+17/-4)
lib/lp/code/model/gitrepository.py (+15/-3)
lib/lp/code/model/tests/test_branch.py (+3/-1)
lib/lp/code/model/tests/test_gitrepository.py (+3/-1)
lib/lp/security.py (+25/-4)
lib/lp/snappy/interfaces/snapbuild.py (+36/-0)
lib/lp/snappy/model/snap.py (+2/-1)
lib/lp/snappy/model/snapbuild.py (+49/-5)
lib/lp/snappy/model/snapbuildbehaviour.py (+2/-0)
lib/lp/snappy/tests/test_snapbuild.py (+91/-3)
lib/lp/testing/factory.py (+1/-1)
To merge this branch: bzr merge lp:~cjwatson/launchpad/snap-build-record-code
Reviewer Review Type Date Requested Status
Launchpad code reviewers 2019-04-01 Pending
Review via email: mp+365356@code.launchpad.net

Commit message

Record the code object from which a SnapBuild was created, for use by security adapters.

Description of the change

This is annoying, but I don't see any other way to cope with privacy changes of code objects that have snaps attached to them without leaking information about old builds.

We shouldn't land this until https://code.launchpad.net/~cjwatson/launchpad/branch-delete-job/+merge/364907 and https://code.launchpad.net/~cjwatson/launchpad/git-repository-delete-job/+merge/364910 have landed, as it'll make deletions slower; and, assuming all of these are approved, we'll need to add SELECT and DELETE permissions for the snap-build-job user on public.snapbuild before landing this.

https://code.launchpad.net/~cjwatson/launchpad/db-snap-build-record-code/+merge/365355 is the corresponding database patch.

To post a comment you must log in.
Colin Watson (cjwatson) wrote :

There are a couple of known problems with this, discussed on today's LP team call:

(1) When the snap build is detached, it will no longer have a private code artifact attached to it and thus may become public. Oops.
(2) It's not obvious that these are quite the semantics we want. Unlike source packages, Git repositories include history, but the history can be mutated (e.g. via git filter-branch), and the process of making a private repository public might well include redacting its history. If old snap builds automatically become public then that could be a problem.

We may need a private flag on the build, but it probably can't just be that because we need some way of knowing who can see it. Perhaps we could detach from public builds (thus keeping logs for old builds that are on public Ubuntu images, etc.) but delete private builds. Perhaps only the snap owner could see old detached private builds, or maybe even only admins. Or something else ...

18920. By Colin Watson on 2019-05-28

Add an explicitly_private flag to SnapBuild.

This lets us avoid accidental information leaks.

18921. By Colin Watson on 2019-05-28

Check privacy of code objects associated with snap builds.

18922. By Colin Watson on 2019-05-28

Merge devel.

18923. By Colin Watson on 2019-05-28

Add warning for future developers.

Colin Watson (cjwatson) wrote :

I've tightened up the privacy rules here as best I can. How's this?

Unmerged revisions

18923. By Colin Watson on 2019-05-28

Add warning for future developers.

18922. By Colin Watson on 2019-05-28

Merge devel.

18921. By Colin Watson on 2019-05-28

Check privacy of code objects associated with snap builds.

18920. By Colin Watson on 2019-05-28

Add an explicitly_private flag to SnapBuild.

This lets us avoid accidental information leaks.

18919. By Colin Watson on 2019-04-01

Record the code object from which a SnapBuild was created, for later use by security adapters.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/code/model/branch.py'
2--- lib/lp/code/model/branch.py 2019-04-17 10:34:13 +0000
3+++ lib/lp/code/model/branch.py 2019-05-28 16:37:20 +0000
4@@ -196,6 +196,7 @@
5 from lp.services.webhooks.interfaces import IWebhookSet
6 from lp.services.webhooks.model import WebhookTargetMixin
7 from lp.snappy.interfaces.snap import ISnapSet
8+from lp.snappy.interfaces.snapbuild import ISnapBuildSet
9
10
11 @implementer(IBranch, IPrivacy, IInformationType)
12@@ -923,10 +924,9 @@
13 DeletionCallable(
14 recipe, _('This recipe uses this branch.'), recipe.destroySelf)
15 for recipe in recipes)
16- if not getUtility(ISnapSet).findByBranch(self).is_empty():
17- alteration_operations.append(DeletionCallable(
18- None, _('Some snap packages build from this branch.'),
19- getUtility(ISnapSet).detachFromBranch, self))
20+ if (not getUtility(ISnapSet).findByBranch(self).is_empty() or
21+ not getUtility(ISnapBuildSet).findByBranch(self).is_empty()):
22+ alteration_operations.append(DetachSnaps(self))
23 return (alteration_operations, deletion_operations)
24
25 def deletionRequirements(self, eager_load=False):
26@@ -1644,6 +1644,19 @@
27 getUtility(ICodeImportSet).delete(self.affected_object)
28
29
30+class DetachSnaps(DeletionOperation):
31+ """Deletion operation that detaches snaps from a branch."""
32+
33+ def __init__(self, branch):
34+ super(DetachSnaps, self).__init__(
35+ None, _('Some snap packages build from this branch.'))
36+ self.branch = branch
37+
38+ def __call__(self):
39+ getUtility(ISnapSet).detachFromBranch(self.branch)
40+ getUtility(ISnapBuildSet).detachFromBranch(self.branch)
41+
42+
43 @implementer(IBranchSet)
44 class BranchSet:
45 """The set of all branches."""
46
47=== modified file 'lib/lp/code/model/gitrepository.py'
48--- lib/lp/code/model/gitrepository.py 2019-05-03 13:18:52 +0000
49+++ lib/lp/code/model/gitrepository.py 2019-05-28 16:37:20 +0000
50@@ -202,6 +202,7 @@
51 from lp.services.webhooks.interfaces import IWebhookSet
52 from lp.services.webhooks.model import WebhookTargetMixin
53 from lp.snappy.interfaces.snap import ISnapSet
54+from lp.snappy.interfaces.snapbuild import ISnapBuildSet
55
56
57 object_type_map = {
58@@ -1476,9 +1477,7 @@
59 recipe.destroySelf)
60 for recipe in recipes)
61 if not getUtility(ISnapSet).findByGitRepository(self).is_empty():
62- alteration_operations.append(DeletionCallable(
63- None, msg("Some snap packages build from this repository."),
64- getUtility(ISnapSet).detachFromGitRepository, self))
65+ alteration_operations.append(DetachSnaps(self))
66
67 return (alteration_operations, deletion_operations)
68
69@@ -1630,6 +1629,19 @@
70 getUtility(ICodeImportSet).delete(self.affected_object)
71
72
73+class DetachSnaps(DeletionOperation):
74+ """Deletion operation that detaches snaps from a repository."""
75+
76+ def __init__(self, repository):
77+ super(DetachSnaps, self).__init__(
78+ None, msg("Some snap packages build from this repository."))
79+ self.repository = repository
80+
81+ def __call__(self):
82+ getUtility(ISnapSet).detachFromGitRepository(self.repository)
83+ getUtility(ISnapBuildSet).detachFromGitRepository(self.repository)
84+
85+
86 @implementer(IGitRepositorySet)
87 class GitRepositorySet:
88 """See `IGitRepositorySet`."""
89
90=== modified file 'lib/lp/code/model/tests/test_branch.py'
91--- lib/lp/code/model/tests/test_branch.py 2019-01-28 18:09:21 +0000
92+++ lib/lp/code/model/tests/test_branch.py 2019-05-28 16:37:20 +0000
93@@ -1697,7 +1697,7 @@
94 def test_snap_requirements(self):
95 # If a branch is used by a snap package, the deletion requirements
96 # indicate this.
97- self.factory.makeSnap(branch=self.branch)
98+ self.factory.makeSnapBuild(branch=self.branch)
99 self.assertEqual(
100 {None: ('alter', _('Some snap packages build from this branch.'))},
101 self.branch.deletionRequirements())
102@@ -1705,7 +1705,9 @@
103 def test_snap_deletion(self):
104 # break_references allows deleting a branch used by a snap package.
105 snap1 = self.factory.makeSnap(branch=self.branch)
106+ self.factory.makeSnapBuild(snap=snap1)
107 snap2 = self.factory.makeSnap(branch=self.branch)
108+ self.factory.makeSnapBuild(snap=snap2)
109 self.branch.destroySelf(break_references=True)
110 transaction.commit()
111 self.assertIsNone(snap1.branch)
112
113=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
114--- lib/lp/code/model/tests/test_gitrepository.py 2019-05-03 13:18:52 +0000
115+++ lib/lp/code/model/tests/test_gitrepository.py 2019-05-28 16:37:20 +0000
116@@ -989,7 +989,7 @@
117 # If a repository is used by a snap package, the deletion
118 # requirements indicate this.
119 [ref] = self.factory.makeGitRefs()
120- self.factory.makeSnap(git_ref=ref)
121+ self.factory.makeSnapBuild(git_ref=ref)
122 self.assertEqual(
123 {None:
124 ("alter", _("Some snap packages build from this repository."))},
125@@ -1001,7 +1001,9 @@
126 [ref1, ref2] = self.factory.makeGitRefs(
127 repository=repository, paths=["refs/heads/1", "refs/heads/2"])
128 snap1 = self.factory.makeSnap(git_ref=ref1)
129+ self.factory.makeSnapBuild(snap=snap1)
130 snap2 = self.factory.makeSnap(git_ref=ref2)
131+ self.factory.makeSnapBuild(snap=snap2)
132 repository.destroySelf(break_references=True)
133 transaction.commit()
134 self.assertIsNone(snap1.git_repository)
135
136=== modified file 'lib/lp/security.py'
137--- lib/lp/security.py 2019-03-26 20:51:38 +0000
138+++ lib/lp/security.py 2019-05-28 16:37:20 +0000
139@@ -3363,13 +3363,34 @@
140 obj, obj.snap, 'launchpad.View')
141
142
143-class ViewSnapBuild(DelegatedAuthorization):
144+class ViewSnapBuild(AuthorizationBase):
145 permission = 'launchpad.View'
146 usedfor = ISnapBuild
147
148- def iter_objects(self):
149- yield self.obj.snap
150- yield self.obj.archive
151+ def checkUnauthenticated(self):
152+ return not self.obj.is_private
153+
154+ def checkAuthenticated(self, user):
155+ if not self.obj.is_private:
156+ return True
157+
158+ for item in (
159+ self.obj.snap, self.obj.archive,
160+ self.obj.branch, self.obj.git_repository):
161+ if (item is not None and
162+ not self.forwardCheckAuthenticated(user, item)):
163+ return False
164+
165+ # Only the snap owner and admins can see explicitly-private builds,
166+ # on top of any restrictions associated with the individual
167+ # components above. This may be overly-restrictive but is at least
168+ # relatively safe; if need be we can investigate loosening this
169+ # later, but be careful of the case where private code objects have
170+ # been detached.
171+ if self.obj.explicitly_private:
172+ return EditSnap(self.obj.snap).checkAuthenticated(user)
173+ else:
174+ return True
175
176
177 class EditSnapBuild(AdminByBuilddAdmin):
178
179=== modified file 'lib/lp/snappy/interfaces/snapbuild.py'
180--- lib/lp/snappy/interfaces/snapbuild.py 2018-12-12 10:31:40 +0000
181+++ lib/lp/snappy/interfaces/snapbuild.py 2019-05-28 16:37:20 +0000
182@@ -51,6 +51,8 @@
183 from lp import _
184 from lp.buildmaster.interfaces.buildfarmjob import ISpecificBuildFarmJobSource
185 from lp.buildmaster.interfaces.packagebuild import IPackageBuild
186+from lp.code.interfaces.branch import IBranch
187+from lp.code.interfaces.gitrepository import IGitRepository
188 from lp.registry.interfaces.person import IPerson
189 from lp.registry.interfaces.pocket import PackagePublishingPocket
190 from lp.services.database.constants import DEFAULT
191@@ -168,6 +170,14 @@
192 "supported."),
193 key_type=TextLine()))
194
195+ explicitly_private = Bool(
196+ title=_("Whether this build is explicitly private."),
197+ description=_(
198+ "An explicitly-private build remains private even if its snap and "
199+ "other associated resources are public. This allows snaps to "
200+ "transition between private and public states without "
201+ "inadvertently leaking old private information."))
202+
203 virtualized = Bool(
204 title=_("If True, this build is virtualized."), readonly=True)
205
206@@ -248,6 +258,20 @@
207 store_upload_metadata = Attribute(
208 _("A dict of data about store upload progress."))
209
210+ branch = Reference(
211+ IBranch,
212+ title=_("Bazaar branch"), required=False, readonly=True)
213+
214+ git_repository = Reference(
215+ IGitRepository,
216+ title=_("Git repository"), required=False, readonly=True)
217+
218+ git_repository_url = TextLine(
219+ title=_("Git repository URL"), required=False, readonly=True)
220+
221+ git_path = TextLine(
222+ title=_("Git branch path"), required=False, readonly=True)
223+
224 def getFiles():
225 """Retrieve the build's `ISnapFile` records.
226
227@@ -343,3 +367,15 @@
228
229 def preloadBuildsData(builds):
230 """Load the data related to a list of snap builds."""
231+
232+ def findByBranch(branch):
233+ """Return all snap builds for the given Bazaar branch."""
234+
235+ def findByGitRepository(repository, paths=None):
236+ """Return all snap builds for the given Git repository."""
237+
238+ def detachFromBranch(branch):
239+ """Detach all snap builds from the given Bazaar branch."""
240+
241+ def detachFromGitRepository(repository):
242+ """Detach all snap builds from the given Git repository."""
243
244=== modified file 'lib/lp/snappy/model/snap.py'
245--- lib/lp/snappy/model/snap.py 2019-05-16 10:21:14 +0000
246+++ lib/lp/snappy/model/snap.py 2019-05-28 16:37:20 +0000
247@@ -650,7 +650,8 @@
248
249 build = getUtility(ISnapBuildSet).new(
250 requester, self, archive, distro_arch_series, pocket,
251- channels=channels, build_request=build_request)
252+ channels=channels, build_request=build_request,
253+ explicitly_private=self.private)
254 build.queueBuild()
255 notify(ObjectCreatedEvent(build, user=requester))
256 return build
257
258=== modified file 'lib/lp/snappy/model/snapbuild.py'
259--- lib/lp/snappy/model/snapbuild.py 2019-05-09 15:44:59 +0000
260+++ lib/lp/snappy/model/snapbuild.py 2019-05-28 16:37:20 +0000
261@@ -154,6 +154,8 @@
262
263 channels = JSON('channels', allow_none=True)
264
265+ explicitly_private = Bool(name='explicitly_private')
266+
267 processor_id = Int(name='processor', allow_none=False)
268 processor = Reference(processor_id, 'Processor.id')
269 virtualized = Bool(name='virtualized')
270@@ -184,9 +186,20 @@
271
272 store_upload_metadata = JSON('store_upload_json_data', allow_none=True)
273
274+ branch_id = Int(name='branch', allow_none=True)
275+ branch = Reference(branch_id, 'Branch.id')
276+
277+ git_repository_id = Int(name='git_repository', allow_none=True)
278+ git_repository = Reference(git_repository_id, 'GitRepository.id')
279+
280+ git_repository_url = Unicode(name='git_repository_url', allow_none=True)
281+
282+ git_path = Unicode(name='git_path', allow_none=True)
283+
284 def __init__(self, build_farm_job, requester, snap, archive,
285 distro_arch_series, pocket, channels, processor, virtualized,
286- date_created, store_upload_metadata=None, build_request=None):
287+ date_created, store_upload_metadata=None, build_request=None,
288+ explicitly_private=False):
289 """Construct a `SnapBuild`."""
290 super(SnapBuild, self).__init__()
291 self.build_farm_job = build_farm_job
292@@ -202,7 +215,14 @@
293 self.store_upload_metadata = store_upload_metadata
294 if build_request is not None:
295 self.build_request_id = build_request.id
296+ self.explicitly_private = explicitly_private
297 self.status = BuildStatus.NEEDSBUILD
298+ # Copy branch/repository information from the snap, for future
299+ # privacy checks on this build.
300+ self.branch = snap.branch
301+ self.git_repository = snap.git_repository
302+ self.git_repository_url = snap.git_repository_url
303+ self.git_path = snap.git_path
304
305 @property
306 def build_request(self):
307@@ -214,10 +234,15 @@
308 def is_private(self):
309 """See `IBuildFarmJob`."""
310 return (
311+ self.explicitly_private or
312 self.snap.private or
313 self.snap.owner.private or
314- self.archive.private
315- )
316+ # We must check individual components here, since the Snap's
317+ # configuration may have changed since this build was created.
318+ self.archive.private or
319+ (self.branch is not None and self.branch.private) or
320+ (self.git_repository is not None and self.git_repository.private)
321+ )
322
323 @property
324 def title(self):
325@@ -518,7 +543,8 @@
326
327 def new(self, requester, snap, archive, distro_arch_series, pocket,
328 channels=None, date_created=DEFAULT,
329- store_upload_metadata=None, build_request=None):
330+ store_upload_metadata=None, build_request=None,
331+ explicitly_private=False):
332 """See `ISnapBuildSet`."""
333 store = IMasterStore(SnapBuild)
334 build_farm_job = getUtility(IBuildFarmJobSource).new(
335@@ -530,7 +556,7 @@
336 not distro_arch_series.processor.supports_nonvirtualized
337 or snap.require_virtualized or archive.require_virtualized,
338 date_created, store_upload_metadata=store_upload_metadata,
339- build_request=build_request)
340+ build_request=build_request, explicitly_private=explicitly_private)
341 store.add(snapbuild)
342 return snapbuild
343
344@@ -592,6 +618,24 @@
345 bfj.id for bfj in build_farm_jobs))
346 return DecoratedResultSet(rows, pre_iter_hook=self.preloadBuildsData)
347
348+ def findByBranch(self, branch):
349+ """See `ISnapBuildSet`."""
350+ return IStore(SnapBuild).find(SnapBuild, SnapBuild.branch == branch)
351+
352+ def findByGitRepository(self, repository):
353+ """See `ISnapBuildSet`."""
354+ return IStore(SnapBuild).find(
355+ SnapBuild, SnapBuild.git_repository == repository)
356+
357+ def detachFromBranch(self, branch):
358+ """See `ISnapBuildSet`."""
359+ self.findByBranch(branch).set(branch_id=None)
360+
361+ def detachFromGitRepository(self, repository):
362+ """See `ISnapBuildSet`."""
363+ self.findByGitRepository(repository).set(
364+ git_repository_id=None, git_path=None)
365+
366
367 @implementer(IMacaroonIssuer)
368 class SnapBuildMacaroonIssuer(MacaroonIssuerBase):
369
370=== modified file 'lib/lp/snappy/model/snapbuildbehaviour.py'
371--- lib/lp/snappy/model/snapbuildbehaviour.py 2019-05-22 16:54:09 +0000
372+++ lib/lp/snappy/model/snapbuildbehaviour.py 2019-05-28 16:37:20 +0000
373@@ -139,6 +139,8 @@
374 # dict, since otherwise we'll be unable to serialise it to
375 # XML-RPC.
376 args["channels"] = removeSecurityProxy(channels)
377+ # XXX cjwatson 2019-04-01: Once new SnapBuilds are being created on
378+ # production with code object columns, we should use them here.
379 if build.snap.branch is not None:
380 args["branch"] = build.snap.branch.bzr_identity
381 elif build.snap.git_ref is not None:
382
383=== modified file 'lib/lp/snappy/tests/test_snapbuild.py'
384--- lib/lp/snappy/tests/test_snapbuild.py 2019-05-09 15:44:59 +0000
385+++ lib/lp/snappy/tests/test_snapbuild.py 2019-05-28 16:37:20 +0000
386@@ -60,6 +60,7 @@
387 from lp.snappy.interfaces.snapbuildjob import ISnapStoreUploadJobSource
388 from lp.soyuz.enums import ArchivePurpose
389 from lp.testing import (
390+ admin_logged_in,
391 ANONYMOUS,
392 api_url,
393 login,
394@@ -158,20 +159,91 @@
395 build = self.factory.makeSnapBuild(archive=self.factory.makeArchive())
396 self.assertEqual("main", build.current_component.name)
397
398- def test_is_private(self):
399- # A SnapBuild is private iff its Snap and archive are.
400+ def test_public(self):
401+ # A SnapBuild is public if it has no associated private resources.
402 self.assertFalse(self.build.is_private)
403+
404+ def test_explicitly_private(self):
405+ # A SnapBuild is private if it is marked explicitly private due to
406+ # its Snap being private. It remains private even if the Snap is
407+ # later made public, in order to avoid accidental information leaks.
408+ with person_logged_in(self.factory.makePerson()) as owner:
409+ build = self.factory.makeSnapBuild(
410+ requester=owner, owner=owner, private=True)
411+ self.assertTrue(build.explicitly_private)
412+ self.assertTrue(build.is_private)
413+ with admin_logged_in():
414+ build.snap.private = False
415+ self.assertTrue(build.is_private)
416+
417+ def test_private_if_owner_is_private(self):
418+ # A SnapBuild is private if its owner is private.
419 private_team = self.factory.makeTeam(
420 visibility=PersonVisibility.PRIVATE)
421 with person_logged_in(private_team.teamowner):
422 build = self.factory.makeSnapBuild(
423 requester=private_team.teamowner, owner=private_team,
424 private=True)
425+ self.assertTrue(build.explicitly_private)
426 self.assertTrue(build.is_private)
427+
428+ def test_private_if_archive_is_private(self):
429+ # A SnapBuild is private if its archive is private.
430 private_archive = self.factory.makeArchive(private=True)
431 with person_logged_in(private_archive.owner):
432 build = self.factory.makeSnapBuild(archive=private_archive)
433- self.assertTrue(build.is_private)
434+ self.assertFalse(build.explicitly_private)
435+ self.assertTrue(build.is_private)
436+
437+ def test_private_if_branch_is_private(self):
438+ # A SnapBuild is private if its Bazaar branch is private, even if
439+ # the Snap is later reconfigured to use a public branch.
440+ private_branch = self.factory.makeAnyBranch(
441+ information_type=InformationType.USERDATA)
442+ with person_logged_in(private_branch.owner):
443+ build = self.factory.makeSnapBuild(
444+ branch=private_branch, private=True)
445+ self.assertTrue(build.explicitly_private)
446+ self.assertTrue(build.is_private)
447+ build.snap.branch = self.factory.makeAnyBranch()
448+ self.assertTrue(build.is_private)
449+
450+ def test_private_if_git_repository_is_private(self):
451+ # A SnapBuild is private if its Git repository is private, even if
452+ # the Snap is later reconfigured to use a public repository.
453+ [private_ref] = self.factory.makeGitRefs(
454+ information_type=InformationType.USERDATA)
455+ with person_logged_in(private_ref.owner):
456+ build = self.factory.makeSnapBuild(
457+ git_ref=private_ref, private=True)
458+ self.assertTrue(build.explicitly_private)
459+ self.assertTrue(build.is_private)
460+ build.snap.git_ref = self.factory.makeGitRefs()[0]
461+ self.assertTrue(build.is_private)
462+
463+ def test_copies_code_information(self):
464+ # Creating a SnapBuild copies code information from its parent Snap.
465+ for args in (
466+ {"branch": self.factory.makeAnyBranch()},
467+ {"git_ref": self.factory.makeGitRefs()[0]},
468+ {"git_ref": self.factory.makeGitRefRemote()},
469+ ):
470+ snap = self.factory.makeSnap(**args)
471+ build = self.factory.makeSnapBuild(snap=snap)
472+ snap.branch = self.factory.makeAnyBranch()
473+ snap.git_ref = None
474+ if "branch" in args:
475+ self.assertThat(build, MatchesStructure(
476+ branch=Equals(args["branch"]),
477+ git_repository=Is(None),
478+ git_repository_url=Is(None),
479+ git_path=Is(None)))
480+ else:
481+ self.assertThat(build, MatchesStructure(
482+ branch=Is(None),
483+ git_repository=Equals(args["git_ref"].repository),
484+ git_repository_url=Equals(args["git_ref"].repository_url),
485+ git_path=Equals(args["git_ref"].path)))
486
487 def test_can_be_cancelled(self):
488 # For all states that can be cancelled, can_be_cancelled returns True.
489@@ -693,6 +765,22 @@
490 # 404 since we aren't allowed to know that the private team exists.
491 self.assertEqual(404, unpriv_webservice.get(build_url).status)
492
493+ def test_private_snap_made_public(self):
494+ # A SnapBuild with a Snap that was initially private but then made
495+ # public is still private.
496+ with person_logged_in(self.person):
497+ db_build = self.factory.makeSnapBuild(
498+ requester=self.person, owner=self.person, private=True)
499+ build_url = api_url(db_build)
500+ with admin_logged_in():
501+ db_build.snap.private = False
502+ unpriv_webservice = webservice_for_person(
503+ self.factory.makePerson(), permission=OAuthPermission.WRITE_PUBLIC)
504+ unpriv_webservice.default_api_version = "devel"
505+ logout()
506+ self.assertEqual(200, self.webservice.get(build_url).status)
507+ self.assertEqual(401, unpriv_webservice.get(build_url).status)
508+
509 def test_private_archive(self):
510 # A SnapBuild with a private archive is private.
511 db_archive = self.factory.makeArchive(owner=self.person, private=True)
512
513=== modified file 'lib/lp/testing/factory.py'
514--- lib/lp/testing/factory.py 2019-05-06 14:22:34 +0000
515+++ lib/lp/testing/factory.py 2019-05-28 16:37:20 +0000
516@@ -4800,7 +4800,7 @@
517 snapbuild = getUtility(ISnapBuildSet).new(
518 requester, snap, archive, distroarchseries, pocket,
519 channels=channels, date_created=date_created,
520- build_request=build_request)
521+ build_request=build_request, explicitly_private=snap.private)
522 if duration is not None:
523 removeSecurityProxy(snapbuild).updateStatus(
524 BuildStatus.BUILDING, builder=builder,