Skip to content

Refactor: Migrate to 2.0-style security policies #11218

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 37 commits into from
May 2, 2022
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
bba2796
warehouse: begin using security policies
woodruffw Apr 20, 2022
7bc9904
Merge remote-tracking branch 'origin/main' into tob-pyramid-2-securit…
woodruffw Apr 20, 2022
03ffbc3
Remove pyramid-multiauth, begin switching to security policies
woodruffw Apr 20, 2022
6beb4dd
migrations: remove incorrectly checked in migrations
woodruffw Apr 20, 2022
4efbccf
warehouse: fix principals a little bit
woodruffw Apr 20, 2022
9da307d
warehouse: begin using real security policies
woodruffw Apr 20, 2022
00afa6e
warehouse: port basic auth
woodruffw Apr 20, 2022
a211e35
warehouse: port macaroon policy, remove transition shim
woodruffw Apr 20, 2022
1be99d8
utils/security_policy: fix principals
woodruffw Apr 20, 2022
936b633
warehouse: fix lint
woodruffw Apr 20, 2022
8f95b0e
tests/unit: rename-o-rama
woodruffw Apr 20, 2022
090ef01
Improve the readabililty of the overall diff
di Apr 21, 2022
0b788d9
warehouse: refactor security policies
woodruffw Apr 21, 2022
0bc2083
macaroons/security_policy: remove redundant route check
woodruffw Apr 21, 2022
8f858e3
Merge remote-tracking branch 'upstream/main' into tob-pyramid-2-secur…
woodruffw Apr 21, 2022
231a46d
accounts/security_policy: lint
woodruffw Apr 21, 2022
e2242ec
Update warehouse/utils/security_policy.py
woodruffw Apr 25, 2022
5cdb53a
macaroons/security_policy: avoid a DB roundtrip
woodruffw Apr 25, 2022
593d199
utils/security_policy: simplify principals, add comment
woodruffw Apr 25, 2022
44d1463
utils/security_policy: re-add id principal
woodruffw Apr 25, 2022
3e0c525
warehouse: disambiguate user IDs inside the principal set
woodruffw Apr 25, 2022
366b5e3
Merge remote-tracking branch 'upstream/main' into tob-pyramid-2-secur…
woodruffw Apr 25, 2022
6be5ae7
Merge remote-tracking branch 'upstream/main' into tob-pyramid-2-secur…
woodruffw Apr 25, 2022
840c301
packaging/models: blacken
woodruffw Apr 25, 2022
52c3120
tests, warehouse: the long and winding road
woodruffw Apr 25, 2022
9c7f8cd
tests/packaging: fix ACL tests
woodruffw Apr 26, 2022
f4f608b
tests, warehouse: rewrite account security policy tests
woodruffw Apr 26, 2022
5db0a10
macaroons: make the tests pass
woodruffw Apr 26, 2022
ab12fd3
tests: finish tests
woodruffw Apr 26, 2022
29b40f9
warehouse: move session invalidation to session authn
woodruffw Apr 26, 2022
f2ee9e9
tests, warehouse: update tests
woodruffw Apr 26, 2022
250a2a7
Merge remote-tracking branch 'upstream/main' into tob-pyramid-2-secur…
woodruffw Apr 26, 2022
42f7beb
Merge remote-tracking branch 'upstream/main' into tob-pyramid-2-secur…
woodruffw Apr 28, 2022
222b293
Merge remote-tracking branch 'upstream/main' into tob-pyramid-2-secur…
woodruffw Apr 28, 2022
ec2c563
utils/security_policy: authenticated_userid only works for user ident…
woodruffw Apr 28, 2022
8cb31c8
tests: update utils/security_policy tests
woodruffw Apr 28, 2022
6e6d039
Merge branch 'main' into tob-pyramid-2-security-policies
di May 2, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ module = [
"pyramid.*", # https://github.com/Pylons/pyramid/issues/2638
"pyramid_jinja2.*",
"pyramid_mailer.*",
"pyramid_multiauth.*",
"pyramid_retry.*",
"pyramid_rpc.*",
"pyqrcode.*",
Expand Down
1 change: 0 additions & 1 deletion requirements/main.in
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ pycurl
pyqrcode
pyramid>=2.0
pymacaroons
pyramid-multiauth
pyramid_jinja2>=2.5
pyramid_mailer>=0.14.1
pyramid_retry>=0.3
Expand Down
5 changes: 0 additions & 5 deletions requirements/main.txt
Original file line number Diff line number Diff line change
Expand Up @@ -987,7 +987,6 @@ pyramid==2.0 \
# -r requirements/main.in
# pyramid-jinja2
# pyramid-mailer
# pyramid-multiauth
# pyramid-retry
# pyramid-rpc
# pyramid-services
Expand All @@ -1000,10 +999,6 @@ pyramid-mailer==0.15.1 \
--hash=sha256:28d4a7829ebc19dd40e712d8cb1998cec03c296ba675b2c112a503539738bdc1 \
--hash=sha256:ec0aff54d9179b2aa2922ff82c2016a4dc8d1da5dc3408d6594f0e2096446f9b
# via -r requirements/main.in
pyramid-multiauth==1.0.1 \
--hash=sha256:6d8785558e1d0bbe0d0da43e296efc0fbe0de5071d1f9b1091e891f0e4ec9682 \
--hash=sha256:c265258af8021094e5b98602e8bfe094eec1350eebb56473f36cd0e076910822
# via -r requirements/main.in
pyramid-retry==2.1.1 \
--hash=sha256:b5129a60eb9d7409234ea52839006426d2ae887b4a1f0530c75ec336cabf2476 \
--hash=sha256:baa8276ae68babad09e5f2f94efc4f7421f3b8fb526151df522052f8cd3ec0c9
Expand Down
39 changes: 19 additions & 20 deletions warehouse/accounts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,18 @@

from pyramid.authorization import ACLAuthorizationPolicy
from pyramid.httpexceptions import HTTPUnauthorized
from pyramid_multiauth import MultiAuthenticationPolicy

from warehouse.accounts.auth_policy import (
BasicAuthAuthenticationPolicy,
SessionAuthenticationPolicy,
TwoFactorAuthorizationPolicy,
)
from warehouse.accounts.interfaces import (
IPasswordBreachedService,
ITokenService,
IUserService,
)
from warehouse.accounts.models import DisableReason
from warehouse.accounts.security_policy import (
BasicAuthSecurityPolicy,
SessionSecurityPolicy,
TwoFactorAuthorizationPolicy,
)
from warehouse.accounts.services import (
HaveIBeenPwnedPasswordBreachedService,
NullPasswordBreachedService,
Expand All @@ -40,11 +39,12 @@
BasicAuthBreachedPassword,
BasicAuthFailedPassword,
)
from warehouse.macaroons.auth_policy import (
MacaroonAuthenticationPolicy,
from warehouse.macaroons.security_policy import (
MacaroonAuthorizationPolicy,
MacaroonSecurityPolicy,
)
from warehouse.rate_limiting import IRateLimiter, RateLimit
from warehouse.utils.security_policy import MultiSecurityPolicy

__all__ = ["NullPasswordBreachedService", "HaveIBeenPwnedPasswordBreachedService"]

Expand Down Expand Up @@ -206,19 +206,18 @@ def includeme(config):
breached_pw_class.create_service, IPasswordBreachedService
)

# Register our authentication and authorization policies
config.set_authentication_policy(
MultiAuthenticationPolicy(
[
SessionAuthenticationPolicy(callback=_session_authenticate),
BasicAuthAuthenticationPolicy(check=_basic_auth_check),
MacaroonAuthenticationPolicy(callback=_macaroon_authenticate),
]
)
# Register our authentication and authorization policies under combined security policies
authz_policy = TwoFactorAuthorizationPolicy(
policy=MacaroonAuthorizationPolicy(policy=ACLAuthorizationPolicy())
)
config.set_authorization_policy(
TwoFactorAuthorizationPolicy(
policy=MacaroonAuthorizationPolicy(policy=ACLAuthorizationPolicy())
config.set_security_policy(
MultiSecurityPolicy(
[
SessionSecurityPolicy(callback=_session_authenticate),
BasicAuthSecurityPolicy(check=_basic_auth_check),
MacaroonSecurityPolicy(callback=_macaroon_authenticate),
],
authz_policy,
)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,45 +11,77 @@
# limitations under the License.

from pyramid.authentication import (
BasicAuthAuthenticationPolicy as _BasicAuthAuthenticationPolicy,
SessionAuthenticationPolicy as _SessionAuthenticationPolicy,
SessionAuthenticationHelper,
extract_http_basic_credentials,
)
from pyramid.interfaces import IAuthorizationPolicy
from pyramid.interfaces import IAuthorizationPolicy, ISecurityPolicy
from pyramid.threadlocal import get_current_request
from zope.interface import implementer

from warehouse.accounts.interfaces import IUserService
from warehouse.cache.http import add_vary_callback
from warehouse.errors import WarehouseDenied
from warehouse.packaging.models import TwoFactorRequireable
from warehouse.utils.security_policy import SecurityPolicy


class BasicAuthAuthenticationPolicy(_BasicAuthAuthenticationPolicy):
@implementer(ISecurityPolicy)
class SessionSecurityPolicy(SecurityPolicy):
def __init__(self, callback=None):
super().__init__(callback)
self._session_helper = SessionAuthenticationHelper()

def unauthenticated_userid(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
# Authorization header.
request.add_response_callback(add_vary_callback("Authorization"))
# Cookie header.
request.add_response_callback(add_vary_callback("Cookie"))

return self._session_helper.authenticated_userid(request)

def forget(self, request, **kw):
return self._session_helper.forget(request, **kw)

def remember(self, request, userid, **kw):
return self._session_helper.remember(request, userid, **kw)


# Dispatch to the real basic authentication policy
username = super().unauthenticated_userid(request)
@implementer(ISecurityPolicy)
class BasicAuthSecurityPolicy(SecurityPolicy):
def __init__(self, check):
super().__init__(self.callback)

# Assuming we got a username from the basic authentication policy, we
# want to locate the userid from the IUserService.
if username is not None:
login_service = request.find_service(IUserService, context=None)
return str(login_service.find_userid(username))
self._check = check

def callback(self, userid, request):
if credentials := extract_http_basic_credentials(request):
username, password = credentials
return self._check(username, password, request)
return None

class SessionAuthenticationPolicy(_SessionAuthenticationPolicy):
def unauthenticated_userid(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
# Cookie header.
request.add_response_callback(add_vary_callback("Cookie"))
# Authorization header.
request.add_response_callback(add_vary_callback("Authorization"))

credentials = extract_http_basic_credentials(request)
if credentials is None:
return None

login_service = request.find_service(IUserService, context=None)
if userid := login_service.find_userid(credentials.username):
return str(userid)

return None

def forget(self, request, **kw):
# No-op.
return []

# Dispatch to the real SessionAuthenticationPolicy
return super().unauthenticated_userid(request)
def remember(self, request, userid, **kw):
# NOTE: We could make realm configurable here.
return [("WWW-Authenticate", 'Basic realm="Realm"')]


@implementer(IAuthorizationPolicy)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@

import base64

from pyramid.authentication import CallbackAuthenticationPolicy
from pyramid.interfaces import IAuthenticationPolicy, IAuthorizationPolicy
from pyramid.interfaces import IAuthorizationPolicy, ISecurityPolicy
from pyramid.threadlocal import get_current_request
from zope.interface import implementer

from warehouse.cache.http import add_vary_callback
from warehouse.errors import WarehouseDenied
from warehouse.macaroons.interfaces import IMacaroonService
from warehouse.macaroons.services import InvalidMacaroonError
from warehouse.utils.security_policy import SecurityPolicy


def _extract_basic_macaroon(auth):
Expand Down Expand Up @@ -67,11 +67,8 @@ def _extract_http_macaroon(request):
return None


@implementer(IAuthenticationPolicy)
class MacaroonAuthenticationPolicy(CallbackAuthenticationPolicy):
def __init__(self, callback=None):
self.callback = callback

@implementer(ISecurityPolicy)
class MacaroonSecurityPolicy(SecurityPolicy):
def unauthenticated_userid(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
Expand All @@ -96,7 +93,7 @@ def remember(self, request, userid, **kw):
# assumes it has been configured in clients somewhere out of band.
return []

def forget(self, request):
def forget(self, request, **kw):
# This is a NO-OP because our Macaroon header policy doesn't allow
# the ability for authentication to "forget" the user id. This
# assumes it has been configured in clients somewhere out of band.
Expand Down
95 changes: 95 additions & 0 deletions warehouse/utils/security_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from pyramid.authorization import Authenticated, Everyone
from pyramid.interfaces import ISecurityPolicy
from zope.interface import implementer

from warehouse.accounts.interfaces import IUserService


class SecurityPolicy:
def __init__(self, callback=None):
self._callback = callback

def identity(self, request):
login_service = request.find_service(IUserService, context=None)
user = login_service.get_user(self.unauthenticated_userid(request))
if user is None:
return None

principals = []
if self._callback is not None:
principals = self._callback(user.id, request)
if principals is None:
return None

return {"entity": user, "principals": principals}

def permits(self, request, context, permission):
return NotImplemented


@implementer(ISecurityPolicy)
class MultiSecurityPolicy:
"""
A wrapper for multiple Pyramid 2.0-style "security policies", which replace
Pyramid 1.0's separate AuthN and AuthZ APIs.

Security policies are checked in the order provided during initialization,
with the following semantics:

* `identity`: Selected from the first policy to return non-`None`
* `authenticated_userid`: Selected from the request identity, if present
* `forget`: Combined from all policies
* `remember`: Combined from all policies
* `permits`: Uses the AuthZ policy passed during initialization

These semantics mostly mirror those of `pyramid-multiauth`.
"""

def __init__(self, policies, authz):
self._policies = policies
self._authz = authz

def identity(self, request):
for policy in self._policies:
if ident := policy.identity(request):
return ident

return None

def authenticated_userid(self, request):
if request.identity:
return str(request.identity["entity"].id)
return None

def forget(self, request, **kw):
headers = []
for policy in self._policies:
headers.extend(policy.forget(request, **kw))
return headers

def remember(self, request, userid, **kw):
headers = []
for policy in self._policies:
headers.extend(policy.remember(request, userid, **kw))
return headers

def permits(self, request, context, permission):
identity = request.identity
principals = [Everyone]
if identity is not None:
principals.extend(
[Authenticated, str(identity["entity"].id), identity["principals"]]
)
return self._authz.permits(context, principals, permission)