from django.conf import settings from django.db import models from django.db.models import Q from infrasynth.tenancy.managers import AllObjectsManager from infrasynth.tenancy.mixins import ( ContextGlobalOrTenantModel, GlobalOrTenantModel, TenantOwnedModel, ) class Role(GlobalOrTenantModel): """A permission role. ``tenant IS NULL`` is a system role; a tenant row overrides it.""" name = models.CharField(max_length=100) slug = models.SlugField(max_length=100) description = models.TextField(blank=True) permissions = models.JSONField(default=list, help_text="List of permission codenames") is_system = models.BooleanField(default=False, help_text="System roles cannot be deleted") users = models.ManyToManyField( settings.AUTH_USER_MODEL, related_name="roles", blank=True, help_text="Users assigned this role", ) class Meta: db_table = "security_role" constraints = [ models.UniqueConstraint(fields=["tenant", "slug"], name="uniq_role_slug_per_tenant"), models.UniqueConstraint( fields=["slug"], condition=Q(tenant__isnull=True), name="uniq_global_role_slug", ), ] def __str__(self): return self.name class Grant(ContextGlobalOrTenantModel): """A direct user permission grant. ``tenant IS NULL`` is a platform-wide grant (applies in every tenant); a non-null tenant scopes it to that tenant. A context write with no explicit tenant lands in the current tenant (see ``ContextGlobalOrTenantModel``). """ user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="direct_grants", ) codename = models.CharField(max_length=200, db_index=True) granted_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="grants_given", ) reason = models.TextField(blank=True) expires_at = models.DateTimeField(null=True, blank=True) class Meta: db_table = "security_grant" constraints = [ models.UniqueConstraint(fields=["tenant", "user", "codename"], name="uniq_grant_per_tenant_user"), models.UniqueConstraint( fields=["user", "codename"], condition=Q(tenant__isnull=True), name="uniq_global_grant_user_codename", ), ] class Revoke(ContextGlobalOrTenantModel): """A direct user permission revoke. ``tenant IS NULL`` revokes globally.""" user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="direct_revokes", ) codename = models.CharField(max_length=200, db_index=True) revoked_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="revokes_given", ) reason = models.TextField(blank=True) class Meta: db_table = "security_revoke" constraints = [ models.UniqueConstraint(fields=["tenant", "user", "codename"], name="uniq_revoke_per_tenant_user"), models.UniqueConstraint( fields=["user", "codename"], condition=Q(tenant__isnull=True), name="uniq_global_revoke_user_codename", ), ] class Permission(models.Model): """The permission catalog: every assignable codename (derived or custom). Global platform metadata — not tenant-scoped. Rows are upserted by :func:`infrasynth.security.catalog.sync_permissions`; ``is_active`` is flipped off (never deleted) so existing role assignments survive. """ codename = models.CharField(max_length=200, unique=True) name = models.CharField(max_length=200) app_label = models.CharField(max_length=100, db_index=True) model = models.CharField(max_length=100, blank=True) action = models.CharField(max_length=50, blank=True) group = models.CharField(max_length=100, blank=True) description = models.TextField(blank=True) is_custom = models.BooleanField(default=False, help_text="Registered in app code (not derived from a model)") is_active = models.BooleanField(default=True) objects = AllObjectsManager() class Meta: db_table = "security_permission" ordering = ["group", "model", "codename"] indexes = [models.Index(fields=["app_label", "model"])] def __str__(self) -> str: return self.codename class RoleAssignment(TenantOwnedModel): """Assigns a role to a user **within one tenant** (many roles per user). Global roles are assigned through ``Role.users`` (they apply everywhere); tenant-local roles are assigned through this table so ``tenancy`` stays independent of ``security``. """ user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="tenant_role_assignments", ) role = models.ForeignKey(Role, on_delete=models.CASCADE, related_name="assignments") assigned_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+", ) created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = "security_role_assignment" constraints = [ models.UniqueConstraint(fields=["tenant", "user", "role"], name="uniq_role_assignment"), ] class APIKey(TenantOwnedModel): """A tenant-scoped service credential (``prefix.secret``, secret hashed).""" name = models.CharField(max_length=200) prefix = models.CharField(max_length=12, help_text="First 8 characters visible in UI") key_hash = models.CharField(max_length=255, help_text="PBKDF2 hash of the full secret") scopes = models.JSONField(default=list, help_text='["read:users", "write:billing"]') created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) is_active = models.BooleanField(default=True) expires_at = models.DateTimeField(null=True, blank=True) last_used_at = models.DateTimeField(null=True, blank=True) rotated_from = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True) class Meta: db_table = "security_api_key" constraints = [ models.UniqueConstraint(fields=["tenant", "prefix"], name="uniq_api_key_prefix_per_tenant"), ] class TwoFactorConfig(models.Model): METHOD_TOTP = "totp" METHOD_EMAIL = "email" METHOD_BOTH = "both" user = models.OneToOneField( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="two_factor_config", ) is_enabled = models.BooleanField(default=False) is_configured = models.BooleanField(default=False) method = models.CharField( max_length=10, choices=[ (METHOD_TOTP, "TOTP"), (METHOD_EMAIL, "Email"), (METHOD_BOTH, "Both"), ], default=METHOD_TOTP, ) secret_key_encrypted = models.CharField(max_length=500, null=True, blank=True) recovery_codes_encrypted = models.TextField(null=True, blank=True) email_verified = models.BooleanField(default=False) email_code = models.CharField(max_length=6, null=True, blank=True) email_code_expires_at = models.DateTimeField(null=True, blank=True) class Meta: db_table = "security_two_factor_config" class ALTCHAChallenge(models.Model): challenge_id = models.CharField(max_length=64, primary_key=True) salt = models.CharField(max_length=32) difficulty = models.IntegerField(default=10000) expires_at = models.DateTimeField(db_index=True) is_verified = models.BooleanField(default=False) class Meta: db_table = "security_altcha_challenge"