diff --git a/requirements/main.in b/requirements/main.in index c18c1ba4af98..607ef6322c57 100644 --- a/requirements/main.in +++ b/requirements/main.in @@ -33,7 +33,7 @@ psycopg2 pycurl pyqrcode pyramid>=1.9,<1.11.0 -pymacaroons +pypitoken pyramid_jinja2>=2.5 pyramid_mailer>=0.14.1 pyramid_multiauth diff --git a/requirements/main.txt b/requirements/main.txt index aca20bbeefe2..ee59b16fc175 100644 --- a/requirements/main.txt +++ b/requirements/main.txt @@ -33,7 +33,9 @@ argon2-cffi==20.1.0 \ attrs==20.3.0 \ --hash=sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6 \ --hash=sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700 - # via automat + # via + # automat + # jsonschema automat==20.2.0 \ --hash=sha256:7979803c74610e11ef0c0d68a2942b152df52da55336e0c9d58daf1831cbdf33 \ --hash=sha256:b6feb6455337df834f6c9962d6ccf771515b7d939bca142b29c20c2376bc6111 @@ -454,6 +456,10 @@ jmespath==0.10.0 \ # via # boto3 # botocore +jsonschema==3.2.0 \ + --hash=sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163 \ + --hash=sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a + # via pypitoken kombu==4.6.11 \ --hash=sha256:be48cdffb54a2194d93ad6533d73f69408486483d189fe9f5990ee24255b0e0a \ --hash=sha256:ca1b45faac8c0b18493d02a8571792f3c40291cf2bcf1f55afed3d8f3aa7ba74 @@ -708,7 +714,7 @@ pygments==2.8.1 \ pymacaroons==0.13.0 \ --hash=sha256:1e6bba42a5f66c245adf38a5a4006a99dcc06a0703786ea636098667d42903b8 \ --hash=sha256:3e14dff6a262fdbf1a15e769ce635a8aea72e6f8f91e408f9a97166c53b91907 - # via -r requirements/main.in + # via pypitoken pynacl==1.4.0 \ --hash=sha256:06cbb4d9b2c4bd3c8dc0d267416aaed79906e7b33f114ddbf0911969794b1cc4 \ --hash=sha256:11335f09060af52c97137d4ac54285bcb7df0cef29014a1a4efe64ac065434c4 \ @@ -737,6 +743,10 @@ pyparsing==2.4.7 \ --hash=sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1 \ --hash=sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b # via packaging +pypitoken==3.0.2 \ + --hash=sha256:1e92cb2e56965ffc18223aeb96bd3501755921512e290db6d947a7fb65afb021 \ + --hash=sha256:d8c560978b1fc2132dc9d2c7454cc3acedb92c2af7eaed789292417fdf73a588 + # via -r requirements/main.in pyqrcode==1.2.1 \ --hash=sha256:1b2812775fa6ff5c527977c4cd2ccb07051ca7d0bc0aecf937a43864abe5eff6 \ --hash=sha256:fdbf7634733e56b72e27f9bce46e4550b75a3a2c420414035cae9d9d26b234d5 @@ -781,6 +791,9 @@ pyramid==1.10.5 \ # pyramid-rpc # pyramid-services # pyramid-tm +pyrsistent==0.17.3 \ + --hash=sha256:2e636185d9eb976a18a8a8e96efce62f2905fea90041958d8cc2a189756ebf3e + # via jsonschema python-dateutil==2.8.1 \ --hash=sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c \ --hash=sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a @@ -862,6 +875,7 @@ six==1.15.0 \ # google-resumable-media # grpcio # html5lib + # jsonschema # limits # protobuf # pymacaroons @@ -1080,6 +1094,7 @@ setuptools==54.1.2 \ # -r requirements/main.in # google-api-core # google-auth + # jsonschema # pastedeploy # plaster # pyramid diff --git a/tests/functional/manage/test_views.py b/tests/functional/manage/test_views.py index dec2e80d2ba1..18cea2579824 100644 --- a/tests/functional/manage/test_views.py +++ b/tests/functional/manage/test_views.py @@ -15,6 +15,8 @@ from webob.multidict import MultiDict from warehouse.accounts.interfaces import IPasswordBreachedService, IUserService +from warehouse.macaroons.interfaces import IMacaroonService +from warehouse.macaroons.services import database_macaroon_factory from warehouse.manage import views from ...common.db.accounts import EmailFactory, UserFactory @@ -27,6 +29,8 @@ def test_save_account(self, pyramid_services, user_service, db_request): pyramid_services.register_service( breach_service, IPasswordBreachedService, None ) + macaroon_service = database_macaroon_factory(context={}, request=db_request) + pyramid_services.register_service(macaroon_service, IMacaroonService, None) user = UserFactory.create(name="old name") EmailFactory.create(primary=True, verified=True, public=True, user=user) db_request.user = user diff --git a/tests/unit/macaroons/test_caveats.py b/tests/unit/macaroons/test_caveats.py deleted file mode 100644 index 50eedbb4c8c0..000000000000 --- a/tests/unit/macaroons/test_caveats.py +++ /dev/null @@ -1,125 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json - -import pretend -import pytest - -from pymacaroons.exceptions import MacaroonInvalidSignatureException - -from warehouse.macaroons.caveats import Caveat, InvalidMacaroon, V1Caveat, Verifier - -from ...common.db.packaging import ProjectFactory - - -class TestCaveat: - def test_creation(self): - verifier = pretend.stub() - caveat = Caveat(verifier) - - assert caveat.verifier is verifier - with pytest.raises(InvalidMacaroon): - caveat.verify(pretend.stub()) - with pytest.raises(InvalidMacaroon): - caveat(pretend.stub()) - - -class TestV1Caveat: - @pytest.mark.parametrize( - ["predicate", "result"], - [ - ("invalid json", False), - ('{"version": 2}', False), - ('{"permissions": null, "version": 1}', False), - ], - ) - def test_verify_invalid_predicates(self, predicate, result): - verifier = pretend.stub() - caveat = V1Caveat(verifier) - - with pytest.raises(InvalidMacaroon): - caveat(predicate) - - def test_verify_valid_predicate(self): - verifier = pretend.stub() - caveat = V1Caveat(verifier) - predicate = '{"permissions": "user", "version": 1}' - - assert caveat(predicate) is True - - def test_verify_project_invalid_context(self): - verifier = pretend.stub(context=pretend.stub()) - caveat = V1Caveat(verifier) - - predicate = {"version": 1, "permissions": {"projects": ["notfoobar"]}} - with pytest.raises(InvalidMacaroon): - caveat(json.dumps(predicate)) - - def test_verify_project_invalid_project_name(self, db_request): - project = ProjectFactory.create(name="foobar") - verifier = pretend.stub(context=project) - caveat = V1Caveat(verifier) - - predicate = {"version": 1, "permissions": {"projects": ["notfoobar"]}} - with pytest.raises(InvalidMacaroon): - caveat(json.dumps(predicate)) - - def test_verify_project_no_projects_object(self, db_request): - project = ProjectFactory.create(name="foobar") - verifier = pretend.stub(context=project) - caveat = V1Caveat(verifier) - - predicate = { - "version": 1, - "permissions": {"somethingthatisntprojects": ["blah"]}, - } - with pytest.raises(InvalidMacaroon): - caveat(json.dumps(predicate)) - - def test_verify_project(self, db_request): - project = ProjectFactory.create(name="foobar") - verifier = pretend.stub(context=project) - caveat = V1Caveat(verifier) - - predicate = {"version": 1, "permissions": {"projects": ["foobar"]}} - assert caveat(json.dumps(predicate)) is True - - -class TestVerifier: - def test_creation(self): - macaroon = pretend.stub() - context = pretend.stub() - principals = pretend.stub() - permission = pretend.stub() - verifier = Verifier(macaroon, context, principals, permission) - - assert verifier.macaroon is macaroon - assert verifier.context is context - assert verifier.principals is principals - assert verifier.permission is permission - - def test_verify(self, monkeypatch): - verify = pretend.call_recorder( - pretend.raiser(MacaroonInvalidSignatureException) - ) - macaroon = pretend.stub() - context = pretend.stub() - principals = pretend.stub() - permission = pretend.stub() - key = pretend.stub() - verifier = Verifier(macaroon, context, principals, permission) - - monkeypatch.setattr(verifier.verifier, "verify", verify) - with pytest.raises(InvalidMacaroon): - verifier.verify(key) - assert verify.calls == [pretend.call(macaroon, key)] diff --git a/tests/unit/macaroons/test_models.py b/tests/unit/macaroons/test_models.py deleted file mode 100644 index 17fd9c4c1cf9..000000000000 --- a/tests/unit/macaroons/test_models.py +++ /dev/null @@ -1,20 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from warehouse.macaroons import models - - -def test_generate_key(): - key = models._generate_key() - - assert isinstance(key, bytes) - assert len(key) == 32 diff --git a/tests/unit/macaroons/test_services.py b/tests/unit/macaroons/test_services.py index 0d4ea3891a3b..56d2d5c192f0 100644 --- a/tests/unit/macaroons/test_services.py +++ b/tests/unit/macaroons/test_services.py @@ -10,24 +10,25 @@ # See the License for the specific language governing permissions and # limitations under the License. -import binascii -import struct - -from unittest import mock from uuid import uuid4 import pretend -import pymacaroons +import pypitoken import pytest -from pymacaroons.exceptions import MacaroonDeserializationException - from warehouse.macaroons import services from warehouse.macaroons.models import Macaroon from ...common.db.accounts import UserFactory +def test_generate_key(): + key = services._generate_key() + + assert isinstance(key, bytes) + assert len(key) == 32 + + def test_database_macaroon_factory(): db = pretend.stub() request = pretend.stub(db=db) @@ -43,18 +44,6 @@ def test_creation(self): assert service.db is session - @pytest.mark.parametrize( - ["raw_macaroon", "result"], - [ - (None, None), - ("noprefixhere", None), - ("invalid:prefix", None), - ("pypi-validprefix", "validprefix"), - ], - ) - def test_extract_raw_macaroon(self, macaroon_service, raw_macaroon, result): - assert macaroon_service._extract_raw_macaroon(raw_macaroon) == result - def test_find_macaroon_invalid_macaroon(self, macaroon_service): assert macaroon_service.find_macaroon(str(uuid4())) is None @@ -84,29 +73,23 @@ def test_find_from_raw(self, user_service, macaroon_service): "raw_macaroon", [ "pypi-aaaa", # Invalid macaroon - # Macaroon properly formatted but not found. The string is purposedly cut to - # avoid triggering the github token disclosure feature that this very - # function implements. - "py" - "pi-AgEIcHlwaS5vcmcCJGQ0ZDhhNzA2LTUxYTEtNDg0NC1hNDlmLTEyZDRiYzNkYjZmOQAABi" - "D6hJOpYl9jFI4jBPvA8gvV1mSu1Ic3xMHmxA4CSA2w_g", + pypitoken.Token.create( + domain="example.com", + identifier=str(uuid4()), + key=b"fake key", + ).dump(), ], ) def test_find_from_raw_not_found_or_invalid(self, macaroon_service, raw_macaroon): with pytest.raises(services.InvalidMacaroon): macaroon_service.find_from_raw(raw_macaroon) - def test_find_userid_no_macaroon(self, macaroon_service): - assert macaroon_service.find_userid(None) is None - def test_find_userid_invalid_macaroon(self, macaroon_service): - raw_macaroon = pymacaroons.Macaroon( - location="fake location", + raw_macaroon = pypitoken.Token.create( + domain="example.com", identifier=str(uuid4()), key=b"fake key", - version=pymacaroons.MACAROON_V2, - ).serialize() - raw_macaroon = f"pypi-{raw_macaroon}" + ).dump() assert macaroon_service.find_userid(raw_macaroon) is None @@ -129,27 +112,18 @@ def test_find_userid(self, macaroon_service): assert user.id == user_id - def test_verify_unprefixed_macaroon(self, macaroon_service): - raw_macaroon = pymacaroons.Macaroon( - location="fake location", - identifier=str(uuid4()), - key=b"fake key", - version=pymacaroons.MACAROON_V2, - ).serialize() - + def test_verify_bad_macaroon(self, macaroon_service): with pytest.raises(services.InvalidMacaroon): macaroon_service.verify( - raw_macaroon, pretend.stub(), pretend.stub(), pretend.stub() + "foo", pretend.stub(), pretend.stub(), pretend.stub() ) def test_verify_no_macaroon(self, macaroon_service): - raw_macaroon = pymacaroons.Macaroon( - location="fake location", + raw_macaroon = pypitoken.Token.create( + domain="example.com", identifier=str(uuid4()), key=b"fake key", - version=pymacaroons.MACAROON_V2, - ).serialize() - raw_macaroon = f"pypi-{raw_macaroon}" + ).dump() with pytest.raises(services.InvalidMacaroon): macaroon_service.verify( @@ -158,59 +132,50 @@ def test_verify_no_macaroon(self, macaroon_service): def test_verify_invalid_macaroon(self, monkeypatch, user_service, macaroon_service): user = UserFactory.create() - raw_macaroon, _ = macaroon_service.create_macaroon( + raw_macaroon, dm = macaroon_service.create_macaroon( "fake location", user.id, "fake description", {"fake": "caveats"} ) - verifier_obj = pretend.stub(verify=pretend.call_recorder(lambda k: False)) - verifier_cls = pretend.call_recorder(lambda *a: verifier_obj) - monkeypatch.setattr(services, "Verifier", verifier_cls) + token_obj = pretend.stub( + check=pretend.call_recorder(pretend.raiser(pypitoken.ValidationError)), + identifier=str(dm.id), + prefix="pypi", + ) + token_cls = pretend.stub(load=lambda *a: token_obj) + + monkeypatch.setattr(pypitoken, "Token", token_cls) - context = pretend.stub() + context = pretend.stub(normalized_name="foo") principals = pretend.stub() permissions = pretend.stub() with pytest.raises(services.InvalidMacaroon): macaroon_service.verify(raw_macaroon, context, principals, permissions) - assert verifier_cls.calls == [ - pretend.call(mock.ANY, context, principals, permissions) - ] + assert token_obj.check.calls == [pretend.call(key=dm.key, project="foo")] - def test_deserialize_raw_macaroon_when_none(self, macaroon_service): - raw_macaroon = pretend.stub() - macaroon_service._extract_raw_macaroon = pretend.call_recorder(lambda a: None) + def test_deserialize_raw_macaroon(self, macaroon_service): + token = pypitoken.Token.create(domain="pypi.org", identifier="b", key="c") + serialized = token.dump() - with pytest.raises(services.InvalidMacaroon): - macaroon_service._deserialize_raw_macaroon(raw_macaroon) + result = macaroon_service._deserialize_raw_macaroon(serialized) - assert macaroon_service._extract_raw_macaroon.calls == [ - pretend.call(raw_macaroon), - ] + assert result.dump() == serialized - @pytest.mark.parametrize( - "exception", - [ - IndexError, - TypeError, - UnicodeDecodeError, - ValueError, - binascii.Error, - struct.error, - MacaroonDeserializationException, - Exception, # https://github.com/ecordell/pymacaroons/issues/50 - ], - ) - def test_deserialize_raw_macaroon(self, monkeypatch, macaroon_service, exception): - raw_macaroon = pretend.stub() - macaroon_service._extract_raw_macaroon = pretend.call_recorder( - lambda a: raw_macaroon - ) - monkeypatch.setattr( - pymacaroons.Macaroon, "deserialize", pretend.raiser(exception) + def test_deserialize_raw_macaroon_wrong_prefix(self, macaroon_service): + token = pypitoken.Token.create( + domain="pypi.org", identifier="b", key="c", prefix="wrong" ) + serialized = token.dump() + with pytest.raises(services.InvalidMacaroon): + macaroon_service._deserialize_raw_macaroon(serialized) + + def test_deserialize_raw_macaroon_wrong_format(self, macaroon_service): + with pytest.raises(services.InvalidMacaroon): + macaroon_service._deserialize_raw_macaroon("foo") + def test_deserialize_raw_macaroon_wrong_token(self, macaroon_service): with pytest.raises(services.InvalidMacaroon): - macaroon_service._deserialize_raw_macaroon(raw_macaroon) + macaroon_service._deserialize_raw_macaroon("pypi-foo") def test_verify_malformed_macaroon(self, macaroon_service): with pytest.raises(services.InvalidMacaroon): @@ -218,22 +183,24 @@ def test_verify_malformed_macaroon(self, macaroon_service): def test_verify_valid_macaroon(self, monkeypatch, macaroon_service): user = UserFactory.create() - raw_macaroon, _ = macaroon_service.create_macaroon( + raw_macaroon, dm = macaroon_service.create_macaroon( "fake location", user.id, "fake description", {"fake": "caveats"} ) - verifier_obj = pretend.stub(verify=pretend.call_recorder(lambda k: True)) - verifier_cls = pretend.call_recorder(lambda *a: verifier_obj) - monkeypatch.setattr(services, "Verifier", verifier_cls) + token_obj = pretend.stub( + check=pretend.call_recorder(lambda **a: None), + identifier=str(dm.id), + prefix="pypi", + ) + token_cls = pretend.stub(load=pretend.call_recorder(lambda *a: token_obj)) + monkeypatch.setattr(pypitoken, "Token", token_cls) - context = pretend.stub() + context = pretend.stub(normalized_name="foo") principals = pretend.stub() permissions = pretend.stub() assert macaroon_service.verify(raw_macaroon, context, principals, permissions) - assert verifier_cls.calls == [ - pretend.call(mock.ANY, context, principals, permissions) - ] + assert token_obj.check.calls == [pretend.call(key=dm.key, project="foo")] def test_delete_macaroon(self, user_service, macaroon_service): user = UserFactory.create() @@ -265,3 +232,18 @@ def test_get_macaroon_by_description(self, macaroon_service): macaroon_service.get_macaroon_by_description(user.id, macaroon.description) == dm ) + + @pytest.mark.parametrize( + "description", + [ + {}, + {"projects": ["baz", "yay"]}, + ], + ) + def test_describe_caveats(self, macaroon_service, description): + token = pypitoken.Token.create( + domain="example.com", identifier="foo", key="bar" + ) + token.restrict(**description) + caveat = token.restrictions[0].dump() + assert macaroon_service.describe_caveats(caveat) == description diff --git a/tests/unit/manage/test_forms.py b/tests/unit/manage/test_forms.py index 33c05d562030..3a60d58a1e86 100644 --- a/tests/unit/manage/test_forms.py +++ b/tests/unit/manage/test_forms.py @@ -431,6 +431,7 @@ def test_validate_token_scope_valid_user(self): ) assert form.validate() + assert form.validated_restrictions == {} def test_validate_token_scope_valid_project(self): form = forms.CreateMacaroonForm( @@ -441,6 +442,7 @@ def test_validate_token_scope_valid_project(self): ) assert form.validate() + assert form.validated_restrictions == {"projects": ["foo"]} class TestDeleteMacaroonForm: diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py index 3320b727b75d..9001fcf5dcb6 100644 --- a/tests/unit/manage/test_views.py +++ b/tests/unit/manage/test_views.py @@ -69,12 +69,15 @@ class TestManageAccount: def test_default_response(self, monkeypatch, public_email, expected_public_email): breach_service = pretend.stub() user_service = pretend.stub() + describe_caveats = pretend.stub() + macaroon_service = pretend.stub(describe_caveats=describe_caveats) name = pretend.stub() user_id = pretend.stub() request = pretend.stub( find_service=lambda iface, **kw: { IPasswordBreachedService: breach_service, IUserService: user_service, + IMacaroonService: macaroon_service, }[iface], user=pretend.stub(name=name, id=user_id, public_email=public_email), ) @@ -99,6 +102,7 @@ def test_default_response(self, monkeypatch, public_email, expected_public_email "add_email_form": add_email_obj, "change_password_form": change_pass_obj, "active_projects": view.active_projects, + "describe_caveats": describe_caveats, } assert view.request == request assert view.user_service == user_service @@ -1722,12 +1726,13 @@ def test_default_response(self, monkeypatch): monkeypatch.setattr( views.ProvisionMacaroonViews, "project_names", project_names ) - + describe_caveats = pretend.stub() + macaroon_service = pretend.stub(describe_caveats=describe_caveats) request = pretend.stub( user=pretend.stub(id=pretend.stub(), username=pretend.stub()), find_service=lambda interface, **kw: { - IMacaroonService: pretend.stub(), IUserService: pretend.stub(), + IMacaroonService: macaroon_service, }[interface], ) @@ -1737,6 +1742,7 @@ def test_default_response(self, monkeypatch): "project_names": project_names, "create_macaroon_form": create_macaroon_obj, "delete_macaroon_form": delete_macaroon_obj, + "describe_caveats": describe_caveats, } def test_project_names(self, db_request): @@ -1843,7 +1849,8 @@ def test_create_macaroon_invalid_form(self, monkeypatch): assert macaroon_service.create_macaroon.calls == [] def test_create_macaroon(self, monkeypatch): - macaroon = pretend.stub() + caveats = {"foo": "bar"} + macaroon = pretend.stub(caveats=caveats) macaroon_service = pretend.stub( create_macaroon=pretend.call_recorder( lambda *a, **kw: ("not a real raw macaroon", macaroon) @@ -1866,7 +1873,7 @@ def test_create_macaroon(self, monkeypatch): create_macaroon_obj = pretend.stub( validate=lambda: True, description=pretend.stub(data=pretend.stub()), - validated_scope="foobar", + validated_restrictions={}, ) create_macaroon_cls = pretend.call_recorder( lambda *a, **kw: create_macaroon_obj @@ -1888,13 +1895,10 @@ def test_create_macaroon(self, monkeypatch): assert macaroon_service.create_macaroon.calls == [ pretend.call( - location=request.domain, + domain=request.domain, user_id=request.user.id, description=create_macaroon_obj.description.data, - caveats={ - "permissions": create_macaroon_obj.validated_scope, - "version": 1, - }, + restrictions={}, ) ] assert result == { @@ -1910,16 +1914,14 @@ def test_create_macaroon(self, monkeypatch): ip_address=request.remote_addr, additional={ "description": create_macaroon_obj.description.data, - "caveats": { - "permissions": create_macaroon_obj.validated_scope, - "version": 1, - }, + "caveats": {"foo": "bar"}, }, ) ] def test_create_macaroon_records_events_for_each_project(self, monkeypatch): - macaroon = pretend.stub() + caveats = pretend.stub() + macaroon = pretend.stub(caveats=caveats) macaroon_service = pretend.stub( create_macaroon=pretend.call_recorder( lambda *a, **kw: ("not a real raw macaroon", macaroon) @@ -1949,7 +1951,7 @@ def test_create_macaroon_records_events_for_each_project(self, monkeypatch): create_macaroon_obj = pretend.stub( validate=lambda: True, description=pretend.stub(data=pretend.stub()), - validated_scope={"projects": ["foo", "bar"]}, + validated_restrictions={"projects": ["foo", "bar"]}, ) create_macaroon_cls = pretend.call_recorder( lambda *a, **kw: create_macaroon_obj @@ -1971,12 +1973,11 @@ def test_create_macaroon_records_events_for_each_project(self, monkeypatch): assert macaroon_service.create_macaroon.calls == [ pretend.call( - location=request.domain, + domain=request.domain, user_id=request.user.id, description=create_macaroon_obj.description.data, - caveats={ - "permissions": create_macaroon_obj.validated_scope, - "version": 1, + restrictions={ + "projects": ["foo", "bar"], }, ) ] @@ -1993,10 +1994,7 @@ def test_create_macaroon_records_events_for_each_project(self, monkeypatch): ip_address=request.remote_addr, additional={ "description": create_macaroon_obj.description.data, - "caveats": { - "permissions": create_macaroon_obj.validated_scope, - "version": 1, - }, + "caveats": caveats, }, ), pretend.call( @@ -2083,12 +2081,11 @@ def test_delete_macaroon_dangerous_redirect(self, monkeypatch): assert macaroon_service.delete_macaroon.calls == [] def test_delete_macaroon(self, monkeypatch): - macaroon = pretend.stub( - description="fake macaroon", caveats={"version": 1, "permissions": "user"} - ) + macaroon = pretend.stub(description="fake macaroon", caveats=pretend.stub()) macaroon_service = pretend.stub( delete_macaroon=pretend.call_recorder(lambda id: pretend.stub()), find_macaroon=pretend.call_recorder(lambda id: macaroon), + describe_caveats=pretend.call_recorder(lambda mac: {}), ) record_event = pretend.call_recorder( pretend.call_recorder(lambda *a, **kw: None) @@ -2128,6 +2125,9 @@ def test_delete_macaroon(self, monkeypatch): assert macaroon_service.find_macaroon.calls == [ pretend.call(delete_macaroon_obj.macaroon_id.data) ] + assert macaroon_service.describe_caveats.calls == [ + pretend.call(macaroon.caveats) + ] assert request.session.flash.calls == [ pretend.call("Deleted API token 'fake macaroon'.", queue="success") ] @@ -2143,11 +2143,14 @@ def test_delete_macaroon(self, monkeypatch): def test_delete_macaroon_records_events_for_each_project(self, monkeypatch): macaroon = pretend.stub( description="fake macaroon", - caveats={"version": 1, "permissions": {"projects": ["foo", "bar"]}}, + caveats=pretend.stub(), ) macaroon_service = pretend.stub( delete_macaroon=pretend.call_recorder(lambda id: pretend.stub()), find_macaroon=pretend.call_recorder(lambda id: macaroon), + describe_caveats=pretend.call_recorder( + lambda mac: {"projects": ["foo", "bar"]} + ), ) record_event = pretend.call_recorder( pretend.call_recorder(lambda *a, **kw: None) @@ -2194,6 +2197,9 @@ def test_delete_macaroon_records_events_for_each_project(self, monkeypatch): assert macaroon_service.find_macaroon.calls == [ pretend.call(delete_macaroon_obj.macaroon_id.data) ] + assert macaroon_service.describe_caveats.calls == [ + pretend.call(macaroon.caveats) + ] assert request.session.flash.calls == [ pretend.call("Deleted API token 'fake macaroon'.", queue="success") ] diff --git a/warehouse/integrations/github/utils.py b/warehouse/integrations/github/utils.py index d3c72a1f0a82..a6c01c26c354 100644 --- a/warehouse/integrations/github/utils.py +++ b/warehouse/integrations/github/utils.py @@ -27,7 +27,7 @@ from warehouse.accounts.interfaces import IUserService from warehouse.email import send_token_compromised_email_leak -from warehouse.macaroons.caveats import InvalidMacaroon +from warehouse.macaroons.services import InvalidMacaroon from warehouse.macaroons.interfaces import IMacaroonService from warehouse.metrics import IMetricsService diff --git a/warehouse/macaroons/caveats.py b/warehouse/macaroons/caveats.py deleted file mode 100644 index eadb085d9ab6..000000000000 --- a/warehouse/macaroons/caveats.py +++ /dev/null @@ -1,93 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json - -import pymacaroons - -from warehouse.packaging.models import Project - - -class InvalidMacaroon(Exception): - ... - - -class Caveat: - def __init__(self, verifier): - self.verifier = verifier - - def verify(self, predicate): - raise InvalidMacaroon - - def __call__(self, predicate): - return self.verify(predicate) - - -class V1Caveat(Caveat): - def verify_projects(self, projects): - # First, ensure that we're actually operating in - # the context of a package. - if not isinstance(self.verifier.context, Project): - raise InvalidMacaroon( - "project-scoped token used outside of a project context" - ) - - project = self.verifier.context - if project.normalized_name in projects: - return True - - raise InvalidMacaroon( - f"project-scoped token is not valid for project '{project.name}'" - ) - - def verify(self, predicate): - try: - data = json.loads(predicate) - except ValueError: - raise InvalidMacaroon("malformatted predicate") - - if data.get("version") != 1: - raise InvalidMacaroon("invalidate version in predicate") - - permissions = data.get("permissions") - if permissions is None: - raise InvalidMacaroon("invalid permissions in predicate") - - if permissions == "user": - # User-scoped tokens behave exactly like a user's normal credentials. - return True - - projects = permissions.get("projects") - if projects is None: - raise InvalidMacaroon("invalid projects in predicate") - - return self.verify_projects(projects) - - -class Verifier: - def __init__(self, macaroon, context, principals, permission): - self.macaroon = macaroon - self.context = context - self.principals = principals - self.permission = permission - self.verifier = pymacaroons.Verifier() - - def verify(self, key): - self.verifier.satisfy_general(V1Caveat(self)) - - try: - return self.verifier.verify(self.macaroon, key) - except ( - pymacaroons.exceptions.MacaroonInvalidSignatureException, - Exception, # https://github.com/ecordell/pymacaroons/issues/50 - ): - raise InvalidMacaroon("invalid macaroon signature") diff --git a/warehouse/macaroons/interfaces.py b/warehouse/macaroons/interfaces.py index ba2ddfa942e1..015d64a86d43 100644 --- a/warehouse/macaroons/interfaces.py +++ b/warehouse/macaroons/interfaces.py @@ -49,10 +49,16 @@ def verify(raw_macaroon, context, principals, permission): Raises InvalidMacaroon if the macaroon is not valid. """ - def create_macaroon(location, user_id, description, caveats): + def create_macaroon(domain, user_id, description, restrictions): """ - Returns a new raw (serialized) macaroon. The description provided - is not embedded into the macaroon, only stored in the DB model. + Returns a tuple with: + + - the raw (serialized) macaroon string, + - the database macaroon + + The description provided is not embedded into the macaroon, only stored in the + DB model. + Restrictions has the same format as in describe_caveats. """ def delete_macaroon(macaroon_id): @@ -67,3 +73,12 @@ def get_macaroon_by_description(user_id, description): Returns None if the user doesn't have a macaroon with this description. """ + + def describe_caveats(self, caveats): + """ + Given a value of macaroon caveats (like the one stored in DB), return a + description of the caveats, as a dict: + + - No key: no restriction (user-wide token) + - "projects" key: contains a list of normalized project names. + """ diff --git a/warehouse/macaroons/models.py b/warehouse/macaroons/models.py index 33373b7aa9e0..65473e7928bd 100644 --- a/warehouse/macaroons/models.py +++ b/warehouse/macaroons/models.py @@ -10,8 +10,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import os - from sqlalchemy import ( Column, DateTime, @@ -26,10 +24,6 @@ from warehouse import db -def _generate_key(): - return os.urandom(32) - - class Macaroon(db.Model): __tablename__ = "macaroons" @@ -55,11 +49,4 @@ class Macaroon(db.Model): # scope of their macaroon is. caveats = Column(JSONB, nullable=False, server_default=sql.text("'{}'")) - # It might be better to move this default into the database, that way we - # make it less likely that something does it incorrectly (since the - # default would be to generate a random key). However, it appears the - # PostgreSQL pgcrypto extension uses OpenSSL RAND_bytes if available - # instead of urandom. This is less than optimal, and we would generally - # prefer to just always use urandom. Thus we'll do this ourselves here - # in our application. - key = Column(LargeBinary, nullable=False, default=_generate_key) + key = Column(LargeBinary, nullable=False) diff --git a/warehouse/macaroons/services.py b/warehouse/macaroons/services.py index ae8ff708e885..f51a00b00cd4 100644 --- a/warehouse/macaroons/services.py +++ b/warehouse/macaroons/services.py @@ -11,43 +11,35 @@ # limitations under the License. import datetime -import json +import os import uuid -import pymacaroons +import pypitoken -from pymacaroons.exceptions import MacaroonDeserializationException from sqlalchemy.orm import joinedload from sqlalchemy.orm.exc import NoResultFound from zope.interface import implementer from warehouse.accounts.models import User -from warehouse.macaroons.caveats import InvalidMacaroon, Verifier from warehouse.macaroons.interfaces import IMacaroonService from warehouse.macaroons.models import Macaroon -@implementer(IMacaroonService) -class DatabaseMacaroonService: - def __init__(self, db_session): - self.db = db_session +def _generate_key(): + return os.urandom(32) - def _extract_raw_macaroon(self, prefixed_macaroon): - """ - Returns the base64-encoded macaroon component of a PyPI macaroon, - dropping the prefix. - Returns None if the macaroon is None, has no prefix, or has the - wrong prefix. - """ - if prefixed_macaroon is None: - return None +class InvalidMacaroon(Exception): + ... - prefix, _, raw_macaroon = prefixed_macaroon.partition("-") - if prefix != "pypi" or not raw_macaroon: - return None - return raw_macaroon +TOKEN_PREFIX = "pypi" + + +@implementer(IMacaroonService) +class DatabaseMacaroonService: + def __init__(self, db_session): + self.db = db_session def find_macaroon(self, macaroon_id): """ @@ -67,34 +59,27 @@ def find_macaroon(self, macaroon_id): return dm def _deserialize_raw_macaroon(self, raw_macaroon): - raw_macaroon = self._extract_raw_macaroon(raw_macaroon) - - if raw_macaroon is None: - raise InvalidMacaroon("malformed or nonexistent macaroon") - try: - return pymacaroons.Macaroon.deserialize(raw_macaroon) - except ( - MacaroonDeserializationException, - Exception, # https://github.com/ecordell/pymacaroons/issues/50 - ): - raise InvalidMacaroon("malformed macaroon") + token = pypitoken.Token.load(raw_macaroon) + except pypitoken.LoaderError as exc: + raise InvalidMacaroon(str(exc)) + if token.prefix != TOKEN_PREFIX: + raise InvalidMacaroon( + f"Token has wrong prefix: {token.prefix} (expected {TOKEN_PREFIX}" + ) + # We don't check the domain because it's checks as part of the signature check + return token def find_userid(self, raw_macaroon): """ Returns the id of the user associated with the given raw (serialized) - macaroon. + macaroon or None. """ try: - m = self._deserialize_raw_macaroon(raw_macaroon) + dm = self.find_from_raw(raw_macaroon) except InvalidMacaroon: return None - dm = self.find_macaroon(m.identifier.decode()) - - if dm is None: - return None - return dm.user.id def find_from_raw(self, raw_macaroon): @@ -102,7 +87,7 @@ def find_from_raw(self, raw_macaroon): Returns a DB macaroon matching the imput, or raises InvalidMacaroon """ m = self._deserialize_raw_macaroon(raw_macaroon) - dm = self.find_macaroon(m.identifier.decode()) + dm = self.find_macaroon(m.identifier) if not dm: raise InvalidMacaroon("Macaroon not found") return dm @@ -114,20 +99,23 @@ def verify(self, raw_macaroon, context, principals, permission): Raises InvalidMacaroon if the macaroon is not valid. """ - m = self._deserialize_raw_macaroon(raw_macaroon) - dm = self.find_macaroon(m.identifier.decode()) + token = self._deserialize_raw_macaroon(raw_macaroon) + dm = self.find_macaroon(token.identifier) if dm is None: raise InvalidMacaroon("deleted or nonexistent macaroon") - verifier = Verifier(m, context, principals, permission) - if verifier.verify(dm.key): - dm.last_used = datetime.datetime.now() - return True + project = context.normalized_name + + try: + token.check(key=dm.key, project=project) + except pypitoken.ValidationError as exc: + raise InvalidMacaroon(str(exc)) - raise InvalidMacaroon("invalid macaroon") + dm.last_used = datetime.datetime.now() + return True - def create_macaroon(self, location, user_id, description, caveats): + def create_macaroon(self, domain, user_id, description, restrictions): """ Returns a tuple of a new raw (serialized) macaroon and its DB model. The description provided is not embedded into the macaroon, only stored @@ -135,19 +123,42 @@ def create_macaroon(self, location, user_id, description, caveats): """ user = self.db.query(User).filter(User.id == user_id).one() - dm = Macaroon(user=user, description=description, caveats=caveats) + identifier = uuid.uuid4() + key = _generate_key() + + token = pypitoken.Token.create( + domain=domain, identifier=str(identifier), key=key, prefix="pypi" + ) + # even if projects is None, this will create a NoopRestriction. With + # the current implementation, we need to always have a restriction in place. + token.restrict( + # We're likely to copy restrictions into this function kwargs as-is, but + # it's good to avoid **restrictions here to maintain the abstraction layer. + # If something break because the pypitoken lib expects new arguments, we'd + # rather it fails here and be sure to see it in the tests. + projects=restrictions.get("projects", None), + ) + + dm = Macaroon( + id=identifier, + user=user, + key=key, + description=description, + caveats=token.restrictions[0].dump(), + ) self.db.add(dm) self.db.flush() - m = pymacaroons.Macaroon( - location=location, - identifier=str(dm.id), - key=dm.key, - version=pymacaroons.MACAROON_V2, - ) - m.add_first_party_caveat(json.dumps(caveats)) - serialized_macaroon = f"pypi-{m.serialize()}" - return serialized_macaroon, dm + return token.dump(), dm + + def describe_caveats(self, caveats): + description = {} + restriction = pypitoken.Restriction.load(caveats) + + if isinstance(restriction, pypitoken.ProjectsRestriction): + description["projects"] = restriction.projects + + return description def delete_macaroon(self, macaroon_id): """ diff --git a/warehouse/manage/forms.py b/warehouse/manage/forms.py index d788b3cfd413..470dabffe6ca 100644 --- a/warehouse/manage/forms.py +++ b/warehouse/manage/forms.py @@ -252,7 +252,7 @@ def validate_token_scope(self, field): raise wtforms.ValidationError("Specify the token scope") if scope_kind == "user": - self.validated_scope = scope_kind + self.validated_restrictions = {} return try: @@ -267,7 +267,7 @@ def validate_token_scope(self, field): f"Unknown or invalid project name: {scope_value}" ) - self.validated_scope = {"projects": [scope_value]} + self.validated_restrictions = {"projects": [scope_value]} class DeleteMacaroonForm(UsernameMixin, PasswordMixin, forms.Form): diff --git a/warehouse/manage/views.py b/warehouse/manage/views.py index babf55fb105f..f232a6d7d60b 100644 --- a/warehouse/manage/views.py +++ b/warehouse/manage/views.py @@ -131,6 +131,7 @@ def __init__(self, request): self.breach_service = request.find_service( IPasswordBreachedService, context=None ) + self.macaroon_service = request.find_service(IMacaroonService, context=None) @property def active_projects(self): @@ -152,6 +153,7 @@ def default_response(self): user_service=self.user_service, breach_service=self.breach_service ), "active_projects": self.active_projects, + "describe_caveats": self.macaroon_service.describe_caveats, } @view_config(request_method="GET") @@ -791,6 +793,7 @@ def default_response(self): user_service=self.user_service, macaroon_service=self.macaroon_service, ), + "describe_caveats": self.macaroon_service.describe_caveats, } @view_config(request_method="GET") @@ -814,12 +817,12 @@ def create_macaroon(self): response = {**self.default_response} if form.validate(): - macaroon_caveats = {"permissions": form.validated_scope, "version": 1} + restrictions = form.validated_restrictions serialized_macaroon, macaroon = self.macaroon_service.create_macaroon( - location=self.request.domain, + domain=self.request.domain, user_id=self.request.user.id, description=form.description.data, - caveats=macaroon_caveats, + restrictions=restrictions, ) self.user_service.record_event( self.request.user.id, @@ -827,14 +830,14 @@ def create_macaroon(self): ip_address=self.request.remote_addr, additional={ "description": form.description.data, - "caveats": macaroon_caveats, + "caveats": macaroon.caveats, }, ) - if "projects" in form.validated_scope: + if "projects" in restrictions: projects = [ project for project in self.request.user.projects - if project.normalized_name in form.validated_scope["projects"] + if project.normalized_name in restrictions["projects"] ] for project in projects: # NOTE: We don't disclose the full caveats for this token @@ -877,12 +880,12 @@ def delete_macaroon(self): ip_address=self.request.remote_addr, additional={"macaroon_id": form.macaroon_id.data}, ) - if "projects" in macaroon.caveats["permissions"]: + restrictions = self.macaroon_service.describe_caveats(macaroon.caveats) + if "projects" in restrictions: projects = [ project for project in self.request.user.projects - if project.normalized_name - in macaroon.caveats["permissions"]["projects"] + if project.normalized_name in restrictions["projects"] ] for project in projects: project.record_event( diff --git a/warehouse/templates/manage/account.html b/warehouse/templates/manage/account.html index e1f4afbad7d0..96070c8e4c18 100644 --- a/warehouse/templates/manage/account.html +++ b/warehouse/templates/manage/account.html @@ -158,6 +158,7 @@ {% endmacro %} {% macro api_row(macaroon) -%} + {% set restrictions = describe_caveats(macaroon.caveats) %}
{% trans %}Permissions:{% endtrans %} {% trans %}Upload packages{% endtrans %}
- {% if macaroon.caveats.permissions == "user" %}
- {% trans %}Scope:{% endtrans %} {% trans %}Entire account (all projects){% endtrans %}
+ {% trans %}Scope:{% endtrans %}
+ {% if not restrictions %}
+ {% trans %}Entire account (all projects){% endtrans %}
{% else %}
- {% trans %}Scope:{% endtrans %} {% trans project=macaroon.caveats.permissions.projects[0] %}Project "{{ project }}"{% endtrans %}
+ {% trans project=restrictions.projects[0] %}Project "{{ project }}"{% endtrans %}
{% endif %}
{{ prefix }}
prefix{% endtrans %}{% trans trimmed href='https://pypi.org/project/twine/', filename='$HOME/.pypirc' %}