"""Member and User models — no local password authentication. Identity is established via NetKingdom OIDC (IAM Profile). Passwords are never collected or validated; Django users always have an unusable password. """ from __future__ import annotations import uuid from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin from django.db import models from django.utils import timezone class UserManager(BaseUserManager): def create_user(self, username: str, **extra_fields): if not username: raise ValueError("username is required") user = self.model(username=username, **extra_fields) user.set_unusable_password() user.save(using=self._db) return user def create_superuser(self, username: str, **extra_fields): extra_fields.setdefault("is_staff", True) extra_fields.setdefault("is_superuser", True) return self.create_user(username, **extra_fields) class User(AbstractBaseUser, PermissionsMixin): """Session principal. Credentials live at the NetKingdom issuer, not here.""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) username = models.CharField(max_length=255, unique=True) email = models.EmailField(blank=True) full_name = models.CharField(max_length=255, blank=True) is_active = models.BooleanField(default=True) is_staff = models.BooleanField(default=False) date_joined = models.DateTimeField(default=timezone.now) objects = UserManager() USERNAME_FIELD = "username" REQUIRED_FIELDS: list[str] = [] class Meta: verbose_name = "user" verbose_name_plural = "users" def get_full_name(self) -> str: return self.full_name or self.username def get_short_name(self) -> str: return self.full_name or self.username def __str__(self) -> str: return self.username class Member(models.Model): """Local app context for a platform user within a tenant. Links NetKingdom OIDC subject + user-engine user id. No password fields. """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) tenant_id = models.CharField(max_length=64, db_index=True) user = models.OneToOneField( User, on_delete=models.CASCADE, related_name="member", ) # Stable id from user-engine (string to avoid coupling to UUID vs ULID choice) user_engine_user_id = models.CharField(max_length=64, db_index=True) issuer = models.CharField(max_length=512) subject = models.CharField(max_length=255) display_name = models.CharField(max_length=255, blank=True) email = models.EmailField(blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) last_login_at = models.DateTimeField(null=True, blank=True) class Meta: constraints = [ models.UniqueConstraint( fields=["issuer", "subject"], name="members_member_issuer_subject_uniq", ), models.UniqueConstraint( fields=["tenant_id", "user_engine_user_id"], name="members_member_tenant_ue_user_uniq", ), ] indexes = [ models.Index(fields=["tenant_id", "subject"]), ] def __str__(self) -> str: return f"{self.display_name or self.subject}@{self.tenant_id}"