infrasynth-backend-kit/infrasynth/security/models.py
jcv-dev a2930426b4 feat: tenant configs, live signals, and automatic permission management
- infrasynth.configs: typed multi-tenant config store (registry, service,
  secrets, cache) + public config_changed/config_reset signals and API
- emit the declared-but-dead signals (features flags/overrides, scheduler
  task_completed/task_failed, tenancy tenant_updated, audit model_changed)
  and per-model audit field exclusions
- security: permission catalog (security_permission), Django-style
  model-derived AutoPermission, PermissionRegistry, RoleAssignment,
  global-or-tenant Grant/Revoke, catalog API
- consolidate the permission surface: PermissionRegistry only (drop the
  settings dict), IsAuthenticatedAndPermitted aliases HybridPermission,
  require_permission replaced by required_permissions + require_all
- packaging: add [build-system]; add Forgejo publish workflow (.forgejo)
2026-09-29 17:06:54 -05:00

225 lines
7.8 KiB
Python

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"