Use custom roles for authorisation in Django web applications¶
In this guide you will learn how to add custom roles to your application which are powered by Entra groups. Entra groups can be tied to Lookup groups providing a powerful and delegated means to add custom authorisation to your applications.
Our example¶
We will use the case study of wanting to restrict an application to only staff members. We'll also use the example of a Django application which is using python-social-auth for authentication.
Important
Guides for other web frameworks are welcome.
UIS provide some centrally managed
groups
which will be useful here. In particular the group 1f440b90-597d-45b4-9a0d-11707f784de7 contains
all the staff members of the University.
We will be re-using the "Punt Booker" example from the guide on how to add SSO to your web application.
Prerequisites¶
Follow the steps in how to add SSO to your web application to register an Entra ID sign in application.
Follow the steps in how to configure Django to use SSO.
Add the custom role¶
Add a custom role to the application configurations in the Entra ID Application Factory. For example, for the production application, the configuration will look like the following. Additional lines have been highlighted.
type: sign-in
display_name: Punt Booker
logo_image: punt-booker.png
web_redirect_uris:
- https://punt-booker.apps.cam.ac.uk/accounts/complete/azuread-tenant-oauth2/
credential_secrets:
sign_in:
template: default
iam_policy:
"roles/secretmanager.secretAccessor":
- webapp@punt-booker-prod.iam.gserviceaccount.com
roles:
staff:
description: "Staff"
display_name: "Staff"
principal_object_ids:
- "1f440b90-597d-45b4-9a0d-11707f784de7"
Tip
You can add more than one group id to a custom role. A user will be granted that role if they are a member of any of the groups.
Restrict views¶
The roles claim will now be available in the extra_data associated with the social user object.
For example, you can create a decorator which requires that the logged in user be a staff member:
from social_django.models import UserSocialAuth
from django.contrib.auth.decorators import user_passes_test
def is_staff(user):
# A non-signed in user is not staff.
if user is None or user.is_anonymous:
return False
# A user without an Entra ID social auth record is not staff.
try:
social_auth = user.social_auth.get(provider="azuread-tenant-oauth2")
except UserSocialAuth.DoesNotExist:
return False
# Only users with the "staff" role are staff.
return "staff" in social_auth.extra_data.get("roles", [])
@user_passes_test(is_staff)
def my_view(request):
# ...
Restricting admin view¶
Authenticating users via SSO rather than Django's built in authentication system is more secure,
being backed by MFA and ensuring that all actions are traceable to specific users. To restrict
access to the admin view and disable insecure logins from being trusted, inject an AdminSite
with the appropriate authorization logic.
Firstly add a role to Entra that is specific to the users that should be able to access the
administration console. The combination of the configured client_id or
SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY and the presence of the role will both be required for
access, therefore a generic role like system_admin does not risk a token from another,
more permissive service providing access, unless the client_id is shared.
roles:
# ...
system_admin:
description: "Users who should be able to administrate the service"
display_name: "system administrators"
principal_object_ids:
- # The UID of a group, e.g. a DevOps team, that should administrate the *system*
Then create an AdminSite that replaces the authorization logic with checking that the specified
role is in the identity token, which is propagated to the extra_data of the SocialAccount. In
this example the role fetching logic and the admin authorization implementation are bundled into a
file called admin.py.
The AdminSite can also be configured with a login_template to guide the user to login with SSO
as has been done in the regent-house-ballots
project.
import structlog
from django.contrib import admin
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
from django.http import HttpRequest
from social_django.models import UserSocialAuth
logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__)
def _get_roles(user: AbstractBaseUser | AnonymousUser | None) -> list[str]:
if user is None:
logger.info("User is None, cannot have roles.")
return []
if user.is_anonymous:
logger.info(f"User {user} is anonymous, cannot have roles.")
return []
try:
social_auth = user.social_auth.get(provider="azuread-tenant-oauth2")
except UserSocialAuth.DoesNotExist:
logger.info(
f"User {user} unauthenticated or authenticated with an unexpected provider,"
" cannot have roles."
)
return []
if "roles" not in social_auth.extra_data:
logger.debug(
f"User {user} does not have 'roles' in extra_data,"
" they may not be present in JWT, or parsing may have changed?"
)
return social_auth.extra_data.get("roles", [])
class AdminSite(admin.AdminSite):
def has_permission(self, request: HttpRequest) -> bool:
"""
Prevent local accounts being required/allowed for admin.
Allows access if the specific role is in the JWT.
"""
return "system_admin" in _get_roles(request.user)
Configure the default AdminSite to be used in another file (e.g. apps.py) to prevent circular
loading issues:
from django.contrib.admin.apps import AdminConfig
class CustomAdminConfig(AdminConfig):
default_site = "punt_booker.admin.AdminSite"
Finally, configure the Django app to use the custom admin config in base.py, and remove the
ability for "local" users to log in:
INSTALLED_APPS = [
"punt_booker.apps.CustomAdminConfig", # replaces "django.contrib.admin"
...
]
AUTHENTICATION_BACKENDS = [
# "django.contrib.auth.backends.ModelBackend", Disallow local auth by removing this entry
"social_core.backends.azuread_tenant.AzureADTenantOAuth2", # Only allow social auth
]
Common logic can later be extracted and refactored for re-use where other views require
authorization based on roles or to be utilised by e.g. the rules library:
def has_role(role: str, user: AbstractBaseUser | AnonymousUser | None) -> bool:
return role in _get_roles(user)
is_admin_user: rules.Predicate = rules.predicate(partial(has_role, "system_admin"))
is_staff: rules.Predicate = rules.predicate(partial(has_role, "staff"))
Summary¶
In this guide we saw how to use custom roles in applications to provide authorisation in Django applications.