Files
training-software/accounts/services.py
Paperclip CTO 2c38fd862d
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
feat(TRA-234): implement OIDC auth and group-to-role mapping
- 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>
2026-05-07 09:11:23 +02:00

50 lines
1.7 KiB
Python

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)