diff --git a/apps/buyer/forms.py b/apps/buyer/forms.py new file mode 100644 index 0000000..0046c69 --- /dev/null +++ b/apps/buyer/forms.py @@ -0,0 +1,69 @@ +"""The public buyer sign-up form. + +This is the only place on the platform a password minimum is enforced — the +realm has no password policy set and no mail service exists — so the rules live +here and are deliberately plain: long enough, not the email or the company name, +and typed twice the same. The form is also the first line of duplicate-email +handling; Keycloak refuses a second account with the same address, and the view +turns that into the same sentence. +""" +from django import forms + +from apps.users.models import User + +MIN_PASSWORD_LENGTH = 12 + +DUPLICATE_EMAIL_MESSAGE = "An account with this email already exists — sign in instead." + + +class BuyerSignupForm(forms.Form): + full_name = forms.CharField( + max_length=255, + label="Full name", + widget=forms.TextInput(attrs={"class": "form-control", "autocomplete": "name"}), + ) + company_name = forms.CharField( + max_length=255, + label="Company name", + widget=forms.TextInput(attrs={"class": "form-control", "autocomplete": "organization"}), + ) + email = forms.EmailField( + label="Email", + widget=forms.EmailInput(attrs={"class": "form-control", "autocomplete": "email"}), + ) + password = forms.CharField( + label="Password", + widget=forms.PasswordInput(attrs={"class": "form-control", "autocomplete": "new-password"}), + help_text=f"At least {MIN_PASSWORD_LENGTH} characters.", + ) + confirm_password = forms.CharField( + label="Confirm password", + widget=forms.PasswordInput(attrs={"class": "form-control", "autocomplete": "new-password"}), + ) + + def clean_email(self): + email = self.cleaned_data["email"].strip().lower() + if User.objects.filter(email__iexact=email).exists(): + raise forms.ValidationError(DUPLICATE_EMAIL_MESSAGE) + return email + + def clean(self): + cleaned = super().clean() + password = cleaned.get("password") + confirm = cleaned.get("confirm_password") + if password and confirm and password != confirm: + self.add_error("confirm_password", "The two passwords do not match.") + if password: + if len(password) < MIN_PASSWORD_LENGTH: + self.add_error( + "password", + f"Use at least {MIN_PASSWORD_LENGTH} characters.", + ) + email = (cleaned.get("email") or "").strip().lower() + company = (cleaned.get("company_name") or "").strip().lower() + if password.lower() in (email, company) and password: + self.add_error( + "password", + "Choose a password that is not your email address or company name.", + ) + return cleaned diff --git a/apps/buyer/tests.py b/apps/buyer/tests.py new file mode 100644 index 0000000..77dafeb --- /dev/null +++ b/apps/buyer/tests.py @@ -0,0 +1,122 @@ +"""The public buyer sign-up screen. + +Two things matter: the page is reachable without signing in, and the account it +creates is a plain buyer — the same kind of account everything else uses, with +no platform privileges. Keycloak is patched here; the live Admin API path is +exercised by the smoke test, not by the suite. +""" +from unittest.mock import patch +from urllib.parse import urlencode + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse + +from apps.users.keycloak import DuplicateEmailError, KeycloakUnavailableError + +User = get_user_model() + +SIGNUP_URL = "/portal/signup/" + +VALID = { + "full_name": "Ada Greenfield", + "company_name": "Greenfield Foods Ltd", + "email": "signup-smoke-01@example.com", + "password": "a-long-enough-passphrase", + "confirm_password": "a-long-enough-passphrase", +} + + +class SignupViewTests(TestCase): + def test_the_page_is_public(self): + response = self.client.get(SIGNUP_URL) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Create your buyer account") + + @patch("apps.buyer.views.create_keycloak_user", return_value="kc-sub-0001") + def test_successful_signup_creates_both_records(self, create): + response = self.client.post(SIGNUP_URL, VALID) + + expected = reverse("oidc_authentication_init") + "?" + urlencode({"next": "/portal/?welcome=1"}) + self.assertRedirects(response, expected, fetch_redirect_response=False) + create.assert_called_once_with( + email=VALID["email"], full_name=VALID["full_name"], password=VALID["password"] + ) + + user = User.objects.get(email=VALID["email"]) + self.assertEqual(user.role, "buyer") + self.assertFalse(user.is_staff) + self.assertTrue(user.is_active) + self.assertEqual(user.company_name, "Greenfield Foods Ltd") + self.assertEqual(user.keycloak_id, "kc-sub-0001") + + def test_a_duplicate_email_is_refused_without_calling_keycloak(self): + User.objects.create_user(email=VALID["email"], role="buyer") + with patch("apps.buyer.views.create_keycloak_user") as create: + response = self.client.post(SIGNUP_URL, VALID) + create.assert_not_called() + self.assertEqual(response.status_code, 200) + self.assertContains(response, "An account with this email already exists") + + @patch("apps.buyer.views.create_keycloak_user", side_effect=DuplicateEmailError("x")) + def test_a_keycloak_duplicate_is_refused_the_same_way(self, create): + response = self.client.post(SIGNUP_URL, VALID) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "An account with this email already exists") + self.assertFalse(User.objects.filter(email=VALID["email"]).exists()) + + @patch("apps.buyer.views.create_keycloak_user", side_effect=KeycloakUnavailableError("down")) + def test_an_unreachable_keycloak_is_reported_not_a_500(self, create): + response = self.client.post(SIGNUP_URL, VALID) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "could not create your account") + self.assertFalse(User.objects.filter(email=VALID["email"]).exists()) + + @patch("apps.buyer.views.create_keycloak_user", return_value="kc-sub-0002") + def test_mismatched_confirmation_is_refused(self, create): + response = self.client.post(SIGNUP_URL, {**VALID, "confirm_password": "something-else-entirely"}) + create.assert_not_called() + self.assertEqual(response.status_code, 200) + self.assertContains(response, "The two passwords do not match") + self.assertFalse(User.objects.filter(email=VALID["email"]).exists()) + + @patch("apps.buyer.views.create_keycloak_user", return_value="kc-sub-0003") + def test_a_short_password_is_refused(self, create): + response = self.client.post(SIGNUP_URL, {**VALID, "password": "short", "confirm_password": "short"}) + create.assert_not_called() + self.assertEqual(response.status_code, 200) + self.assertContains(response, "at least 12 characters") + + @patch("apps.buyer.views.create_keycloak_user", return_value="kc-sub-0004") + def test_a_password_equal_to_the_email_is_refused(self, create): + password = VALID["email"] + response = self.client.post( + SIGNUP_URL, {**VALID, "password": password, "confirm_password": password} + ) + create.assert_not_called() + self.assertEqual(response.status_code, 200) + self.assertContains(response, "not your email address or company name") + + def test_a_signed_in_user_is_sent_to_their_portal(self): + User.objects.create_user(email="already-in@example.com", role="buyer", is_staff=False) + self.client.force_login(User.objects.get(email="already-in@example.com")) + response = self.client.get(SIGNUP_URL) + self.assertRedirects(response, "/", fetch_redirect_response=False) + + +class DashboardWelcomeTests(TestCase): + def test_the_welcome_message_shows_after_signup(self): + User.objects.create_user( + email="welcome-buyer@example.com", role="buyer", is_staff=False + ) + self.client.force_login(User.objects.get(email="welcome-buyer@example.com")) + response = self.client.get("/portal/?welcome=1") + self.assertContains(response, "Your account is ready") + + def test_no_welcome_message_on_a_normal_visit(self): + User.objects.create_user( + email="quiet-buyer@example.com", role="buyer", is_staff=False + ) + self.client.force_login(User.objects.get(email="quiet-buyer@example.com")) + response = self.client.get("/portal/") + self.assertNotContains(response, "Your account is ready") diff --git a/apps/buyer/urls.py b/apps/buyer/urls.py index f2e1f78..4017578 100644 --- a/apps/buyer/urls.py +++ b/apps/buyer/urls.py @@ -4,6 +4,7 @@ from . import views app_name = 'portal' urlpatterns = [ + path("signup/", views.SignupView.as_view(), name="signup"), path("chat/", views.ChatView.as_view(), name="chat"), path("chat/embed/", views.ChatEmbedView.as_view(), name="chat_embed"), path('', views.DashboardView.as_view(), name='dashboard'), diff --git a/apps/buyer/views.py b/apps/buyer/views.py index d650f02..bfc1b2b 100644 --- a/apps/buyer/views.py +++ b/apps/buyer/views.py @@ -1,8 +1,11 @@ +from urllib.parse import urlencode + from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.db.models import Count -from django.shortcuts import get_object_or_404 +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse from django.views import View -from django.views.generic import TemplateView, ListView, DetailView +from django.views.generic import TemplateView, ListView, DetailView, FormView from apps.batches.models import ProcessingBatch from apps.shipments.models import Shipment @@ -12,6 +15,14 @@ from apps.esg.models import ESGFieldReport from apps.subscriptions.models import Subscription from apps.guides.views import GuideDetailView as BaseGuideDetailView from apps.guides.views import GuideIndexView as BaseGuideIndexView +from apps.users.keycloak import ( + DuplicateEmailError, + KeycloakError, + create_keycloak_user, +) +from apps.users.models import User + +from .forms import DUPLICATE_EMAIL_MESSAGE, BuyerSignupForm class BuyerRequiredMixin(UserPassesTestMixin): @@ -20,11 +31,64 @@ class BuyerRequiredMixin(UserPassesTestMixin): return user.is_authenticated and (user.role in ("buyer", "admin") or user.is_superuser) +class SignupView(FormView): + """Public buyer self-registration. + + Creates the Keycloak user first — the Django row stores its subject id — + then the matching ``users`` row as a buyer, and sends the new buyer through + the normal sign-in flow. There is one sign-in mechanism platform-wide; the + sign-up screen only creates the account. + + A duplicate is refused with the same sentence whether Django or Keycloak + spots it, and an unreachable Keycloak is reported, never a 500. + """ + + template_name = "portal/signup.html" + form_class = BuyerSignupForm + + def dispatch(self, request, *args, **kwargs): + if request.user.is_authenticated: + return redirect("home") + return super().dispatch(request, *args, **kwargs) + + def form_valid(self, form): + data = form.cleaned_data + try: + keycloak_id = create_keycloak_user( + email=data["email"], + full_name=data["full_name"], + password=data["password"], + ) + except DuplicateEmailError: + form.add_error("email", DUPLICATE_EMAIL_MESSAGE) + return self.form_invalid(form) + except KeycloakError: + form.add_error( + None, + "We could not create your account just now. Please try again shortly.", + ) + return self.form_invalid(form) + + User.objects.create_user( + email=data["email"], + full_name=data["full_name"], + company_name=data["company_name"], + role=User.Roles.BUYER, + is_active=True, + is_staff=False, + keycloak_id=keycloak_id, + ) + + sign_in = reverse("oidc_authentication_init") + return redirect(f"{sign_in}?{urlencode({'next': '/portal/?welcome=1'})}") + + class DashboardView(BuyerRequiredMixin, TemplateView): template_name = "portal/dashboard.html" def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) + ctx["welcome"] = self.request.GET.get("welcome") == "1" ctx["total_batches"] = ProcessingBatch.objects.count() ctx["recent_batches"] = ProcessingBatch.objects.order_by("-created_at")[:5] ctx["documents"] = Document.objects.filter(visibility="buyer").order_by("-uploaded_at")[:5] diff --git a/apps/core/middleware.py b/apps/core/middleware.py index b675ce2..5d05f19 100644 --- a/apps/core/middleware.py +++ b/apps/core/middleware.py @@ -1,6 +1,7 @@ import re -from django.shortcuts import redirect + from django.conf import settings +from django.shortcuts import redirect class RBACMiddleware: @@ -14,11 +15,23 @@ class RBACMiddleware: self.get_response = get_response def __call__(self, request): - if request.user.is_authenticated and not request.user.is_staff: + user = request.user + if user.is_authenticated and not user.is_staff and not user.is_superuser: path = request.path_info user_role = getattr(request.user, "role", None) - for role, patterns in self.ROLE_ROUTES.items(): - for pattern in patterns: - if re.match(pattern, path) and user_role != role and not request.user.is_superuser: - return redirect(settings.LOGIN_REDIRECT_URL) + allowed = self.ROLE_ROUTES.get(user_role, []) + if any(re.match(pattern, path) for pattern in allowed): + return self.get_response(request) + # The path belongs to a portal this role may not enter. Send them to + # the role-aware landing view rather than straight into a portal they + # cannot use. It used to redirect if *any* role's pattern matched the + # path without belonging to this role, which bounced a buyer off + # /portal/ itself (the admin patterns include /portal/), and looped + # with the landing redirect. + if any( + re.match(pattern, path) + for patterns in self.ROLE_ROUTES.values() + for pattern in patterns + ): + return redirect(settings.LOGIN_REDIRECT_URL) return self.get_response(request) diff --git a/apps/core/tests.py b/apps/core/tests.py index 2153f22..aa653d2 100644 --- a/apps/core/tests.py +++ b/apps/core/tests.py @@ -142,6 +142,60 @@ class PortalPagesRenderTests(TestCase): def test_buyer_pages_render(self): self.sweep(self.make_user("buyer-sweep@ixg.local", "buyer"), PORTAL_ROUTES, "portal.ixgltd.com") + def test_the_public_signup_page_renders_for_visitors(self): + response = Client(SERVER_NAME="portal.ixgltd.com").get("/portal/signup/") + self.assertEqual(response.status_code, 200) + + +class RoleRoutingTests(TestCase): + """A buyer is not staff, so the middleware must not bounce them off /portal/. + + The old middleware redirected if any role's pattern matched the path without + belonging to the user's role, and the admin patterns include /portal/ — so a + real (non-staff) buyer was sent to the landing view, which sent them back. + These pin the corrected routing: each role reaches its own portal and is + turned away from the others via the landing view. + """ + + def make_user(self, email, role): + return User.objects.create_user( + email=email, password="pass1234", role=role, is_staff=False + ) + + def test_a_buyer_reaches_the_buyer_portal(self): + client = Client(SERVER_NAME="portal.ixgltd.com") + client.force_login(self.make_user("route-buyer@ixg.local", "buyer")) + self.assertEqual(client.get("/portal/").status_code, 200) + + def test_a_buyer_is_turned_away_from_ops_and_admin(self): + client = Client(SERVER_NAME="portal.ixgltd.com") + client.force_login(self.make_user("route-buyer2@ixg.local", "buyer")) + for path in ("/ops/", "/admin/"): + with self.subTest(path=path): + response = client.get(path) + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], "/") + + def test_ops_reaches_ops(self): + client = Client(SERVER_NAME="ops.ixgltd.com") + client.force_login(self.make_user("route-ops@ixg.local", "ops")) + self.assertEqual(client.get("/ops/").status_code, 200) + + def test_the_landing_view_sends_each_role_to_its_portal(self): + cases = [("buyer", "/portal/"), ("ops", "/ops/"), ("admin", "/admin/")] + for role, destination in cases: + with self.subTest(role=role): + client = Client(SERVER_NAME="app.ixgltd.com") + client.force_login(self.make_user(f"landing-{role}@ixg.local", role)) + response = client.get("/") + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], destination) + + def test_a_visitor_is_sent_to_sign_in(self): + response = Client(SERVER_NAME="app.ixgltd.com").get("/") + self.assertEqual(response.status_code, 302) + self.assertIn("/oidc/authenticate/", response["Location"]) + class ApiPermissionBoundaryTests(TestCase): """The API permission boundary, pinned for every viewset. diff --git a/apps/core/views.py b/apps/core/views.py new file mode 100644 index 0000000..0f45720 --- /dev/null +++ b/apps/core/views.py @@ -0,0 +1,27 @@ +from django.contrib.auth.views import redirect_to_login +from django.shortcuts import redirect +from django.views import View + + +class RoleRedirectView(View): + """Send a signed-in user to their own portal; send visitors to sign-in. + + The platform root is shared by all three portals, so it cannot point at one + of them. It used to hard-code ``/admin/``, which bounced a buyer off an area + they cannot enter; the destination now depends on the role. + + ``/`` deliberately sits outside every RBAC route prefix, so + ``RBACMiddleware`` can redirect a wrong-role request here (via + ``LOGIN_REDIRECT_URL``) without looping back into a route it would redirect + again. + """ + + def get(self, request): + user = request.user + if not user.is_authenticated: + return redirect_to_login(request.get_full_path()) + if user.role == "admin" or user.is_superuser: + return redirect("/admin/") + if user.role == "ops": + return redirect("/ops/") + return redirect("/portal/") diff --git a/apps/users/auth.py b/apps/users/auth.py index 4c284cc..5ee0750 100644 --- a/apps/users/auth.py +++ b/apps/users/auth.py @@ -1,25 +1,38 @@ from mozilla_django_oidc.auth import OIDCAuthenticationBackend +# Roles that carry platform privileges. A buyer is not staff: staff status is +# derived from the role, never asserted, so a self-registered buyer cannot come +# out as staff (or flip to staff on a later sign-in). +STAFF_ROLES = {"ops", "admin"} + + +def is_staff_for_role(role, *, is_superuser=False): + return bool(is_superuser) or role in STAFF_ROLES + + class KeycloakOIDCBackend(OIDCAuthenticationBackend): def create_user(self, claims): email = claims.get("email", "") + role = claims.get("role", "buyer") + if role not in dict(self.UserModel.Roles.choices): + role = self.UserModel.Roles.BUYER user = self.UserModel.objects.create_user( email=email, full_name=claims.get("name", ""), keycloak_id=claims.get("sub", ""), - role=claims.get("role", "buyer"), - is_staff=True, + role=role, + is_staff=is_staff_for_role(role), ) return user def update_user(self, user, claims): user.email = claims.get("email", user.email) user.full_name = claims.get("name", user.full_name) - user.is_staff = True role = claims.get("role", user.role) if role in dict(self.UserModel.Roles.choices): user.role = role + user.is_staff = is_staff_for_role(user.role, is_superuser=user.is_superuser) user.save() return user diff --git a/apps/users/keycloak.py b/apps/users/keycloak.py new file mode 100644 index 0000000..e250e05 --- /dev/null +++ b/apps/users/keycloak.py @@ -0,0 +1,120 @@ +"""Create Keycloak users from the platform. + +The public buyer sign-up screen needs a Keycloak account before it can write the +matching Django row, because that row stores the Keycloak subject id. Realm +self-registration is off (``registrationAllowed = false``) and could not capture +the company name anyway, so the platform creates the user itself through the +Keycloak Admin API, using a dedicated service account +(``KEYCLOAK_PROVISIONING_CLIENT_ID``) that holds only ``manage-users`` and +``view-users`` on ``realm-management``. + +The account it creates is deliberately bare: **no realm roles and no client +roles**, so it grants nothing on the platform. The Django row's ``role`` is the +authority; Keycloak is only the identity store. + +Credentials are read from settings (django-environ), never hard-coded, and no +token or secret is ever logged. +""" +import requests +from django.conf import settings + + +class KeycloakError(Exception): + """Base class for provisioning failures the sign-up screen can report.""" + + +class DuplicateEmailError(KeycloakError): + """Keycloak already has a user with this email address.""" + + +class KeycloakUnavailableError(KeycloakError): + """Keycloak could not be reached, or refused the service account.""" + + +def _realm_url(): + return f"{settings.KEYCLOAK_SERVER_URL.rstrip('/')}/realms/{settings.KEYCLOAK_REALM}" + + +def _admin_url(path): + return ( + f"{settings.KEYCLOAK_SERVER_URL.rstrip('/')}" + f"/admin/realms/{settings.KEYCLOAK_REALM}{path}" + ) + + +def _split_name(full_name): + """Split a full name for Keycloak's required firstName/lastName fields. + + The realm's user profile marks both as required for users, and Keycloak + inserts a "Update Account Information" (``VERIFY_PROFILE``) step at sign-in + whenever one is empty. A single-word name therefore fills both fields: the + platform shows ``full_name`` from its own row, so the split only has to + satisfy Keycloak, not be a perfect decomposition. + """ + parts = full_name.strip().split() + if not parts: + return "", "" + if len(parts) == 1: + return parts[0], parts[0] + return " ".join(parts[:-1]), parts[-1] + + +def _service_account_token(timeout=10): + """Exchange the provisioning client's credentials for an admin token.""" + try: + response = requests.post( + f"{_realm_url()}/protocol/openid-connect/token", + data={ + "grant_type": "client_credentials", + "client_id": settings.KEYCLOAK_PROVISIONING_CLIENT_ID, + "client_secret": settings.KEYCLOAK_PROVISIONING_CLIENT_SECRET, + }, + timeout=timeout, + ) + except requests.RequestException as exc: + raise KeycloakUnavailableError("Could not reach Keycloak.") from exc + if response.status_code != 200: + raise KeycloakUnavailableError("Keycloak refused the provisioning account.") + return response.json()["access_token"] + + +def create_keycloak_user(*, email, full_name, password, timeout=10): + """Create an enabled, unverified Keycloak user and return its subject id. + + ``emailVerified`` is left false on purpose: the realm has ``verifyEmail`` + off and no mail service exists, so an unverified user can still sign in. + A duplicate email (``duplicateEmailsAllowed`` is off) surfaces as + :class:`DuplicateEmailError` rather than a raw 409. + """ + token = _service_account_token(timeout=timeout) + first_name, last_name = _split_name(full_name) + payload = { + "username": email, + "email": email, + "firstName": first_name, + "lastName": last_name, + "enabled": True, + "emailVerified": False, + "credentials": [ + {"type": "password", "value": password, "temporary": False} + ], + } + try: + response = requests.post( + _admin_url("/users"), + json=payload, + headers={"Authorization": f"Bearer {token}"}, + timeout=timeout, + ) + except requests.RequestException as exc: + raise KeycloakUnavailableError("Could not reach Keycloak.") from exc + + if response.status_code == 409: + raise DuplicateEmailError(email) + if response.status_code not in (201, 204): + raise KeycloakUnavailableError("Keycloak could not create the account.") + + location = response.headers.get("Location", "") + if not location: + raise KeycloakUnavailableError("Keycloak did not return the new account.") + return location.rstrip("/").rsplit("/", 1)[-1] diff --git a/apps/users/tests.py b/apps/users/tests.py new file mode 100644 index 0000000..fb4f530 --- /dev/null +++ b/apps/users/tests.py @@ -0,0 +1,107 @@ +"""Staff status is derived from the role, never asserted. + +The bug these pin: ``create_user`` and ``update_user`` used to set +``is_staff=True`` on every account, so a self-registered buyer came out as staff +— and, because ``update_user`` runs on every sign-in, a buyer row created +correctly would still flip to staff the first time they signed in. The second +case is the one that passes a first test and fails later, so it is covered +directly. +""" +from django.contrib.auth import get_user_model +from django.test import TestCase + +from .auth import KeycloakOIDCBackend, is_staff_for_role +from .keycloak import _split_name + +User = get_user_model() + + +class SplitNameTests(TestCase): + """Both Keycloak name fields must be non-empty, or sign-in shows the + "Update Account Information" (VERIFY_PROFILE) step.""" + + def test_a_two_part_name_splits(self): + self.assertEqual(_split_name("Ada Greenfield"), ("Ada", "Greenfield")) + + def test_a_multi_part_name_keeps_the_last_word_as_the_surname(self): + self.assertEqual(_split_name("Ada Mary Greenfield"), ("Ada Mary", "Greenfield")) + + def test_a_single_word_name_fills_both_fields(self): + self.assertEqual(_split_name("Ada"), ("Ada", "Ada")) + + def test_an_empty_name_yields_empty_fields(self): + self.assertEqual(_split_name(" "), ("", "")) + + +class StaffRoleTests(TestCase): + def setUp(self): + self.backend = KeycloakOIDCBackend() + + def test_only_ops_and_admin_are_staff(self): + self.assertFalse(is_staff_for_role("buyer")) + self.assertTrue(is_staff_for_role("ops")) + self.assertTrue(is_staff_for_role("admin")) + self.assertTrue(is_staff_for_role("buyer", is_superuser=True)) + + def test_create_user_derives_staff_from_the_claims_role(self): + buyer = self.backend.create_user( + {"email": "signup-buyer@example.com", "name": "Buyer One", "sub": "sub-buyer", "role": "buyer"} + ) + self.assertEqual(buyer.role, "buyer") + self.assertFalse(buyer.is_staff) + + ops = self.backend.create_user( + {"email": "signup-ops@example.com", "name": "Ops One", "sub": "sub-ops", "role": "ops"} + ) + self.assertTrue(ops.is_staff) + + def test_create_user_defaults_to_buyer_without_a_role_claim(self): + user = self.backend.create_user( + {"email": "signup-norole@example.com", "name": "No Role", "sub": "sub-norole"} + ) + self.assertEqual(user.role, "buyer") + self.assertFalse(user.is_staff) + + def test_update_user_does_not_staff_a_buyer(self): + user = User.objects.create_user( + email="buyer-stays@example.com", role="buyer", is_staff=False, keycloak_id="sub-1" + ) + self.backend.update_user( + user, {"email": "buyer-stays@example.com", "name": "Buyer", "sub": "sub-1", "role": "buyer"} + ) + user.refresh_from_db() + self.assertEqual(user.role, "buyer") + self.assertFalse(user.is_staff) + + def test_update_user_staffs_ops_and_admin(self): + for role in ("ops", "admin"): + user = User.objects.create_user( + email=f"{role}-signin@example.com", role="buyer", is_staff=False, + keycloak_id=f"sub-{role}", + ) + self.backend.update_user( + user, {"email": user.email, "name": role, "sub": user.keycloak_id, "role": role} + ) + user.refresh_from_db() + self.assertEqual(user.role, role) + self.assertTrue(user.is_staff) + + def test_update_user_keeps_the_existing_role_when_the_claim_is_absent(self): + user = User.objects.create_user( + email="ops-no-role-claim@example.com", role="ops", is_staff=True, keycloak_id="sub-2" + ) + self.backend.update_user(user, {"email": user.email, "name": "Ops", "sub": "sub-2"}) + user.refresh_from_db() + self.assertEqual(user.role, "ops") + self.assertTrue(user.is_staff) + + def test_update_user_preserves_a_superuser(self): + user = User.objects.create_superuser( + email="super@example.com", password="pass1234", role="buyer", keycloak_id="sub-3" + ) + self.backend.update_user( + user, {"email": user.email, "name": "Super", "sub": "sub-3", "role": "buyer"} + ) + user.refresh_from_db() + self.assertTrue(user.is_superuser) + self.assertTrue(user.is_staff) diff --git a/dsp_platform/settings.py b/dsp_platform/settings.py index fb93f25..9769b8a 100644 --- a/dsp_platform/settings.py +++ b/dsp_platform/settings.py @@ -179,11 +179,21 @@ OIDC_STORE_ACCESS_TOKEN = True OIDC_STORE_ID_TOKEN = True LOGIN_URL = "oidc_authentication_init" LOGOUT_REDIRECT_URL = "/" -LOGIN_REDIRECT_URL = "/admin/" +# The root view dispatches by role (apps/core/views.RoleRedirectView), so a +# buyer who signs in is not aimed at the admin area, and the RBAC middleware +# can redirect a wrong-role request here without looping. +LOGIN_REDIRECT_URL = "/" KEYCLOAK_SERVER_URL = env("KEYCLOAK_SERVER_URL", default="http://194.164.95.50:8443") KEYCLOAK_REALM = env("KEYCLOAK_REALM", default="ixg") +# Service account used by the public buyer sign-up screen to create Keycloak +# users through the Admin API (apps/users/keycloak.py). It holds only +# realm-management's manage-users and view-users. Secrets come from the +# environment and are never written to code, logs or the wiki. +KEYCLOAK_PROVISIONING_CLIENT_ID = env("KEYCLOAK_PROVISIONING_CLIENT_ID", default="") +KEYCLOAK_PROVISIONING_CLIENT_SECRET = env("KEYCLOAK_PROVISIONING_CLIENT_SECRET", default="") + REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework.authentication.SessionAuthentication", diff --git a/dsp_platform/urls.py b/dsp_platform/urls.py index c3973c2..f0b81bb 100644 --- a/dsp_platform/urls.py +++ b/dsp_platform/urls.py @@ -1,14 +1,14 @@ from django.contrib import admin -from django.http import HttpResponseRedirect from django.urls import path, include from django.conf import settings from django.conf.urls.static import static from rest_framework.authtoken import views as drf_auth from apps.core.tiles import tile_view +from apps.core.views import RoleRedirectView urlpatterns = [ - path("", lambda r: HttpResponseRedirect("/admin/")), + path("", RoleRedirectView.as_view(), name="home"), path("tiles///.png", tile_view, name="tile"), path("grappelli/", include("grappelli.urls")), path("admin/", admin.site.urls), diff --git a/templates/portal/dashboard.html b/templates/portal/dashboard.html index b78f71d..057d27d 100644 --- a/templates/portal/dashboard.html +++ b/templates/portal/dashboard.html @@ -2,6 +2,12 @@ {% block title %}Dashboard - IXG Buyer Portal{% endblock %} {% block content %} +{% if welcome %} + +{% endif %} +