Compare commits

..

11 Commits

31 changed files with 262 additions and 162 deletions

View File

@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.10.1-stable current_version = 0.10.2-stable
tag = True tag = True
commit = True commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*) parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)

View File

@ -18,11 +18,11 @@ jobs:
- name: Building Docker Image - name: Building Docker Image
run: docker build run: docker build
--no-cache --no-cache
-t beryju/passbook:0.10.1-stable -t beryju/passbook:0.10.2-stable
-t beryju/passbook:latest -t beryju/passbook:latest
-f Dockerfile . -f Dockerfile .
- name: Push Docker Container to Registry (versioned) - name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook:0.10.1-stable run: docker push beryju/passbook:0.10.2-stable
- name: Push Docker Container to Registry (latest) - name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook:latest run: docker push beryju/passbook:latest
build-proxy: build-proxy:
@ -48,11 +48,11 @@ jobs:
cd proxy cd proxy
docker build \ docker build \
--no-cache \ --no-cache \
-t beryju/passbook-proxy:0.10.1-stable \ -t beryju/passbook-proxy:0.10.2-stable \
-t beryju/passbook-proxy:latest \ -t beryju/passbook-proxy:latest \
-f Dockerfile . -f Dockerfile .
- name: Push Docker Container to Registry (versioned) - name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook-proxy:0.10.1-stable run: docker push beryju/passbook-proxy:0.10.2-stable
- name: Push Docker Container to Registry (latest) - name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook-proxy:latest run: docker push beryju/passbook-proxy:latest
build-static: build-static:
@ -77,11 +77,11 @@ jobs:
run: docker build run: docker build
--no-cache --no-cache
--network=$(docker network ls | grep github | awk '{print $1}') --network=$(docker network ls | grep github | awk '{print $1}')
-t beryju/passbook-static:0.10.1-stable -t beryju/passbook-static:0.10.2-stable
-t beryju/passbook-static:latest -t beryju/passbook-static:latest
-f static.Dockerfile . -f static.Dockerfile .
- name: Push Docker Container to Registry (versioned) - name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook-static:0.10.1-stable run: docker push beryju/passbook-static:0.10.2-stable
- name: Push Docker Container to Registry (latest) - name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook-static:latest run: docker push beryju/passbook-static:latest
test-release: test-release:
@ -114,5 +114,5 @@ jobs:
SENTRY_PROJECT: passbook SENTRY_PROJECT: passbook
SENTRY_URL: https://sentry.beryju.org SENTRY_URL: https://sentry.beryju.org
with: with:
tagName: 0.10.1-stable tagName: 0.10.2-stable
environment: beryjuorg-prod environment: beryjuorg-prod

View File

@ -20,7 +20,7 @@ wget https://raw.githubusercontent.com/BeryJu/passbook/master/docker-compose.yml
# Optionally enable Error-reporting # Optionally enable Error-reporting
# export PASSBOOK_ERROR_REPORTING=true # export PASSBOOK_ERROR_REPORTING=true
# Optionally deploy a different version # Optionally deploy a different version
# export PASSBOOK_TAG=0.10.1-stable # export PASSBOOK_TAG=0.10.2-stable
# If this is a productive installation, set a different PostgreSQL Password # If this is a productive installation, set a different PostgreSQL Password
# export PG_PASS=$(pwgen 40 1) # export PG_PASS=$(pwgen 40 1)
docker-compose pull docker-compose pull

View File

@ -23,7 +23,7 @@ services:
labels: labels:
- traefik.enable=false - traefik.enable=false
server: server:
image: beryju/passbook:${PASSBOOK_TAG:-0.10.1-stable} image: beryju/passbook:${PASSBOOK_TAG:-0.10.2-stable}
command: server command: server
environment: environment:
PASSBOOK_REDIS__HOST: redis PASSBOOK_REDIS__HOST: redis
@ -41,7 +41,7 @@ services:
env_file: env_file:
- .env - .env
worker: worker:
image: beryju/passbook:${PASSBOOK_TAG:-0.10.1-stable} image: beryju/passbook:${PASSBOOK_TAG:-0.10.2-stable}
command: worker command: worker
networks: networks:
- internal - internal
@ -55,7 +55,7 @@ services:
env_file: env_file:
- .env - .env
static: static:
image: beryju/passbook-static:${PASSBOOK_TAG:-0.10.1-stable} image: beryju/passbook-static:${PASSBOOK_TAG:-0.10.2-stable}
networks: networks:
- internal - internal
labels: labels:

View File

@ -13,7 +13,7 @@ Download the latest `docker-compose.yml` from [here](https://raw.githubuserconte
To optionally enable error-reporting, run `echo PASSBOOK_ERROR_REPORTING=true >> .env` To optionally enable error-reporting, run `echo PASSBOOK_ERROR_REPORTING=true >> .env`
To optionally deploy a different version run `echo PASSBOOK_TAG=0.10.1-stable >> .env` To optionally deploy a different version run `echo PASSBOOK_TAG=0.10.2-stable >> .env`
If this is a fresh passbook install run the following commands to generate a password: If this is a fresh passbook install run the following commands to generate a password:

View File

@ -11,7 +11,7 @@ This installation automatically applies database migrations on startup. After th
image: image:
name: beryju/passbook name: beryju/passbook
name_static: beryju/passbook-static name_static: beryju/passbook-static
tag: 0.10.1-stable tag: 0.10.2-stable
nameOverride: "" nameOverride: ""

View File

@ -6,6 +6,7 @@ from unittest.case import skipUnless
from docker.types import Healthcheck from docker.types import Healthcheck
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as ec
from e2e.utils import USER, SeleniumTestCase from e2e.utils import USER, SeleniumTestCase
from passbook.core.models import Application from passbook.core.models import Application
@ -214,7 +215,10 @@ class TestProviderOAuth2Github(SeleniumTestCase):
self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER)
self.driver.find_element(By.ID, "id_password").send_keys(USER().username) self.driver.find_element(By.ID, "id_password").send_keys(USER().username)
self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER)
self.wait_for_url(self.url("passbook_flows:denied"))
self.wait.until(
ec.presence_of_element_located((By.CSS_SELECTOR, "header > h1"))
)
self.assertEqual( self.assertEqual(
self.driver.find_element(By.CSS_SELECTOR, "header > h1").text, self.driver.find_element(By.CSS_SELECTOR, "header > h1").text,
"Permission denied", "Permission denied",

View File

@ -285,7 +285,10 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER)
self.driver.find_element(By.ID, "id_password").send_keys(USER().username) self.driver.find_element(By.ID, "id_password").send_keys(USER().username)
self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER)
self.wait_for_url(self.url("passbook_flows:denied"))
self.wait.until(
ec.presence_of_element_located((By.CSS_SELECTOR, "header > h1"))
)
self.assertEqual( self.assertEqual(
self.driver.find_element(By.CSS_SELECTOR, "header > h1").text, self.driver.find_element(By.CSS_SELECTOR, "header > h1").text,
"Permission denied", "Permission denied",

View File

@ -8,6 +8,7 @@ from docker.models.containers import Container
from docker.types import Healthcheck from docker.types import Healthcheck
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as ec
from structlog import get_logger from structlog import get_logger
from e2e.utils import USER, SeleniumTestCase from e2e.utils import USER, SeleniumTestCase
@ -206,7 +207,10 @@ class TestProviderSAML(SeleniumTestCase):
self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER)
self.driver.find_element(By.ID, "id_password").send_keys(USER().username) self.driver.find_element(By.ID, "id_password").send_keys(USER().username)
self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER)
self.wait_for_url(self.url("passbook_flows:denied"))
self.wait.until(
ec.presence_of_element_located((By.CSS_SELECTOR, "header > h1"))
)
self.assertEqual( self.assertEqual(
self.driver.find_element(By.CSS_SELECTOR, "header > h1").text, self.driver.find_element(By.CSS_SELECTOR, "header > h1").text,
"Permission denied", "Permission denied",

View File

@ -1,8 +1,8 @@
apiVersion: v2 apiVersion: v2
appVersion: "0.10.1-stable" appVersion: "0.10.2-stable"
description: A Helm chart for passbook. description: A Helm chart for passbook.
name: passbook name: passbook
version: "0.10.1-stable" version: "0.10.2-stable"
icon: https://github.com/BeryJu/passbook/blob/master/docs/images/logo.svg icon: https://github.com/BeryJu/passbook/blob/master/docs/images/logo.svg
dependencies: dependencies:
- name: postgresql - name: postgresql

View File

@ -25,21 +25,23 @@ spec:
affinity: affinity:
podAntiAffinity: podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution: preferredDuringSchedulingIgnoredDuringExecution:
- labelSelector: - weight: 1
matchExpressions: podAffinityTerm:
- key: app.kubernetes.io/name labelSelector:
operator: In matchExpressions:
values: - key: app.kubernetes.io/name
- {{ include "passbook.name" . }} operator: In
- key: app.kubernetes.io/instance values:
operator: In - {{ include "passbook.name" . }}
values: - key: app.kubernetes.io/instance
- {{ .Release.Name }} operator: In
- key: k8s.passbook.beryju.org/component values:
operator: In - {{ .Release.Name }}
values: - key: k8s.passbook.beryju.org/component
- web operator: In
topologyKey: "kubernetes.io/hostname" values:
- web
topologyKey: "kubernetes.io/hostname"
initContainers: initContainers:
- name: passbook-database-migrations - name: passbook-database-migrations
image: "{{ .Values.image.name }}:{{ .Values.image.tag }}" image: "{{ .Values.image.name }}:{{ .Values.image.tag }}"
@ -109,7 +111,7 @@ spec:
resources: resources:
requests: requests:
cpu: 100m cpu: 100m
memory: 200M memory: 300M
limits: limits:
cpu: 300m cpu: 300m
memory: 350M memory: 500M

View File

@ -25,21 +25,23 @@ spec:
affinity: affinity:
podAntiAffinity: podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution: preferredDuringSchedulingIgnoredDuringExecution:
- labelSelector: - weight: 1
matchExpressions: podAffinityTerm:
- key: app.kubernetes.io/name labelSelector:
operator: In matchExpressions:
values: - key: app.kubernetes.io/name
- {{ include "passbook.name" . }} operator: In
- key: app.kubernetes.io/instance values:
operator: In - {{ include "passbook.name" . }}
values: - key: app.kubernetes.io/instance
- {{ .Release.Name }} operator: In
- key: k8s.passbook.beryju.org/component values:
operator: In - {{ .Release.Name }}
values: - key: k8s.passbook.beryju.org/component
- worker operator: In
topologyKey: "kubernetes.io/hostname" values:
- worker
topologyKey: "kubernetes.io/hostname"
containers: containers:
- name: {{ .Chart.Name }} - name: {{ .Chart.Name }}
image: "{{ .Values.image.name }}:{{ .Values.image.tag }}" image: "{{ .Values.image.name }}:{{ .Values.image.tag }}"
@ -68,7 +70,7 @@ spec:
resources: resources:
requests: requests:
cpu: 150m cpu: 150m
memory: 300M memory: 400M
limits: limits:
cpu: 300m cpu: 300m
memory: 500M memory: 600M

View File

@ -4,7 +4,7 @@
image: image:
name: beryju/passbook name: beryju/passbook
name_static: beryju/passbook-static name_static: beryju/passbook-static
tag: 0.10.1-stable tag: 0.10.2-stable
nameOverride: "" nameOverride: ""

View File

@ -4,7 +4,7 @@ printf '{"event": "Bootstrap completed", "level": "info", "logger": "bootstrap",
if [[ "$1" == "server" ]]; then if [[ "$1" == "server" ]]; then
gunicorn -c /lifecycle/gunicorn.conf.py passbook.root.asgi:application gunicorn -c /lifecycle/gunicorn.conf.py passbook.root.asgi:application
elif [[ "$1" == "worker" ]]; then elif [[ "$1" == "worker" ]]; then
celery worker --autoscale=10,3 -E -B -A=passbook.root.celery -s=/tmp/celerybeat-schedule celery worker --autoscale=10,3 -E -B -A=passbook.root.celery -s=/tmp/celerybeat-schedule -Q passbook,passbook_scheduled
elif [[ "$1" == "migrate" ]]; then elif [[ "$1" == "migrate" ]]; then
# Run system migrations first, run normal migrations after # Run system migrations first, run normal migrations after
python -m lifecycle.migrate python -m lifecycle.migrate

View File

@ -1,2 +1,2 @@
"""passbook""" """passbook"""
__version__ = "0.10.1-stable" __version__ = "0.10.2-stable"

View File

@ -45,7 +45,7 @@ class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs["application_count"] = len(Application.objects.all()) kwargs["application_count"] = len(Application.objects.all())
kwargs["policy_count"] = len(Policy.objects.all()) kwargs["policy_count"] = len(Policy.objects.all())
kwargs["user_count"] = len(User.objects.all()) kwargs["user_count"] = len(User.objects.all()) - 1 # Remove anonymous user
kwargs["provider_count"] = len(Provider.objects.all()) kwargs["provider_count"] = len(Provider.objects.all())
kwargs["source_count"] = len(Source.objects.all()) kwargs["source_count"] = len(Source.objects.all())
kwargs["stage_count"] = len(Stage.objects.all()) kwargs["stage_count"] = len(Stage.objects.all())

View File

@ -1,16 +1,19 @@
"""flow views tests""" """flow views tests"""
from unittest.mock import MagicMock, PropertyMock, patch from unittest.mock import MagicMock, PropertyMock, patch
from django.http import HttpRequest, HttpResponse
from django.shortcuts import reverse from django.shortcuts import reverse
from django.test import Client, TestCase from django.test import Client, TestCase
from django.utils.encoding import force_str from django.utils.encoding import force_str
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from passbook.flows.markers import ReevaluateMarker, StageMarker from passbook.flows.markers import ReevaluateMarker, StageMarker
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
from passbook.flows.planner import FlowPlan from passbook.flows.planner import FlowPlan
from passbook.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN from passbook.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN
from passbook.lib.config import CONFIG from passbook.lib.config import CONFIG
from passbook.policies.dummy.models import DummyPolicy from passbook.policies.dummy.models import DummyPolicy
from passbook.policies.http import AccessDeniedResponse
from passbook.policies.models import PolicyBinding from passbook.policies.models import PolicyBinding
from passbook.policies.types import PolicyResult from passbook.policies.types import PolicyResult
from passbook.stages.dummy.models import DummyStage from passbook.stages.dummy.models import DummyStage
@ -19,6 +22,15 @@ POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False))
POLICY_RETURN_TRUE = MagicMock(return_value=PolicyResult(True)) POLICY_RETURN_TRUE = MagicMock(return_value=PolicyResult(True))
def to_stage_response(request: HttpRequest, source: HttpResponse):
"""Mock for to_stage_response that returns the original response, so we can check
inheritance and member attributes"""
return source
TO_STAGE_RESPONSE_MOCK = MagicMock(side_effect=to_stage_response)
class TestFlowExecutor(TestCase): class TestFlowExecutor(TestCase):
"""Test views logic""" """Test views logic"""
@ -50,6 +62,9 @@ class TestFlowExecutor(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(cancel_mock.call_count, 2) self.assertEqual(cancel_mock.call_count, 2)
@patch(
"passbook.flows.views.to_stage_response", TO_STAGE_RESPONSE_MOCK,
)
@patch( @patch(
"passbook.policies.engine.PolicyEngine.result", POLICY_RETURN_FALSE, "passbook.policies.engine.PolicyEngine.result", POLICY_RETURN_FALSE,
) )
@ -66,11 +81,12 @@ class TestFlowExecutor(TestCase):
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertIsInstance(response, AccessDeniedResponse)
force_str(response.content), self.assertInHTML(FlowNonApplicableException.__doc__, response.rendered_content)
{"type": "redirect", "to": reverse("passbook_flows:denied")},
)
@patch(
"passbook.flows.views.to_stage_response", TO_STAGE_RESPONSE_MOCK,
)
def test_invalid_empty_flow(self): def test_invalid_empty_flow(self):
"""Tests that an empty flow returns the correct error message""" """Tests that an empty flow returns the correct error message"""
flow = Flow.objects.create( flow = Flow.objects.create(
@ -84,10 +100,8 @@ class TestFlowExecutor(TestCase):
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertIsInstance(response, AccessDeniedResponse)
force_str(response.content), self.assertInHTML(EmptyFlowException.__doc__, response.rendered_content)
{"type": "redirect", "to": reverse("passbook_flows:denied")},
)
def test_invalid_flow_redirect(self): def test_invalid_flow_redirect(self):
"""Tests that an invalid flow still redirects""" """Tests that an invalid flow still redirects"""
@ -101,8 +115,10 @@ class TestFlowExecutor(TestCase):
dest = "/unique-string" dest = "/unique-string"
url = reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}) url = reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug})
response = self.client.get(url + f"?{NEXT_ARG_NAME}={dest}") response = self.client.get(url + f"?{NEXT_ARG_NAME}={dest}")
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 200)
self.assertEqual(response.url, dest) self.assertJSONEqual(
force_str(response.content), {"type": "redirect", "to": dest},
)
def test_multi_stage_flow(self): def test_multi_stage_flow(self):
"""Test a full flow with multiple stages""" """Test a full flow with multiple stages"""

View File

@ -6,12 +6,10 @@ from passbook.flows.views import (
CancelView, CancelView,
FlowExecutorShellView, FlowExecutorShellView,
FlowExecutorView, FlowExecutorView,
FlowPermissionDeniedView,
ToDefaultFlow, ToDefaultFlow,
) )
urlpatterns = [ urlpatterns = [
path("-/denied/", FlowPermissionDeniedView.as_view(), name="denied"),
path( path(
"-/default/authentication/", "-/default/authentication/",
ToDefaultFlow.as_view(designation=FlowDesignation.AUTHENTICATION), ToDefaultFlow.as_view(designation=FlowDesignation.AUTHENTICATION),

View File

@ -9,10 +9,9 @@ from django.http import (
HttpResponseRedirect, HttpResponseRedirect,
JsonResponse, JsonResponse,
) )
from django.shortcuts import get_object_or_404, redirect, render, reverse from django.shortcuts import get_object_or_404, redirect, reverse
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _
from django.views.decorators.clickjacking import xframe_options_sameorigin from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.generic import TemplateView, View from django.views.generic import TemplateView, View
from structlog import get_logger from structlog import get_logger
@ -24,6 +23,7 @@ from passbook.flows.models import Flow, FlowDesignation, Stage
from passbook.flows.planner import FlowPlan, FlowPlanner from passbook.flows.planner import FlowPlan, FlowPlanner
from passbook.lib.utils.reflection import class_to_path from passbook.lib.utils.reflection import class_to_path
from passbook.lib.utils.urls import is_url_absolute, redirect_with_qs from passbook.lib.utils.urls import is_url_absolute, redirect_with_qs
from passbook.policies.http import AccessDeniedResponse
LOGGER = get_logger() LOGGER = get_logger()
# Argument used to redirect user after login # Argument used to redirect user after login
@ -31,8 +31,6 @@ NEXT_ARG_NAME = "next"
SESSION_KEY_PLAN = "passbook_flows_plan" SESSION_KEY_PLAN = "passbook_flows_plan"
SESSION_KEY_APPLICATION_PRE = "passbook_flows_application_pre" SESSION_KEY_APPLICATION_PRE = "passbook_flows_application_pre"
SESSION_KEY_GET = "passbook_flows_get" SESSION_KEY_GET = "passbook_flows_get"
SESSION_KEY_DENIED_ERROR = "passbook_flows_denied_error"
SESSION_KEY_DENIED_POLICY_RESULT = "passbook_flows_denied_policy_result"
@method_decorator(xframe_options_sameorigin, name="dispatch") @method_decorator(xframe_options_sameorigin, name="dispatch")
@ -56,9 +54,7 @@ class FlowExecutorView(View):
LOGGER.debug("f(exec): Redirecting to next on fail") LOGGER.debug("f(exec): Redirecting to next on fail")
return redirect(self.request.GET.get(NEXT_ARG_NAME)) return redirect(self.request.GET.get(NEXT_ARG_NAME))
message = exc.__doc__ if exc.__doc__ else str(exc) message = exc.__doc__ if exc.__doc__ else str(exc)
return to_stage_response( return self.stage_invalid(error_message=message)
self.request, self.stage_invalid(error_message=message)
)
def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse: def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse:
# Early check if theres an active Plan for the current session # Early check if theres an active Plan for the current session
@ -83,10 +79,10 @@ class FlowExecutorView(View):
self.plan = self._initiate_plan() self.plan = self._initiate_plan()
except FlowNonApplicableException as exc: except FlowNonApplicableException as exc:
LOGGER.warning("f(exec): Flow not applicable to current user", exc=exc) LOGGER.warning("f(exec): Flow not applicable to current user", exc=exc)
return self.handle_invalid_flow(exc) return to_stage_response(self.request, self.handle_invalid_flow(exc))
except EmptyFlowException as exc: except EmptyFlowException as exc:
LOGGER.warning("f(exec): Flow is empty", exc=exc) LOGGER.warning("f(exec): Flow is empty", exc=exc)
return self.handle_invalid_flow(exc) return to_stage_response(self.request, self.handle_invalid_flow(exc))
# We don't save the Plan after getting the next stage # We don't save the Plan after getting the next stage
# as it hasn't been successfully passed yet # as it hasn't been successfully passed yet
next_stage = self.plan.next() next_stage = self.plan.next()
@ -119,14 +115,7 @@ class FlowExecutorView(View):
return to_stage_response(request, stage_response) return to_stage_response(request, stage_response)
except Exception as exc: # pylint: disable=broad-except except Exception as exc: # pylint: disable=broad-except
LOGGER.exception(exc) LOGGER.exception(exc)
return to_stage_response( return to_stage_response(request, FlowErrorResponse(request, exc))
request,
render(
request,
"flows/error.html",
{"error": exc, "tb": "".join(format_tb(exc.__traceback__))},
),
)
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""pass post request to current stage""" """pass post request to current stage"""
@ -141,14 +130,7 @@ class FlowExecutorView(View):
return to_stage_response(request, stage_response) return to_stage_response(request, stage_response)
except Exception as exc: # pylint: disable=broad-except except Exception as exc: # pylint: disable=broad-except
LOGGER.exception(exc) LOGGER.exception(exc)
return to_stage_response( return to_stage_response(request, FlowErrorResponse(request, exc))
request,
render(
request,
"flows/error.html",
{"error": exc, "tb": "".join(format_tb(exc.__traceback__))},
),
)
def _initiate_plan(self) -> FlowPlan: def _initiate_plan(self) -> FlowPlan:
planner = FlowPlanner(self.flow) planner = FlowPlanner(self.flow)
@ -205,12 +187,9 @@ class FlowExecutorView(View):
is a superuser.""" is a superuser."""
LOGGER.debug("f(exec): Stage invalid", flow_slug=self.flow.slug) LOGGER.debug("f(exec): Stage invalid", flow_slug=self.flow.slug)
self.cancel() self.cancel()
if self.request.user and self.request.user.is_authenticated: response = AccessDeniedResponse(self.request)
if self.request.user.is_superuser or self.request.user.attributes.get( response.error_message = error_message
PASSBOOK_USER_DEBUG, False return response
):
self.request.session[SESSION_KEY_DENIED_ERROR] = error_message
return redirect_with_qs("passbook_flows:denied", self.request.GET)
def cancel(self): def cancel(self):
"""Cancel current execution and return a redirect""" """Cancel current execution and return a redirect"""
@ -224,21 +203,30 @@ class FlowExecutorView(View):
del self.request.session[key] del self.request.session[key]
class FlowPermissionDeniedView(TemplateView): class FlowErrorResponse(TemplateResponse):
"""User could not be authenticated""" """Response class when an unhandled error occurs during a stage. Normal users
are shown an error message, superusers are shown a full stacktrace."""
template_name = "flows/denied.html" error: Exception
title = _("Permission denied.")
def get_context_data(self, **kwargs): def __init__(self, request: HttpRequest, error: Exception) -> None:
kwargs["title"] = self.title # For some reason pyright complains about keyword argument usage here
if SESSION_KEY_DENIED_ERROR in self.request.session: # pyright: reportGeneralTypeIssues=false
kwargs["error"] = self.request.session[SESSION_KEY_DENIED_ERROR] super().__init__(request=request, template="flows/error.html")
if SESSION_KEY_DENIED_POLICY_RESULT in self.request.session: self.error = error
kwargs["policy_result"] = self.request.session[
SESSION_KEY_DENIED_POLICY_RESULT def resolve_context(
] self, context: Optional[Dict[str, Any]]
return super().get_context_data(**kwargs) ) -> Optional[Dict[str, Any]]:
if not context:
context = {}
context["error"] = self.error
if self._request.user and self._request.user.is_authenticated:
if self._request.user.is_superuser or self._request.user.attributes.get(
PASSBOOK_USER_DEBUG, False
):
context["tb"] = "".join(format_tb(self.error.__traceback__))
return context
class FlowExecutorShellView(TemplateView): class FlowExecutorShellView(TemplateView):

View File

@ -1,6 +1,7 @@
"""passbook sentry integration""" """passbook sentry integration"""
from billiard.exceptions import WorkerLostError from billiard.exceptions import WorkerLostError
from botocore.client import ClientError from botocore.client import ClientError
from celery.exceptions import CeleryError
from django.core.exceptions import DisallowedHost, ValidationError from django.core.exceptions import DisallowedHost, ValidationError
from django.db import InternalError, OperationalError, ProgrammingError from django.db import InternalError, OperationalError, ProgrammingError
from django_redis.exceptions import ConnectionInterrupted from django_redis.exceptions import ConnectionInterrupted
@ -8,6 +9,7 @@ from redis.exceptions import ConnectionError as RedisConnectionError
from redis.exceptions import RedisError from redis.exceptions import RedisError
from rest_framework.exceptions import APIException from rest_framework.exceptions import APIException
from structlog import get_logger from structlog import get_logger
from websockets.exceptions import WebSocketException
LOGGER = get_logger() LOGGER = get_logger()
@ -35,6 +37,8 @@ def before_send(event, hint):
OSError, OSError,
RedisError, RedisError,
SentryIgnoredException, SentryIgnoredException,
WebSocketException,
CeleryError,
) )
if "exc_info" in hint: if "exc_info" in hint:
_, exc_value, _ = hint["exc_info"] _, exc_value, _ = hint["exc_info"]

View File

@ -1,10 +1,10 @@
"""Outposts Settings""" """Outposts Settings"""
from celery.schedules import crontab # from celery.schedules import crontab
CELERY_BEAT_SCHEDULE = { # CELERY_BEAT_SCHEDULE = {
"outposts_k8s": { # "outposts_k8s": {
"task": "passbook.outposts.tasks.outpost_k8s_controller", # "task": "passbook.outposts.tasks.outpost_k8s_controller",
"schedule": crontab(minute="*/5"), # Run every 5 minutes # "schedule": crontab(minute="*/5"), # Run every 5 minutes
"options": {"queue": "passbook_scheduled"}, # "options": {"queue": "passbook_scheduled"},
} # }
} # }

44
passbook/policies/http.py Normal file
View File

@ -0,0 +1,44 @@
"""policy http response"""
from typing import Any, Dict, Optional
from django.http.request import HttpRequest
from django.template.response import TemplateResponse
from django.utils.translation import gettext as _
from passbook.core.models import PASSBOOK_USER_DEBUG
from passbook.policies.types import PolicyResult
class AccessDeniedResponse(TemplateResponse):
"""Response used for access denied messages. Can optionally show an error message,
and if the user is a superuser or has user_debug enabled, shows a policy result."""
title: str
error_message: Optional[str] = None
policy_result: Optional[PolicyResult] = None
def __init__(self, request: HttpRequest) -> None:
# For some reason pyright complains about keyword argument usage here
# pyright: reportGeneralTypeIssues=false
super().__init__(request=request, template="policies/denied.html")
self.title = _("Access denied")
def resolve_context(
self, context: Optional[Dict[str, Any]]
) -> Optional[Dict[str, Any]]:
if not context:
context = {}
context["title"] = self.title
if self.error_message:
context["error"] = self.error_message
# Only show policy result if user is authenticated and
# either superuser or has PASSBOOK_USER_DEBUG set
if self.policy_result:
if self._request.user and self._request.user.is_authenticated:
if (
self._request.user.is_superuser
or self._request.user.attributes.get(PASSBOOK_USER_DEBUG, False)
):
context["policy_result"] = self.policy_result
return context

View File

@ -5,16 +5,13 @@ 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.shortcuts import redirect
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
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
from passbook.flows.views import ( from passbook.flows.views import SESSION_KEY_APPLICATION_PRE
SESSION_KEY_APPLICATION_PRE,
SESSION_KEY_DENIED_POLICY_RESULT,
)
from passbook.policies.engine import PolicyEngine from passbook.policies.engine import PolicyEngine
from passbook.policies.http import AccessDeniedResponse
from passbook.policies.types import PolicyResult from passbook.policies.types import PolicyResult
LOGGER = get_logger() LOGGER = get_logger()
@ -31,6 +28,9 @@ class PolicyAccessMixin(BaseMixin, AccessMixin):
Provider functions to check application access, etc""" Provider functions to check application access, etc"""
def handle_no_permission(self, application: Optional[Application] = None): def handle_no_permission(self, application: Optional[Application] = None):
"""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
a hint on the Identification Stage what the user should login for."""
if application: if application:
self.request.session[SESSION_KEY_APPLICATION_PRE] = application self.request.session[SESSION_KEY_APPLICATION_PRE] = application
return redirect_to_login( return redirect_to_login(
@ -43,10 +43,10 @@ class PolicyAccessMixin(BaseMixin, AccessMixin):
self, result: Optional[PolicyResult] = None self, result: Optional[PolicyResult] = None
) -> HttpResponse: ) -> HttpResponse:
"""Function called when user has no permissions but is authenticated""" """Function called when user has no permissions but is authenticated"""
response = AccessDeniedResponse(self.request)
if result: if result:
self.request.session[SESSION_KEY_DENIED_POLICY_RESULT] = result response.policy_result = result
# TODO: Remove this URL and render the view instead return response
return redirect("passbook_flows:denied")
def provider_to_application(self, provider: Provider) -> Application: def provider_to_application(self, provider: Provider) -> Application:
"""Lookup application assigned to provider, throw error if no application assigned""" """Lookup application assigned to provider, throw error if no application assigned"""

View File

@ -0,0 +1,14 @@
"""passbook ldap source signals"""
from django.db.models.signals import post_save
from django.dispatch import receiver
from passbook.sources.ldap.models import LDAPSource
from passbook.sources.ldap.tasks import sync_single
@receiver(post_save, sender=LDAPSource)
# pylint: disable=unused-argument
def sync_ldap_source_on_save(sender, instance: LDAPSource, **_):
"""Ensure that source is synced on save (if enabled)"""
if instance.enabled:
sync_single.delay(instance.pk)

View File

@ -8,7 +8,14 @@ from passbook.sources.ldap.models import LDAPSource
def sync(): def sync():
"""Sync all sources""" """Sync all sources"""
for source in LDAPSource.objects.filter(enabled=True): for source in LDAPSource.objects.filter(enabled=True):
connector = Connector(source) sync_single.delay(source.pk)
connector.sync_users()
connector.sync_groups()
connector.sync_membership() @CELERY_APP.task()
def sync_single(source_pk):
"""Sync a single source"""
source = LDAPSource.objects.get(pk=source_pk)
connector = Connector(source)
connector.sync_users()
connector.sync_groups()
connector.sync_membership()

View File

@ -10,7 +10,9 @@ from passbook.core.models import User
from passbook.flows.markers import StageMarker from passbook.flows.markers import StageMarker
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from passbook.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK
from passbook.flows.views import SESSION_KEY_PLAN from passbook.flows.views import SESSION_KEY_PLAN
from passbook.policies.http import AccessDeniedResponse
from passbook.stages.invitation.forms import InvitationStageForm from passbook.stages.invitation.forms import InvitationStageForm
from passbook.stages.invitation.models import Invitation, InvitationStage from passbook.stages.invitation.models import Invitation, InvitationStage
from passbook.stages.invitation.stage import INVITATION_TOKEN_KEY, PLAN_CONTEXT_PROMPT from passbook.stages.invitation.stage import INVITATION_TOKEN_KEY, PLAN_CONTEXT_PROMPT
@ -38,6 +40,9 @@ class TestUserLoginStage(TestCase):
data = {"name": "test"} data = {"name": "test"}
self.assertEqual(InvitationStageForm(data).is_valid(), True) self.assertEqual(InvitationStageForm(data).is_valid(), True)
@patch(
"passbook.flows.views.to_stage_response", TO_STAGE_RESPONSE_MOCK,
)
def test_without_invitation_fail(self): def test_without_invitation_fail(self):
"""Test without any invitation, continue_flow_without_invitation not set.""" """Test without any invitation, continue_flow_without_invitation not set."""
plan = FlowPlan( plan = FlowPlan(
@ -56,12 +61,8 @@ class TestUserLoginStage(TestCase):
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
) )
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertIsInstance(response, AccessDeniedResponse)
force_str(response.content),
{"type": "redirect", "to": reverse("passbook_flows:denied")},
)
def test_without_invitation_continue(self): def test_without_invitation_continue(self):
"""Test without any invitation, continue_flow_without_invitation is set.""" """Test without any invitation, continue_flow_without_invitation is set."""

View File

@ -12,7 +12,9 @@ from passbook.core.models import User
from passbook.flows.markers import StageMarker from passbook.flows.markers import StageMarker
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from passbook.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK
from passbook.flows.views import SESSION_KEY_PLAN from passbook.flows.views import SESSION_KEY_PLAN
from passbook.policies.http import AccessDeniedResponse
from passbook.stages.password.models import PasswordStage from passbook.stages.password.models import PasswordStage
MOCK_BACKEND_AUTHENTICATE = MagicMock(side_effect=PermissionDenied("test")) MOCK_BACKEND_AUTHENTICATE = MagicMock(side_effect=PermissionDenied("test"))
@ -42,6 +44,9 @@ class TestPasswordStage(TestCase):
) )
FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
@patch(
"passbook.flows.views.to_stage_response", TO_STAGE_RESPONSE_MOCK,
)
def test_without_user(self): def test_without_user(self):
"""Test without user""" """Test without user"""
plan = FlowPlan( plan = FlowPlan(
@ -60,10 +65,7 @@ class TestPasswordStage(TestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertIsInstance(response, AccessDeniedResponse)
force_str(response.content),
{"type": "redirect", "to": reverse("passbook_flows:denied")},
)
def test_recovery_flow_link(self): def test_recovery_flow_link(self):
"""Test link to the default recovery flow""" """Test link to the default recovery flow"""
@ -129,6 +131,9 @@ class TestPasswordStage(TestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@patch(
"passbook.flows.views.to_stage_response", TO_STAGE_RESPONSE_MOCK,
)
@patch( @patch(
"django.contrib.auth.backends.ModelBackend.authenticate", "django.contrib.auth.backends.ModelBackend.authenticate",
MOCK_BACKEND_AUTHENTICATE, MOCK_BACKEND_AUTHENTICATE,
@ -153,7 +158,4 @@ class TestPasswordStage(TestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertIsInstance(response, AccessDeniedResponse)
force_str(response.content),
{"type": "redirect", "to": reverse("passbook_flows:denied")},
)

View File

@ -1,4 +1,6 @@
"""delete tests""" """delete tests"""
from unittest.mock import patch
from django.shortcuts import reverse from django.shortcuts import reverse
from django.test import Client, TestCase from django.test import Client, TestCase
from django.utils.encoding import force_str from django.utils.encoding import force_str
@ -7,7 +9,9 @@ from passbook.core.models import User
from passbook.flows.markers import StageMarker from passbook.flows.markers import StageMarker
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from passbook.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK
from passbook.flows.views import SESSION_KEY_PLAN from passbook.flows.views import SESSION_KEY_PLAN
from passbook.policies.http import AccessDeniedResponse
from passbook.stages.user_delete.models import UserDeleteStage from passbook.stages.user_delete.models import UserDeleteStage
@ -28,6 +32,9 @@ class TestUserDeleteStage(TestCase):
self.stage = UserDeleteStage.objects.create(name="delete") self.stage = UserDeleteStage.objects.create(name="delete")
FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
@patch(
"passbook.flows.views.to_stage_response", TO_STAGE_RESPONSE_MOCK,
)
def test_no_user(self): def test_no_user(self):
"""Test without user set""" """Test without user set"""
plan = FlowPlan( plan = FlowPlan(
@ -43,10 +50,7 @@ class TestUserDeleteStage(TestCase):
) )
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertIsInstance(response, AccessDeniedResponse)
force_str(response.content),
{"type": "redirect", "to": reverse("passbook_flows:denied")},
)
def test_user_delete_get(self): def test_user_delete_get(self):
"""Test Form render""" """Test Form render"""

View File

@ -1,4 +1,6 @@
"""login tests""" """login tests"""
from unittest.mock import patch
from django.shortcuts import reverse from django.shortcuts import reverse
from django.test import Client, TestCase from django.test import Client, TestCase
from django.utils.encoding import force_str from django.utils.encoding import force_str
@ -7,7 +9,9 @@ from passbook.core.models import User
from passbook.flows.markers import StageMarker from passbook.flows.markers import StageMarker
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from passbook.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK
from passbook.flows.views import SESSION_KEY_PLAN from passbook.flows.views import SESSION_KEY_PLAN
from passbook.policies.http import AccessDeniedResponse
from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
from passbook.stages.user_login.forms import UserLoginStageForm from passbook.stages.user_login.forms import UserLoginStageForm
from passbook.stages.user_login.models import UserLoginStage from passbook.stages.user_login.models import UserLoginStage
@ -54,6 +58,9 @@ class TestUserLoginStage(TestCase):
{"type": "redirect", "to": reverse("passbook_core:overview")}, {"type": "redirect", "to": reverse("passbook_core:overview")},
) )
@patch(
"passbook.flows.views.to_stage_response", TO_STAGE_RESPONSE_MOCK,
)
def test_without_user(self): def test_without_user(self):
"""Test a plan without any pending user, resulting in a denied""" """Test a plan without any pending user, resulting in a denied"""
plan = FlowPlan( plan = FlowPlan(
@ -70,11 +77,11 @@ class TestUserLoginStage(TestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertIsInstance(response, AccessDeniedResponse)
force_str(response.content),
{"type": "redirect", "to": reverse("passbook_flows:denied")},
)
@patch(
"passbook.flows.views.to_stage_response", TO_STAGE_RESPONSE_MOCK,
)
def test_without_backend(self): def test_without_backend(self):
"""Test a plan with pending user, without backend, resulting in a denied""" """Test a plan with pending user, without backend, resulting in a denied"""
plan = FlowPlan( plan = FlowPlan(
@ -92,10 +99,7 @@ class TestUserLoginStage(TestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertIsInstance(response, AccessDeniedResponse)
force_str(response.content),
{"type": "redirect", "to": reverse("passbook_flows:denied")},
)
def test_form(self): def test_form(self):
"""Test Form""" """Test Form"""

View File

@ -1,6 +1,7 @@
"""write tests""" """write tests"""
import string import string
from random import SystemRandom from random import SystemRandom
from unittest.mock import patch
from django.shortcuts import reverse from django.shortcuts import reverse
from django.test import Client, TestCase from django.test import Client, TestCase
@ -10,7 +11,9 @@ from passbook.core.models import User
from passbook.flows.markers import StageMarker from passbook.flows.markers import StageMarker
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from passbook.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK
from passbook.flows.views import SESSION_KEY_PLAN from passbook.flows.views import SESSION_KEY_PLAN
from passbook.policies.http import AccessDeniedResponse
from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT
from passbook.stages.user_write.forms import UserWriteStageForm from passbook.stages.user_write.forms import UserWriteStageForm
from passbook.stages.user_write.models import UserWriteStage from passbook.stages.user_write.models import UserWriteStage
@ -107,6 +110,9 @@ class TestUserWriteStage(TestCase):
self.assertTrue(user_qs.first().check_password(new_password)) self.assertTrue(user_qs.first().check_password(new_password))
self.assertEqual(user_qs.first().attributes["some-custom-attribute"], "test") self.assertEqual(user_qs.first().attributes["some-custom-attribute"], "test")
@patch(
"passbook.flows.views.to_stage_response", TO_STAGE_RESPONSE_MOCK,
)
def test_without_data(self): def test_without_data(self):
"""Test without data results in error""" """Test without data results in error"""
plan = FlowPlan( plan = FlowPlan(
@ -123,10 +129,7 @@ class TestUserWriteStage(TestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertIsInstance(response, AccessDeniedResponse)
force_str(response.content),
{"type": "redirect", "to": reverse("passbook_flows:denied")},
)
def test_form(self): def test_form(self):
"""Test Form""" """Test Form"""