- 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)
225 lines
7.8 KiB
Python
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"
|