diff --git a/tests/common/db/accounts.py b/tests/common/db/accounts.py index a86f52a714ef..71be86399f5c 100644 --- a/tests/common/db/accounts.py +++ b/tests/common/db/accounts.py @@ -15,7 +15,7 @@ import factory import factory.fuzzy -from warehouse.accounts.models import Email, User +from warehouse.accounts.models import Email, User, UserEvent from .base import FuzzyEmail, WarehouseFactory @@ -36,6 +36,13 @@ class Meta: last_login = factory.fuzzy.FuzzyNaiveDateTime(datetime.datetime(2011, 1, 1)) +class UserEventFactory(WarehouseFactory): + class Meta: + model = UserEvent + + user = factory.SubFactory(User) + + class EmailFactory(WarehouseFactory): class Meta: model = Email diff --git a/tests/common/db/packaging.py b/tests/common/db/packaging.py index c09d4253dad3..2c55099d0242 100644 --- a/tests/common/db/packaging.py +++ b/tests/common/db/packaging.py @@ -26,6 +26,7 @@ File, JournalEntry, Project, + ProjectEvent, Release, Role, ) @@ -43,6 +44,13 @@ class Meta: name = factory.fuzzy.FuzzyText(length=12) +class ProjectEventFactory(WarehouseFactory): + class Meta: + model = ProjectEvent + + project = factory.SubFactory(ProjectFactory) + + class DescriptionFactory(WarehouseFactory): class Meta: model = Description diff --git a/tests/unit/accounts/test_models.py b/tests/unit/accounts/test_models.py index ee816c822a98..6214b6662810 100644 --- a/tests/unit/accounts/test_models.py +++ b/tests/unit/accounts/test_models.py @@ -10,12 +10,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime + import pytest from warehouse.accounts.models import Email, User, UserFactory from ...common.db.accounts import ( EmailFactory as DBEmailFactory, + UserEventFactory as DBUserEventFactory, UserFactory as DBUserFactory, ) @@ -83,3 +86,18 @@ def test_query_by_email_when_not_primary(self, db_session): result = db_session.query(User).filter(User.email == email.email).first() assert result is None + + def test_recent_events(self, db_session): + user = DBUserFactory.create() + recent_event = DBUserEventFactory(user=user, tag="foo", ip_address="0.0.0.0") + stale_event = DBUserEventFactory( + user=user, + tag="bar", + ip_address="0.0.0.0", + time=datetime.datetime.now() - datetime.timedelta(days=15), + ) + + assert len(user.events) == 2 + assert len(user.recent_events) == 1 + assert user.events == [recent_event, stale_event] + assert user.recent_events == [recent_event] diff --git a/tests/unit/accounts/test_views.py b/tests/unit/accounts/test_views.py index 482b92bbc13b..33503c820b8b 100644 --- a/tests/unit/accounts/test_views.py +++ b/tests/unit/accounts/test_views.py @@ -159,6 +159,7 @@ def test_post_validate_redirects( find_userid=pretend.call_recorder(lambda username: user_id), update_user=pretend.call_recorder(lambda *a, **kw: None), has_two_factor=lambda userid: False, + record_event=pretend.call_recorder(lambda *a, **kw: None), ) breach_service = pretend.stub(check_password=lambda password, tags=None: False) @@ -174,6 +175,7 @@ def test_post_validate_redirects( invalidate=pretend.call_recorder(lambda: None), new_csrf_token=pretend.call_recorder(lambda: None), ) + pyramid_request.remote_addr = "0.0.0.0" pyramid_request.set_property( lambda r: str(uuid.uuid4()) if with_user else None, @@ -214,6 +216,14 @@ def test_post_validate_redirects( assert user_service.find_userid.calls == [pretend.call("theuser")] assert user_service.update_user.calls == [pretend.call(user_id, last_login=now)] + assert user_service.record_event.calls == [ + pretend.call( + user_id, + tag="account:login:success", + ip_address="0.0.0.0", + additional={"two_factor_method": None}, + ) + ] if with_user: assert new_session == {} @@ -237,6 +247,7 @@ def test_post_validate_no_redirects( find_userid=pretend.call_recorder(lambda username: 1), update_user=lambda *a, **k: None, has_two_factor=lambda userid: False, + record_event=pretend.call_recorder(lambda *a, **kw: None), ) breach_service = pretend.stub(check_password=lambda password, tags=None: False) @@ -247,6 +258,7 @@ def test_post_validate_no_redirects( pyramid_request.method = "POST" pyramid_request.POST["next"] = expected_next_url + pyramid_request.remote_addr = "0.0.0.0" form_obj = pretend.stub( validate=pretend.call_recorder(lambda: True), @@ -259,6 +271,14 @@ def test_post_validate_no_redirects( assert isinstance(result, HTTPSeeOther) assert result.headers["Location"] == observed_next_url + assert user_service.record_event.calls == [ + pretend.call( + 1, + tag="account:login:success", + ip_address="0.0.0.0", + additional={"two_factor_method": None}, + ) + ] def test_redirect_authenticated_user(self): pyramid_request = pretend.stub(authenticated_userid=1) @@ -275,6 +295,7 @@ def test_two_factor_auth(self, pyramid_request, redirect_url, token_service): find_userid=pretend.call_recorder(lambda username: 1), update_user=lambda *a, **k: None, has_two_factor=lambda userid: True, + record_event=pretend.call_recorder(lambda *a, **kw: None), ) breach_service = pretend.stub(check_password=lambda pw: False) @@ -288,6 +309,7 @@ def test_two_factor_auth(self, pyramid_request, redirect_url, token_service): pyramid_request.method = "POST" if redirect_url: pyramid_request.POST["next"] = redirect_url + pyramid_request.remote_addr = "0.0.0.0" form_obj = pretend.stub( validate=pretend.call_recorder(lambda: True), @@ -312,6 +334,7 @@ def test_two_factor_auth(self, pyramid_request, redirect_url, token_service): ("Content-Length", "0"), ("Location", "/account/two-factor"), ] + assert user_service.record_event.calls == [] class TestTwoFactor: @@ -406,6 +429,7 @@ def test_totp_auth(self, monkeypatch, pyramid_request, redirect_url): has_totp=lambda userid: True, has_webauthn=lambda userid: False, check_totp_value=lambda userid, totp_value: True, + record_event=pretend.call_recorder(lambda *a, **kw: None), ) new_session = {} @@ -416,6 +440,7 @@ def test_totp_auth(self, monkeypatch, pyramid_request, redirect_url): }[interface] pyramid_request.method = "POST" + pyramid_request.remote_addr = "0.0.0.0" pyramid_request.session = pretend.stub( items=lambda: [("a", "b"), ("foo", "bar")], update=new_session.update, @@ -451,52 +476,14 @@ def test_totp_auth(self, monkeypatch, pyramid_request, redirect_url): assert remember.calls == [pretend.call(pyramid_request, str(1))] assert pyramid_request.session.invalidate.calls == [pretend.call()] assert pyramid_request.session.new_csrf_token.calls == [pretend.call()] - - @pytest.mark.parametrize("redirect_url", ["test_redirect_url", None]) - def test_totp_auth_invalid(self, pyramid_request, redirect_url): - query_params = {"userid": str(1)} - if redirect_url: - query_params["redirect_to"] = redirect_url - - token_service = pretend.stub( - loads=pretend.call_recorder(lambda s: query_params) - ) - - user_service = pretend.stub( - find_userid=pretend.call_recorder(lambda username: 1), - update_user=lambda *a, **k: None, - has_totp=lambda userid: True, - has_webauthn=lambda userid: False, - check_totp_value=lambda userid, totp_value: False, - ) - - pyramid_request.find_service = lambda interface, **kwargs: { - ITokenService: token_service, - IUserService: user_service, - }[interface] - - pyramid_request.method = "POST" - - form_obj = pretend.stub( - validate=pretend.call_recorder(lambda: True), - totp_value=pretend.stub(data="test-otp-secret"), - ) - form_class = pretend.call_recorder(lambda d, user_service, **kw: form_obj) - pyramid_request.route_path = pretend.call_recorder( - lambda a: "/account/two-factor" - ) - pyramid_request.params = pretend.stub( - get=pretend.call_recorder(lambda k: query_params.get(k)) - ) - result = views.two_factor_and_totp_validate( - pyramid_request, _form_class=form_class - ) - - token_expected_data = {"userid": str(1)} - if redirect_url: - token_expected_data["redirect_to"] = redirect_url - - assert isinstance(result, HTTPSeeOther) + assert user_service.record_event.calls == [ + pretend.call( + "1", + tag="account:login:success", + ip_address="0.0.0.0", + additional={"two_factor_method": "totp"}, + ) + ] def test_totp_auth_already_authed(self): request = pretend.stub( @@ -698,7 +685,7 @@ def test_webauthn_validate(self, monkeypatch): ) monkeypatch.setattr(views, "_get_two_factor_data", _get_two_factor_data) - _login_user = pretend.call_recorder(lambda req, uid: pretend.stub()) + _login_user = pretend.call_recorder(lambda *a, **kw: pretend.stub()) monkeypatch.setattr(views, "_login_user", _login_user) user = pretend.stub(webauthn=pretend.stub(sign_count=pretend.stub())) @@ -738,7 +725,9 @@ def test_webauthn_validate(self, monkeypatch): result = views.webauthn_authentication_validate(request) assert _get_two_factor_data.calls == [pretend.call(request)] - assert _login_user.calls == [pretend.call(request, 1)] + assert _login_user.calls == [ + pretend.call(request, 1, two_factor_method="webauthn") + ] assert request.session.get_webauthn_challenge.calls == [pretend.call()] assert request.session.clear_webauthn_challenge.calls == [pretend.call()] @@ -858,6 +847,7 @@ def test_register_redirect(self, db_request, monkeypatch): email = pretend.stub() create_user = pretend.call_recorder(lambda *args, **kwargs: user) add_email = pretend.call_recorder(lambda *args, **kwargs: email) + record_event = pretend.call_recorder(lambda *a, **kw: None) db_request.find_service = pretend.call_recorder( lambda *args, **kwargs: pretend.stub( csp_policy={}, @@ -870,9 +860,11 @@ def test_register_redirect(self, db_request, monkeypatch): create_user=create_user, add_email=add_email, check_password=lambda pw, tags=None: False, + record_event=record_event, ) ) db_request.route_path = pretend.call_recorder(lambda name: "/") + db_request.remote_addr = "0.0.0.0" db_request.POST.update( { "username": "username_value", @@ -894,6 +886,20 @@ def test_register_redirect(self, db_request, monkeypatch): ] assert add_email.calls == [pretend.call(user.id, "foo@bar.com", primary=True)] assert send_email.calls == [pretend.call(db_request, (user, email))] + assert record_event.calls == [ + pretend.call( + user.id, + tag="account:create", + ip_address=db_request.remote_addr, + additional={"email": "foo@bar.com"}, + ), + pretend.call( + user.id, + tag="account:login:success", + ip_address=db_request.remote_addr, + additional={"two_factor_method": None}, + ), + ] def test_register_fails_with_admin_flag_set(self, db_request): # This flag was already set via migration, just need to enable it @@ -951,10 +957,12 @@ def test_request_password_reset( self, monkeypatch, pyramid_request, pyramid_config, user_service, token_service ): - stub_user = pretend.stub(username=pretend.stub()) + stub_user = pretend.stub(id=pretend.stub(), username=pretend.stub()) pyramid_request.method = "POST" + pyramid_request.remote_addr = "0.0.0.0" token_service.dumps = pretend.call_recorder(lambda a: "TOK") user_service.get_user_by_username = pretend.call_recorder(lambda a: stub_user) + user_service.record_event = pretend.call_recorder(lambda *a, **kw: None) pyramid_request.find_service = pretend.call_recorder( lambda interface, **kw: { IUserService: user_service, @@ -991,18 +999,29 @@ def test_request_password_reset( assert send_password_reset_email.calls == [ pretend.call(pyramid_request, (stub_user, None)) ] + assert user_service.record_event.calls == [ + pretend.call( + stub_user.id, + tag="account:password:reset:request", + ip_address=pyramid_request.remote_addr, + ) + ] def test_request_password_reset_with_email( self, monkeypatch, pyramid_request, pyramid_config, user_service, token_service ): stub_user = pretend.stub( - email="foo@example.com", emails=[pretend.stub(email="foo@example.com")] + id=pretend.stub(), + email="foo@example.com", + emails=[pretend.stub(email="foo@example.com")], ) pyramid_request.method = "POST" + pyramid_request.remote_addr = "0.0.0.0" token_service.dumps = pretend.call_recorder(lambda a: "TOK") user_service.get_user_by_username = pretend.call_recorder(lambda a: None) user_service.get_user_by_email = pretend.call_recorder(lambda a: stub_user) + user_service.record_event = pretend.call_recorder(lambda *a, **kw: None) pyramid_request.find_service = pretend.call_recorder( lambda interface, **kw: { IUserService: user_service, @@ -1040,12 +1059,20 @@ def test_request_password_reset_with_email( assert send_password_reset_email.calls == [ pretend.call(pyramid_request, (stub_user, stub_user.emails[0])) ] + assert user_service.record_event.calls == [ + pretend.call( + stub_user.id, + tag="account:password:reset:request", + ip_address=pyramid_request.remote_addr, + ) + ] def test_request_password_reset_with_non_primary_email( self, monkeypatch, pyramid_request, pyramid_config, user_service, token_service ): stub_user = pretend.stub( + id=pretend.stub(), email="foo@example.com", emails=[ pretend.stub(email="foo@example.com"), @@ -1053,9 +1080,11 @@ def test_request_password_reset_with_non_primary_email( ], ) pyramid_request.method = "POST" + pyramid_request.remote_addr = "0.0.0.0" token_service.dumps = pretend.call_recorder(lambda a: "TOK") user_service.get_user_by_username = pretend.call_recorder(lambda a: None) user_service.get_user_by_email = pretend.call_recorder(lambda a: stub_user) + user_service.record_event = pretend.call_recorder(lambda *a, **kw: None) pyramid_request.find_service = pretend.call_recorder( lambda interface, **kw: { IUserService: user_service, @@ -1095,6 +1124,13 @@ def test_request_password_reset_with_non_primary_email( assert send_password_reset_email.calls == [ pretend.call(pyramid_request, (stub_user, stub_user.emails[1])) ] + assert user_service.record_event.calls == [ + pretend.call( + stub_user.id, + tag="account:password:reset:request", + ip_address=pyramid_request.remote_addr, + ) + ] def test_redirect_authenticated_user(self): pyramid_request = pretend.stub(authenticated_userid=1) @@ -1163,6 +1199,7 @@ def test_reset_password(self, db_request, user_service, token_service): breach_service = pretend.stub(check_password=lambda pw: False) db_request.route_path = pretend.call_recorder(lambda name: "/account/login") + db_request.remote_addr = "0.0.0.0" token_service.loads = pretend.call_recorder( lambda token: { "action": "password-reset", @@ -1380,6 +1417,7 @@ def test_verify_email( db_request.user = user db_request.GET.update({"token": "RANDOM_KEY"}) db_request.route_path = pretend.call_recorder(lambda name: "/") + db_request.remote_addr = "0.0.0.0" token_service.loads = pretend.call_recorder( lambda token: {"action": "email-verify", "email.id": str(email.id)} ) diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py index 71d7397cdb1c..abe717be5cd9 100644 --- a/tests/unit/manage/test_views.py +++ b/tests/unit/manage/test_views.py @@ -29,7 +29,14 @@ from warehouse.accounts.interfaces import IPasswordBreachedService, IUserService from warehouse.macaroons.interfaces import IMacaroonService from warehouse.manage import views -from warehouse.packaging.models import File, JournalEntry, Project, Role, User +from warehouse.packaging.models import ( + File, + JournalEntry, + Project, + ProjectEvent, + Role, + User, +) from warehouse.utils.paginate import paginate_url_factory from warehouse.utils.project import remove_documentation @@ -37,6 +44,7 @@ from ...common.db.packaging import ( FileFactory, JournalEntryFactory, + ProjectEventFactory, ProjectFactory, ReleaseFactory, RoleFactory, @@ -186,7 +194,8 @@ def test_add_email(self, monkeypatch, pyramid_config): email_address = "test@example.com" email = pretend.stub(id=pretend.stub(), email=email_address) user_service = pretend.stub( - add_email=pretend.call_recorder(lambda *a, **kw: email) + add_email=pretend.call_recorder(lambda *a, **kw: email), + record_event=pretend.call_recorder(lambda *a, **kw: None), ) request = pretend.stub( POST={"email": email_address}, @@ -197,6 +206,7 @@ def test_add_email(self, monkeypatch, pyramid_config): emails=[], username="username", name="Name", id=pretend.stub() ), task=pretend.call_recorder(lambda *args, **kwargs: send_email), + remote_addr="0.0.0.0", ) monkeypatch.setattr( views, @@ -226,6 +236,14 @@ def test_add_email(self, monkeypatch, pyramid_config): ) ] assert send_email.calls == [pretend.call(request, (request.user, email))] + assert user_service.record_event.calls == [ + pretend.call( + request.user.id, + tag="account:email:add", + ip_address=request.remote_addr, + additional={"email": email_address}, + ) + ] def test_add_email_validation_fails(self, monkeypatch): email_address = "test@example.com" @@ -262,6 +280,9 @@ def test_add_email_validation_fails(self, monkeypatch): def test_delete_email(self, monkeypatch): email = pretend.stub(id=pretend.stub(), primary=False, email=pretend.stub()) some_other_email = pretend.stub() + user_service = pretend.stub( + record_event=pretend.call_recorder(lambda *a, **kw: None) + ) request = pretend.stub( POST={"delete_email_id": email.id}, user=pretend.stub( @@ -272,8 +293,9 @@ def test_delete_email(self, monkeypatch): filter=lambda *a: pretend.stub(one=lambda: email) ) ), - find_service=lambda *a, **kw: pretend.stub(), + find_service=lambda *a, **kw: user_service, session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + remote_addr="0.0.0.0", ) monkeypatch.setattr( views.ManageAccountViews, "default_response", {"_": pretend.stub()} @@ -285,6 +307,14 @@ def test_delete_email(self, monkeypatch): pretend.call(f"Email address {email.email} removed", queue="success") ] assert request.user.emails == [some_other_email] + assert user_service.record_event.calls == [ + pretend.call( + request.user.id, + tag="account:email:remove", + ip_address=request.remote_addr, + additional={"email": email.email}, + ) + ] def test_delete_email_not_found(self, monkeypatch): email = pretend.stub() @@ -341,13 +371,17 @@ def test_delete_email_is_primary(self, monkeypatch): def test_change_primary_email(self, monkeypatch, db_request): user = UserFactory() - old_primary = EmailFactory(primary=True, user=user) - new_primary = EmailFactory(primary=False, verified=True, user=user) + old_primary = EmailFactory(primary=True, user=user, email="old") + new_primary = EmailFactory(primary=False, verified=True, user=user, email="new") db_request.user = user - db_request.find_service = lambda *a, **kw: pretend.stub() + user_service = pretend.stub( + record_event=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.find_service = lambda *a, **kw: user_service db_request.POST = {"primary_email_id": new_primary.id} + db_request.remote_addr = "0.0.0.0" db_request.session.flash = pretend.call_recorder(lambda *a, **kw: None) monkeypatch.setattr( views.ManageAccountViews, "default_response", {"_": pretend.stub()} @@ -367,6 +401,14 @@ def test_change_primary_email(self, monkeypatch, db_request): ] assert not old_primary.primary assert new_primary.primary + assert user_service.record_event.calls == [ + pretend.call( + user.id, + tag="account:email:primary:change", + ip_address=db_request.remote_addr, + additional={"old_primary": "old", "new_primary": "new"}, + ) + ] def test_change_primary_email_without_current(self, monkeypatch, db_request): user = UserFactory() @@ -374,8 +416,12 @@ def test_change_primary_email_without_current(self, monkeypatch, db_request): db_request.user = user - db_request.find_service = lambda *a, **kw: pretend.stub() + user_service = pretend.stub( + record_event=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.find_service = lambda *a, **kw: user_service db_request.POST = {"primary_email_id": new_primary.id} + db_request.remote_addr = "0.0.0.0" db_request.session.flash = pretend.call_recorder(lambda *a, **kw: None) monkeypatch.setattr( views.ManageAccountViews, "default_response", {"_": pretend.stub()} @@ -392,6 +438,14 @@ def test_change_primary_email_without_current(self, monkeypatch, db_request): ) ] assert new_primary.primary + assert user_service.record_event.calls == [ + pretend.call( + user.id, + tag="account:email:primary:change", + ip_address=db_request.remote_addr, + additional={"old_primary": None, "new_primary": new_primary.email}, + ) + ] def test_change_primary_email_not_found(self, monkeypatch, db_request): user = UserFactory() @@ -414,7 +468,13 @@ def test_change_primary_email_not_found(self, monkeypatch, db_request): assert old_primary.primary def test_reverify_email(self, monkeypatch): - email = pretend.stub(verified=False, email="email_address") + email = pretend.stub( + verified=False, + email="email_address", + user=pretend.stub( + record_event=pretend.call_recorder(lambda *a, **kw: None) + ), + ) request = pretend.stub( POST={"reverify_email_id": pretend.stub()}, @@ -426,6 +486,7 @@ def test_reverify_email(self, monkeypatch): session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), find_service=lambda *a, **kw: pretend.stub(), user=pretend.stub(id=pretend.stub(), username="username", name="Name"), + remote_addr="0.0.0.0", ) send_email = pretend.call_recorder(lambda *a: None) monkeypatch.setattr(views, "send_email_verification_email", send_email) @@ -439,6 +500,13 @@ def test_reverify_email(self, monkeypatch): pretend.call("Verification email for email_address resent", queue="success") ] assert send_email.calls == [pretend.call(request, (request.user, email))] + assert email.user.record_event.calls == [ + pretend.call( + tag="account:email:reverify", + ip_address=request.remote_addr, + additional={"email": email.email}, + ) + ] def test_reverify_email_not_found(self, monkeypatch): def raise_no_result(): @@ -499,7 +567,8 @@ def test_change_password(self, monkeypatch): old_password = "0ld_p455w0rd" new_password = "n3w_p455w0rd" user_service = pretend.stub( - update_user=pretend.call_recorder(lambda *a, **kw: None) + update_user=pretend.call_recorder(lambda *a, **kw: None), + record_event=pretend.call_recorder(lambda *a, **kw: None), ) request = pretend.stub( POST={ @@ -515,6 +584,7 @@ def test_change_password(self, monkeypatch): email=pretend.stub(), name=pretend.stub(), ), + remote_addr="0.0.0.0", ) change_pwd_obj = pretend.stub( validate=lambda: True, new_password=pretend.stub(data=new_password) @@ -540,6 +610,13 @@ def test_change_password(self, monkeypatch): assert user_service.update_user.calls == [ pretend.call(request.user.id, password=new_password) ] + assert user_service.record_event.calls == [ + pretend.call( + request.user.id, + tag="account:password:change", + ip_address=request.remote_addr, + ) + ] def test_change_password_validation_fails(self, monkeypatch): old_password = "0ld_p455w0rd" @@ -847,6 +924,7 @@ def test_validate_totp_provision(self, monkeypatch): user_service = pretend.stub( get_totp_secret=lambda id: None, update_user=pretend.call_recorder(lambda *a, **kw: None), + record_event=pretend.call_recorder(lambda *a, **kw: None), ) request = pretend.stub( POST={"totp_value": "123456"}, @@ -866,6 +944,7 @@ def test_validate_totp_provision(self, monkeypatch): has_primary_verified_email=True, ), route_path=lambda *a, **kw: "/foo/bar/", + remote_addr="0.0.0.0", ) provision_totp_obj = pretend.stub(validate=lambda: True) @@ -885,6 +964,14 @@ def test_validate_totp_provision(self, monkeypatch): "Authentication application successfully set up", queue="success" ) ] + assert user_service.record_event.calls == [ + pretend.call( + request.user.id, + tag="account:two_factor:method_added", + ip_address=request.remote_addr, + additional={"method": "totp"}, + ) + ] def test_validate_totp_provision_already_provisioned(self, monkeypatch): user_service = pretend.stub( @@ -990,6 +1077,7 @@ def test_delete_totp(self, monkeypatch, db_request): user_service = pretend.stub( get_totp_secret=lambda id: b"secret", update_user=pretend.call_recorder(lambda *a, **kw: None), + record_event=pretend.call_recorder(lambda *a, **kw: None), ) request = pretend.stub( POST={"confirm_username": pretend.stub()}, @@ -1004,6 +1092,7 @@ def test_delete_totp(self, monkeypatch, db_request): has_primary_verified_email=True, ), route_path=lambda *a, **kw: "/foo/bar/", + remote_addr="0.0.0.0", ) delete_totp_obj = pretend.stub(validate=lambda: True) @@ -1025,6 +1114,14 @@ def test_delete_totp(self, monkeypatch, db_request): ] assert isinstance(result, HTTPSeeOther) assert result.headers["Location"] == "/foo/bar/" + assert user_service.record_event.calls == [ + pretend.call( + request.user.id, + tag="account:two_factor:method_removed", + ip_address=request.remote_addr, + additional={"method": "totp"}, + ) + ] def test_delete_totp_bad_username(self, monkeypatch, db_request): user_service = pretend.stub( @@ -1157,7 +1254,8 @@ def test_get_webauthn_options(self): def test_validate_webauthn_provision(self, monkeypatch): user_service = pretend.stub( - add_webauthn=pretend.call_recorder(lambda *a, **kw: pretend.stub()) + add_webauthn=pretend.call_recorder(lambda *a, **kw: pretend.stub()), + record_event=pretend.call_recorder(lambda *a, **kw: None), ) request = pretend.stub( POST={}, @@ -1170,6 +1268,7 @@ def test_validate_webauthn_provision(self, monkeypatch): find_service=lambda *a, **kw: user_service, domain="fake_domain", host_url="fake_host_url", + remote_addr="0.0.0.0", ) provision_webauthn_obj = pretend.stub( @@ -1204,6 +1303,17 @@ def test_validate_webauthn_provision(self, monkeypatch): pretend.call("Security device successfully set up", queue="success") ] assert result == {"success": "Security device successfully set up"} + assert user_service.record_event.calls == [ + pretend.call( + request.user.id, + tag="account:two_factor:method_added", + ip_address=request.remote_addr, + additional={ + "method": "webauthn", + "label": provision_webauthn_obj.label.data, + }, + ) + ] def test_validate_webauthn_provision_invalid_form(self, monkeypatch): user_service = pretend.stub( @@ -1242,6 +1352,9 @@ def test_validate_webauthn_provision_invalid_form(self, monkeypatch): assert result == {"fail": {"errors": ["Not a real error"]}} def test_delete_webauthn(self, monkeypatch): + user_service = pretend.stub( + record_event=pretend.call_recorder(lambda *a, **kw: None) + ) request = pretend.stub( POST={}, user=pretend.stub( @@ -1255,11 +1368,14 @@ def test_delete_webauthn(self, monkeypatch): ), session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), route_path=pretend.call_recorder(lambda x: "/foo/bar"), - find_service=lambda *a, **kw: pretend.stub(), + find_service=lambda *a, **kw: user_service, + remote_addr="0.0.0.0", ) delete_webauthn_obj = pretend.stub( - validate=lambda: True, webauthn=pretend.stub() + validate=lambda: True, + webauthn=pretend.stub(), + label=pretend.stub(data="fake label"), ) delete_webauthn_cls = pretend.call_recorder( lambda *a, **kw: delete_webauthn_obj @@ -1275,6 +1391,17 @@ def test_delete_webauthn(self, monkeypatch): assert request.route_path.calls == [pretend.call("manage.account")] assert isinstance(result, HTTPSeeOther) assert result.headers["Location"] == "/foo/bar" + assert user_service.record_event.calls == [ + pretend.call( + request.user.id, + tag="account:two_factor:method_removed", + ip_address=request.remote_addr, + additional={ + "method": "webauthn", + "label": delete_webauthn_obj.label.data, + }, + ) + ] def test_delete_webauthn_not_provisioned(self): request = pretend.stub( @@ -1467,20 +1594,107 @@ def test_create_macaroon(self, monkeypatch): lambda *a, **kw: ("not a real raw macaroon", macaroon) ) ) + user_service = pretend.stub( + record_event=pretend.call_recorder(lambda *a, **kw: None) + ) request = pretend.stub( POST={}, domain=pretend.stub(), user=pretend.stub(id=pretend.stub(), has_primary_verified_email=True), find_service=lambda interface, **kw: { IMacaroonService: macaroon_service, - IUserService: pretend.stub(), + IUserService: user_service, + }[interface], + remote_addr="0.0.0.0", + ) + + create_macaroon_obj = pretend.stub( + validate=lambda: True, + description=pretend.stub(data=pretend.stub()), + validated_scope="foobar", + ) + 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 + ) + + view = views.ProvisionMacaroonViews(request) + result = view.create_macaroon() + + assert macaroon_service.create_macaroon.calls == [ + pretend.call( + location=request.domain, + user_id=request.user.id, + description=create_macaroon_obj.description.data, + caveats={ + "permissions": create_macaroon_obj.validated_scope, + "version": 1, + }, + ) + ] + assert result == { + **default_response, + "serialized_macaroon": "not a real raw macaroon", + "macaroon": macaroon, + "create_macaroon_form": create_macaroon_obj, + } + assert user_service.record_event.calls == [ + pretend.call( + request.user.id, + tag="account:api_token:added", + ip_address=request.remote_addr, + additional={ + "description": create_macaroon_obj.description.data, + "caveats": { + "permissions": create_macaroon_obj.validated_scope, + "version": 1, + }, + }, + ) + ] + + def test_create_macaroon_records_events_for_each_project(self, monkeypatch): + macaroon = pretend.stub() + macaroon_service = pretend.stub( + create_macaroon=pretend.call_recorder( + lambda *a, **kw: ("not a real raw macaroon", macaroon) + ) + ) + record_event = pretend.call_recorder(lambda *a, **kw: None) + user_service = pretend.stub(record_event=record_event) + request = pretend.stub( + POST={}, + domain=pretend.stub(), + user=pretend.stub( + id=pretend.stub(), + has_primary_verified_email=True, + username=pretend.stub(), + projects=[ + pretend.stub(normalized_name="foo", record_event=record_event), + pretend.stub(normalized_name="bar", record_event=record_event), + ], + ), + find_service=lambda interface, **kw: { + IMacaroonService: macaroon_service, + IUserService: user_service, }[interface], + remote_addr="0.0.0.0", ) create_macaroon_obj = pretend.stub( validate=lambda: True, description=pretend.stub(data=pretend.stub()), - validated_scope=pretend.stub(), + validated_scope={"projects": ["foo", "bar"]}, ) create_macaroon_cls = pretend.call_recorder( lambda *a, **kw: create_macaroon_obj @@ -1517,6 +1731,36 @@ def test_create_macaroon(self, monkeypatch): "macaroon": macaroon, "create_macaroon_form": create_macaroon_obj, } + assert record_event.calls == [ + pretend.call( + request.user.id, + tag="account:api_token:added", + ip_address=request.remote_addr, + additional={ + "description": create_macaroon_obj.description.data, + "caveats": { + "permissions": create_macaroon_obj.validated_scope, + "version": 1, + }, + }, + ), + pretend.call( + tag="project:api_token:added", + ip_address=request.remote_addr, + additional={ + "description": create_macaroon_obj.description.data, + "user": request.user.username, + }, + ), + pretend.call( + tag="project:api_token:added", + ip_address=request.remote_addr, + additional={ + "description": create_macaroon_obj.description.data, + "user": request.user.username, + }, + ), + ] def test_delete_macaroon_invalid_form(self, monkeypatch): macaroon_service = pretend.stub( @@ -1577,22 +1821,29 @@ 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_service = pretend.stub( delete_macaroon=pretend.call_recorder(lambda id: pretend.stub()), - find_macaroon=pretend.call_recorder( - lambda id: pretend.stub(description="fake macaroon") - ), + find_macaroon=pretend.call_recorder(lambda id: macaroon), + ) + record_event = pretend.call_recorder( + pretend.call_recorder(lambda *a, **kw: None) ) + user_service = pretend.stub(record_event=record_event) request = pretend.stub( POST={}, route_path=pretend.call_recorder(lambda x: pretend.stub()), find_service=lambda interface, **kw: { IMacaroonService: macaroon_service, - IUserService: pretend.stub(), + IUserService: user_service, }[interface], session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), referer="/fake/safe/route", host=None, + user=pretend.stub(id=pretend.stub()), + remote_addr="0.0.0.0", ) delete_macaroon_obj = pretend.stub( @@ -1618,6 +1869,96 @@ def test_delete_macaroon(self, monkeypatch): assert request.session.flash.calls == [ pretend.call("Deleted API token 'fake macaroon'.", queue="success") ] + assert record_event.calls == [ + pretend.call( + request.user.id, + tag="account:api_token:removed", + ip_address=request.remote_addr, + additional={"macaroon_id": delete_macaroon_obj.macaroon_id.data}, + ) + ] + + def test_delete_macaroon_records_events_for_each_project(self, monkeypatch): + macaroon = pretend.stub( + description="fake macaroon", + caveats={"version": 1, "permissions": {"projects": ["foo", "bar"]}}, + ) + macaroon_service = pretend.stub( + delete_macaroon=pretend.call_recorder(lambda id: pretend.stub()), + find_macaroon=pretend.call_recorder(lambda id: macaroon), + ) + record_event = pretend.call_recorder( + pretend.call_recorder(lambda *a, **kw: None) + ) + user_service = pretend.stub(record_event=record_event) + request = pretend.stub( + POST={}, + route_path=pretend.call_recorder(lambda x: pretend.stub()), + find_service=lambda interface, **kw: { + IMacaroonService: macaroon_service, + IUserService: user_service, + }[interface], + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + referer="/fake/safe/route", + host=None, + user=pretend.stub( + id=pretend.stub(), + username=pretend.stub(), + projects=[ + pretend.stub(normalized_name="foo", record_event=record_event), + pretend.stub(normalized_name="bar", record_event=record_event), + ], + ), + remote_addr="0.0.0.0", + ) + + delete_macaroon_obj = pretend.stub( + validate=lambda: True, macaroon_id=pretend.stub(data=pretend.stub()) + ) + delete_macaroon_cls = pretend.call_recorder( + lambda *a, **kw: delete_macaroon_obj + ) + monkeypatch.setattr(views, "DeleteMacaroonForm", delete_macaroon_cls) + + view = views.ProvisionMacaroonViews(request) + result = view.delete_macaroon() + + assert request.route_path.calls == [] + assert isinstance(result, HTTPSeeOther) + assert result.location == "/fake/safe/route" + assert macaroon_service.delete_macaroon.calls == [ + pretend.call(delete_macaroon_obj.macaroon_id.data) + ] + assert macaroon_service.find_macaroon.calls == [ + pretend.call(delete_macaroon_obj.macaroon_id.data) + ] + assert request.session.flash.calls == [ + pretend.call("Deleted API token 'fake macaroon'.", queue="success") + ] + assert record_event.calls == [ + pretend.call( + request.user.id, + tag="account:api_token:removed", + ip_address=request.remote_addr, + additional={"macaroon_id": delete_macaroon_obj.macaroon_id.data}, + ), + pretend.call( + tag="project:api_token:removed", + ip_address=request.remote_addr, + additional={ + "description": "fake macaroon", + "user": request.user.username, + }, + ), + pretend.call( + tag="project:api_token:removed", + ip_address=request.remote_addr, + additional={ + "description": "fake macaroon", + "user": request.user.username, + }, + ), + ] class TestManageProjects: @@ -1832,7 +2173,13 @@ def test_manage_project_release(self): } def test_delete_project_release(self, monkeypatch): - release = pretend.stub(version="1.2.3", project=pretend.stub(name="foobar")) + release = pretend.stub( + version="1.2.3", + canonical_version="1.2.3", + project=pretend.stub( + name="foobar", record_event=pretend.call_recorder(lambda *a, **kw: None) + ), + ) request = pretend.stub( POST={"confirm_version": release.version}, method="POST", @@ -1842,7 +2189,7 @@ def test_delete_project_release(self, monkeypatch): ), route_path=pretend.call_recorder(lambda *a, **kw: "/the-redirect"), session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), - user=pretend.stub(), + user=pretend.stub(username=pretend.stub()), remote_addr=pretend.stub(), ) journal_obj = pretend.stub() @@ -1873,6 +2220,16 @@ def test_delete_project_release(self, monkeypatch): assert request.route_path.calls == [ pretend.call("manage.project.releases", project_name=release.project.name) ] + assert release.project.record_event.calls == [ + pretend.call( + tag="project:release:remove", + ip_address=request.remote_addr, + additional={ + "submitted_by": request.user.username, + "canonical_version": release.canonical_version, + }, + ) + ] def test_delete_project_release_no_confirm(self): release = pretend.stub(version="1.2.3", project=pretend.stub(name="foobar")) @@ -2597,6 +2954,129 @@ def test_delete_own_owner_role(self, db_request): class TestManageProjectHistory: + def test_get(self, db_request): + project = ProjectFactory.create() + older_event = ProjectEventFactory.create( + project=project, + tag="fake:event", + ip_address="0.0.0.0", + time=datetime.datetime(2017, 2, 5, 17, 18, 18, 462_634), + ) + newer_event = ProjectEventFactory.create( + project=project, + tag="fake:event", + ip_address="0.0.0.0", + time=datetime.datetime(2018, 2, 5, 17, 18, 18, 462_634), + ) + + assert views.manage_project_history(project, db_request) == { + "project": project, + "events": [newer_event, older_event], + } + + def test_raises_400_with_pagenum_type_str(self, monkeypatch, db_request): + params = MultiDict({"page": "abc"}) + db_request.params = params + + events_query = pretend.stub() + db_request.events_query = pretend.stub( + events_query=lambda *a, **kw: events_query + ) + + page_obj = pretend.stub(page_count=10, item_count=1000) + page_cls = pretend.call_recorder(lambda *a, **kw: page_obj) + monkeypatch.setattr(views, "SQLAlchemyORMPage", page_cls) + + url_maker = pretend.stub() + url_maker_factory = pretend.call_recorder(lambda request: url_maker) + monkeypatch.setattr(views, "paginate_url_factory", url_maker_factory) + + project = ProjectFactory.create() + with pytest.raises(HTTPBadRequest): + views.manage_project_history(project, db_request) + + assert page_cls.calls == [] + + def test_first_page(self, db_request): + page_number = 1 + params = MultiDict({"page": page_number}) + db_request.params = params + + project = ProjectFactory.create() + items_per_page = 25 + total_items = items_per_page + 2 + for _ in range(total_items): + ProjectEventFactory.create( + project=project, tag="fake:event", ip_address="0.0.0.0" + ) + events_query = ( + db_request.db.query(ProjectEvent) + .join(ProjectEvent.project) + .filter(ProjectEvent.project_id == project.id) + .order_by(ProjectEvent.time.desc()) + ) + + events_page = SQLAlchemyORMPage( + events_query, + page=page_number, + items_per_page=items_per_page, + item_count=total_items, + url_maker=paginate_url_factory(db_request), + ) + assert views.manage_project_history(project, db_request) == { + "project": project, + "events": events_page, + } + + def test_last_page(self, db_request): + page_number = 2 + params = MultiDict({"page": page_number}) + db_request.params = params + + project = ProjectFactory.create() + items_per_page = 25 + total_items = items_per_page + 2 + for _ in range(total_items): + ProjectEventFactory.create( + project=project, tag="fake:event", ip_address="0.0.0.0" + ) + events_query = ( + db_request.db.query(ProjectEvent) + .join(ProjectEvent.project) + .filter(ProjectEvent.project_id == project.id) + .order_by(ProjectEvent.time.desc()) + ) + + events_page = SQLAlchemyORMPage( + events_query, + page=page_number, + items_per_page=items_per_page, + item_count=total_items, + url_maker=paginate_url_factory(db_request), + ) + assert views.manage_project_history(project, db_request) == { + "project": project, + "events": events_page, + } + + def test_raises_404_with_out_of_range_page(self, db_request): + page_number = 3 + params = MultiDict({"page": page_number}) + db_request.params = params + + project = ProjectFactory.create() + items_per_page = 25 + total_items = items_per_page + 2 + for _ in range(total_items): + ProjectEventFactory.create( + project=project, tag="fake:event", ip_address="0.0.0.0" + ) + + with pytest.raises(HTTPNotFound): + assert views.manage_project_history(project, db_request) + + +class TestManageProjectJournal: def test_get(self, db_request): project = ProjectFactory.create() older_journal = JournalEntryFactory.create( @@ -2608,7 +3088,7 @@ def test_get(self, db_request): submitted_date=datetime.datetime(2018, 2, 5, 17, 18, 18, 462_634), ) - assert views.manage_project_history(project, db_request) == { + assert views.manage_project_journal(project, db_request) == { "project": project, "journals": [newer_journal, older_journal], } @@ -2632,7 +3112,7 @@ def test_raises_400_with_pagenum_type_str(self, monkeypatch, db_request): project = ProjectFactory.create() with pytest.raises(HTTPBadRequest): - views.manage_project_history(project, db_request) + views.manage_project_journal(project, db_request) assert page_cls.calls == [] @@ -2662,7 +3142,7 @@ def test_first_page(self, db_request): item_count=total_items, url_maker=paginate_url_factory(db_request), ) - assert views.manage_project_history(project, db_request) == { + assert views.manage_project_journal(project, db_request) == { "project": project, "journals": journals_page, } @@ -2693,7 +3173,7 @@ def test_last_page(self, db_request): item_count=total_items, url_maker=paginate_url_factory(db_request), ) - assert views.manage_project_history(project, db_request) == { + assert views.manage_project_journal(project, db_request) == { "project": project, "journals": journals_page, } @@ -2712,4 +3192,4 @@ def test_raises_404_with_out_of_range_page(self, db_request): ) with pytest.raises(HTTPNotFound): - assert views.manage_project_history(project, db_request) + assert views.manage_project_journal(project, db_request) diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index 5f4f12a00f34..8dc0c7aba96d 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -257,6 +257,13 @@ def add_policy(name, filename): traverse="/{project_name}", domain=warehouse, ), + pretend.call( + "manage.project.journal", + "/manage/project/{project_name}/journal/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{project_name}", + domain=warehouse, + ), pretend.call( "packaging.project", "/project/{name}/", diff --git a/warehouse/accounts/interfaces.py b/warehouse/accounts/interfaces.py index 3c6899f410f7..c054297ee1cd 100644 --- a/warehouse/accounts/interfaces.py +++ b/warehouse/accounts/interfaces.py @@ -179,6 +179,14 @@ def get_webauthn_by_credential_id(user_id, credential_id): or None of the user doesn't have a credential with this ID. """ + def record_event(user_id, *, tag, ip_address, additional=None): + """ + Creates a new UserEvent for the given user with the given + tag, IP address, and additional metadata. + + Returns the event. + """ + class ITokenService(Interface): def dumps(data): diff --git a/warehouse/accounts/models.py b/warehouse/accounts/models.py index 63329eaa4e95..8c0150f1e0d5 100644 --- a/warehouse/accounts/models.py +++ b/warehouse/accounts/models.py @@ -10,6 +10,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime import enum from citext import CIText @@ -29,7 +30,7 @@ select, sql, ) -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm.exc import NoResultFound @@ -95,6 +96,18 @@ class User(SitemapMixin, db.Model): "Macaroon", backref="user", cascade="all, delete-orphan", lazy=False ) + events = orm.relationship("UserEvent", backref="user", lazy=False) + + def record_event(self, *, tag, ip_address, additional): + session = orm.object_session(self) + event = UserEvent( + user=self, tag=tag, ip_address=ip_address, additional=additional + ) + session.add(event) + session.flush() + + return event + @property def primary_email(self): primaries = [x for x in self.emails if x.primary] @@ -122,6 +135,17 @@ def has_two_factor(self): def has_primary_verified_email(self): return self.primary_email is not None and self.primary_email.verified + @property + def recent_events(self): + session = orm.object_session(self) + last_fortnight = datetime.datetime.now() - datetime.timedelta(days=14) + return ( + session.query(UserEvent) + .filter((UserEvent.user_id == self.id) & (UserEvent.time >= last_fortnight)) + .order_by(UserEvent.time.desc()) + .all() + ) + class WebAuthn(db.Model): __tablename__ = "user_security_keys" @@ -140,6 +164,20 @@ class WebAuthn(db.Model): sign_count = Column(Integer, default=0) +class UserEvent(db.Model): + __tablename__ = "user_events" + + user_id = Column( + UUID(as_uuid=True), + ForeignKey("users.id", deferrable=True, initially="DEFERRED"), + nullable=False, + ) + tag = Column(String, nullable=False) + time = Column(DateTime, nullable=False, server_default=sql.func.now()) + ip_address = Column(String, nullable=False) + additional = Column(JSONB, nullable=True) + + class UnverifyReasons(enum.Enum): SpamComplaint = "spam complaint" diff --git a/warehouse/accounts/services.py b/warehouse/accounts/services.py index 42050ef4cc3a..e4c33ee2d13e 100644 --- a/warehouse/accounts/services.py +++ b/warehouse/accounts/services.py @@ -428,6 +428,16 @@ def get_webauthn_by_credential_id(self, user_id, credential_id): None, ) + def record_event(self, user_id, *, tag, ip_address, additional=None): + """ + Creates a new UserEvent for the given user with the given + tag, IP address, and additional metadata. + + Returns the event. + """ + user = self.get_user(user_id) + return user.record_event(tag=tag, ip_address=ip_address, additional=additional) + @implementer(ITokenService) class TokenService: diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py index e7b0016cbc72..f88c2633fff1 100644 --- a/warehouse/accounts/views.py +++ b/warehouse/accounts/views.py @@ -138,7 +138,6 @@ def login(request, redirect_field_name=REDIRECT_FIELD_NAME, _form_class=LoginFor resp = HTTPSeeOther( request.route_path("accounts.two-factor", _query=token) ) - return resp else: # If the user-originating redirection url is not safe, then @@ -171,7 +170,6 @@ def login(request, redirect_field_name=REDIRECT_FIELD_NAME, _form_class=LoginFor .hexdigest() .lower(), ) - return resp return { @@ -216,7 +214,7 @@ def two_factor_and_totp_validate(request, _form_class=TOTPAuthenticationForm): if request.method == "POST": form = two_factor_state["totp_form"] if form.validate(): - _login_user(request, userid) + _login_user(request, userid, two_factor_method="totp") resp = HTTPSeeOther(redirect_to) resp.set_cookie( @@ -295,7 +293,7 @@ def webauthn_authentication_validate(request): webauthn = user_service.get_webauthn_by_credential_id(userid, credential_id) webauthn.sign_count = sign_count - _login_user(request, userid) + _login_user(request, userid, two_factor_method="webauthn") request.response.set_cookie( USER_ID_INSECURE_COOKIE, @@ -400,6 +398,12 @@ def register(request, _form_class=RegistrationForm): form.username.data, form.full_name.data, form.new_password.data ) email = user_service.add_email(user.id, form.email.data, primary=True) + user_service.record_event( + user.id, + tag="account:create", + ip_address=request.remote_addr, + additional={"email": form.email.data}, + ) send_email_verification_email(request, (user, email)) @@ -433,6 +437,11 @@ def request_password_reset(request, _form_class=RequestPasswordResetForm): ) send_password_reset_email(request, (user, email)) + user_service.record_event( + user.id, + tag="account:password:reset:request", + ip_address=request.remote_addr, + ) token_service = request.find_service(ITokenService, name="password") n_hours = token_service.max_age // 60 // 60 @@ -507,6 +516,9 @@ def _error(message): if request.method == "POST" and form.validate(): # Update password. user_service.update_user(user.id, password=form.new_password.data) + user_service.record_event( + user.id, tag="account:password:reset", ip_address=request.remote_addr + ) # Flash a success message request.session.flash("You have reset your password", queue="success") @@ -556,6 +568,11 @@ def _error(message): email.verified = True email.unverify_reason = None email.transient_bounces = 0 + email.user.record_event( + tag="account:email:verified", + ip_address=request.remote_addr, + additional={"email": email.email, "primary": email.primary}, + ) if not email.primary: confirm_message = "You can now set this email as your primary address" @@ -586,7 +603,7 @@ def _get_two_factor_data(request, _redirect_to="/"): return two_factor_data -def _login_user(request, userid): +def _login_user(request, userid, two_factor_method=None): # We have a session factory associated with this request, so in order # to protect against session fixation attacks we're going to make sure # that we create a new session (which for sessions with an identifier @@ -625,6 +642,12 @@ def _login_user(request, userid): # records when the last login was. user_service = request.find_service(IUserService, context=None) user_service.update_user(userid, last_login=datetime.datetime.utcnow()) + user_service.record_event( + userid, + tag="account:login:success", + ip_address=request.remote_addr, + additional={"two_factor_method": two_factor_method}, + ) return headers diff --git a/warehouse/admin/templates/admin/projects/detail.html b/warehouse/admin/templates/admin/projects/detail.html index fdf5ce618eb5..f4794115902e 100644 --- a/warehouse/admin/templates/admin/projects/detail.html +++ b/warehouse/admin/templates/admin/projects/detail.html @@ -228,6 +228,31 @@

Projects that this project might be squatting on:

{% endif %} +
+
+

Project activity

+
+
+ + + + + + + + + {% for event in project.events %} + + + + + + + {% endfor %} + +
EventTimeIP addressAdditional information
{{ event.tag }}{{ event.time }}{{ event.ip_address }}{{ event.additional }}
+
+
diff --git a/warehouse/admin/templates/admin/users/detail.html b/warehouse/admin/templates/admin/users/detail.html index f65f389cbb9f..aa839a737edb 100644 --- a/warehouse/admin/templates/admin/users/detail.html +++ b/warehouse/admin/templates/admin/users/detail.html @@ -258,6 +258,31 @@

Projects

+
+
+

Account activity

+
+
+ + + + + + + + + {% for event in user.recent_events %} + + + + + + + {% endfor %} + +
EventTimeIP addressAdditional information
{{ event.tag }}{{ event.time }}{{ event.ip_address }}{{ event.additional }}
+
+
{% endblock %} diff --git a/warehouse/forklift/legacy.py b/warehouse/forklift/legacy.py index 74d6dfe4d432..fd5f59c80bc4 100644 --- a/warehouse/forklift/legacy.py +++ b/warehouse/forklift/legacy.py @@ -937,6 +937,21 @@ def file_upload(request): ) ) + project.record_event( + tag="project:create", + ip_address=request.remote_addr, + additional={"created_by": request.user.username}, + ) + project.record_event( + tag="project:role:add", + ip_address=request.remote_addr, + additional={ + "submitted_by": request.user.username, + "role_name": "Owner", + "target_user": request.user.username, + }, + ) + # Check that the user has permission to do things to this project, if this # is a new project this will act as a sanity check for the role we just # added above. @@ -1077,6 +1092,15 @@ def file_upload(request): ) ) + project.record_event( + tag="project:release:add", + ip_address=request.remote_addr, + additional={ + "submitted_by": request.user.username, + "canonical_version": release.canonical_version, + }, + ) + # TODO: We need a better solution to this than to just do it inline inside # this method. Ideally the version field would just be sortable, but # at least this should be some sort of hook or trigger. diff --git a/warehouse/manage/views.py b/warehouse/manage/views.py index 452312dcbf5a..720c76a5dd36 100644 --- a/warehouse/manage/views.py +++ b/warehouse/manage/views.py @@ -52,7 +52,14 @@ ProvisionWebAuthnForm, SaveAccountForm, ) -from warehouse.packaging.models import File, JournalEntry, Project, Release, Role +from warehouse.packaging.models import ( + File, + JournalEntry, + Project, + ProjectEvent, + Release, + Role, +) from warehouse.utils.http import is_safe_url from warehouse.utils.paginate import paginate_url_factory from warehouse.utils.project import confirm_project, destroy_docs, remove_project @@ -146,6 +153,12 @@ def add_email(self): if form.validate(): email = self.user_service.add_email(self.request.user.id, form.email.data) + self.user_service.record_event( + self.request.user.id, + tag="account:email:add", + ip_address=self.request.remote_addr, + additional={"email": email.email}, + ) send_email_verification_email(self.request, (self.request.user, email)) @@ -179,6 +192,12 @@ def delete_email(self): ) else: self.request.user.emails.remove(email) + self.user_service.record_event( + self.request.user.id, + tag="account:email:remove", + ip_address=self.request.remote_addr, + additional={"email": email.email}, + ) self.request.session.flash( f"Email address {email.email} removed", queue="success" ) @@ -206,6 +225,17 @@ def change_primary_email(self): ).update(values={"primary": False}) new_primary_email.primary = True + self.user_service.record_event( + self.request.user.id, + tag="account:email:primary:change", + ip_address=self.request.remote_addr, + additional={ + "old_primary": previous_primary_email.email + if previous_primary_email + else None, + "new_primary": new_primary_email.email, + }, + ) self.request.session.flash( f"Email address {new_primary_email.email} set as primary", queue="success" @@ -236,6 +266,11 @@ def reverify_email(self): self.request.session.flash("Email is already verified", queue="error") else: send_email_verification_email(self.request, (self.request.user, email)) + email.user.record_event( + tag="account:email:reverify", + ip_address=self.request.remote_addr, + additional={"email": email.email}, + ) self.request.session.flash( f"Verification email for {email.email} resent", queue="success" @@ -259,6 +294,11 @@ def change_password(self): self.user_service.update_user( self.request.user.id, password=form.new_password.data ) + self.user_service.record_event( + self.request.user.id, + tag="account:password:change", + ip_address=self.request.remote_addr, + ) send_password_change_email(self.request, self.request.user) self.request.session.flash("Password updated", queue="success") @@ -399,8 +439,13 @@ def validate_totp_provision(self): self.user_service.update_user( self.request.user.id, totp_secret=self.request.session.get_totp_secret() ) - self.request.session.clear_totp_secret() + self.user_service.record_event( + self.request.user.id, + tag="account:two_factor:method_added", + ip_address=self.request.remote_addr, + additional={"method": "totp"}, + ) self.request.session.flash( "Authentication application successfully set up", queue="success" ) @@ -432,6 +477,12 @@ def delete_totp(self): if form.validate(): self.user_service.update_user(self.request.user.id, totp_secret=None) + self.user_service.record_event( + self.request.user.id, + tag="account:two_factor:method_removed", + ip_address=self.request.remote_addr, + additional={"method": "totp"}, + ) self.request.session.flash( "Authentication application removed from PyPI. " "Remember to remove PyPI from your application.", @@ -502,6 +553,12 @@ def validate_webauthn_provision(self): public_key=form.validated_credential.public_key.decode(), sign_count=form.validated_credential.sign_count, ) + self.user_service.record_event( + self.request.user.id, + tag="account:two_factor:method_added", + ip_address=self.request.remote_addr, + additional={"method": "webauthn", "label": form.label.data}, + ) self.request.session.flash( "Security device successfully set up", queue="success" ) @@ -533,6 +590,12 @@ def delete_webauthn(self): if form.validate(): self.request.user.webauthn.remove(form.webauthn) + self.user_service.record_event( + self.request.user.id, + tag="account:two_factor:method_removed", + ip_address=self.request.remote_addr, + additional={"method": "webauthn", "label": form.label.data}, + ) self.request.session.flash("Security device removed", queue="success") else: self.request.session.flash("Invalid credentials", queue="error") @@ -593,12 +656,42 @@ def create_macaroon(self): 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={"permissions": form.validated_scope, "version": 1}, + caveats=macaroon_caveats, + ) + self.user_service.record_event( + self.request.user.id, + tag="account:api_token:added", + ip_address=self.request.remote_addr, + additional={ + "description": form.description.data, + "caveats": macaroon_caveats, + }, ) + if "projects" in form.validated_scope: + projects = [ + project + for project in self.request.user.projects + if project.normalized_name in form.validated_scope["projects"] + ] + 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=self.request.remote_addr, + additional={ + "description": form.description.data, + "user": self.request.user.username, + }, + ) + response.update(serialized_macaroon=serialized_macaroon, macaroon=macaroon) return {**response, "create_macaroon_form": form} @@ -610,12 +703,32 @@ def delete_macaroon(self): ) if form.validate(): - description = self.macaroon_service.find_macaroon( - form.macaroon_id.data - ).description + macaroon = self.macaroon_service.find_macaroon(form.macaroon_id.data) self.macaroon_service.delete_macaroon(form.macaroon_id.data) + self.user_service.record_event( + self.request.user.id, + tag="account:api_token:removed", + ip_address=self.request.remote_addr, + additional={"macaroon_id": form.macaroon_id.data}, + ) + if "projects" in macaroon.caveats["permissions"]: + projects = [ + project + for project in self.request.user.projects + if project.normalized_name + in macaroon.caveats["permissions"]["projects"] + ] + for project in projects: + project.record_event( + tag="project:api_token:removed", + ip_address=self.request.remote_addr, + additional={ + "description": macaroon.description, + "user": self.request.user.username, + }, + ) self.request.session.flash( - f"Deleted API token '{description}'.", queue="success" + f"Deleted API token '{macaroon.description}'.", queue="success" ) redirect_to = self.request.referer @@ -764,6 +877,15 @@ def delete_project_release(self): ) ) + self.release.project.record_event( + tag="project:release:remove", + ip_address=self.request.remote_addr, + additional={ + "submitted_by": self.request.user.username, + "canonical_version": self.release.canonical_version, + }, + ) + self.request.db.delete(self.release) self.request.session.flash( @@ -823,6 +945,16 @@ def _error(message): ) ) + self.release.project.record_event( + tag="project:release:file:remove", + ip_address=self.request.remote_addr, + additional={ + "submitted_by": self.request.user.username, + "canonical_version": self.release.canonical_version, + "filename": release_file.filename, + }, + ) + self.request.db.delete(release_file) self.request.session.flash( @@ -885,6 +1017,15 @@ def manage_project_roles(project, request, _form_class=CreateRoleForm): submitted_from=request.remote_addr, ) ) + project.record_event( + tag="project:role:add", + ip_address=request.remote_addr, + additional={ + "submitted_by": request.user.username, + "role_name": role_name, + "target_user": username, + }, + ) owner_roles = ( request.db.query(Role) @@ -980,6 +1121,15 @@ def change_project_role(project, request, _form_class=ChangeRoleForm): submitted_from=request.remote_addr, ) ) + project.record_event( + tag="project:role:delete", + ip_address=request.remote_addr, + additional={ + "submitted_by": request.user.username, + "role_name": role.role_name, + "target_user": role.user.username, + }, + ) request.session.flash("Changed role", queue="success") else: # This user only has one role, so get it and change the type. @@ -1008,6 +1158,15 @@ def change_project_role(project, request, _form_class=ChangeRoleForm): ) ) role.role_name = form.role_name.data + project.record_event( + tag="project:role:change", + ip_address=request.remote_addr, + additional={ + "submitted_by": request.user.username, + "role_name": form.role_name.data, + "target_user": role.user.username, + }, + ) request.session.flash("Changed role", queue="success") except NoResultFound: request.session.flash("Could not find role", queue="error") @@ -1053,6 +1212,15 @@ def delete_project_role(project, request): submitted_from=request.remote_addr, ) ) + project.record_event( + tag="project:role:delete", + ip_address=request.remote_addr, + additional={ + "submitted_by": request.user.username, + "role_name": role.role_name, + "target_user": role.user.username, + }, + ) request.session.flash("Removed role", queue="success") return HTTPSeeOther( @@ -1073,6 +1241,39 @@ def manage_project_history(project, request): except ValueError: raise HTTPBadRequest("'page' must be an integer.") + events_query = ( + request.db.query(ProjectEvent) + .join(ProjectEvent.project) + .filter(ProjectEvent.project_id == project.id) + .order_by(ProjectEvent.time.desc()) + ) + + events = SQLAlchemyORMPage( + events_query, + page=page_num, + items_per_page=25, + url_maker=paginate_url_factory(request), + ) + + if events.page_count and page_num > events.page_count: + raise HTTPNotFound + + return {"project": project, "events": events} + + +@view_config( + route_name="manage.project.journal", + context=Project, + renderer="manage/journal.html", + uses_session=True, + permission="manage:project", +) +def manage_project_journal(project, request): + try: + page_num = int(request.params.get("page", 1)) + except ValueError: + raise HTTPBadRequest("'page' must be an integer.") + journals_query = ( request.db.query(JournalEntry) .options(joinedload("submitted_by")) diff --git a/warehouse/migrations/versions/0ac2f506ef2e_user_and_project_event_models.py b/warehouse/migrations/versions/0ac2f506ef2e_user_and_project_event_models.py new file mode 100644 index 000000000000..498012e9174e --- /dev/null +++ b/warehouse/migrations/versions/0ac2f506ef2e_user_and_project_event_models.py @@ -0,0 +1,74 @@ +# 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. +""" +User and Project event models + +Revision ID: 0ac2f506ef2e +Revises: d83f20495c10 +Create Date: 2019-07-31 21:50:43.407231 +""" + +import sqlalchemy as sa + +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "0ac2f506ef2e" +down_revision = "d83f20495c10" + + +def upgrade(): + op.create_table( + "project_events", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("tag", sa.String(), nullable=False), + sa.Column( + "time", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column("ip_address", sa.String(), nullable=False), + sa.Column("additional", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.ForeignKeyConstraint( + ["project_id"], ["projects.id"], initially="DEFERRED", deferrable=True + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "user_events", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("tag", sa.String(), nullable=False), + sa.Column( + "time", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column("ip_address", sa.String(), nullable=False), + sa.Column("additional", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], ["users.id"], initially="DEFERRED", deferrable=True + ), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade(): + op.drop_table("user_events") + op.drop_table("project_events") diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py index 3c6ee2083a57..81b4e6623795 100644 --- a/warehouse/packaging/models.py +++ b/warehouse/packaging/models.py @@ -31,13 +31,14 @@ ForeignKey, Index, Integer, + String, Table, Text, func, orm, sql, ) -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.ext.hybrid import hybrid_property @@ -135,6 +136,8 @@ class Project(SitemapMixin, db.Model): passive_deletes=True, ) + events = orm.relationship("ProjectEvent", backref="project", lazy=False) + def __getitem__(self, version): session = orm.object_session(self) canonical_version = packaging.utils.canonicalize_version(version) @@ -187,6 +190,16 @@ def __acl__(self): acls.append((Allow, str(role.user.id), ["upload"])) return acls + def record_event(self, *, tag, ip_address, additional=None): + session = orm.object_session(self) + event = ProjectEvent( + project=self, tag=tag, ip_address=ip_address, additional=additional + ) + session.add(event) + session.flush() + + return event + @property def documentation_url(self): # TODO: Move this into the database and eliminate the use of the @@ -220,6 +233,20 @@ def latest_version(self): ) +class ProjectEvent(db.Model): + __tablename__ = "project_events" + + project_id = Column( + UUID(as_uuid=True), + ForeignKey("projects.id", deferrable=True, initially="DEFERRED"), + nullable=False, + ) + tag = Column(String, nullable=False) + time = Column(DateTime, nullable=False, server_default=sql.func.now()) + ip_address = Column(String, nullable=False) + additional = Column(JSONB, nullable=True) + + class DependencyKind(enum.IntEnum): requires = 1 diff --git a/warehouse/routes.py b/warehouse/routes.py index 2aae9ce53cc4..a7d1d343fdd3 100644 --- a/warehouse/routes.py +++ b/warehouse/routes.py @@ -232,6 +232,13 @@ def includeme(config): traverse="/{project_name}", domain=warehouse, ) + config.add_route( + "manage.project.journal", + "/manage/project/{project_name}/journal/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{project_name}", + domain=warehouse, + ) # Packaging config.add_redirect("/p/{name}/", "/project/{name}/", domain=warehouse) diff --git a/warehouse/static/sass/blocks/_table.scss b/warehouse/static/sass/blocks/_table.scss index e0e462fe3ccd..1c3d2d92da3b 100644 --- a/warehouse/static/sass/blocks/_table.scss +++ b/warehouse/static/sass/blocks/_table.scss @@ -43,6 +43,7 @@ - emails: specific styles for emails on the manage account page - 2fa: specific styles for 2fa methods on the manage account page - api-tokens: specific styles for API tokens on the manage account page + - security-logs: specific styles for security logs on the manage account page */ // TABLE @@ -70,6 +71,11 @@ border: 0; font-size: $base-font-size; box-shadow: none; + background-color: transparent; + + thead tr { + background-color: transparent; + } tbody tr, tbody tr:nth-child(2n), @@ -668,4 +674,41 @@ } } } + + &--security-logs { + margin-top: 0; + + tr td { + padding-right: 20px; + + &:last-of-type { + padding-right: 0; + } + } + + @media only screen and (max-width: $small-tablet) { + thead { + display: none; + } + + tbody tr, + tbody tr:nth-child(2n) { + display: block; + padding: 20px 0; + border-bottom: 1px solid $base-grey; + } + + tbody tr:last-of-type { + border-bottom: 0; + } + + tbody tr td { + padding: 0; + width: 100%; + display: block; + text-align: left; + border-bottom: 0; + } + } + } } diff --git a/warehouse/templates/includes/manage/manage-project-menu.html b/warehouse/templates/includes/manage/manage-project-menu.html index 5bca9c82692d..5cbf99cd4d1d 100644 --- a/warehouse/templates/includes/manage/manage-project-menu.html +++ b/warehouse/templates/includes/manage/manage-project-menu.html @@ -28,7 +28,13 @@
  • - History + Security History + +
  • +
  • + + + Journal
  • {% if project.has_docs %} diff --git a/warehouse/templates/manage/account.html b/warehouse/templates/manage/account.html index bdb6713fadb0..bb3edb4606c5 100644 --- a/warehouse/templates/manage/account.html +++ b/warehouse/templates/manage/account.html @@ -496,6 +496,127 @@

    API tokens Beta feature


    +
    +

    Security history Beta feature

    + + {% set recent_events = user.recent_events %} + {% if recent_events|length > 0 %} + + {% macro event_summary(event) -%} + {% if event.tag == "account:create" %} + Account created + + {% elif event.tag == "account:login:success" %} + Logged in
    + + Two factor method: + {% if event.additional.two_factor_method == None %} + None + {% elif event.additional.two_factor_method == "webauthn" %} + Security device (webauthn) + {% elif event.additional.two_factor_method == "totp" %} + Authentication application (TOTP) + {% endif %} + + + {% elif event.tag == "account:email:add" %} + Email added to account
    + {{ event.additional.email }} + {% elif event.tag == "account:email:remove" %} + Email removed from account
    + {{ event.additional.email }} + {% elif event.tag == "account:email:verified" %} + Email verified
    + {{ event.additional.email }} + {% elif event.tag == "account:email:reverify" %} + Email reverified
    + {{ event.additional.email }} + {% elif event.tag == "account:email:primary:change" %} + {% if event.additional.old_primary %} + Primary email changed
    + + Old primary email: {{ event.additional.old_primary }}
    + New primary email: {{ event.additional.new_primary }} + + {% else %} + Primary email set
    + + {{ event.additional.new_primary }} + + {% endif %} + + {% elif event.tag == "account:password:reset:request" %} + Password reset requested + {% elif event.tag == "account:password:reset" %} + Password successfully reset + {% elif event.tag == "account:password:change" %} + Password successfully changed + + {% elif event.tag == "account:two_factor:method_added" %} + Two factor authentication added
    + + {% if event.additional.method == "webauthn" %} + Method: Security device (webauthn)
    + Device name: {{ event.additional.label }} + {% elif event.additional.method == "totp" %} + Method: Authentication application (TOTP) + {% endif %} +
    + {% elif event.tag == "account:two_factor:method_removed" %} + Two factor authentication removed
    + + {% if event.additional.method == "webauthn" %} + Method: Security device (webauthn)
    + Device name: {{ event.additional.label }} + {% elif event.additional.method == "totp" %} + Method: Authentication application (TOTP) + {% endif %} +
    + + {% elif event.tag == "account:api_token:added" %} + API token added
    + + Token name: {{ event.additional.description }}
    + {% if event.additional.caveats.permissions == "user" %} + Token scope: entire account + {% else %} + Token scope: Project {{event.additional.caveats.permissions.projects[0] }} + {% endif %} +
    + + {% elif event.tag == "account:api_token:removed" %} + API token removed
    + Unique identifier: {{ event.additional.macaroon_id }} + {% else %} + {{ event.tag }} + {% endif %} + {%- endmacro %} + +

    Events appear here as security-related actions occur on your account.

    + + + + + + + + + {% for event in recent_events %} + + + + + + {% endfor %} + +
    Recent account activity
    EventDate / timeIP address
    {{ event_summary(event) }}{{ humanize(event.time) }}{{ event.ip_address }}
    + {% else %} +

    Events will appear here as security-related actions occur on your account.

    + {% endif %} +
    + +
    +

    Delete account

    diff --git a/warehouse/templates/manage/history.html b/warehouse/templates/manage/history.html index db053d48e1a7..ea8c2c4b7302 100644 --- a/warehouse/templates/manage/history.html +++ b/warehouse/templates/manage/history.html @@ -17,38 +17,89 @@ {% set active_tab = 'history' %} -{% block title %}{{ project.name }}' project history{% endblock %} +{% block title %}'{{ project.name }}' project history{% endblock %} {% block main %} -

    Project history

    +

    Security history Beta feature

    -

    Each time you or your collaborators update this project, the action is recorded and displayed here

    +

    Each time you (or your collaborators) perform a security action related to this project, the action is recorded and displayed here.

    - - + {% macro event_summary(event) -%} + {% if event.tag == "project:create" %} + Project created
    + + Created by: {{ event.additional.created_by }} + + {% elif event.tag == "project:release:add" %} + Release version {{ event.additional.canonical_version }} created
    + + Added by: {{ event.additional.submitted_by }} + + {% elif event.tag == "project:release:remove" %} + Release version {{ event.additional.canonical_version }} removed
    + + Removed by: {{ event.additional.submitted_by }} + + {% elif event.tag == "project:release:file:remove" %} + File removed from release version {{ event.additional.canonical_version }}
    + Filename: {{ event.additional.filename }}
    + + Removed by: {{ event.additional.submitted_by }} + + {% elif event.tag == "project:role:add" %} + {{ event.additional.target_user }} added as project {{ event.additional.role_name|lower }}
    + + Added by: {{ event.additional.submitted_by }} + + {% elif event.tag == "project:role:delete" %} + {{ event.additional.target_user }} removed as project {{ event.additional.role_name|lower }}
    + + Removed by: {{ event.additional.submitted_by }} + + {% elif event.tag == "project:role:change" %} + {{ event.additional.target_user }} changed to project {{ event.additional.role_name|lower }}
    + + Changed by: {{ event.additional.submitted_by }} + + {% elif event.tag == "project:api_token:added" %} + API token added
    + + Permissions: Can upload to this project
    + Controlled by: {{ event.additional.user }}
    + Token name: {{ event.additional.description }} +
    + {% elif event.tag == "project:api_token:removed" %} + API token removed
    + + Permissions: Can upload to this project
    + Controlled by: {{ event.additional.user }}
    + Token name: {{ event.additional.description }} +
    + {% else %} + {{ event.tag }} + {% endif %} + {%- endmacro %} + + {% if events %} +
    History for {{ project.name }}
    + - - - + + + - {% for journal in journals %} + {% for event in events %} - - - + + + {% endfor %}
    Security history for {{ project.name }}
    ActionDateUserEventDate / timeIP address
    - {% if journal.version %} Release {{ journal.version }}: {% endif %}{{ journal.action }} - - {{ journal.submitted_date|format_datetime() }} - - {{ journal.submitted_by.username }} - from {{ journal.submitted_from }} - {{ event_summary(event) }}{{ humanize(event.time) }}{{ event.ip_address }}
    - {{ pagination.paginate(journals) }} + {{ pagination.paginate(events) }} + {% endif %} {% endblock %} diff --git a/warehouse/templates/manage/journal.html b/warehouse/templates/manage/journal.html new file mode 100644 index 000000000000..9101897e3198 --- /dev/null +++ b/warehouse/templates/manage/journal.html @@ -0,0 +1,58 @@ +{# + # 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. +-#} +{% extends "manage_project_base.html" %} + +{% import "warehouse:templates/includes/pagination.html" as pagination %} + +{% set active_tab = 'journal' %} + +{% block title %}'{{ project.name }}' project journal{% endblock %} + +{% block main %} +

    Project journal

    + +

    Each time you or your collaborators update this project, the action is recorded and displayed here.

    + +
    +

    This feature will be deprecated in the future, replaced by the security history page. +

    + + + + + + + + + + + + {% for journal in journals %} + + + + + + {% endfor %} + +
    History for {{ project.name }}
    ActionDateUser
    + {% if journal.version %} Release {{ journal.version }}: {% endif %}{{ journal.action }} + + {{ journal.submitted_date|format_datetime() }} + + {{ journal.submitted_by.username }} + from {{ journal.submitted_from }} +
    + {{ pagination.paginate(journals) }} +{% endblock %}