From ef733cb7bf191b432c2598e638e13a134a48c14d Mon Sep 17 00:00:00 2001 From: Robert Helewka Date: Wed, 13 May 2026 06:31:00 -0400 Subject: [PATCH] SSO Pattern update --- docs/Pattern_SSO-Allauth-Casdoor_V1-01.md | 739 ---------------------- 1 file changed, 739 deletions(-) delete mode 100644 docs/Pattern_SSO-Allauth-Casdoor_V1-01.md diff --git a/docs/Pattern_SSO-Allauth-Casdoor_V1-01.md b/docs/Pattern_SSO-Allauth-Casdoor_V1-01.md deleted file mode 100644 index e290196..0000000 --- a/docs/Pattern_SSO-Allauth-Casdoor_V1-01.md +++ /dev/null @@ -1,739 +0,0 @@ -# SSO with Allauth & Casdoor Pattern v1.0.0 - -Standardizes OIDC-based Single Sign-On using Django Allauth and Casdoor, covering adapter customization, user provisioning, group mapping, superuser protection, and configurable local-login fallback. Used by the `core` Django application. - -## 🐾 Red Panda Approvalβ„’ - -This pattern follows Red Panda Approval standards. - ---- - -## Why a Pattern, Not a Shared Implementation - -Every Django project that adopts SSO has different identity-provider configurations, claim schemas, permission models, and organizational structures: - -- A **project management** app needs role claims mapped to project-scoped permissions -- An **e-commerce** app needs tenant/store claims with purchase-limit groups -- An **RFP tool** (Spelunker) needs organization + group claims mapped to View Only / Staff / SME / Admin groups - -Instead, this pattern defines: - -- **Required components** β€” every implementation must have -- **Required settings** β€” Django & Allauth configuration values -- **Standard conventions** β€” group names, claim mappings, redirect URL format -- **Extension guidelines** β€” for domain-specific provisioning logic - ---- - -## Required Components - -Every SSO implementation following this pattern must provide these files: - -| Component | Location | Purpose | -|-----------|----------|---------| -| Social account adapter | `/adapters.py` | User provisioning, group mapping, superuser protection | -| Local account adapter | `/adapters.py` | Disable local signup, authentication logging | -| Management command | `/management/commands/create_sso_groups.py` | Idempotent group + permission creation | -| Login template | `templates/account/login.html` | SSO button + conditional local login form | -| Context processor | `/context_processors.py` | Expose `CASDOOR_ENABLED` / `ALLOW_LOCAL_LOGIN` to templates | -| SSL patch (optional) | `/ssl_patch.py` | Development-only SSL bypass | - -### Minimum settings.py configuration - -```python -# INSTALLED_APPS β€” required entries -INSTALLED_APPS = [ - # ... standard Django apps ... - 'allauth', - 'allauth.account', - 'allauth.socialaccount', - 'allauth.socialaccount.providers.openid_connect', - '', -] - -# MIDDLEWARE β€” Allauth middleware is required -MIDDLEWARE = [ - # ... standard Django middleware ... - 'allauth.account.middleware.AccountMiddleware', -] - -# AUTHENTICATION_BACKENDS β€” both local and SSO -AUTHENTICATION_BACKENDS = [ - 'django.contrib.auth.backends.ModelBackend', - 'allauth.account.auth_backends.AuthenticationBackend', -] -``` - ---- - -## Standard Values / Conventions - -### Environment Variables - -Every deployment must set these environment variables (or `.env` entries): - -| Variable | Required | Default | Description | -|----------|----------|---------|-------------| -| `CASDOOR_ENABLED` | Yes | β€” | Enable/disable SSO (`true`/`false`) | -| `CASDOOR_ORIGIN` | Yes | β€” | Casdoor backend URL for OIDC discovery | -| `CASDOOR_ORIGIN_FRONTEND` | Yes | β€” | Casdoor frontend URL (may differ behind reverse proxy) | -| `CASDOOR_CLIENT_ID` | Yes | β€” | OAuth client ID from Casdoor application | -| `CASDOOR_CLIENT_SECRET` | Yes | β€” | OAuth client secret from Casdoor application | -| `CASDOOR_ORG_NAME` | Yes | β€” | Default organization slug in Casdoor | -| `ALLOW_LOCAL_LOGIN` | No | `false` | Show local login form for non-superusers | -| `CASDOOR_SSL_VERIFY` | No | `true` | SSL verification (`true`, `false`, or CA-bundle path) | - -### Redirect URL Convention - -The Allauth OIDC callback URL follows a fixed format. Register this URL in Casdoor: - -``` -/accounts/oidc//login/callback/ -``` - -For Spelunker with `provider_id = casdoor`: - -``` -/accounts/oidc/casdoor/login/callback/ -``` - -> **Important:** The path segment is `oidc`, not `openid_connect`. - -### Standard Group Mapping - -Casdoor group names map to Django groups with consistent naming: - -| Casdoor Group | Django Group | `is_staff` | Permissions | -|---------------|-------------|------------|-------------| -| `view_only` | `View Only` | `False` | `view_*` | -| `staff` | `Staff` | `True` | `view_*`, `add_*`, `change_*` | -| `sme` | `SME` | `True` | `view_*`, `add_*`, `change_*` | -| `admin` | `Admin` | `True` | `view_*`, `add_*`, `change_*`, `delete_*` | - -### Standard OIDC Claim Mapping - -| Casdoor Claim | Django Field | Notes | -|---------------|-------------|-------| -| `email` | `User.username`, `User.email` | Full email used as username | -| `given_name` | `User.first_name` | β€” | -| `family_name` | `User.last_name` | β€” | -| `name` | Parsed into first/last | Fallback when given/family absent | -| `organization` | Organization lookup/create | Via adapter | -| `groups` | Django Group membership | Via adapter mapping | - ---- - -## Recommended Settings - -Most implementations should include these Allauth settings: - -```python -# Authentication mode -ACCOUNT_LOGIN_METHODS = {'email'} -ACCOUNT_SIGNUP_FIELDS = ['email*', 'password1*', 'password2*'] -ACCOUNT_EMAIL_VERIFICATION = 'optional' -ACCOUNT_SESSION_REMEMBER = True -ACCOUNT_LOGIN_ON_PASSWORD_RESET = True -ACCOUNT_UNIQUE_EMAIL = True - -# Redirects -LOGIN_REDIRECT_URL = '/dashboard/' -ACCOUNT_LOGOUT_REDIRECT_URL = '/' -LOGIN_URL = '/accounts/login/' - -# Social account behavior -SOCIALACCOUNT_AUTO_SIGNUP = True -SOCIALACCOUNT_EMAIL_VERIFICATION = 'none' -SOCIALACCOUNT_QUERY_EMAIL = True -SOCIALACCOUNT_STORE_TOKENS = True -SOCIALACCOUNT_ADAPTER = '.adapters.CasdoorAccountAdapter' -ACCOUNT_ADAPTER = '.adapters.LocalAccountAdapter' - -# Session management -SESSION_COOKIE_AGE = 28800 # 8 hours -SESSION_SAVE_EVERY_REQUEST = True - -# Account linking β€” auto-connect SSO to an existing local account with -# the same verified email instead of raising a conflict error -SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True -``` - -### Multi-Factor Authentication (Recommended) - -Add `allauth.mfa` for TOTP/WebAuthn second-factor support: - -```python -INSTALLED_APPS += ['allauth.mfa'] -MFA_ADAPTER = 'allauth.mfa.adapter.DefaultMFAAdapter' -``` - -MFA is enforced per-user inside Django; Casdoor may also enforce its own MFA upstream. - -### Rate Limiting on Local Login (Recommended) - -Protect the local login form from brute-force attacks with `django-axes` or similar: - -```python -# pip install django-axes -INSTALLED_APPS += ['axes'] -AUTHENTICATION_BACKENDS = [ - 'axes.backends.AxesStandaloneBackend', - 'django.contrib.auth.backends.ModelBackend', - 'allauth.account.auth_backends.AuthenticationBackend', -] -AXES_FAILURE_LIMIT = 5 # Lock after 5 failures -AXES_COOLOFF_TIME = 1 # 1-hour cooloff -AXES_LOCKOUT_PARAMETERS = ['ip_address', 'username'] -``` - ---- - -## Social Account Adapter - -The social account adapter is the core of the pattern. It handles user provisioning on SSO login, maps claims to Django fields, enforces superuser protection, and assigns groups. - -```python -from allauth.socialaccount.adapter import DefaultSocialAccountAdapter -from allauth.exceptions import ImmediateHttpResponse -from django.contrib.auth.models import User, Group -from django.contrib import messages -from django.shortcuts import redirect -import logging - -logger = logging.getLogger(__name__) - - -class CasdoorAccountAdapter(DefaultSocialAccountAdapter): - - def is_open_for_signup(self, request, sociallogin): - """Always allow SSO-initiated signup.""" - return True - - def pre_social_login(self, request, sociallogin): - """ - Runs on every SSO login (new and returning users). - - 1. Blocks superusers β€” they must use local auth. - 2. Re-syncs organization and group claims for returning users - so that IdP changes are reflected immediately. - """ - if sociallogin.user.id: - user = sociallogin.user - - # --- Superuser gate --- - if user.is_superuser: - logger.warning( - f"SSO login blocked for superuser {user.username}. " - "Superusers must use local authentication." - ) - messages.error( - request, - "Superuser accounts must use local authentication." - ) - raise ImmediateHttpResponse(redirect('account_login')) - - # --- Re-sync claims for returning users --- - extra_data = sociallogin.account.extra_data - - org_identifier = extra_data.get('organization', '') - if org_identifier: - self._assign_organization(user, org_identifier) - - groups = extra_data.get('groups', []) - self._assign_groups(user, groups) - - user.is_staff = any( - g in ['staff', 'sme', 'admin'] for g in groups - ) - user.save(update_fields=['is_staff']) - - def populate_user(self, request, sociallogin, data): - """Map Casdoor claims to Django User fields.""" - user = super().populate_user(request, sociallogin, data) - - email = data.get('email', '') - user.username = email - user.email = email - - user.first_name = data.get('given_name', '') - user.last_name = data.get('family_name', '') - - # Fallback: parse full 'name' claim - if not user.first_name and not user.last_name: - full_name = data.get('name', '') - if full_name: - parts = full_name.split(' ', 1) - user.first_name = parts[0] - user.last_name = parts[1] if len(parts) > 1 else '' - - # Security: SSO users are never superusers - user.is_superuser = False - - # Set is_staff from group membership - groups = data.get('groups', []) - user.is_staff = any(g in ['staff', 'sme', 'admin'] for g in groups) - - return user - - def save_user(self, request, sociallogin, form=None): - """Save user and handle organization + group mapping.""" - user = super().save_user(request, sociallogin, form) - extra_data = sociallogin.account.extra_data - - org_identifier = extra_data.get('organization', '') - if org_identifier: - self._assign_organization(user, org_identifier) - - groups = extra_data.get('groups', []) - self._assign_groups(user, groups) - return user - - def _assign_organization(self, user, org_identifier): - """Assign (or create) organization from the OIDC claim.""" - # Domain-specific β€” see Extension Examples below - raise NotImplementedError("Override per project") - - def _assign_groups(self, user, group_names): - """Map Casdoor groups to Django groups.""" - group_mapping = { - 'view_only': 'View Only', - 'staff': 'Staff', - 'sme': 'SME', - 'admin': 'Admin', - } - user.groups.clear() - for casdoor_group in group_names: - django_group_name = group_mapping.get(casdoor_group.lower()) - if django_group_name: - group, _ = Group.objects.get_or_create(name=django_group_name) - user.groups.add(group) - logger.info(f"Added {user.username} to group {django_group_name}") -``` - ---- - -## Local Account Adapter - -Prevents local registration and logs authentication failures: - -```python -from allauth.account.adapter import DefaultAccountAdapter -import logging - -logger = logging.getLogger(__name__) - - -class LocalAccountAdapter(DefaultAccountAdapter): - - def is_open_for_signup(self, request): - """Disable local signup β€” all users come via SSO or admin.""" - return False - - def authentication_failed(self, request, **kwargs): - """Log failures for security monitoring.""" - logger.warning( - f"Local authentication failed from {request.META.get('REMOTE_ADDR')}" - ) - super().authentication_failed(request, **kwargs) -``` - ---- - -## OIDC Provider Configuration - -Register Casdoor as an OpenID Connect provider in `settings.py`: - -```python -SOCIALACCOUNT_PROVIDERS = { - 'openid_connect': { - 'APPS': [ - { - 'provider_id': 'casdoor', - 'name': 'Casdoor SSO', - 'client_id': CASDOOR_CLIENT_ID, - 'secret': CASDOOR_CLIENT_SECRET, - 'settings': { - 'server_url': f'{CASDOOR_ORIGIN}/.well-known/openid-configuration', - }, - } - ], - 'OAUTH_PKCE_ENABLED': True, - } -} -``` - ---- - -## Management Command β€” Group Creation - -An idempotent management command ensures groups and permissions exist: - -```python -from django.core.management.base import BaseCommand -from django.contrib.auth.models import Group, Permission - - -class Command(BaseCommand): - help = 'Create Django groups for Casdoor SSO integration' - - def handle(self, *args, **options): - groups_config = { - 'View Only': {'permissions': ['view']}, - 'Staff': {'permissions': ['view', 'add', 'change']}, - 'SME': {'permissions': ['view', 'add', 'change']}, - 'Admin': {'permissions': ['view', 'add', 'change', 'delete']}, - } - - # Add your domain-specific model names here - models_to_permission = [ - 'vendor', 'document', 'rfp', 'rfpquestion', - ] - - for group_name, config in groups_config.items(): - group, created = Group.objects.get_or_create(name=group_name) - status = 'Created' if created else 'Exists' - self.stdout.write(f'{status}: {group_name}') - - for perm_prefix in config['permissions']: - for model in models_to_permission: - try: - perm = Permission.objects.get( - codename=f'{perm_prefix}_{model}' - ) - group.permissions.add(perm) - except Permission.DoesNotExist: - pass - - self.stdout.write(self.style.SUCCESS('SSO groups created successfully')) -``` - ---- - -## Login Template - -The login template shows an SSO button when Casdoor is enabled and conditionally reveals the local login form: - -```html -{% load socialaccount %} - - -{% if CASDOOR_ENABLED %} -
- {% csrf_token %} - -
-{% endif %} - - -{% if ALLOW_LOCAL_LOGIN or user.is_superuser %} -
- {% csrf_token %} - {{ form.as_p }} - -
-{% endif %} -``` - -> **Why POST?** Using a `` GET link to initiate the OAuth flow skips CSRF -> validation. Allauth's `{% provider_login_url %}` is designed for use inside a -> `
` so the CSRF token is verified before the redirect. - ---- - -## Context Processor - -Exposes SSO settings to every template: - -```python -from django.conf import settings - - -def user_preferences(request): - context = {} - - # Always expose SSO flags for the login page - context['CASDOOR_ENABLED'] = getattr(settings, 'CASDOOR_ENABLED', False) - context['ALLOW_LOCAL_LOGIN'] = getattr(settings, 'ALLOW_LOCAL_LOGIN', False) - - return context -``` - -Register in `settings.py`: - -```python -TEMPLATES = [{ - 'OPTIONS': { - 'context_processors': [ - # ... standard processors ... - '.context_processors.user_preferences', - ], - }, -}] -``` - ---- - -## SSL Bypass (Development Only) - -For sandbox environments with self-signed certificates, an optional SSL patch disables verification at the `requests` library level: - -```python -import os, logging -logger = logging.getLogger(__name__) - - -def apply_ssl_bypass(): - ssl_verify = os.environ.get('CASDOOR_SSL_VERIFY', 'true').lower() - if ssl_verify != 'false': - return - - logger.warning("SSL verification DISABLED β€” sandbox only") - - import urllib3 - from requests.adapters import HTTPAdapter - urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - - _original_send = HTTPAdapter.send - - def _patched_send(self, request, stream=False, timeout=None, - verify=True, cert=None, proxies=None): - return _original_send(self, request, stream=stream, - timeout=timeout, verify=False, - cert=cert, proxies=proxies) - - HTTPAdapter.send = _patched_send - -apply_ssl_bypass() -``` - -Load it at the top of `settings.py` **before** any library imports that make HTTP calls: - -```python -_ssl_verify = os.environ.get('CASDOOR_SSL_VERIFY', 'true').lower() -if _ssl_verify == 'false': - import .ssl_patch # noqa: F401 -``` - ---- - -## Logout Flow - -By default, Django's `account_logout` destroys the local session but does **not** terminate the upstream Casdoor session. The user remains logged in at the IdP and will be silently re-authenticated on next visit. - -### Options - -| Strategy | Behaviour | Implementation | -|----------|-----------|----------------| -| **Local-only logout** (default) | Destroys Django session; IdP session survives | No extra work | -| **IdP redirect logout** | Redirects to Casdoor's `/api/logout` after local logout | Override `ACCOUNT_LOGOUT_REDIRECT_URL` to point at Casdoor | -| **OIDC back-channel logout** | Casdoor notifies Django to invalidate sessions | Requires Casdoor back-channel support + a Django webhook endpoint | - -### Recommended: IdP redirect logout - -```python -# settings.py -ACCOUNT_LOGOUT_REDIRECT_URL = ( - f'{CASDOOR_ORIGIN}/api/logout' - f'?post_logout_redirect_uri=https://your-app.example.com/' -) -``` - -This ensures the Casdoor session cookie is cleared before the user returns to your app. - ---- - -## Domain Extension Examples - -### Spelunker (RFP Tool) - -Spelunker's adapter creates organizations on first encounter and links them to user profiles: - -```python -def _assign_organization(self, user, org_identifier): - from django.db import models - from django.utils.text import slugify - from core.models import Organization - - try: - org = Organization.objects.filter( - models.Q(slug=org_identifier) | models.Q(name=org_identifier) - ).first() - - if not org: - org = Organization.objects.create( - name=org_identifier, - slug=slugify(org_identifier), - type='for-profit', - legal_country='CA', - status='active', - ) - logger.info(f"Created organization: {org.name}") - - if hasattr(user, 'profile'): - logger.info(f"Assigned {user.username} β†’ {org.name}") - - except Exception as e: - logger.error(f"Organization assignment error: {e}") -``` - -### Multi-Tenant SaaS App - -A multi-tenant app might restrict users to a single tenant and enforce tenant isolation: - -```python -def _assign_organization(self, user, org_identifier): - from tenants.models import Tenant - - tenant = Tenant.objects.filter(external_id=org_identifier).first() - if not tenant: - raise ValueError(f"Unknown tenant: {org_identifier}") - - user.tenant = tenant - user.save(update_fields=['tenant']) -``` - ---- - -## Anti-Patterns - -- ❌ Don't allow SSO to grant `is_superuser` β€” always force `is_superuser = False` in `populate_user` -- ❌ Don't *log-and-continue* for superuser SSO attempts β€” raise `ImmediateHttpResponse` to actually block the login -- ❌ Don't disable local login for superusers β€” they need emergency access when SSO is unavailable -- ❌ Don't rely on SSO username claims β€” use email as the canonical identifier -- ❌ Don't hard-code the OIDC provider URL β€” always read from environment variables -- ❌ Don't skip the management command β€” groups and permissions must be idempotent and repeatable -- ❌ Don't use `CASDOOR_SSL_VERIFY=false` in production β€” only for sandbox environments with self-signed certificates -- ❌ Don't forget PKCE β€” always set `OAUTH_PKCE_ENABLED: True` for Authorization Code flow -- ❌ Don't sync groups only on first login β€” re-sync in `pre_social_login` so IdP changes take effect immediately -- ❌ Don't use a GET link (``) to start the OAuth flow β€” use a POST form so CSRF protection applies -- ❌ Don't assume Django logout kills the IdP session β€” configure an IdP redirect or back-channel logout -- ❌ Don't leave the local login endpoint unprotected β€” add rate limiting (e.g. `django-axes`) to prevent brute-force attacks - ---- - -## Settings - -All Django settings this pattern recognizes: - -```python -# settings.py - -# --- SSO Provider --- -CASDOOR_ENABLED = env.bool('CASDOOR_ENABLED') # Master SSO toggle -CASDOOR_ORIGIN = env('CASDOOR_ORIGIN') # OIDC discovery base URL -CASDOOR_ORIGIN_FRONTEND = env('CASDOOR_ORIGIN_FRONTEND') # Frontend URL (may differ) -CASDOOR_CLIENT_ID = env('CASDOOR_CLIENT_ID') # OAuth client ID -CASDOOR_CLIENT_SECRET = env('CASDOOR_CLIENT_SECRET') # OAuth client secret -CASDOOR_ORG_NAME = env('CASDOOR_ORG_NAME') # Default organization -# CASDOOR_SSL_VERIFY is NOT a Django setting β€” it is read directly from the -# environment at the top of settings.py (before any imports) to apply SSL -# bypass via ssl_patch.py or set REQUESTS_CA_BUNDLE for a custom CA. -# See the SSL Bypass section above for the correct implementation. - -# --- Login Behavior --- -ALLOW_LOCAL_LOGIN = env.bool('ALLOW_LOCAL_LOGIN', default=False) # Show local form - -# --- Allauth --- -SOCIALACCOUNT_ADAPTER = '.adapters.CasdoorAccountAdapter' -ACCOUNT_ADAPTER = '.adapters.LocalAccountAdapter' -``` - ---- - -## Testing - -Standard test cases every implementation should cover: - -```python -from django.test import TestCase, override_settings -from unittest.mock import MagicMock -from django.contrib.auth.models import User, Group -from .adapters import CasdoorAccountAdapter, LocalAccountAdapter - - -class CasdoorAdapterTest(TestCase): - - def setUp(self): - self.adapter = CasdoorAccountAdapter() - - def test_signup_always_open(self): - """SSO signup must always be permitted.""" - self.assertTrue(self.adapter.is_open_for_signup(MagicMock(), MagicMock())) - - def test_superuser_never_set_via_sso(self): - """populate_user must force is_superuser=False.""" - sociallogin = MagicMock() - data = {'email': 'admin@example.com', 'groups': ['admin']} - user = self.adapter.populate_user(MagicMock(), sociallogin, data) - self.assertFalse(user.is_superuser) - - def test_email_used_as_username(self): - """Username must be the full email address.""" - sociallogin = MagicMock() - data = {'email': 'jane@example.com'} - user = self.adapter.populate_user(MagicMock(), sociallogin, data) - self.assertEqual(user.username, 'jane@example.com') - - def test_staff_flag_from_groups(self): - """is_staff must be True when user belongs to staff/sme/admin.""" - sociallogin = MagicMock() - for group in ['staff', 'sme', 'admin']: - data = {'email': 'user@example.com', 'groups': [group]} - user = self.adapter.populate_user(MagicMock(), sociallogin, data) - self.assertTrue(user.is_staff, f"is_staff should be True for group '{group}'") - - def test_name_fallback_parsing(self): - """When given_name/family_name absent, parse 'name' claim.""" - sociallogin = MagicMock() - data = {'email': 'user@example.com', 'name': 'Jane Doe'} - user = self.adapter.populate_user(MagicMock(), sociallogin, data) - self.assertEqual(user.first_name, 'Jane') - self.assertEqual(user.last_name, 'Doe') - - def test_group_mapping(self): - """Casdoor groups must map to correctly named Django groups.""" - Group.objects.create(name='View Only') - Group.objects.create(name='Staff') - user = User.objects.create_user('test@example.com', 'test@example.com') - self.adapter._assign_groups(user, ['view_only', 'staff']) - group_names = set(user.groups.values_list('name', flat=True)) - self.assertEqual(group_names, {'View Only', 'Staff'}) - - def test_superuser_sso_login_blocked(self): - """pre_social_login must raise ImmediateHttpResponse for superusers.""" - from allauth.exceptions import ImmediateHttpResponse - user = User.objects.create_superuser( - 'admin@example.com', 'admin@example.com', 'pass' - ) - sociallogin = MagicMock() - sociallogin.user = user - sociallogin.user.id = user.id - with self.assertRaises(ImmediateHttpResponse): - self.adapter.pre_social_login(MagicMock(), sociallogin) - - def test_groups_resync_on_returning_login(self): - """pre_social_login must re-sync groups for existing users.""" - Group.objects.create(name='Admin') - Group.objects.create(name='Staff') - user = User.objects.create_user('user@example.com', 'user@example.com') - user.groups.add(Group.objects.get(name='Staff')) - - sociallogin = MagicMock() - sociallogin.user = user - sociallogin.user.id = user.id - sociallogin.account.extra_data = { - 'groups': ['admin'], - 'organization': '', - } - self.adapter.pre_social_login(MagicMock(), sociallogin) - group_names = set(user.groups.values_list('name', flat=True)) - self.assertEqual(group_names, {'Admin'}) - - -class LocalAdapterTest(TestCase): - - def test_local_signup_disabled(self): - """Local signup must always be disabled.""" - adapter = LocalAccountAdapter() - self.assertFalse(adapter.is_open_for_signup(MagicMock())) -```