launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #27388
[Merge] ~cjwatson/launchpad:charmhub-authorization into launchpad:master
Colin Watson has proposed merging ~cjwatson/launchpad:charmhub-authorization into launchpad:master.
Commit message:
Add mechanisms and a view for authorizing Charmhub uploads
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/406852
As with snaps, we also automatically direct the user through this workflow if they configure a charm recipe such that it requires authorization.
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charmhub-authorization into launchpad:master.
diff --git a/lib/lp/charms/browser/charmrecipe.py b/lib/lp/charms/browser/charmrecipe.py
index 501f700..8bbaba6 100644
--- a/lib/lp/charms/browser/charmrecipe.py
+++ b/lib/lp/charms/browser/charmrecipe.py
@@ -7,6 +7,7 @@ __metaclass__ = type
__all__ = [
"CharmRecipeAddView",
"CharmRecipeAdminView",
+ "CharmRecipeAuthorizeView",
"CharmRecipeContextMenu",
"CharmRecipeDeleteView",
"CharmRecipeEditView",
@@ -22,6 +23,7 @@ from lazr.restful.interface import (
use_template,
)
from zope.component import getUtility
+from zope.error.interfaces import IErrorReportingUtility
from zope.interface import (
implementer,
Interface,
@@ -43,7 +45,11 @@ from lp.app.browser.tales import format_link
from lp.charms.browser.widgets.charmrecipebuildchannels import (
CharmRecipeBuildChannelsWidget,
)
+from lp.charms.interfaces.charmhubclient import (
+ BadRequestPackageUploadResponse,
+ )
from lp.charms.interfaces.charmrecipe import (
+ CannotAuthorizeCharmhubUploads,
ICharmRecipe,
ICharmRecipeSet,
NoSuchCharmRecipe,
@@ -64,11 +70,13 @@ from lp.services.webapp import (
Navigation,
NavigationMenu,
stepthrough,
+ structured,
)
from lp.services.webapp.breadcrumb import (
Breadcrumb,
NameBreadcrumb,
)
+from lp.services.webapp.candid import request_candid_discharge
from lp.services.webapp.interfaces import ICanonicalUrlData
from lp.snappy.browser.widgets.storechannels import StoreChannelsWidget
from lp.soyuz.browser.build import get_build_by_id_str
@@ -130,7 +138,7 @@ class CharmRecipeNavigationMenu(NavigationMenu):
facet = "overview"
- links = ("admin", "edit", "delete")
+ links = ("admin", "edit", "authorize", "delete")
@enabled_with_permission("launchpad.Admin")
def admin(self):
@@ -141,6 +149,14 @@ class CharmRecipeNavigationMenu(NavigationMenu):
return Link("+edit", "Edit charm recipe", icon="edit")
@enabled_with_permission("launchpad.Edit")
+ def authorize(self):
+ if self.context.store_secrets:
+ text = "Reauthorize Charmhub uploads"
+ else:
+ text = "Authorize Charmhub uploads"
+ return Link("+authorize", text, icon="edit")
+
+ @enabled_with_permission("launchpad.Edit")
def delete(self):
return Link("+delete", "Delete charm recipe", icon="trash-icon")
@@ -264,7 +280,26 @@ class ICharmRecipeEditSchema(Interface):
store_channels = copy_field(ICharmRecipe["store_channels"], required=True)
-class CharmRecipeAddView(LaunchpadFormView):
+def log_oops(error, request):
+ """Log an oops report without raising an error."""
+ info = (error.__class__, error, None)
+ getUtility(IErrorReportingUtility).raising(info, request)
+
+
+class CharmRecipeAuthorizeMixin:
+
+ def requestAuthorization(self, recipe):
+ try:
+ self.next_url = CharmRecipeAuthorizeView.requestAuthorization(
+ recipe, self.request)
+ except BadRequestPackageUploadResponse as e:
+ self.setFieldError(
+ "store_upload",
+ "Cannot get permission from Charmhub to upload this package.")
+ log_oops(e, self.request)
+
+
+class CharmRecipeAddView(CharmRecipeAuthorizeMixin, LaunchpadFormView):
"""View for creating charm recipes."""
page_title = label = "Create a new charm recipe"
@@ -336,7 +371,10 @@ class CharmRecipeAddView(LaunchpadFormView):
store_upload=data["store_upload"],
store_name=data["store_name"],
store_channels=data.get("store_channels"))
- self.next_url = canonical_url(recipe)
+ if data["store_upload"]:
+ self.requestAuthorization(recipe)
+ else:
+ self.next_url = canonical_url(recipe)
def validate(self, data):
super(CharmRecipeAddView, self).validate(data)
@@ -354,7 +392,8 @@ class CharmRecipeAddView(LaunchpadFormView):
"this name." % (owner.display_name, project.display_name))
-class BaseCharmRecipeEditView(LaunchpadEditFormView):
+class BaseCharmRecipeEditView(
+ CharmRecipeAuthorizeMixin, LaunchpadEditFormView):
schema = ICharmRecipeEditSchema
@@ -391,6 +430,16 @@ class BaseCharmRecipeEditView(LaunchpadEditFormView):
"git_ref",
"A public charm recipe cannot have a private repository.")
+ def _needCharmhubReauth(self, data):
+ """Does this change require reauthorizing to Charmhub?"""
+ store_upload = data.get("store_upload", False)
+ store_name = data.get("store_name")
+ if not store_upload or store_name is None:
+ return False
+ return (
+ not self.context.store_upload or
+ store_name != self.context.store_name)
+
@action("Update charm recipe", name="update")
def request_action(self, action, data):
if not data.get("auto_build", False):
@@ -402,8 +451,12 @@ class BaseCharmRecipeEditView(LaunchpadEditFormView):
del data["store_name"]
if "store_channels" in data:
del data["store_channels"]
+ need_charmhub_reauth = self._needCharmhubReauth(data)
self.updateContextFromData(data)
- self.next_url = canonical_url(self.context)
+ if need_charmhub_reauth:
+ self.requestAuthorization(self.context)
+ else:
+ self.next_url = canonical_url(self.context)
@property
def adapters(self):
@@ -466,6 +519,69 @@ class CharmRecipeEditView(BaseCharmRecipeEditView):
pass
+class CharmRecipeAuthorizeView(LaunchpadEditFormView):
+ """View for authorizing charm recipe uploads to Charmhub."""
+
+ @property
+ def label(self):
+ return "Authorize Charmhub uploads of %s" % self.context.name
+
+ page_title = "Authorize Charmhub uploads"
+
+ class schema(Interface):
+ """Schema for authorizing charm recipe uploads to Charmhub."""
+
+ discharge_macaroon = TextLine(
+ title="Serialized discharge macaroon", required=True)
+
+ render_context = False
+
+ focusedElementScript = None
+
+ @property
+ def cancel_url(self):
+ return canonical_url(self.context)
+
+ @classmethod
+ def requestAuthorization(cls, recipe, request):
+ """Begin the process of authorizing uploads of a charm recipe."""
+ try:
+ root_macaroon_raw = recipe.beginAuthorization()
+ except CannotAuthorizeCharmhubUploads as e:
+ request.response.addInfoNotification(str(e))
+ request.response.redirect(canonical_url(recipe))
+ else:
+ base_url = canonical_url(recipe, view_name="+authorize")
+ return request_candid_discharge(
+ request, root_macaroon_raw, base_url,
+ "field.discharge_macaroon",
+ discharge_macaroon_action="field.actions.complete")
+
+ @action("Begin authorization", name="begin")
+ def begin_action(self, action, data):
+ login_url = self.requestAuthorization(self.context, self.request)
+ if login_url is not None:
+ self.request.response.redirect(login_url)
+
+ @action("Complete authorization", name="complete")
+ def complete_action(self, action, data):
+ if not data.get("discharge_macaroon"):
+ self.addError(structured(
+ _("Uploads of %(recipe)s to Charmhub were not authorized."),
+ recipe=self.context.name))
+ return
+ self.context.completeAuthorization(data["discharge_macaroon"])
+ self.request.response.addInfoNotification(structured(
+ _("Uploads of %(recipe)s to Charmhub are now authorized."),
+ recipe=self.context.name))
+ self.request.response.redirect(canonical_url(self.context))
+
+ @property
+ def adapters(self):
+ """See `LaunchpadFormView`."""
+ return {self.schema: self.context}
+
+
class CharmRecipeDeleteView(BaseCharmRecipeEditView):
"""View for deleting charm recipes."""
diff --git a/lib/lp/charms/browser/configure.zcml b/lib/lp/charms/browser/configure.zcml
index 868316b..2577f78 100644
--- a/lib/lp/charms/browser/configure.zcml
+++ b/lib/lp/charms/browser/configure.zcml
@@ -42,6 +42,12 @@
template="../templates/charmrecipe-edit.pt" />
<browser:page
for="lp.charms.interfaces.charmrecipe.ICharmRecipe"
+ class="lp.charms.browser.charmrecipe.CharmRecipeAuthorizeView"
+ permission="launchpad.Edit"
+ name="+authorize"
+ template="../templates/charmrecipe-authorize.pt" />
+ <browser:page
+ for="lp.charms.interfaces.charmrecipe.ICharmRecipe"
class="lp.charms.browser.charmrecipe.CharmRecipeDeleteView"
permission="launchpad.Edit"
name="+delete"
diff --git a/lib/lp/charms/browser/tests/test_charmrecipe.py b/lib/lp/charms/browser/tests/test_charmrecipe.py
index 6dc4a2d..4e72f78 100644
--- a/lib/lp/charms/browser/tests/test_charmrecipe.py
+++ b/lib/lp/charms/browser/tests/test_charmrecipe.py
@@ -5,19 +5,31 @@
__metaclass__ = type
+import base64
from datetime import (
datetime,
timedelta,
)
+import json
import re
+from urllib.parse import (
+ parse_qs,
+ urlsplit,
+ )
from fixtures import FakeLogger
+from nacl.public import PrivateKey
+from pymacaroons import Macaroon
+from pymacaroons.serializers import JsonSerializer
import pytz
+import responses
import soupmatchers
from testtools.matchers import (
AfterPreprocessing,
+ ContainsDict,
Equals,
Is,
+ MatchesDict,
MatchesListwise,
MatchesStructure,
)
@@ -40,14 +52,17 @@ from lp.charms.browser.charmrecipe import (
from lp.charms.interfaces.charmrecipe import (
CHARM_RECIPE_ALLOW_CREATE,
CharmRecipeBuildRequestStatus,
+ ICharmRecipeSet,
)
from lp.registry.enums import PersonVisibility
+from lp.services.crypto.interfaces import IEncryptedContainer
from lp.services.database.constants import UTC_NOW
from lp.services.features.testing import FeatureFixture
from lp.services.job.interfaces.job import JobStatus
from lp.services.propertycache import get_property_cache
from lp.services.webapp import canonical_url
from lp.services.webapp.servers import LaunchpadTestRequest
+from lp.snappy.interfaces.snapstoreclient import ISnapStoreClient
from lp.testing import (
BrowserTestCase,
login,
@@ -57,6 +72,8 @@ from lp.testing import (
TestCaseWithFactory,
time_counter,
)
+from lp.testing.fakemethod import FakeMethod
+from lp.testing.fixture import ZopeUtilityFixture
from lp.testing.layers import (
DatabaseFunctionalLayer,
LaunchpadFunctionalLayer,
@@ -70,6 +87,7 @@ from lp.testing.pages import (
find_main_content,
find_tag_by_id,
find_tags_by_class,
+ get_feedback_messages,
)
from lp.testing.publication import test_traverse
from lp.testing.views import (
@@ -111,6 +129,13 @@ class BaseTestCharmRecipeView(BrowserTestCase):
super(BaseTestCharmRecipeView, self).setUp()
self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
self.useFixture(FakeLogger())
+ self.snap_store_client = FakeMethod()
+ self.snap_store_client.listChannels = FakeMethod(result=[
+ {"name": "stable", "display_name": "Stable"},
+ {"name": "edge", "display_name": "Edge"},
+ ])
+ self.useFixture(
+ ZopeUtilityFixture(self.snap_store_client, ISnapStoreClient))
self.person = self.factory.makePerson(
name="test-person", displayname="Test Person")
@@ -263,6 +288,115 @@ class TestCharmRecipeAddView(BaseTestCharmRecipeView):
"core20\nedge/feature\n",
MatchesTagText(content, "auto_build_channels"))
+ @responses.activate
+ def test_create_new_recipe_store_upload(self):
+ # Creating a new recipe and asking for it to be automatically
+ # uploaded to Charmhub sets all the appropriate fields and redirects
+ # to Candid for authorization.
+ self.pushConfig("charms", charmhub_url="http://charmhub.example/")
+ self.pushConfig(
+ "launchpad",
+ candid_service_root="https://candid.test/",
+ csrf_secret="test secret")
+ project = self.factory.makeProduct(
+ name="test-project", displayname="Test Project")
+ [git_ref] = self.factory.makeGitRefs()
+ view_url = canonical_url(git_ref, view_name="+new-charm-recipe")
+ browser = self.getNonRedirectingBrowser(url=view_url, user=self.person)
+ browser.getControl(name="field.name").value = "charm-name"
+ browser.getControl(name="field.project").value = "test-project"
+ browser.getControl("Automatically upload to store").selected = True
+ browser.getControl("Registered store name").value = "charmhub-name"
+ self.assertFalse(browser.getControl("Stable").selected)
+ browser.getControl(name="field.store_channels.track").value = "track"
+ browser.getControl("Edge").selected = True
+ root_macaroon = Macaroon(version=2)
+ root_macaroon.add_third_party_caveat(
+ "https://candid.test/", "", "identity")
+ caveat = root_macaroon.caveats[0]
+ root_macaroon_raw = root_macaroon.serialize(JsonSerializer())
+ responses.add(
+ "POST", "http://charmhub.example/v1/tokens",
+ json={"macaroon": root_macaroon_raw})
+ responses.add(
+ "POST", "https://candid.test/discharge", status=401,
+ json={
+ "Code": "interaction required",
+ "Message": (
+ "macaroon discharge required: authentication required"),
+ "Info": {
+ "InteractionMethods": {
+ "browser-redirect": {
+ "LoginURL": "https://candid.test/login-redirect",
+ },
+ },
+ },
+ })
+ browser.getControl("Create charm recipe").click()
+ login_person(self.person)
+ recipe = getUtility(ICharmRecipeSet).getByName(
+ self.person, project, "charm-name")
+ self.assertThat(recipe, MatchesStructure.byEquality(
+ owner=self.person, project=project, name="charm-name",
+ source=git_ref, store_upload=True, store_name="charmhub-name",
+ store_secrets={"root": root_macaroon_raw},
+ store_channels=["track/edge"]))
+ self.assertThat(responses.calls, MatchesListwise([
+ MatchesStructure(
+ request=MatchesStructure(
+ url=Equals("http://charmhub.example/v1/tokens"),
+ method=Equals("POST"),
+ body=AfterPreprocessing(
+ lambda b: json.loads(b.decode()),
+ Equals({
+ "description": "charmhub-name for launchpad.test",
+ "packages": [
+ {"type": "charm", "name": "charmhub-name"},
+ ],
+ "permissions": [
+ "package-manage-releases",
+ "package-manage-revisions",
+ ],
+ })))),
+ MatchesStructure(
+ request=MatchesStructure(
+ url=Equals("https://candid.test/discharge"),
+ headers=ContainsDict({
+ "Content-Type": Equals(
+ "application/x-www-form-urlencoded"),
+ }),
+ body=AfterPreprocessing(parse_qs, MatchesDict({
+ "id64": Equals(
+ [base64.b64encode(
+ caveat.caveat_id_bytes).decode()]),
+ })))),
+ ]))
+ self.assertEqual(303, int(browser.headers["Status"].split(" ", 1)[0]))
+ self.assertThat(
+ urlsplit(browser.headers["Location"]),
+ MatchesStructure(
+ scheme=Equals("https"),
+ netloc=Equals("candid.test"),
+ path=Equals("/login-redirect"),
+ query=AfterPreprocessing(parse_qs, ContainsDict({
+ "return_to": MatchesListwise([
+ AfterPreprocessing(urlsplit, MatchesStructure(
+ scheme=Equals("http"),
+ netloc=Equals("launchpad.test"),
+ path=Equals("/+candid-callback"),
+ query=AfterPreprocessing(parse_qs, MatchesDict({
+ "starting_url": Equals(
+ [canonical_url(recipe) + "/+authorize"]),
+ "discharge_macaroon_action": Equals(
+ ["field.actions.complete"]),
+ "discharge_macaroon_field": Equals(
+ ["field.discharge_macaroon"]),
+ })),
+ fragment=Equals(""))),
+ ]),
+ })),
+ fragment=Equals("")))
+
class TestCharmRecipeAdminView(BaseTestCharmRecipeView):
@@ -422,6 +556,209 @@ class TestCharmRecipeEditView(BaseTestCharmRecipeView):
extract_text(find_tags_by_class(browser.contents, "message")[1]))
+class TestCharmRecipeAuthorizeView(BaseTestCharmRecipeView):
+
+ def setUp(self):
+ super().setUp()
+ self.pushConfig("charms", charmhub_url="http://charmhub.example/")
+ self.pushConfig(
+ "launchpad",
+ candid_service_root="https://candid.test/",
+ csrf_secret="test secret")
+ self.recipe = self.factory.makeCharmRecipe(
+ registrant=self.person, owner=self.person, store_upload=True,
+ store_name=self.factory.getUniqueUnicode())
+
+ def test_unauthorized(self):
+ # A user without edit access cannot authorize charm recipe uploads.
+ other_person = self.factory.makePerson()
+ self.assertRaises(
+ Unauthorized, self.getUserBrowser,
+ canonical_url(self.recipe) + "/+authorize", user=other_person)
+
+ @responses.activate
+ def test_begin_authorization(self):
+ # With no special form actions, we return a form inviting the user
+ # to begin authorization. This allows (re-)authorizing uploads of
+ # an existing charm recipe without having to edit it.
+ recipe_url = canonical_url(self.recipe)
+ owner = self.recipe.owner
+ browser = self.getNonRedirectingBrowser(
+ url=recipe_url + "/+authorize", user=self.recipe.owner)
+ root_macaroon = Macaroon(version=2)
+ root_macaroon.add_third_party_caveat(
+ "https://candid.test/", "", "identity")
+ caveat = root_macaroon.caveats[0]
+ root_macaroon_raw = root_macaroon.serialize(JsonSerializer())
+ responses.add(
+ "POST", "http://charmhub.example/v1/tokens",
+ json={"macaroon": root_macaroon_raw})
+ responses.add(
+ "POST", "https://candid.test/discharge", status=401,
+ json={
+ "Code": "interaction required",
+ "Message": (
+ "macaroon discharge required: authentication required"),
+ "Info": {
+ "InteractionMethods": {
+ "browser-redirect": {
+ "LoginURL": "https://candid.test/login-redirect",
+ },
+ },
+ },
+ })
+ browser.getControl("Begin authorization").click()
+ with person_logged_in(owner):
+ self.assertThat(responses.calls, MatchesListwise([
+ MatchesStructure(
+ request=MatchesStructure(
+ url=Equals("http://charmhub.example/v1/tokens"),
+ method=Equals("POST"),
+ body=AfterPreprocessing(
+ lambda b: json.loads(b.decode()),
+ Equals({
+ "description": (
+ "{} for launchpad.test".format(
+ self.recipe.store_name)),
+ "packages": [
+ {"type": "charm",
+ "name": self.recipe.store_name},
+ ],
+ "permissions": [
+ "package-manage-releases",
+ "package-manage-revisions",
+ ],
+ })))),
+ MatchesStructure(
+ request=MatchesStructure(
+ url=Equals("https://candid.test/discharge"),
+ headers=ContainsDict({
+ "Content-Type": Equals(
+ "application/x-www-form-urlencoded"),
+ }),
+ body=AfterPreprocessing(parse_qs, MatchesDict({
+ "id64": Equals(
+ [base64.b64encode(
+ caveat.caveat_id_bytes).decode()]),
+ })))),
+ ]))
+ self.assertEqual(
+ {"root": root_macaroon_raw}, self.recipe.store_secrets)
+ self.assertEqual(303, int(browser.headers["Status"].split(" ", 1)[0]))
+ self.assertThat(
+ urlsplit(browser.headers["Location"]),
+ MatchesStructure(
+ scheme=Equals("https"),
+ netloc=Equals("candid.test"),
+ path=Equals("/login-redirect"),
+ query=AfterPreprocessing(parse_qs, ContainsDict({
+ "return_to": MatchesListwise([
+ AfterPreprocessing(urlsplit, MatchesStructure(
+ scheme=Equals("http"),
+ netloc=Equals("launchpad.test"),
+ path=Equals("/+candid-callback"),
+ query=AfterPreprocessing(parse_qs, MatchesDict({
+ "starting_url": Equals(
+ [recipe_url + "/+authorize"]),
+ "discharge_macaroon_action": Equals(
+ ["field.actions.complete"]),
+ "discharge_macaroon_field": Equals(
+ ["field.discharge_macaroon"]),
+ })),
+ fragment=Equals(""))),
+ ]),
+ })),
+ fragment=Equals("")))
+
+ def test_complete_authorization_missing_discharge_macaroon(self):
+ # If the form does not include a discharge macaroon, the "complete"
+ # action fails.
+ with person_logged_in(self.recipe.owner):
+ self.recipe.store_secrets = {
+ "root": Macaroon(version=2).serialize(JsonSerializer()),
+ }
+ transaction.commit()
+ form = {"field.actions.complete": "1"}
+ view = create_initialized_view(
+ self.recipe, "+authorize", form=form, method="POST",
+ principal=self.recipe.owner)
+ html = view()
+ self.assertEqual(
+ "Uploads of %s to Charmhub were not authorized." %
+ self.recipe.name,
+ get_feedback_messages(html)[1])
+ self.assertNotIn("exchanged_encrypted", self.recipe.store_secrets)
+
+ @responses.activate
+ def test_complete_authorization(self):
+ # If the form includes a discharge macaroon, the "complete" action
+ # exchanges the root and discharge pair with Charmhub for a single
+ # macaroon, then succeeds and records the new secrets.
+ private_key = PrivateKey.generate()
+ self.pushConfig(
+ "charms",
+ charmhub_secrets_public_key=base64.b64encode(
+ bytes(private_key.public_key)).decode())
+ root_macaroon = Macaroon(version=2)
+ root_macaroon_raw = root_macaroon.serialize(JsonSerializer())
+ unbound_discharge_macaroon = Macaroon(version=2)
+ unbound_discharge_macaroon_raw = unbound_discharge_macaroon.serialize(
+ JsonSerializer())
+ discharge_macaroon_raw = root_macaroon.prepare_for_request(
+ unbound_discharge_macaroon).serialize(JsonSerializer())
+ exchanged_macaroon = Macaroon(version=2)
+ exchanged_macaroon_raw = exchanged_macaroon.serialize(JsonSerializer())
+ responses.add(
+ "POST", "http://charmhub.example/v1/tokens/exchange",
+ json={"macaroon": exchanged_macaroon_raw})
+ with person_logged_in(self.recipe.owner):
+ self.recipe.store_secrets = {"root": root_macaroon_raw}
+ transaction.commit()
+ form = {
+ "field.actions.complete": "1",
+ "field.discharge_macaroon": unbound_discharge_macaroon_raw,
+ }
+ view = create_initialized_view(
+ self.recipe, "+authorize", form=form, method="POST",
+ principal=self.recipe.owner)
+ self.assertEqual(302, view.request.response.getStatus())
+ self.assertEqual(
+ canonical_url(self.recipe),
+ view.request.response.getHeader("Location"))
+ self.assertEqual(
+ "Uploads of %s to Charmhub are now authorized." %
+ self.recipe.name,
+ view.request.response.notifications[0].message)
+ self.pushConfig(
+ "charms",
+ charmhub_secrets_private_key=base64.b64encode(
+ bytes(private_key)).decode())
+ container = getUtility(IEncryptedContainer, "charmhub-secrets")
+ self.assertThat(self.recipe.store_secrets, MatchesDict({
+ "exchanged_encrypted": AfterPreprocessing(
+ lambda data: container.decrypt(data).decode(),
+ Equals(exchanged_macaroon_raw)),
+ }))
+ self.assertThat(responses.calls, MatchesListwise([
+ MatchesStructure(
+ request=MatchesStructure(
+ url=Equals("http://charmhub.example/v1/tokens/exchange"),
+ method=Equals("POST"),
+ headers=ContainsDict({
+ "Macaroons": AfterPreprocessing(
+ lambda v: json.loads(
+ base64.b64decode(v.encode()).decode()),
+ Equals([
+ json.loads(m) for m in (
+ root_macaroon_raw,
+ discharge_macaroon_raw)])),
+ }),
+ body=AfterPreprocessing(
+ lambda b: json.loads(b.decode()),
+ Equals({})))),
+ ]))
+
+
class TestCharmRecipeDeleteView(BaseTestCharmRecipeView):
def test_unauthorized(self):
diff --git a/lib/lp/charms/configure.zcml b/lib/lp/charms/configure.zcml
index 9cda4c5..397c2eb 100644
--- a/lib/lp/charms/configure.zcml
+++ b/lib/lp/charms/configure.zcml
@@ -48,6 +48,14 @@
interface="lp.charms.interfaces.charmrecipe.ICharmRecipeBuildRequest" />
</class>
+ <!-- CharmhubSecretsEncryptedContainer -->
+ <securedutility
+ class="lp.charms.model.charmrecipe.CharmhubSecretsEncryptedContainer"
+ provides="lp.services.crypto.interfaces.IEncryptedContainer"
+ name="charmhub-secrets">
+ <allow interface="lp.services.crypto.interfaces.IEncryptedContainer" />
+ </securedutility>
+
<!-- CharmRecipeBuild -->
<class class="lp.charms.model.charmrecipebuild.CharmRecipeBuild">
<require
@@ -86,6 +94,13 @@
factory="lp.charms.model.charmrecipebuildbehaviour.CharmRecipeBuildBehaviour"
permission="zope.Public" />
+ <!-- Charmhub interaction -->
+ <securedutility
+ class="lp.charms.model.charmhubclient.CharmhubClient"
+ provides="lp.charms.interfaces.charmhubclient.ICharmhubClient">
+ <allow interface="lp.charms.interfaces.charmhubclient.ICharmhubClient" />
+ </securedutility>
+
<!-- Charm-related jobs -->
<class class="lp.charms.model.charmrecipejob.CharmRecipeJob">
<allow interface="lp.charms.interfaces.charmrecipejob.ICharmRecipeJob" />
diff --git a/lib/lp/charms/interfaces/charmhubclient.py b/lib/lp/charms/interfaces/charmhubclient.py
new file mode 100644
index 0000000..8beb251
--- /dev/null
+++ b/lib/lp/charms/interfaces/charmhubclient.py
@@ -0,0 +1,63 @@
+# Copyright 2021 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Interface for communication with Charmhub."""
+
+__all__ = [
+ "BadExchangeMacaroonsResponse",
+ "BadRequestPackageUploadResponse",
+ "ICharmhubClient",
+ ]
+
+import http.client
+
+from lazr.restful.declarations import error_status
+from zope.interface import Interface
+
+
+class CharmhubError(Exception):
+
+ def __init__(self, message="", detail=None, can_retry=False):
+ super().__init__(message)
+ self.message = message
+ self.detail = detail
+ self.can_retry = can_retry
+
+
+@error_status(http.client.INTERNAL_SERVER_ERROR)
+class BadRequestPackageUploadResponse(CharmhubError):
+ pass
+
+
+class BadExchangeMacaroonsResponse(CharmhubError):
+ pass
+
+
+class ICharmhubClient(Interface):
+ """Interface for the API provided by Charmhub."""
+
+ def requestPackageUploadPermission(package_name):
+ """Request permission from Charmhub to upload builds of a charm.
+
+ We need the following permissions: `package-manage-revisions` (to
+ upload new blobs) and `package-manage-releases` (to release
+ revisions).
+
+ The returned macaroon will include a third-party caveat that must be
+ discharged by Candid. This method does not acquire that discharge;
+ it must be acquired separately.
+
+ :param package_name: The registered name of this charm on Charmhub.
+ :return: A serialized macaroon appropriate for uploading builds of
+ this charm.
+ """
+
+ def exchangeMacaroons(root_macaroon_raw, unbound_discharge_macaroon_raw):
+ """Exchange root+discharge macaroons for a new Charmhub-only macaroon.
+
+ :param root_macaroon: A serialized root macaroon from Charmhub.
+ :param unbound_discharge_macaroon: A corresponding serialized
+ unbound discharge macaroon from Candid.
+ :return: A serialized macaroon from Charmhub with no third-party
+ Candid caveat.
+ """
diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
index 0f55360..57d5ce8 100644
--- a/lib/lp/charms/interfaces/charmrecipe.py
+++ b/lib/lp/charms/interfaces/charmrecipe.py
@@ -7,6 +7,7 @@ __metaclass__ = type
__all__ = [
"BadCharmRecipeSource",
"BadCharmRecipeSearchContext",
+ "CannotAuthorizeCharmhubUploads",
"CannotFetchCharmcraftYaml",
"CannotParseCharmcraftYaml",
"CHARM_RECIPE_ALLOW_CREATE",
@@ -145,6 +146,11 @@ class BadCharmRecipeSearchContext(Exception):
"""The context is not valid for a charm recipe search."""
+@error_status(http_client.BAD_REQUEST)
+class CannotAuthorizeCharmhubUploads(Exception):
+ """Cannot authorize uploads of a charm to Charmhub."""
+
+
class MissingCharmcraftYaml(Exception):
"""The repository for this charm recipe does not have a charmcraft.yaml."""
@@ -372,6 +378,30 @@ class ICharmRecipeView(Interface):
class ICharmRecipeEdit(Interface):
"""`ICharmRecipe` methods that require launchpad.Edit permission."""
+ def beginAuthorization():
+ """Begin authorizing uploads of this charm recipe to Charmhub.
+
+ :raises CannotAuthorizeCharmhubUploads: if the charm recipe is not
+ properly configured for Charmhub uploads.
+ :raises BadRequestPackageUploadResponse: if Charmhub returns an
+ error or a response without a macaroon when asked to issue a
+ macaroon.
+ :raises BadCandidMacaroon: if the macaroon returned by Charmhub has
+ unsuitable Candid caveats.
+ :return: The serialized macaroon returned by the store. The caller
+ should acquire a discharge macaroon for this caveat from Candid
+ and then call `completeAuthorization`.
+ """
+
+ def completeAuthorization(unbound_discharge_macaroon_raw):
+ """Complete authorizing uploads of this charm recipe to Charmhub.
+
+ :param unbound_discharge_macaroon_raw: The serialized unbound
+ discharge macaroon returned by Candid.
+ :raises CannotAuthorizeCharmhubUploads: if the charm recipe is not
+ properly configured for Charmhub uploads.
+ """
+
def destroySelf():
"""Delete this charm recipe, provided that it has no builds."""
diff --git a/lib/lp/charms/model/charmhubclient.py b/lib/lp/charms/model/charmhubclient.py
new file mode 100644
index 0000000..be195d3
--- /dev/null
+++ b/lib/lp/charms/model/charmhubclient.py
@@ -0,0 +1,117 @@
+# Copyright 2021 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Communication with Charmhub."""
+
+__all__ = [
+ "CharmhubClient",
+ ]
+
+from base64 import b64encode
+
+from lazr.restful.utils import get_current_browser_request
+from pymacaroons import Macaroon
+from pymacaroons.serializers import JsonSerializer
+import requests
+from zope.interface import implementer
+
+from lp.charms.interfaces.charmhubclient import (
+ BadExchangeMacaroonsResponse,
+ BadRequestPackageUploadResponse,
+ ICharmhubClient,
+ )
+from lp.services.config import config
+from lp.services.timeline.requesttimeline import get_request_timeline
+from lp.services.timeout import urlfetch
+from lp.services.webapp.url import urlappend
+
+
+@implementer(ICharmhubClient)
+class CharmhubClient:
+ """A client for the API provided by Charmhub."""
+
+ @staticmethod
+ def _getTimeline():
+ # XXX cjwatson 2021-08-05: This can be simplified once jobs have
+ # timeline support.
+ request = get_current_browser_request()
+ if request is None:
+ return None
+ return get_request_timeline(request)
+
+ @classmethod
+ def _makeCharmhubError(cls, error_class, requests_error):
+ error_message = requests_error.args[0]
+ if requests_error.response.content:
+ try:
+ response_data = requests_error.response.json()
+ except ValueError:
+ pass
+ else:
+ if "error_list" in response_data:
+ error_message = "\n".join(
+ error["message"]
+ for error in response_data["error_list"])
+ detail = requests_error.response.content.decode(errors="replace")
+ can_retry = requests_error.response.status_code in (502, 503)
+ return error_class(error_message, detail=detail, can_retry=can_retry)
+
+ @classmethod
+ def requestPackageUploadPermission(cls, package_name):
+ """See `ICharmhubClient`."""
+ assert config.charms.charmhub_url is not None
+ request_url = urlappend(config.charms.charmhub_url, "v1/tokens")
+ request = get_current_browser_request()
+ timeline_action = get_request_timeline(request).start(
+ "request-charm-upload-macaroon", package_name, allow_nested=True)
+ try:
+ response = urlfetch(
+ request_url, method="POST",
+ json={
+ "description": "{} for {}".format(
+ package_name, config.vhost.mainsite.hostname),
+ "packages": [{"type": "charm", "name": package_name}],
+ "permissions": [
+ "package-manage-releases",
+ "package-manage-revisions",
+ ],
+ })
+ response_data = response.json()
+ if "macaroon" not in response_data:
+ raise BadRequestPackageUploadResponse(response.text)
+ return response_data["macaroon"]
+ except requests.HTTPError as e:
+ raise cls._makeCharmhubError(BadRequestPackageUploadResponse, e)
+ finally:
+ timeline_action.finish()
+
+ @classmethod
+ def exchangeMacaroons(cls, root_macaroon_raw,
+ unbound_discharge_macaroon_raw):
+ """See `ICharmhubClient`."""
+ assert config.charms.charmhub_url is not None
+ root_macaroon = Macaroon.deserialize(
+ root_macaroon_raw, JsonSerializer())
+ unbound_discharge_macaroon = Macaroon.deserialize(
+ unbound_discharge_macaroon_raw, JsonSerializer())
+ discharge_macaroon_raw = root_macaroon.prepare_for_request(
+ unbound_discharge_macaroon).serialize(JsonSerializer())
+ request_url = urlappend(
+ config.charms.charmhub_url, "v1/tokens/exchange")
+ request = get_current_browser_request()
+ timeline_action = get_request_timeline(request).start(
+ "exchange-macaroons", "", allow_nested=True)
+ try:
+ all_macaroons = b64encode("[{}, {}]".format(
+ root_macaroon_raw, discharge_macaroon_raw).encode()).decode()
+ response = urlfetch(
+ request_url, method="POST",
+ headers={"Macaroons": all_macaroons}, json={})
+ response_data = response.json()
+ if "macaroon" not in response_data:
+ raise BadExchangeMacaroonsResponse(response.text)
+ return response_data["macaroon"]
+ except requests.HTTPError as e:
+ raise cls._makeCharmhubError(BadExchangeMacaroonsResponse, e)
+ finally:
+ timeline_action.finish()
diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
index 1231749..a3ce3b3 100644
--- a/lib/lp/charms/model/charmrecipe.py
+++ b/lib/lp/charms/model/charmrecipe.py
@@ -9,12 +9,15 @@ __all__ = [
"get_charm_recipe_privacy_filter",
]
+import base64
from operator import (
attrgetter,
itemgetter,
)
from lazr.lifecycle.event import ObjectCreatedEvent
+from pymacaroons import Macaroon
+from pymacaroons.serializers import JsonSerializer
import pytz
from storm.databases.postgres import JSON
from storm.locals import (
@@ -49,8 +52,10 @@ from lp.buildmaster.model.builder import Builder
from lp.buildmaster.model.buildfarmjob import BuildFarmJob
from lp.buildmaster.model.buildqueue import BuildQueue
from lp.charms.adapters.buildarch import determine_instances_to_build
+from lp.charms.interfaces.charmhubclient import ICharmhubClient
from lp.charms.interfaces.charmrecipe import (
BadCharmRecipeSearchContext,
+ CannotAuthorizeCharmhubUploads,
CannotFetchCharmcraftYaml,
CannotParseCharmcraftYaml,
CHARM_RECIPE_ALLOW_CREATE,
@@ -102,6 +107,9 @@ from lp.registry.model.distribution import Distribution
from lp.registry.model.distroseries import DistroSeries
from lp.registry.model.product import Product
from lp.registry.model.series import ACTIVE_STATUSES
+from lp.services.config import config
+from lp.services.crypto.interfaces import IEncryptedContainer
+from lp.services.crypto.model import NaClEncryptedContainerBase
from lp.services.database.bulk import load_related
from lp.services.database.constants import (
DEFAULT,
@@ -126,6 +134,7 @@ from lp.services.propertycache import (
cachedproperty,
get_property_cache,
)
+from lp.services.webapp.candid import extract_candid_caveat
from lp.soyuz.model.distroarchseries import (
DistroArchSeries,
PocketChroot,
@@ -647,6 +656,42 @@ class CharmRecipe(StormBase):
order_by = Desc(CharmRecipeBuild.id)
return self._getBuilds(filter_term, order_by)
+ def beginAuthorization(self):
+ """See `ICharmRecipe`."""
+ if self.store_name is None:
+ raise CannotAuthorizeCharmhubUploads(
+ "Cannot authorize uploads of a charm recipe with no store "
+ "name.")
+ charmhub_client = getUtility(ICharmhubClient)
+ root_macaroon_raw = charmhub_client.requestPackageUploadPermission(
+ self.store_name)
+ # Check that the macaroon has exactly one Candid caveat.
+ extract_candid_caveat(
+ Macaroon.deserialize(root_macaroon_raw, JsonSerializer()))
+ self.store_secrets = {"root": root_macaroon_raw}
+ return root_macaroon_raw
+
+ def completeAuthorization(self, unbound_discharge_macaroon_raw):
+ """See `ICharmRecipe`."""
+ if self.store_secrets is None or "root" not in self.store_secrets:
+ raise CannotAuthorizeCharmhubUploads(
+ "beginAuthorization must be called before "
+ "completeAuthorization.")
+ try:
+ Macaroon.deserialize(
+ unbound_discharge_macaroon_raw, JsonSerializer())
+ except Exception:
+ raise CannotAuthorizeCharmhubUploads(
+ "discharge_macaroon_raw is invalid.")
+ charmhub_client = getUtility(ICharmhubClient)
+ exchanged_macaroon_raw = charmhub_client.exchangeMacaroons(
+ self.store_secrets["root"], unbound_discharge_macaroon_raw)
+ container = getUtility(IEncryptedContainer, "charmhub-secrets")
+ assert container.can_encrypt
+ self.store_secrets["exchanged_encrypted"] = removeSecurityProxy(
+ container.encrypt(exchanged_macaroon_raw.encode()))
+ self.store_secrets.pop("root", None)
+
def destroySelf(self):
"""See `ICharmRecipe`."""
store = IStore(self)
@@ -922,6 +967,26 @@ class CharmRecipeSet:
git_repository_id=None, git_path=None, date_last_modified=UTC_NOW)
+@implementer(IEncryptedContainer)
+class CharmhubSecretsEncryptedContainer(NaClEncryptedContainerBase):
+
+ @property
+ def public_key_bytes(self):
+ if config.charms.charmhub_secrets_public_key is not None:
+ return base64.b64decode(
+ config.charms.charmhub_secrets_public_key.encode())
+ else:
+ return None
+
+ @property
+ def private_key_bytes(self):
+ if config.charms.charmhub_secrets_private_key is not None:
+ return base64.b64decode(
+ config.charms.charmhub_secrets_private_key.encode())
+ else:
+ return None
+
+
def get_charm_recipe_privacy_filter(user):
"""Return a Storm query filter to find charm recipes visible to `user`."""
public_filter = CharmRecipe.information_type.is_in(
diff --git a/lib/lp/charms/templates/charmrecipe-authorize.pt b/lib/lp/charms/templates/charmrecipe-authorize.pt
new file mode 100644
index 0000000..41fb95c
--- /dev/null
+++ b/lib/lp/charms/templates/charmrecipe-authorize.pt
@@ -0,0 +1,24 @@
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ metal:use-macro="view/macro:page/main_only"
+ i18n:domain="launchpad">
+<body>
+
+<div metal:fill-slot="main">
+ <div metal:use-macro="context/@@launchpad_form/form">
+ <p metal:fill-slot="extra_info">
+ The login service will prompt you to authorize this request.
+ </p>
+ <metal:suppress-superfluous-widgets fill-slot="widgets" />
+ <div class="actions" metal:fill-slot="buttons">
+ <input tal:replace="structure view/begin_action/render" />
+ or <a tal:attributes="href view/cancel_url">Cancel</a>
+ </div>
+ </div>
+</div>
+
+</body>
+</html>
diff --git a/lib/lp/charms/tests/test_charmhubclient.py b/lib/lp/charms/tests/test_charmhubclient.py
new file mode 100644
index 0000000..b041055
--- /dev/null
+++ b/lib/lp/charms/tests/test_charmhubclient.py
@@ -0,0 +1,184 @@
+# Copyright 2021 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for communication with Charmhub."""
+
+import base64
+import json
+
+from lazr.restful.utils import get_current_browser_request
+from pymacaroons import Macaroon
+from pymacaroons.serializers import JsonSerializer
+import responses
+from testtools.matchers import (
+ AfterPreprocessing,
+ ContainsDict,
+ Equals,
+ MatchesAll,
+ MatchesStructure,
+ )
+from zope.component import getUtility
+
+from lp.charms.interfaces.charmhubclient import (
+ BadExchangeMacaroonsResponse,
+ BadRequestPackageUploadResponse,
+ ICharmhubClient,
+ )
+from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
+from lp.services.features.testing import FeatureFixture
+from lp.services.timeline.requesttimeline import get_request_timeline
+from lp.testing import TestCaseWithFactory
+from lp.testing.layers import ZopelessDatabaseLayer
+
+
+class RequestMatches(MatchesStructure):
+ """Matches a request with the specified attributes."""
+
+ def __init__(self, macaroons=None, json_data=None, **kwargs):
+ kwargs = dict(kwargs)
+ if macaroons is not None:
+ headers_matcher = ContainsDict({
+ "Macaroons": AfterPreprocessing(
+ lambda v: json.loads(
+ base64.b64decode(v.encode()).decode()),
+ Equals([json.loads(m) for m in macaroons])),
+ })
+ if kwargs.get("headers"):
+ headers_matcher = MatchesAll(
+ kwargs["headers"], headers_matcher)
+ kwargs["headers"] = headers_matcher
+ if json_data is not None:
+ body_matcher = AfterPreprocessing(
+ lambda b: json.loads(b.decode()), Equals(json_data))
+ if kwargs.get("body"):
+ body_matcher = MatchesAll(kwargs["body"], body_matcher)
+ kwargs["body"] = body_matcher
+ super().__init__(**kwargs)
+
+
+class TestCharmhubClient(TestCaseWithFactory):
+
+ layer = ZopelessDatabaseLayer
+
+ def setUp(self):
+ super().setUp()
+ self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+ self.pushConfig("charms", charmhub_url="http://charmhub.example/")
+ self.client = getUtility(ICharmhubClient)
+
+ @responses.activate
+ def test_requestPackageUploadPermission(self):
+ responses.add(
+ "POST", "http://charmhub.example/v1/tokens",
+ json={"macaroon": "sentinel"})
+ macaroon = self.client.requestPackageUploadPermission("test-charm")
+ self.assertThat(responses.calls[-1].request, RequestMatches(
+ url=Equals("http://charmhub.example/v1/tokens"),
+ method=Equals("POST"),
+ json_data={
+ "description": "test-charm for launchpad.test",
+ "packages": [{"type": "charm", "name": "test-charm"}],
+ "permissions": [
+ "package-manage-releases",
+ "package-manage-revisions",
+ ],
+ }))
+ self.assertEqual("sentinel", macaroon)
+ request = get_current_browser_request()
+ start, stop = get_request_timeline(request).actions[-2:]
+ self.assertThat(start, MatchesStructure.byEquality(
+ category="request-charm-upload-macaroon-start",
+ detail="test-charm"))
+ self.assertThat(stop, MatchesStructure.byEquality(
+ category="request-charm-upload-macaroon-stop",
+ detail="test-charm"))
+
+ @responses.activate
+ def test_requestPackageUploadPermission_missing_macaroon(self):
+ responses.add("POST", "http://charmhub.example/v1/tokens", json={})
+ self.assertRaisesWithContent(
+ BadRequestPackageUploadResponse, "{}",
+ self.client.requestPackageUploadPermission, "test-charm")
+
+ @responses.activate
+ def test_requestPackageUploadPermission_error(self):
+ responses.add(
+ "POST", "http://charmhub.example/v1/tokens",
+ status=503, json={"error_list": [{"message": "Failed"}]})
+ self.assertRaisesWithContent(
+ BadRequestPackageUploadResponse, "Failed",
+ self.client.requestPackageUploadPermission, "test-charm")
+
+ @responses.activate
+ def test_requestPackageUploadPermission_404(self):
+ responses.add("POST", "http://charmhub.example/v1/tokens", status=404)
+ self.assertRaisesWithContent(
+ BadRequestPackageUploadResponse,
+ "404 Client Error: Not Found",
+ self.client.requestPackageUploadPermission, "test-charm")
+
+ @responses.activate
+ def test_exchangeMacaroons(self):
+ responses.add(
+ "POST", "http://charmhub.example/v1/tokens/exchange",
+ json={"macaroon": "sentinel"})
+ root_macaroon = Macaroon(version=2)
+ root_macaroon_raw = root_macaroon.serialize(JsonSerializer())
+ unbound_discharge_macaroon = Macaroon(version=2)
+ unbound_discharge_macaroon_raw = unbound_discharge_macaroon.serialize(
+ JsonSerializer())
+ discharge_macaroon_raw = root_macaroon.prepare_for_request(
+ unbound_discharge_macaroon).serialize(JsonSerializer())
+ exchanged_macaroon_raw = self.client.exchangeMacaroons(
+ root_macaroon_raw, unbound_discharge_macaroon_raw)
+ self.assertThat(responses.calls[-1].request, RequestMatches(
+ url=Equals("http://charmhub.example/v1/tokens/exchange"),
+ method=Equals("POST"),
+ macaroons=[root_macaroon_raw, discharge_macaroon_raw],
+ json_data={}))
+ self.assertEqual("sentinel", exchanged_macaroon_raw)
+ request = get_current_browser_request()
+ start, stop = get_request_timeline(request).actions[-2:]
+ self.assertThat(start, MatchesStructure.byEquality(
+ category="exchange-macaroons-start", detail=""))
+ self.assertThat(stop, MatchesStructure.byEquality(
+ category="exchange-macaroons-stop", detail=""))
+
+ @responses.activate
+ def test_exchangeMacaroons_missing_macaroon(self):
+ responses.add(
+ "POST", "http://charmhub.example/v1/tokens/exchange", json={})
+ root_macaroon_raw = Macaroon(version=2).serialize(JsonSerializer())
+ discharge_macaroon_raw = Macaroon(version=2).serialize(
+ JsonSerializer())
+ self.assertRaisesWithContent(
+ BadExchangeMacaroonsResponse, "{}",
+ self.client.exchangeMacaroons,
+ root_macaroon_raw, discharge_macaroon_raw)
+
+ @responses.activate
+ def test_exchangeMacaroons_error(self):
+ responses.add(
+ "POST", "http://charmhub.example/v1/tokens/exchange",
+ status=401,
+ json={"error_list": [{"message": "Exchange window expired"}]})
+ root_macaroon_raw = Macaroon(version=2).serialize(JsonSerializer())
+ discharge_macaroon_raw = Macaroon(version=2).serialize(
+ JsonSerializer())
+ self.assertRaisesWithContent(
+ BadExchangeMacaroonsResponse, "Exchange window expired",
+ self.client.exchangeMacaroons,
+ root_macaroon_raw, discharge_macaroon_raw)
+
+ @responses.activate
+ def test_exchangeMacaroons_404(self):
+ responses.add(
+ "POST", "http://charmhub.example/v1/tokens/exchange", status=404)
+ root_macaroon_raw = Macaroon(version=2).serialize(JsonSerializer())
+ discharge_macaroon_raw = Macaroon(version=2).serialize(
+ JsonSerializer())
+ self.assertRaisesWithContent(
+ BadExchangeMacaroonsResponse,
+ "404 Client Error: Not Found",
+ self.client.exchangeMacaroons,
+ root_macaroon_raw, discharge_macaroon_raw)
diff --git a/lib/lp/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py
index 21b7a50..af961e7 100644
--- a/lib/lp/charms/tests/test_charmrecipe.py
+++ b/lib/lp/charms/tests/test_charmrecipe.py
@@ -5,18 +5,28 @@
__metaclass__ = type
+import base64
+import json
from textwrap import dedent
+from nacl.public import PrivateKey
+from pymacaroons import Macaroon
+from pymacaroons.serializers import JsonSerializer
+import responses
from storm.locals import Store
from testtools.matchers import (
+ AfterPreprocessing,
+ ContainsDict,
Equals,
Is,
MatchesDict,
+ MatchesListwise,
MatchesSetwise,
MatchesStructure,
)
import transaction
from zope.component import getUtility
+from zope.security.interfaces import Unauthorized
from zope.security.proxy import removeSecurityProxy
from lp.app.enums import InformationType
@@ -35,6 +45,7 @@ from lp.buildmaster.model.buildfarmjob import BuildFarmJob
from lp.buildmaster.model.buildqueue import BuildQueue
from lp.charms.interfaces.charmrecipe import (
BadCharmRecipeSearchContext,
+ CannotAuthorizeCharmhubUploads,
CHARM_RECIPE_ALLOW_CREATE,
CHARM_RECIPE_BUILD_DISTRIBUTION,
CharmRecipeBuildAlreadyPending,
@@ -59,6 +70,7 @@ from lp.code.errors import GitRepositoryBlobNotFound
from lp.code.tests.helpers import GitHostingFixture
from lp.registry.interfaces.series import SeriesStatus
from lp.services.config import config
+from lp.services.crypto.interfaces import IEncryptedContainer
from lp.services.database.constants import (
ONE_DAY_AGO,
UTC_NOW,
@@ -555,6 +567,149 @@ class TestCharmRecipe(TestCaseWithFactory):
getUtility(ICharmRecipeSet).exists(owner, project, "condemned"))
+class TestCharmRecipeAuthorization(TestCaseWithFactory):
+
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ super().setUp()
+ self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+ self.pushConfig("charms", charmhub_url="http://charmhub.example/")
+ self.pushConfig(
+ "launchpad", candid_service_root="https://candid.test/")
+
+ @responses.activate
+ def assertBeginsAuthorization(self, recipe, **kwargs):
+ root_macaroon = Macaroon(version=2)
+ root_macaroon.add_third_party_caveat(
+ "https://candid.test/", "", "identity")
+ root_macaroon_raw = root_macaroon.serialize(JsonSerializer())
+ responses.add(
+ "POST", "http://charmhub.example/v1/tokens",
+ json={"macaroon": root_macaroon_raw})
+ self.assertEqual(root_macaroon_raw, recipe.beginAuthorization())
+ self.assertThat(responses.calls, MatchesListwise([
+ MatchesStructure(
+ request=MatchesStructure(
+ url=Equals("http://charmhub.example/v1/tokens"),
+ method=Equals("POST"),
+ body=AfterPreprocessing(
+ lambda b: json.loads(b.decode()),
+ Equals({
+ "description": (
+ "{} for launchpad.test".format(
+ recipe.store_name)),
+ "packages": [
+ {"type": "charm", "name": recipe.store_name},
+ ],
+ "permissions": [
+ "package-manage-releases",
+ "package-manage-revisions",
+ ],
+ })))),
+ ]))
+ self.assertEqual({"root": root_macaroon_raw}, recipe.store_secrets)
+
+ def test_beginAuthorization(self):
+ recipe = self.factory.makeCharmRecipe(
+ store_upload=True, store_name=self.factory.getUniqueUnicode())
+ with person_logged_in(recipe.registrant):
+ self.assertBeginsAuthorization(recipe)
+
+ def test_beginAuthorization_unauthorized(self):
+ # A user without edit access cannot authorize charm recipe uploads.
+ recipe = self.factory.makeCharmRecipe(
+ store_upload=True, store_name=self.factory.getUniqueUnicode())
+ with person_logged_in(self.factory.makePerson()):
+ self.assertRaises(
+ Unauthorized, getattr, recipe, "beginAuthorization")
+
+ @responses.activate
+ def test_completeAuthorization(self):
+ private_key = PrivateKey.generate()
+ self.pushConfig(
+ "charms",
+ charmhub_secrets_public_key=base64.b64encode(
+ bytes(private_key.public_key)).decode())
+ root_macaroon = Macaroon(version=2)
+ root_macaroon_raw = root_macaroon.serialize(JsonSerializer())
+ unbound_discharge_macaroon = Macaroon(version=2)
+ unbound_discharge_macaroon_raw = unbound_discharge_macaroon.serialize(
+ JsonSerializer())
+ discharge_macaroon_raw = root_macaroon.prepare_for_request(
+ unbound_discharge_macaroon).serialize(JsonSerializer())
+ exchanged_macaroon = Macaroon(version=2)
+ exchanged_macaroon_raw = exchanged_macaroon.serialize(JsonSerializer())
+ responses.add(
+ "POST", "http://charmhub.example/v1/tokens/exchange",
+ json={"macaroon": exchanged_macaroon_raw})
+ recipe = self.factory.makeCharmRecipe(
+ store_upload=True, store_name=self.factory.getUniqueUnicode(),
+ store_secrets={"root": root_macaroon_raw})
+ with person_logged_in(recipe.registrant):
+ recipe.completeAuthorization(unbound_discharge_macaroon_raw)
+ self.pushConfig(
+ "charms",
+ charmhub_secrets_private_key=base64.b64encode(
+ bytes(private_key)).decode())
+ container = getUtility(IEncryptedContainer, "charmhub-secrets")
+ self.assertThat(recipe.store_secrets, MatchesDict({
+ "exchanged_encrypted": AfterPreprocessing(
+ lambda data: container.decrypt(data).decode(),
+ Equals(exchanged_macaroon_raw)),
+ }))
+ self.assertThat(responses.calls, MatchesListwise([
+ MatchesStructure(
+ request=MatchesStructure(
+ url=Equals("http://charmhub.example/v1/tokens/exchange"),
+ method=Equals("POST"),
+ headers=ContainsDict({
+ "Macaroons": AfterPreprocessing(
+ lambda v: json.loads(
+ base64.b64decode(v.encode()).decode()),
+ Equals([
+ json.loads(m) for m in (
+ root_macaroon_raw,
+ discharge_macaroon_raw)])),
+ }),
+ body=AfterPreprocessing(
+ lambda b: json.loads(b.decode()),
+ Equals({})))),
+ ]))
+
+ def test_completeAuthorization_without_beginAuthorization(self):
+ recipe = self.factory.makeCharmRecipe(
+ store_upload=True, store_name=self.factory.getUniqueUnicode())
+ discharge_macaroon = Macaroon(version=2)
+ with person_logged_in(recipe.registrant):
+ self.assertRaisesWithContent(
+ CannotAuthorizeCharmhubUploads,
+ "beginAuthorization must be called before "
+ "completeAuthorization.",
+ recipe.completeAuthorization,
+ discharge_macaroon.serialize(JsonSerializer()))
+
+ def test_completeAuthorization_unauthorized(self):
+ root_macaroon = Macaroon(version=2)
+ recipe = self.factory.makeCharmRecipe(
+ store_upload=True, store_name=self.factory.getUniqueUnicode(),
+ store_secrets={"root": root_macaroon.serialize(JsonSerializer())})
+ with person_logged_in(self.factory.makePerson()):
+ self.assertRaises(
+ Unauthorized, getattr, recipe, "completeAuthorization")
+
+ def test_completeAuthorization_malformed_discharge_macaroon(self):
+ root_macaroon = Macaroon(version=2)
+ recipe = self.factory.makeCharmRecipe(
+ store_upload=True, store_name=self.factory.getUniqueUnicode(),
+ store_secrets={"root": root_macaroon.serialize(JsonSerializer())})
+ with person_logged_in(recipe.registrant):
+ self.assertRaisesWithContent(
+ CannotAuthorizeCharmhubUploads,
+ "discharge_macaroon_raw is invalid.",
+ recipe.completeAuthorization, "nonsense")
+
+
class TestCharmRecipeDeleteWithBuilds(TestCaseWithFactory):
layer = LaunchpadFunctionalLayer
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index 719c002..e6242b9 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -150,6 +150,22 @@ admin_address: system-error@xxxxxxxxxxxxx
cron_control_url: file:cronscripts.ini
+[charms]
+# Charmhub's primary URL endpoint.
+# datatype: urlbase
+charmhub_url: none
+
+# Base64-encoded NaCl private key for decrypting Charmhub upload tokens.
+# This should only be set in secret overlays on systems that need to perform
+# Charmhub uploads on behalf of users.
+# datatype: string
+charmhub_secrets_private_key: none
+
+# Base64-encoded NaCl public key for encrypting Charmhub upload tokens.
+# datatype: string
+charmhub_secrets_public_key: none
+
+
[checkwatches]
# The database user to run this process as.
# datatype: string
Follow ups