infrasynth-backend-kit/infrasynth/security/models.py
jcv-dev 551b42eab5 feat: production-hardening pass across the kit
Close the gaps between the documented contract (API-STANDARD, TENANCY,
ENTITLEMENTS) and the implementation, and remove committed build artifacts.

Security:
- verify + process inbound webhooks (HMAC/handler verify, size limit,
  timestamp tolerance, idempotency via InboundEvent.external_id)
- real 2FA login flow (pre-auth challenge; tokens only after verify/recovery)
- wire HybridPermission into security/audit views; add API-key rotate and
  users/<id>/permissions|roles endpoints
- tenant-scoped throttling on by default; webhook replay protection
- verify MercadoPago webhook signatures
- login brute-force guard, configurable password policy, real ALTCHA PoW

Correctness:
- apply verified billing webhooks idempotently (subscription/entitlement/
  invoice/PaymentTransaction); scheduled payment lifecycle jobs
- capture audit update diffs automatically; add audit retention purge
- working notification retries, per-channel rate limits, log retention
- pluggable virus scanner, upload-size limit, pipeline toggle
- feature rollout %/environment targeting; settings-driven registrations
- workflow guards (instance cap, route depth, self-assignment, clone on re-entry)
- wire every previously-dead INFRASYNTH_* setting; drop truly dead ones

Delivery:
- README + CHANGELOG; CI format check + coverage gate
- keep test media out of the tree; untrack .coverage, __pycache__,
  egg-info, docs/ and invoice artifacts
2026-09-24 10:41:21 -05:00

142 lines
4.8 KiB
Python

from django.conf import settings
from django.db import models
from django.db.models import Q
from infrasynth.tenancy.mixins import 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(TenantOwnedModel):
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"),
]
class Revoke(TenantOwnedModel):
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"),
]
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"