From aa89acf1529138a35248ddc5a3477dd74a0166f8 Mon Sep 17 00:00:00 2001 From: Andrew Pan Date: Thu, 25 May 2023 17:10:54 -0500 Subject: [PATCH 01/18] */security_policy: remove `IAuthorizationPolicy` Signed-off-by: Andrew Pan --- tests/unit/accounts/test_core.py | 19 +-- tests/unit/accounts/test_security_policy.py | 128 +++++-------------- tests/unit/macaroons/test_security_policy.py | 88 +++---------- tests/unit/utils/test_security_policy.py | 88 +++++++------ warehouse/accounts/__init__.py | 16 +-- warehouse/accounts/security_policy.py | 53 ++++---- warehouse/macaroons/security_policy.py | 38 ++---- warehouse/utils/security_policy.py | 27 ++-- 8 files changed, 164 insertions(+), 293 deletions(-) diff --git a/tests/unit/accounts/test_core.py b/tests/unit/accounts/test_core.py index 18bec5f54009..e3a61ffc90e0 100644 --- a/tests/unit/accounts/test_core.py +++ b/tests/unit/accounts/test_core.py @@ -390,14 +390,8 @@ def test_unauthenticated_userid(self): def test_includeme(monkeypatch): - authz_obj = pretend.stub() - authz_cls = pretend.call_recorder(lambda *a, **kw: authz_obj) - monkeypatch.setattr(accounts, "ACLAuthorizationPolicy", authz_cls) - monkeypatch.setattr(accounts, "MacaroonAuthorizationPolicy", authz_cls) - monkeypatch.setattr(accounts, "TwoFactorAuthorizationPolicy", authz_cls) - multi_policy_obj = pretend.stub() - multi_policy_cls = pretend.call_recorder(lambda ps, authz: multi_policy_obj) + multi_policy_cls = pretend.call_recorder(lambda ps: multi_policy_obj) monkeypatch.setattr(accounts, "MultiSecurityPolicy", multi_policy_cls) session_policy_obj = pretend.stub() @@ -412,6 +406,10 @@ def test_includeme(monkeypatch): macaroon_policy_cls = pretend.call_recorder(lambda: macaroon_policy_obj) monkeypatch.setattr(accounts, "MacaroonSecurityPolicy", macaroon_policy_cls) + twofactor_policy_obj = pretend.stub() + twofactor_policy_cls = pretend.call_recorder(lambda: twofactor_policy_obj) + monkeypatch.setattr(accounts, "TwoFactorSecurityPolicy", twofactor_policy_cls) + config = pretend.stub( registry=pretend.stub( settings={ @@ -474,6 +472,11 @@ def test_includeme(monkeypatch): assert config.set_security_policy.calls == [pretend.call(multi_policy_obj)] assert multi_policy_cls.calls == [ pretend.call( - [session_policy_obj, basic_policy_obj, macaroon_policy_obj], authz_obj + [ + session_policy_obj, + basic_policy_obj, + macaroon_policy_obj, + twofactor_policy_obj, + ] ) ] diff --git a/tests/unit/accounts/test_security_policy.py b/tests/unit/accounts/test_security_policy.py index 969a33c726e8..adf5f91f1e54 100644 --- a/tests/unit/accounts/test_security_policy.py +++ b/tests/unit/accounts/test_security_policy.py @@ -14,8 +14,8 @@ import pretend import pytest -from pyramid.interfaces import IAuthorizationPolicy, ISecurityPolicy -from pyramid.security import Allowed, Denied +from pyramid.interfaces import ISecurityPolicy +from pyramid.security import Allowed from zope.interface.verify import verifyClass from warehouse.accounts import security_policy @@ -510,79 +510,51 @@ def test_identity_ip_banned(self, monkeypatch): assert request.add_response_callback.calls == [pretend.call(vary_cb)] -class TestTwoFactorAuthorizationPolicy: +class TestTwoFactorSecurityPolicy: def test_verify(self): - assert verifyClass( - IAuthorizationPolicy, security_policy.TwoFactorAuthorizationPolicy - ) - - def test_permits_no_active_request(self, monkeypatch): - get_current_request = pretend.call_recorder(lambda: None) - monkeypatch.setattr(security_policy, "get_current_request", get_current_request) - - backing_policy = pretend.stub( - permits=pretend.call_recorder(lambda *a, **kw: pretend.stub()) - ) - policy = security_policy.TwoFactorAuthorizationPolicy(policy=backing_policy) - result = policy.permits(pretend.stub(), pretend.stub(), pretend.stub()) + assert verifyClass(ISecurityPolicy, security_policy.TwoFactorSecurityPolicy) - assert result == WarehouseDenied("") - assert result.s == "There was no active request." + def test_noops(self): + policy = security_policy.TwoFactorSecurityPolicy() - def test_permits_if_context_is_not_permitted_by_backing_policy(self, monkeypatch): - request = pretend.stub() - get_current_request = pretend.call_recorder(lambda: request) - monkeypatch.setattr(security_policy, "get_current_request", get_current_request) + assert policy.identity(pretend.stub()) == None + assert policy.forget(pretend.stub()) == [] + assert policy.remember(pretend.stub(), pretend.stub()) == [] - permits_result = Denied("Because") - backing_policy = pretend.stub( - permits=pretend.call_recorder(lambda *a, **kw: permits_result) - ) - policy = security_policy.TwoFactorAuthorizationPolicy(policy=backing_policy) - result = policy.permits(pretend.stub(), pretend.stub(), pretend.stub()) + with pytest.raises(NotImplementedError): + policy.authenticated_userid(pretend.stub()) - assert result == permits_result + def test_permits_no_active_request(self): + policy = security_policy.TwoFactorSecurityPolicy() + result = policy.permits(None, pretend.stub(), pretend.stub()) - def test_permits_if_non_2fa_requireable_context(self, monkeypatch): - request = pretend.stub() - get_current_request = pretend.call_recorder(lambda: request) - monkeypatch.setattr(security_policy, "get_current_request", get_current_request) + assert result == WarehouseDenied("") + assert result.s == "There was no active request." - permits_result = Allowed("Because") - backing_policy = pretend.stub( - permits=pretend.call_recorder(lambda *a, **kw: permits_result) - ) - policy = security_policy.TwoFactorAuthorizationPolicy(policy=backing_policy) + def test_permits_if_non_2fa_requireable_context(self): + policy = security_policy.TwoFactorSecurityPolicy() result = policy.permits(pretend.stub(), pretend.stub(), pretend.stub()) - assert result == permits_result + assert result == Allowed("Two factor requirements fulfilled") - def test_permits_if_context_does_not_require_2fa(self, monkeypatch, db_request): + def test_permits_if_context_does_not_require_2fa(self, db_request): db_request.user = pretend.stub() db_request.registry.settings = { "warehouse.two_factor_mandate.enabled": True, "warehouse.two_factor_mandate.available": True, "warehouse.two_factor_requirement.enabled": True, } - get_current_request = pretend.call_recorder(lambda: db_request) - monkeypatch.setattr(security_policy, "get_current_request", get_current_request) - permits_result = Allowed("Because") - backing_policy = pretend.stub( - permits=pretend.call_recorder(lambda *a, **kw: permits_result) - ) - policy = security_policy.TwoFactorAuthorizationPolicy(policy=backing_policy) + policy = security_policy.TwoFactorSecurityPolicy() context = ProjectFactory.create( owners_require_2fa=False, pypi_mandates_2fa=False, ) - result = policy.permits(context, pretend.stub(), pretend.stub()) + result = policy.permits(db_request, context, pretend.stub()) - assert result == permits_result + assert result == Allowed("Two factor requirements fulfilled") - def test_flashes_if_context_requires_2fa_but_not_enabled( - self, monkeypatch, db_request - ): + def test_flashes_if_context_requires_2fa_but_not_enabled(self, db_request): db_request.registry.settings = { "warehouse.two_factor_mandate.enabled": False, "warehouse.two_factor_mandate.available": True, @@ -590,21 +562,15 @@ def test_flashes_if_context_requires_2fa_but_not_enabled( } db_request.session.flash = pretend.call_recorder(lambda m, queue: None) db_request.user = pretend.stub(has_two_factor=False) - get_current_request = pretend.call_recorder(lambda: db_request) - monkeypatch.setattr(security_policy, "get_current_request", get_current_request) - permits_result = Allowed("Because") - backing_policy = pretend.stub( - permits=pretend.call_recorder(lambda *a, **kw: permits_result) - ) - policy = security_policy.TwoFactorAuthorizationPolicy(policy=backing_policy) + policy = security_policy.TwoFactorSecurityPolicy() context = ProjectFactory.create( owners_require_2fa=False, pypi_mandates_2fa=True, ) - result = policy.permits(context, pretend.stub(), pretend.stub()) + result = policy.permits(db_request, context, pretend.stub()) - assert result == permits_result + assert result == Allowed("Two factor requirements fulfilled") assert db_request.session.flash.calls == [ pretend.call( "This project is included in PyPI's two-factor mandate " @@ -621,7 +587,6 @@ def test_flashes_if_context_requires_2fa_but_not_enabled( @pytest.mark.parametrize("two_factor_mandate_enabled", [True, False]) def test_permits_if_user_has_2fa( self, - monkeypatch, owners_require_2fa, pypi_mandates_2fa, two_factor_requirement_enabled, @@ -636,20 +601,13 @@ def test_permits_if_user_has_2fa( } user = pretend.stub(has_two_factor=True) db_request.user = user - get_current_request = pretend.call_recorder(lambda: db_request) - monkeypatch.setattr(security_policy, "get_current_request", get_current_request) - - permits_result = Allowed("Because") - backing_policy = pretend.stub( - permits=pretend.call_recorder(lambda *a, **kw: permits_result) - ) - policy = security_policy.TwoFactorAuthorizationPolicy(policy=backing_policy) + policy = security_policy.TwoFactorSecurityPolicy() context = ProjectFactory.create( owners_require_2fa=owners_require_2fa, pypi_mandates_2fa=pypi_mandates_2fa ) - result = policy.permits(context, pretend.stub(), pretend.stub()) + result = policy.permits(db_request, context, pretend.stub()) - assert result == permits_result + assert result == Allowed("Two factor requirements fulfilled") @pytest.mark.parametrize( "owners_require_2fa, pypi_mandates_2fa, reason", @@ -661,7 +619,6 @@ def test_permits_if_user_has_2fa( ) def test_denies_if_2fa_is_required_but_user_doesnt_have_2fa( self, - monkeypatch, owners_require_2fa, pypi_mandates_2fa, reason, @@ -673,18 +630,11 @@ def test_denies_if_2fa_is_required_but_user_doesnt_have_2fa( } user = pretend.stub(has_two_factor=False) db_request.user = user - get_current_request = pretend.call_recorder(lambda: db_request) - monkeypatch.setattr(security_policy, "get_current_request", get_current_request) - - permits_result = Allowed("Because") - backing_policy = pretend.stub( - permits=pretend.call_recorder(lambda *a, **kw: permits_result) - ) - policy = security_policy.TwoFactorAuthorizationPolicy(policy=backing_policy) + policy = security_policy.TwoFactorSecurityPolicy() context = ProjectFactory.create( owners_require_2fa=owners_require_2fa, pypi_mandates_2fa=pypi_mandates_2fa ) - result = policy.permits(context, pretend.stub(), pretend.stub()) + result = policy.permits(db_request, context, pretend.stub()) summary = { "owners_require_2fa": ( @@ -698,17 +648,3 @@ def test_denies_if_2fa_is_required_but_user_doesnt_have_2fa( }[reason] assert result == WarehouseDenied(summary, reason="two_factor_required") - - def test_principals_allowed_by_permission(self): - principals = pretend.stub() - backing_policy = pretend.stub( - principals_allowed_by_permission=pretend.call_recorder( - lambda *a: principals - ) - ) - policy = security_policy.TwoFactorAuthorizationPolicy(policy=backing_policy) - - assert ( - policy.principals_allowed_by_permission(pretend.stub(), pretend.stub()) - is principals - ) diff --git a/tests/unit/macaroons/test_security_policy.py b/tests/unit/macaroons/test_security_policy.py index eb4e0b08e815..690d5bb3b942 100644 --- a/tests/unit/macaroons/test_security_policy.py +++ b/tests/unit/macaroons/test_security_policy.py @@ -14,8 +14,8 @@ import pretend import pytest -from pyramid.interfaces import IAuthorizationPolicy, ISecurityPolicy -from pyramid.security import Denied +from pyramid.interfaces import ISecurityPolicy +from pyramid.security import Allowed, Denied from zope.interface.verify import verifyClass from warehouse.macaroons import security_policy @@ -68,8 +68,6 @@ def test_noops(self): policy = security_policy.MacaroonSecurityPolicy() with pytest.raises(NotImplementedError): policy.authenticated_userid(pretend.stub()) - with pytest.raises(NotImplementedError): - policy.permits(pretend.stub(), pretend.stub(), pretend.stub()) def test_forget_and_remember(self): policy = security_policy.MacaroonSecurityPolicy() @@ -208,44 +206,23 @@ def test_identity_oidc_publisher(self, monkeypatch): assert add_vary_cb.calls == [pretend.call("Authorization")] assert request.add_response_callback.calls == [pretend.call(vary_cb)] - -class TestMacaroonAuthorizationPolicy: - def test_verify(self): - assert verifyClass( - IAuthorizationPolicy, security_policy.MacaroonAuthorizationPolicy - ) - def test_permits_no_active_request(self, monkeypatch): - get_current_request = pretend.call_recorder(lambda: None) - monkeypatch.setattr(security_policy, "get_current_request", get_current_request) - - backing_policy = pretend.stub( - permits=pretend.call_recorder(lambda *a, **kw: pretend.stub()) - ) - policy = security_policy.MacaroonAuthorizationPolicy(policy=backing_policy) - result = policy.permits(pretend.stub(), pretend.stub(), pretend.stub()) + policy = security_policy.MacaroonSecurityPolicy() + result = policy.permits(None, pretend.stub(), pretend.stub()) assert result == Denied("") assert result.s == "There was no active request." def test_permits_no_macaroon(self, monkeypatch): - request = pretend.stub() - get_current_request = pretend.call_recorder(lambda: request) - monkeypatch.setattr(security_policy, "get_current_request", get_current_request) - _extract_http_macaroon = pretend.call_recorder(lambda r: None) monkeypatch.setattr( security_policy, "_extract_http_macaroon", _extract_http_macaroon ) - permits = pretend.stub() - backing_policy = pretend.stub( - permits=pretend.call_recorder(lambda *a, **kw: permits) - ) - policy = security_policy.MacaroonAuthorizationPolicy(policy=backing_policy) - result = policy.permits(pretend.stub(), pretend.stub(), pretend.stub()) + policy = security_policy.MacaroonSecurityPolicy() - assert result == permits + with pytest.raises(NotImplementedError): + policy.permits(pretend.stub(), pretend.stub(), pretend.stub()) def test_permits_invalid_macaroon(self, monkeypatch): macaroon_service = pretend.stub( @@ -254,20 +231,13 @@ def test_permits_invalid_macaroon(self, monkeypatch): request = pretend.stub( find_service=pretend.call_recorder(lambda interface, **kw: macaroon_service) ) - get_current_request = pretend.call_recorder(lambda: request) - monkeypatch.setattr(security_policy, "get_current_request", get_current_request) - _extract_http_macaroon = pretend.call_recorder(lambda r: b"not a real macaroon") monkeypatch.setattr( security_policy, "_extract_http_macaroon", _extract_http_macaroon ) - permits = pretend.stub() - backing_policy = pretend.stub( - permits=pretend.call_recorder(lambda *a, **kw: permits) - ) - policy = security_policy.MacaroonAuthorizationPolicy(policy=backing_policy) - result = policy.permits(pretend.stub(), pretend.stub(), pretend.stub()) + policy = security_policy.MacaroonSecurityPolicy() + result = policy.permits(request, pretend.stub(), pretend.stub()) assert result == Denied("") assert result.s == "Invalid API Token: foo" @@ -279,26 +249,19 @@ def test_permits_valid_macaroon(self, monkeypatch): request = pretend.stub( find_service=pretend.call_recorder(lambda interface, **kw: macaroon_service) ) - get_current_request = pretend.call_recorder(lambda: request) - monkeypatch.setattr(security_policy, "get_current_request", get_current_request) - _extract_http_macaroon = pretend.call_recorder(lambda r: b"not a real macaroon") monkeypatch.setattr( security_policy, "_extract_http_macaroon", _extract_http_macaroon ) - permits = pretend.stub() - backing_policy = pretend.stub( - permits=pretend.call_recorder(lambda *a, **kw: permits) - ) - policy = security_policy.MacaroonAuthorizationPolicy(policy=backing_policy) - result = policy.permits(pretend.stub(), pretend.stub(), "upload") + policy = security_policy.MacaroonSecurityPolicy() + result = policy.permits(request, pretend.stub(), "upload") - assert result == permits + assert result == Allowed("") @pytest.mark.parametrize( "invalid_permission", - ["admin", "moderator", "manage:user", "manage:project", "nonexistant"], + ["admin", "moderator", "manage:user", "manage:project", "nonexistent"], ) def test_denies_valid_macaroon_for_incorrect_permission( self, monkeypatch, invalid_permission @@ -309,36 +272,15 @@ def test_denies_valid_macaroon_for_incorrect_permission( request = pretend.stub( find_service=pretend.call_recorder(lambda interface, **kw: macaroon_service) ) - get_current_request = pretend.call_recorder(lambda: request) - monkeypatch.setattr(security_policy, "get_current_request", get_current_request) - _extract_http_macaroon = pretend.call_recorder(lambda r: b"not a real macaroon") monkeypatch.setattr( security_policy, "_extract_http_macaroon", _extract_http_macaroon ) - permits = pretend.stub() - backing_policy = pretend.stub( - permits=pretend.call_recorder(lambda *a, **kw: permits) - ) - policy = security_policy.MacaroonAuthorizationPolicy(policy=backing_policy) - result = policy.permits(pretend.stub(), pretend.stub(), invalid_permission) + policy = security_policy.MacaroonSecurityPolicy() + result = policy.permits(request, pretend.stub(), invalid_permission) assert result == Denied("") assert result.s == ( f"API tokens are not valid for permission: {invalid_permission}!" ) - - def test_principals_allowed_by_permission(self): - principals = pretend.stub() - backing_policy = pretend.stub( - principals_allowed_by_permission=pretend.call_recorder( - lambda *a: principals - ) - ) - policy = security_policy.MacaroonAuthorizationPolicy(policy=backing_policy) - - assert ( - policy.principals_allowed_by_permission(pretend.stub(), pretend.stub()) - is principals - ) diff --git a/tests/unit/utils/test_security_policy.py b/tests/unit/utils/test_security_policy.py index 38e7d287c899..be3eb249856a 100644 --- a/tests/unit/utils/test_security_policy.py +++ b/tests/unit/utils/test_security_policy.py @@ -96,16 +96,13 @@ def test_principals_for_authenticated_user( class TestMultiSecurityPolicy: def test_initializes(self): subpolicies = pretend.stub() - authz = pretend.stub() - policy = security_policy.MultiSecurityPolicy(subpolicies, authz) + policy = security_policy.MultiSecurityPolicy(subpolicies) assert policy._policies is subpolicies - assert policy._authz is authz def test_identity_none(self): subpolicies = [pretend.stub(identity=pretend.call_recorder(lambda r: None))] - authz = pretend.stub() - policy = security_policy.MultiSecurityPolicy(subpolicies, authz) + policy = security_policy.MultiSecurityPolicy(subpolicies) request = pretend.stub() assert policy.identity(request) is None @@ -120,8 +117,7 @@ def test_identity_first_come_first_serve(self): pretend.stub(identity=pretend.call_recorder(lambda r: identity1)), pretend.stub(identity=pretend.call_recorder(lambda r: identity2)), ] - authz = pretend.stub() - policy = security_policy.MultiSecurityPolicy(subpolicies, authz) + policy = security_policy.MultiSecurityPolicy(subpolicies) request = pretend.stub() assert policy.identity(request) is identity1 @@ -132,8 +128,7 @@ def test_identity_first_come_first_serve(self): def test_authenticated_userid_no_identity(self): request = pretend.stub() subpolicies = [pretend.stub(identity=pretend.call_recorder(lambda r: None))] - authz = pretend.stub() - policy = security_policy.MultiSecurityPolicy(subpolicies, authz) + policy = security_policy.MultiSecurityPolicy(subpolicies) assert policy.authenticated_userid(request) is None assert subpolicies[0].identity.calls == [pretend.call(request)] @@ -142,8 +137,7 @@ def test_authenticated_userid_nonuser_identity(self, db_request): request = pretend.stub() nonuser = pretend.stub(id="not-a-user-instance") subpolicies = [pretend.stub(identity=pretend.call_recorder(lambda r: nonuser))] - authz = pretend.stub() - policy = security_policy.MultiSecurityPolicy(subpolicies, authz) + policy = security_policy.MultiSecurityPolicy(subpolicies) assert policy.authenticated_userid(request) is None assert subpolicies[0].identity.calls == [pretend.call(request)] @@ -152,8 +146,7 @@ def test_authenticated_userid(self, db_request): request = pretend.stub() user = UserFactory.create() subpolicies = [pretend.stub(identity=pretend.call_recorder(lambda r: user))] - authz = pretend.stub() - policy = security_policy.MultiSecurityPolicy(subpolicies, authz) + policy = security_policy.MultiSecurityPolicy(subpolicies) assert policy.authenticated_userid(request) == str(user.id) assert subpolicies[0].identity.calls == [pretend.call(request)] @@ -163,8 +156,7 @@ def test_forget(self): subpolicies = [ pretend.stub(forget=pretend.call_recorder(lambda r, **kw: [header])) ] - authz = pretend.stub() - policy = security_policy.MultiSecurityPolicy(subpolicies, authz) + policy = security_policy.MultiSecurityPolicy(subpolicies) request = pretend.stub() assert policy.forget(request, foo=None) == [header] @@ -175,8 +167,7 @@ def test_remember(self): subpolicies = [ pretend.stub(remember=pretend.call_recorder(lambda r, uid, **kw: [header])) ] - authz = pretend.stub() - policy = security_policy.MultiSecurityPolicy(subpolicies, authz) + policy = security_policy.MultiSecurityPolicy(subpolicies) request = pretend.stub() userid = pretend.stub() @@ -186,10 +177,11 @@ def test_remember(self): ] def test_permits_user(self, db_request, monkeypatch): - subpolicies = pretend.stub() status = pretend.stub() - authz = pretend.stub(permits=pretend.call_recorder(lambda *a: status)) - policy = security_policy.MultiSecurityPolicy(subpolicies, authz) + + policy = security_policy.MultiSecurityPolicy([]) + acl = pretend.stub(permits=pretend.call_recorder(lambda *a: status)) + monkeypatch.setattr(policy, "_acl", acl) principals_for_authenticated_user = pretend.call_recorder( lambda *a: ["some:principal"] @@ -205,7 +197,7 @@ def test_permits_user(self, db_request, monkeypatch): context = pretend.stub() permission = pretend.stub() assert policy.permits(request, context, permission) is status - assert authz.permits.calls == [ + assert acl.permits.calls == [ pretend.call( context, [Authenticated, f"user:{user.id}", "some:principal"], @@ -213,18 +205,35 @@ def test_permits_user(self, db_request, monkeypatch): ) ] - def test_permits_oidc_publisher(self, db_request): - subpolicies = pretend.stub() + def test_permits_subpolicy_denied(self, db_request): + def permits_raises(request, context, permission): + raise NotImplementedError + + denied = Denied("Because") + subpolicies = [ + pretend.stub(permits=permits_raises), + pretend.stub(permits=lambda r, c, p: True), + pretend.stub(permits=lambda r, c, p: denied), + ] + policy = security_policy.MultiSecurityPolicy(subpolicies) + user = UserFactory.create() + request = pretend.stub(identity=user) + context = pretend.stub() + permission = pretend.stub() + assert policy.permits(request, context, permission) is denied + + def test_permits_oidc_publisher(self, db_request, monkeypatch): status = pretend.stub() - authz = pretend.stub(permits=pretend.call_recorder(lambda *a: status)) - policy = security_policy.MultiSecurityPolicy(subpolicies, authz) + policy = security_policy.MultiSecurityPolicy([]) + acl = pretend.stub(permits=pretend.call_recorder(lambda *a: status)) + monkeypatch.setattr(policy, "_acl", acl) publisher = GitHubPublisherFactory.create() request = pretend.stub(identity=OIDCContext(publisher, None)) context = pretend.stub() permission = pretend.stub() assert policy.permits(request, context, permission) is status - assert authz.permits.calls == [ + assert acl.permits.calls == [ pretend.call( context, [Authenticated, f"oidc:{publisher.id}"], @@ -232,10 +241,10 @@ def test_permits_oidc_publisher(self, db_request): ) ] - def test_permits_nonuser_denied(self): - subpolicies = pretend.stub() - authz = pretend.stub(permits=pretend.call_recorder(lambda *a: pretend.stub())) - policy = security_policy.MultiSecurityPolicy(subpolicies, authz) + def test_permits_nonuser_denied(self, monkeypatch): + policy = security_policy.MultiSecurityPolicy([]) + acl = pretend.stub(permits=pretend.call_recorder(lambda *a: pretend.stub())) + monkeypatch.setattr(policy, "_acl", acl) # Anything that doesn't pass an isinstance check for User fakeuser = pretend.stub() @@ -243,24 +252,25 @@ def test_permits_nonuser_denied(self): context = pretend.stub() permission = pretend.stub() assert policy.permits(request, context, permission) == Denied("unimplemented") - assert authz.permits.calls == [] + assert acl.permits.calls == [] - def test_permits_no_identity(self): - subpolicies = pretend.stub() + def test_permits_no_identity(self, monkeypatch): status = pretend.stub() - authz = pretend.stub(permits=pretend.call_recorder(lambda *a: status)) - policy = security_policy.MultiSecurityPolicy(subpolicies, authz) + policy = security_policy.MultiSecurityPolicy([]) + acl = pretend.stub(permits=pretend.call_recorder(lambda *a: status)) + monkeypatch.setattr(policy, "_acl", acl) request = pretend.stub(identity=None) context = pretend.stub() permission = pretend.stub() assert policy.permits(request, context, permission) is status - assert authz.permits.calls == [pretend.call(context, [], permission)] + assert acl.permits.calls == [pretend.call(context, [], permission)] - def test_cant_use_unauthenticated_userid(self): + def test_cant_use_unauthenticated_userid(self, monkeypatch): subpolicies = pretend.stub() - authz = pretend.stub(permits=pretend.call_recorder(lambda *a: pretend.stub())) - policy = security_policy.MultiSecurityPolicy(subpolicies, authz) + policy = security_policy.MultiSecurityPolicy(subpolicies) + acl = pretend.stub(permits=pretend.call_recorder(lambda *a: pretend.stub())) + monkeypatch.setattr(policy, "_acl", acl) with pytest.raises(NotImplementedError): policy.unauthenticated_userid(pretend.stub()) diff --git a/warehouse/accounts/__init__.py b/warehouse/accounts/__init__.py index 05cc2e97518c..c01241d4c9f2 100644 --- a/warehouse/accounts/__init__.py +++ b/warehouse/accounts/__init__.py @@ -10,8 +10,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from pyramid.authorization import ACLAuthorizationPolicy - from warehouse.accounts.interfaces import ( IEmailBreachedService, IPasswordBreachedService, @@ -22,7 +20,7 @@ from warehouse.accounts.security_policy import ( BasicAuthSecurityPolicy, SessionSecurityPolicy, - TwoFactorAuthorizationPolicy, + TwoFactorSecurityPolicy, ) from warehouse.accounts.services import ( HaveIBeenPwnedEmailBreachedService, @@ -33,11 +31,8 @@ database_login_factory, ) from warehouse.admin.flags import AdminFlagValue -from warehouse.macaroons.security_policy import ( - MacaroonAuthorizationPolicy, - MacaroonSecurityPolicy, -) from warehouse.oidc.utils import OIDCContext +from warehouse.macaroons.security_policy import MacaroonSecurityPolicy from warehouse.organizations.services import IOrganizationService from warehouse.rate_limiting import IRateLimiter, RateLimit from warehouse.utils.security_policy import MultiSecurityPolicy @@ -127,18 +122,15 @@ def includeme(config): breached_email_class.create_service, IEmailBreachedService ) - # Register our security policies (AuthN + AuthZ) - authz_policy = TwoFactorAuthorizationPolicy( - policy=MacaroonAuthorizationPolicy(policy=ACLAuthorizationPolicy()) - ) + # Register our security policies. config.set_security_policy( MultiSecurityPolicy( [ SessionSecurityPolicy(), BasicAuthSecurityPolicy(), MacaroonSecurityPolicy(), + TwoFactorSecurityPolicy(), ], - authz_policy, ) ) diff --git a/warehouse/accounts/security_policy.py b/warehouse/accounts/security_policy.py index 16dc2d6775e9..6ab28b249660 100644 --- a/warehouse/accounts/security_policy.py +++ b/warehouse/accounts/security_policy.py @@ -17,8 +17,8 @@ extract_http_basic_credentials, ) from pyramid.httpexceptions import HTTPUnauthorized -from pyramid.interfaces import IAuthorizationPolicy, ISecurityPolicy -from pyramid.threadlocal import get_current_request +from pyramid.interfaces import ISecurityPolicy +from pyramid.security import Allowed from zope.interface import implementer from warehouse.accounts.interfaces import IPasswordBreachedService, IUserService @@ -229,18 +229,30 @@ def permits(self, request, context, permission): raise NotImplementedError -@implementer(IAuthorizationPolicy) -class TwoFactorAuthorizationPolicy: - def __init__(self, policy): - self.policy = policy +@implementer(ISecurityPolicy) +class TwoFactorSecurityPolicy: + """ + Security policy that enforces two-factor authentication for specific + contexts. + + Most member methods, with the exception of `permits`, are no-ops. This is + because this security policy solely checks authorization and does not + identify/authenticate users. + """ + + def identity(self, request): + return None + + def forget(self, request, **kw): + return [] + + def remember(self, request, userid, **kw): + return [] - def permits(self, context, principals, permission): - # The Pyramid API doesn't let us access the request here, so we have to pull it - # out of the thread local instead. - # TODO: Work with Pyramid devs to figure out if there is a better way to support - # the worklow we are using here or not. - request = get_current_request() + def authenticated_userid(self, request): + raise NotImplementedError + def permits(self, request, context, permission): # Our request could possibly be a None, if there isn't an active request, in # that case we're going to always deny, because without a request, we can't # determine if this request is authorized or not. @@ -249,9 +261,6 @@ def permits(self, context, principals, permission): "There was no active request.", reason="no_active_request" ) - # Check if the subpolicy permits authorization - subpolicy_permits = self.policy.permits(context, principals, permission) - # If the request is permitted by the subpolicy, check if the context is # 2FA requireable, if 2FA is indeed required, and if the user has 2FA # enabled. @@ -260,11 +269,7 @@ def permits(self, context, principals, permission): # this check for non-user identities: the only way a non-user # identity can be created is after a 2FA check on a 2FA-mandated # project. - if ( - subpolicy_permits - and isinstance(context, TwoFactorRequireable) - and request.user - ): + if isinstance(context, TwoFactorRequireable) and request.user: if ( request.registry.settings["warehouse.two_factor_requirement.enabled"] and context.owners_require_2fa @@ -297,10 +302,4 @@ def permits(self, context, principals, permission): queue="warning", ) - return subpolicy_permits - - def principals_allowed_by_permission(self, context, permission): - # We just dispatch this, because this policy doesn't restrict what - # principals are allowed by a particular permission, it just restricts - # specific requests to not have that permission. - return self.policy.principals_allowed_by_permission(context, permission) + return Allowed("Two factor requirements fulfilled") diff --git a/warehouse/macaroons/security_policy.py b/warehouse/macaroons/security_policy.py index 0ec3872cf0aa..b4141ed3f9eb 100644 --- a/warehouse/macaroons/security_policy.py +++ b/warehouse/macaroons/security_policy.py @@ -12,8 +12,8 @@ import base64 -from pyramid.interfaces import IAuthorizationPolicy, ISecurityPolicy -from pyramid.threadlocal import get_current_request +from pyramid.interfaces import ISecurityPolicy +from pyramid.security import Allowed from zope.interface import implementer from warehouse.cache.http import add_vary_callback @@ -122,22 +122,6 @@ def authenticated_userid(self, request): raise NotImplementedError def permits(self, request, context, permission): - # Handled by MultiSecurityPolicy - raise NotImplementedError - - -@implementer(IAuthorizationPolicy) -class MacaroonAuthorizationPolicy: - def __init__(self, policy): - self.policy = policy - - def permits(self, context, principals, permission): - # The Pyramid API doesn't let us access the request here, so we have to pull it - # out of the thread local instead. - # TODO: Work with Pyramid devs to figure out if there is a better way to support - # the worklow we are using here or not. - request = get_current_request() - # Our request could possibly be a None, if there isn't an active request, in # that case we're going to always deny, because without a request, we can't # determine if this request is authorized or not. @@ -165,11 +149,11 @@ def permits(self, context, principals, permission): f"Invalid API Token: {exc}", reason="invalid_api_token" ) - # If our Macaroon is verified, and for a valid permission then we'll pass - # this request to our underlying Authorization policy, so it can handle its - # own authorization logic on the principal. + # If our Macaroon is verified, and for a valid permission then we'll + # Allow the request, letting MultiSecurityPolicy handle the rest of + # the authorization logic against the principal. if permission in valid_permissions: - return self.policy.permits(context, principals, permission) + return Allowed("API token validated") else: return WarehouseDenied( f"API tokens are not valid for permission: {permission}!", @@ -177,10 +161,6 @@ def permits(self, context, principals, permission): ) else: - return self.policy.permits(context, principals, permission) - - def principals_allowed_by_permission(self, context, permission): - # We just dispatch this, because Macaroons don't restrict what principals are - # allowed by a particular permission, they just restrict specific requests - # to not have that permission. - return self.policy.principals_allowed_by_permission(context, permission) + # We can't pass judgement on requests that don't have associated + # macaroons. + raise NotImplementedError diff --git a/warehouse/utils/security_policy.py b/warehouse/utils/security_policy.py index 4bb3c7c6150d..94e86169ebb7 100644 --- a/warehouse/utils/security_policy.py +++ b/warehouse/utils/security_policy.py @@ -12,7 +12,7 @@ import enum -from pyramid.authorization import Authenticated +from pyramid.authorization import ACLHelper, Authenticated from pyramid.interfaces import ISecurityPolicy from pyramid.security import Denied from zope.interface import implementer @@ -62,9 +62,9 @@ class MultiSecurityPolicy: These semantics mostly mirror those of `pyramid-multiauth`. """ - def __init__(self, policies, authz): + def __init__(self, policies): self._policies = policies - self._authz = authz + self._acl = ACLHelper() def identity(self, request): for policy in self._policies: @@ -96,6 +96,19 @@ def remember(self, request, userid, **kw): return headers def permits(self, request, context, permission): + # First, check if any subpolicy denies the request. + for policy in self._policies: + try: + if not (permits := policy.permits(request, context, permission)): + return permits + except NotImplementedError: + # Raised when a subpolicy does not support a given request. e.g. + # `MacaroonSecurityPolicy` being handed a non-macaroon request. + # If a subpolicy raises this, we don't treat it as an explicit + # permit or reject decision, just pass and check the next policy. + pass + + # Next, construct a list of principals from our request. identity = request.identity principals = [] if identity is not None: @@ -109,9 +122,5 @@ def permits(self, request, context, permission): else: return Denied("unknown identity") - # NOTE: Observe that the parameters passed into the underlying AuthZ - # policy here are not the same (or in the same order) as the ones - # passed into `permits` above. This is because the underlying AuthZ - # policy is a "legacy" Pyramid 1.0 style one that implements the - # `IAuthorizationPolicy` interface rather than `ISecurityPolicy`. - return self._authz.permits(context, principals, permission) + # Finally, check the principals against the context. + return self._acl.permits(context, principals, permission) From 58440fe9ce37e606d0c91a839ebf9fe743473792 Mon Sep 17 00:00:00 2001 From: Andrew Pan Date: Thu, 25 May 2023 17:23:23 -0500 Subject: [PATCH 02/18] lints Signed-off-by: Andrew Pan --- tests/unit/accounts/test_security_policy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/accounts/test_security_policy.py b/tests/unit/accounts/test_security_policy.py index adf5f91f1e54..3bd821ecf55d 100644 --- a/tests/unit/accounts/test_security_policy.py +++ b/tests/unit/accounts/test_security_policy.py @@ -517,7 +517,7 @@ def test_verify(self): def test_noops(self): policy = security_policy.TwoFactorSecurityPolicy() - assert policy.identity(pretend.stub()) == None + assert policy.identity(pretend.stub()) is None assert policy.forget(pretend.stub()) == [] assert policy.remember(pretend.stub(), pretend.stub()) == [] From d55b08a9fd84feb636ece65f53cb9ea6f1c7fce8 Mon Sep 17 00:00:00 2001 From: Andrew Pan Date: Fri, 26 May 2023 12:30:51 -0500 Subject: [PATCH 03/18] test_security_policy: more robust multi tests Signed-off-by: Andrew Pan --- tests/unit/utils/test_security_policy.py | 74 +++++++++++++++++++----- 1 file changed, 60 insertions(+), 14 deletions(-) diff --git a/tests/unit/utils/test_security_policy.py b/tests/unit/utils/test_security_policy.py index be3eb249856a..0299791de282 100644 --- a/tests/unit/utils/test_security_policy.py +++ b/tests/unit/utils/test_security_policy.py @@ -14,7 +14,7 @@ import pytest from pyramid.authorization import Authenticated -from pyramid.security import Denied +from pyramid.security import Allowed, Denied from warehouse.oidc.utils import OIDCContext from warehouse.utils import security_policy @@ -205,22 +205,68 @@ def test_permits_user(self, db_request, monkeypatch): ) ] - def test_permits_subpolicy_denied(self, db_request): - def permits_raises(request, context, permission): + class SubPolicyHelpers: + @staticmethod + def subpolicy(fn): + return pretend.stub(permits=fn) + + @subpolicy + @staticmethod + def raises(a, b, c): raise NotImplementedError - denied = Denied("Because") - subpolicies = [ - pretend.stub(permits=permits_raises), - pretend.stub(permits=lambda r, c, p: True), - pretend.stub(permits=lambda r, c, p: denied), - ] - policy = security_policy.MultiSecurityPolicy(subpolicies) + @subpolicy + @staticmethod + def passes(a, b, c): + return Allowed("subpolicy") + + @subpolicy + @staticmethod + def denies(a, b, c): + return Denied("subpolicy") + + @pytest.mark.parametrize( + ("policies", "expected"), + [ + ([], None), + ([SubPolicyHelpers.raises], None), + ([SubPolicyHelpers.denies], Denied("subpolicy")), + ([SubPolicyHelpers.passes], None), + ( + [ + SubPolicyHelpers.raises, + SubPolicyHelpers.passes, + SubPolicyHelpers.passes, + ], + None, + ), + ([SubPolicyHelpers.passes, SubPolicyHelpers.denies], Denied("subpolicy")), + ( + [ + SubPolicyHelpers.raises, + SubPolicyHelpers.passes, + SubPolicyHelpers.denies, + ], + Denied("subpolicy"), + ), + ], + ) + def test_permits_subpolicies(self, db_request, monkeypatch, policies, expected): + status = pretend.stub(s="") + policy = security_policy.MultiSecurityPolicy(policies) + acl = pretend.stub(permits=pretend.call_recorder(lambda *a: status)) + monkeypatch.setattr(policy, "_acl", acl) user = UserFactory.create() - request = pretend.stub(identity=user) - context = pretend.stub() - permission = pretend.stub() - assert policy.permits(request, context, permission) is denied + + # A parameter of None indicates that the ACL value should be passed through. + if expected is None: + expected = status + + result = policy.permits( + pretend.stub(identity=user), pretend.stub(), pretend.stub() + ) + assert result == expected + assert result.s == expected.s def test_permits_oidc_publisher(self, db_request, monkeypatch): status = pretend.stub() From d2ac6c6179d2fdae185f1f8e45ff621b5ca7baaf Mon Sep 17 00:00:00 2001 From: Donald Stufft Date: Fri, 2 Jun 2023 23:44:20 -0400 Subject: [PATCH 04/18] use the security policy that provided the identity for permits --- warehouse/accounts/models.py | 21 +++++- warehouse/accounts/security_policy.py | 32 +++++++-- warehouse/macaroons/security_policy.py | 70 ++++++++++--------- warehouse/oidc/utils.py | 4 ++ warehouse/utils/security_policy.py | 93 ++++++++++++-------------- 5 files changed, 131 insertions(+), 89 deletions(-) diff --git a/warehouse/accounts/models.py b/warehouse/accounts/models.py index 08c464bd1950..d41bc56375a6 100644 --- a/warehouse/accounts/models.py +++ b/warehouse/accounts/models.py @@ -14,7 +14,7 @@ import enum from citext import CIText -from pyramid.authorization import Allow +from pyramid.authorization import Authenticated, Allow from sqlalchemy import ( Boolean, CheckConstraint, @@ -192,6 +192,25 @@ def can_reset_password(self): ] ) + def __principals__(self) -> list[str]: + principals = [Authenticated, f"user:{self.id}"] + + if self.is_superuser: + principals.append("group:admins") + if self.is_moderator or self.is_superuser: + principals.append("group:moderators") + if self.is_psf_staff or self.is_superuser: + principals.append("group:psf_staff") + + # user must have base admin access if any admin permission + # TODO: This feels wrong? Why are we using a group to add dashboard + # access and not a permission? Why are we adding it to every + # user? Something is weird, and we should figure it out. + if principals: + principals.append("group:with_admin_dashboard_access") + + return principals + def __acl__(self): return [ (Allow, "group:admins", "admin"), diff --git a/warehouse/accounts/security_policy.py b/warehouse/accounts/security_policy.py index 6ab28b249660..913045cddbec 100644 --- a/warehouse/accounts/security_policy.py +++ b/warehouse/accounts/security_policy.py @@ -16,13 +16,14 @@ SessionAuthenticationHelper, extract_http_basic_credentials, ) +from pyramid.authorization import ACLHelper, Authenticated from pyramid.httpexceptions import HTTPUnauthorized from pyramid.interfaces import ISecurityPolicy from pyramid.security import Allowed from zope.interface import implementer from warehouse.accounts.interfaces import IPasswordBreachedService, IUserService -from warehouse.accounts.models import DisableReason +from warehouse.accounts.models import DisableReason, User from warehouse.cache.http import add_vary_callback from warehouse.email import send_password_compromised_email_hibp from warehouse.errors import ( @@ -33,7 +34,7 @@ ) from warehouse.events.tags import EventTag from warehouse.packaging.models import TwoFactorRequireable -from warehouse.utils.security_policy import AuthenticationMethod +from warehouse.utils.security_policy import AuthenticationMethod, principals_for def _format_exc_status(exc, message): @@ -54,6 +55,7 @@ def _basic_auth_check(username, password, request): breach_service = request.find_service(IPasswordBreachedService, context=None) userid = login_service.find_userid(username) + # TODO: Why are we stashing this here? request._unauthenticated_userid = userid if userid is not None: user = login_service.get_user(userid) @@ -124,6 +126,7 @@ def _basic_auth_check(username, password, request): class SessionSecurityPolicy: def __init__(self): self._session_helper = SessionAuthenticationHelper() + self._acl = ACLHelper() def identity(self, request): # If we're calling into this API on a request, then we want to register @@ -144,6 +147,7 @@ def identity(self, request): return None userid = self._session_helper.authenticated_userid(request) + # TODO: Why are we stashing this here? request._unauthenticated_userid = userid if userid is None: @@ -184,12 +188,20 @@ def authenticated_userid(self, request): raise NotImplementedError def permits(self, request, context, permission): - # Handled by MultiSecurityPolicy - raise NotImplementedError + # It should only be possible for request.identity to be a User object + # at this point, and we only a User in these policies. + assert isinstance(request.identity, User) + + # Dispatch to our ACL + # NOTE: These parameters are in a different order + return self._acl.permits(context, principals_for(request.identity), permission) @implementer(ISecurityPolicy) class BasicAuthSecurityPolicy: + def __init__(self): + self._acl = ACLHelper() + def identity(self, request): # If we're calling into this API on a request, then we want to register # a callback which will ensure that the response varies based on the @@ -225,8 +237,16 @@ def authenticated_userid(self, request): raise NotImplementedError def permits(self, request, context, permission): - # Handled by MultiSecurityPolicy - raise NotImplementedError + # It should only be possible for request.identity to be a User object + # at this point, and we only a User in these policies. + assert isinstance(request.identity, User) + + # Lookup the principals that should exist for our current user. + principals = principals_for(request.identity) + + # Dispatch to our ACL + # NOTE: These parameters are in a different order + return self._acl.permits(context, principals_for(request.identity), permission) @implementer(ISecurityPolicy) diff --git a/warehouse/macaroons/security_policy.py b/warehouse/macaroons/security_policy.py index b4141ed3f9eb..0359a34dd0cb 100644 --- a/warehouse/macaroons/security_policy.py +++ b/warehouse/macaroons/security_policy.py @@ -12,8 +12,8 @@ import base64 +from pyramid.authorization import ACLHelper from pyramid.interfaces import ISecurityPolicy -from pyramid.security import Allowed from zope.interface import implementer from warehouse.cache.http import add_vary_callback @@ -21,7 +21,7 @@ from warehouse.macaroons import InvalidMacaroonError from warehouse.macaroons.interfaces import IMacaroonService from warehouse.oidc.utils import OIDCContext -from warehouse.utils.security_policy import AuthenticationMethod +from warehouse.utils.security_policy import AuthenticationMethod, principals_for def _extract_basic_macaroon(auth): @@ -73,6 +73,9 @@ def _extract_http_macaroon(request): @implementer(ISecurityPolicy) class MacaroonSecurityPolicy: + def __init__(self): + self._acl = ACLHelper() + def identity(self, request): # If we're calling into this API on a request, then we want to register # a callback which will ensure that the response varies based on the @@ -125,6 +128,8 @@ def permits(self, request, context, permission): # Our request could possibly be a None, if there isn't an active request, in # that case we're going to always deny, because without a request, we can't # determine if this request is authorized or not. + # TODO: This is weird? This feels like it comes from the old AuthZ policies + # which didn't have access to the request. if request is None: return WarehouseDenied( "There was no active request.", reason="no_active_request" @@ -135,32 +140,35 @@ def permits(self, request, context, permission): # a principal-- which doesn't seem to be the right fit for it. macaroon = _extract_http_macaroon(request) - # This logic will only happen on requests that are being authenticated with - # Macaroons. Any other request will just fall back to the standard Authorization - # policy. - if macaroon is not None: - valid_permissions = ["upload"] - macaroon_service = request.find_service(IMacaroonService, context=None) - - try: - macaroon_service.verify(macaroon, request, context, permission) - except InvalidMacaroonError as exc: - return WarehouseDenied( - f"Invalid API Token: {exc}", reason="invalid_api_token" - ) - - # If our Macaroon is verified, and for a valid permission then we'll - # Allow the request, letting MultiSecurityPolicy handle the rest of - # the authorization logic against the principal. - if permission in valid_permissions: - return Allowed("API token validated") - else: - return WarehouseDenied( - f"API tokens are not valid for permission: {permission}!", - reason="invalid_permission", - ) - - else: - # We can't pass judgement on requests that don't have associated - # macaroons. - raise NotImplementedError + # It should not be possible to *not* have a macaroon at this point, because we + # can't call this function without an identity that came from a macaroon + assert isinstance(macaroon, str), "no valid macaroon" + + # Check to make sure that the permission we're attempting to permit is one that + # is allowed to be used for macaroons. + # TODO: This should be moved out of there and into the acaroons themselves, it + # doesn't really make a lot of sense here and it makes things more + # complicated if we want to allow the use of macaroons for actions other + # than uploading. + if permission not in ["upload"]: + return WarehouseDenied( + f"API tokens are not valid for permission: {permission}!", + reason="invalid_permission", + ) + + # Check if our macaroon itself is valid. This does not actually check if the + # identity bound to that macaroon has permission to do what it's trying to do + # but rather that the caveats embedded into the macaroon are valid for the given + # request, context, and permission. + macaroon_service = request.find_service(IMacaroonService, context=None) + try: + macaroon_service.verify(macaroon, request, context, permission) + except InvalidMacaroonError as exc: + return WarehouseDenied( + f"Invalid API Token: {exc}", reason="invalid_api_token" + ) + + # The macaroon is valid, so we can actually see if request.identity is + # authorized now or not. + # NOTE: These parameters are in a different order + return self._acl.permits(context, principals_for(request.identity), permission) diff --git a/warehouse/oidc/utils.py b/warehouse/oidc/utils.py index c5e2f3d701aa..0b2ad261210e 100644 --- a/warehouse/oidc/utils.py +++ b/warehouse/oidc/utils.py @@ -14,6 +14,7 @@ from dataclasses import dataclass +from pyramid.authorization import Authenticated from sqlalchemy.sql.expression import func, literal from warehouse.oidc.interfaces import SignedClaims @@ -117,3 +118,6 @@ class OIDCContext: """ Pertinent OIDC claims from the token, if they exist. """ + + def __principals__(self) -> list[str]: + principals = [Authenticated, f"oidc:{self.publisher.id}"] diff --git a/warehouse/utils/security_policy.py b/warehouse/utils/security_policy.py index 94e86169ebb7..043b1d86cf26 100644 --- a/warehouse/utils/security_policy.py +++ b/warehouse/utils/security_policy.py @@ -14,6 +14,7 @@ from pyramid.authorization import ACLHelper, Authenticated from pyramid.interfaces import ISecurityPolicy +from pyramid.request import RequestLocalCache from pyramid.security import Denied from zope.interface import implementer @@ -21,29 +22,20 @@ from warehouse.oidc.utils import OIDCContext +# NOTE: Is there a better place for this to live? It may not even need to exist +# since it's so small, it may be easier to just inline it. +def principals_for(identity) -> list[str]: + if hasattr(identity, "__principals__"): + return identity.__principals__() + return [] + + class AuthenticationMethod(enum.Enum): BASIC_AUTH = "basic-auth" SESSION = "session" MACAROON = "macaroon" -def _principals_for_authenticated_user(user): - """Apply the necessary principals to the authenticated user""" - principals = [] - if user.is_superuser: - principals.append("group:admins") - if user.is_moderator or user.is_superuser: - principals.append("group:moderators") - if user.is_psf_staff or user.is_superuser: - principals.append("group:psf_staff") - - # user must have base admin access if any admin permission - if principals: - principals.append("group:with_admin_dashboard_access") - - return principals - - @implementer(ISecurityPolicy) class MultiSecurityPolicy: """ @@ -54,27 +46,41 @@ class MultiSecurityPolicy: with the following semantics: * `identity`: Selected from the first policy to return non-`None` - * `authenticated_userid`: Selected from the first policy to return a user + * `authenticated_userid`: Selected from the first policy to return an identity * `forget`: Combined from all policies * `remember`: Combined from all policies - * `permits`: Uses the AuthZ policy passed during initialization + * `permits`: Uses the the policy that returned the identity. These semantics mostly mirror those of `pyramid-multiauth`. """ def __init__(self, policies): self._policies = policies - self._acl = ACLHelper() + self._identity_cache = RequestLocalCache(self._get_identity_with_policy) - def identity(self, request): + def _get_identity_with_policy(self, request): + # This will be cached per request, which means that we'll have a stable + # result for both the identity AND the policy that produced it. Further + # uses can then make sure to use the same policy throughout, at least + # where it makes sense to. for policy in self._policies: if ident := policy.identity(request): - return ident + return ident, policy - return None + return None, None + + def identity(self, request): + identity, _policy = self._identity_cache.get_or_create(request) + return identity def authenticated_userid(self, request): if ident := self.identity(request): + # TODO: Note, this logic breaks the contract of a SecurityPolicy, the + # authenticated_userid is intended to be used to fetch the unique + # identifier that represents the current identity. We're leaving + # it here for now, because there are a number of views directly + # using this to detect user vs not, which we'll need to move to a + # more correct pattern before fixing this. if isinstance(ident, User): return str(ident.id) return None @@ -96,31 +102,16 @@ def remember(self, request, userid, **kw): return headers def permits(self, request, context, permission): - # First, check if any subpolicy denies the request. - for policy in self._policies: - try: - if not (permits := policy.permits(request, context, permission)): - return permits - except NotImplementedError: - # Raised when a subpolicy does not support a given request. e.g. - # `MacaroonSecurityPolicy` being handed a non-macaroon request. - # If a subpolicy raises this, we don't treat it as an explicit - # permit or reject decision, just pass and check the next policy. - pass - - # Next, construct a list of principals from our request. - identity = request.identity - principals = [] - if identity is not None: - principals.append(Authenticated) - - if isinstance(identity, User): - principals.append(f"user:{identity.id}") - principals.extend(_principals_for_authenticated_user(identity)) - elif isinstance(identity, OIDCContext): - principals.append(f"oidc:{identity.publisher.id}") - else: - return Denied("unknown identity") - - # Finally, check the principals against the context. - return self._acl.permits(context, principals, permission) + identity, policy = self._identity_cache.get_or_create(request) + # Sanity check that somehow our cached identity + policy didn't end up + # different than what the request.identity is. This shouldn't be possible + # but we'll assert it because if we let it pass silently it may mean that + # some kind of confused-deputy attack is possible. + assert request.identity == identity, "request has a different identity" + + # Dispatch to the underlying policy for the given identity, if there was one + # for this request. + if policy is not None: + return policy.permits(request, context, permission) + else: + return Denied("unknown identity") From 30cfe8b25e671ae24b13359b0354afbaca09f123 Mon Sep 17 00:00:00 2001 From: Donald Stufft Date: Sat, 3 Jun 2023 00:07:28 -0400 Subject: [PATCH 05/18] reimplement the 2FA mandate --- warehouse/accounts/security_policy.py | 146 ++++++++++++-------------- 1 file changed, 69 insertions(+), 77 deletions(-) diff --git a/warehouse/accounts/security_policy.py b/warehouse/accounts/security_policy.py index 913045cddbec..f607dfabcc64 100644 --- a/warehouse/accounts/security_policy.py +++ b/warehouse/accounts/security_policy.py @@ -194,7 +194,17 @@ def permits(self, request, context, permission): # Dispatch to our ACL # NOTE: These parameters are in a different order - return self._acl.permits(context, principals_for(request.identity), permission) + res = self._acl.permits(context, principals_for(request.identity), permission) + + # If our underlying permits allowed this, we will check our 2FA status, + # that might possibly return a reason to deny the request anyways, and if + # it does we'll return that. + if isinstance(res, Allowed): + mfa = _check_for_mfa(request, context) + if mfa is not None: + return mfa + + return res @implementer(ISecurityPolicy) @@ -241,85 +251,67 @@ def permits(self, request, context, permission): # at this point, and we only a User in these policies. assert isinstance(request.identity, User) - # Lookup the principals that should exist for our current user. - principals = principals_for(request.identity) - # Dispatch to our ACL # NOTE: These parameters are in a different order - return self._acl.permits(context, principals_for(request.identity), permission) - - -@implementer(ISecurityPolicy) -class TwoFactorSecurityPolicy: - """ - Security policy that enforces two-factor authentication for specific - contexts. - - Most member methods, with the exception of `permits`, are no-ops. This is - because this security policy solely checks authorization and does not - identify/authenticate users. - """ - - def identity(self, request): - return None - - def forget(self, request, **kw): - return [] - - def remember(self, request, userid, **kw): - return [] - - def authenticated_userid(self, request): - raise NotImplementedError - - def permits(self, request, context, permission): - # Our request could possibly be a None, if there isn't an active request, in - # that case we're going to always deny, because without a request, we can't - # determine if this request is authorized or not. - if request is None: + res = self._acl.permits(context, principals_for(request.identity), permission) + + # If our underlying permits allowed this, we will check our 2FA status, + # that might possibly return a reason to deny the request anyways, and if + # it does we'll return that. + if isinstance(res, Allowed): + mfa = _check_for_mfa(request, context) + if mfa is not None: + return mfa + + return res + + +def _check_for_mfa(request, context) -> WarehouseDenied | None: + # It should only be possible for request.identity to be a User object + # at this point, and we only a User in these policies. + assert isinstance(request.identity, User) + + # Check if the context is 2FA requireable, if 2FA is indeed required, and if + # the user has 2FA enabled. + if isinstance(context, TwoFactorRequireable): + # Check if we allow owners to require 2FA, and if so does our context owner + # require 2FA? And if so does our user have 2FA? + if ( + request.registry.settings["warehouse.two_factor_requirement.enabled"] + and context.owners_require_2fa + and not request.identity.has_two_factor + ): return WarehouseDenied( - "There was no active request.", reason="no_active_request" + "This project requires two factor authentication to be enabled " + "for all contributors.", + reason="owners_require_2fa", ) - # If the request is permitted by the subpolicy, check if the context is - # 2FA requireable, if 2FA is indeed required, and if the user has 2FA - # enabled. - # - # We check for `request.user` explicitly because we don't perform - # this check for non-user identities: the only way a non-user - # identity can be created is after a 2FA check on a 2FA-mandated - # project. - if isinstance(context, TwoFactorRequireable) and request.user: - if ( - request.registry.settings["warehouse.two_factor_requirement.enabled"] - and context.owners_require_2fa - and not request.user.has_two_factor - ): - return WarehouseDenied( - "This project requires two factor authentication to be enabled " - "for all contributors.", - reason="owners_require_2fa", - ) - if ( - request.registry.settings["warehouse.two_factor_mandate.enabled"] - and context.pypi_mandates_2fa - and not request.user.has_two_factor - ): - return WarehouseDenied( - "PyPI requires two factor authentication to be enabled " - "for all contributors to this project.", - reason="pypi_mandates_2fa", - ) - if ( - request.registry.settings["warehouse.two_factor_mandate.available"] - and context.pypi_mandates_2fa - and not request.user.has_two_factor - ): - request.session.flash( - "This project is included in PyPI's two-factor mandate " - "for critical projects. In the future, you will be unable to " - "perform this action without enabling 2FA for your account", - queue="warning", - ) + # Check if PyPI is enforcing the 2FA mandate on "critical" projects, and if it + # is does our current context require it, and if it does, does our user have + # 2FA? + if ( + request.registry.settings["warehouse.two_factor_mandate.enabled"] + and context.pypi_mandates_2fa + and not request.identity.has_two_factor + ): + return WarehouseDenied( + "PyPI requires two factor authentication to be enabled " + "for all contributors to this project.", + reason="pypi_mandates_2fa", + ) - return Allowed("Two factor requirements fulfilled") + # Check if PyPI's 2FA mandate is available, but not enforcing, and if it is and + # the current context would require 2FA, and if our user doesn't have have 2FA + # then we'll flash a warning. + if ( + request.registry.settings["warehouse.two_factor_mandate.available"] + and context.pypi_mandates_2fa + and not request.user.has_two_factor + ): + request.session.flash( + "This project is included in PyPI's two-factor mandate " + "for critical projects. In the future, you will be unable to " + "perform this action without enabling 2FA for your account", + queue="warning", + ) From ffeefdc966ff67899f2a6d194e1f0a35defcc9d8 Mon Sep 17 00:00:00 2001 From: Donald Stufft Date: Sat, 3 Jun 2023 00:10:48 -0400 Subject: [PATCH 06/18] refactor common permits logic into a mixin --- warehouse/accounts/security_policy.py | 63 ++++++++++----------------- 1 file changed, 23 insertions(+), 40 deletions(-) diff --git a/warehouse/accounts/security_policy.py b/warehouse/accounts/security_policy.py index f607dfabcc64..2da63c27b4da 100644 --- a/warehouse/accounts/security_policy.py +++ b/warehouse/accounts/security_policy.py @@ -122,8 +122,29 @@ def _basic_auth_check(username, password, request): return False +class UserSecurityPolicyMixin: + def permits(self, request, context, permission): + # It should only be possible for request.identity to be a User object + # at this point, and we only a User in these policies. + assert isinstance(request.identity, User) + + # Dispatch to our ACL + # NOTE: These parameters are in a different order + res = self._acl.permits(context, principals_for(request.identity), permission) + + # If our underlying permits allowed this, we will check our 2FA status, + # that might possibly return a reason to deny the request anyways, and if + # it does we'll return that. + if isinstance(res, Allowed): + mfa = _check_for_mfa(request, context) + if mfa is not None: + return mfa + + return res + + @implementer(ISecurityPolicy) -class SessionSecurityPolicy: +class SessionSecurityPolicy(UserSecurityPolicyMixin): def __init__(self): self._session_helper = SessionAuthenticationHelper() self._acl = ACLHelper() @@ -187,28 +208,9 @@ def authenticated_userid(self, request): # Handled by MultiSecurityPolicy raise NotImplementedError - def permits(self, request, context, permission): - # It should only be possible for request.identity to be a User object - # at this point, and we only a User in these policies. - assert isinstance(request.identity, User) - - # Dispatch to our ACL - # NOTE: These parameters are in a different order - res = self._acl.permits(context, principals_for(request.identity), permission) - - # If our underlying permits allowed this, we will check our 2FA status, - # that might possibly return a reason to deny the request anyways, and if - # it does we'll return that. - if isinstance(res, Allowed): - mfa = _check_for_mfa(request, context) - if mfa is not None: - return mfa - - return res - @implementer(ISecurityPolicy) -class BasicAuthSecurityPolicy: +class BasicAuthSecurityPolicy(UserSecurityPolicyMixin): def __init__(self): self._acl = ACLHelper() @@ -246,25 +248,6 @@ def authenticated_userid(self, request): # Handled by MultiSecurityPolicy raise NotImplementedError - def permits(self, request, context, permission): - # It should only be possible for request.identity to be a User object - # at this point, and we only a User in these policies. - assert isinstance(request.identity, User) - - # Dispatch to our ACL - # NOTE: These parameters are in a different order - res = self._acl.permits(context, principals_for(request.identity), permission) - - # If our underlying permits allowed this, we will check our 2FA status, - # that might possibly return a reason to deny the request anyways, and if - # it does we'll return that. - if isinstance(res, Allowed): - mfa = _check_for_mfa(request, context) - if mfa is not None: - return mfa - - return res - def _check_for_mfa(request, context) -> WarehouseDenied | None: # It should only be possible for request.identity to be a User object From d5ad51e5be57971450c535f50c5b4b31b602d905 Mon Sep 17 00:00:00 2001 From: Donald Stufft Date: Sat, 3 Jun 2023 00:14:39 -0400 Subject: [PATCH 07/18] function is just as good here and simpler --- warehouse/accounts/security_policy.py | 51 +++++++++++++++------------ 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/warehouse/accounts/security_policy.py b/warehouse/accounts/security_policy.py index 2da63c27b4da..a7885a3687c0 100644 --- a/warehouse/accounts/security_policy.py +++ b/warehouse/accounts/security_policy.py @@ -122,29 +122,8 @@ def _basic_auth_check(username, password, request): return False -class UserSecurityPolicyMixin: - def permits(self, request, context, permission): - # It should only be possible for request.identity to be a User object - # at this point, and we only a User in these policies. - assert isinstance(request.identity, User) - - # Dispatch to our ACL - # NOTE: These parameters are in a different order - res = self._acl.permits(context, principals_for(request.identity), permission) - - # If our underlying permits allowed this, we will check our 2FA status, - # that might possibly return a reason to deny the request anyways, and if - # it does we'll return that. - if isinstance(res, Allowed): - mfa = _check_for_mfa(request, context) - if mfa is not None: - return mfa - - return res - - @implementer(ISecurityPolicy) -class SessionSecurityPolicy(UserSecurityPolicyMixin): +class SessionSecurityPolicy: def __init__(self): self._session_helper = SessionAuthenticationHelper() self._acl = ACLHelper() @@ -208,9 +187,12 @@ def authenticated_userid(self, request): # Handled by MultiSecurityPolicy raise NotImplementedError + def permits(self, request, context, permission): + return _permits_for_user_policy(self._acl, request, context, permission) + @implementer(ISecurityPolicy) -class BasicAuthSecurityPolicy(UserSecurityPolicyMixin): +class BasicAuthSecurityPolicy: def __init__(self): self._acl = ACLHelper() @@ -248,6 +230,29 @@ def authenticated_userid(self, request): # Handled by MultiSecurityPolicy raise NotImplementedError + def permits(self, request, context, permission): + return _permits_for_user_policy(self._acl, request, context, permission) + + +def _permits_for_user_policy(acl, request, context, permission): + # It should only be possible for request.identity to be a User object + # at this point, and we only a User in these policies. + assert isinstance(request.identity, User) + + # Dispatch to our ACL + # NOTE: These parameters are in a different order + res = acl.permits(context, principals_for(request.identity), permission) + + # If our underlying permits allowed this, we will check our 2FA status, + # that might possibly return a reason to deny the request anyways, and if + # it does we'll return that. + if isinstance(res, Allowed): + mfa = _check_for_mfa(request, context) + if mfa is not None: + return mfa + + return res + def _check_for_mfa(request, context) -> WarehouseDenied | None: # It should only be possible for request.identity to be a User object From 1ed7b6370649adb74b8daf5d0370ddf9c0dd349a Mon Sep 17 00:00:00 2001 From: Donald Stufft Date: Sat, 3 Jun 2023 00:26:13 -0400 Subject: [PATCH 08/18] fixes --- warehouse/accounts/__init__.py | 4 +--- warehouse/accounts/models.py | 2 +- warehouse/accounts/security_policy.py | 4 +++- warehouse/oidc/utils.py | 2 +- warehouse/utils/security_policy.py | 2 -- 5 files changed, 6 insertions(+), 8 deletions(-) diff --git a/warehouse/accounts/__init__.py b/warehouse/accounts/__init__.py index c01241d4c9f2..9667b3a064c9 100644 --- a/warehouse/accounts/__init__.py +++ b/warehouse/accounts/__init__.py @@ -20,7 +20,6 @@ from warehouse.accounts.security_policy import ( BasicAuthSecurityPolicy, SessionSecurityPolicy, - TwoFactorSecurityPolicy, ) from warehouse.accounts.services import ( HaveIBeenPwnedEmailBreachedService, @@ -31,8 +30,8 @@ database_login_factory, ) from warehouse.admin.flags import AdminFlagValue -from warehouse.oidc.utils import OIDCContext from warehouse.macaroons.security_policy import MacaroonSecurityPolicy +from warehouse.oidc.utils import OIDCContext from warehouse.organizations.services import IOrganizationService from warehouse.rate_limiting import IRateLimiter, RateLimit from warehouse.utils.security_policy import MultiSecurityPolicy @@ -129,7 +128,6 @@ def includeme(config): SessionSecurityPolicy(), BasicAuthSecurityPolicy(), MacaroonSecurityPolicy(), - TwoFactorSecurityPolicy(), ], ) ) diff --git a/warehouse/accounts/models.py b/warehouse/accounts/models.py index d41bc56375a6..d0df5795fb40 100644 --- a/warehouse/accounts/models.py +++ b/warehouse/accounts/models.py @@ -14,7 +14,7 @@ import enum from citext import CIText -from pyramid.authorization import Authenticated, Allow +from pyramid.authorization import Allow, Authenticated from sqlalchemy import ( Boolean, CheckConstraint, diff --git a/warehouse/accounts/security_policy.py b/warehouse/accounts/security_policy.py index a7885a3687c0..9f5cdddd0640 100644 --- a/warehouse/accounts/security_policy.py +++ b/warehouse/accounts/security_policy.py @@ -16,7 +16,7 @@ SessionAuthenticationHelper, extract_http_basic_credentials, ) -from pyramid.authorization import ACLHelper, Authenticated +from pyramid.authorization import ACLHelper from pyramid.httpexceptions import HTTPUnauthorized from pyramid.interfaces import ISecurityPolicy from pyramid.security import Allowed @@ -303,3 +303,5 @@ def _check_for_mfa(request, context) -> WarehouseDenied | None: "perform this action without enabling 2FA for your account", queue="warning", ) + + return None diff --git a/warehouse/oidc/utils.py b/warehouse/oidc/utils.py index 0b2ad261210e..d692adb3867d 100644 --- a/warehouse/oidc/utils.py +++ b/warehouse/oidc/utils.py @@ -120,4 +120,4 @@ class OIDCContext: """ def __principals__(self) -> list[str]: - principals = [Authenticated, f"oidc:{self.publisher.id}"] + return [Authenticated, f"oidc:{self.publisher.id}"] diff --git a/warehouse/utils/security_policy.py b/warehouse/utils/security_policy.py index 043b1d86cf26..0259db81a1f9 100644 --- a/warehouse/utils/security_policy.py +++ b/warehouse/utils/security_policy.py @@ -12,14 +12,12 @@ import enum -from pyramid.authorization import ACLHelper, Authenticated from pyramid.interfaces import ISecurityPolicy from pyramid.request import RequestLocalCache from pyramid.security import Denied from zope.interface import implementer from warehouse.accounts.models import User -from warehouse.oidc.utils import OIDCContext # NOTE: Is there a better place for this to live? It may not even need to exist From 1d74b53147fa17ab4b5012e71208ae8b687dcb2c Mon Sep 17 00:00:00 2001 From: Donald Stufft Date: Sat, 3 Jun 2023 00:36:34 -0400 Subject: [PATCH 09/18] we always have an active request now --- warehouse/macaroons/security_policy.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/warehouse/macaroons/security_policy.py b/warehouse/macaroons/security_policy.py index 0359a34dd0cb..4629f8c41b94 100644 --- a/warehouse/macaroons/security_policy.py +++ b/warehouse/macaroons/security_policy.py @@ -125,16 +125,6 @@ def authenticated_userid(self, request): raise NotImplementedError def permits(self, request, context, permission): - # Our request could possibly be a None, if there isn't an active request, in - # that case we're going to always deny, because without a request, we can't - # determine if this request is authorized or not. - # TODO: This is weird? This feels like it comes from the old AuthZ policies - # which didn't have access to the request. - if request is None: - return WarehouseDenied( - "There was no active request.", reason="no_active_request" - ) - # Re-extract our Macaroon from the request, it sucks to have to do this work # twice, but I believe it is inevitable unless we pass the Macaroon back as # a principal-- which doesn't seem to be the right fit for it. From 46104e895c73c0fe04cd9bb7391c4454c5b37cbd Mon Sep 17 00:00:00 2001 From: Donald Stufft Date: Sat, 3 Jun 2023 01:23:34 -0400 Subject: [PATCH 10/18] fix account tests --- tests/unit/accounts/test_core.py | 5 - tests/unit/accounts/test_security_policy.py | 240 ++++++++++---------- warehouse/accounts/security_policy.py | 2 +- 3 files changed, 116 insertions(+), 131 deletions(-) diff --git a/tests/unit/accounts/test_core.py b/tests/unit/accounts/test_core.py index e3a61ffc90e0..ffcf4125a724 100644 --- a/tests/unit/accounts/test_core.py +++ b/tests/unit/accounts/test_core.py @@ -406,10 +406,6 @@ def test_includeme(monkeypatch): macaroon_policy_cls = pretend.call_recorder(lambda: macaroon_policy_obj) monkeypatch.setattr(accounts, "MacaroonSecurityPolicy", macaroon_policy_cls) - twofactor_policy_obj = pretend.stub() - twofactor_policy_cls = pretend.call_recorder(lambda: twofactor_policy_obj) - monkeypatch.setattr(accounts, "TwoFactorSecurityPolicy", twofactor_policy_cls) - config = pretend.stub( registry=pretend.stub( settings={ @@ -476,7 +472,6 @@ def test_includeme(monkeypatch): session_policy_obj, basic_policy_obj, macaroon_policy_obj, - twofactor_policy_obj, ] ) ] diff --git a/tests/unit/accounts/test_security_policy.py b/tests/unit/accounts/test_security_policy.py index 3bd821ecf55d..bc20da915d4d 100644 --- a/tests/unit/accounts/test_security_policy.py +++ b/tests/unit/accounts/test_security_policy.py @@ -14,6 +14,7 @@ import pretend import pytest +from pyramid.authorization import Allow from pyramid.interfaces import ISecurityPolicy from pyramid.security import Allowed from zope.interface.verify import verifyClass @@ -37,8 +38,6 @@ def test_noops(self): policy = security_policy.BasicAuthSecurityPolicy() with pytest.raises(NotImplementedError): policy.authenticated_userid(pretend.stub()) - with pytest.raises(NotImplementedError): - policy.permits(pretend.stub(), pretend.stub(), pretend.stub()) def test_forget_and_remember(self): policy = security_policy.BasicAuthSecurityPolicy() @@ -221,8 +220,6 @@ def test_noops(self): policy = security_policy.SessionSecurityPolicy() with pytest.raises(NotImplementedError): policy.authenticated_userid(pretend.stub()) - with pytest.raises(NotImplementedError): - policy.permits(pretend.stub(), pretend.stub(), pretend.stub()) def test_forget_and_remember(self, monkeypatch): request = pretend.stub() @@ -510,141 +507,134 @@ def test_identity_ip_banned(self, monkeypatch): assert request.add_response_callback.calls == [pretend.call(vary_cb)] -class TestTwoFactorSecurityPolicy: - def test_verify(self): - assert verifyClass(ISecurityPolicy, security_policy.TwoFactorSecurityPolicy) +@pytest.mark.parametrize( + "policy_class", + [security_policy.BasicAuthSecurityPolicy, security_policy.SessionSecurityPolicy], +) +class TestPermits: + @pytest.mark.parametrize( + "principals,expected", [("user:5", True), ("user:1", False)] + ) + def test_acl(self, monkeypatch, policy_class, principals, expected): + monkeypatch.setattr(security_policy, "User", pretend.stub) - def test_noops(self): - policy = security_policy.TwoFactorSecurityPolicy() + request = pretend.stub(identity=pretend.stub(__principals__=lambda: principals)) + context = pretend.stub(__acl__=[(Allow, "user:5", "myperm")]) - assert policy.identity(pretend.stub()) is None - assert policy.forget(pretend.stub()) == [] - assert policy.remember(pretend.stub(), pretend.stub()) == [] + policy = policy_class() + assert bool(policy.permits(request, context, "myperm")) == expected - with pytest.raises(NotImplementedError): - policy.authenticated_userid(pretend.stub()) + @pytest.mark.parametrize( + "mfa_required,has_mfa,expected", + [ + (True, True, True), + (False, True, True), + (True, False, False), + (False, False, True), + ], + ) + def test_2fa_owner_requires( + self, monkeypatch, policy_class, mfa_required, has_mfa, expected + ): + monkeypatch.setattr(security_policy, "User", pretend.stub) + monkeypatch.setattr(security_policy, "TwoFactorRequireable", pretend.stub) - def test_permits_no_active_request(self): - policy = security_policy.TwoFactorSecurityPolicy() - result = policy.permits(None, pretend.stub(), pretend.stub()) - - assert result == WarehouseDenied("") - assert result.s == "There was no active request." - - def test_permits_if_non_2fa_requireable_context(self): - policy = security_policy.TwoFactorSecurityPolicy() - result = policy.permits(pretend.stub(), pretend.stub(), pretend.stub()) - - assert result == Allowed("Two factor requirements fulfilled") - - def test_permits_if_context_does_not_require_2fa(self, db_request): - db_request.user = pretend.stub() - db_request.registry.settings = { - "warehouse.two_factor_mandate.enabled": True, - "warehouse.two_factor_mandate.available": True, - "warehouse.two_factor_requirement.enabled": True, - } - - policy = security_policy.TwoFactorSecurityPolicy() - context = ProjectFactory.create( - owners_require_2fa=False, - pypi_mandates_2fa=False, - ) - result = policy.permits(db_request, context, pretend.stub()) - - assert result == Allowed("Two factor requirements fulfilled") - - def test_flashes_if_context_requires_2fa_but_not_enabled(self, db_request): - db_request.registry.settings = { - "warehouse.two_factor_mandate.enabled": False, - "warehouse.two_factor_mandate.available": True, - "warehouse.two_factor_requirement.enabled": True, - } - db_request.session.flash = pretend.call_recorder(lambda m, queue: None) - db_request.user = pretend.stub(has_two_factor=False) - - policy = security_policy.TwoFactorSecurityPolicy() - context = ProjectFactory.create( - owners_require_2fa=False, - pypi_mandates_2fa=True, - ) - result = policy.permits(db_request, context, pretend.stub()) - - assert result == Allowed("Two factor requirements fulfilled") - assert db_request.session.flash.calls == [ - pretend.call( - "This project is included in PyPI's two-factor mandate " - "for critical projects. In the future, you will be unable to " - "perform this action without enabling 2FA for your account", - queue="warning", + request = pretend.stub( + identity=pretend.stub( + __principals__=lambda: ["user:5"], has_two_factor=has_mfa ), - ] + registry=pretend.stub( + settings={ + "warehouse.two_factor_requirement.enabled": True, + "warehouse.two_factor_mandate.enabled": False, + "warehouse.two_factor_mandate.available": False, + } + ), + ) + context = pretend.stub( + __acl__=[(Allow, "user:5", "myperm")], owners_require_2fa=mfa_required + ) + + policy = policy_class() + assert bool(policy.permits(request, context, "myperm")) == expected - @pytest.mark.parametrize("owners_require_2fa", [True, False]) - @pytest.mark.parametrize("pypi_mandates_2fa", [True, False]) - @pytest.mark.parametrize("two_factor_requirement_enabled", [True, False]) - @pytest.mark.parametrize("two_factor_mandate_available", [True, False]) - @pytest.mark.parametrize("two_factor_mandate_enabled", [True, False]) - def test_permits_if_user_has_2fa( - self, - owners_require_2fa, - pypi_mandates_2fa, - two_factor_requirement_enabled, - two_factor_mandate_available, - two_factor_mandate_enabled, - db_request, + @pytest.mark.parametrize( + "mfa_required,has_mfa,expected", + [ + (True, True, True), + (False, True, True), + (True, False, False), + (False, False, True), + ], + ) + def test_2fa_pypi_mandates_2fa( + self, monkeypatch, policy_class, mfa_required, has_mfa, expected ): - db_request.registry.settings = { - "warehouse.two_factor_requirement.enabled": two_factor_requirement_enabled, - "warehouse.two_factor_mandate.available": two_factor_mandate_available, - "warehouse.two_factor_mandate.enabled": two_factor_mandate_enabled, - } - user = pretend.stub(has_two_factor=True) - db_request.user = user - policy = security_policy.TwoFactorSecurityPolicy() - context = ProjectFactory.create( - owners_require_2fa=owners_require_2fa, pypi_mandates_2fa=pypi_mandates_2fa + monkeypatch.setattr(security_policy, "User", pretend.stub) + monkeypatch.setattr(security_policy, "TwoFactorRequireable", pretend.stub) + + request = pretend.stub( + identity=pretend.stub( + __principals__=lambda: ["user:5"], has_two_factor=has_mfa + ), + registry=pretend.stub( + settings={ + "warehouse.two_factor_requirement.enabled": False, + "warehouse.two_factor_mandate.enabled": True, + "warehouse.two_factor_mandate.available": False, + } + ), + ) + context = pretend.stub( + __acl__=[(Allow, "user:5", "myperm")], pypi_mandates_2fa=mfa_required ) - result = policy.permits(db_request, context, pretend.stub()) - assert result == Allowed("Two factor requirements fulfilled") + policy = policy_class() + assert bool(policy.permits(request, context, "myperm")) == expected @pytest.mark.parametrize( - "owners_require_2fa, pypi_mandates_2fa, reason", + "mfa_required,has_mfa,expected", [ - (True, False, "owners_require_2fa"), - (False, True, "pypi_mandates_2fa"), - (True, True, "pypi_mandates_2fa"), + (True, True, True), + (False, True, True), + (True, False, False), + (False, False, True), ], ) - def test_denies_if_2fa_is_required_but_user_doesnt_have_2fa( - self, - owners_require_2fa, - pypi_mandates_2fa, - reason, - db_request, + def test_2fa_pypi_mandates_2fa_with_warning( + self, monkeypatch, policy_class, mfa_required, has_mfa, expected ): - db_request.registry.settings = { - "warehouse.two_factor_requirement.enabled": owners_require_2fa, - "warehouse.two_factor_mandate.enabled": pypi_mandates_2fa, - } - user = pretend.stub(has_two_factor=False) - db_request.user = user - policy = security_policy.TwoFactorSecurityPolicy() - context = ProjectFactory.create( - owners_require_2fa=owners_require_2fa, pypi_mandates_2fa=pypi_mandates_2fa - ) - result = policy.permits(db_request, context, pretend.stub()) - - summary = { - "owners_require_2fa": ( - "This project requires two factor authentication to be enabled " - "for all contributors.", + monkeypatch.setattr(security_policy, "User", pretend.stub) + monkeypatch.setattr(security_policy, "TwoFactorRequireable", pretend.stub) + + request = pretend.stub( + identity=pretend.stub( + __principals__=lambda: ["user:5"], has_two_factor=has_mfa ), - "pypi_mandates_2fa": ( - "PyPI requires two factor authentication to be enabled " - "for all contributors to this project.", + registry=pretend.stub( + settings={ + "warehouse.two_factor_requirement.enabled": False, + "warehouse.two_factor_mandate.enabled": False, + "warehouse.two_factor_mandate.available": True, + } ), - }[reason] - - assert result == WarehouseDenied(summary, reason="two_factor_required") + session=pretend.stub(flash=pretend.call_recorder(lambda msg, queue: None)), + ) + context = pretend.stub( + __acl__=[(Allow, "user:5", "myperm")], pypi_mandates_2fa=mfa_required + ) + + policy = policy_class() + assert bool(policy.permits(request, context, "myperm")) + + if not expected: + assert request.session.flash.calls == [ + pretend.call( + "This project is included in PyPI's two-factor mandate " + "for critical projects. In the future, you will be unable to " + "perform this action without enabling 2FA for your account", + queue="warning", + ) + ] + else: + assert request.session.flash.calls == [] diff --git a/warehouse/accounts/security_policy.py b/warehouse/accounts/security_policy.py index 9f5cdddd0640..64174fa57a73 100644 --- a/warehouse/accounts/security_policy.py +++ b/warehouse/accounts/security_policy.py @@ -295,7 +295,7 @@ def _check_for_mfa(request, context) -> WarehouseDenied | None: if ( request.registry.settings["warehouse.two_factor_mandate.available"] and context.pypi_mandates_2fa - and not request.user.has_two_factor + and not request.identity.has_two_factor ): request.session.flash( "This project is included in PyPI's two-factor mandate " From a0eb7b4b2107c7e4747787a343dc20ab5e02ddc4 Mon Sep 17 00:00:00 2001 From: Donald Stufft Date: Sat, 3 Jun 2023 01:38:24 -0400 Subject: [PATCH 11/18] fix tests for macaroon policy --- tests/unit/macaroons/test_security_policy.py | 51 +++++++------------- 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/tests/unit/macaroons/test_security_policy.py b/tests/unit/macaroons/test_security_policy.py index 690d5bb3b942..d295106191dd 100644 --- a/tests/unit/macaroons/test_security_policy.py +++ b/tests/unit/macaroons/test_security_policy.py @@ -14,6 +14,7 @@ import pretend import pytest +from pyramid.authorization import Allow from pyramid.interfaces import ISecurityPolicy from pyramid.security import Allowed, Denied from zope.interface.verify import verifyClass @@ -206,24 +207,6 @@ def test_identity_oidc_publisher(self, monkeypatch): assert add_vary_cb.calls == [pretend.call("Authorization")] assert request.add_response_callback.calls == [pretend.call(vary_cb)] - def test_permits_no_active_request(self, monkeypatch): - policy = security_policy.MacaroonSecurityPolicy() - result = policy.permits(None, pretend.stub(), pretend.stub()) - - assert result == Denied("") - assert result.s == "There was no active request." - - def test_permits_no_macaroon(self, monkeypatch): - _extract_http_macaroon = pretend.call_recorder(lambda r: None) - monkeypatch.setattr( - security_policy, "_extract_http_macaroon", _extract_http_macaroon - ) - - policy = security_policy.MacaroonSecurityPolicy() - - with pytest.raises(NotImplementedError): - policy.permits(pretend.stub(), pretend.stub(), pretend.stub()) - def test_permits_invalid_macaroon(self, monkeypatch): macaroon_service = pretend.stub( verify=pretend.raiser(InvalidMacaroonError("foo")) @@ -231,33 +214,41 @@ def test_permits_invalid_macaroon(self, monkeypatch): request = pretend.stub( find_service=pretend.call_recorder(lambda interface, **kw: macaroon_service) ) - _extract_http_macaroon = pretend.call_recorder(lambda r: b"not a real macaroon") + _extract_http_macaroon = pretend.call_recorder(lambda r: "not a real macaroon") monkeypatch.setattr( security_policy, "_extract_http_macaroon", _extract_http_macaroon ) policy = security_policy.MacaroonSecurityPolicy() - result = policy.permits(request, pretend.stub(), pretend.stub()) + result = policy.permits(request, pretend.stub(), "upload") assert result == Denied("") assert result.s == "Invalid API Token: foo" - def test_permits_valid_macaroon(self, monkeypatch): + @pytest.mark.parametrize( + "principals,expected", [(["user:5"], True), (["user:1"], False)] + ) + def test_permits_valid_macaroon(self, monkeypatch, principals, expected): macaroon_service = pretend.stub( verify=pretend.call_recorder(lambda *a: pretend.stub()) ) request = pretend.stub( - find_service=pretend.call_recorder(lambda interface, **kw: macaroon_service) + identity=pretend.stub(__principals__=lambda: principals), + find_service=pretend.call_recorder( + lambda interface, **kw: macaroon_service + ), ) - _extract_http_macaroon = pretend.call_recorder(lambda r: b"not a real macaroon") + _extract_http_macaroon = pretend.call_recorder(lambda r: "not a real macaroon") monkeypatch.setattr( security_policy, "_extract_http_macaroon", _extract_http_macaroon ) + context = pretend.stub(__acl__=[(Allow, "user:5", ["upload"])]) + policy = security_policy.MacaroonSecurityPolicy() - result = policy.permits(request, pretend.stub(), "upload") + result = policy.permits(request, context, "upload") - assert result == Allowed("") + assert bool(result) == expected @pytest.mark.parametrize( "invalid_permission", @@ -266,19 +257,13 @@ def test_permits_valid_macaroon(self, monkeypatch): def test_denies_valid_macaroon_for_incorrect_permission( self, monkeypatch, invalid_permission ): - macaroon_service = pretend.stub( - verify=pretend.call_recorder(lambda *a: pretend.stub()) - ) - request = pretend.stub( - find_service=pretend.call_recorder(lambda interface, **kw: macaroon_service) - ) - _extract_http_macaroon = pretend.call_recorder(lambda r: b"not a real macaroon") + _extract_http_macaroon = pretend.call_recorder(lambda r: "not a real macaroon") monkeypatch.setattr( security_policy, "_extract_http_macaroon", _extract_http_macaroon ) policy = security_policy.MacaroonSecurityPolicy() - result = policy.permits(request, pretend.stub(), invalid_permission) + result = policy.permits(pretend.stub(), pretend.stub(), invalid_permission) assert result == Denied("") assert result.s == ( From 9fcc68ae9530467f7d0617bf7b5dc92eec909736 Mon Sep 17 00:00:00 2001 From: Donald Stufft Date: Sat, 3 Jun 2023 02:03:09 -0400 Subject: [PATCH 12/18] fix tests --- tests/unit/accounts/test_models.py | 78 +++++++ tests/unit/utils/test_security_policy.py | 281 +++-------------------- warehouse/utils/security_policy.py | 4 - 3 files changed, 108 insertions(+), 255 deletions(-) diff --git a/tests/unit/accounts/test_models.py b/tests/unit/accounts/test_models.py index 5223818539a8..cfe2e3158c79 100644 --- a/tests/unit/accounts/test_models.py +++ b/tests/unit/accounts/test_models.py @@ -11,10 +11,14 @@ # limitations under the License. import datetime +import uuid import pytest +from pyramid.authorization import Authenticated + from warehouse.accounts.models import Email, RecoveryCode, User, UserFactory +from warehouse.utils.security_policy import principals_for from ...common.db.accounts import ( EmailFactory as DBEmailFactory, @@ -162,3 +166,77 @@ def test_acl(self, db_session): ("Allow", "group:admins", "admin"), ("Allow", "group:moderators", "moderator"), ] + + @pytest.mark.parametrize( + ( + "is_superuser", + "is_moderator", + "is_psf_staff", + "expected", + ), + [ + (False, False, False, ["group:with_admin_dashboard_access"]), + ( + True, + False, + False, + [ + "group:admins", + "group:moderators", + "group:psf_staff", + "group:with_admin_dashboard_access", + ], + ), + ( + False, + True, + False, + ["group:moderators", "group:with_admin_dashboard_access"], + ), + ( + True, + True, + False, + [ + "group:admins", + "group:moderators", + "group:psf_staff", + "group:with_admin_dashboard_access", + ], + ), + ( + False, + False, + True, + ["group:psf_staff", "group:with_admin_dashboard_access"], + ), + ( + False, + True, + True, + [ + "group:moderators", + "group:psf_staff", + "group:with_admin_dashboard_access", + ], + ), + ], + ) + def test_principals( + self, + # db_session, + is_superuser, + is_moderator, + is_psf_staff, + expected, + ): + user = User( + id=uuid.uuid4(), + is_superuser=is_superuser, + is_moderator=is_moderator, + is_psf_staff=is_psf_staff, + ) + + expected = expected[:] + [f"user:{user.id}", Authenticated] + + assert set(principals_for(user)) == set(expected) diff --git a/tests/unit/utils/test_security_policy.py b/tests/unit/utils/test_security_policy.py index 0299791de282..065feeb77b4a 100644 --- a/tests/unit/utils/test_security_policy.py +++ b/tests/unit/utils/test_security_policy.py @@ -15,6 +15,8 @@ from pyramid.authorization import Authenticated from pyramid.security import Allowed, Denied +from zope.interface.verify import verifyClass +from pyramid.interfaces import ISecurityPolicy from warehouse.oidc.utils import OIDCContext from warehouse.utils import security_policy @@ -23,300 +25,77 @@ from ...common.db.oidc import GitHubPublisherFactory -@pytest.mark.parametrize( - ( - "is_superuser", - "is_moderator", - "is_psf_staff", - "expected", - ), - [ - (False, False, False, []), - ( - True, - False, - False, - [ - "group:admins", - "group:moderators", - "group:psf_staff", - "group:with_admin_dashboard_access", - ], - ), - ( - False, - True, - False, - ["group:moderators", "group:with_admin_dashboard_access"], - ), - ( - True, - True, - False, - [ - "group:admins", - "group:moderators", - "group:psf_staff", - "group:with_admin_dashboard_access", - ], - ), - ( - False, - False, - True, - ["group:psf_staff", "group:with_admin_dashboard_access"], - ), - ( - False, - True, - True, - [ - "group:moderators", - "group:psf_staff", - "group:with_admin_dashboard_access", - ], - ), - ], -) -def test_principals_for_authenticated_user( - is_superuser, - is_moderator, - is_psf_staff, - expected, -): - user = pretend.stub( - id=1, - is_superuser=is_superuser, - is_moderator=is_moderator, - is_psf_staff=is_psf_staff, - ) - assert security_policy._principals_for_authenticated_user(user) == expected +def test_principals_for(): + identity = pretend.stub(__principals__=lambda: ["a", "b", "z"]) + assert security_policy.principals_for(identity) == ["a", "b", "z"] class TestMultiSecurityPolicy: - def test_initializes(self): - subpolicies = pretend.stub() - policy = security_policy.MultiSecurityPolicy(subpolicies) - - assert policy._policies is subpolicies + def test_verify(self): + assert verifyClass( + ISecurityPolicy, + security_policy.MultiSecurityPolicy, + ) def test_identity_none(self): - subpolicies = [pretend.stub(identity=pretend.call_recorder(lambda r: None))] + subpolicies = [pretend.stub(identity=lambda r: None)] policy = security_policy.MultiSecurityPolicy(subpolicies) - request = pretend.stub() + request = pretend.stub(add_finished_callback=lambda *a, **kw: None) assert policy.identity(request) is None - for p in subpolicies: - assert p.identity.calls == [pretend.call(request)] def test_identity_first_come_first_serve(self): identity1 = pretend.stub() identity2 = pretend.stub() subpolicies = [ - pretend.stub(identity=pretend.call_recorder(lambda r: None)), - pretend.stub(identity=pretend.call_recorder(lambda r: identity1)), - pretend.stub(identity=pretend.call_recorder(lambda r: identity2)), + pretend.stub(identity=lambda r: None), + pretend.stub(identity=lambda r: identity1), + pretend.stub(identity=lambda r: identity2), ] policy = security_policy.MultiSecurityPolicy(subpolicies) - request = pretend.stub() + request = pretend.stub(add_finished_callback=lambda *a, **kw: None) assert policy.identity(request) is identity1 - assert subpolicies[0].identity.calls == [pretend.call(request)] - assert subpolicies[1].identity.calls == [pretend.call(request)] - assert subpolicies[2].identity.calls == [] def test_authenticated_userid_no_identity(self): - request = pretend.stub() - subpolicies = [pretend.stub(identity=pretend.call_recorder(lambda r: None))] + request = pretend.stub(add_finished_callback=lambda *a, **kw: None) + subpolicies = [pretend.stub(identity=lambda r: None)] policy = security_policy.MultiSecurityPolicy(subpolicies) assert policy.authenticated_userid(request) is None - assert subpolicies[0].identity.calls == [pretend.call(request)] def test_authenticated_userid_nonuser_identity(self, db_request): - request = pretend.stub() + request = pretend.stub(add_finished_callback=lambda *a, **kw: None) nonuser = pretend.stub(id="not-a-user-instance") - subpolicies = [pretend.stub(identity=pretend.call_recorder(lambda r: nonuser))] + subpolicies = [pretend.stub(identity=lambda r: nonuser)] policy = security_policy.MultiSecurityPolicy(subpolicies) assert policy.authenticated_userid(request) is None - assert subpolicies[0].identity.calls == [pretend.call(request)] - def test_authenticated_userid(self, db_request): - request = pretend.stub() - user = UserFactory.create() - subpolicies = [pretend.stub(identity=pretend.call_recorder(lambda r: user))] + def test_authenticated_userid(self, monkeypatch): + monkeypatch.setattr(security_policy, "User", pretend.stub) + + request = pretend.stub(add_finished_callback=lambda *a, **kw: None) + user = pretend.stub(id="a fake user") + subpolicies = [pretend.stub(identity=lambda r: user)] policy = security_policy.MultiSecurityPolicy(subpolicies) assert policy.authenticated_userid(request) == str(user.id) - assert subpolicies[0].identity.calls == [pretend.call(request)] def test_forget(self): - header = pretend.stub() - subpolicies = [ - pretend.stub(forget=pretend.call_recorder(lambda r, **kw: [header])) - ] + subpolicies = [pretend.stub(forget=lambda r, **kw: [("ForgetMe", "1")])] policy = security_policy.MultiSecurityPolicy(subpolicies) request = pretend.stub() - assert policy.forget(request, foo=None) == [header] - assert subpolicies[0].forget.calls == [pretend.call(request, foo=None)] + assert policy.forget(request, foo=None) == [("ForgetMe", "1")] def test_remember(self): header = pretend.stub() subpolicies = [ - pretend.stub(remember=pretend.call_recorder(lambda r, uid, **kw: [header])) + pretend.stub(remember=lambda r, uid, foo, **kw: [("RememberMe", foo)]) ] policy = security_policy.MultiSecurityPolicy(subpolicies) request = pretend.stub() userid = pretend.stub() - assert policy.remember(request, userid, foo=None) == [header] - assert subpolicies[0].remember.calls == [ - pretend.call(request, userid, foo=None) - ] - - def test_permits_user(self, db_request, monkeypatch): - status = pretend.stub() - - policy = security_policy.MultiSecurityPolicy([]) - acl = pretend.stub(permits=pretend.call_recorder(lambda *a: status)) - monkeypatch.setattr(policy, "_acl", acl) - - principals_for_authenticated_user = pretend.call_recorder( - lambda *a: ["some:principal"] - ) - monkeypatch.setattr( - security_policy, - "_principals_for_authenticated_user", - principals_for_authenticated_user, - ) - - user = UserFactory.create() - request = pretend.stub(identity=user) - context = pretend.stub() - permission = pretend.stub() - assert policy.permits(request, context, permission) is status - assert acl.permits.calls == [ - pretend.call( - context, - [Authenticated, f"user:{user.id}", "some:principal"], - permission, - ) - ] - - class SubPolicyHelpers: - @staticmethod - def subpolicy(fn): - return pretend.stub(permits=fn) - - @subpolicy - @staticmethod - def raises(a, b, c): - raise NotImplementedError - - @subpolicy - @staticmethod - def passes(a, b, c): - return Allowed("subpolicy") - - @subpolicy - @staticmethod - def denies(a, b, c): - return Denied("subpolicy") - - @pytest.mark.parametrize( - ("policies", "expected"), - [ - ([], None), - ([SubPolicyHelpers.raises], None), - ([SubPolicyHelpers.denies], Denied("subpolicy")), - ([SubPolicyHelpers.passes], None), - ( - [ - SubPolicyHelpers.raises, - SubPolicyHelpers.passes, - SubPolicyHelpers.passes, - ], - None, - ), - ([SubPolicyHelpers.passes, SubPolicyHelpers.denies], Denied("subpolicy")), - ( - [ - SubPolicyHelpers.raises, - SubPolicyHelpers.passes, - SubPolicyHelpers.denies, - ], - Denied("subpolicy"), - ), - ], - ) - def test_permits_subpolicies(self, db_request, monkeypatch, policies, expected): - status = pretend.stub(s="") - policy = security_policy.MultiSecurityPolicy(policies) - acl = pretend.stub(permits=pretend.call_recorder(lambda *a: status)) - monkeypatch.setattr(policy, "_acl", acl) - user = UserFactory.create() - - # A parameter of None indicates that the ACL value should be passed through. - if expected is None: - expected = status - - result = policy.permits( - pretend.stub(identity=user), pretend.stub(), pretend.stub() - ) - assert result == expected - assert result.s == expected.s - - def test_permits_oidc_publisher(self, db_request, monkeypatch): - status = pretend.stub() - policy = security_policy.MultiSecurityPolicy([]) - acl = pretend.stub(permits=pretend.call_recorder(lambda *a: status)) - monkeypatch.setattr(policy, "_acl", acl) - - publisher = GitHubPublisherFactory.create() - request = pretend.stub(identity=OIDCContext(publisher, None)) - context = pretend.stub() - permission = pretend.stub() - assert policy.permits(request, context, permission) is status - assert acl.permits.calls == [ - pretend.call( - context, - [Authenticated, f"oidc:{publisher.id}"], - permission, - ) - ] - - def test_permits_nonuser_denied(self, monkeypatch): - policy = security_policy.MultiSecurityPolicy([]) - acl = pretend.stub(permits=pretend.call_recorder(lambda *a: pretend.stub())) - monkeypatch.setattr(policy, "_acl", acl) - - # Anything that doesn't pass an isinstance check for User - fakeuser = pretend.stub() - request = pretend.stub(identity=fakeuser) - context = pretend.stub() - permission = pretend.stub() - assert policy.permits(request, context, permission) == Denied("unimplemented") - assert acl.permits.calls == [] - - def test_permits_no_identity(self, monkeypatch): - status = pretend.stub() - policy = security_policy.MultiSecurityPolicy([]) - acl = pretend.stub(permits=pretend.call_recorder(lambda *a: status)) - monkeypatch.setattr(policy, "_acl", acl) - - request = pretend.stub(identity=None) - context = pretend.stub() - permission = pretend.stub() - assert policy.permits(request, context, permission) is status - assert acl.permits.calls == [pretend.call(context, [], permission)] - - def test_cant_use_unauthenticated_userid(self, monkeypatch): - subpolicies = pretend.stub() - policy = security_policy.MultiSecurityPolicy(subpolicies) - acl = pretend.stub(permits=pretend.call_recorder(lambda *a: pretend.stub())) - monkeypatch.setattr(policy, "_acl", acl) - - with pytest.raises(NotImplementedError): - policy.unauthenticated_userid(pretend.stub()) + assert policy.remember(request, userid, foo="bob") == [("RememberMe", "bob")] diff --git a/warehouse/utils/security_policy.py b/warehouse/utils/security_policy.py index 0259db81a1f9..f0874eb343ed 100644 --- a/warehouse/utils/security_policy.py +++ b/warehouse/utils/security_policy.py @@ -83,10 +83,6 @@ def authenticated_userid(self, request): return str(ident.id) return None - def unauthenticated_userid(self, request): - # This is deprecated and we shouldn't use it - raise NotImplementedError - def forget(self, request, **kw): headers = [] for policy in self._policies: From 47f5e1dc02345015ac825e14343d160a41f74d3f Mon Sep 17 00:00:00 2001 From: Donald Stufft Date: Sat, 3 Jun 2023 02:31:59 -0400 Subject: [PATCH 13/18] more --- tests/unit/accounts/test_models.py | 26 ++++---------- tests/unit/oidc/test_utils.py | 12 +++++++ tests/unit/test_config.py | 9 ++--- tests/unit/utils/test_security_policy.py | 45 +++++++++++++++++++++++- warehouse/accounts/models.py | 7 ---- warehouse/config.py | 1 - 6 files changed, 64 insertions(+), 36 deletions(-) diff --git a/tests/unit/accounts/test_models.py b/tests/unit/accounts/test_models.py index cfe2e3158c79..9a595132c1e9 100644 --- a/tests/unit/accounts/test_models.py +++ b/tests/unit/accounts/test_models.py @@ -175,50 +175,36 @@ def test_acl(self, db_session): "expected", ), [ - (False, False, False, ["group:with_admin_dashboard_access"]), + (False, False, False, []), ( True, False, False, - [ - "group:admins", - "group:moderators", - "group:psf_staff", - "group:with_admin_dashboard_access", - ], + ["group:admins", "group:moderators", "group:psf_staff"], ), ( False, True, False, - ["group:moderators", "group:with_admin_dashboard_access"], + ["group:moderators"], ), ( True, True, False, - [ - "group:admins", - "group:moderators", - "group:psf_staff", - "group:with_admin_dashboard_access", - ], + ["group:admins", "group:moderators", "group:psf_staff"], ), ( False, False, True, - ["group:psf_staff", "group:with_admin_dashboard_access"], + ["group:psf_staff"], ), ( False, True, True, - [ - "group:moderators", - "group:psf_staff", - "group:with_admin_dashboard_access", - ], + ["group:moderators", "group:psf_staff"], ), ], ) diff --git a/tests/unit/oidc/test_utils.py b/tests/unit/oidc/test_utils.py index 769ee4ae11bc..cad2f0ba3c35 100644 --- a/tests/unit/oidc/test_utils.py +++ b/tests/unit/oidc/test_utils.py @@ -15,8 +15,11 @@ import pretend import pytest +from pyramid.authorization import Authenticated + from tests.common.db.oidc import GitHubPublisherFactory from warehouse.oidc import utils +from warehouse.utils.security_policy import principals_for def test_find_publisher_by_issuer_bad_issuer_url(): @@ -71,3 +74,12 @@ def test_find_publisher_by_issuer_github(db_request, environment, expected_id): ).id == expected_id ) + + +def test_OIDContext_principals(): + assert principals_for( + utils.OIDCContext(publisher=pretend.stub(id=17), claims=None) + ) == [ + Authenticated, + "oidc:17", + ] diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index c65ddbf6b3a1..5dcf9b29ed56 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -470,13 +470,8 @@ def __init__(self): def test_root_factory_access_control_list(): acl = config.RootFactory.__acl__ - assert len(acl) == 5 + assert len(acl) == 4 assert acl[0] == (Allow, "group:admins", "admin") assert acl[1] == (Allow, "group:moderators", "moderator") assert acl[2] == (Allow, "group:psf_staff", "psf_staff") - assert acl[3] == ( - Allow, - "group:with_admin_dashboard_access", - "admin_dashboard_access", - ) - assert acl[4] == (Allow, Authenticated, "manage:user") + assert acl[3] == (Allow, Authenticated, "manage:user") diff --git a/tests/unit/utils/test_security_policy.py b/tests/unit/utils/test_security_policy.py index 065feeb77b4a..1a857d9c8cab 100644 --- a/tests/unit/utils/test_security_policy.py +++ b/tests/unit/utils/test_security_policy.py @@ -30,6 +30,10 @@ def test_principals_for(): assert security_policy.principals_for(identity) == ["a", "b", "z"] +def test_principals_for_with_none(): + assert security_policy.principals_for(pretend.stub()) == [] + + class TestMultiSecurityPolicy: def test_verify(self): assert verifyClass( @@ -90,7 +94,6 @@ def test_forget(self): assert policy.forget(request, foo=None) == [("ForgetMe", "1")] def test_remember(self): - header = pretend.stub() subpolicies = [ pretend.stub(remember=lambda r, uid, foo, **kw: [("RememberMe", foo)]) ] @@ -99,3 +102,43 @@ def test_remember(self): request = pretend.stub() userid = pretend.stub() assert policy.remember(request, userid, foo="bob") == [("RememberMe", "bob")] + + def test_permits(self): + identity1 = pretend.stub() + identity2 = pretend.stub() + context = pretend.stub() + + subpolicies = [ + pretend.stub(identity=lambda r: None), + pretend.stub( + identity=lambda r: identity1, + permits=( + lambda r, c, p: r.identity == identity1 + and c == context + and p == "myperm" + ), + ), + pretend.stub(identity=lambda r: identity2), + ] + policy = security_policy.MultiSecurityPolicy(subpolicies) + + request = pretend.stub( + identity=identity1, + add_finished_callback=lambda *a, **kw: None, + ) + + assert policy.permits(request, context, "myperm") + + def test_permits_no_policy(self): + subpolicies = [ + pretend.stub(identity=lambda r: None), + pretend.stub(identity=lambda r: None), + pretend.stub(identity=lambda r: None), + ] + policy = security_policy.MultiSecurityPolicy(subpolicies) + request = pretend.stub( + identity=None, add_finished_callback=lambda *a, **kw: None + ) + context = pretend.stub() + + assert not policy.permits(request, context, "myperm") diff --git a/warehouse/accounts/models.py b/warehouse/accounts/models.py index d0df5795fb40..5aad274d43f5 100644 --- a/warehouse/accounts/models.py +++ b/warehouse/accounts/models.py @@ -202,13 +202,6 @@ def __principals__(self) -> list[str]: if self.is_psf_staff or self.is_superuser: principals.append("group:psf_staff") - # user must have base admin access if any admin permission - # TODO: This feels wrong? Why are we using a group to add dashboard - # access and not a permission? Why are we adding it to every - # user? Something is weird, and we should figure it out. - if principals: - principals.append("group:with_admin_dashboard_access") - return principals def __acl__(self): diff --git a/warehouse/config.py b/warehouse/config.py index b89a5bf04828..a32bda07832d 100644 --- a/warehouse/config.py +++ b/warehouse/config.py @@ -60,7 +60,6 @@ class RootFactory: (Allow, "group:admins", "admin"), (Allow, "group:moderators", "moderator"), (Allow, "group:psf_staff", "psf_staff"), - (Allow, "group:with_admin_dashboard_access", "admin_dashboard_access"), (Allow, Authenticated, "manage:user"), ] From 7a50bc0bb0a8801c1e1dbb6f1b44cf1855a6c91c Mon Sep 17 00:00:00 2001 From: Donald Stufft Date: Sat, 3 Jun 2023 02:40:05 -0400 Subject: [PATCH 14/18] more --- tests/unit/accounts/test_security_policy.py | 4 ---- tests/unit/macaroons/test_security_policy.py | 2 +- tests/unit/oidc/test_utils.py | 2 +- tests/unit/utils/test_security_policy.py | 9 +-------- 4 files changed, 3 insertions(+), 14 deletions(-) diff --git a/tests/unit/accounts/test_security_policy.py b/tests/unit/accounts/test_security_policy.py index bc20da915d4d..3bbd14ba85dd 100644 --- a/tests/unit/accounts/test_security_policy.py +++ b/tests/unit/accounts/test_security_policy.py @@ -16,16 +16,12 @@ from pyramid.authorization import Allow from pyramid.interfaces import ISecurityPolicy -from pyramid.security import Allowed from zope.interface.verify import verifyClass from warehouse.accounts import security_policy from warehouse.accounts.interfaces import IUserService -from warehouse.errors import WarehouseDenied from warehouse.utils.security_policy import AuthenticationMethod -from ...common.db.packaging import ProjectFactory - class TestBasicAuthSecurityPolicy: def test_verify(self): diff --git a/tests/unit/macaroons/test_security_policy.py b/tests/unit/macaroons/test_security_policy.py index d295106191dd..eeb06ab20953 100644 --- a/tests/unit/macaroons/test_security_policy.py +++ b/tests/unit/macaroons/test_security_policy.py @@ -16,7 +16,7 @@ from pyramid.authorization import Allow from pyramid.interfaces import ISecurityPolicy -from pyramid.security import Allowed, Denied +from pyramid.security import Denied from zope.interface.verify import verifyClass from warehouse.macaroons import security_policy diff --git a/tests/unit/oidc/test_utils.py b/tests/unit/oidc/test_utils.py index cad2f0ba3c35..496806c68c43 100644 --- a/tests/unit/oidc/test_utils.py +++ b/tests/unit/oidc/test_utils.py @@ -76,7 +76,7 @@ def test_find_publisher_by_issuer_github(db_request, environment, expected_id): ) -def test_OIDContext_principals(): +def test_oidc_context_principals(): assert principals_for( utils.OIDCContext(publisher=pretend.stub(id=17), claims=None) ) == [ diff --git a/tests/unit/utils/test_security_policy.py b/tests/unit/utils/test_security_policy.py index 1a857d9c8cab..c2054b3fcb27 100644 --- a/tests/unit/utils/test_security_policy.py +++ b/tests/unit/utils/test_security_policy.py @@ -11,19 +11,12 @@ # limitations under the License. import pretend -import pytest -from pyramid.authorization import Authenticated -from pyramid.security import Allowed, Denied -from zope.interface.verify import verifyClass from pyramid.interfaces import ISecurityPolicy +from zope.interface.verify import verifyClass -from warehouse.oidc.utils import OIDCContext from warehouse.utils import security_policy -from ...common.db.accounts import UserFactory -from ...common.db.oidc import GitHubPublisherFactory - def test_principals_for(): identity = pretend.stub(__principals__=lambda: ["a", "b", "z"]) From c2de9585949bfa3f7e1f1c4c795c48d0520ea0ff Mon Sep 17 00:00:00 2001 From: Donald Stufft Date: Sat, 3 Jun 2023 02:49:11 -0400 Subject: [PATCH 15/18] more --- warehouse/accounts/security_policy.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/warehouse/accounts/security_policy.py b/warehouse/accounts/security_policy.py index 64174fa57a73..f551c86bced4 100644 --- a/warehouse/accounts/security_policy.py +++ b/warehouse/accounts/security_policy.py @@ -55,7 +55,6 @@ def _basic_auth_check(username, password, request): breach_service = request.find_service(IPasswordBreachedService, context=None) userid = login_service.find_userid(username) - # TODO: Why are we stashing this here? request._unauthenticated_userid = userid if userid is not None: user = login_service.get_user(userid) @@ -147,7 +146,6 @@ def identity(self, request): return None userid = self._session_helper.authenticated_userid(request) - # TODO: Why are we stashing this here? request._unauthenticated_userid = userid if userid is None: From 5657fd2188a2341ade918058754c673d1c165f03 Mon Sep 17 00:00:00 2001 From: Donald Stufft Date: Sat, 3 Jun 2023 14:52:04 -0400 Subject: [PATCH 16/18] restore admin_dashboard_access --- tests/unit/test_config.py | 14 +++++++++----- warehouse/config.py | 3 +++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 5dcf9b29ed56..a0f01434396b 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -470,8 +470,12 @@ def __init__(self): def test_root_factory_access_control_list(): acl = config.RootFactory.__acl__ - assert len(acl) == 4 - assert acl[0] == (Allow, "group:admins", "admin") - assert acl[1] == (Allow, "group:moderators", "moderator") - assert acl[2] == (Allow, "group:psf_staff", "psf_staff") - assert acl[3] == (Allow, Authenticated, "manage:user") + assert acl == [ + (Allow, "group:admins", "admin"), + (Allow, "group:admins", "admin_dashboard_access"), + (Allow, "group:moderators", "moderator"), + (Allow, "group:moderators", "admin_dashboard_access"), + (Allow, "group:psf_staff", "psf_staff"), + (Allow, "group:psf_staff", "admin_dashboard_access"), + (Allow, Authenticated, "manage:user"), + ] diff --git a/warehouse/config.py b/warehouse/config.py index a32bda07832d..57b734c31462 100644 --- a/warehouse/config.py +++ b/warehouse/config.py @@ -58,8 +58,11 @@ class RootFactory: __acl__ = [ (Allow, "group:admins", "admin"), + (Allow, "group:admins", "admin_dashboard_access"), (Allow, "group:moderators", "moderator"), + (Allow, "group:moderators", "admin_dashboard_access"), (Allow, "group:psf_staff", "psf_staff"), + (Allow, "group:psf_staff", "admin_dashboard_access"), (Allow, Authenticated, "manage:user"), ] From f439472217430947ce0621f23d5aa70c6db3da29 Mon Sep 17 00:00:00 2001 From: Donald Stufft Date: Sun, 4 Jun 2023 13:51:29 -0400 Subject: [PATCH 17/18] Apply suggestions from code review Co-authored-by: Mike Fiedler Co-authored-by: Ee Durbin --- tests/unit/accounts/test_models.py | 1 - warehouse/accounts/security_policy.py | 2 +- warehouse/macaroons/security_policy.py | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/unit/accounts/test_models.py b/tests/unit/accounts/test_models.py index 9a595132c1e9..515a49048ed2 100644 --- a/tests/unit/accounts/test_models.py +++ b/tests/unit/accounts/test_models.py @@ -210,7 +210,6 @@ def test_acl(self, db_session): ) def test_principals( self, - # db_session, is_superuser, is_moderator, is_psf_staff, diff --git a/warehouse/accounts/security_policy.py b/warehouse/accounts/security_policy.py index f551c86bced4..463ba6c8cf15 100644 --- a/warehouse/accounts/security_policy.py +++ b/warehouse/accounts/security_policy.py @@ -238,7 +238,7 @@ def _permits_for_user_policy(acl, request, context, permission): assert isinstance(request.identity, User) # Dispatch to our ACL - # NOTE: These parameters are in a different order + # NOTE: These parameters are in a different order than the signature of this method. res = acl.permits(context, principals_for(request.identity), permission) # If our underlying permits allowed this, we will check our 2FA status, diff --git a/warehouse/macaroons/security_policy.py b/warehouse/macaroons/security_policy.py index 4629f8c41b94..8d634e128e2b 100644 --- a/warehouse/macaroons/security_policy.py +++ b/warehouse/macaroons/security_policy.py @@ -136,7 +136,7 @@ def permits(self, request, context, permission): # Check to make sure that the permission we're attempting to permit is one that # is allowed to be used for macaroons. - # TODO: This should be moved out of there and into the acaroons themselves, it + # TODO: This should be moved out of there and into the macaroons themselves, it # doesn't really make a lot of sense here and it makes things more # complicated if we want to allow the use of macaroons for actions other # than uploading. @@ -160,5 +160,5 @@ def permits(self, request, context, permission): # The macaroon is valid, so we can actually see if request.identity is # authorized now or not. - # NOTE: These parameters are in a different order + # NOTE: These parameters are in a different order than the signature of this method. return self._acl.permits(context, principals_for(request.identity), permission) From 0d35f5640c1ee7ebdae8609f45beeb9844eaeb13 Mon Sep 17 00:00:00 2001 From: Donald Stufft Date: Sun, 4 Jun 2023 13:56:57 -0400 Subject: [PATCH 18/18] fix lint --- warehouse/macaroons/security_policy.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/warehouse/macaroons/security_policy.py b/warehouse/macaroons/security_policy.py index 8d634e128e2b..56b0bcb1ac2a 100644 --- a/warehouse/macaroons/security_policy.py +++ b/warehouse/macaroons/security_policy.py @@ -160,5 +160,6 @@ def permits(self, request, context, permission): # The macaroon is valid, so we can actually see if request.identity is # authorized now or not. - # NOTE: These parameters are in a different order than the signature of this method. + # NOTE: These parameters are in a different order than the signature of this + # method. return self._acl.permits(context, principals_for(request.identity), permission)