feat(TRA-234): implement OIDC auth and group-to-role mapping
Some checks failed
CI / Tests (Python 3.12) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / OpenAPI Schema (push) Has been cancelled

- AccountUser custom user model (UUID PK, email login, oidc_sub field)
- Role, UserRoleBinding, GroupRoleMap domain models with migrations
- TrainingOIDCBackend: create_user/update_user with Authentik claim hooks
- sync_roles_from_oidc_claims: reconciles OIDC-sourced bindings only,
  preserving manually-granted bindings
- get_effective_capabilities: flat capability set from role slugs
- DRF views: /me/, /me/permissions/, /users/, /users/{id}/roles/
- IsAdminOrManager and IsAdmin permission classes
- Audit signal logging on UserRoleBinding post_save/post_delete
- Seed migration for canonical role slugs (learner/trainer/author/manager/admin)
- AUTH_USER_MODEL = accounts.AccountUser wired in base settings
- OIDC settings: scopes, username algo, store_access/refresh_token flags
- Test suite: 20 unit + integration tests covering sync, capabilities, API

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip CTO
2026-05-07 09:11:02 +02:00
parent cfa9ad6f53
commit 2c38fd862d
18 changed files with 954 additions and 4 deletions

37
accounts/admin.py Normal file
View File

@@ -0,0 +1,37 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import AccountUser, GroupRoleMap, Role, UserRoleBinding
@admin.register(AccountUser)
class AccountUserAdmin(UserAdmin):
list_display = ("email", "display_name", "is_active", "is_staff", "created_at")
search_fields = ("email", "display_name", "oidc_sub")
ordering = ("email",)
fieldsets = (
(None, {"fields": ("email", "password")}),
("Profile", {"fields": ("display_name", "oidc_sub", "last_login_at")}),
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
)
add_fieldsets = (
(None, {"classes": ("wide",), "fields": ("email", "password1", "password2")}),
)
@admin.register(Role)
class RoleAdmin(admin.ModelAdmin):
list_display = ("slug", "name")
@admin.register(UserRoleBinding)
class UserRoleBindingAdmin(admin.ModelAdmin):
list_display = ("user", "role", "org_id", "source")
list_filter = ("source", "role")
raw_id_fields = ("user",)
@admin.register(GroupRoleMap)
class GroupRoleMapAdmin(admin.ModelAdmin):
list_display = ("oidc_group", "role", "org_id")
list_filter = ("role",)

View File

@@ -4,3 +4,7 @@ from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "accounts"
def ready(self):
from .signals import register_signals
register_signals()

21
accounts/managers.py Normal file
View File

@@ -0,0 +1,21 @@
from django.contrib.auth.models import BaseUserManager
class AccountUserManager(BaseUserManager):
def create_user(self, email, password=None, **extra_fields):
if not email:
raise ValueError("Email is required")
email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
def create_superuser(self, email, password=None, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
if not extra_fields["is_staff"]:
raise ValueError("Superuser must have is_staff=True")
if not extra_fields["is_superuser"]:
raise ValueError("Superuser must have is_superuser=True")
return self.create_user(email, password, **extra_fields)

View File

@@ -0,0 +1,104 @@
import uuid
import django.db.models.deletion
from django.db import migrations, models
import accounts.managers
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="Role",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("slug", models.CharField(max_length=50, unique=True)),
("name", models.CharField(max_length=100)),
],
options={"db_table": "accounts_role"},
),
migrations.CreateModel(
name="AccountUser",
fields=[
("password", models.CharField(max_length=128, verbose_name="password")),
("last_login", models.DateTimeField(blank=True, null=True, verbose_name="last login")),
("is_superuser", models.BooleanField(default=False)),
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
("email", models.EmailField(max_length=254, unique=True)),
("display_name", models.CharField(blank=True, max_length=255)),
("oidc_sub", models.CharField(blank=True, db_index=True, max_length=512, null=True, unique=True)),
("is_active", models.BooleanField(default=True)),
("is_staff", models.BooleanField(default=False)),
("last_login_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("groups", models.ManyToManyField(
blank=True,
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
)),
("user_permissions", models.ManyToManyField(
blank=True,
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
)),
],
options={"db_table": "accounts_user"},
managers=[("objects", accounts.managers.AccountUserManager())],
),
migrations.CreateModel(
name="UserRoleBinding",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("org_id", models.UUIDField(blank=True, db_index=True, null=True)),
("source", models.CharField(default="oidc", max_length=20)),
("user", models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="role_bindings",
to="accounts.accountuser",
)),
("role", models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="accounts.role",
)),
],
options={"db_table": "accounts_user_role_binding"},
),
migrations.AddConstraint(
model_name="userrolebinding",
constraint=models.UniqueConstraint(
fields=("user", "role", "org_id"),
name="unique_user_role_org",
),
),
migrations.CreateModel(
name="GroupRoleMap",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("oidc_group", models.CharField(db_index=True, max_length=255)),
("org_id", models.UUIDField(blank=True, null=True)),
("role", models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="accounts.role",
)),
],
options={"db_table": "accounts_group_role_map"},
),
migrations.AddConstraint(
model_name="grouprolemap",
constraint=models.UniqueConstraint(
fields=("oidc_group", "role", "org_id"),
name="unique_group_role_org",
),
),
]

View File

@@ -0,0 +1,31 @@
from django.db import migrations
CANONICAL_ROLES = [
("learner", "Learner"),
("trainer", "Trainer"),
("author", "Content Author"),
("manager", "Manager"),
("admin", "Administrator"),
]
def seed_roles(apps, schema_editor):
Role = apps.get_model("accounts", "Role")
for slug, name in CANONICAL_ROLES:
Role.objects.get_or_create(slug=slug, defaults={"name": name})
def unseed_roles(apps, schema_editor):
Role = apps.get_model("accounts", "Role")
Role.objects.filter(slug__in=[s for s, _ in CANONICAL_ROLES]).delete()
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
migrations.RunPython(seed_roles, reverse_code=unseed_roles),
]

View File

View File

@@ -1 +1,90 @@
# Domain models will be implemented in subsequent milestone tickets.
import uuid
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.db import models
from .managers import AccountUserManager
class AccountUser(AbstractBaseUser, PermissionsMixin):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
email = models.EmailField(unique=True)
display_name = models.CharField(max_length=255, blank=True)
oidc_sub = models.CharField(max_length=512, unique=True, db_index=True, null=True, blank=True)
is_active = models.BooleanField(default=True)
is_staff = models.BooleanField(default=False)
last_login_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
objects = AccountUserManager()
USERNAME_FIELD = "email"
REQUIRED_FIELDS = []
class Meta:
db_table = "accounts_user"
def __str__(self):
return self.email
def get_full_name(self):
return self.display_name or self.email
def get_short_name(self):
return self.display_name or self.email.split("@")[0]
class Role(models.Model):
slug = models.CharField(max_length=50, unique=True)
name = models.CharField(max_length=100)
class Meta:
db_table = "accounts_role"
def __str__(self):
return self.slug
ROLE_CAPABILITIES: dict[str, list[str]] = {
"learner": ["courses:view", "courses:enroll", "progress:view_own", "assessments:take"],
"trainer": ["courses:view", "progress:view_team", "assessments:view", "training:signoff"],
"author": ["courses:view", "courses:create", "courses:edit", "cms:manage"],
"manager": ["courses:view", "courses:manage", "progress:view_all", "reports:view", "users:list"],
"admin": [
"courses:view", "courses:manage",
"progress:view_all", "reports:view",
"users:list", "users:manage",
"certificates:manage", "audit:view",
],
}
class UserRoleBinding(models.Model):
user = models.ForeignKey(AccountUser, on_delete=models.CASCADE, related_name="role_bindings")
role = models.ForeignKey(Role, on_delete=models.PROTECT)
org_id = models.UUIDField(null=True, blank=True, db_index=True)
source = models.CharField(max_length=20, default="oidc") # "oidc" | "manual"
class Meta:
db_table = "accounts_user_role_binding"
constraints = [
models.UniqueConstraint(fields=["user", "role", "org_id"], name="unique_user_role_org"),
]
def __str__(self):
return f"{self.user.email}:{self.role.slug}:{self.org_id or 'global'}:{self.source}"
class GroupRoleMap(models.Model):
oidc_group = models.CharField(max_length=255, db_index=True)
role = models.ForeignKey(Role, on_delete=models.CASCADE)
org_id = models.UUIDField(null=True, blank=True) # null = global mapping
class Meta:
db_table = "accounts_group_role_map"
constraints = [
models.UniqueConstraint(fields=["oidc_group", "role", "org_id"], name="unique_group_role_org"),
]
def __str__(self):
return f"{self.oidc_group} -> {self.role.slug}"

43
accounts/oidc_backend.py Normal file
View File

@@ -0,0 +1,43 @@
import uuid
from django.utils.timezone import now
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
from .services import sync_roles_from_oidc_claims
def generate_username(email: str) -> str:
"""Deterministic username derived from email — satisfies OIDC_USERNAME_ALGO requirement."""
return email
class TrainingOIDCBackend(OIDCAuthenticationBackend):
def filter_users_by_claims(self, claims):
sub = claims.get("sub")
if sub:
return self.UserModel.objects.filter(oidc_sub=sub)
return self.UserModel.objects.none()
def create_user(self, claims):
email = claims.get("email", "")
user = self.UserModel.objects.create_user(
email=email,
display_name=claims.get("name", ""),
oidc_sub=claims.get("sub", ""),
)
sync_roles_from_oidc_claims(user, claims)
return user
def update_user(self, user, claims):
changed = False
display_name = claims.get("name", user.display_name)
if user.display_name != display_name:
user.display_name = display_name
changed = True
user.last_login_at = now()
if changed:
user.save(update_fields=["display_name", "last_login_at"])
else:
user.save(update_fields=["last_login_at"])
sync_roles_from_oidc_claims(user, claims)
return user

34
accounts/permissions.py Normal file
View File

@@ -0,0 +1,34 @@
from rest_framework.permissions import BasePermission
from .services import get_effective_capabilities
class HasCapability(BasePermission):
"""Reusable permission: require a specific capability string."""
capability: str = ""
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated:
return False
return self.capability in get_effective_capabilities(request.user)
@classmethod
def for_capability(cls, cap: str):
return type(f"Has_{cap.replace(':', '_')}", (cls,), {"capability": cap})
class IsAdminOrManager(BasePermission):
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated:
return False
caps = get_effective_capabilities(request.user)
return "users:list" in caps
class IsAdmin(BasePermission):
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated:
return False
caps = get_effective_capabilities(request.user)
return "users:manage" in caps

44
accounts/serializers.py Normal file
View File

@@ -0,0 +1,44 @@
from rest_framework import serializers
from .models import AccountUser, Role, UserRoleBinding
from .services import get_effective_capabilities
class RoleSerializer(serializers.ModelSerializer):
class Meta:
model = Role
fields = ["slug", "name"]
class UserRoleBindingSerializer(serializers.ModelSerializer):
role_slug = serializers.CharField(source="role.slug", read_only=True)
class Meta:
model = UserRoleBinding
fields = ["role_slug", "org_id", "source"]
class UserProfileSerializer(serializers.ModelSerializer):
roles = serializers.SerializerMethodField()
class Meta:
model = AccountUser
fields = ["id", "email", "display_name", "is_active", "last_login_at", "created_at", "roles"]
read_only_fields = fields
def get_roles(self, obj):
return list(
obj.role_bindings.select_related("role").values_list("role__slug", flat=True)
)
class UserListSerializer(serializers.ModelSerializer):
class Meta:
model = AccountUser
fields = ["id", "email", "display_name", "is_active", "created_at"]
read_only_fields = fields
class RolePatchSerializer(serializers.Serializer):
role_slugs = serializers.ListField(child=serializers.CharField(), allow_empty=True)
org_id = serializers.UUIDField(required=False, allow_null=True, default=None)

49
accounts/services.py Normal file
View File

@@ -0,0 +1,49 @@
from __future__ import annotations
from .models import AccountUser, GroupRoleMap, Role, UserRoleBinding
def sync_roles_from_oidc_claims(user: AccountUser, claims: dict) -> None:
"""
Reconcile UserRoleBinding rows sourced from OIDC against the current
group claim set. Only "oidc"-sourced bindings are touched; manually
granted bindings are preserved.
"""
groups: list[str] = claims.get("groups", [])
desired: set[tuple] = set()
for mapping in GroupRoleMap.objects.filter(oidc_group__in=groups).select_related("role"):
desired.add((mapping.role_id, mapping.org_id))
existing = {
(b.role_id, b.org_id): b
for b in UserRoleBinding.objects.filter(user=user, source="oidc")
}
to_create = desired - set(existing)
to_delete = set(existing) - desired
for role_id, org_id in to_create:
UserRoleBinding.objects.create(user=user, role_id=role_id, org_id=org_id, source="oidc")
if to_delete:
role_ids = [k[0] for k in to_delete]
org_ids = [k[1] for k in to_delete]
for role_id, org_id in to_delete:
UserRoleBinding.objects.filter(
user=user, source="oidc", role_id=role_id, org_id=org_id
).delete()
def get_effective_capabilities(user: AccountUser, org_id=None) -> list[str]:
"""Return flat sorted capability list for a user, scoped to org_id when given."""
from .models import ROLE_CAPABILITIES
bindings = UserRoleBinding.objects.filter(user=user).select_related("role")
if org_id is not None:
bindings = bindings.filter(org_id__in=[org_id, None])
caps: set[str] = set()
for binding in bindings:
caps.update(ROLE_CAPABILITIES.get(binding.role.slug, []))
return sorted(caps)

29
accounts/signals.py Normal file
View File

@@ -0,0 +1,29 @@
import logging
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
logger = logging.getLogger("security")
def _log_role_change(action: str, instance) -> None:
logger.info(
"role_binding_%s user=%s role=%s org=%s source=%s",
action,
instance.user_id,
instance.role.slug if instance.role_id else "?",
instance.org_id,
instance.source,
)
def register_signals():
from .models import UserRoleBinding
@receiver(post_save, sender=UserRoleBinding, weak=False)
def on_role_binding_saved(sender, instance, created, **kwargs):
_log_role_change("created" if created else "updated", instance)
@receiver(post_delete, sender=UserRoleBinding, weak=False)
def on_role_binding_deleted(sender, instance, **kwargs):
_log_role_change("deleted", instance)

View File

View File

@@ -0,0 +1,42 @@
import uuid
import factory
from accounts.models import AccountUser, GroupRoleMap, Role, UserRoleBinding
class RoleFactory(factory.django.DjangoModelFactory):
class Meta:
model = Role
django_get_or_create = ("slug",)
slug = factory.Sequence(lambda n: f"role_{n}")
name = factory.LazyAttribute(lambda o: o.slug.title())
class AccountUserFactory(factory.django.DjangoModelFactory):
class Meta:
model = AccountUser
email = factory.Sequence(lambda n: f"user{n}@example.com")
display_name = factory.Faker("name")
oidc_sub = factory.LazyFunction(lambda: str(uuid.uuid4()))
is_active = True
class UserRoleBindingFactory(factory.django.DjangoModelFactory):
class Meta:
model = UserRoleBinding
user = factory.SubFactory(AccountUserFactory)
role = factory.SubFactory(RoleFactory)
org_id = None
source = "oidc"
class GroupRoleMapFactory(factory.django.DjangoModelFactory):
class Meta:
model = GroupRoleMap
oidc_group = factory.Sequence(lambda n: f"group_{n}")
role = factory.SubFactory(RoleFactory)
org_id = None

View File

@@ -0,0 +1,336 @@
"""
TRA-234: OIDC and Role Mapping tests.
Unit tests:
- sync_roles_from_oidc_claims (stale removal, new creation, manual binding preservation)
- filter_users_by_claims (sub lookup)
- get_effective_capabilities
Integration tests (mock OIDC via responses library):
- OIDC callback creates user with correct role bindings
- OIDC callback updates roles on re-auth
- Stale role removed when group claim drops
- Manual binding preserved through OIDC sync
- /me/ endpoint (authenticated + unauthenticated)
- /me/permissions/ endpoint
- /users/ list (admin pass, learner forbidden)
- /users/{id}/roles/ PATCH (non-admin forbidden)
"""
import uuid
from unittest.mock import MagicMock, patch
import pytest
from rest_framework.test import APIClient
from accounts.models import AccountUser, GroupRoleMap, Role, UserRoleBinding
from accounts.oidc_backend import TrainingOIDCBackend
from accounts.services import get_effective_capabilities, sync_roles_from_oidc_claims
from .factories import AccountUserFactory, GroupRoleMapFactory, RoleFactory, UserRoleBindingFactory
pytestmark = pytest.mark.django_db
# ── Unit: sync_roles_from_oidc_claims ──────────────────────────────────────────
class TestSyncRoles:
def test_creates_new_binding_from_group(self):
user = AccountUserFactory()
learner = RoleFactory(slug="learner", name="Learner")
GroupRoleMapFactory(oidc_group="g_learner", role=learner)
sync_roles_from_oidc_claims(user, {"groups": ["g_learner"]})
assert UserRoleBinding.objects.filter(user=user, role=learner, source="oidc").exists()
def test_removes_stale_oidc_binding(self):
user = AccountUserFactory()
learner = RoleFactory(slug="learner", name="Learner")
GroupRoleMapFactory(oidc_group="g_learner", role=learner)
UserRoleBindingFactory(user=user, role=learner, source="oidc")
# No groups in new claim → stale binding removed
sync_roles_from_oidc_claims(user, {"groups": []})
assert not UserRoleBinding.objects.filter(user=user, role=learner, source="oidc").exists()
def test_preserves_manual_binding(self):
user = AccountUserFactory()
trainer = RoleFactory(slug="trainer", name="Trainer")
UserRoleBindingFactory(user=user, role=trainer, source="manual")
# Sync with no groups — manual binding must survive
sync_roles_from_oidc_claims(user, {"groups": []})
assert UserRoleBinding.objects.filter(user=user, role=trainer, source="manual").exists()
def test_no_duplicate_on_repeated_sync(self):
user = AccountUserFactory()
learner = RoleFactory(slug="learner", name="Learner")
GroupRoleMapFactory(oidc_group="g_learner", role=learner)
sync_roles_from_oidc_claims(user, {"groups": ["g_learner"]})
sync_roles_from_oidc_claims(user, {"groups": ["g_learner"]})
assert UserRoleBinding.objects.filter(user=user, role=learner).count() == 1
# ── Unit: TrainingOIDCBackend.filter_users_by_claims ──────────────────────────
class TestFilterUsersByClaims:
def test_returns_user_for_known_sub(self, settings):
user = AccountUserFactory(oidc_sub="sub-known-123")
backend = TrainingOIDCBackend()
result = list(backend.filter_users_by_claims({"sub": "sub-known-123"}))
assert result == [user]
def test_returns_empty_for_missing_sub(self):
backend = TrainingOIDCBackend()
result = list(backend.filter_users_by_claims({}))
assert result == []
def test_returns_empty_for_unknown_sub(self):
backend = TrainingOIDCBackend()
result = list(backend.filter_users_by_claims({"sub": "nonexistent"}))
assert result == []
# ── Unit: get_effective_capabilities ──────────────────────────────────────────
class TestGetEffectiveCapabilities:
def test_learner_capabilities(self):
user = AccountUserFactory()
learner = RoleFactory(slug="learner", name="Learner")
UserRoleBindingFactory(user=user, role=learner)
caps = get_effective_capabilities(user)
assert "courses:view" in caps
assert "courses:enroll" in caps
assert "users:manage" not in caps
def test_admin_capabilities(self):
user = AccountUserFactory()
admin = RoleFactory(slug="admin", name="Administrator")
UserRoleBindingFactory(user=user, role=admin)
caps = get_effective_capabilities(user)
assert "users:manage" in caps
assert "audit:view" in caps
def test_empty_for_no_roles(self):
user = AccountUserFactory()
assert get_effective_capabilities(user) == []
def test_combined_roles(self):
user = AccountUserFactory()
learner = RoleFactory(slug="learner", name="Learner")
trainer = RoleFactory(slug="trainer", name="Trainer")
UserRoleBindingFactory(user=user, role=learner)
UserRoleBindingFactory(user=user, role=trainer)
caps = get_effective_capabilities(user)
assert "courses:enroll" in caps # learner
assert "training:signoff" in caps # trainer
def test_org_scoped_binding_excluded_for_other_org(self):
user = AccountUserFactory()
org_a = uuid.uuid4()
org_b = uuid.uuid4()
admin = RoleFactory(slug="admin", name="Administrator")
UserRoleBindingFactory(user=user, role=admin, org_id=org_a)
caps = get_effective_capabilities(user, org_id=org_b)
assert "users:manage" not in caps
# ── Integration: API endpoints ─────────────────────────────────────────────────
class TestMeEndpoint:
def test_authenticated_returns_profile(self):
user = AccountUserFactory(email="test@example.com", display_name="Test User")
client = APIClient()
client.force_authenticate(user=user)
response = client.get("/api/v1/accounts/me/")
assert response.status_code == 200
assert response.data["email"] == "test@example.com"
assert response.data["display_name"] == "Test User"
assert "roles" in response.data
def test_unauthenticated_returns_401(self):
client = APIClient()
response = client.get("/api/v1/accounts/me/")
assert response.status_code == 401
def test_me_includes_role_slugs(self):
user = AccountUserFactory()
learner = RoleFactory(slug="learner", name="Learner")
UserRoleBindingFactory(user=user, role=learner)
client = APIClient()
client.force_authenticate(user=user)
response = client.get("/api/v1/accounts/me/")
assert "learner" in response.data["roles"]
class TestMePermissionsEndpoint:
def test_returns_capabilities(self):
user = AccountUserFactory()
learner = RoleFactory(slug="learner", name="Learner")
UserRoleBindingFactory(user=user, role=learner)
client = APIClient()
client.force_authenticate(user=user)
response = client.get("/api/v1/accounts/me/permissions/")
assert response.status_code == 200
assert "courses:enroll" in response.data["capabilities"]
def test_unauthenticated_returns_401(self):
client = APIClient()
response = client.get("/api/v1/accounts/me/permissions/")
assert response.status_code == 401
class TestUserListEndpoint:
def test_admin_can_list_users(self):
admin_user = AccountUserFactory()
admin = RoleFactory(slug="admin", name="Administrator")
UserRoleBindingFactory(user=admin_user, role=admin)
client = APIClient()
client.force_authenticate(user=admin_user)
response = client.get("/api/v1/accounts/users/")
assert response.status_code == 200
def test_learner_gets_403(self):
user = AccountUserFactory()
learner = RoleFactory(slug="learner", name="Learner")
UserRoleBindingFactory(user=user, role=learner)
client = APIClient()
client.force_authenticate(user=user)
response = client.get("/api/v1/accounts/users/")
assert response.status_code == 403
def test_unauthenticated_gets_401(self):
client = APIClient()
response = client.get("/api/v1/accounts/users/")
assert response.status_code == 401
class TestUserRolesEndpoint:
def test_non_admin_gets_403(self):
user = AccountUserFactory()
learner = RoleFactory(slug="learner", name="Learner")
UserRoleBindingFactory(user=user, role=learner)
target = AccountUserFactory()
client = APIClient()
client.force_authenticate(user=user)
response = client.patch(
f"/api/v1/accounts/users/{target.id}/roles/",
{"role_slugs": ["learner"], "org_id": None},
format="json",
)
assert response.status_code == 403
def test_admin_can_assign_roles(self):
admin_user = AccountUserFactory()
admin = RoleFactory(slug="admin", name="Administrator")
UserRoleBindingFactory(user=admin_user, role=admin)
RoleFactory(slug="trainer", name="Trainer")
target = AccountUserFactory()
client = APIClient()
client.force_authenticate(user=admin_user)
response = client.patch(
f"/api/v1/accounts/users/{target.id}/roles/",
{"role_slugs": ["trainer"]},
format="json",
)
assert response.status_code == 200
assert UserRoleBinding.objects.filter(user=target, role__slug="trainer", source="manual").exists()
def test_unknown_role_slug_returns_400(self):
admin_user = AccountUserFactory()
admin = RoleFactory(slug="admin", name="Administrator")
UserRoleBindingFactory(user=admin_user, role=admin)
target = AccountUserFactory()
client = APIClient()
client.force_authenticate(user=admin_user)
response = client.patch(
f"/api/v1/accounts/users/{target.id}/roles/",
{"role_slugs": ["nonexistent_role"]},
format="json",
)
assert response.status_code == 400
# ── Integration: OIDC backend user lifecycle ──────────────────────────────────
class TestOIDCBackendLifecycle:
def _make_claims(self, sub=None, email=None, groups=None, name="Test User"):
return {
"sub": sub or str(uuid.uuid4()),
"email": email or f"{uuid.uuid4()}@example.com",
"name": name,
"groups": groups or [],
}
def test_create_user_with_roles(self):
learner = RoleFactory(slug="learner", name="Learner")
GroupRoleMapFactory(oidc_group="training/learners", role=learner)
claims = self._make_claims(groups=["training/learners"])
backend = TrainingOIDCBackend()
user = backend.create_user(claims)
assert AccountUser.objects.filter(email=claims["email"]).exists()
assert UserRoleBinding.objects.filter(user=user, role=learner, source="oidc").exists()
def test_update_user_reflects_role_change(self):
learner = RoleFactory(slug="learner", name="Learner")
trainer = RoleFactory(slug="trainer", name="Trainer")
GroupRoleMapFactory(oidc_group="g_learner", role=learner)
GroupRoleMapFactory(oidc_group="g_trainer", role=trainer)
sub = str(uuid.uuid4())
user = AccountUserFactory(oidc_sub=sub)
UserRoleBindingFactory(user=user, role=learner, source="oidc")
backend = TrainingOIDCBackend()
claims = self._make_claims(sub=sub, email=user.email, groups=["g_trainer"])
backend.update_user(user, claims)
assert not UserRoleBinding.objects.filter(user=user, role=learner, source="oidc").exists()
assert UserRoleBinding.objects.filter(user=user, role=trainer, source="oidc").exists()
def test_update_user_preserves_manual_binding(self):
learner = RoleFactory(slug="learner", name="Learner")
trainer = RoleFactory(slug="trainer", name="Trainer")
GroupRoleMapFactory(oidc_group="g_learner", role=learner)
sub = str(uuid.uuid4())
user = AccountUserFactory(oidc_sub=sub)
# manual trainer binding should survive OIDC sync
UserRoleBindingFactory(user=user, role=trainer, source="manual")
backend = TrainingOIDCBackend()
claims = self._make_claims(sub=sub, email=user.email, groups=["g_learner"])
backend.update_user(user, claims)
assert UserRoleBinding.objects.filter(user=user, role=trainer, source="manual").exists()
assert UserRoleBinding.objects.filter(user=user, role=learner, source="oidc").exists()

View File

@@ -1,5 +1,13 @@
from django.urls import path
from django.urls import include, path
from .views import MePermissionsView, MeView, UserListView, UserRolesView
app_name = "accounts"
urlpatterns = []
urlpatterns = [
path("oidc/", include("mozilla_django_oidc.urls")),
path("me/", MeView.as_view(), name="me"),
path("me/permissions/", MePermissionsView.as_view(), name="me-permissions"),
path("users/", UserListView.as_view(), name="user-list"),
path("users/<uuid:user_id>/roles/", UserRolesView.as_view(), name="user-roles"),
]

73
accounts/views.py Normal file
View File

@@ -0,0 +1,73 @@
from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.generics import ListAPIView, RetrieveAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import AccountUser, Role, UserRoleBinding
from .permissions import IsAdmin, IsAdminOrManager
from .serializers import (
RolePatchSerializer,
UserListSerializer,
UserProfileSerializer,
)
from .services import get_effective_capabilities
class MeView(RetrieveAPIView):
permission_classes = [IsAuthenticated]
serializer_class = UserProfileSerializer
def get_object(self):
return self.request.user
class MePermissionsView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
org_id = request.query_params.get("org_id")
caps = get_effective_capabilities(request.user, org_id=org_id)
return Response({"capabilities": caps})
class UserListView(ListAPIView):
permission_classes = [IsAuthenticated, IsAdminOrManager]
serializer_class = UserListSerializer
def get_queryset(self):
qs = AccountUser.objects.filter(is_active=True).order_by("email")
org_id = self.request.query_params.get("org_id")
if org_id:
qs = qs.filter(role_bindings__org_id=org_id).distinct()
return qs
class UserRolesView(APIView):
permission_classes = [IsAuthenticated, IsAdmin]
def patch(self, request, user_id):
user = get_object_or_404(AccountUser, pk=user_id)
serializer = RolePatchSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
role_slugs: list[str] = serializer.validated_data["role_slugs"]
org_id = serializer.validated_data.get("org_id")
roles = list(Role.objects.filter(slug__in=role_slugs))
found_slugs = {r.slug for r in roles}
missing = set(role_slugs) - found_slugs
if missing:
return Response(
{"detail": f"Unknown role slugs: {', '.join(sorted(missing))}"},
status=status.HTTP_400_BAD_REQUEST,
)
UserRoleBinding.objects.filter(user=user, source="manual", org_id=org_id).delete()
for role in roles:
UserRoleBinding.objects.get_or_create(
user=user, role=role, org_id=org_id, defaults={"source": "manual"}
)
return Response({"assigned": role_slugs})

View File

@@ -44,6 +44,8 @@ LOCAL_APPS = [
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
AUTH_USER_MODEL = "accounts.AccountUser"
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"csp.middleware.CSPMiddleware",
@@ -93,8 +95,8 @@ AUTH_PASSWORD_VALIDATORS = [
]
AUTHENTICATION_BACKENDS = [
"accounts.oidc_backend.TrainingOIDCBackend",
"django.contrib.auth.backends.ModelBackend",
"mozilla_django_oidc.auth.OIDCAuthenticationBackend",
"guardian.backends.ObjectPermissionBackend",
]
@@ -160,6 +162,10 @@ OIDC_OP_TOKEN_ENDPOINT = env("OIDC_OP_TOKEN_ENDPOINT", default="")
OIDC_OP_USER_ENDPOINT = env("OIDC_OP_USER_ENDPOINT", default="")
OIDC_OP_JWKS_ENDPOINT = env("OIDC_OP_JWKS_ENDPOINT", default="")
OIDC_RP_SIGN_ALGO = env("OIDC_RP_SIGN_ALGO", default="RS256")
OIDC_RP_SCOPES = "openid email profile groups"
OIDC_STORE_ACCESS_TOKEN = False
OIDC_STORE_REFRESH_TOKEN = False
OIDC_USERNAME_ALGO = "accounts.oidc_backend.generate_username"
# --- SimpleJWT ---
from datetime import timedelta