policies: add PolicyAccessView, which does complete access checking

This commit is contained in:
Jens Langhammer 2020-10-11 19:26:20 +02:00
parent 1ea2d99ff2
commit 610b6c7f70
4 changed files with 85 additions and 90 deletions

View File

@ -1,11 +1,12 @@
"""passbook access helper classes""" """passbook access helper classes"""
from typing import Optional from typing import Any, Optional
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import AccessMixin from django.contrib.auth.mixins import AccessMixin
from django.contrib.auth.views import redirect_to_login from django.contrib.auth.views import redirect_to_login
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic.base import View
from structlog import get_logger from structlog import get_logger
from passbook.core.models import Application, Provider, User from passbook.core.models import Application, Provider, User
@ -23,16 +24,40 @@ class BaseMixin:
request: HttpRequest request: HttpRequest
class PolicyAccessMixin(BaseMixin, AccessMixin): class PolicyAccessView(AccessMixin, View):
"""Mixin class for usage in Authorization views. """Mixin class for usage in Authorization views.
Provider functions to check application access, etc""" Provider functions to check application access, etc"""
def handle_no_permission(self, application: Optional[Application] = None): provider: Provider
application: Application
def resolve_provider_application(self):
"""Resolve self.provider and self.application. *.DoesNotExist Exceptions cause a normal
AccessDenied view to be shown. An Http404 exception
is not caught, and will return directly"""
raise NotImplementedError
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
try:
self.resolve_provider_application()
except (Application.DoesNotExist, Provider.DoesNotExist):
return self.handle_no_permission_authenticated()
# Check if user is unauthenticated, so we pass the application
# for the identification stage
if not request.user.is_authenticated:
return self.handle_no_permission()
# Check permissions
result = self.user_has_access()
if not result.passing:
return self.handle_no_permission_authenticated(result)
return super().dispatch(request, *args, **kwargs)
def handle_no_permission(self) -> HttpResponse:
"""User has no access and is not authenticated, so we remember the application """User has no access and is not authenticated, so we remember the application
they try to access and redirect to the login URL. The application is saved to show they try to access and redirect to the login URL. The application is saved to show
a hint on the Identification Stage what the user should login for.""" a hint on the Identification Stage what the user should login for."""
if application: if self.application:
self.request.session[SESSION_KEY_APPLICATION_PRE] = application self.request.session[SESSION_KEY_APPLICATION_PRE] = self.application
return redirect_to_login( return redirect_to_login(
self.request.get_full_path(), self.request.get_full_path(),
self.get_login_url(), self.get_login_url(),
@ -48,34 +73,18 @@ class PolicyAccessMixin(BaseMixin, AccessMixin):
response.policy_result = result response.policy_result = result
return response return response
def provider_to_application(self, provider: Provider) -> Application: def user_has_access(self, user: Optional[User] = None) -> PolicyResult:
"""Lookup application assigned to provider, throw error if no application assigned"""
try:
return provider.application
except Application.DoesNotExist as exc:
messages.error(
self.request,
_(
'Provider "%(name)s" has no application assigned'
% {"name": provider}
),
)
raise exc
def user_has_access(
self, application: Application, user: Optional[User] = None
) -> PolicyResult:
"""Check if user has access to application.""" """Check if user has access to application."""
user = user or self.request.user user = user or self.request.user
policy_engine = PolicyEngine( policy_engine = PolicyEngine(
application, user or self.request.user, self.request self.application, user or self.request.user, self.request
) )
policy_engine.build() policy_engine.build()
result = policy_engine.result result = policy_engine.result
LOGGER.debug( LOGGER.debug(
"AccessMixin user_has_access", "AccessMixin user_has_access",
user=user, user=user,
app=application, app=self.application,
result=result, result=result,
) )
if not result.passing: if not result.passing:

View File

@ -7,7 +7,6 @@ from uuid import uuid4
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.utils import timezone from django.utils import timezone
from django.views import View
from structlog import get_logger from structlog import get_logger
from passbook.audit.models import Event, EventAction from passbook.audit.models import Event, EventAction
@ -24,7 +23,7 @@ from passbook.flows.views import SESSION_KEY_PLAN
from passbook.lib.utils.time import timedelta_from_string from passbook.lib.utils.time import timedelta_from_string
from passbook.lib.utils.urls import redirect_with_qs from passbook.lib.utils.urls import redirect_with_qs
from passbook.lib.views import bad_request_message from passbook.lib.views import bad_request_message
from passbook.policies.mixins import PolicyAccessMixin from passbook.policies.views import PolicyAccessView
from passbook.providers.oauth2.constants import ( from passbook.providers.oauth2.constants import (
PROMPT_CONSNET, PROMPT_CONSNET,
PROMPT_NONE, PROMPT_NONE,
@ -329,28 +328,17 @@ class OAuthFulfillmentStage(StageView):
return urlunsplit(uri) return urlunsplit(uri)
class AuthorizationFlowInitView(PolicyAccessMixin, View): class AuthorizationFlowInitView(PolicyAccessView):
"""OAuth2 Flow initializer, checks access to application and starts flow""" """OAuth2 Flow initializer, checks access to application and starts flow"""
def resolve_provider_application(self):
client_id = self.request.GET.get("client_id")
self.provider = get_object_or_404(OAuth2Provider, client_id=client_id)
self.application = self.provider.application
# pylint: disable=unused-argument # pylint: disable=unused-argument
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Check access to application, start FlowPLanner, return to flow executor shell""" """Check access to application, start FlowPLanner, return to flow executor shell"""
client_id = request.GET.get("client_id")
# TODO: This whole block should be moved to a base class
provider = get_object_or_404(OAuth2Provider, client_id=client_id)
try:
application = self.provider_to_application(provider)
except Application.DoesNotExist:
return self.handle_no_permission_authenticated()
# Check if user is unauthenticated, so we pass the application
# for the identification stage
if not request.user.is_authenticated:
return self.handle_no_permission(application)
# Check permissions
result = self.user_has_access(application)
if not result.passing:
return self.handle_no_permission_authenticated(result)
# TODO: End block
# Extract params so we can save them in the plan context # Extract params so we can save them in the plan context
try: try:
params = OAuthAuthorizationParams.from_request(request) params = OAuthAuthorizationParams.from_request(request)
@ -358,14 +346,14 @@ class AuthorizationFlowInitView(PolicyAccessMixin, View):
# pylint: disable=no-member # pylint: disable=no-member
return bad_request_message(request, error.description, title=error.error) return bad_request_message(request, error.description, title=error.error)
# Regardless, we start the planner and return to it # Regardless, we start the planner and return to it
planner = FlowPlanner(provider.authorization_flow) planner = FlowPlanner(self.provider.authorization_flow)
# planner.use_cache = False # planner.use_cache = False
planner.allow_empty_flows = True planner.allow_empty_flows = True
plan: FlowPlan = planner.plan( plan: FlowPlan = planner.plan(
self.request, self.request,
{ {
PLAN_CONTEXT_SSO: True, PLAN_CONTEXT_SSO: True,
PLAN_CONTEXT_APPLICATION: application, PLAN_CONTEXT_APPLICATION: self.application,
# OAuth2 related params # OAuth2 related params
PLAN_CONTEXT_PARAMS: params, PLAN_CONTEXT_PARAMS: params,
PLAN_CONTEXT_SCOPE_DESCRIPTIONS: UserInfoView().get_scope_descriptions( PLAN_CONTEXT_SCOPE_DESCRIPTIONS: UserInfoView().get_scope_descriptions(
@ -390,5 +378,5 @@ class AuthorizationFlowInitView(PolicyAccessMixin, View):
return redirect_with_qs( return redirect_with_qs(
"passbook_flows:flow-executor-shell", "passbook_flows:flow-executor-shell",
self.request.GET, self.request.GET,
flow_slug=provider.authorization_flow.slug, flow_slug=self.provider.authorization_flow.slug,
) )

View File

@ -23,7 +23,7 @@ from passbook.flows.stage import StageView
from passbook.flows.views import SESSION_KEY_PLAN from passbook.flows.views import SESSION_KEY_PLAN
from passbook.lib.utils.urls import redirect_with_qs from passbook.lib.utils.urls import redirect_with_qs
from passbook.lib.views import bad_request_message from passbook.lib.views import bad_request_message
from passbook.policies.mixins import PolicyAccessMixin from passbook.policies.views import PolicyAccessView
from passbook.providers.saml.exceptions import CannotHandleAssertion from passbook.providers.saml.exceptions import CannotHandleAssertion
from passbook.providers.saml.models import SAMLBindings, SAMLProvider from passbook.providers.saml.models import SAMLBindings, SAMLProvider
from passbook.providers.saml.processors.assertion import AssertionProcessor from passbook.providers.saml.processors.assertion import AssertionProcessor
@ -46,34 +46,35 @@ REQUEST_KEY_RELAY_STATE = "RelayState"
SESSION_KEY_AUTH_N_REQUEST = "authn_request" SESSION_KEY_AUTH_N_REQUEST = "authn_request"
class SAMLSSOView(PolicyAccessMixin, View): class SAMLSSOView(PolicyAccessView):
""" "SAML SSO Base View, which plans a flow and injects our final stage. """ "SAML SSO Base View, which plans a flow and injects our final stage.
Calls get/post handler.""" Calls get/post handler."""
application: Application def resolve_provider_application(self):
provider: SAMLProvider self.application = get_object_or_404(
Application, slug=self.kwargs["application_slug"]
def dispatch( )
self, request: HttpRequest, *args, application_slug: str, **kwargs
) -> HttpResponse:
self.application = get_object_or_404(Application, slug=application_slug)
self.provider: SAMLProvider = get_object_or_404( self.provider: SAMLProvider = get_object_or_404(
SAMLProvider, pk=self.application.provider_id SAMLProvider, pk=self.application.provider_id
) )
if not request.user.is_authenticated:
return self.handle_no_permission(self.application) def check_saml_request(self) -> Optional[HttpRequest]:
has_access = self.user_has_access(self.application) """Handler to verify the SAML Request. Must be implemented by a subclass"""
if not has_access.passing: raise NotImplementedError
return self.handle_no_permission_authenticated(has_access)
# Call the method handler, which checks the SAML Request # pylint: disable=unused-argument
method_response = super().dispatch(request, *args, application_slug, **kwargs) def get(self, request: HttpRequest, application_slug: str) -> HttpResponse:
"""Verify the SAML Request, and if valid initiate the FlowPlanner for the application"""
# Call the method handler, which checks the SAML
# Request and returns a HTTP Response on error
method_response = self.check_saml_request()
if method_response: if method_response:
return method_response return method_response
# Regardless, we start the planner and return to it # Regardless, we start the planner and return to it
planner = FlowPlanner(self.provider.authorization_flow) planner = FlowPlanner(self.provider.authorization_flow)
planner.allow_empty_flows = True planner.allow_empty_flows = True
plan = planner.plan( plan = planner.plan(
self.request, request,
{ {
PLAN_CONTEXT_SSO: True, PLAN_CONTEXT_SSO: True,
PLAN_CONTEXT_APPLICATION: self.application, PLAN_CONTEXT_APPLICATION: self.application,
@ -81,23 +82,25 @@ class SAMLSSOView(PolicyAccessMixin, View):
}, },
) )
plan.append(in_memory_stage(SAMLFlowFinalView)) plan.append(in_memory_stage(SAMLFlowFinalView))
self.request.session[SESSION_KEY_PLAN] = plan request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs( return redirect_with_qs(
"passbook_flows:flow-executor-shell", "passbook_flows:flow-executor-shell",
self.request.GET, request.GET,
flow_slug=self.provider.authorization_flow.slug, flow_slug=self.provider.authorization_flow.slug,
) )
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
"""GET and POST use the same handler, but we can't
override .dispatch easily because PolicyAccessView's dispatch"""
return self.get(request, application_slug)
class SAMLSSOBindingRedirectView(SAMLSSOView): class SAMLSSOBindingRedirectView(SAMLSSOView):
"""SAML Handler for SSO/Redirect bindings, which are sent via GET""" """SAML Handler for SSO/Redirect bindings, which are sent via GET"""
# pylint: disable=unused-argument def check_saml_request(self) -> Optional[HttpRequest]:
def get( # lgtm [py/similar-function]
self, request: HttpRequest, application_slug: str
) -> Optional[HttpResponse]:
"""Handle REDIRECT bindings""" """Handle REDIRECT bindings"""
if REQUEST_KEY_SAML_REQUEST not in request.GET: if REQUEST_KEY_SAML_REQUEST not in self.request.GET:
LOGGER.info("handle_saml_request: SAML payload missing") LOGGER.info("handle_saml_request: SAML payload missing")
return bad_request_message( return bad_request_message(
self.request, "The SAML request payload is missing." self.request, "The SAML request payload is missing."
@ -105,10 +108,10 @@ class SAMLSSOBindingRedirectView(SAMLSSOView):
try: try:
auth_n_request = AuthNRequestParser(self.provider).parse_detached( auth_n_request = AuthNRequestParser(self.provider).parse_detached(
request.GET[REQUEST_KEY_SAML_REQUEST], self.request.GET[REQUEST_KEY_SAML_REQUEST],
request.GET.get(REQUEST_KEY_RELAY_STATE), self.request.GET.get(REQUEST_KEY_RELAY_STATE),
request.GET.get(REQUEST_KEY_SAML_SIGNATURE), self.request.GET.get(REQUEST_KEY_SAML_SIGNATURE),
request.GET.get(REQUEST_KEY_SAML_SIG_ALG), self.request.GET.get(REQUEST_KEY_SAML_SIG_ALG),
) )
self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
except CannotHandleAssertion as exc: except CannotHandleAssertion as exc:
@ -121,12 +124,9 @@ class SAMLSSOBindingRedirectView(SAMLSSOView):
class SAMLSSOBindingPOSTView(SAMLSSOView): class SAMLSSOBindingPOSTView(SAMLSSOView):
"""SAML Handler for SSO/POST bindings""" """SAML Handler for SSO/POST bindings"""
# pylint: disable=unused-argument def check_saml_request(self) -> Optional[HttpRequest]:
def post(
self, request: HttpRequest, application_slug: str
) -> Optional[HttpResponse]:
"""Handle POST bindings""" """Handle POST bindings"""
if REQUEST_KEY_SAML_REQUEST not in request.POST: if REQUEST_KEY_SAML_REQUEST not in self.request.POST:
LOGGER.info("handle_saml_request: SAML payload missing") LOGGER.info("handle_saml_request: SAML payload missing")
return bad_request_message( return bad_request_message(
self.request, "The SAML request payload is missing." self.request, "The SAML request payload is missing."
@ -134,8 +134,8 @@ class SAMLSSOBindingPOSTView(SAMLSSOView):
try: try:
auth_n_request = AuthNRequestParser(self.provider).parse( auth_n_request = AuthNRequestParser(self.provider).parse(
request.POST[REQUEST_KEY_SAML_REQUEST], self.request.POST[REQUEST_KEY_SAML_REQUEST],
request.POST.get(REQUEST_KEY_RELAY_STATE), self.request.POST.get(REQUEST_KEY_RELAY_STATE),
) )
self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
except CannotHandleAssertion as exc: except CannotHandleAssertion as exc:
@ -147,10 +147,7 @@ class SAMLSSOBindingPOSTView(SAMLSSOView):
class SAMLSSOBindingInitView(SAMLSSOView): class SAMLSSOBindingInitView(SAMLSSOView):
"""SAML Handler for for IdP Initiated login flows""" """SAML Handler for for IdP Initiated login flows"""
# pylint: disable=unused-argument def check_saml_request(self) -> Optional[HttpRequest]:
def get(
self, request: HttpRequest, application_slug: str
) -> Optional[HttpResponse]:
"""Create SAML Response from scratch""" """Create SAML Response from scratch"""
LOGGER.debug( LOGGER.debug(
"handle_saml_no_request: No SAML Request, using IdP-initiated flow." "handle_saml_no_request: No SAML Request, using IdP-initiated flow."

View File

@ -122,7 +122,7 @@ paths:
/core/applications/: /core/applications/:
get: get:
operationId: core_applications_list operationId: core_applications_list
description: Application Viewset description: Custom list method that checks Policy based access instead of guardian
parameters: parameters:
- name: ordering - name: ordering
in: query in: query
@ -186,7 +186,7 @@ paths:
tags: tags:
- core - core
parameters: [] parameters: []
/core/applications/{pbm_uuid}/: /core/applications/{slug}/:
get: get:
operationId: core_applications_read operationId: core_applications_read
description: Application Viewset description: Application Viewset
@ -240,12 +240,13 @@ paths:
tags: tags:
- core - core
parameters: parameters:
- name: pbm_uuid - name: slug
in: path in: path
description: A UUID string identifying this Application. description: Internal application name, used in URLs.
required: true required: true
type: string type: string
format: uuid format: slug
pattern: ^[-a-zA-Z0-9_]+$
/core/groups/: /core/groups/:
get: get:
operationId: core_groups_list operationId: core_groups_list