diff --git a/.gitignore b/.gitignore index 35284547b7cb..7ca91205d3ec 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ node_modules/ dev/example.sql dev/prod.sql dev/prod.sql.xz +dev/notdatadog.py +dev/smtp.py +docs/conf.py ./data/ warehouse/.commit diff --git a/tests/unit/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py index 62cf9b13ff58..29e529957cfa 100644 --- a/tests/unit/legacy/api/test_json.py +++ b/tests/unit/legacy/api/test_json.py @@ -474,3 +474,160 @@ def test_normalizing_redirects(self, db_request): ) ] assert resp.headers["Location"] == "/project/the-redirect" + + +class TestCreateToken: + def test_create_token_unauthenticated(self): + request = pretend.stub( + response=pretend.stub(status_code=None), authenticated_userid=None + ) + + resp = json.create_token(request) + assert resp == {"success": False, "message": "invalid authentication token"} + assert request.response.status_code == 400 + + def test_create_token_bad_payload(self): + request = pretend.stub( + response=pretend.stub(status_code=None), + authenticated_userid="fake_id", + json_body="invalid json", + ) + + resp = json.create_token(request) + assert resp == {"success": False, "message": "invalid payload"} + assert request.response.status_code == 400 + + def test_create_token_payload_not_a_dict(self): + request = pretend.stub( + response=pretend.stub(status_code=None), + authenticated_userid="fake_id", + json_body=[], + ) + + resp = json.create_token(request) + assert resp == {"success": False, "message": "invalid payload"} + assert request.response.status_code == 400 + + def test_create_token_invalid_form(self, monkeypatch): + request = pretend.stub( + response=pretend.stub(status_code=None), + authenticated_userid="fake_id", + find_service=pretend.call_recorder(lambda *a, **kw: pretend.stub()), + user=pretend.stub(id=pretend.stub(), projects=pretend.stub()), + json_body={}, + ) + create_macaroon_obj = pretend.stub( + validate=pretend.call_recorder(lambda: False), + errors=pretend.stub(values=pretend.call_recorder(lambda: [["fake error"]])), + ) + create_macaroon_cls = pretend.call_recorder( + lambda *a, **kw: create_macaroon_obj + ) + monkeypatch.setattr(json, "CreateMacaroonForm", create_macaroon_cls) + + resp = json.create_token(request) + assert resp == {"success": False, "message": "fake error"} + assert request.response.status_code == 400 + + def test_create_token_user_scoped(self, monkeypatch): + macaroon_service = pretend.stub( + create_macaroon=pretend.call_recorder( + lambda *a, **kw: ("fake_token", pretend.stub()) + ) + ) + request = pretend.stub( + domain=pretend.stub(), + remote_addr=pretend.stub(), + response=pretend.stub(status_code=None), + authenticated_userid="fake_id", + find_service=pretend.call_recorder(lambda *a, **kw: macaroon_service), + user=pretend.stub( + id=pretend.stub(), + projects=pretend.stub(), + record_event=pretend.call_recorder(lambda *a, **kw: pretend.stub()), + ), + json_body={}, + ) + create_macaroon_obj = pretend.stub( + validate=pretend.call_recorder(lambda: True), + description=pretend.stub(data="fake description"), + validated_caveats={"permissions": "user"}, + ) + create_macaroon_cls = pretend.call_recorder( + lambda *a, **kw: create_macaroon_obj + ) + monkeypatch.setattr(json, "CreateMacaroonForm", create_macaroon_cls) + + resp = json.create_token(request) + assert resp == {"success": True, "token": "fake_token"} + assert request.user.record_event.calls == [ + pretend.call( + tag="account:api_token:added", + ip_address=request.remote_addr, + additional={ + "description": "fake description", + "caveats": {"permissions": "user"}, + }, + ) + ] + + def test_create_token_project_scoped(self, monkeypatch): + macaroon_service = pretend.stub( + create_macaroon=pretend.call_recorder( + lambda *a, **kw: ("fake_token", pretend.stub()) + ) + ) + request = pretend.stub( + domain=pretend.stub(), + remote_addr=pretend.stub(), + response=pretend.stub(status_code=None), + authenticated_userid="fake_id", + find_service=pretend.call_recorder(lambda *a, **kw: macaroon_service), + user=pretend.stub( + id=pretend.stub(), + username=pretend.stub(), + projects=[ + pretend.stub( + normalized_name="fakeproject", + record_event=pretend.call_recorder( + lambda *a, **kw: pretend.stub() + ), + ) + ], + record_event=pretend.call_recorder(lambda *a, **kw: pretend.stub()), + ), + json_body={}, + ) + create_macaroon_obj = pretend.stub( + validate=pretend.call_recorder(lambda: True), + description=pretend.stub(data="fake description"), + validated_caveats={"permissions": {"projects": [{"name": "fakeproject"}]}}, + ) + create_macaroon_cls = pretend.call_recorder( + lambda *a, **kw: create_macaroon_obj + ) + monkeypatch.setattr(json, "CreateMacaroonForm", create_macaroon_cls) + + resp = json.create_token(request) + assert resp == {"success": True, "token": "fake_token"} + assert request.user.record_event.calls == [ + pretend.call( + tag="account:api_token:added", + ip_address=request.remote_addr, + additional={ + "description": "fake description", + "caveats": {"permissions": {"projects": [{"name": "fakeproject"}]}}, + }, + ) + ] + for project in request.user.projects: + assert project.record_event.calls == [ + pretend.call( + tag="project:api_token:added", + ip_address=request.remote_addr, + additional={ + "description": "fake description", + "user": request.user.username, + }, + ) + ] diff --git a/tests/unit/macaroons/test_caveats.py b/tests/unit/macaroons/test_caveats.py index 50eedbb4c8c0..9b967f71cb7d 100644 --- a/tests/unit/macaroons/test_caveats.py +++ b/tests/unit/macaroons/test_caveats.py @@ -10,16 +10,23 @@ # See the License for the specific language governing permissions and # limitations under the License. -import json +from datetime import datetime, timezone import pretend import pytest from pymacaroons.exceptions import MacaroonInvalidSignatureException -from warehouse.macaroons.caveats import Caveat, InvalidMacaroon, V1Caveat, Verifier +from warehouse.macaroons.caveats import ( + Caveat, + InvalidMacaroon, + TopLevelCaveat, + V1Caveat, + V2Caveat, + Verifier, +) -from ...common.db.packaging import ProjectFactory +from ...common.db.packaging import ProjectFactory, ReleaseFactory class TestCaveat: @@ -34,45 +41,167 @@ def test_creation(self): caveat(pretend.stub()) -class TestV1Caveat: +class TestTopLevelCaveat: @pytest.mark.parametrize( - ["predicate", "result"], + ["predicate", "valid"], [ ("invalid json", False), + ("", False), + ("{}", False), + ('{"version": 1, "permissions": "user"}', True), + ('{"version": 1}', False), + ('{"version": 2, "permissions": "user"}', True), ('{"version": 2}', False), - ('{"permissions": null, "version": 1}', False), + ('{"version": 3}', False), ], ) - def test_verify_invalid_predicates(self, predicate, result): + def test_verify_toplevel_caveat(self, monkeypatch, predicate, valid): + verifier = pretend.stub() + caveat = TopLevelCaveat(verifier) + + if not valid: + with pytest.raises(InvalidMacaroon): + caveat(predicate) + else: + assert caveat(predicate) + + +class TestV1Caveat: + @pytest.mark.parametrize( + "predicate", [{}, {"permissions": None}, {"permissions": {"projects": None}}] + ) + def test_verify_invalid_v1_predicates(self, predicate): verifier = pretend.stub() caveat = V1Caveat(verifier) with pytest.raises(InvalidMacaroon): caveat(predicate) - def test_verify_valid_predicate(self): - verifier = pretend.stub() + @pytest.mark.parametrize( + "predicate", + [ + {"version": 1, "permissions": {"projects": ["foobar"]}}, + {"version": 1, "permissions": "user"}, + ], + ) + def test_verify_valid_v1_predicates(self, db_request, predicate): + project = ProjectFactory.create(name="foobar") + verifier = pretend.stub(context=project) caveat = V1Caveat(verifier) - predicate = '{"permissions": "user", "version": 1}' - assert caveat(predicate) is True + caveat(predicate) - def test_verify_project_invalid_context(self): + @pytest.mark.parametrize( + "predicate", + [ + {"version": 1, "permissions": {"projects": ["notfoobar"]}}, + {"version": 2, "permissions": {"projects": [{"name": "notfoobar"}]}}, + ], + ) + def test_verify_project_invalid_context(self, predicate): verifier = pretend.stub(context=pretend.stub()) - caveat = V1Caveat(verifier) - predicate = {"version": 1, "permissions": {"projects": ["notfoobar"]}} + if predicate["version"] == 1: + caveat = V1Caveat(verifier) + else: + caveat = V2Caveat(verifier) + with pytest.raises(InvalidMacaroon): - caveat(json.dumps(predicate)) + caveat(predicate) + + @pytest.mark.parametrize( + ["predicate", "valid"], + [ + ({"version": 2, "expiration": 0, "permissions": "user"}, False), + ( + { + "version": 2, + "expiration": int(datetime.now(tz=timezone.utc).timestamp()) + 3600, + "permissions": "user", + }, + True, + ), + ], + ) + def test_verify_v2_caveat_expiration(self, predicate, valid): + verifier = pretend.stub() + caveat = V2Caveat(verifier) + + if not valid: + with pytest.raises(InvalidMacaroon): + caveat(predicate) + else: + assert caveat(predicate) + + @pytest.mark.parametrize( + ["predicate", "valid"], + [ + ( + { + "version": 2, + "permissions": {"projects": [{"name": "foo", "version": "1.0.0"}]}, + }, + False, + ), + ( + { + "version": 2, + "permissions": {"projects": [{"name": "foo", "version": "1.0.1"}]}, + }, + True, + ), + ], + ) + def test_verify_v2_caveat_release(self, db_request, predicate, valid): + project = ProjectFactory.create(name="foo") + ReleaseFactory.create(project=project, version="1.0.0") - def test_verify_project_invalid_project_name(self, db_request): + verifier = pretend.stub(context=project) + caveat = V2Caveat(verifier) + + if not valid: + with pytest.raises(InvalidMacaroon): + caveat(predicate) + else: + assert caveat(predicate) + + @pytest.mark.parametrize( + ["predicate", "valid"], + [ + ({"version": 2, "permissions": {"projects": [{"name": "foo"}]}}, False), + ({"version": 2, "permissions": {}}, False), + ({"version": 2, "permissions": {"projects": [{"name": "bar"}]}}, True), + ], + ) + def test_verify_v2_caveat_project(self, db_request, predicate, valid): + project = ProjectFactory.create(name="bar") + verifier = pretend.stub(context=project) + caveat = V2Caveat(verifier) + + if not valid: + with pytest.raises(InvalidMacaroon): + caveat(predicate) + else: + assert caveat(predicate) + + @pytest.mark.parametrize( + "predicate", + [ + {"version": 1, "permissions": {"projects": ["notfoobar"]}}, + {"version": 2, "permissions": {"projects": [{"name": "notfoobar"}]}}, + ], + ) + def test_verify_project_invalid_project_name(self, db_request, predicate): project = ProjectFactory.create(name="foobar") verifier = pretend.stub(context=project) - caveat = V1Caveat(verifier) - predicate = {"version": 1, "permissions": {"projects": ["notfoobar"]}} + if predicate["version"] == 1: + caveat = V1Caveat(verifier) + else: + caveat = V2Caveat(verifier) + with pytest.raises(InvalidMacaroon): - caveat(json.dumps(predicate)) + caveat(predicate) def test_verify_project_no_projects_object(self, db_request): project = ProjectFactory.create(name="foobar") @@ -84,26 +213,51 @@ def test_verify_project_no_projects_object(self, db_request): "permissions": {"somethingthatisntprojects": ["blah"]}, } with pytest.raises(InvalidMacaroon): - caveat(json.dumps(predicate)) + caveat(predicate) - def test_verify_project(self, db_request): + @pytest.mark.parametrize( + "predicate", + [ + {"version": 1, "permissions": {"projects": ["foobar"]}}, + {"version": 2, "permissions": {"projects": [{"name": "foobar"}]}}, + ], + ) + def test_verify_project(self, db_request, predicate): project = ProjectFactory.create(name="foobar") + ReleaseFactory.create(project=project) verifier = pretend.stub(context=project) - caveat = V1Caveat(verifier) - predicate = {"version": 1, "permissions": {"projects": ["foobar"]}} - assert caveat(json.dumps(predicate)) is True + if predicate["version"] == 1: + caveat = V1Caveat(verifier) + else: + caveat = V2Caveat(verifier) + + assert caveat(predicate) is True + + +class TestV2Caveat: + def test_onetime_caveat(self): + predicate = {"version": 2, "permissions": "user", "onetime": True} + verifier = pretend.stub( + database_macaroon=pretend.stub(last_used=pretend.stub()) + ) + caveat = V2Caveat(verifier) + + with pytest.raises(InvalidMacaroon): + caveat(predicate) class TestVerifier: def test_creation(self): macaroon = pretend.stub() + db_macaroon = pretend.stub() context = pretend.stub() principals = pretend.stub() permission = pretend.stub() - verifier = Verifier(macaroon, context, principals, permission) + verifier = Verifier(macaroon, db_macaroon, context, principals, permission) assert verifier.macaroon is macaroon + assert verifier.database_macaroon is db_macaroon assert verifier.context is context assert verifier.principals is principals assert verifier.permission is permission @@ -113,13 +267,26 @@ def test_verify(self, monkeypatch): pretend.raiser(MacaroonInvalidSignatureException) ) macaroon = pretend.stub() + db_macaroon = pretend.stub() context = pretend.stub() principals = pretend.stub() permission = pretend.stub() key = pretend.stub() - verifier = Verifier(macaroon, context, principals, permission) + verifier = Verifier(macaroon, db_macaroon, context, principals, permission) monkeypatch.setattr(verifier.verifier, "verify", verify) with pytest.raises(InvalidMacaroon): verifier.verify(key) assert verify.calls == [pretend.call(macaroon, key)] + + def test_verify_updates_last_used(self, monkeypatch): + db_macaroon = pretend.stub(last_used=None) + verifier = Verifier( + pretend.stub(), db_macaroon, pretend.stub(), pretend.stub(), pretend.stub() + ) + + monkeypatch.setattr(verifier.verifier, "satisfy_general", lambda c: None) + monkeypatch.setattr(verifier.verifier, "verify", lambda *a: True) + + assert verifier.verify(pretend.stub()) + assert verifier.database_macaroon.last_used is not None diff --git a/tests/unit/macaroons/test_services.py b/tests/unit/macaroons/test_services.py index 8cdc9d775080..905d38dca0ef 100644 --- a/tests/unit/macaroons/test_services.py +++ b/tests/unit/macaroons/test_services.py @@ -119,7 +119,7 @@ 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, db_macaroon = macaroon_service.create_macaroon( "fake location", user.id, "fake description", {"fake": "caveats"} ) @@ -134,7 +134,7 @@ def test_verify_invalid_macaroon(self, monkeypatch, user_service, macaroon_servi with pytest.raises(services.InvalidMacaroon): macaroon_service.verify(raw_macaroon, context, principals, permissions) assert verifier_cls.calls == [ - pretend.call(mock.ANY, context, principals, permissions) + pretend.call(mock.ANY, db_macaroon, context, principals, permissions) ] def test_verify_malformed_macaroon(self, macaroon_service): @@ -143,7 +143,7 @@ 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, db_macaroon = macaroon_service.create_macaroon( "fake location", user.id, "fake description", {"fake": "caveats"} ) @@ -157,7 +157,7 @@ def test_verify_valid_macaroon(self, monkeypatch, macaroon_service): assert macaroon_service.verify(raw_macaroon, context, principals, permissions) assert verifier_cls.calls == [ - pretend.call(mock.ANY, context, principals, permissions) + pretend.call(mock.ANY, db_macaroon, context, principals, permissions) ] def test_delete_macaroon(self, user_service, macaroon_service): diff --git a/tests/unit/manage/test_forms.py b/tests/unit/manage/test_forms.py index 7b2c38dd5d68..d9da56be4390 100644 --- a/tests/unit/manage/test_forms.py +++ b/tests/unit/manage/test_forms.py @@ -10,6 +10,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from datetime import datetime, timedelta, timezone + import pretend import pytest import wtforms @@ -20,6 +22,9 @@ import warehouse.utils.webauthn as webauthn from warehouse.manage import forms +from warehouse.packaging.models import Project + +from ...common.db.packaging import ProjectFactory, ReleaseFactory class TestCreateRoleForm: @@ -340,23 +345,23 @@ class TestCreateMacaroonForm: def test_creation(self): user_id = pretend.stub() macaroon_service = pretend.stub() - project_names = pretend.stub() + all_projects = pretend.stub() form = forms.CreateMacaroonForm( user_id=user_id, macaroon_service=macaroon_service, - project_names=project_names, + all_projects=all_projects, ) assert form.user_id is user_id assert form.macaroon_service is macaroon_service - assert form.project_names is project_names + assert form.all_projects is all_projects def test_validate_description_missing(self): form = forms.CreateMacaroonForm( data={"token_scope": "scope:user"}, user_id=pretend.stub(), macaroon_service=pretend.stub(), - project_names=pretend.stub(), + all_projects=pretend.stub(), ) assert not form.validate() @@ -369,7 +374,7 @@ def test_validate_description_in_use(self): macaroon_service=pretend.stub( get_macaroon_by_description=lambda *a: pretend.stub() ), - project_names=pretend.stub(), + all_projects=pretend.stub(), ) assert not form.validate() @@ -380,7 +385,7 @@ def test_validate_token_scope_missing(self): data={"description": "dummy"}, user_id=pretend.stub(), macaroon_service=pretend.stub(get_macaroon_by_description=lambda *a: None), - project_names=pretend.stub(), + all_projects=pretend.stub(), ) assert not form.validate() @@ -391,7 +396,7 @@ def test_validate_token_scope_unspecified(self): data={"description": "dummy", "token_scope": "scope:unspecified"}, user_id=pretend.stub(), macaroon_service=pretend.stub(get_macaroon_by_description=lambda *a: None), - project_names=pretend.stub(), + all_projects=pretend.stub(), ) assert not form.validate() @@ -405,7 +410,7 @@ def test_validate_token_scope_invalid_format(self, scope): data={"description": "dummy", "token_scope": scope}, user_id=pretend.stub(), macaroon_service=pretend.stub(get_macaroon_by_description=lambda *a: None), - project_names=pretend.stub(), + all_projects=pretend.stub(), ) assert not form.validate() @@ -416,31 +421,122 @@ def test_validate_token_scope_invalid_project(self): data={"description": "dummy", "token_scope": "scope:project:foo"}, user_id=pretend.stub(), macaroon_service=pretend.stub(get_macaroon_by_description=lambda *a: None), - project_names=["bar"], + all_projects=[], ) assert not form.validate() - assert form.token_scope.errors.pop() == "Unknown or invalid project name: foo" + assert ( + form.token_scope.errors.pop() == "Unknown or invalid project name(s): foo" + ) - def test_validate_token_scope_valid_user(self): + def test_validate_token_scope_valid_project(self, db_request): + project = ProjectFactory(name="foo") form = forms.CreateMacaroonForm( - data={"description": "dummy", "token_scope": "scope:user"}, + data={"description": "dummy", "token_scope": "scope:project:foo"}, user_id=pretend.stub(), macaroon_service=pretend.stub(get_macaroon_by_description=lambda *a: None), - project_names=pretend.stub(), + all_projects=[project], ) + assert form.validate() + def test_validate_token_scope_valid_project_and_version(self, db_request): + project = ProjectFactory(name="foo") + ReleaseFactory.create(project=project, version="1.0.0") + form = forms.CreateMacaroonForm( + data={ + "description": "dummy", + "token_scope": "scope:project:foo", + "project_version": "1.0.1", + }, + user_id=pretend.stub(), + macaroon_service=pretend.stub(get_macaroon_by_description=lambda *a: None), + all_projects=[project], + ) assert form.validate() - def test_validate_token_scope_valid_project(self): + def test_validate_token_scope_not_in_projects(self, db_request): + project = ProjectFactory(name="foo") form = forms.CreateMacaroonForm( - data={"description": "dummy", "token_scope": "scope:project:foo"}, + data={"description": "dummy", "token_scope": "scope:project:foobar"}, + user_id=pretend.stub(), + macaroon_service=pretend.stub(get_macaroon_by_description=lambda *a: None), + all_projects=[project], + ) + assert not form.validate() + assert ( + form.token_scope.errors.pop() + == "Unknown or invalid project name(s): foobar" + ) + + def test_validate_token_scope_invalid_release(self): + form = forms.CreateMacaroonForm( + data={ + "description": "dummy", + "token_scope": "scope:project:foo", + "project_version": "AA.BB.CC", + }, + user_id=pretend.stub(), + macaroon_service=pretend.stub(get_macaroon_by_description=lambda *a: None), + all_projects=[], + ) + + assert not form.validate() + assert form.project_version.errors.pop() == "Invalid version format" + + def test_validate_token_scope_release_in_use(self, db_request): + project = Project(name="foo") + ReleaseFactory.create(project=project) + form = forms.CreateMacaroonForm( + data={ + "description": "dummy", + "token_scope": "scope:project:foo", + "project_version": project.latest_version[0], + }, + user_id=pretend.stub(), + macaroon_service=pretend.stub(get_macaroon_by_description=lambda *a: None), + all_projects=[project], + ) + + assert not form.validate() + assert form.token_scope.errors.pop() == "Release already exists" + + @pytest.mark.parametrize( + ["expiration", "valid"], + [ + ("invalid date", False), + ((datetime.now() - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M"), False), + ((datetime.now() + timedelta(days=366)).strftime("%Y-%m-%dT%H:%M"), False), + ((datetime.now() + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M"), True), + ], + ) + def test_validate_expiration(self, expiration, valid): + form = forms.CreateMacaroonForm( + data={ + "description": "dummy", + "token_scope": "scope:user", + "expiration": expiration, + }, + user_id=pretend.stub(), + macaroon_service=pretend.stub(get_macaroon_by_description=lambda *a: None), + all_projects=pretend.stub(), + ) + + assert form.validate() == valid + if valid: + expiration = datetime.strptime(expiration, "%Y-%m-%dT%H:%M") + expiration = expiration.astimezone(timezone.utc) + assert form.validated_caveats["expiration"] == int(expiration.timestamp()) + + def test_validate_assigns_onetime(self): + form = forms.CreateMacaroonForm( + data={"description": "dummy", "token_scope": "scope:user", "onetime": True}, user_id=pretend.stub(), macaroon_service=pretend.stub(get_macaroon_by_description=lambda *a: None), - project_names=["foo"], + all_projects=pretend.stub(), ) assert form.validate() + assert form.validated_caveats["onetime"] class TestDeleteMacaroonForm: diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py index 80c1b5392b13..fd34d80878b4 100644 --- a/tests/unit/manage/test_views.py +++ b/tests/unit/manage/test_views.py @@ -1482,10 +1482,8 @@ def test_default_response(self, monkeypatch): ) monkeypatch.setattr(views, "DeleteMacaroonForm", delete_macaroon_cls) - project_names = [pretend.stub()] - monkeypatch.setattr( - views.ProvisionMacaroonViews, "project_names", project_names - ) + all_projects = [pretend.stub()] + monkeypatch.setattr(views.ProvisionMacaroonViews, "all_projects", all_projects) request = pretend.stub( user=pretend.stub(id=pretend.stub(), username=pretend.stub()), @@ -1498,12 +1496,12 @@ def test_default_response(self, monkeypatch): view = views.ProvisionMacaroonViews(request) assert view.default_response == { - "project_names": project_names, + "all_projects": all_projects, "create_macaroon_form": create_macaroon_obj, "delete_macaroon_form": delete_macaroon_obj, } - def test_project_names(self, db_request): + def test_projects(self, db_request): user = UserFactory.create() another_user = UserFactory.create() @@ -1536,7 +1534,11 @@ def test_project_names(self, db_request): ) view = views.ProvisionMacaroonViews(db_request) - assert set(view.project_names) == {"foo", "bar", "baz"} + assert set(view.all_projects) == { + with_sole_owner, + with_multiple_owners, + not_an_owner, + } def test_manage_macaroons(self, monkeypatch): request = pretend.stub(find_service=lambda *a, **kw: pretend.stub()) @@ -1587,10 +1589,8 @@ def test_create_macaroon_invalid_form(self, monkeypatch): ) monkeypatch.setattr(views, "CreateMacaroonForm", create_macaroon_cls) - project_names = [pretend.stub()] - monkeypatch.setattr( - views.ProvisionMacaroonViews, "project_names", project_names - ) + all_projects = [pretend.stub()] + monkeypatch.setattr(views.ProvisionMacaroonViews, "all_projects", all_projects) default_response = {"default": "response"} monkeypatch.setattr( @@ -1630,17 +1630,15 @@ def test_create_macaroon(self, monkeypatch): create_macaroon_obj = pretend.stub( validate=lambda: True, description=pretend.stub(data=pretend.stub()), - validated_scope="foobar", + validated_caveats={"version": 2, "permissions": "user"}, ) create_macaroon_cls = pretend.call_recorder( lambda *a, **kw: create_macaroon_obj ) monkeypatch.setattr(views, "CreateMacaroonForm", create_macaroon_cls) - project_names = [pretend.stub()] - monkeypatch.setattr( - views.ProvisionMacaroonViews, "project_names", project_names - ) + all_projects = [pretend.stub()] + monkeypatch.setattr(views.ProvisionMacaroonViews, "all_projects", all_projects) default_response = {"default": "response"} monkeypatch.setattr( @@ -1655,10 +1653,7 @@ def test_create_macaroon(self, monkeypatch): location=request.domain, user_id=request.user.id, description=create_macaroon_obj.description.data, - caveats={ - "permissions": create_macaroon_obj.validated_scope, - "version": 1, - }, + caveats=create_macaroon_obj.validated_caveats, ) ] assert result == { @@ -1674,10 +1669,7 @@ 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": create_macaroon_obj.validated_caveats, }, ) ] @@ -1713,18 +1705,16 @@ 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_caveats={ + "version": 2, + "permissions": {"projects": [{"name": "foo"}, {"name": "bar"}]}, + }, ) create_macaroon_cls = pretend.call_recorder( lambda *a, **kw: create_macaroon_obj ) monkeypatch.setattr(views, "CreateMacaroonForm", create_macaroon_cls) - project_names = [pretend.stub()] - monkeypatch.setattr( - views.ProvisionMacaroonViews, "project_names", project_names - ) - default_response = {"default": "response"} monkeypatch.setattr( views.ProvisionMacaroonViews, "default_response", default_response @@ -1738,10 +1728,7 @@ def test_create_macaroon_records_events_for_each_project(self, monkeypatch): location=request.domain, user_id=request.user.id, description=create_macaroon_obj.description.data, - caveats={ - "permissions": create_macaroon_obj.validated_scope, - "version": 1, - }, + caveats=create_macaroon_obj.validated_caveats, ) ] assert result == { @@ -1757,10 +1744,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": create_macaroon_obj.validated_caveats, }, ), pretend.call( diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index fb46cabed5f2..c13ff9813192 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -324,6 +324,9 @@ def add_policy(name, filename): read_only=True, domain=warehouse, ), + pretend.call( + "legacy.api.json.token.new", "/pypi/create_token/", domain=warehouse + ), pretend.call("legacy.docs", docs_route_url), ] diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py index 8192efc451ed..e1475f81baa8 100644 --- a/warehouse/legacy/api/json.py +++ b/warehouse/legacy/api/json.py @@ -19,6 +19,8 @@ from warehouse.cache.http import cache_control from warehouse.cache.origin import origin_cache +from warehouse.macaroons.interfaces import IMacaroonService +from warehouse.manage.forms import CreateMacaroonForm from warehouse.packaging.models import File, Project, Release # Generate appropriate CORS headers for the JSON endpoint. @@ -205,3 +207,82 @@ def json_release_slash(release, request): ), headers=_CORS_HEADERS, ) + + +@view_config( + route_name="legacy.api.json.token.new", + renderer="json", + require_methods=["POST"], + uses_session=True, + permission="manage:user", + require_csrf=False, +) +def create_token(request): + def _fail(message): + request.response.status_code = 400 + return {"success": False, "message": message} + + if request.authenticated_userid is None: + return _fail("invalid authentication token") + + # Sanity-check our JSON payload. Ideally this would be done in a form, + # but WTForms isn't well equipped to handle JSON bodies. + try: + payload = request.json_body + if not isinstance(payload, dict): + raise ValueError + except Exception: + return _fail("invalid payload") + + macaroon_service = request.find_service(IMacaroonService, context=None) + + form = CreateMacaroonForm( + **payload, + user_id=request.user.id, + macaroon_service=macaroon_service, + all_projects=request.user.projects, + ) + if not form.validate(): + errors = "\n".join( + [str(error) for error_list in form.errors.values() for error in error_list] + ) + return _fail(errors) + + serialized_macaroon, _ = macaroon_service.create_macaroon( + location=request.domain, + user_id=request.user.id, + description=form.description.data, + caveats=form.validated_caveats, + ) + request.user.record_event( + tag="account:api_token:added", + ip_address=request.remote_addr, + additional={ + "description": form.description.data, + "caveats": form.validated_caveats, + }, + ) + + permissions = form.validated_caveats["permissions"] + if "projects" in permissions: + project_names = [project["name"] for project in permissions["projects"]] + projects = [ + project + for project in request.user.projects + if project.normalized_name in project_names + ] + for project in projects: + # NOTE: We don't disclose the full caveats for this token + # to the project event log, since the token could also + # have access to projects that this project's owner + # isn't aware of. + project.record_event( + tag="project:api_token:added", + ip_address=request.remote_addr, + additional={ + "description": form.description.data, + "user": request.user.username, + }, + ) + + return {"success": True, "token": serialized_macaroon} diff --git a/warehouse/macaroons/caveats.py b/warehouse/macaroons/caveats.py index fa024f78319b..0dc8a4e474fd 100644 --- a/warehouse/macaroons/caveats.py +++ b/warehouse/macaroons/caveats.py @@ -12,6 +12,8 @@ import json +from datetime import datetime, timezone + import pymacaroons from warehouse.packaging.models import Project @@ -48,15 +50,68 @@ def verify_projects(self, projects): raise InvalidMacaroon("project-scoped token matches no projects") def verify(self, predicate): - try: - data = json.loads(predicate) - except ValueError: - raise InvalidMacaroon("malformatted predicate") + permissions = predicate.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") + else: + self.verify_projects(projects) + + return True + + +class V2Caveat(Caveat): + def verify_expiration(self, expiration): + now = int(datetime.now(tz=timezone.utc).timestamp()) + if expiration < now: + raise InvalidMacaroon("token has expired") + + return True + + def verify_version(self, version): + project = self.verifier.context + + for extant_version in project.all_versions: + if version == extant_version[0]: + raise InvalidMacaroon("release already exists") + return True + + 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" + ) - if data.get("version") != 1: - raise InvalidMacaroon("invalidate version in predicate") + project = self.verifier.context + + for proj in projects: + if proj["name"] == project.normalized_name: + version = proj.get("version") + if version is not None: + self.verify_version(version) + return True + + raise InvalidMacaroon("project-scoped token matches no projects") + + def verify(self, predicate): + onetime = predicate.get("onetime", False) + if onetime and self.verifier.database_macaroon.last_used is not None: + raise InvalidMacaroon("token can't be reused") - permissions = data.get("permissions") + expiration = predicate.get("expiration") + if expiration is not None: + self.verify_expiration(expiration) + + permissions = predicate.get("permissions") if permissions is None: raise InvalidMacaroon("invalid permissions in predicate") @@ -67,22 +122,47 @@ def verify(self, predicate): projects = permissions.get("projects") if projects is None: raise InvalidMacaroon("invalid projects in predicate") + self.verify_projects(projects) + + return True - return self.verify_projects(projects) + +class TopLevelCaveat(Caveat): + def verify(self, predicate): + try: + data = json.loads(predicate) + except ValueError: + raise InvalidMacaroon("malformed predicate") + + version = data.get("version") + if version is None: + raise InvalidMacaroon("malformed version") + + if version == 1: + caveat_verifier = V1Caveat(self.verifier) + elif version == 2: + caveat_verifier = V2Caveat(self.verifier) + else: + raise InvalidMacaroon("invalid version") + + return caveat_verifier.verify(data) class Verifier: - def __init__(self, macaroon, context, principals, permission): + def __init__(self, macaroon, database_macaroon, context, principals, permission): self.macaroon = macaroon + self.database_macaroon = database_macaroon self.context = context self.principals = principals self.permission = permission self.verifier = pymacaroons.Verifier() def verify(self, key): - self.verifier.satisfy_general(V1Caveat(self)) + self.verifier.satisfy_general(TopLevelCaveat(self)) try: - return self.verifier.verify(self.macaroon, key) + self.verifier.verify(self.macaroon, key) + self.database_macaroon.last_used = datetime.now() + return True except pymacaroons.exceptions.MacaroonInvalidSignatureException: raise InvalidMacaroon("invalid macaroon signature") diff --git a/warehouse/macaroons/services.py b/warehouse/macaroons/services.py index 0fff949ade55..f24f9fbc5514 100644 --- a/warehouse/macaroons/services.py +++ b/warehouse/macaroons/services.py @@ -10,7 +10,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import datetime import json import uuid @@ -66,9 +65,9 @@ def find_macaroon(self, macaroon_id): return dm - def find_userid(self, raw_macaroon): + def find_user(self, raw_macaroon): """ - Returns the id of the user associated with the given raw (serialized) + Returns the user model associated with the given raw (serialized) macaroon. """ raw_macaroon = self._extract_raw_macaroon(raw_macaroon) @@ -85,7 +84,16 @@ def find_userid(self, raw_macaroon): if dm is None: return None - return dm.user.id + return dm.user + + def find_userid(self, raw_macaroon): + """ + Returns the id of the user associated with the given raw (serialized) + macaroon. + """ + user = self.find_user(raw_macaroon) + if user is not None: + return user.id def verify(self, raw_macaroon, context, principals, permission): """ @@ -108,9 +116,8 @@ def verify(self, raw_macaroon, context, principals, permission): if dm is None: raise InvalidMacaroon("deleted or nonexistent macaroon") - verifier = Verifier(m, context, principals, permission) + verifier = Verifier(m, dm, context, principals, permission) if verifier.verify(dm.key): - dm.last_used = datetime.datetime.now() return True raise InvalidMacaroon("invalid macaroon") diff --git a/warehouse/manage/forms.py b/warehouse/manage/forms.py index be196aebc3bc..7d00e91acd9e 100644 --- a/warehouse/manage/forms.py +++ b/warehouse/manage/forms.py @@ -12,6 +12,9 @@ import json +from datetime import datetime, timedelta, timezone + +import packaging import wtforms import warehouse.utils.otp as otp @@ -191,13 +194,7 @@ def validate_label(self, field): class CreateMacaroonForm(forms.Form): - __params__ = ["description", "token_scope"] - - def __init__(self, *args, user_id, macaroon_service, project_names, **kwargs): - super().__init__(*args, **kwargs) - self.user_id = user_id - self.macaroon_service = macaroon_service - self.project_names = project_names + __params__ = ["description", "token_scope", "expiration", "onetime"] description = wtforms.StringField( validators=[ @@ -208,10 +205,26 @@ def __init__(self, *args, user_id, macaroon_service, project_names, **kwargs): ] ) + project_version = wtforms.StringField() + token_scope = wtforms.StringField( validators=[wtforms.validators.DataRequired(message="Specify the token scope")] ) + expiration = wtforms.DateTimeField() + + onetime = wtforms.BooleanField() + + def __init__(self, *args, user_id, macaroon_service, all_projects, **kwargs): + super().__init__(*args, **kwargs) + self.user_id = user_id + self.macaroon_service = macaroon_service + self.all_projects = all_projects + self.validated_expiration = None + self.validated_project_version = None + self.validated_scope = None + self.validated_caveats = None + def validate_description(self, field): description = field.data @@ -221,6 +234,15 @@ def validate_description(self, field): ): raise wtforms.validators.ValidationError("API token name already in use") + def validate_project_version(self, field): + if not field.data: + return + + version = packaging.version.parse(field.data) + if not isinstance(version, packaging.version.Version): + raise wtforms.validators.ValidationError("Invalid version format") + self.validated_project_version = str(version) + def validate_token_scope(self, field): scope = field.data @@ -238,17 +260,62 @@ def validate_token_scope(self, field): try: scope_kind, scope_value = scope_kind.split(":", 1) + scope_values = scope_value.split(",") except ValueError: raise wtforms.ValidationError(f"Unknown token scope: {scope}") if scope_kind != "project": raise wtforms.ValidationError(f"Unknown token scope: {scope}") - if scope_value not in self.project_names: - raise wtforms.ValidationError( - f"Unknown or invalid project name: {scope_value}" + + self.validated_scope = {"projects": []} + for project in self.all_projects: + if project.normalized_name in scope_values: + project_scope = {"name": project.normalized_name} + all_versions = [version[0] for version in project.all_versions] + if self.validated_project_version: + if self.validated_project_version in all_versions: + raise wtforms.validators.ValidationError( + "Release already exists" + ) + project_scope["version"] = self.validated_project_version + self.validated_scope["projects"].append(project_scope) + return + + raise wtforms.ValidationError( + f"Unknown or invalid project name(s): {scope_value}" + ) + + def validate_expiration(self, field): + if not field.data: + return + + try: + expiration = datetime.strptime(field.data, "%Y-%m-%dT%H:%M") + except ValueError: + raise wtforms.validators.ValidationError("Malformatted date") + + expiration = expiration.astimezone(timezone.utc) + now = datetime.now(tz=timezone.utc) + if expiration > now + timedelta(days=365): + raise wtforms.validators.ValidationError( + "Expiration cannot be greater than one year" + ) + if expiration < now: + raise wtforms.validators.ValidationError( + "Expiration must be after the current time" ) - self.validated_scope = {"projects": [scope_value]} + self.validated_expiration = int(expiration.timestamp()) + + def validate(self): + res = super().validate() + + self.validated_caveats = {"version": 2, "permissions": self.validated_scope} + if self.validated_expiration is not None: + self.validated_caveats["expiration"] = self.validated_expiration + if self.onetime.data: + self.validated_caveats["onetime"] = True + return res class DeleteMacaroonForm(UsernameMixin, PasswordMixin, forms.Form): diff --git a/warehouse/manage/views.py b/warehouse/manage/views.py index c3ca7b868ff2..1a2259c54e10 100644 --- a/warehouse/manage/views.py +++ b/warehouse/manage/views.py @@ -632,17 +632,17 @@ def __init__(self, request): self.macaroon_service = request.find_service(IMacaroonService, context=None) @property - def project_names(self): - return sorted(project.normalized_name for project in self.request.user.projects) + def all_projects(self): + return [project for project in self.request.user.projects] @property def default_response(self): return { - "project_names": self.project_names, + "all_projects": self.all_projects, "create_macaroon_form": CreateMacaroonForm( user_id=self.request.user.id, macaroon_service=self.macaroon_service, - project_names=self.project_names, + all_projects=self.all_projects, ), "delete_macaroon_form": DeleteMacaroonForm( username=self.request.user.username, @@ -667,17 +667,16 @@ def create_macaroon(self): **self.request.POST, user_id=self.request.user.id, macaroon_service=self.macaroon_service, - project_names=self.project_names, + all_projects=self.all_projects, ) response = {**self.default_response} if form.validate(): - macaroon_caveats = {"permissions": form.validated_scope, "version": 1} serialized_macaroon, macaroon = self.macaroon_service.create_macaroon( location=self.request.domain, user_id=self.request.user.id, description=form.description.data, - caveats=macaroon_caveats, + caveats=form.validated_caveats, ) self.user_service.record_event( self.request.user.id, @@ -685,14 +684,17 @@ def create_macaroon(self): ip_address=self.request.remote_addr, additional={ "description": form.description.data, - "caveats": macaroon_caveats, + "caveats": form.validated_caveats, }, ) - if "projects" in form.validated_scope: + + permissions = form.validated_caveats["permissions"] + if "projects" in permissions: + project_names = [project["name"] for project in permissions["projects"]] projects = [ project for project in self.request.user.projects - if project.normalized_name in form.validated_scope["projects"] + if project.normalized_name in project_names ] for project in projects: # NOTE: We don't disclose the full caveats for this token diff --git a/warehouse/routes.py b/warehouse/routes.py index a6c144858fea..48c5e8fbdd98 100644 --- a/warehouse/routes.py +++ b/warehouse/routes.py @@ -321,6 +321,9 @@ def includeme(config): read_only=True, domain=warehouse, ) + config.add_route( + "legacy.api.json.token.new", "/pypi/create_token/", domain=warehouse + ) # Legacy Action URLs # TODO: We should probably add Warehouse routes for these that just error diff --git a/warehouse/static/js/warehouse/index.js b/warehouse/static/js/warehouse/index.js index 6305f2fcaca0..faf3fe7f655b 100644 --- a/warehouse/static/js/warehouse/index.js +++ b/warehouse/static/js/warehouse/index.js @@ -245,12 +245,13 @@ docReady(() => { tokenSelect.addEventListener("change", () => { const tokenScopeWarning = document.getElementById("api-token-scope-warning"); - if (tokenScopeWarning === null) { + const releaseHidden = document.getElementById("project-release"); + if (tokenScopeWarning === null || releaseHidden === null) { return; } - const tokenScope = tokenSelect.options[tokenSelect.selectedIndex].value; tokenScopeWarning.hidden = (tokenScope !== "scope:user"); + releaseHidden.hidden = !(tokenScope.startsWith("scope:project")); }); }); diff --git a/warehouse/templates/manage/account.html b/warehouse/templates/manage/account.html index d3340103f6ed..a41398f8c4fe 100644 --- a/warehouse/templates/manage/account.html +++ b/warehouse/templates/manage/account.html @@ -166,7 +166,8 @@ {% trans %}All projects{% endtrans %} {% else %} {% for project in macaroon.caveats.get("permissions")['projects'] %} - {{ project }} + {% set project_name = project["name"] if project is mapping else project %} + {{ project_name }} {% endfor %} {% endif %} diff --git a/warehouse/templates/manage/token.html b/warehouse/templates/manage/token.html index 6908b473c087..bdb452d9536d 100644 --- a/warehouse/templates/manage/token.html +++ b/warehouse/templates/manage/token.html @@ -34,7 +34,7 @@
{% trans %}What is this token for?{% endtrans %}
+{% trans %}Upload packages{% endtrans %}
+ + +