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)
This commit is contained in:
jcv-dev 2026-09-29 17:06:54 -05:00
parent 5df0be1f5c
commit a2930426b4
65 changed files with 3992 additions and 149 deletions

View file

@ -0,0 +1,49 @@
name: Publish
# Publishes the package to the Forgejo package registry (PyPI-compatible).
# Trigger by pushing an annotated tag, e.g.: git tag v1.0.1 && git push origin main --tags
#
# Required repository secrets (Settings → Actions → Secrets):
# FORGEJO_USERNAME your Forgejo username (e.g. moravak)
# FORGEJO_TOKEN an access token with the `write:package` scope
#
# `runs-on` must match a label registered by your Forgejo runner
# (commonly `ubuntu-latest` or `docker`).
on:
push:
tags: ["v*"]
workflow_dispatch:
permissions:
contents: read
env:
PYTHON_VERSION: "3.12"
# Forgejo's PyPI registry is /api/packages/<owner>/pypi
REGISTRY_URL: https://git.infrasynth.net/api/packages/moravak/pypi
jobs:
publish:
name: Build & publish
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install build tooling
run: python -m pip install --upgrade pip build twine
- name: Build sdist + wheel
run: python -m build
- name: Publish to Forgejo PyPI registry
env:
TWINE_USERNAME: ${{ secrets.FORGEJO_USERNAME }}
TWINE_PASSWORD: ${{ secrets.FORGEJO_TOKEN }}
run: |
python -m twine upload --non-interactive --repository-url "$REGISTRY_URL" dist/*

View file

@ -2,7 +2,7 @@
## Project Overview
InfraSynth Base is a **reusable Django backend infrastructure kit** distributed as a single pip package (`infrasynth-base`). It is the **one shared kit** every Infrasynth app depends on, providing 10 Django apps that cover tenancy, authentication, authorization, audit logging, file storage, notifications, webhooks, workflows, job scheduling, feature flags, and billing. External systems (App B) install this package and build their domain apps on top without modifying InfraSynth source code.
InfraSynth Base is a **reusable Django backend infrastructure kit** distributed as a single pip package (`infrasynth-base`). It is the **one shared kit** every Infrasynth app depends on, providing 11 Django apps that cover tenancy, authentication, authorization, audit logging, file storage, notifications, webhooks, workflows, job scheduling, feature flags, typed tenant configuration, and billing. External systems (App B) install this package and build their domain apps on top without modifying InfraSynth source code.
**Every app is multi-tenant.** One deployment per app serves all customers; a customer is a **tenant** (workspace), isolated at row level via `tenant_id` on a shared schema. Read `../TENANCY.md` — it is the source of truth for tenancy.
@ -54,6 +54,7 @@ infrasynth-base/
│ ├── workflows/ # Django app: 'infrasynth.workflows'
│ ├── scheduler/ # Django app: 'infrasynth.scheduler'
│ ├── features/ # Django app: 'infrasynth.features'
│ ├── configs/ # Django app: 'infrasynth.configs' (typed per-tenant configuration)
│ └── billing/ # Django app: 'infrasynth.billing'
│
└── tests/
@ -191,6 +192,7 @@ def initial(self, request, *args, **kwargs):
5. **Pagination:** All list views use the standard `CustomPagination` class. Query param `?page_size=` (default 25, max 100).
6. **Filtering:** Use `DjangoFilterBackend` with a `FilterSet` class per view.
7. **Tenant scoping is automatic:** never filter by tenant by hand — `Model.objects` is already scoped to the current tenant. Never call `unsafe_all()` from a view. A cross-tenant id resolves to `404` (the scoped manager makes the row invisible), never `403`.
8. **Permissions are automatic:** subclass `infrasynth.security.viewsets.InfraSynthModelViewSet` (or `InfraSynthReadOnlyModelViewSet`) and the derived `{app}.{verb}_{model}` codename is enforced per action — no `required_permissions` needed. Override with an explicit `required_permissions` (any-of; set `require_all = True` for all-of) or map a custom action with `action_permissions = {"action": "codename"}`. Register non-model permissions with `PermissionRegistry.register(...)` in `apps.py:ready()`.
### Signal Conventions
@ -198,12 +200,20 @@ def initial(self, request, *args, **kwargs):
2. **Receiver functions go in `receivers.py` or `apps.py:ready()`** (for connecting signals across apps).
3. **Always use `sender=` parameter** when connecting to specific model signals.
4. **Use `@receiver(signal_name)`** decorator pattern.
5. **Every declared `Signal()` is emitted.** The kit's signals all have a real
`.send(...)` call site (`features.*`, `scheduler.task_completed/task_failed`,
`tenancy.*`, `audit.model_changed`, `configs.config_changed/config_reset`).
Emit from the service/mutation point, not from a receiver, and always pass
`tenant_id` explicitly. Never put a secret's plaintext or ciphertext in a
signal payload — pass it masked (`None`).
### Registry Conventions
Registries are singleton classes (not instances) with `@classmethod` methods. They live in a `registry.py` file in their owning app:
- `infrasynth.features.registry.FeatureRegistry` — feature flag definitions
- `infrasynth.security.registry.PermissionRegistry` — custom permission definitions
- `infrasynth.configs.registry.ConfigRegistry` — typed configuration definitions
- `infrasynth.webhooks.registry.EventRegistry` — event definitions
- `infrasynth.notifications.resolvers.VariableResolverRegistry` — template variable resolvers
- `infrasynth.workflows.validators.DataValidatorRegistry` — workflow data validators
@ -259,7 +269,7 @@ from infrasynth.shared.settings_utils import get_setting
cookie_secure = get_setting("INFRASYNTH_SECURITY", "COOKIE_SECURE", True)
```
5. **Tenancy is configured via `INFRASYNTH_TENANCY`** (`TENANT_MODEL`, `TENANT_CLAIM`, `REQUIRE_TENANT_BY_DEFAULT`, allowlist, defaults). Billing/grace via `INFRASYNTH_BILLING` (`GRACE_PERIOD_DAYS`, `DEFAULT_CURRENCY`). Both have safe defaults (`../TENANCY.md`, `../ENTITLEMENTS.md`).
5. **Tenancy is configured via `INFRASYNTH_TENANCY`** (`TENANT_MODEL`, `TENANT_CLAIM`, `REQUIRE_TENANT_BY_DEFAULT`, allowlist, defaults). Billing/grace via `INFRASYNTH_BILLING` (`GRACE_PERIOD_DAYS`, `DEFAULT_CURRENCY`). Typed tenant configuration via `INFRASYNTH_CONFIGS` (`DEFINITIONS`, cache, `ALLOW_GLOBAL_WRITES`). Permissions via `INFRASYNTH_SECURITY` (`AUTO_PERMISSIONS`, `STRICT_PERMISSION_VALIDATION`, `PERMISSION_EXCLUDE_MODELS`). All have safe defaults (`../TENANCY.md`, `../ENTITLEMENTS.md`).
### Crypto Conventions
@ -428,6 +438,12 @@ Enforcement is server-side and in-process. There is no license key and no offlin
| `infrasynth/shared/results.py` | Result monad |
| `infrasynth/features/registry.py` | Feature flag registry |
| `infrasynth/features/services.py` | Feature flag evaluation |
| `infrasynth/configs/registry.py` | Typed configuration registry |
| `infrasynth/configs/services.py` | ConfigService (precedence, secrets, cache) |
| `infrasynth/security/registry.py` | Custom permission registry |
| `infrasynth/security/catalog.py` | Permission derivation + `sync_permissions` |
| `infrasynth/security/permissions.py` | HybridPermission + AutoPermission |
| `infrasynth/security/viewsets.py` | Auto-permission viewset bases |
| `infrasynth/webhooks/registry.py` | Event registry |
| `infrasynth/notifications/resolvers.py` | Template variable resolvers |
| `infrasynth/notifications/channels/base.py` | Channel ABC |
@ -437,7 +453,7 @@ Enforcement is server-side and in-process. There is no license key and no offlin
| `infrasynth/security/services.py` | AuthorizationService |
| `infrasynth/security/auth/cookies.py` | CookieJWTAuthentication |
| `infrasynth/security/auth/api_keys.py` | APIKeyAuthentication |
| `infrasynth/security/permissions.py` | HybridPermission + require_permission |
| `infrasynth/security/permissions.py` | HybridPermission + AutoPermission |
| `infrasynth/files/services.py` | FileService (upload, signed_url, delete) |
| `infrasynth/scheduler/services.py` | TaskService |

View file

@ -10,6 +10,44 @@ not hand-pick a version (see `../AGENTS.backend-packages.md` §8).
## [Unreleased]
### Added
- **Automatic permission management.** Every concrete model contributes
Django-style `view`/`add`/`change`/`delete` codenames
(`{app}.{verb}_{model}`) to a kit-owned permission catalog
(`security.Permission`), and `infrasynth.security.permissions.AutoPermission`
derives and enforces the right codename per DRF action with no per-view
configuration. Consumers register custom codenames in their own code via
`PermissionRegistry`, kit model
viewsets enforce automatically, and `manage.py sync_permissions` +
`post_migrate` keep the catalog in sync. `INFRASYNTH_SECURITY["AUTO_PERMISSIONS"]`
selects `global` (default) / `opt_in` / `off`. New `GET /api/v1/auth/permissions/`
catalog endpoint for assignment UIs.
- **Multiple roles per user per tenant.** `security.RoleAssignment` (tenant-scoped)
complements the global `Role.users` M2M; `AuthorizationService` resolves both.
Global roles (`tenant IS NULL`) require `platform.roles.manage` to create/edit;
tenant roles remain under `security.manage_roles`.
- **Global grants/revokes.** `Grant`/`Revoke` are now global-or-tenant: a normal
write is tenant-scoped, `{"scope": "global"}` (requires `platform.roles.manage`)
creates a platform-wide override that applies in every tenant.
- Built-in custom permissions for the kit (`security.*`, `audit.*`, `configs.*`,
`tenancy.*`, and the `platform.*` cross-tenant set).
- **`infrasynth.configs` — typed, multi-tenant configuration store.** New app
(`configs` feature flag) with a code/settings registry
(`ConfigRegistry`/`INFRASYNTH_CONFIGS["DEFINITIONS"]`), typed coercion
(string/int/float/bool/decimal/json/choice/duration), precedence
tenant override → global default → registry default, per-tenant caching, and
Fernet-encrypted secrets that are masked in the API/signals/audit. API under
`/api/v1/configs/` (`GET` values/definitions, `PUT`/`DELETE` override,
`PUT .../global/<key>/`), gated by `configs.manage`/`configs.manage_global`.
Public signals `config_changed`/`config_reset`.
- **Emitted signals that were previously declared but never fired.**
`features.flag_created`/`flag_toggled`/`flag_deleted` and
`override_created`/`override_deleted` now fire from the flag viewsets (and flag
mutations bust the feature cache); `scheduler.task_completed`/`task_failed` fire
exactly once on terminal `TaskExecution` transitions (new `scheduler/receivers.py`);
`tenancy.tenant_updated` fires from the tenant edit path (`TenantService.update_tenant`);
`audit.model_changed` fires alongside each `ModelChangeLog` row.
- `INFRASYNTH_AUDIT["EXCLUDED_MODEL_FIELDS"]` — per-model excluded fields so
`ConfigValue` is audited without ever logging its (possibly encrypted) value.
- **Uniform extensibility across every module.** Storage backends can be
registered (`register_storage_backend` or `STORAGE_BACKENDS[name]["CLASS"]`),
pipeline steps via `PipelineStepRegistry`/`@pipeline_step`, the pipeline
@ -41,7 +79,7 @@ not hand-pick a version (see `../AGENTS.backend-packages.md` §8).
second factor (pre-auth session + cookie) and only mints JWT cookies after
`2fa/verify/` (or `2fa/recovery/`) succeeds; `TwoFactorMiddleware` guards the
session-authenticated surface.
- **Permission enforcement.** `HybridPermission` / `require_permission` are now
- **Permission enforcement.** `HybridPermission` (any-of `required_permissions`, or all-of with `require_all`) is now
wired into security and audit viewsets with documented codenames and a
tenant-owner bypass; `HybridPermission` takes tenant ownership into account.
- **API-key rotation** (`/api/v1/auth/api-keys/<id>/rotate/`) and
@ -69,6 +107,12 @@ not hand-pick a version (see `../AGENTS.backend-packages.md` §8).
- `README.md`, `CHANGELOG.md`, and a CI format/coverage gate.
### Changed
- **Permission surface consolidated.** `IsAuthenticatedAndPermitted` is now an
alias of `HybridPermission` (it was a no-op subclass), and the
`require_permission(...)` class factory was removed: use
`required_permissions` (any-of) plus `require_all = True` on the view for
all-of. Custom permissions are declared only through `PermissionRegistry`
(the `INFRASYNTH_SECURITY["CUSTOM_PERMISSIONS"]` settings path was dropped).
- `TenantRateThrottle` and `RateLimitHeadersMiddleware` are active by default,
producing `X-RateLimit-*` headers on API responses.
- `EntitlementService` treats `past_due` as within grace (entitled) and merges

313
PLAN.md
View file

@ -61,7 +61,7 @@ InfraSynth Base es un conjunto de Django apps reutilizables que proveen la infra
### Fase 6 — Seguridad: Autorización (`infrasynth/security/`) ✅ _(2026-07-30)_
- [x] `services.py` — AuthorizationService (has_permission, get_effective_permissions, chain)
- [x] `permissions.py` — HybridPermission + require_permission
- [x] `permissions.py` — HybridPermission (`required_permissions` + `require_all`) + AutoPermission
- [x] Views: roles, grants, revokes CRUD
- [x] Tests: permission resolution chain (superuser → revoke → grant → role → default) ✅ _(2026-07-30)_
- Fixes: Role.users M2M added (related_name="roles"), SystemUser scopes as permissions, RoleViewSet lookup by slug, PermissionDenied instead of PermissionError
@ -165,7 +165,7 @@ InfraSynth Base es un conjunto de Django apps reutilizables que proveen la infra
### Fase 19 — Endurecimiento a producción ✅ _(2026-09-24)_
- [x] Webhooks entrantes verificados: HMAC / `BaseInboundHandler.verify`, límite de tamaño, tolerancia de timestamp, idempotencia por `external_id`, handler `process()` ejecutado y `is_verified`/`is_processed` persistidos
- [x] 2FA real en login (pre-auth session + cookie, tokens sólo tras verificar) y `TwoFactorMiddleware` para sesión
- [x] Permisos cableados: `HybridPermission`/`require_permission` en security y audit, bypass de owner del tenant, rotación de API keys, endpoints `users/<id>/permissions` y `/roles`
- [x] Permisos cableados: `HybridPermission` en security y audit, bypass de owner del tenant, rotación de API keys, endpoints `users/<id>/permissions` y `/roles`
- [x] Webhooks de pago procesados e idempotentes (suscripción/entitlement/invoice/`PaymentTransaction`), replay protegido, firma MercadoPago
- [x] Ciclo de vida de entitlements programado (sync, past_due→grace→suspended, expiración, facturas de renovación)
- [x] Reintentos de notificaciones + rate limit por canal + retención de logs; captura automática de diffs de update en audit + retención
@ -176,6 +176,26 @@ InfraSynth Base es un conjunto de Django apps reutilizables que proveen la infra
- [x] README + CHANGELOG; CI con `ruff format --check` y umbral de cobertura
- [x] `infrasynth.gates` — gates por endpoint (`TwoFactorGate`, `AltchaGate`, `EntitlementGate`, `FeatureGate`, `PermissionGate`) declarables sin tocar el kit; `GatePermission` por defecto; claim `2fa` en el JWT
### Fase 20 — Configuración multi-tenant (`infrasynth.configs`) y señales reales ✅ _(2026-09-24)_
- [x] App `infrasynth.configs` (`label="infrasynth_configs"`): modelo `ConfigValue` (`GlobalOrTenantModel`: fila global con `tenant IS NULL` + override por tenant), registro tipado `ConfigRegistry`/`ConfigDefinition`/`ConfigType` y registro declarativo `INFRASYNTH_CONFIGS["DEFINITIONS"]`
- [x] `ConfigService`: precedencia tenant → global → default, coerción/validación tipada (`string/int/float/bool/decimal/json/choice/duration`, validadores por dotted path), caché por tenant con invalidación en escritura
- [x] Secretos con Fernet cifrados en reposo, descifrados al leer y **enmascarados** en API/señales/audit; `INFRASYNTH_AUDIT["EXCLUDED_MODEL_FIELDS"]` evita registrar `ConfigValue.value`
- [x] API `/api/v1/configs/` (valores efectivos con `?group=`/`?keys=`, `PUT`/`DELETE` de override, `definitions/`, `PUT global/<key>/`) con permisos `configs.manage`/`configs.manage_global` y bypass de owner
- [x] Señales públicas `config_changed`/`config_reset` emitidas desde el servicio (secretos enmascarados, `tenant_id` explícito)
- [x] Señales antes declaradas y nunca emitidas ahora reales: `features.flag_created/flag_toggled/flag_deleted` + `override_created/override_deleted` (con invalidación de caché), `scheduler.task_completed/task_failed` (transición terminal única), `tenancy.tenant_updated`, `audit.model_changed`
- [x] Suite `tests/test_configs/` (registry, services, isolation, signals, views) + tests de señales de features/scheduler/tenancy/audit
### Fase 21 — Gestión automática de permisos ✅ _(2026-09-24)_
- [x] Catálogo `security.Permission` (global): `codename`, `name`, `app_label`, `model`, `action`, `group`, `is_custom`, `is_active`; sincronizado por `manage.py sync_permissions` y en `post_migrate`
- [x] Codenames derivados estilo Django `{app_label}.{verb}_{model}` (view/add/change/delete) para **todos** los modelos (kit y consumidor), con denylist de internos
- [x] `PermissionRegistry` para permisos custom declarados en el código de la app consumidora, sin tocar el kit
- [x] `AutoPermission` + `HybridPermission` derivan y aplican el codename según la acción DRF sin `required_permissions`; modo `AUTO_PERMISSIONS` = `global` (default) / `opt_in` / `off`; bypass de owner/superuser; `action_permissions` para acciones custom
- [x] ViewSets base del kit (`InfraSynthModelViewSet`/`InfraSynthReadOnlyModelViewSet`) y migración de los viewsets del kit a enforcement automático
- [x] `security.RoleAssignment` (roles múltiples por usuario y tenant) además del M2M global `Role.users`; separación rol global (`platform.roles.manage`) vs rol de tenant (`security.manage_roles`)
- [x] `Grant`/`Revoke` global-o-tenant (`tenant IS NULL` = global) con `scope` en la API; precedencia revoke → grant → rol
- [x] API de catálogo `GET /api/v1/auth/permissions/` (`?app_label=`/`?group=`) y validación estricta opcional (`STRICT_PERMISSION_VALIDATION`)
- [x] Tests `tests/test_security/test_permissions.py` + verificación en dev server (miembro sin rol → 403; asignar rol de tenant → 200)
## 1. Estructura del Paquete
```
@ -249,9 +269,13 @@ backend-package/ # ← repo root / pip package roo
│ ├── security/ # Django app: 'infrasynth.security'
│ │ ├── __init__.py
│ │ ├── apps.py # SecurityConfig(AppConfig), registra flag "security"
│ │ ├── models.py # Role, Grant, Revoke, APIKey, TwoFactorConfig, ALTCHAChallenge
│ │ ├── models.py # Role, Grant, Revoke, RoleAssignment, Permission, APIKey, TwoFactorConfig, ALTCHAChallenge
│ │ ├── services.py # AuthorizationService: has_permission(), get_permissions()
│ │ ├── permissions.py # HybridPermission, require_permission decorator
│ │ ├── permissions.py # HybridPermission, AutoPermission
│ │ ├── registry.py # PermissionRegistry (permisos custom)
│ │ ├── catalog.py # derivación {app}.{verb}_{model} + sync_permissions()
│ │ ├── viewsets.py # InfraSynthModelViewSet (permisos automáticos)
│ │ ├── management/commands/ # sync_permissions
│ │ ├── auth/
│ │ │ ├── __init__.py
│ │ │ ├── cookies.py # CookieJWTAuthentication
@ -365,6 +389,18 @@ backend-package/ # ← repo root / pip package roo
│ │ ├── signals.py
│ │ └── migrations/
│ │
│ ├── configs/ # Django app: 'infrasynth.configs'
│ │ ├── __init__.py
│ │ ├── apps.py # ConfigsConfig, registra flag "configs" + DEFINITIONS
│ │ ├── registry.py # ConfigType, ConfigDefinition, ConfigRegistry
│ │ ├── models.py # ConfigValue (global default + tenant override)
│ │ ├── services.py # ConfigService: get/set/set_global/reset, secrets, caché
│ │ ├── serializers.py
│ │ ├── views.py
│ │ ├── urls.py
│ │ ├── signals.py # config_changed, config_reset (públicas)
│ │ └── migrations/
│ │
│ └── billing/ # Django app: 'infrasynth.billing'
│ ├── __init__.py
│ ├── apps.py # BillingConfig, registra flag "billing"
@ -394,6 +430,7 @@ backend-package/ # ← repo root / pip package roo
├── test_workflows/
├── test_scheduler/
├── test_features/
├── test_configs/
└── test_billing/
```
@ -693,7 +730,7 @@ class SecurityEvent(models.Model):
# infrasynth/audit/signals.py
from django.dispatch import Signal
model_changed = Signal() # kwargs: model_label, object_id, action, changes, actor
model_changed = Signal() # kwargs: tenant_id, model_label, object_id, action, changes, actor, request_id
security_event_occurred = Signal() # kwargs: event_type, actor, ip_address, metadata
```
@ -1073,30 +1110,27 @@ INFRASYNTH_SECURITY = {
```python
# infrasynth/security/permissions.py
class HybridPermission(BasePermission):
"""
Clase de permiso DRF que usa el AuthorizationService.
Define required_permissions en la view.
"""
"""Authenticated, then permission-checked (owner/superuser bypass)."""
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated:
return False
required = getattr(view, "required_permissions", [])
if not required:
evaluate_gates(request, view) # los gates aplican a todos
if request.user.is_superuser or is_tenant_owner(request.user):
return True
required = getattr(view, "required_permissions", []) or []
if not required:
return evaluate_auto_permission(request, view) # derivado del modelo
authz = AuthorizationService()
if getattr(view, "require_all", False): # all-of; por defecto any-of
return authz.has_all_permissions(request.user, required)
return authz.has_any_permission(request.user, required)
def require_permission(*codenames: str):
"""Decorador/clase para views DRF."""
class PermissionRequired(HybridPermission):
def has_permission(self, request, view):
if not super().has_permission(request, view):
return False
authz = AuthorizationService()
return authz.has_all_permissions(request.user, list(codenames))
return PermissionRequired
# Alias de compatibilidad: el nombre idiomático para views del kit.
IsAuthenticatedAndPermitted = HybridPermission
```
`required_permissions` es **any-of**; añade `require_all = True` en la vista para exigir **all-of**. No hay una clase factory aparte.
#### Patrón de Integración para App B
```python
@ -1112,11 +1146,12 @@ REST_FRAMEWORK = {
}
# En views de App B
from infrasynth.security.permissions import require_permission
from infrasynth.security.permissions import HybridPermission
from infrasynth.security.services import AuthorizationService
class TicketViewSet(ModelViewSet):
permission_classes = [IsAuthenticated, require_permission("helpdesk.manage_tickets")]
permission_classes = [HybridPermission]
required_permissions = ["helpdesk.manage_tickets"] # any-of; require_all=True para all-of
def get_queryset(self):
authz = AuthorizationService()
@ -2298,8 +2333,8 @@ class TaskExecution(models.Model):
```python
task_scheduled = Signal() # kwargs: task_name, eta
task_started = Signal() # kwargs: task_name, task_id, worker
task_completed = Signal() # kwargs: task_name, task_id, result, duration_seconds
task_failed = Signal() # kwargs: task_name, task_id, error, traceback
task_completed = Signal() # kwargs: tenant_id, task_name, task_id, duration_ms, result
task_failed = Signal() # kwargs: tenant_id, task_name, task_id, error, traceback
```
#### API Endpoints
@ -2515,11 +2550,11 @@ class FeatureService:
#### Señales
```python
flag_created = Signal() # kwargs: slug, created_by
flag_toggled = Signal() # kwargs: slug, new_state, toggled_by
flag_deleted = Signal() # kwargs: slug, deleted_by
override_created = Signal() # kwargs: flag_slug, user, group, is_enabled
override_deleted = Signal() # kwargs: flag_slug, user, group
flag_created = Signal() # kwargs: tenant_id, flag_slug, is_active, actor_id
flag_toggled = Signal() # kwargs: tenant_id, flag_slug, is_active, previous_is_active, actor_id
flag_deleted = Signal() # kwargs: tenant_id, flag_slug, actor_id
override_created = Signal() # kwargs: tenant_id, flag_slug, user_id, is_enabled, actor_id
override_deleted = Signal() # kwargs: tenant_id, flag_slug, user_id, actor_id
```
#### Configuración Externalizable
@ -2974,6 +3009,210 @@ Ticket.all_objects.all() # TODOS los tenants — solo para admin/management
---
### 2.12 `infrasynth.configs` — Configuración Multi-tenant
**Feature flag:** `configs` (default: True)
**Dependencias:** `infrasynth.shared`, `infrasynth.tenancy` (mixin `GlobalOrTenantModel`)
**Propósito:** almacén genérico, tipado y multi-tenant de preferencias escalares/JSON (branding, límites, integraciones). No es un toggle operativo (`features`) ni un derecho comercial (`billing`): es la configuración arbitraria de cada tenant.
#### Modelo
```python
class ConfigValue(GlobalOrTenantModel):
"""tenant IS NULL = default de plataforma; no nulo = override del tenant."""
key = models.CharField(max_length=200, db_index=True)
value = models.JSONField(default=dict) # token Fernet si el definition es secreto
updated_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
null=True, blank=True, related_name="+")
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "configs_value"
constraints = [
models.UniqueConstraint(fields=["tenant", "key"], name="uniq_config_key_per_tenant"),
models.UniqueConstraint(fields=["key"], condition=Q(tenant__isnull=True),
name="uniq_global_config_key"),
]
indexes = [models.Index(fields=["tenant_id", "key"])]
```
#### `registry.py` — Definiciones tipadas
```python
class ConfigType(StrEnum):
STRING = "string"; INT = "int"; FLOAT = "float"; BOOL = "bool"
DECIMAL = "decimal"; JSON = "json"; CHOICE = "choice"; DURATION = "duration" # int segundos
@dataclass(frozen=True)
class ConfigDefinition:
key: str; type: ConfigType = ConfigType.JSON; default: Any = None
choices: tuple = (); is_secret: bool = False
label: str = ""; group: str = ""; description: str = ""
validator: str | None = None # dotted path; callable(value) -> None | raises
class ConfigRegistry:
register(key, *, type=ConfigType.JSON, default=None, choices=(), is_secret=False,
label="", group="", description="", validator=None) -> ConfigDefinition
get(key) -> ConfigDefinition | None
all() -> dict[str, ConfigDefinition]
clear() -> None # tests
coerce(definition, value) -> Any # falla con ValidationAppError(VALIDATION_CONFIG_INVALID)
```
Reglas de `coerce`: `INT`/`FLOAT` rechazan `bool`; `DECIMAL` usa `Decimal(str(value))`; `BOOL` sólo acepta `bool` real; `JSON` debe ser serializable; `CHOICE` valida pertenencia; `DURATION` acepta segundos enteros o `"30s"/"5m"/"2h"/"1d"`. Un `validator` por dotted path se ejecuta tras la coerción y cualquier fallo se normaliza a `ValidationAppError`.
Registro: los consumidores llaman `ConfigRegistry.register(...)` en su `apps.py:ready()`; `ConfigsConfig.ready()` además registra `INFRASYNTH_CONFIGS["DEFINITIONS"]` (si `AUTO_REGISTER_FROM_SETTINGS`).
#### `services.py` — `ConfigService`
```python
class ConfigService:
def get(self, key, *, tenant=None, default=_UNSET) -> Any: ...
def get_many(self, keys: list[str], *, tenant=None) -> dict[str, Any]: ...
def get_all(self, *, tenant=None, group: str | None = None) -> dict[str, Any]: ...
def get_metadata(self, key, *, tenant=None) -> dict: ...
def set(self, key, value, *, tenant, user=None) -> ConfigValue: ...
def set_global(self, key, value, *, user=None) -> ConfigValue: ...
def reset(self, key, *, tenant) -> bool: ...
def is_overridden(self, key, *, tenant) -> bool: ...
def invalidate(self, tenant, key: str | None = None) -> None: ...
```
- **Precedencia:** fila del tenant → fila global → default del registry. Clave desconocida sin fila ni default ⇒ `NotFoundError` (nunca `None` silencioso).
- **Fail closed:** sin tenant sólo se lee la fila global y el default; nunca una fila de otro tenant. `tenant=None` cae a `get_current_tenant()`.
- **Tipado/secretos:** los secretos se descifran al leer y nunca se devuelve cifrado.
- **Caché:** alias `INFRASYNTH_CONFIGS["CACHE_BACKEND"]`, prefijo `CACHE_KEY_PREFIX`, TTL `CACHE_TTL_SECONDS`. Claves `f"{prefix}:tenant:{pk}:configs:{key}"` y `f"{prefix}:tenant:global:configs:{key}"`. Escrituras y `reset` invalidan.
- **Escrituras:** `transaction.atomic` + `update_or_create` sobre `all_objects`, `invalidate`, y emisión de señales. `set_global` requiere `ALLOW_GLOBAL_WRITES` (si no, `AuthError`).
#### Señales públicas (`signals.py`)
```python
config_changed = Signal() # tenant_id, key, scope ("tenant"|"global"), old_value, new_value, actor_id
config_reset = Signal() # tenant_id, key, scope, previous_value, actor_id
```
Emitidas **desde el servicio** (no por un receiver) con `tenant_id` explícito; en escrituras globales `tenant_id=None`. Los secretos se enmascaran (`None`) en ambos signals. Los consumidores conectan en `apps.py:ready()`.
#### API Endpoints (`/api/v1/configs/`)
| Endpoint | Método | Permiso | Descripción |
|---|---|---|---|
| `/configs/` | GET | autenticado | Valores efectivos (secretos enmascarados); `?group=`, `?keys=a,b` |
| `/configs/<key>/` | GET | autenticado | Valor efectivo + metadata (`type`, `is_secret`, `is_overridden`, `default`) |
| `/configs/<key>/` | PUT | `configs.manage` | Set del override del tenant (coercido/validado) |
| `/configs/<key>/` | DELETE | `configs.manage` | Reset del override al global/default |
| `/configs/definitions/` | GET | autenticado | Esquema de claves registradas (para formularios) |
| `/configs/global/<key>/` | PUT | `configs.manage_global` | Set del default de plataforma (si `ALLOW_GLOBAL_WRITES`) |
Clave desconocida ⇒ `404`; valor inválido ⇒ `400 VALIDATION_CONFIG_INVALID`. No se acepta `tenant` en el body: siempre se usa `get_current_tenant()`. `GET /configs/` devuelve un objeto acotado `{"values": [...]}` (excepción documentada a la paginación de colecciones de `API-STANDARD.md` §6; se usa una lista para que el camelCase no deforme las claves con puntos).
#### Configuración Externalizable
```python
INFRASYNTH_CONFIGS = {
"CACHE_BACKEND": "default",
"CACHE_KEY_PREFIX": "configs",
"CACHE_TTL_SECONDS": 60,
"AUTO_REGISTER_FROM_SETTINGS": True,
"DEFINITIONS": {}, # {"branding.primary_color": {"type": "string", "default": "#1a3a5c"}}
"ALLOW_GLOBAL_WRITES": True, # False en despliegues sólo-tenant
}
INFRASYNTH_AUDIT["EXCLUDED_MODEL_FIELDS"] = {"infrasynth_configs.ConfigValue": ["value"]}
```
#### Patrón de Integración para App B
```python
# helpdesk/apps.py → ready()
from infrasynth.configs import ConfigRegistry, config_changed
ConfigRegistry.register("helpdesk.sla_hours", type="int", default=24, group="helpdesk")
def _refresh_sla(sender, tenant_id, key, **kwargs):
if key == "helpdesk.sla_hours":
invalidate_sla_cache(tenant_id)
config_changed.connect(_refresh_sla, dispatch_uid="helpdesk.sla")
# helpdesk/services.py
from infrasynth.configs import ConfigService
sla_hours = ConfigService().get("helpdesk.sla_hours") # override del tenant → global → default
```
---
### 2.13 `infrasynth.security` — Gestión Automática de Permisos
**Propósito:** que cualquier sistema construido sobre el kit tenga permisos derivados de sus modelos, permisos custom declarados en su propio código, roles/usuarios asignables por UI y enforcement automático sin tocar vistas ni el kit.
#### Catálogo (`models.Permission`)
```python
class Permission(models.Model): # db_table = "security_permission" (global, no tenant-owned)
codename: str # "helpdesk.delete_ticket"
name: str # "Can delete ticket"
app_label: str; model: str; action: str; group: str
description: str; is_custom: bool; is_active: bool
```
`manage.py sync_permissions` (y `post_migrate`) hace upsert desde:
1. **Derivación de modelos** — todo modelo concreto aporta `{app_label}.{verb}_{model}` para `view/add/change/delete` (denylist de internos: sesiones, admin, contenttypes, celery, token_blacklist, logs de audit, el propio catálogo).
2. **`PermissionRegistry.register(...)`** — permisos custom declarados en `apps.py:ready()` de la app consumidora.
Los permisos que dejan de existir se marcan `is_active=False` (nunca se borran) para preservar asignaciones.
#### Enforcement automático (`permissions.AutoPermission`)
```python
class TicketViewSet(InfraSynthModelViewSet): # infrasynth.security.viewsets
queryset = Ticket.objects.all()
action_permissions = {"resolve": "helpdesk.resolve_ticket"} # opcional
```
Prioridad: `required_permissions` (explícito; **any-of** por defecto, `require_all = True` para all-of) → `action_permissions[action]` → derivado `{app}.{verb}_{model}` → `[]` (abstiene). Modos: `INFRASYNTH_SECURITY["AUTO_PERMISSIONS"]` = `"global"` (default; también en `DEFAULT_PERMISSION_CLASSES`, abstiene en APIViews sin modelo), `"opt_in"` (solo `auto_permissions=True`), `"off"`. Owner del tenant y superuser hacen bypass. Los viewsets del kit usan `HybridPermission` (alias `IsAuthenticatedAndPermitted`), que integra la evaluación automática.
#### Roles y overrides
- **Roles globales** (`Role.tenant_id IS NULL`): se asignan por el M2M `Role.users`, aplican en todos los tenants; requieren `platform.roles.manage` para crear/editar.
- **Roles de tenant**: se asignan por `RoleAssignment(tenant, user, role)` (varios roles por usuario y tenant); requieren `security.manage_roles`.
- **`Grant`/`Revoke`** son global-o-tenant (`tenant IS NULL` = global). Al crear sin `scope` se fijan al tenant actual; `{"scope": "global"}` (requiere `platform.roles.manage`) crea el override global. Precedencia: revoke → grant → roles → default.
#### API
| Endpoint | Método | Permiso | Descripción |
|---|---|---|---|
| `/auth/permissions/` | GET | `security.view_permissions` | Catálogo (`?app_label=`, `?group=`) |
| `/auth/roles/` | POST/PUT/DELETE | `security.manage_roles` (tenant) / `platform.roles.manage` (global) | Roles |
| `/auth/users/<id>/roles/` | GET/PUT | `security.manage_roles` | Asignar roles globales y de tenant |
| `/auth/grants/`, `/auth/revokes/` | POST/GET/DELETE | `security.manage_grants` (+ `platform.roles.manage` para `scope=global`) | Overrides por usuario |
#### Configuración Externalizable
```python
INFRASYNTH_SECURITY = {
"AUTO_PERMISSIONS": "global", # "global" | "opt_in" | "off"
"STRICT_PERMISSION_VALIDATION": False, # rechazar codenames desconocidos al escribir roles
"PERMISSION_EXCLUDE_MODELS": [], # labels extra a excluir de la derivación
"PERMISSION_APPS": None, # allowlist de app_labels (None = todas)
}
```
#### Patrón de Integración para App B
```python
# helpdesk/apps.py → ready()
from infrasynth.security.registry import PermissionRegistry
PermissionRegistry.register("helpdesk.export_ticket", name="Export tickets", group="Helpdesk")
# helpdesk/views.py
from infrasynth.security.viewsets import InfraSynthModelViewSet
class TicketViewSet(InfraSynthModelViewSet):
queryset = Ticket.objects.all()
serializer_class = TicketSerializer # view/add/change/delete_ticket automáticos
```
---
## 3. Patrones de Acoplamiento
### 3.1 Regla de Oro
@ -3330,6 +3569,14 @@ INFRASYNTH_SCHEDULER = {
"CELERY_BROKER_URL": os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0"),
}
INFRASYNTH_CONFIGS = {
"CACHE_TTL_SECONDS": 60,
"ALLOW_GLOBAL_WRITES": True,
"DEFINITIONS": {
"branding.primary_color": {"type": "string", "default": "#1a3a5c", "group": "branding"},
},
}
# ===================================================================
# Database (PostgreSQL)
# ===================================================================
@ -3414,11 +3661,12 @@ class Ticket(WorkflowAwareModel):
# ============================================================
# helpdesk/views.py
# ============================================================
from infrasynth.security.permissions import require_permission
from infrasynth.security.permissions import HybridPermission
from infrasynth.features.services import FeatureService
class TicketViewSet(ModelViewSet):
permission_classes = [IsAuthenticated, require_permission("helpdesk.manage_tickets")]
permission_classes = [HybridPermission]
required_permissions = ["helpdesk.manage_tickets"] # any-of; require_all=True para all-of
def get_queryset(self):
# Ticket.objects ya está scopeado al tenant actual por TenantManager (fail closed).
@ -3459,7 +3707,7 @@ class TicketViewSet(ModelViewSet):
| `pyproject.toml` | Meta-paquete con todas las dependencias |
| `infrasynth/shared/` | Protocolos, enums, crypto, Result monad |
| `infrasynth/audit/` | Django app: auditoría pasiva sin herencia |
| `infrasynth/security/` | Django app: auth JWT cookies + API keys, roles/grants híbridos, 2FA, ALTCHA |
| `infrasynth/security/` | Django app: auth JWT cookies + API keys, roles/grants/revokes, catálogo de permisos y permisos automáticos por modelo, 2FA, ALTCHA |
| `infrasynth/files/` | Django app: storage cloud (S3, Cloudinary, GCS), signed URLs, pipelines |
| `infrasynth/notifications/` | Django app: dispatch multi-canal con failover, templates, resolvers |
| `infrasynth/webhooks/` | Django app: inbound/outbound con HMAC, EventRegistry |
@ -3467,6 +3715,7 @@ class TicketViewSet(ModelViewSet):
| `infrasynth/scheduler/` | Django app: dashboard y API de jobs Celery |
| `infrasynth/tenancy/` | Django app: Tenant, TenantMembership, managers scopeados, middleware, contexto de request |
| `infrasynth/features/` | Django app: feature flags con tenant/user overrides, endpoint central `/api/features/active/` |
| `infrasynth/configs/` | Django app: configuración tipada multi-tenant (`ConfigValue`, `ConfigRegistry`, `ConfigService`), secretos cifrados, señales `config_changed`/`config_reset` |
| `infrasynth/billing/` | Django app: App, Plan, Entitlements, suscripciones, facturas, Stripe/MercadoPago/Wompi |
| `tests/` | Test suite completa con pytest + factory_boy, incluye aislamiento de tenants |
| `AGENTS.md` | Guía completa para agentes de IA |

124
README.md
View file

@ -15,16 +15,17 @@ Then `pytest` (single-command test suite), `ruff check .`, and `mypy infrasynth/
## What this is
One pip package (`infrasynth-base`) providing ten Django apps so no app ever reimplements auth, tenancy, entitlements, audit, files, notifications, webhooks, workflows, scheduling, or the API envelope:
One pip package (`infrasynth-base`) providing eleven Django apps so no app ever reimplements auth, tenancy, entitlements, audit, files, notifications, webhooks, workflows, scheduling, configuration, or the API envelope:
| Module | Responsibility |
|---|---|
| `infrasynth.shared` | Zero-Django primitives: protocols, enums, `Result`, Fernet crypto, settings helper |
| `infrasynth.api` | DRF envelope, camelCase, cursor pagination, request-id, exceptions, throttling, idempotency, webhook hardening |
| `infrasynth.tenancy` | `Tenant`, membership, invitations, platform staff, `current_tenant`, scoped managers, middleware |
| `infrasynth.security` | JWT cookie + API-key auth, roles/grants/revokes, 2FA (TOTP), ALTCHA, login brute-force guard, password policy |
| `infrasynth.security` | JWT cookie + API-key auth, roles/grants/revokes, permission catalog, automatic model permissions, 2FA (TOTP), ALTCHA, login brute-force guard, password policy |
| `infrasynth.audit` | Passive create/update/delete tracking, API interaction log, security events, retention purge |
| `infrasynth.features` | Operational feature flags with tenant/user/group overrides, rollout %, environment targeting |
| `infrasynth.configs` | Typed per-tenant configuration values (global default + tenant override), Fernet-encrypted secrets, `config_changed`/`config_reset` signals |
| `infrasynth.billing` | App catalog, plans, entitlements, subscriptions, invoices, gateways, entitlement lifecycle jobs |
| `infrasynth.files` | Storage abstraction (S3/GCS/local/Cloudinary), signed URLs, processing pipelines, pluggable virus scanning |
| `infrasynth.notifications` | Multi-channel delivery with failover, retries, rate limits, and log retention |
@ -73,7 +74,7 @@ class SlackChannel(BaseChannel):
def from_config(cls, config): return cls(**config)
```
Registries are populated in `apps.py:ready()`: `FeatureRegistry`, `EventRegistry`, `VariableResolverRegistry`, `DataValidatorRegistry`, `PipelineStepRegistry`.
Registries are populated in `apps.py:ready()`: `FeatureRegistry`, `PermissionRegistry`, `EventRegistry`, `VariableResolverRegistry`, `DataValidatorRegistry`, `PipelineStepRegistry`.
### Extension points, per module
@ -84,9 +85,10 @@ Every module is swappable through settings/registries — no kit edits, no forks
| `shared` | Use the primitives directly (`Result`, `encrypt/decrypt`, `get_setting`, protocols) |
| `api` | Override `renderer/pagination/exception handler` per view; add idempotency with `@idempotent` |
| `tenancy` | `TenantService`, scoped managers, `tenant_context`; configure `INFRASYNTH_TENANCY` |
| `security` | `AUTH_BACKEND_CLASS`; `TWO_FACTOR_SERVICE`/`TWO_FACTOR_RECOVERY_SERVICE`; `PasswordPolicyValidator`; custom permission classes; `infrasynth.gates` |
| `security` | `AUTH_BACKEND_CLASS`; `TWO_FACTOR_SERVICE`/`TWO_FACTOR_RECOVERY_SERVICE`; `PasswordPolicyValidator`; custom permission classes; `infrasynth.gates`; `PermissionRegistry` |
| `audit` | `EXCLUDED_MODELS`/`EXCLUDED_FIELDS`/`SENSITIVE_KEYS`/`STORE_IN_DB`; listen to `security_event_occurred` |
| `features` | `FeatureRegistry.register(...)` or `INFRASYNTH_FEATURES["FLAGS"]`; `FeatureFlagOverride` rows |
| `configs` | `ConfigRegistry.register(...)` or `INFRASYNTH_CONFIGS["DEFINITIONS"]`; listen to `config_changed`/`config_reset` |
| `billing` | Gateway via `PaymentGateway.gateway_class`; `INVOICE_PDF_BUILDER`; `Entitlement`/`plan` |
| `files` | `register_storage_backend(...)` or `STORAGE_BACKENDS[name]["CLASS"]`; `PipelineStepRegistry.register(...)`; `PIPELINE_EXECUTOR`; `VIRUS_SCANNER` |
| `notifications` | `INFRASYNTH_NOTIFICATIONS["CHANNELS"]` dotted paths; `VariableResolverRegistry` |
@ -99,6 +101,113 @@ services, registries, signals, and `infrasynth.gates` documented here.
---
## Tenant configuration
`infrasynth.configs` is a generic, typed, per-tenant configuration store for
scalar/JSON preferences (branding, limits, integration settings). Precedence is
**tenant override → global default → registry default**; reads fail closed, so
without a tenant context only the global row and the registry default are visible.
```python
# myapp/apps.py → ready(): declare the key (or set INFRASYNTH_CONFIGS["DEFINITIONS"])
from infrasynth.configs import ConfigRegistry
ConfigRegistry.register("branding.primary_color", type="string", default="#1a3a5c", group="branding")
# read / write
from infrasynth.configs import ConfigService
ConfigService().get("branding.primary_color") # tenant override, else global, else default
ConfigService().set("branding.primary_color", "#0044cc", tenant=tenant, user=request.user)
ConfigService().set_global("branding.primary_color", "#1a3a5c") # platform default
```
- **Secrets:** mark a definition `is_secret=True`; the value is Fernet-encrypted
at rest and masked (`None`) in the API, signals, and audit payloads.
- **API:** `GET /api/v1/configs/` (effective values, `?group=`/`?keys=`),
`GET/PUT/DELETE /api/v1/configs/<key>/`, `GET /api/v1/configs/definitions/`,
and `PUT /api/v1/configs/global/<key>/`. Writes require `configs.manage`
(tenant) / `configs.manage_global` (platform), with the owner bypass.
- **Cross-tenant:** the key is always resolved for the request tenant; another
tenant's value is never returned, and an unknown key returns `404`.
---
## Permissions & roles (automatic)
Every model gets permissions automatically, and assigning them to roles/users
through the API enforces them without touching code.
**Derived codenames** (`{app_label}.{verb}_{model}`, Django-style):
| Action | Codename |
|---|---|
| `list` / `retrieve` | `{app}.view_{model}` |
| `create` | `{app}.add_{model}` |
| `update` / `partial_update` | `{app}.change_{model}` |
| `destroy` | `{app}.delete_{model}` |
Subclass the kit base viewset and enforcement is automatic:
```python
# helpdesk/views.py — no required_permissions needed
from infrasynth.security.viewsets import InfraSynthModelViewSet
class TicketViewSet(InfraSynthModelViewSet):
queryset = Ticket.objects.all()
serializer_class = TicketSerializer
# requires helpdesk.view/add/change/delete_ticket
action_permissions = {"resolve": "helpdesk.resolve_ticket"} # optional, for @action
@action(detail=True, methods=["post"])
def resolve(self, request, pk=None): ...
```
- **Custom permissions** live in *your* code (never the kit):
`PermissionRegistry.register("helpdesk.export_ticket", name="Export tickets", group="Helpdesk")`
in your `apps.py:ready()`.
- **Enforcement modes:** `INFRASYNTH_SECURITY["AUTO_PERMISSIONS"]` is `"global"`
(default; model-backed views are gated everywhere, non-model views abstain),
`"opt_in"` (only kit base viewsets / `auto_permissions = True`), or `"off"`.
An explicit `required_permissions` always wins; it is **any-of** unless the
view sets `require_all = True`. Tenant owners and superusers bypass.
- **Catalog:** `manage.py sync_permissions` (also runs on `post_migrate`) upserts
every derived + custom codename into `security_permission`; `GET
/api/v1/auth/permissions/` lists it (filter with `?app_label=`/`?group=`) for
building the assignment UI. Unknown codenames are rejected on write when
`STRICT_PERMISSION_VALIDATION` is on.
- **Assignment:** tenant roles (`POST /api/v1/auth/roles/`) are per-tenant and
assigned per user with `/api/v1/auth/users/<id>/roles/`; global roles
(`tenant IS NULL`, require `platform.roles.manage`) apply in every tenant.
`Grant`/`Revoke` add per-user overrides — tenant-scoped by default, or
platform-wide with `{"scope": "global"}` (requires `platform.roles.manage`).
### Signals
Every kit signal carries `tenant_id` explicitly (a global write uses
`tenant_id=None`, `scope="global"`). Consumers connect in `apps.py:ready()`:
```python
# myapp/apps.py → ready()
from infrasynth.configs import config_changed
def on_config_changed(sender, tenant_id, key, scope, old_value, new_value, actor_id, **kwargs):
if key == "branding.primary_color":
refresh_theme_cache(tenant_id)
config_changed.connect(on_config_changed, dispatch_uid="myapp.theme")
```
| Signal | Emitted when | Key kwargs |
|---|---|---|
| `configs.config_changed` | a tenant/global value is written | `tenant_id`, `key`, `scope`, `old_value`, `new_value`, `actor_id` (secrets masked) |
| `configs.config_reset` | a tenant override is deleted | `tenant_id`, `key`, `scope`, `previous_value`, `actor_id` |
| `features.flag_created` / `flag_toggled` / `flag_deleted` | a feature flag is created/toggled/deleted | `tenant_id`, `flag_slug`, `is_active`, … |
| `features.override_created` / `override_deleted` | a user/group flag override changes | `tenant_id`, `flag_slug`, `user_id`, `is_enabled` |
| `tenancy.tenant_updated` | a tenant's editable fields change | `tenant_id`, `changes`, `actor_id` |
| `scheduler.task_completed` / `task_failed` | a task execution reaches a terminal status | `tenant_id`, `task_name`, `task_id`, … |
| `audit.model_changed` | a tracked model create/update/delete is logged | `tenant_id`, `model_label`, `object_id`, `action`, `changes` |
---
## Gating your own endpoints (no kit edits)
`infrasynth.gates` is the composable, per-endpoint access layer. A view declares
@ -138,8 +247,9 @@ class TicketViewSet(ModelViewSet):
(`AUTH_*`, `ENTITLEMENT_*`, `VALIDATION_*`, `NOT_FOUND`) so the envelope gets
the right code and status. No gates declared ⇒ the permission is a no-op.
- `GatePermission` is in `DEFAULT_PERMISSION_CLASSES`; the kit's own
`HybridPermission`/`IsAuthenticatedAndPermitted` also evaluate declared gates,
so you only add it explicitly on views that use plain DRF permissions.
`HybridPermission` (aliased `IsAuthenticatedAndPermitted`) also evaluates
declared gates, so you only add it explicitly on views that use plain DRF
permissions.
- `AltchaGate` accepts the solution in a JSON `altcha` object, flat body/query
keys, or the `X-Altcha: <challenge_id>:<solution>:<number>` header; clients get
a challenge from `/api/v1/auth/altcha/challenge/`.
@ -160,11 +270,13 @@ Consumers import from the public modules, never internal helpers:
|---|---|
| Gating | `from infrasynth.gates import GatePermission, AltchaGate, …` |
| Permissions/auth | `from infrasynth.security.permissions import HybridPermission` |
| Automatic permissions | `from infrasynth.security import InfraSynthModelViewSet, PermissionRegistry, AutoPermission` |
| JWT/API-key auth | `from infrasynth.security.auth.cookies import CookieJWTAuthentication` |
| Authorization | `from infrasynth.security.services import AuthorizationService` |
| Tenant context/scoping | `from infrasynth.tenancy.managers import TenantManager` |
| Entitlements | `from infrasynth.billing.entitlements import EntitlementService` |
| Feature flags | `from infrasynth.features.services import FeatureService` |
| Configuration | `from infrasynth.configs import ConfigService, ConfigRegistry, config_changed` |
| Storage | `from infrasynth.files.storage import get_storage_backend` |
| Errors/envelope | `from infrasynth.shared.exceptions import EntitlementError` |
| Wire format | `from infrasynth.api.renderers import EnvelopeJSONRenderer` |

View file

@ -30,6 +30,7 @@ INSTALLED_APPS = [
"infrasynth.workflows",
"infrasynth.scheduler",
"infrasynth.features",
"infrasynth.configs",
"infrasynth.billing",
]
@ -88,6 +89,7 @@ MIGRATION_MODULES = {
"infrasynth_workflows": "infrasynth.workflows.migrations",
"infrasynth_scheduler": "infrasynth.scheduler.migrations",
"infrasynth_features": "infrasynth.features.migrations",
"infrasynth_configs": "infrasynth.configs.migrations",
"infrasynth_billing": "infrasynth.billing.migrations",
}
@ -110,6 +112,7 @@ REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
"infrasynth.gates.GatePermission",
"infrasynth.security.permissions.AutoPermission",
],
"DEFAULT_RENDERER_CLASSES": [
"infrasynth.api.renderers.EnvelopeJSONRenderer",
@ -160,6 +163,7 @@ INFRASYNTH_AUDIT = {
"infrasynth_features.FeatureFlagOverride",
],
"EXCLUDED_FIELDS": ["password", "token", "secret", "credit_card"],
"EXCLUDED_MODEL_FIELDS": {"infrasynth_configs.ConfigValue": ["value"]},
"SENSITIVE_KEYS": ["password", "token", "secret", "authorization", "api_key"],
"MAX_BODY_SIZE_BYTES": 5000,
"STORE_IN_DB": True,
@ -203,6 +207,14 @@ INFRASYNTH_SECURITY = {
"PASSWORD_REQUIRE_DIGIT": True,
"PASSWORD_REQUIRE_SPECIAL_CHAR": True,
"ENABLE_WORKSPACE_SWITCHING": True,
# Authorization: auto-derive model permissions. "global" adds AutoPermission
# to the default permission classes (abstains on non-model views); "opt_in"
# only enforces on views that set auto_permissions=True (the kit bases);
# "off" disables it.
"AUTO_PERMISSIONS": "global",
"STRICT_PERMISSION_VALIDATION": False,
"PERMISSION_EXCLUDE_MODELS": [],
"PERMISSION_APPS": None,
}
AUTH_PASSWORD_VALIDATORS = [
@ -321,6 +333,15 @@ INFRASYNTH_FEATURES = {
"EXPOSE_ROLES_IN_ACTIVE_ENDPOINT": True,
}
INFRASYNTH_CONFIGS = {
"CACHE_BACKEND": "default",
"CACHE_KEY_PREFIX": "configs",
"CACHE_TTL_SECONDS": 60,
"AUTO_REGISTER_FROM_SETTINGS": True,
"DEFINITIONS": {}, # {"branding.primary_color": {"type": "string", "default": "#1a3a5c"}}
"ALLOW_GLOBAL_WRITES": True, # set False in a tenant-only deployment
}
# Deployment environment used by feature-flag ``environments`` targeting.
ENVIRONMENT = "development"

View file

@ -40,6 +40,7 @@ REST_FRAMEWORK = {
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
"infrasynth.security.permissions.AutoPermission",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 25,

View file

@ -13,5 +13,6 @@ urlpatterns = [
path("api/v1/workflows/", include("infrasynth.workflows.urls")),
path("api/v1/scheduler/", include("infrasynth.scheduler.urls")),
path("api/v1/features/", include("infrasynth.features.urls")),
path("api/v1/configs/", include("infrasynth.configs.urls")),
path("api/v1/billing/", include("infrasynth.billing.urls")),
]

View file

@ -5,7 +5,7 @@ from django.db.models.signals import post_delete, post_save, pre_save
from django.dispatch import receiver
from .models import ModelChangeLog, SecurityEvent
from .signals import security_event_occurred
from .signals import model_changed, security_event_occurred
def _current_tenant():
@ -24,6 +24,36 @@ def _get_excluded_fields():
return set(config.get("EXCLUDED_FIELDS", []))
def _get_excluded_model_fields():
config = getattr(settings, "INFRASYNTH_AUDIT", {})
return config.get("EXCLUDED_MODEL_FIELDS", {}) or {}
def _excluded_fields_for(label: str) -> set[str]:
"""Global excluded fields plus the per-model exclusions for ``label``.
Per-model exclusions let a model keep its audit trail while withholding a
specific field — e.g. ``infrasynth_configs.ConfigValue`` is audited, but its
(possibly encrypted) ``value`` payload never enters the log.
"""
fields = set(_get_excluded_fields())
fields.update(_get_excluded_model_fields().get(label, []) or [])
return fields
def _emit_model_changed(sender, log: ModelChangeLog) -> None:
model_changed.send(
sender=sender,
tenant_id=str(log.tenant_id) if log.tenant_id else None,
model_label=log.model_label,
object_id=log.object_id,
action=log.action,
changes=log.changes,
actor=log.actor,
request_id=log.request_id,
)
def _get_request_id(request=None):
if request:
return getattr(request, "request_id", "") or str(uuid.uuid4())[:8]
@ -72,7 +102,7 @@ def track_model_change(sender, instance, created, raw, **kwargs):
return
if created:
ModelChangeLog.objects.create(
log = ModelChangeLog.objects.create(
tenant=_current_tenant(),
model_label=label,
object_id=str(instance.pk),
@ -81,11 +111,12 @@ def track_model_change(sender, instance, created, raw, **kwargs):
actor=_get_actor_from_instance(instance),
request_id=_get_request_id(),
)
_emit_model_changed(sender, log)
else:
if hasattr(instance, "_previous_state"):
changes = _compute_changes(instance._previous_state, instance)
if changes:
ModelChangeLog.objects.create(
log = ModelChangeLog.objects.create(
tenant=_current_tenant(),
model_label=label,
object_id=str(instance.pk),
@ -94,6 +125,7 @@ def track_model_change(sender, instance, created, raw, **kwargs):
actor=_get_actor_from_instance(instance),
request_id=_get_request_id(),
)
_emit_model_changed(sender, log)
@receiver(post_delete)
@ -107,7 +139,7 @@ def track_model_delete(sender, instance, **kwargs):
if not _store_enabled():
return
ModelChangeLog.objects.create(
log = ModelChangeLog.objects.create(
tenant=_current_tenant(),
model_label=label,
object_id=str(instance.pk),
@ -116,6 +148,7 @@ def track_model_delete(sender, instance, **kwargs):
actor=_get_actor_from_instance(instance),
request_id=_get_request_id(),
)
_emit_model_changed(sender, log)
def _get_actor_from_instance(instance):
@ -127,7 +160,7 @@ def _get_actor_from_instance(instance):
def _get_created_changes(instance):
excluded = _get_excluded_fields()
excluded = _excluded_fields_for(instance._meta.label)
changes = {}
for field in instance._meta.get_fields():
if field.name in excluded:
@ -140,7 +173,7 @@ def _get_created_changes(instance):
def _compute_changes(old, new):
excluded = _get_excluded_fields()
excluded = _excluded_fields_for(new._meta.label)
changes = {}
for field in new._meta.get_fields():
if field.name in excluded:

View file

@ -1,11 +1,12 @@
from django.http import Http404
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from infrasynth.api.idempotency import idempotent
from infrasynth.features.services import FeatureService
from infrasynth.security.permissions import IsAuthenticatedAndPermitted
from infrasynth.shared.exceptions import NotFoundError, ValidationAppError
from infrasynth.tenancy.context import get_current_tenant
@ -50,7 +51,7 @@ class _BillingFeatureMixin:
class PaymentGatewayViewSet(_BillingFeatureMixin, viewsets.ModelViewSet):
queryset = PaymentGateway.objects.all()
serializer_class = PaymentGatewaySerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = PaymentGatewayFilter
def get_queryset(self):
@ -80,7 +81,7 @@ class PlanViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Gen
class EntitlementViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
serializer_class = EntitlementSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = EntitlementFilter
def get_queryset(self):
@ -97,7 +98,7 @@ class EntitlementViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, views
class SubscriptionViewSet(_BillingFeatureMixin, viewsets.ModelViewSet):
serializer_class = SubscriptionSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = SubscriptionFilter
def get_queryset(self):
@ -182,7 +183,7 @@ class SubscriptionViewSet(_BillingFeatureMixin, viewsets.ModelViewSet):
class InvoiceViewSet(_BillingFeatureMixin, viewsets.ModelViewSet):
serializer_class = InvoiceSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = InvoiceFilter
def get_queryset(self):
@ -191,7 +192,7 @@ class InvoiceViewSet(_BillingFeatureMixin, viewsets.ModelViewSet):
class PaymentTransactionViewSet(_BillingFeatureMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = PaymentTransactionSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = PaymentTransactionFilter
def get_queryset(self):

View file

@ -0,0 +1,49 @@
"""Public surface of ``infrasynth.configs``.
The exports are resolved lazily (PEP 562) so importing this package never
triggers Django model imports before the app registry is ready. Import from
here::
from infrasynth.configs import ConfigService, ConfigRegistry, config_changed
"""
from __future__ import annotations
from importlib import import_module
from typing import Any
__all__ = [
"ConfigDefinition",
"ConfigRegistry",
"ConfigService",
"ConfigType",
"ConfigValue",
"config_changed",
"config_reset",
]
# public name -> module (relative to the ``infrasynth`` package) that defines it
_EXPORTS: dict[str, str] = {
"ConfigDefinition": "configs.registry",
"ConfigRegistry": "configs.registry",
"ConfigService": "configs.services",
"ConfigType": "configs.registry",
"ConfigValue": "configs.models",
"config_changed": "configs.signals",
"config_reset": "configs.signals",
}
def __getattr__(name: str) -> Any:
module_path = _EXPORTS.get(name)
if module_path is not None:
return getattr(import_module(f"infrasynth.{module_path}"), name)
# Let ``infrasynth.<app>.<submodule>`` resolve as usual.
try:
return import_module(f"{__name__}.{name}")
except ModuleNotFoundError as exc:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
def __dir__() -> list[str]:
return sorted(set(__all__) | set(globals()))

View file

@ -0,0 +1,11 @@
from django.contrib import admin
from .models import ConfigValue
@admin.register(ConfigValue)
class ConfigValueAdmin(admin.ModelAdmin):
list_display = ("key", "tenant", "updated_by", "updated_at")
list_filter = ("tenant",)
search_fields = ("key",)
readonly_fields = ("updated_at",)

View file

@ -0,0 +1,28 @@
from django.apps import AppConfig
class ConfigsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "infrasynth.configs"
label = "infrasynth_configs"
def ready(self):
from infrasynth.features.registry import FeatureRegistry
from infrasynth.shared.settings_utils import get_setting
from .registry import ConfigRegistry, ConfigType
FeatureRegistry.register(
"configs",
name="Tenant Configuration",
description="Typed per-tenant configuration values",
default=True,
category="system",
)
if get_setting("INFRASYNTH_CONFIGS", "AUTO_REGISTER_FROM_SETTINGS", True):
for key, spec in (get_setting("INFRASYNTH_CONFIGS", "DEFINITIONS", {}) or {}).items():
spec = dict(spec or {})
if isinstance(spec.get("type"), str):
spec["type"] = ConfigType(spec["type"])
ConfigRegistry.register(key, **spec)

View file

@ -0,0 +1,56 @@
# Generated by Django 5.2.17 on 2026-09-24 17:48
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("tenancy", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ConfigValue",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("key", models.CharField(db_index=True, max_length=200)),
("value", models.JSONField(default=dict)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"tenant",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="tenancy.tenant",
),
),
(
"updated_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"db_table": "configs_value",
"indexes": [models.Index(fields=["tenant_id", "key"], name="configs_val_tenant__f38493_idx")],
"constraints": [
models.UniqueConstraint(fields=("tenant", "key"), name="uniq_config_key_per_tenant"),
models.UniqueConstraint(
condition=models.Q(("tenant__isnull", True)), fields=("key",), name="uniq_global_config_key"
),
],
},
),
]

View file

@ -0,0 +1,49 @@
"""Configuration storage model.
A single table stores both the platform default (``tenant IS NULL``) and a
tenant's override (non-null ``tenant``) for the same key — the same shape as
``features.FeatureFlag`` (``PLAN.md`` §2.0). Secret values are stored as a Fernet
token (a JSON string); encryption/decryption is the service's concern and the
model stays dumb.
"""
from __future__ import annotations
from django.conf import settings
from django.db import models
from django.db.models import Q
from infrasynth.tenancy.mixins import GlobalOrTenantModel
__all__ = ["ConfigValue"]
class ConfigValue(GlobalOrTenantModel):
"""A configuration value. ``tenant IS NULL`` = platform default, non-null = tenant override."""
key = models.CharField(max_length=200, db_index=True)
value = models.JSONField(default=dict)
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="+",
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "configs_value"
constraints = [
models.UniqueConstraint(fields=["tenant", "key"], name="uniq_config_key_per_tenant"),
models.UniqueConstraint(
fields=["key"],
condition=Q(tenant__isnull=True),
name="uniq_global_config_key",
),
]
indexes = [models.Index(fields=["tenant_id", "key"])]
def __str__(self) -> str:
scope = self.tenant_id if self.tenant_id is not None else "global"
return f"{self.key}@{scope}"

View file

@ -0,0 +1,221 @@
"""Typed configuration definitions and coercion for ``infrasynth.configs``.
A :class:`ConfigDefinition` is the schema for a single configuration key (its
type, default, grouping, and whether it is a secret). Definitions are registered
by the kit and by consuming apps — either imperatively via
:meth:`ConfigRegistry.register` in ``apps.py:ready()`` or declaratively through
``INFRASYNTH_CONFIGS["DEFINITIONS"]``.
All coercion failures raise :class:`~infrasynth.shared.exceptions.ValidationAppError`
with code ``VALIDATION_CONFIG_INVALID`` so the API envelope stays consistent and
callers never have to catch a bare ``ValueError``.
"""
from __future__ import annotations
import decimal
import json
import re
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
from django.utils.module_loading import import_string
from infrasynth.shared.exceptions import ValidationAppError
__all__ = ["ConfigType", "ConfigDefinition", "ConfigRegistry"]
INVALID_CODE = "VALIDATION_CONFIG_INVALID"
_DURATION_RE = re.compile(r"^\s*(?P<amount>\d+(?:\.\d+)?)\s*(?P<unit>[a-zA-Z]*)\s*$")
_DURATION_UNITS = {
"": 1,
"s": 1,
"sec": 1,
"secs": 1,
"m": 60,
"min": 60,
"mins": 60,
"h": 3600,
"hr": 3600,
"hrs": 3600,
"d": 86400,
"day": 86400,
"days": 86400,
}
def _invalid(key: str, issue: str) -> ValidationAppError:
return ValidationAppError(
f"Invalid value for configuration key '{key}'.",
code=INVALID_CODE,
details=[{"field": key, "issue": issue}],
)
class ConfigType(StrEnum):
"""The storage/validation type of a configuration value."""
STRING = "string"
INT = "int"
FLOAT = "float"
BOOL = "bool"
DECIMAL = "decimal"
JSON = "json"
CHOICE = "choice"
DURATION = "duration" # stored as int seconds
@dataclass(frozen=True)
class ConfigDefinition:
"""Immutable schema for one configuration key."""
key: str
type: ConfigType = ConfigType.JSON
default: Any = None
choices: tuple[Any, ...] = ()
is_secret: bool = False
label: str = ""
group: str = ""
description: str = ""
validator: str | None = None # dotted path; callable(value) -> None | raises
class ConfigRegistry:
"""Registry of typed configuration definitions.
The kit's built-ins live in settings; consuming apps register their own in
``apps.py:ready()`` (mirroring ``FeatureRegistry``). Mutation is global by
design — there is one process-wide schema per deployment.
"""
_definitions: dict[str, ConfigDefinition] = {}
@classmethod
def register(
cls,
key: str,
*,
type: ConfigType | str = ConfigType.JSON,
default: Any = None,
choices: tuple[Any, ...] | list[Any] = (),
is_secret: bool = False,
label: str = "",
group: str = "",
description: str = "",
validator: str | None = None,
) -> ConfigDefinition:
definition = ConfigDefinition(
key=key,
type=ConfigType(type),
default=default,
choices=tuple(choices),
is_secret=is_secret,
label=label or key,
group=group,
description=description,
validator=validator,
)
cls._definitions[key] = definition
return definition
@classmethod
def get(cls, key: str) -> ConfigDefinition | None:
return cls._definitions.get(key)
@classmethod
def all(cls) -> dict[str, ConfigDefinition]:
return dict(cls._definitions)
@classmethod
def clear(cls) -> None:
"""Clears the registry. Tests only; never call from application code."""
cls._definitions.clear()
# --- coercion -----------------------------------------------------------
@classmethod
def coerce(cls, definition: ConfigDefinition, value: Any) -> Any:
"""Coerces/validates ``value`` against ``definition``.
Raises :class:`ValidationAppError` (``VALIDATION_CONFIG_INVALID``) on any
failure, including a failing custom ``validator``.
"""
try:
coerced = cls._coerce_value(definition, value)
if definition.validator:
import_string(definition.validator)(coerced)
return coerced
except ValidationAppError:
raise
except Exception as exc: # noqa: BLE001 - normalised to a typed error
raise _invalid(definition.key, str(exc)) from exc
@classmethod
def _coerce_value(cls, definition: ConfigDefinition, value: Any) -> Any:
config_type = definition.type
if config_type == ConfigType.STRING:
if not isinstance(value, str):
raise ValueError("expected a string")
return value
if config_type == ConfigType.INT:
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
raise ValueError("expected an integer")
if isinstance(value, float) and not value.is_integer():
raise ValueError("expected a whole number")
return int(value)
if config_type == ConfigType.FLOAT:
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
raise ValueError("expected a number")
return float(value)
if config_type == ConfigType.DECIMAL:
if isinstance(value, bool):
raise ValueError("expected a decimal number")
if isinstance(value, float):
value = repr(value)
return decimal.Decimal(str(value))
if config_type == ConfigType.BOOL:
if not isinstance(value, bool):
raise ValueError("expected a boolean")
return value
if config_type == ConfigType.CHOICE:
if value not in definition.choices:
raise ValueError(f"expected one of {list(definition.choices)!r}")
return value
if config_type == ConfigType.DURATION:
return cls._coerce_duration(value)
# ConfigType.JSON (and any future pass-through type).
try:
json.dumps(value)
except (TypeError, ValueError) as exc:
raise ValueError("expected a JSON-serializable value") from exc
return value
@staticmethod
def _coerce_duration(value: Any) -> int:
if isinstance(value, bool):
raise ValueError("expected a duration in seconds or a string like '30s'")
if isinstance(value, int):
return value
if isinstance(value, float):
if not value.is_integer():
raise ValueError("duration seconds must be a whole number")
return int(value)
if not isinstance(value, str):
raise ValueError("expected an integer number of seconds or a string like '30s'")
match = _DURATION_RE.match(value)
if match is None:
raise ValueError("expected an integer number of seconds or a string like '30s'")
unit = match.group("unit").lower()
if unit not in _DURATION_UNITS:
raise ValueError(f"unknown duration unit '{unit}'")
return int(float(match.group("amount")) * _DURATION_UNITS[unit])

View file

@ -0,0 +1,28 @@
"""Serializers for the ``configs`` API.
Reads expose ``key``, the (possibly masked) ``value``, and the definition
metadata the UI needs to render a form. Writes carry only ``value``; coercion and
validation happen in :class:`~infrasynth.configs.services.ConfigService` so there
is a single source of truth.
"""
from __future__ import annotations
from rest_framework import serializers
__all__ = ["ConfigValueSerializer", "ConfigWriteSerializer"]
class ConfigValueSerializer(serializers.Serializer):
key = serializers.CharField()
value = serializers.JSONField(allow_null=True) # type: ignore[assignment]
type = serializers.CharField(allow_null=True)
group = serializers.CharField(allow_blank=True)
label = serializers.CharField(allow_blank=True) # type: ignore[assignment]
is_secret = serializers.BooleanField()
is_overridden = serializers.BooleanField()
updated_at = serializers.DateTimeField(allow_null=True)
class ConfigWriteSerializer(serializers.Serializer):
value = serializers.JSONField(required=True, allow_null=True) # type: ignore[assignment]

View file

@ -0,0 +1,410 @@
"""``ConfigService`` — typed, cached, tenant-scoped configuration access.
Precedence: **tenant row → global row → registry default**. Reads fail closed:
with no tenant context only the global row and the registry default are visible,
never an arbitrary tenant's row. Writes are audited and emit the public
``config_changed``/``config_reset`` signals with secrets masked.
"""
from __future__ import annotations
import builtins
import decimal
import json
from typing import Any
from django.core.cache import caches
from django.db import transaction
from django.db.models import Q
from infrasynth.shared.crypto import decrypt, encrypt
from infrasynth.shared.exceptions import AuthError, NotFoundError, ServerError, ValidationAppError
from infrasynth.shared.settings_utils import get_setting
from infrasynth.tenancy.context import get_current_tenant
from .models import ConfigValue
from .registry import INVALID_CODE, ConfigDefinition, ConfigRegistry
from .signals import config_changed, config_reset
__all__ = ["ConfigService"]
_UNSET: Any = object()
_CACHE_MISS: Any = object()
_DECRYPT_FAILED = "SERVER_CONFIG_DECRYPT_FAILED"
_GLOBAL_WRITES_DISABLED = "AUTH_CONFIG_GLOBAL_WRITES_DISABLED"
_TENANT_REQUIRED = "VALIDATION_TENANT_REQUIRED"
def _unknown_key(key: str) -> NotFoundError:
return NotFoundError(
f"Unknown configuration key '{key}'.",
code="NOT_FOUND",
details=[{"field": "key", "issue": key}],
)
class ConfigService:
"""Reads and writes typed configuration values for a tenant."""
# --- reads --------------------------------------------------------------
def get(self, key: str, *, tenant: Any = None, default: Any = _UNSET) -> Any:
definition = ConfigRegistry.get(key)
resolved_tenant = self._read_tenant(tenant)
if resolved_tenant is not None:
cached = self._cache_get(self._tenant_cache_key(resolved_tenant, key))
if cached is not _CACHE_MISS:
return cached
row = self._find_value(tenant=resolved_tenant, key=key)
if row is not None:
value = self._decode(definition, row.value)
self._cache_set(self._tenant_cache_key(resolved_tenant, key), value)
return value
cached = self._cache_get(self._global_cache_key(key))
if cached is not _CACHE_MISS:
return cached
global_row = self._find_value(tenant=None, key=key)
if global_row is not None:
value = self._decode(definition, global_row.value)
self._cache_set(self._global_cache_key(key), value)
return value
if definition is not None:
return definition.default
if default is not _UNSET:
return default
raise _unknown_key(key)
def get_many(self, keys: list[str], *, tenant: Any = None) -> dict[str, Any]:
"""Resolves the asked keys, skipping unknown/unregistered ones."""
resolved = self._read_tenant(tenant)
result: dict[str, Any] = {}
for key in keys:
try:
result[key] = self.get(key, tenant=resolved)
except NotFoundError:
continue
return result
def get_all(self, *, tenant: Any = None, group: str | None = None) -> dict[str, Any]:
"""Every registered key plus any stored key, resolved for ``tenant``."""
resolved = self._read_tenant(tenant)
keys = set(ConfigRegistry.all().keys())
keys.update(self._visible_rows(resolved).values_list("key", flat=True))
result: dict[str, Any] = {}
for key in sorted(keys):
definition = ConfigRegistry.get(key)
if group is not None and (definition is None or definition.group != group):
continue
result[key] = self.get(key, tenant=resolved)
return result
def get_metadata(self, key: str, *, tenant: Any = None) -> dict[str, Any]:
"""Schema + state for ``key`` (for API/UI forms). Raises for unknown keys."""
resolved = self._read_tenant(tenant)
definition = ConfigRegistry.get(key)
tenant_row = self._find_value(tenant=resolved, key=key) if resolved is not None else None
global_row = self._find_value(tenant=None, key=key)
if definition is None and tenant_row is None and global_row is None:
raise _unknown_key(key)
row = tenant_row or global_row
if definition is None:
return {
"key": key,
"type": None,
"default": None,
"choices": [],
"is_secret": False,
"label": key,
"group": "",
"description": "",
"is_overridden": tenant_row is not None,
"updated_at": row.updated_at if row is not None else None,
}
return {
"key": key,
"type": definition.type.value,
"default": None if definition.is_secret else definition.default,
"choices": list(definition.choices),
"is_secret": definition.is_secret,
"label": definition.label or key,
"group": definition.group,
"description": definition.description,
"is_overridden": tenant_row is not None,
"updated_at": row.updated_at if row is not None else None,
}
def is_overridden(self, key: str, *, tenant: Any = None) -> bool:
resolved = self._read_tenant(tenant)
if resolved is None:
return False
return ConfigValue.all_objects.filter(tenant_id=resolved.pk, key=key).exists()
# --- writes -------------------------------------------------------------
def set(self, key: str, value: Any, *, tenant: Any, user: Any = None) -> ConfigValue:
"""Sets the tenant override for ``key`` (creating or updating it)."""
resolved = self._require_tenant(tenant)
definition = self._known_definition(key, tenant=resolved)
coerced = self._coerce(definition, key, value)
stored = self._encode(definition, coerced)
old_value = self._effective_value(resolved, key, definition)
with transaction.atomic():
obj, _ = ConfigValue.all_objects.update_or_create(
tenant=resolved,
key=key,
defaults={"value": stored, "updated_by": user},
)
self.invalidate(resolved, key)
self._emit_changed(
tenant_id=str(resolved.pk),
scope="tenant",
key=key,
definition=definition,
old_value=old_value,
new_value=coerced,
actor=user,
)
return obj
def set_global(self, key: str, value: Any, *, user: Any = None) -> ConfigValue:
"""Sets the platform default for ``key`` (``tenant IS NULL``)."""
if not get_setting("INFRASYNTH_CONFIGS", "ALLOW_GLOBAL_WRITES", True):
raise AuthError(
"Global configuration writes are disabled for this deployment.",
code=_GLOBAL_WRITES_DISABLED,
status=403,
)
definition = self._known_definition(key, tenant=None)
coerced = self._coerce(definition, key, value)
stored = self._encode(definition, coerced)
old_value = self._effective_value(None, key, definition)
with transaction.atomic():
obj, _ = ConfigValue.all_objects.update_or_create(
tenant=None,
key=key,
defaults={"value": stored, "updated_by": user},
)
self.invalidate(None, key)
self._emit_changed(
tenant_id=None,
scope="global",
key=key,
definition=definition,
old_value=old_value,
new_value=coerced,
actor=user,
)
return obj
def reset(self, key: str, *, tenant: Any, user: Any = None) -> bool:
"""Deletes the tenant override so reads fall back to global/default."""
resolved = self._require_tenant(tenant)
definition = ConfigRegistry.get(key)
previous = self._effective_value(resolved, key, definition)
deleted, _ = ConfigValue.all_objects.filter(tenant_id=resolved.pk, key=key).delete()
self.invalidate(resolved, key)
if deleted:
config_reset.send(
sender=ConfigValue,
tenant_id=str(resolved.pk),
key=key,
scope="tenant",
previous_value=self._mask(definition, previous),
actor_id=getattr(user, "pk", None),
)
return bool(deleted)
def invalidate(self, tenant: Any, key: str | None = None) -> None:
"""Busts cached values. ``key=None`` clears every known key for the scope."""
cache, _ = self._cache()
if key is not None:
cache_key = self._global_cache_key(key) if tenant is None else self._tenant_cache_key(tenant, key)
cache.delete(cache_key)
return
for known in self._known_keys(tenant):
if tenant is None:
cache.delete(self._global_cache_key(known))
else:
cache.delete(self._tenant_cache_key(tenant, known))
# --- internals ----------------------------------------------------------
def _emit_changed(
self,
*,
tenant_id: str | None,
scope: str,
key: str,
definition: ConfigDefinition | None,
old_value: Any,
new_value: Any,
actor: Any,
) -> None:
config_changed.send(
sender=ConfigValue,
tenant_id=tenant_id,
key=key,
scope=scope,
old_value=self._mask(definition, old_value),
new_value=self._mask(definition, new_value),
actor_id=getattr(actor, "pk", None),
)
@staticmethod
def _mask(definition: ConfigDefinition | None, value: Any) -> Any:
if definition is not None and definition.is_secret:
return None
return value
def _coerce(self, definition: ConfigDefinition | None, key: str, value: Any) -> Any:
if definition is not None:
return ConfigRegistry.coerce(definition, value)
try:
json.dumps(value)
except (TypeError, ValueError) as exc:
raise ValidationAppError(
f"Invalid value for configuration key '{key}'.",
code=INVALID_CODE,
details=[{"field": key, "issue": str(exc)}],
) from exc
return value
def _known_definition(self, key: str, *, tenant: Any) -> ConfigDefinition | None:
"""A definition, or ``None`` when the key is stored but unregistered."""
definition = ConfigRegistry.get(key)
if definition is not None:
return definition
if self._find_value(tenant=tenant, key=key) is not None or self._find_value(tenant=None, key=key) is not None:
return None
raise _unknown_key(key)
@staticmethod
def _encode(definition: ConfigDefinition | None, value: Any) -> Any:
payload = ConfigService._jsonable(value)
if definition is not None and definition.is_secret:
return encrypt(json.dumps(payload))
return payload
@staticmethod
def _decode(definition: ConfigDefinition | None, raw: Any) -> Any:
if definition is not None and definition.is_secret:
if not isinstance(raw, str):
raise ServerError(
"Stored configuration secret is not a ciphertext token.",
code=_DECRYPT_FAILED,
)
try:
value: Any = json.loads(decrypt(raw))
except Exception as exc: # noqa: BLE001 - never leak ciphertext
raise ServerError(
"Unable to decrypt the configuration secret.",
code=_DECRYPT_FAILED,
) from exc
else:
value = raw
return ConfigService._restore_type(definition, value)
@staticmethod
def _restore_type(definition: ConfigDefinition | None, value: Any) -> Any:
if definition is None or value is None:
return value
if definition.type.value == "decimal":
return decimal.Decimal(str(value))
if definition.type.value == "duration":
return int(value)
return value
@staticmethod
def _jsonable(value: Any) -> Any:
if isinstance(value, decimal.Decimal):
return str(value)
if isinstance(value, dict):
return {str(k): ConfigService._jsonable(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [ConfigService._jsonable(v) for v in value]
return value
def _effective_value(self, tenant: Any, key: str, definition: ConfigDefinition | None) -> Any:
row = None
if tenant is not None:
row = self._find_value(tenant=tenant, key=key)
if row is None:
row = self._find_value(tenant=None, key=key)
if row is not None:
return self._decode(definition, row.value)
return definition.default if definition is not None else None
@staticmethod
def _find_value(*, tenant: Any, key: str) -> ConfigValue | None:
qs = ConfigValue.all_objects.filter(key=key)
if tenant is None:
return qs.filter(tenant__isnull=True).first()
return qs.filter(tenant_id=tenant.pk).first()
@staticmethod
def _visible_rows(tenant: Any):
qs = ConfigValue.all_objects.all()
if tenant is None:
return qs.filter(tenant__isnull=True)
return qs.filter(Q(tenant_id=tenant.pk) | Q(tenant__isnull=True))
def _known_keys(self, tenant: Any) -> builtins.set[str]:
keys = set(ConfigRegistry.all().keys())
keys.update(self._visible_rows(tenant).values_list("key", flat=True))
return keys
@staticmethod
def _read_tenant(tenant: Any) -> Any:
if tenant is not None:
return tenant
return get_current_tenant()
@staticmethod
def _require_tenant(tenant: Any) -> Any:
resolved = tenant if tenant is not None else get_current_tenant()
if resolved is None:
raise ValidationAppError(
"A tenant context is required to write configuration.",
code=_TENANT_REQUIRED,
)
return resolved
# --- cache --------------------------------------------------------------
@staticmethod
def _cache():
from django.conf import settings
config = getattr(settings, "INFRASYNTH_CONFIGS", {})
alias = config.get("CACHE_BACKEND", "default") or "default"
return caches[alias], config.get("CACHE_KEY_PREFIX", "configs")
@staticmethod
def _ttl() -> int:
return int(get_setting("INFRASYNTH_CONFIGS", "CACHE_TTL_SECONDS", 60))
@staticmethod
def _tenant_cache_key(tenant: Any, key: str) -> str:
_, prefix = ConfigService._cache()
return f"{prefix}:tenant:{tenant.pk}:configs:{key}"
@staticmethod
def _global_cache_key(key: str) -> str:
_, prefix = ConfigService._cache()
return f"{prefix}:tenant:global:configs:{key}"
def _cache_get(self, cache_key: str) -> Any:
cache, _ = self._cache()
return cache.get(cache_key, _CACHE_MISS)
def _cache_set(self, cache_key: str, value: Any) -> None:
cache, _ = self._cache()
cache.set(cache_key, value, self._ttl())

View file

@ -0,0 +1,23 @@
"""Public signals of ``infrasynth.configs``.
Both signals are a documented extension point: a consuming app connects them in
``apps.py:ready()`` to react to configuration changes (for example, to refresh a
third-party client). They are emitted from :class:`ConfigService` itself, so
correctness never depends on a receiver being connected, and every signal carries
``tenant_id`` explicitly (``TENANCY.md`` §7).
Secret values are **always masked**: ``old_value``/``new_value`` are ``None``
when the definition is secret, so no plaintext or ciphertext ever appears in a
signal payload.
"""
from django.dispatch import Signal
__all__ = ["config_changed", "config_reset"]
# kwargs: tenant_id (str|None), key, scope ("tenant"|"global"),
# old_value, new_value, actor_id
config_changed = Signal()
# kwargs: tenant_id, key, scope, previous_value, actor_id
config_reset = Signal()

View file

@ -0,0 +1,12 @@
from django.urls import path
from .views import ConfigDefinitionsView, ConfigDetailView, ConfigGlobalDetailView, ConfigListView
app_name = "configs"
urlpatterns = [
path("", ConfigListView.as_view(), name="list"),
path("definitions/", ConfigDefinitionsView.as_view(), name="definitions"),
path("global/<str:key>/", ConfigGlobalDetailView.as_view(), name="global-detail"),
path("<str:key>/", ConfigDetailView.as_view(), name="detail"),
]

148
infrasynth/configs/views.py Normal file
View file

@ -0,0 +1,148 @@
"""API endpoints for tenant configuration (``/api/v1/configs/``).
Values are resolved for the bound tenant; secrets are never returned (``value``
is ``None``). Writes require ``configs.manage`` (tenant override) or
``configs.manage_global`` (platform default), with the standard owner/superuser
bypass. A cross-tenant key can never be observed because every read goes through
:class:`ConfigService` with the request tenant.
"""
from __future__ import annotations
from typing import Any
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from infrasynth.security.permissions import IsAuthenticatedAndPermitted
from infrasynth.shared.exceptions import AuthError
from infrasynth.tenancy.context import get_current_tenant
from .registry import ConfigRegistry
from .serializers import ConfigValueSerializer, ConfigWriteSerializer
from .services import ConfigService
__all__ = ["ConfigListView", "ConfigDetailView", "ConfigDefinitionsView", "ConfigGlobalDetailView"]
def _require_tenant():
tenant = get_current_tenant()
if tenant is None:
raise AuthError(
"A workspace context is required for this endpoint.",
code="AUTH_TENANT_REQUIRED",
status=403,
)
return tenant
def _entry(service: ConfigService, key: str, tenant: Any, value: Any) -> dict[str, Any]:
meta = service.get_metadata(key, tenant=tenant)
return ConfigValueSerializer(
{
"key": key,
"value": None if meta["is_secret"] else value,
"type": meta["type"],
"group": meta["group"],
"label": meta["label"],
"is_secret": meta["is_secret"],
"is_overridden": meta["is_overridden"],
"updated_at": meta["updated_at"],
}
).data
class ConfigListView(APIView):
"""Effective values for the bound tenant, optionally filtered by group/keys."""
permission_classes = [IsAuthenticated]
def get(self, request):
tenant = _require_tenant()
service = ConfigService()
group = request.query_params.get("group") or None
keys_param = request.query_params.get("keys")
if keys_param:
keys = [key.strip() for key in keys_param.split(",") if key.strip()]
resolved = service.get_many(keys, tenant=tenant)
data = [_entry(service, key, tenant, resolved[key]) for key in keys if key in resolved]
else:
effective = service.get_all(tenant=tenant, group=group)
data = [_entry(service, key, tenant, value) for key, value in effective.items()]
return Response({"values": data})
class ConfigDetailView(APIView):
"""Read/set/reset a single tenant configuration value."""
permission_classes = [IsAuthenticatedAndPermitted]
def get_permissions(self):
if self.request.method in ("PUT", "DELETE"):
self.required_permissions = ["configs.manage"]
else:
self.required_permissions = []
return [permission() for permission in self.permission_classes]
def get(self, request, key):
tenant = _require_tenant()
service = ConfigService()
meta = service.get_metadata(key, tenant=tenant)
value = service.get(key, tenant=tenant)
payload = dict(meta)
payload["value"] = None if meta["is_secret"] else value
return Response(payload)
def put(self, request, key):
tenant = _require_tenant()
serializer = ConfigWriteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
service = ConfigService()
service.set(key, serializer.validated_data["value"], tenant=tenant, user=request.user)
return Response(_entry(service, key, tenant, service.get(key, tenant=tenant)))
def delete(self, request, key):
tenant = _require_tenant()
ConfigService().reset(key, tenant=tenant, user=request.user)
return Response(status=status.HTTP_204_NO_CONTENT)
class ConfigDefinitionsView(APIView):
"""Registered key schema, for building configuration forms."""
permission_classes = [IsAuthenticated]
def get(self, request):
definitions = []
for key, definition in sorted(ConfigRegistry.all().items()):
definitions.append(
{
"key": key,
"type": definition.type.value,
"default": None if definition.is_secret else definition.default,
"choices": list(definition.choices),
"is_secret": definition.is_secret,
"label": definition.label or key,
"group": definition.group,
"description": definition.description,
}
)
return Response({"definitions": definitions})
class ConfigGlobalDetailView(APIView):
"""Set the platform default for a key (``tenant IS NULL``)."""
permission_classes = [IsAuthenticatedAndPermitted]
required_permissions = ["configs.manage_global"]
def put(self, request, key):
tenant = _require_tenant()
serializer = ConfigWriteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
service = ConfigService()
service.set_global(key, serializer.validated_data["value"], user=request.user)
return Response(_entry(service, key, tenant, service.get(key, tenant=tenant)))

View file

@ -134,3 +134,13 @@ class FeatureService:
config = getattr(settings, "INFRASYNTH_FEATURES", {})
alias = config.get("CACHE_BACKEND", "default") or "default"
return caches[alias], config.get("CACHE_KEY_PREFIX", "features")
def invalidate(self, slug: str, tenant_id=None) -> None:
"""Busts the cached tenant and global rows for ``slug``.
Called from flag/override mutations so a moved flag is visible
immediately instead of after ``CACHE_TTL_SECONDS``.
"""
cache, prefix = self._cache()
cache.delete(f"{prefix}:tenant:{tenant_id}:features:{slug}")
cache.delete(f"{prefix}:tenant:global:features:{slug}")

View file

@ -4,8 +4,10 @@ from rest_framework.routers import DefaultRouter
from .views import FeatureFlagOverrideViewSet, FeatureFlagViewSet
router = DefaultRouter()
router.register(r"", FeatureFlagViewSet, basename="feature-flags")
# Register the "overrides" collection before the empty-prefix viewset; otherwise
# the flags detail route ``^(?P<pk>[^/.]+)/$`` shadows ``overrides/``.
router.register(r"overrides", FeatureFlagOverrideViewSet, basename="feature-overrides")
router.register(r"", FeatureFlagViewSet, basename="feature-flags")
urlpatterns = [
path("", include(router.urls)),

View file

@ -1,21 +1,61 @@
from rest_framework import mixins, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from infrasynth.security.permissions import IsAuthenticatedAndPermitted
from .models import FeatureFlag, FeatureFlagOverride
from .serializers import FeatureFlagOverrideSerializer, FeatureFlagSerializer
from .services import FeatureService
from .signals import flag_created, flag_deleted, flag_toggled, override_created, override_deleted
class FeatureFlagViewSet(viewsets.ModelViewSet):
queryset = FeatureFlag.objects.all()
serializer_class = FeatureFlagSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
def get_queryset(self):
return FeatureFlag.objects.all()
def perform_create(self, serializer):
instance = serializer.save()
FeatureService().invalidate(instance.slug, instance.tenant_id)
flag_created.send(
sender=FeatureFlag,
tenant_id=str(instance.tenant_id) if instance.tenant_id else None,
flag_slug=instance.slug,
is_active=instance.is_active,
actor_id=getattr(self.request.user, "pk", None),
)
def perform_update(self, serializer):
previous_active = serializer.instance.is_active
instance = serializer.save()
FeatureService().invalidate(instance.slug, instance.tenant_id)
if instance.is_active != previous_active:
flag_toggled.send(
sender=FeatureFlag,
tenant_id=str(instance.tenant_id) if instance.tenant_id else None,
flag_slug=instance.slug,
is_active=instance.is_active,
previous_is_active=previous_active,
actor_id=getattr(self.request.user, "pk", None),
)
def perform_destroy(self, instance):
slug = instance.slug
tenant_id = instance.tenant_id
FeatureService().invalidate(slug, tenant_id)
actor_id = getattr(self.request.user, "pk", None)
instance.delete()
flag_deleted.send(
sender=FeatureFlag,
tenant_id=str(tenant_id) if tenant_id else None,
flag_slug=slug,
actor_id=actor_id,
)
@action(detail=False, methods=["get"], url_path="active")
def active_flags(self, request):
from django.conf import settings
@ -55,7 +95,36 @@ class FeatureFlagOverrideViewSet(
):
queryset = FeatureFlagOverride.objects.all()
serializer_class = FeatureFlagOverrideSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
def get_queryset(self):
return FeatureFlagOverride.objects.select_related("flag", "user").all()
def perform_create(self, serializer):
instance = serializer.save()
flag_slug = instance.flag.slug
tenant_id = instance.tenant_id
FeatureService().invalidate(flag_slug, tenant_id)
override_created.send(
sender=FeatureFlagOverride,
tenant_id=str(tenant_id) if tenant_id else None,
flag_slug=flag_slug,
user_id=instance.user_id,
is_enabled=instance.is_enabled,
actor_id=getattr(self.request.user, "pk", None),
)
def perform_destroy(self, instance):
flag_slug = instance.flag.slug
tenant_id = instance.tenant_id
user_id = instance.user_id
FeatureService().invalidate(flag_slug, tenant_id)
actor_id = getattr(self.request.user, "pk", None)
instance.delete()
override_deleted.send(
sender=FeatureFlagOverride,
tenant_id=str(tenant_id) if tenant_id else None,
flag_slug=flag_slug,
user_id=user_id,
actor_id=actor_id,
)

View file

@ -1,6 +1,7 @@
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from infrasynth.security.permissions import IsAuthenticatedAndPermitted
from .models import FileCategory, ProcessingPipeline, StoredFile
from .serializers import (
@ -14,7 +15,7 @@ from .services import FileService
class StoredFileViewSet(viewsets.ModelViewSet):
queryset = StoredFile.objects.all()
serializer_class = StoredFileSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
def initial(self, request, *args, **kwargs):
from infrasynth.features.services import FeatureService
@ -37,7 +38,7 @@ class StoredFileViewSet(viewsets.ModelViewSet):
class FileCategoryViewSet(viewsets.ModelViewSet):
queryset = FileCategory.objects.all()
serializer_class = FileCategorySerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
def initial(self, request, *args, **kwargs):
from infrasynth.features.services import FeatureService
@ -55,7 +56,7 @@ class FileCategoryViewSet(viewsets.ModelViewSet):
class ProcessingPipelineViewSet(viewsets.ModelViewSet):
queryset = ProcessingPipeline.objects.all()
serializer_class = ProcessingPipelineSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
def initial(self, request, *args, **kwargs):
from infrasynth.features.services import FeatureService

View file

@ -1,6 +1,7 @@
from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from infrasynth.security.permissions import IsAuthenticatedAndPermitted
from .filters import (
ChannelConfigFilter,
NotificationDispatchFilter,
@ -17,7 +18,7 @@ from .serializers import (
class NotificationTemplateViewSet(ModelViewSet):
queryset = NotificationTemplate.objects.all()
serializer_class = NotificationTemplateSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = NotificationTemplateFilter
def get_queryset(self):
@ -27,7 +28,7 @@ class NotificationTemplateViewSet(ModelViewSet):
class NotificationDispatchViewSet(ReadOnlyModelViewSet):
queryset = NotificationDispatch.objects.select_related("template")
serializer_class = NotificationDispatchSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = NotificationDispatchFilter
def get_queryset(self):
@ -37,7 +38,7 @@ class NotificationDispatchViewSet(ReadOnlyModelViewSet):
class ChannelConfigViewSet(ModelViewSet):
queryset = ChannelConfig.objects.all()
serializer_class = ChannelConfigSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = ChannelConfigFilter
def get_queryset(self):

View file

@ -9,6 +9,8 @@ class SchedulerConfig(AppConfig):
def ready(self):
from infrasynth.features.registry import FeatureRegistry
from . import receivers # noqa: F401
FeatureRegistry.register(
"scheduler",
name="Scheduler",

View file

@ -0,0 +1,65 @@
"""Receivers emitting terminal ``TaskExecution`` signals.
``task_completed``/``task_failed`` fire exactly once, on the transition into a
terminal status, so the sync/plain-callable path and any task that updates its
own ``TaskExecution`` row are both covered without duplicate emissions.
Note: for the ``apply_async`` branch the execution row currently stays
``RUNNING`` (no Celery callback), so completion is only observable when the task
updates its own ``TaskExecution`` or runs synchronously. Tracking async
completion is a separate enhancement (``PLAN.md`` §2.8).
"""
from __future__ import annotations
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from .models import TaskExecution
from .signals import task_completed, task_failed
@receiver(pre_save, sender=TaskExecution)
def _capture_previous_status(sender, instance, raw=False, **kwargs):
if raw or instance.pk is None:
return
previous = TaskExecution.all_objects.filter(pk=instance.pk).values_list("status", flat=True).first()
if previous is not None:
instance._previous_status = previous
@receiver(post_save, sender=TaskExecution)
def _emit_terminal_status(sender, instance, created, raw=False, **kwargs):
if raw or created:
return
previous = getattr(instance, "_previous_status", None)
if previous is None or previous == instance.status:
return
tenant_id = str(instance.tenant_id) if instance.tenant_id else None
task_name = getattr(instance.task, "name", "")
if instance.status == TaskExecution.Status.SUCCESS:
task_completed.send(
sender=TaskExecution,
tenant_id=tenant_id,
task_name=task_name,
task_id=instance.pk,
duration_ms=_duration_ms(instance),
result=instance.result,
)
elif instance.status == TaskExecution.Status.FAILURE:
task_failed.send(
sender=TaskExecution,
tenant_id=tenant_id,
task_name=task_name,
task_id=instance.pk,
error=instance.error_traceback,
traceback="",
)
def _duration_ms(execution: TaskExecution) -> int | None:
if execution.started_at is None or execution.completed_at is None:
return None
return int((execution.completed_at - execution.started_at).total_seconds() * 1000)

View file

@ -6,7 +6,7 @@ from django.utils.module_loading import import_string
from infrasynth.shared.settings_utils import get_setting
from .models import ScheduledTask, TaskExecution
from .signals import task_failed, task_scheduled, task_started
from .signals import task_scheduled, task_started
logger = logging.getLogger(__name__)
@ -32,13 +32,6 @@ class TaskService:
execution.error_traceback = f"Could not import task path '{task.task_path}'"
execution.completed_at = timezone.now()
execution.save()
task_failed.send(
sender=TaskExecution,
task_name=task.name,
task_id=execution.id,
error=execution.error_traceback,
traceback="",
)
return execution
args = task.args or []
@ -89,14 +82,6 @@ class TaskService:
execution.error_traceback = str(exc)
execution.completed_at = timezone.now()
execution.save()
task_failed.send(
sender=TaskExecution,
tenant_id=str(task.tenant_id),
task_name=task.name,
task_id=execution.id,
error=str(exc),
traceback="",
)
return execution
task_scheduled.send(sender=TaskExecution, tenant_id=str(task.tenant_id), task_name=task.name, eta=None)

View file

@ -1,8 +1,9 @@
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from infrasynth.security.permissions import IsAuthenticatedAndPermitted
from .filters import ScheduledTaskFilter, TaskExecutionFilter
from .models import ScheduledTask, TaskExecution
from .serializers import ScheduledTaskSerializer, TaskExecutionSerializer
@ -12,7 +13,7 @@ from .services import TaskService
class ScheduledTaskViewSet(viewsets.ModelViewSet):
queryset = ScheduledTask.objects.all()
serializer_class = ScheduledTaskSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = ScheduledTaskFilter
def initial(self, request, *args, **kwargs):
@ -46,7 +47,7 @@ class ScheduledTaskViewSet(viewsets.ModelViewSet):
class TaskExecutionViewSet(viewsets.ReadOnlyModelViewSet):
queryset = TaskExecution.objects.all()
serializer_class = TaskExecutionSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = TaskExecutionFilter
def initial(self, request, *args, **kwargs):
@ -63,7 +64,7 @@ class TaskExecutionViewSet(viewsets.ReadOnlyModelViewSet):
class SchedulerStatusViewSet(viewsets.GenericViewSet):
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
def initial(self, request, *args, **kwargs):
from infrasynth.features.services import FeatureService

View file

@ -16,19 +16,24 @@ __all__ = [
"ALTCHAService",
"APIKeyAuthentication",
"AuthorizationService",
"AutoPermission",
"CookieJWTAuthentication",
"EmailOrUsernameBackend",
"HybridPermission",
"InfraSynthModelViewSet",
"InfraSynthReadOnlyModelViewSet",
"IsAuthenticatedAndPermitted",
"LoginAttemptGuard",
"PasswordPolicyValidator",
"Permission",
"PermissionRegistry",
"RecoveryCodeService",
"RoleAssignment",
"SystemUser",
"TOTPService",
"get_recovery_code_service",
"get_two_factor_service",
"is_tenant_owner",
"require_permission",
]
# public name -> module (relative to the ``infrasynth`` package) that defines it
@ -36,19 +41,24 @@ _EXPORTS: dict[str, str] = {
"ALTCHAService": "security.altcha.services",
"APIKeyAuthentication": "security.auth.api_keys",
"AuthorizationService": "security.services",
"AutoPermission": "security.permissions",
"CookieJWTAuthentication": "security.auth.cookies",
"EmailOrUsernameBackend": "security.auth.backends",
"HybridPermission": "security.permissions",
"InfraSynthModelViewSet": "security.viewsets",
"InfraSynthReadOnlyModelViewSet": "security.viewsets",
"IsAuthenticatedAndPermitted": "security.permissions",
"LoginAttemptGuard": "security.throttling",
"PasswordPolicyValidator": "security.password_validation",
"Permission": "security.models",
"PermissionRegistry": "security.registry",
"RecoveryCodeService": "security.two_factor.services",
"RoleAssignment": "security.models",
"SystemUser": "security.auth.api_keys",
"TOTPService": "security.two_factor.services",
"get_recovery_code_service": "security.two_factor.services",
"get_two_factor_service": "security.two_factor.services",
"is_tenant_owner": "security.permissions",
"require_permission": "security.permissions",
}

View file

@ -1,4 +1,55 @@
import logging
from django.apps import AppConfig
from django.db.models.signals import post_migrate
logger = logging.getLogger(__name__)
# Explicit (non model-derived) codenames the kit enforces or documents.
_KIT_PERMISSIONS: list[tuple[str, str, str, str]] = [
# (codename, name, group, description)
("security.manage_api_keys", "Manage API keys", "Security", "Create/rotate/delete tenant API keys."),
("security.manage_roles", "Manage roles", "Security", "Create and assign tenant roles."),
("security.manage_grants", "Manage grants", "Security", "Grant/revoke permissions directly."),
("security.view_permissions", "View permissions", "Security", "Read the permission catalog."),
("platform.roles.manage", "Manage platform roles", "Platform", "Create and assign global roles."),
("platform.tenants.view", "View any tenant", "Platform", "Read tenants across the platform."),
("platform.tenants.manage", "Manage tenants", "Platform", "Edit tenant metadata across the platform."),
("platform.tenants.suspend", "Suspend tenants", "Platform", "Suspend a tenant."),
("platform.tenants.reinstate", "Reinstate tenants", "Platform", "Reinstate a suspended tenant."),
("platform.tenants.delete", "Delete tenants", "Platform", "Archive/delete a tenant."),
("platform.tenants.impersonate", "Impersonate tenants", "Platform", "Open a support session into a tenant."),
("platform.users.view_any", "View any user", "Platform", "Read users/members across tenants."),
("platform.users.manage_email", "Change any user email", "Platform", "Update a user's email across tenants."),
("platform.users.deactivate", "Deactivate any user", "Platform", "Disable accounts across tenants."),
("platform.users.manage_roles", "Assign roles anywhere", "Platform", "Assign roles in any tenant."),
("platform.audit.view_all", "View all audit", "Platform", "Read audit records across tenants."),
("audit.view_model_changes", "View model changes", "Audit", "Read the model change log."),
("audit.view_api_logs", "View API logs", "Audit", "Read API interaction logs."),
("audit.view_security_events", "View security events", "Audit", "Read security events."),
("tenancy.manage_tenant", "Manage tenant", "Tenancy", "Edit the current tenant."),
("tenancy.manage_members", "Manage members", "Tenancy", "Invite/remove tenant members."),
("configs.manage", "Manage configuration", "Configs", "Write tenant configuration values."),
("configs.manage_global", "Manage global configuration", "Configs", "Write platform configuration defaults."),
]
def register_builtin_permissions() -> None:
from .registry import PermissionRegistry
for codename, name, group, description in _KIT_PERMISSIONS:
PermissionRegistry.register(codename, name=name, group=group, description=description)
def _sync_permissions_on_migrate(sender, **kwargs) -> None:
from .catalog import sync_permissions
try:
summary = sync_permissions()
except Exception: # noqa: BLE001 - migrate must never fail because of the catalog
logger.exception("Permission catalog sync failed during post_migrate")
return
logger.info("Permission catalog synced: %s", summary)
class SecurityConfig(AppConfig):
@ -9,6 +60,8 @@ class SecurityConfig(AppConfig):
def ready(self):
from infrasynth.features.registry import FeatureRegistry
register_builtin_permissions()
FeatureRegistry.register(
"security",
name="Security & Access",
@ -16,3 +69,9 @@ class SecurityConfig(AppConfig):
default=True,
category="system",
)
post_migrate.connect(
_sync_permissions_on_migrate,
sender=self,
dispatch_uid="infrasynth_security.sync_permissions",
)

View file

@ -0,0 +1,174 @@
"""Permission catalog: auto-derivation + sync.
Every concrete model contributes ``view``/``add``/``change``/``delete``
permissions (Django-style codenames ``{app_label}.{verb}_{model_name}``).
Apps add custom permissions through
:class:`infrasynth.security.registry.PermissionRegistry`. Both are merged into
the :class:`infrasynth.security.models.Permission` catalog so a UI can list and
assign them, and so codenames can be validated.
Enforcement itself does not require the catalog to be populated: the codename is
derived from the model + action at request time. The catalog is metadata.
"""
from __future__ import annotations
import logging
from django.apps import apps
from infrasynth.shared.settings_utils import get_setting
from .registry import PermissionDefinition, PermissionRegistry
logger = logging.getLogger(__name__)
__all__ = [
"DRF_ACTION_VERBS",
"permission_for",
"build_catalog",
"sync_permissions",
]
# DRF viewset action -> Django permission verb.
DRF_ACTION_VERBS: dict[str, str] = {
"list": "view",
"retrieve": "view",
"create": "add",
"update": "change",
"partial_update": "change",
"destroy": "delete",
}
# Apps/models that never expose a permission (framework internals, logs, the
# catalog itself). Everything else — kit and consumer models — is included.
DEFAULT_EXCLUDED_MODELS: frozenset[str] = frozenset(
{
"sessions.Session",
"admin.LogEntry",
"contenttypes.ContentType",
"auth.Permission",
"rest_framework.authtoken.Token",
"token_blacklist.OutstandingToken",
"token_blacklist.BlacklistedToken",
"django_celery_results.TaskResult",
"django_celery_results.GroupResult",
"django_celery_beat.PeriodicTask",
"django_celery_beat.IntervalSchedule",
"django_celery_beat.CrontabSchedule",
"django_celery_beat.SolarSchedule",
"django_celery_beat.ClockedSchedule",
"infrasynth_audit.ModelChangeLog",
"infrasynth_audit.APIInteractionLog",
"infrasynth_audit.SecurityEvent",
"infrasynth_security.Permission",
"infrasynth_security.TwoFactorConfig",
"infrasynth_security.ALTCHAChallenge",
}
)
_VERBS = ("view", "add", "change", "delete")
def permission_for(model, action: str) -> str:
"""Django-style codename for ``model`` and a DRF/verb ``action``."""
verb = DRF_ACTION_VERBS.get(action, action)
opts = model._meta
return f"{opts.app_label}.{verb}_{opts.model_name}"
def _excluded_models() -> frozenset[str]:
configured = get_setting("INFRASYNTH_SECURITY", "PERMISSION_EXCLUDE_MODELS", None)
if not configured:
return DEFAULT_EXCLUDED_MODELS
return DEFAULT_EXCLUDED_MODELS | frozenset(configured)
def _allowed_apps() -> list[str] | None:
return list(get_setting("INFRASYNTH_SECURITY", "PERMISSION_APPS", None) or []) or None
def _model_definitions() -> dict[str, PermissionDefinition]:
excluded = _excluded_models()
allowed_apps = _allowed_apps()
definitions: dict[str, PermissionDefinition] = {}
for model in apps.get_models():
opts = model._meta
if opts.abstract or opts.proxy or opts.auto_created or not opts.managed:
continue
if opts.label in excluded:
continue
if allowed_apps is not None and opts.app_label not in allowed_apps:
continue
group = opts.app_label.replace("_", " ").title()
model_name = opts.model_name or ""
for verb in _VERBS:
codename = f"{opts.app_label}.{verb}_{model_name}"
definitions[codename] = PermissionDefinition(
codename=codename,
name=f"Can {verb} {opts.verbose_name}",
app=opts.app_label,
model=model_name,
action=verb,
group=group,
is_custom=False,
)
return definitions
def build_catalog() -> dict[str, PermissionDefinition]:
"""Merged model-derived + custom-registered definitions (registry wins)."""
catalog = _model_definitions()
catalog.update(PermissionRegistry.all())
return catalog
def sync_permissions(*, deactivate_missing: bool = True) -> dict[str, int]:
"""Upserts the catalog into the ``Permission`` table. Idempotent."""
from .models import Permission
catalog = build_catalog()
existing = {permission.codename: permission for permission in Permission.objects.all()}
created = updated = reactivated = 0
for codename, definition in catalog.items():
fields = {
"name": definition.name,
"app_label": definition.app,
"model": definition.model,
"action": definition.action,
"group": definition.group,
"description": definition.description,
"is_custom": definition.is_custom,
}
obj = existing.get(codename)
if obj is None:
Permission.objects.create(codename=codename, **fields)
created += 1
continue
changed = {key: value for key, value in fields.items() if getattr(obj, key) != value}
if not obj.is_active:
changed["is_active"] = True
reactivated += 1
if changed:
for key, value in changed.items():
setattr(obj, key, value)
obj.save(update_fields=list(changed))
updated += 1
deactivated = 0
if deactivate_missing:
for codename in set(existing) - set(catalog):
permission = existing[codename]
if permission.is_active:
permission.is_active = False
permission.save(update_fields=["is_active"])
deactivated += 1
return {
"created": created,
"updated": updated,
"reactivated": reactivated,
"deactivated": deactivated,
"total": len(catalog),
}

View file

@ -0,0 +1,35 @@
"""Synchronise the permission catalog.
Model-derived permissions (``{app}.{verb}_{model}``) plus permissions registered
by apps through ``PermissionRegistry`` are upserted into the
``security_permission`` table. Imported permissions are
marked inactive, never deleted, so existing role assignments survive.
"""
from __future__ import annotations
from django.core.management.base import BaseCommand
from infrasynth.security.catalog import sync_permissions
class Command(BaseCommand):
help = "Synchronise the permission catalog from models and registered custom permissions."
def add_arguments(self, parser):
parser.add_argument(
"--no-deactivate",
action="store_true",
help="Do not deactivate catalog entries that are no longer derived/registered.",
)
def handle(self, *args, **options):
summary = sync_permissions(deactivate_missing=not options["no_deactivate"])
self.stdout.write(
self.style.SUCCESS(
"Permission catalog synced: "
f"{summary['created']} created, {summary['updated']} updated, "
f"{summary['reactivated']} reactivated, {summary['deactivated']} deactivated "
f"({summary['total']} total)."
)
)

View file

@ -0,0 +1,128 @@
# Generated by Django 5.2.17 on 2026-09-24 19:26
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("infrasynth_security", "0001_initial"),
("tenancy", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Permission",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("codename", models.CharField(max_length=200, unique=True)),
("name", models.CharField(max_length=200)),
("app_label", models.CharField(db_index=True, max_length=100)),
("model", models.CharField(blank=True, max_length=100)),
("action", models.CharField(blank=True, max_length=50)),
("group", models.CharField(blank=True, max_length=100)),
("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)),
],
options={
"db_table": "security_permission",
"ordering": ["group", "model", "codename"],
},
),
migrations.CreateModel(
name="RoleAssignment",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
],
options={
"db_table": "security_role_assignment",
},
),
migrations.AlterField(
model_name="grant",
name="tenant",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="tenancy.tenant",
),
),
migrations.AlterField(
model_name="revoke",
name="tenant",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="tenancy.tenant",
),
),
migrations.AddConstraint(
model_name="grant",
constraint=models.UniqueConstraint(
condition=models.Q(("tenant__isnull", True)),
fields=("user", "codename"),
name="uniq_global_grant_user_codename",
),
),
migrations.AddConstraint(
model_name="revoke",
constraint=models.UniqueConstraint(
condition=models.Q(("tenant__isnull", True)),
fields=("user", "codename"),
name="uniq_global_revoke_user_codename",
),
),
migrations.AddIndex(
model_name="permission",
index=models.Index(fields=["app_label", "model"], name="security_pe_app_lab_a92e89_idx"),
),
migrations.AddField(
model_name="roleassignment",
name="assigned_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="roleassignment",
name="role",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name="assignments", to="infrasynth_security.role"
),
),
migrations.AddField(
model_name="roleassignment",
name="tenant",
field=models.ForeignKey(
editable=False, on_delete=django.db.models.deletion.CASCADE, related_name="+", to="tenancy.tenant"
),
),
migrations.AddField(
model_name="roleassignment",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="tenant_role_assignments",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddConstraint(
model_name="roleassignment",
constraint=models.UniqueConstraint(fields=("tenant", "user", "role"), name="uniq_role_assignment"),
),
]

View file

@ -2,7 +2,12 @@ from django.conf import settings
from django.db import models
from django.db.models import Q
from infrasynth.tenancy.mixins import GlobalOrTenantModel, TenantOwnedModel
from infrasynth.tenancy.managers import AllObjectsManager
from infrasynth.tenancy.mixins import (
ContextGlobalOrTenantModel,
GlobalOrTenantModel,
TenantOwnedModel,
)
class Role(GlobalOrTenantModel):
@ -35,7 +40,14 @@ class Role(GlobalOrTenantModel):
return self.name
class Grant(TenantOwnedModel):
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,
@ -55,10 +67,17 @@ class Grant(TenantOwnedModel):
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(TenantOwnedModel):
class Revoke(ContextGlobalOrTenantModel):
"""A direct user permission revoke. ``tenant IS NULL`` revokes globally."""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
@ -77,6 +96,70 @@ class Revoke(TenantOwnedModel):
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"),
]

View file

@ -5,10 +5,13 @@ Enforcement model
* A superuser is always allowed.
* A **tenant owner** (``TenantMembership.is_owner`` for the resolved tenant) is
allowed — ownership is a capability, not a permission row.
* Otherwise the request user must hold at least one of the view's
``required_permissions`` (``HybridPermission``) or all of them
(``require_permission``).
* A view with no ``required_permissions`` only needs authentication.
* Otherwise the request user must hold **any** of the view's
``required_permissions`` (set ``require_all = True`` to demand all of them).
* A view with no ``required_permissions`` falls back to the model-derived
codename (see :class:`AutoPermission`).
* Declared gates (``infrasynth_gates``) run first for everyone, including owners
and superusers; use :class:`infrasynth.gates.PermissionGate` when even the
owner must hold a codename.
The underlying :class:`AuthorizationService` is deliberately strict (owners are
not implicitly granted every codename) so it stays a pure permission resolver;
@ -22,6 +25,7 @@ from typing import Any
from rest_framework.permissions import BasePermission
from infrasynth.gates import evaluate_gates
from infrasynth.shared.settings_utils import get_setting
from .services import AuthorizationService
@ -48,7 +52,12 @@ def is_tenant_owner(user: Any) -> bool:
class HybridPermission(BasePermission):
"""Allows when the user is an owner or holds any ``required_permissions``."""
"""Authenticated, then permission-checked, with an owner/superuser bypass.
A view declares ``required_permissions`` (any-of by default; set
``require_all = True`` for all-of). With none declared, the model-derived
codename is enforced via :func:`evaluate_auto_permission`.
"""
def has_permission(self, request, view):
user = getattr(request, "user", None)
@ -62,29 +71,100 @@ class HybridPermission(BasePermission):
return True
required = getattr(view, "required_permissions", []) or []
if not required:
return True
return AuthorizationService().has_any_permission(user, required)
# No explicit permission: fall back to the model-derived codename.
return evaluate_auto_permission(request, view)
authz = AuthorizationService()
if getattr(view, "require_all", False):
return authz.has_all_permissions(user, list(required))
return authz.has_any_permission(user, list(required))
class IsAuthenticatedAndPermitted(HybridPermission):
"""The idiom for kit views: authenticated, then permission-checked."""
class AutoPermission(BasePermission):
"""Derives and enforces ``{app}.{action}_{model}`` permissions automatically.
Only applies to model-backed DRF viewsets (and only when ``AUTO_PERMISSIONS``
is enabled); it abstains on plain APIViews so it is safe in
``DEFAULT_PERMISSION_CLASSES``. Tenant owners and superusers bypass, matching
``HybridPermission``.
"""
message = "You do not have permission to perform this action."
def has_permission(self, request, view):
if not getattr(getattr(request, "user", None), "is_authenticated", False):
user = getattr(request, "user", None)
if not user or not getattr(user, "is_authenticated", False):
return False
return super().has_permission(request, view)
if getattr(user, "is_superuser", False):
return True
return evaluate_auto_permission(request, view)
def require_permission(*codenames: str):
"""View (or view-decorator) requiring *all* listed permissions."""
# Backwards-compatible alias: ``IsAuthenticatedAndPermitted`` is the documented
# idiom name for kit views but is exactly ``HybridPermission``.
IsAuthenticatedAndPermitted = HybridPermission
class PermissionRequired(IsAuthenticatedAndPermitted):
def has_permission(self, request, view):
if not super().has_permission(request, view):
return False
user = request.user
if getattr(user, "is_superuser", False) or is_tenant_owner(user):
return True
return AuthorizationService().has_all_permissions(user, list(codenames))
return PermissionRequired
def resolve_view_model(view) -> Any:
"""Best-effort concrete model behind a DRF view, or ``None``."""
model = getattr(getattr(view, "queryset", None), "model", None)
if model is not None:
return model
get_queryset = getattr(view, "get_queryset", None)
if callable(get_queryset):
try:
return getattr(get_queryset(), "model", None)
except Exception: # noqa: BLE001 - not every queryset is safe to build
return None
return None
def automatic_permissions(view) -> list[str]:
"""The codenames a view requires, explicitly declared or auto-derived.
Priority: ``required_permissions`` (explicit) → ``action_permissions`` for
the current action → derived ``{app}.{verb}_{model}`` → ``[]``.
"""
explicit = getattr(view, "required_permissions", None)
if explicit:
return list(explicit)
action = getattr(view, "action", None)
if not action:
return []
action_map = getattr(view, "action_permissions", None) or {}
if action in action_map:
return [action_map[action]]
model = resolve_view_model(view)
if model is None:
return []
from .catalog import permission_for
return [permission_for(model, action)]
def auto_permissions_enabled(view) -> bool:
"""Whether model-derived enforcement applies to ``view``."""
mode = get_setting("INFRASYNTH_SECURITY", "AUTO_PERMISSIONS", "global")
if mode in (False, None, "off", "disabled"):
return False
if getattr(view, "auto_permissions", None) is False:
return False
if mode == "opt_in":
return bool(getattr(view, "auto_permissions", False))
return True
def evaluate_auto_permission(request, view) -> bool:
"""Runs the auto-permission check, abstaining when nothing is derivable."""
if not auto_permissions_enabled(view):
return True
codenames = automatic_permissions(view)
if not codenames:
return True
user = getattr(request, "user", None)
if not user or not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_superuser", False):
return True
if is_tenant_owner(user):
return True
return AuthorizationService().has_any_permission(user, codenames)

View file

@ -0,0 +1,71 @@
"""Registry of custom permissions declared by apps.
Consuming apps (and the kit itself) register codenames in ``apps.py:ready()``
so they appear in the permission catalog and can be assigned to roles/users
without editing the kit. Model-derived CRUD/view permissions are generated
automatically by :mod:`infrasynth.security.catalog` and do not need to be
registered here.
"""
from __future__ import annotations
from dataclasses import dataclass
__all__ = ["PermissionDefinition", "PermissionRegistry"]
@dataclass(frozen=True)
class PermissionDefinition:
codename: str
name: str = ""
app: str = ""
model: str = ""
action: str = ""
group: str = ""
description: str = ""
is_custom: bool = True
class PermissionRegistry:
"""Global registry of explicitly declared permissions."""
_permissions: dict[str, PermissionDefinition] = {}
@classmethod
def register(
cls,
codename: str,
*,
name: str = "",
app: str = "",
model: str = "",
action: str = "",
group: str = "",
description: str = "",
is_custom: bool = True,
) -> PermissionDefinition:
definition = PermissionDefinition(
codename=codename,
name=name or codename,
app=app or codename.split(".", 1)[0],
model=model,
action=action,
group=group or (app or codename.split(".", 1)[0]).replace("_", " ").title(),
description=description,
is_custom=is_custom,
)
cls._permissions[codename] = definition
return definition
@classmethod
def get(cls, codename: str) -> PermissionDefinition | None:
return cls._permissions.get(codename)
@classmethod
def all(cls) -> dict[str, PermissionDefinition]:
return dict(cls._permissions)
@classmethod
def clear(cls) -> None:
"""Clears the registry. Tests only."""
cls._permissions.clear()

View file

@ -1,6 +1,27 @@
from rest_framework import serializers
from .models import APIKey, Grant, Revoke, Role
from infrasynth.shared.settings_utils import get_setting
from .models import APIKey, Grant, Permission, Revoke, Role
from .services import AuthorizationService
class PermissionSerializer(serializers.ModelSerializer):
class Meta:
model = Permission
fields = [
"id",
"codename",
"name",
"app_label",
"model",
"action",
"group",
"description",
"is_custom",
"is_active",
]
read_only_fields = fields
class RoleSerializer(serializers.ModelSerializer):
@ -9,20 +30,74 @@ class RoleSerializer(serializers.ModelSerializer):
fields = ["id", "name", "slug", "description", "permissions", "is_system", "users"]
read_only_fields = ["id", "is_system"]
def validate_permissions(self, value):
if not get_setting("INFRASYNTH_SECURITY", "STRICT_PERMISSION_VALIDATION", False):
return value
known = set(Permission.objects.filter(is_active=True).values_list("codename", flat=True))
unknown = sorted({codename for codename in value if codename not in known})
if unknown:
raise serializers.ValidationError(f"Unknown permission(s): {', '.join(unknown)}")
return value
def _validate_grant_scope(scope, request):
if scope != "global":
return
user = getattr(request, "user", None)
allowed = bool(user and getattr(user, "is_authenticated", False)) and (
getattr(user, "is_superuser", False) or AuthorizationService().has_permission(user, "platform.roles.manage")
)
if not allowed:
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied("Global grants/revokes require platform.roles.manage.")
class GrantSerializer(serializers.ModelSerializer):
scope = serializers.ChoiceField(choices=["tenant", "global"], default="tenant", write_only=True)
class Meta:
model = Grant
fields = ["id", "user", "codename", "granted_by", "reason", "expires_at"]
fields = ["id", "user", "codename", "granted_by", "reason", "expires_at", "scope"]
read_only_fields = ["id", "granted_by"]
def validate(self, attrs):
_validate_grant_scope(attrs.get("scope", "tenant"), self.context.get("request"))
return attrs
def create(self, validated_data):
force_global = validated_data.pop("scope", "tenant") == "global"
grant = Grant(**validated_data)
if force_global:
grant.tenant = None
grant.save(force_global=True)
else:
grant.save()
return grant
class RevokeSerializer(serializers.ModelSerializer):
scope = serializers.ChoiceField(choices=["tenant", "global"], default="tenant", write_only=True)
class Meta:
model = Revoke
fields = ["id", "user", "codename", "revoked_by", "reason"]
fields = ["id", "user", "codename", "revoked_by", "reason", "scope"]
read_only_fields = ["id", "revoked_by"]
def validate(self, attrs):
_validate_grant_scope(attrs.get("scope", "tenant"), self.context.get("request"))
return attrs
def create(self, validated_data):
force_global = validated_data.pop("scope", "tenant") == "global"
revoke = Revoke(**validated_data)
if force_global:
revoke.tenant = None
revoke.save(force_global=True)
else:
revoke.save()
return revoke
class APIKeySerializer(serializers.Serializer):
id = serializers.IntegerField(read_only=True)

View file

@ -60,16 +60,22 @@ class AuthorizationService:
from infrasynth.tenancy.context import get_current_tenant
from infrasynth.tenancy.models import TenantMembership
from .models import Role
from .models import Role, RoleAssignment
role_perms: list[list[str]] = []
# Global role assignments (Role.users) apply in every tenant.
roles = getattr(user, "roles", None)
if roles is not None:
role_perms.extend(list(roles.values_list("permissions", flat=True)))
# Roles assigned via membership in the current tenant (TENANCY.md §6).
tenant = get_current_tenant()
if tenant is not None:
# Tenant-local role assignments (many roles per user per tenant).
role_perms.extend(
list(RoleAssignment.objects.filter(user=user).values_list("role__permissions", flat=True))
)
# Roles assigned via membership in the current tenant (TENANCY.md §6).
slugs = TenantMembership.objects.filter(user=user, tenant=tenant, is_active=True).values_list(
"role", flat=True
)

View file

@ -6,6 +6,7 @@ from .views import (
APIKeyViewSet,
AuthViewSet,
GrantViewSet,
PermissionViewSet,
RevokeViewSet,
RoleViewSet,
TwoFactorViewSet,
@ -15,6 +16,7 @@ from .views import (
router = DefaultRouter()
router.register(r"api-keys", APIKeyViewSet, basename="api-keys")
router.register(r"roles", RoleViewSet, basename="roles")
router.register(r"permissions", PermissionViewSet, basename="permissions")
router.register(r"grants", GrantViewSet, basename="grants")
router.register(r"revokes", RevokeViewSet, basename="revokes")

View file

@ -21,12 +21,13 @@ from infrasynth.shared.settings_utils import get_setting
from infrasynth.tenancy.services import TenantService
from .altcha.services import ALTCHAService
from .models import APIKey, Grant, Revoke, Role, TwoFactorConfig
from .models import APIKey, Grant, Permission, Revoke, Role, RoleAssignment, TwoFactorConfig
from .permissions import IsAuthenticatedAndPermitted
from .serializers import (
APIKeySerializer,
GrantSerializer,
LoginSerializer,
PermissionSerializer,
RevokeSerializer,
RoleSerializer,
)
@ -563,12 +564,60 @@ class RoleViewSet(viewsets.ModelViewSet):
def get_queryset(self):
return Role.objects.order_by("name").all()
def _assert_can_manage(self, role):
"""Global roles are platform-owned; tenant roles are tenant-owned."""
if role.tenant_id is not None:
return
user = self.request.user
if getattr(user, "is_superuser", False) or AuthorizationService().has_permission(user, "platform.roles.manage"):
return
raise PermissionDenied("Global roles require platform.roles.manage.")
def perform_create(self, serializer):
from infrasynth.tenancy.context import get_current_tenant
tenant = get_current_tenant()
if tenant is not None:
serializer.save(tenant=tenant)
return
user = self.request.user
if getattr(user, "is_superuser", False) or AuthorizationService().has_permission(user, "platform.roles.manage"):
serializer.save()
return
raise PermissionDenied("Global roles require platform.roles.manage.")
def perform_update(self, serializer):
self._assert_can_manage(serializer.instance)
serializer.save()
def perform_destroy(self, instance):
if instance.is_system:
raise PermissionDenied("System roles cannot be deleted.")
self._assert_can_manage(instance)
instance.delete()
class PermissionViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
"""The permission catalog, for building role/user assignment UIs."""
serializer_class = PermissionSerializer
permission_classes = [IsAuthenticatedAndPermitted]
required_permissions = ["security.view_permissions"]
def get_queryset(self):
queryset = Permission.objects.all()
app_label = self.request.query_params.get("app_label") or self.request.query_params.get("app")
group = self.request.query_params.get("group")
active = self.request.query_params.get("is_active")
if app_label:
queryset = queryset.filter(app_label=app_label)
if group:
queryset = queryset.filter(group__iexact=group)
if active is not None:
queryset = queryset.filter(is_active=active.lower() in ("1", "true", "yes"))
return queryset.order_by("group", "model", "codename")
class GrantViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
@ -632,7 +681,10 @@ class UserPermissionViewSet(viewsets.GenericViewSet):
@action(detail=True, methods=["get", "put"], url_path="roles")
def roles(self, request, pk=None):
from infrasynth.tenancy.context import get_current_tenant
user = self._get_user(pk)
tenant = get_current_tenant()
if request.method == "PUT":
slugs = request.data.get("roles", [])
if not isinstance(slugs, list):
@ -641,5 +693,23 @@ class UserPermissionViewSet(viewsets.GenericViewSet):
missing = set(slugs) - {role.slug for role in resolved}
if missing:
raise ValidationError({"roles": f"Unknown role(s): {', '.join(sorted(missing))}"})
user.roles.set(resolved)
return Response({"user": user.pk, "roles": list(user.roles.values_list("slug", flat=True))})
global_roles = [role for role in resolved if role.tenant_id is None]
tenant_roles = [role for role in resolved if role.tenant_id is not None]
user.roles.set(global_roles)
if tenant is not None:
RoleAssignment.objects.filter(tenant=tenant, user=user).exclude(role__in=tenant_roles).delete()
for role in tenant_roles:
RoleAssignment.objects.get_or_create(
tenant=tenant,
user=user,
role=role,
defaults={"assigned_by": request.user},
)
assigned = list(user.roles.values_list("slug", flat=True))
if tenant is not None:
assigned += list(
RoleAssignment.objects.filter(tenant=tenant, user=user).values_list("role__slug", flat=True)
)
return Response({"user": user.pk, "roles": sorted(set(assigned))})

View file

@ -0,0 +1,44 @@
"""Kit base viewsets with model-derived permissions wired in.
Subclass one of these in a consuming app and CRUD/view endpoints require the
auto-derived codename (``{app}.{verb}_{model}``) with no per-view configuration::
from infrasynth.security.viewsets import InfraSynthModelViewSet
class TicketViewSet(InfraSynthModelViewSet):
queryset = Ticket.objects.all()
serializer_class = TicketSerializer # needs view/add/change/delete_ticket
An explicit ``required_permissions`` still wins, and custom actions can name
their own codename with ``action_permissions = {"resolve": "helpdesk.resolve_ticket"}``.
"""
from __future__ import annotations
from rest_framework import viewsets
from .permissions import IsAuthenticatedAndPermitted
__all__ = [
"InfraSynthModelViewSet",
"InfraSynthReadOnlyModelViewSet",
"AutoPermissionMixin",
]
class AutoPermissionMixin:
"""Marks a viewset as opting into auto-derived model permissions."""
auto_permissions = True
class InfraSynthModelViewSet(AutoPermissionMixin, viewsets.ModelViewSet):
"""Full CRUD viewset: derives view/add/change/delete permissions."""
permission_classes = [IsAuthenticatedAndPermitted]
class InfraSynthReadOnlyModelViewSet(AutoPermissionMixin, viewsets.ReadOnlyModelViewSet):
"""Read-only viewset: derives the view permission."""
permission_classes = [IsAuthenticatedAndPermitted]

View file

@ -15,7 +15,7 @@ from django.db import models
from .context import get_current_tenant
from .managers import AllObjectsManager, GlobalOrTenantManager, TenantManager
__all__ = ["TenantOwnedModel", "GlobalOrTenantModel"]
__all__ = ["TenantOwnedModel", "GlobalOrTenantModel", "ContextGlobalOrTenantModel"]
class TenantOwnedModel(models.Model):
@ -69,3 +69,23 @@ class GlobalOrTenantModel(models.Model):
class Meta:
abstract = True
class ContextGlobalOrTenantModel(GlobalOrTenantModel):
"""A global-or-tenant model that fills an unset tenant from context on save.
The global row still exists (``force_global=True`` on :meth:`save`), but a
normal write inside a request/task lands in the current tenant instead of
silently becoming a platform-wide row. Used for per-user overrides
(``Grant``/``Revoke``) where a tenant-scoped write is the safe default.
"""
class Meta:
abstract = True
def save(self, *args: Any, force_global: bool = False, **kwargs: Any) -> None:
if self.tenant_id is None and not force_global:
tenant = get_current_tenant()
if tenant is not None:
self.tenant = tenant
super().save(*args, **kwargs)

View file

@ -27,6 +27,7 @@ from .signals import (
tenant_reinstated,
tenant_suspended,
tenant_switched,
tenant_updated,
)
__all__ = ["TenantService"]
@ -181,6 +182,27 @@ class TenantService:
# --- lifecycle ----------------------------------------------------------
def update_tenant(self, tenant: Tenant, *, actor: Any = None, **changes: Any) -> Tenant:
"""Updates the editable tenant fields and emits ``tenant_updated``.
Only ``name``/``locale``/``timezone``/``metadata`` are applied; ``None``
values are ignored. No signal (and no write) when nothing changes.
"""
allowed = {"name", "locale", "timezone", "metadata"}
applied = {key: value for key, value in changes.items() if key in allowed and value is not None}
if not applied:
return tenant
for field, value in applied.items():
setattr(tenant, field, value)
tenant.save(update_fields=list(applied))
tenant_updated.send(
sender=Tenant,
tenant_id=str(tenant.pk),
changes={key: str(value) for key, value in applied.items()},
actor_id=getattr(actor, "pk", None),
)
return tenant
def suspend(self, tenant: Tenant, reason: str = "") -> None:
if tenant.status == Tenant.Status.SUSPENDED:
return

View file

@ -1,9 +1,9 @@
from django.shortcuts import get_object_or_404
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from infrasynth.security.permissions import IsAuthenticatedAndPermitted
from infrasynth.shared.exceptions import NotFoundError, ValidationAppError
from .filters import TenantFilter, TenantInvitationFilter, TenantMembershipFilter
@ -18,7 +18,7 @@ from .services import TenantService
class TenantViewSet(viewsets.ModelViewSet):
serializer_class = TenantSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = TenantFilter
def get_queryset(self):
@ -36,6 +36,13 @@ class TenantViewSet(viewsets.ModelViewSet):
)
serializer.instance = tenant
def perform_update(self, serializer):
validated = serializer.validated_data
changes = {
field: validated[field] for field in ("name", "locale", "timezone", "metadata") if field in validated
}
TenantService().update_tenant(serializer.instance, actor=self.request.user, **changes)
@action(detail=True, methods=["get"], url_path="members")
def members(self, request, pk=None):
tenant = self.get_object()
@ -71,7 +78,7 @@ class TenantMembershipViewSet(
viewsets.GenericViewSet,
):
serializer_class = TenantMembershipSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = TenantMembershipFilter
def get_queryset(self):
@ -87,7 +94,7 @@ class TenantMembershipViewSet(
class TenantInvitationViewSet(viewsets.GenericViewSet):
serializer_class = TenantInvitationSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = TenantInvitationFilter
@action(detail=False, methods=["post"], url_path="accept")

View file

@ -1,8 +1,9 @@
from django.utils.module_loading import import_string
from rest_framework import status, viewsets
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from infrasynth.security.permissions import IsAuthenticatedAndPermitted
from infrasynth.shared.settings_utils import get_setting
from .filters import (
@ -33,7 +34,7 @@ from .signals import inbound_event_received
class OutboundEndpointViewSet(viewsets.ModelViewSet):
queryset = OutboundEndpoint.objects.all()
serializer_class = OutboundEndpointSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = OutboundEndpointFilter
search_fields = ["name"]
@ -44,7 +45,7 @@ class OutboundEndpointViewSet(viewsets.ModelViewSet):
class OutboundSubscriptionViewSet(viewsets.ModelViewSet):
queryset = OutboundSubscription.objects.select_related("endpoint").all()
serializer_class = OutboundSubscriptionSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = OutboundSubscriptionFilter
search_fields = ["event_name"]
@ -55,7 +56,7 @@ class OutboundSubscriptionViewSet(viewsets.ModelViewSet):
class OutboundDeliveryViewSet(viewsets.ReadOnlyModelViewSet):
queryset = OutboundDelivery.objects.select_related("subscription__endpoint").all()
serializer_class = OutboundDeliverySerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = OutboundDeliveryFilter
def get_queryset(self):
@ -65,7 +66,7 @@ class OutboundDeliveryViewSet(viewsets.ReadOnlyModelViewSet):
class InboundEndpointViewSet(viewsets.ModelViewSet):
queryset = InboundEndpoint.objects.all()
serializer_class = InboundEndpointSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = InboundEndpointFilter
search_fields = ["name", "slug"]
@ -76,7 +77,7 @@ class InboundEndpointViewSet(viewsets.ModelViewSet):
class InboundEventViewSet(viewsets.ReadOnlyModelViewSet):
queryset = InboundEvent.objects.select_related("endpoint").all()
serializer_class = InboundEventSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = InboundEventFilter
def get_queryset(self):

View file

@ -1,8 +1,9 @@
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from infrasynth.security.permissions import IsAuthenticatedAndPermitted
from .filters import (
NodeAssignmentFilter,
TransitionFilter,
@ -32,7 +33,7 @@ from .serializers import (
class WorkflowViewSet(viewsets.ModelViewSet):
queryset = Workflow.objects.all()
serializer_class = WorkflowSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = WorkflowFilter
def initial(self, request, *args, **kwargs):
@ -51,7 +52,7 @@ class WorkflowViewSet(viewsets.ModelViewSet):
class WorkflowNodeViewSet(viewsets.ModelViewSet):
queryset = WorkflowNode.objects.all()
serializer_class = WorkflowNodeSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = WorkflowNodeFilter
def initial(self, request, *args, **kwargs):
@ -70,7 +71,7 @@ class WorkflowNodeViewSet(viewsets.ModelViewSet):
class TransitionViewSet(viewsets.ModelViewSet):
queryset = Transition.objects.all()
serializer_class = TransitionSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = TransitionFilter
def initial(self, request, *args, **kwargs):
@ -89,7 +90,7 @@ class TransitionViewSet(viewsets.ModelViewSet):
class WorkflowInstanceViewSet(viewsets.ModelViewSet):
queryset = WorkflowInstance.objects.all()
serializer_class = WorkflowInstanceSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = WorkflowInstanceFilter
def initial(self, request, *args, **kwargs):
@ -228,7 +229,7 @@ class WorkflowInstanceViewSet(viewsets.ModelViewSet):
class NodeAssignmentViewSet(viewsets.ModelViewSet):
queryset = NodeAssignment.objects.all()
serializer_class = NodeAssignmentSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = NodeAssignmentFilter
def initial(self, request, *args, **kwargs):
@ -247,7 +248,7 @@ class NodeAssignmentViewSet(viewsets.ModelViewSet):
class WorkflowObserverViewSet(viewsets.ModelViewSet):
queryset = WorkflowObserver.objects.all()
serializer_class = WorkflowObserverSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticatedAndPermitted]
filterset_class = WorkflowObserverFilter
def initial(self, request, *args, **kwargs):

View file

@ -1,3 +1,8 @@
[build-system]
# PEP 518/517: required so `python -m build` produces sdist + wheel.
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "infrasynth-base"
version = "1.0.0"

View file

@ -152,3 +152,14 @@ def clean_feature_registry():
FeatureRegistry._features.clear()
yield
FeatureRegistry._features = snapshot
@pytest.fixture
def clean_config_registry():
"""Clears the global ConfigRegistry for the test, then restores it."""
from infrasynth.configs.registry import ConfigRegistry
snapshot = dict(ConfigRegistry._definitions)
ConfigRegistry.clear()
yield
ConfigRegistry._definitions = snapshot

View file

@ -3,7 +3,7 @@ from django.conf import settings
from django.test import override_settings
from infrasynth.audit.models import ModelChangeLog, SecurityEvent
from infrasynth.audit.signals import security_event_occurred
from infrasynth.audit.signals import model_changed, security_event_occurred
from infrasynth.security.models import Role
@ -11,6 +11,13 @@ def audit_config(**overrides):
return {**settings.INFRASYNTH_AUDIT, **overrides}
def _capture(signal):
received = []
receiver = lambda **kwargs: received.append(kwargs) # noqa: E731
signal.connect(receiver, weak=False)
return received, receiver
@pytest.fixture
def role(db):
return Role.objects.create(name="Test Role", slug="test-role", permissions=[])
@ -128,3 +135,67 @@ class TestSecurityEvents:
assert event.ip_address == "127.0.0.1"
assert event.metadata == {"reason": "bad_password"}
assert event.request_id
class TestModelChangedSignal:
def test_create_emits(self, db):
received, receiver = _capture(model_changed)
try:
role = Role.objects.create(name="Signalled", slug="signalled")
finally:
model_changed.disconnect(receiver)
assert len(received) == 1
assert received[0]["model_label"] == "infrasynth_security.Role"
assert received[0]["object_id"] == str(role.pk)
assert received[0]["action"] == "create"
assert received[0]["changes"]["name"] == [None, "Signalled"]
def test_update_emits_with_diff(self, db):
role = Role.objects.create(name="Before", slug="before")
received, receiver = _capture(model_changed)
try:
role.name = "After"
role.save()
finally:
model_changed.disconnect(receiver)
assert received[-1]["action"] == "update"
assert received[-1]["changes"]["name"] == ["Before", "After"]
def test_delete_emits(self, db):
role = Role.objects.create(name="Doomed", slug="doomed")
pk = role.pk
received, receiver = _capture(model_changed)
try:
role.delete()
finally:
model_changed.disconnect(receiver)
assert received[-1]["action"] == "delete"
assert received[-1]["object_id"] == str(pk)
@override_settings(INFRASYNTH_AUDIT=audit_config(EXCLUDED_MODELS=["infrasynth_security.Role"]))
def test_excluded_model_does_not_emit(self, db):
received, receiver = _capture(model_changed)
try:
Role.objects.create(name="Ignored", slug="ignored")
finally:
model_changed.disconnect(receiver)
assert received == []
class TestConfigSecretAudit:
def test_secret_value_never_enters_changes(self, db, tenant, clean_config_registry):
from infrasynth.configs.models import ConfigValue
from infrasynth.configs.registry import ConfigRegistry, ConfigType
from infrasynth.configs.services import ConfigService
ConfigRegistry.register("api.token", type=ConfigType.STRING, is_secret=True)
ConfigService().set("api.token", "hunter2", tenant=tenant)
ciphertext = ConfigValue.all_objects.get(tenant=tenant, key="api.token").value
log = ModelChangeLog.objects.get(
model_label="infrasynth_configs.ConfigValue",
action="create",
)
assert "value" not in log.changes
assert ciphertext not in str(log.changes)
assert "hunter2" not in str(log.changes)

View file

View file

@ -0,0 +1,76 @@
"""Tenant isolation for configuration values (TENANCY.md §10)."""
import pytest
from infrasynth.configs.models import ConfigValue
from infrasynth.configs.registry import ConfigRegistry, ConfigType
from infrasynth.configs.services import ConfigService
from infrasynth.tenancy.context import tenant_context
from infrasynth.tenancy.models import Tenant
@pytest.fixture(autouse=True)
def clean_registry(clean_config_registry):
yield
@pytest.fixture(autouse=True)
def envelope_errors(settings):
settings.REST_FRAMEWORK = {
**settings.REST_FRAMEWORK,
"EXCEPTION_HANDLER": "infrasynth.api.exceptions.envelope_exception_handler",
}
@pytest.fixture
def service():
return ConfigService()
@pytest.fixture
def other_tenant():
return Tenant.objects.create(slug="other-config-ws", name="Other Workspace")
class TestServiceIsolation:
def test_tenant_a_cannot_read_tenant_b(self, service, tenant, other_tenant):
ConfigRegistry.register("k", type=ConfigType.STRING, default="default")
service.set("k", "b-secret", tenant=other_tenant)
assert service.get("k", tenant=tenant) == "default"
def test_absent_override_falls_back_to_global_not_other_tenant(self, service, tenant, other_tenant):
ConfigRegistry.register("k", type=ConfigType.STRING, default="default")
service.set_global("k", "global")
service.set("k", "b-secret", tenant=other_tenant)
assert service.get("k", tenant=tenant) == "global"
def test_reset_only_affects_own_tenant(self, service, tenant, other_tenant):
ConfigRegistry.register("k", type=ConfigType.STRING, default="default")
service.set("k", "b-value", tenant=other_tenant)
assert service.reset("k", tenant=tenant) is False
assert service.get("k", tenant=other_tenant) == "b-value"
class TestManagerIsolation:
def test_scoped_manager_hides_other_tenant(self, tenant, other_tenant):
ConfigValue.all_objects.create(tenant=other_tenant, key="other.only", value="x")
with tenant_context(tenant):
assert ConfigValue.objects.count() == 0
assert ConfigValue.objects.filter(key="other.only").delete()[0] == 0
with tenant_context(other_tenant):
assert ConfigValue.objects.filter(key="other.only").exists()
class TestApiIsolation:
def test_other_tenant_key_is_404_not_403(self, authenticated_client, tenant, other_tenant):
ConfigValue.all_objects.create(tenant=other_tenant, key="other.only", value="x")
response = authenticated_client.get("/api/v1/configs/other.only/")
assert response.status_code == 404
def test_list_never_exposes_other_tenant_values(self, authenticated_client, tenant, other_tenant):
ConfigRegistry.register("shared.key", type=ConfigType.STRING, default="default")
ConfigValue.all_objects.create(tenant=other_tenant, key="shared.key", value="b-secret")
response = authenticated_client.get("/api/v1/configs/")
assert response.status_code == 200
values = {entry["key"]: entry["value"] for entry in response.json()["values"]}
assert values["shared.key"] == "default"

View file

@ -0,0 +1,135 @@
"""``ConfigRegistry`` registration and coercion."""
import decimal
import pytest
from infrasynth.configs.registry import INVALID_CODE, ConfigRegistry, ConfigType
from infrasynth.shared.exceptions import ValidationAppError
def _positive(value):
if value <= 0:
raise ValueError("must be positive")
class TestRegistration:
@pytest.fixture(autouse=True)
def clean_registry(self, clean_config_registry):
yield
def test_register_and_get(self):
definition = ConfigRegistry.register("branding.color", type=ConfigType.STRING, default="#000")
assert ConfigRegistry.get("branding.color") is definition
assert ConfigRegistry.get("missing") is None
def test_all_returns_copy(self):
ConfigRegistry.register("a")
snapshot = ConfigRegistry.all()
snapshot["b"] = None
assert "b" not in ConfigRegistry.all()
def test_register_accepts_string_type(self):
definition = ConfigRegistry.register("n", type="int", default=1)
assert definition.type is ConfigType.INT
def test_label_defaults_to_key(self):
definition = ConfigRegistry.register("some.key")
assert definition.label == "some.key"
class TestCoerce:
@pytest.fixture(autouse=True)
def clean_registry(self, clean_config_registry):
yield
def _definition(self, config_type, **kwargs):
return ConfigRegistry.register("k", type=config_type, **kwargs)
def test_string(self):
definition = self._definition(ConfigType.STRING)
assert ConfigRegistry.coerce(definition, "hi") == "hi"
def test_string_rejects_int(self):
definition = self._definition(ConfigType.STRING)
with pytest.raises(ValidationAppError) as exc:
ConfigRegistry.coerce(definition, 3)
assert exc.value.code == INVALID_CODE
def test_int_accepts_numeric_string(self):
definition = self._definition(ConfigType.INT)
assert ConfigRegistry.coerce(definition, "5") == 5
def test_int_rejects_bool(self):
definition = self._definition(ConfigType.INT)
with pytest.raises(ValidationAppError):
ConfigRegistry.coerce(definition, True)
def test_int_rejects_fractional_float(self):
definition = self._definition(ConfigType.INT)
with pytest.raises(ValidationAppError):
ConfigRegistry.coerce(definition, 1.5)
def test_float(self):
definition = self._definition(ConfigType.FLOAT)
assert ConfigRegistry.coerce(definition, "1.5") == 1.5
def test_decimal(self):
definition = self._definition(ConfigType.DECIMAL)
assert ConfigRegistry.coerce(definition, "1.25") == decimal.Decimal("1.25")
def test_bool_accepts_real_bool(self):
definition = self._definition(ConfigType.BOOL)
assert ConfigRegistry.coerce(definition, False) is False
def test_bool_rejects_truthy_string(self):
definition = self._definition(ConfigType.BOOL)
with pytest.raises(ValidationAppError):
ConfigRegistry.coerce(definition, "false")
def test_json_passthrough(self):
definition = self._definition(ConfigType.JSON)
assert ConfigRegistry.coerce(definition, {"a": [1, 2]}) == {"a": [1, 2]}
def test_json_rejects_unserializable(self):
definition = self._definition(ConfigType.JSON)
with pytest.raises(ValidationAppError):
ConfigRegistry.coerce(definition, object())
def test_choice_accepts_member(self):
definition = self._definition(ConfigType.CHOICE, choices=("a", "b"))
assert ConfigRegistry.coerce(definition, "b") == "b"
def test_choice_rejects_non_member(self):
definition = self._definition(ConfigType.CHOICE, choices=("a", "b"))
with pytest.raises(ValidationAppError) as exc:
ConfigRegistry.coerce(definition, "c")
assert exc.value.code == INVALID_CODE
def test_duration_from_int(self):
definition = self._definition(ConfigType.DURATION)
assert ConfigRegistry.coerce(definition, 90) == 90
@pytest.mark.parametrize(
("raw", "expected"),
[("30s", 30), ("5m", 300), ("2h", 7200), ("1d", 86400), ("120", 120)],
)
def test_duration_from_string(self, raw, expected):
definition = self._definition(ConfigType.DURATION)
assert ConfigRegistry.coerce(definition, raw) == expected
def test_duration_rejects_garbage(self):
definition = self._definition(ConfigType.DURATION)
with pytest.raises(ValidationAppError):
ConfigRegistry.coerce(definition, "soon")
def test_custom_validator_passes(self):
definition = self._definition(ConfigType.INT, validator="tests.test_configs.test_registry._positive")
assert ConfigRegistry.coerce(definition, 2) == 2
def test_custom_validator_failure_is_typed(self):
definition = self._definition(ConfigType.INT, validator="tests.test_configs.test_registry._positive")
with pytest.raises(ValidationAppError) as exc:
ConfigRegistry.coerce(definition, -1)
assert exc.value.code == INVALID_CODE
assert exc.value.details[0]["field"] == "k"

View file

@ -0,0 +1,180 @@
"""``ConfigService`` precedence, typing, secrets, and caching."""
import decimal
import pytest
from infrasynth.configs.models import ConfigValue
from infrasynth.configs.registry import ConfigRegistry, ConfigType
from infrasynth.configs.services import ConfigService
from infrasynth.shared.exceptions import AuthError, NotFoundError, ValidationAppError
@pytest.fixture(autouse=True)
def clean_registry(clean_config_registry):
yield
@pytest.fixture
def service():
return ConfigService()
class TestPrecedence:
def test_registry_default(self, service, tenant):
ConfigRegistry.register("k", type=ConfigType.STRING, default="default")
assert service.get("k", tenant=tenant) == "default"
def test_global_overrides_default(self, service, tenant):
ConfigRegistry.register("k", type=ConfigType.STRING, default="default")
service.set_global("k", "global")
assert service.get("k", tenant=tenant) == "global"
def test_tenant_overrides_global(self, service, tenant):
ConfigRegistry.register("k", type=ConfigType.STRING, default="default")
service.set_global("k", "global")
service.set("k", "tenant", tenant=tenant)
assert service.get("k", tenant=tenant) == "tenant"
def test_reset_falls_back_to_global(self, service, tenant):
ConfigRegistry.register("k", type=ConfigType.STRING, default="default")
service.set_global("k", "global")
service.set("k", "tenant", tenant=tenant)
assert service.reset("k", tenant=tenant) is True
assert service.get("k", tenant=tenant) == "global"
assert service.is_overridden("k", tenant=tenant) is False
def test_fail_closed_without_tenant_reads_global_only(self, service, tenant):
from infrasynth.tenancy.context import tenant_context
ConfigRegistry.register("k", type=ConfigType.STRING, default="default")
service.set("k", "tenant-only", tenant=tenant)
with tenant_context(None):
assert service.get("k") == "default"
# A registered key resolves to its registry default, not the arg.
assert service.get("k", default="fallback") == "default"
class TestUnknownKeys:
def test_unknown_key_raises(self, service, tenant):
with pytest.raises(NotFoundError):
service.get("missing", tenant=tenant)
def test_default_argument_wins(self, service, tenant):
assert service.get("missing", tenant=tenant, default=42) == 42
def test_set_unknown_key_raises(self, service, tenant):
with pytest.raises(NotFoundError):
service.set("missing", "x", tenant=tenant)
def test_set_requires_tenant(self, service, tenant):
from infrasynth.tenancy.context import tenant_context
ConfigRegistry.register("k", default="d")
with tenant_context(None), pytest.raises(ValidationAppError):
service.set("k", "x", tenant=None)
class TestTypedRoundTrips:
@pytest.mark.parametrize(
("config_type", "value", "expected"),
[
(ConfigType.STRING, "hola", "hola"),
(ConfigType.INT, 7, 7),
(ConfigType.FLOAT, 1.5, 1.5),
(ConfigType.BOOL, True, True),
(ConfigType.JSON, {"a": [1]}, {"a": [1]}),
(ConfigType.DECIMAL, "3.50", decimal.Decimal("3.50")),
(ConfigType.DURATION, "15m", 900),
],
)
def test_round_trip(self, service, tenant, config_type, value, expected):
ConfigRegistry.register("k", type=config_type)
service.set("k", value, tenant=tenant)
result = service.get("k", tenant=tenant)
assert result == expected
assert isinstance(result, type(expected))
class TestSecrets:
def test_secret_is_encrypted_at_rest_and_decrypted_on_read(self, service, tenant):
ConfigRegistry.register("api.token", type=ConfigType.STRING, is_secret=True)
service.set("api.token", "hunter2", tenant=tenant)
row = ConfigValue.all_objects.get(tenant=tenant, key="api.token")
assert row.value != "hunter2"
assert "hunter2" not in str(row.value)
assert service.get("api.token", tenant=tenant) == "hunter2"
def test_secret_metadata_masks_default(self, service, tenant):
ConfigRegistry.register("api.token", type=ConfigType.STRING, default="d", is_secret=True)
meta = service.get_metadata("api.token", tenant=tenant)
assert meta["is_secret"] is True
assert meta["default"] is None
class TestCaching:
def test_second_read_is_cached_until_invalidated(self, service, tenant):
ConfigRegistry.register("k", type=ConfigType.INT, default=0)
service.set("k", 1, tenant=tenant)
assert service.get("k", tenant=tenant) == 1
# Bypass the service: the cache must still hold the old value.
ConfigValue.all_objects.filter(tenant=tenant, key="k").update(value=2)
assert service.get("k", tenant=tenant) == 1
service.invalidate(tenant, "k")
assert service.get("k", tenant=tenant) == 2
def test_write_busts_cache(self, service, tenant):
ConfigRegistry.register("k", type=ConfigType.INT, default=0)
service.set("k", 1, tenant=tenant)
assert service.get("k", tenant=tenant) == 1
service.set("k", 5, tenant=tenant)
assert service.get("k", tenant=tenant) == 5
def test_global_write_busts_global_fallback_cache(self, service, tenant):
ConfigRegistry.register("k", type=ConfigType.INT, default=0)
service.set_global("k", 1)
assert service.get("k", tenant=tenant) == 1
service.set_global("k", 2)
assert service.get("k", tenant=tenant) == 2
class TestGlobalWrites:
def test_allowed_by_default(self, service, tenant):
ConfigRegistry.register("k", default="d")
service.set_global("k", "global")
assert service.get("k", tenant=tenant) == "global"
def test_disabled_raises(self, service, tenant, settings):
ConfigRegistry.register("k", default="d")
settings.INFRASYNTH_CONFIGS = {**settings.INFRASYNTH_CONFIGS, "ALLOW_GLOBAL_WRITES": False}
with pytest.raises(AuthError):
service.set_global("k", "global")
class TestIntrospection:
def test_get_many_skips_unknown(self, service, tenant):
ConfigRegistry.register("a", type=ConfigType.INT, default=1)
ConfigRegistry.register("b", type=ConfigType.INT, default=2)
assert service.get_many(["a", "b", "missing"], tenant=tenant) == {"a": 1, "b": 2}
def test_get_all_filters_by_group(self, service, tenant):
ConfigRegistry.register("a", type=ConfigType.INT, default=1, group="branding")
ConfigRegistry.register("b", type=ConfigType.INT, default=2, group="limits")
assert service.get_all(tenant=tenant, group="branding") == {"a": 1}
def test_get_metadata(self, service, tenant):
ConfigRegistry.register("a", type=ConfigType.INT, default=1, group="branding", label="A")
meta = service.get_metadata("a", tenant=tenant)
assert meta["type"] == "int"
assert meta["group"] == "branding"
assert meta["label"] == "A"
assert meta["is_overridden"] is False
service.set("a", 9, tenant=tenant)
assert service.get_metadata("a", tenant=tenant)["is_overridden"] is True
def test_metadata_unknown_key_raises(self, service, tenant):
with pytest.raises(NotFoundError):
service.get_metadata("missing", tenant=tenant)

View file

@ -0,0 +1,90 @@
"""Public ``config_changed``/``config_reset`` signal contract."""
import pytest
from infrasynth.configs.registry import ConfigRegistry, ConfigType
from infrasynth.configs.services import ConfigService
from infrasynth.configs.signals import config_changed, config_reset
@pytest.fixture(autouse=True)
def clean_registry(clean_config_registry):
yield
@pytest.fixture
def service():
return ConfigService()
def _capture(signal):
received = []
receiver = lambda **kwargs: received.append(kwargs) # noqa: E731
signal.connect(receiver, weak=False)
return received, receiver
class TestConfigChanged:
def test_tenant_write_emits_with_all_kwargs(self, service, tenant):
ConfigRegistry.register("k", type=ConfigType.STRING, default="default")
received, receiver = _capture(config_changed)
try:
service.set("k", "new", tenant=tenant)
finally:
config_changed.disconnect(receiver)
assert len(received) == 1
payload = received[0]
assert payload["tenant_id"] == str(tenant.pk)
assert payload["key"] == "k"
assert payload["scope"] == "tenant"
assert payload["new_value"] == "new"
assert payload["old_value"] == "default"
def test_secret_values_are_masked(self, service, tenant):
ConfigRegistry.register("api.token", type=ConfigType.STRING, is_secret=True)
received, receiver = _capture(config_changed)
try:
service.set("api.token", "hunter2", tenant=tenant)
finally:
config_changed.disconnect(receiver)
payload = received[0]
assert payload["new_value"] is None
assert payload["old_value"] is None
def test_global_write_has_global_scope(self, service, tenant):
ConfigRegistry.register("k", default="default")
received, receiver = _capture(config_changed)
try:
service.set_global("k", "global")
finally:
config_changed.disconnect(receiver)
payload = received[0]
assert payload["tenant_id"] is None
assert payload["scope"] == "global"
assert payload["new_value"] == "global"
class TestConfigReset:
def test_reset_emits_previous_value(self, service, tenant):
ConfigRegistry.register("k", type=ConfigType.STRING, default="default")
service.set("k", "override", tenant=tenant)
received, receiver = _capture(config_reset)
try:
assert service.reset("k", tenant=tenant) is True
finally:
config_reset.disconnect(receiver)
assert len(received) == 1
payload = received[0]
assert payload["tenant_id"] == str(tenant.pk)
assert payload["key"] == "k"
assert payload["scope"] == "tenant"
assert payload["previous_value"] == "override"
def test_reset_without_override_does_not_emit(self, service, tenant):
ConfigRegistry.register("k", default="default")
received, receiver = _capture(config_reset)
try:
assert service.reset("k", tenant=tenant) is False
finally:
config_reset.disconnect(receiver)
assert received == []

View file

@ -0,0 +1,168 @@
"""Config API endpoints, permissions, and masking."""
import pytest
from rest_framework import status
from infrasynth.configs.models import ConfigValue
from infrasynth.configs.registry import ConfigRegistry, ConfigType
from infrasynth.security.models import Role
CONFIGS_URL = "/api/v1/configs/"
DEFINITIONS_URL = "/api/v1/configs/definitions/"
@pytest.fixture(autouse=True)
def clean_registry(clean_config_registry):
yield
@pytest.fixture(autouse=True)
def envelope_errors(settings):
settings.REST_FRAMEWORK = {
**settings.REST_FRAMEWORK,
"EXCEPTION_HANDLER": "infrasynth.api.exceptions.envelope_exception_handler",
}
def _values(response):
return {entry["key"]: entry["value"] for entry in response.json()["values"]}
def _grant_permissions(user, *codenames):
role = Role.objects.create(name="Config Manager", slug=f"cfg-mgr-{user.pk}", permissions=list(codenames))
role.users.add(user)
class TestConfigListView:
def test_lists_effective_values(self, authenticated_client):
ConfigRegistry.register("branding.color", type=ConfigType.STRING, default="#000")
response = authenticated_client.get(CONFIGS_URL)
assert response.status_code == status.HTTP_200_OK
assert _values(response)["branding.color"] == "#000"
def test_group_filter(self, authenticated_client):
ConfigRegistry.register("a", type=ConfigType.INT, default=1, group="branding")
ConfigRegistry.register("b", type=ConfigType.INT, default=2, group="limits")
response = authenticated_client.get(f"{CONFIGS_URL}?group=branding")
assert _values(response) == {"a": 1}
def test_keys_filter(self, authenticated_client):
ConfigRegistry.register("a", type=ConfigType.INT, default=1)
ConfigRegistry.register("b", type=ConfigType.INT, default=2)
response = authenticated_client.get(f"{CONFIGS_URL}?keys=a")
assert _values(response) == {"a": 1}
def test_secrets_masked(self, authenticated_client, tenant):
from infrasynth.configs.services import ConfigService
ConfigRegistry.register("api.token", type=ConfigType.STRING, is_secret=True)
ConfigService().set("api.token", "hunter2", tenant=tenant)
response = authenticated_client.get(CONFIGS_URL)
assert _values(response)["api.token"] is None
def test_requires_auth(self, api_client):
assert api_client.get(CONFIGS_URL).status_code == status.HTTP_401_UNAUTHORIZED
class TestConfigDetailView:
def test_get_returns_value_and_metadata(self, authenticated_client):
ConfigRegistry.register("a", type=ConfigType.INT, default=1, group="branding", label="A")
response = authenticated_client.get(f"{CONFIGS_URL}a/")
assert response.status_code == status.HTTP_200_OK
assert response.data["value"] == 1
assert response.data["type"] == "int"
assert response.data["is_overridden"] is False
def test_put_sets_tenant_override(self, authenticated_client, tenant):
ConfigRegistry.register("a", type=ConfigType.INT, default=1)
response = authenticated_client.put(f"{CONFIGS_URL}a/", {"value": 9}, format="json")
assert response.status_code == status.HTTP_200_OK
assert ConfigValue.all_objects.get(tenant=tenant, key="a").value == 9
assert response.data["is_overridden"] is True
def test_put_invalid_value_is_400(self, authenticated_client):
ConfigRegistry.register("a", type=ConfigType.INT, default=1)
response = authenticated_client.put(f"{CONFIGS_URL}a/", {"value": "nope"}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data["code"] == "VALIDATION_CONFIG_INVALID"
def test_delete_resets_override(self, authenticated_client, tenant):
ConfigRegistry.register("a", type=ConfigType.INT, default=1)
authenticated_client.put(f"{CONFIGS_URL}a/", {"value": 9}, format="json")
response = authenticated_client.delete(f"{CONFIGS_URL}a/")
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not ConfigValue.all_objects.filter(tenant=tenant, key="a").exists()
def test_unknown_key_is_404(self, authenticated_client):
assert authenticated_client.get(f"{CONFIGS_URL}missing/").status_code == status.HTTP_404_NOT_FOUND
assert (
authenticated_client.put(f"{CONFIGS_URL}missing/", {"value": 1}, format="json").status_code
== status.HTTP_404_NOT_FOUND
)
def test_secret_value_masked_on_get(self, authenticated_client, tenant):
from infrasynth.configs.services import ConfigService
ConfigRegistry.register("api.token", type=ConfigType.STRING, is_secret=True)
ConfigService().set("api.token", "hunter2", tenant=tenant)
response = authenticated_client.get(f"{CONFIGS_URL}api.token/")
assert response.data["value"] is None
def test_requires_auth(self, api_client):
ConfigRegistry.register("a", default=1)
assert api_client.get(f"{CONFIGS_URL}a/").status_code == status.HTTP_401_UNAUTHORIZED
class TestConfigPermissions:
def test_non_owner_without_permission_is_403(self, member_client):
ConfigRegistry.register("a", type=ConfigType.INT, default=1)
response = member_client.put(f"{CONFIGS_URL}a/", {"value": 2}, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_non_owner_with_permission_can_write(self, member_client, member_user):
ConfigRegistry.register("a", type=ConfigType.INT, default=1)
_grant_permissions(member_user, "configs.manage")
assert member_client.put(f"{CONFIGS_URL}a/", {"value": 2}, format="json").status_code == status.HTTP_200_OK
def test_owner_can_write(self, authenticated_client):
ConfigRegistry.register("a", type=ConfigType.INT, default=1)
response = authenticated_client.put(f"{CONFIGS_URL}a/", {"value": 2}, format="json")
assert response.status_code == status.HTTP_200_OK
def test_global_write_requires_manage_global(self, member_client, member_user):
ConfigRegistry.register("a", type=ConfigType.INT, default=1)
assert (
member_client.put(f"{CONFIGS_URL}global/a/", {"value": 2}, format="json").status_code
== status.HTTP_403_FORBIDDEN
)
_grant_permissions(member_user, "configs.manage_global")
assert (
member_client.put(f"{CONFIGS_URL}global/a/", {"value": 2}, format="json").status_code == status.HTTP_200_OK
)
def test_global_writes_can_be_disabled(self, authenticated_client, settings):
ConfigRegistry.register("a", type=ConfigType.INT, default=1)
settings.INFRASYNTH_CONFIGS = {**settings.INFRASYNTH_CONFIGS, "ALLOW_GLOBAL_WRITES": False}
response = authenticated_client.put(f"{CONFIGS_URL}global/a/", {"value": 2}, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data["code"] == "AUTH_CONFIG_GLOBAL_WRITES_DISABLED"
class TestDefinitionsView:
def test_lists_registered_schema(self, authenticated_client):
ConfigRegistry.register("a", type=ConfigType.INT, default=1, group="branding", label="A")
response = authenticated_client.get(DEFINITIONS_URL)
assert response.status_code == status.HTTP_200_OK
entry = response.json()["definitions"][0]
assert entry["key"] == "a"
assert entry["type"] == "int"
assert entry["default"] == 1
assert entry["group"] == "branding"
def test_secret_default_masked(self, authenticated_client):
ConfigRegistry.register("api.token", type=ConfigType.STRING, default="d", is_secret=True)
entry = authenticated_client.get(DEFINITIONS_URL).json()["definitions"][0]
assert entry["default"] is None
def test_requires_auth(self, api_client):
assert api_client.get(DEFINITIONS_URL).status_code == status.HTTP_401_UNAUTHORIZED

View file

@ -0,0 +1,101 @@
"""Feature flag/override mutation signals + cache invalidation (Part B1)."""
import pytest
from django.core.cache import cache
from infrasynth.features.models import FeatureFlag
from infrasynth.features.registry import FeatureRegistry
from infrasynth.features.services import FeatureService
from infrasynth.features.signals import (
flag_created,
flag_deleted,
flag_toggled,
override_created,
override_deleted,
)
FLAGS_URL = "/api/v1/features/"
OVERRIDES_URL = "/api/v1/features/overrides/"
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def clean_registry(clean_feature_registry):
cache.clear()
yield
cache.clear()
def _capture(signal):
received = []
receiver = lambda **kwargs: received.append(kwargs) # noqa: E731
signal.connect(receiver, weak=False)
return received, receiver
class TestFlagSignals:
def test_create_emits_flag_created(self, authenticated_client):
received, receiver = _capture(flag_created)
try:
response = authenticated_client.post(
FLAGS_URL, {"slug": "new-flag", "name": "New", "is_active": True}, format="json"
)
finally:
flag_created.disconnect(receiver)
assert response.status_code == 201
assert len(received) == 1
assert received[0]["flag_slug"] == "new-flag"
assert received[0]["is_active"] is True
assert received[0]["actor_id"] is not None
def test_toggle_emits_flag_toggled_only_on_change(self, authenticated_client):
flag = FeatureFlag.objects.create(slug="toggle.flag", name="Toggle", is_active=True)
received, receiver = _capture(flag_toggled)
try:
authenticated_client.patch(f"{FLAGS_URL}{flag.pk}/", {"name": "Renamed"}, format="json")
assert received == []
authenticated_client.patch(f"{FLAGS_URL}{flag.pk}/", {"is_active": False}, format="json")
finally:
flag_toggled.disconnect(receiver)
assert len(received) == 1
assert received[0]["flag_slug"] == "toggle.flag"
assert received[0]["is_active"] is False
assert received[0]["previous_is_active"] is True
def test_delete_emits_flag_deleted(self, authenticated_client):
flag = FeatureFlag.objects.create(slug="delete.flag", name="Delete")
received, receiver = _capture(flag_deleted)
try:
response = authenticated_client.delete(f"{FLAGS_URL}{flag.pk}/")
finally:
flag_deleted.disconnect(receiver)
assert response.status_code == 204
assert len(received) == 1
assert received[0]["flag_slug"] == "delete.flag"
def test_mutation_busts_cache(self, authenticated_client):
FeatureRegistry.register("cache-bust", default=False)
service = FeatureService()
assert service.is_enabled("cache-bust") is False
authenticated_client.post(FLAGS_URL, {"slug": "cache-bust", "name": "Cache", "is_active": True}, format="json")
assert service.is_enabled("cache-bust") is True
class TestOverrideSignals:
def test_create_and_delete_emit(self, authenticated_client, user):
flag = FeatureFlag.objects.create(slug="ov.flag", name="Override", is_active=True)
created, rec_create = _capture(override_created)
deleted, rec_delete = _capture(override_deleted)
try:
response = authenticated_client.post(
OVERRIDES_URL, {"flag": flag.pk, "user": user.pk, "is_enabled": True}, format="json"
)
override_id = response.json()["id"]
authenticated_client.delete(f"{OVERRIDES_URL}{override_id}/")
finally:
override_created.disconnect(rec_create)
override_deleted.disconnect(rec_delete)
assert created[0]["flag_slug"] == "ov.flag"
assert created[0]["user_id"] == user.pk
assert deleted[0]["flag_slug"] == "ov.flag"

View file

@ -0,0 +1,78 @@
"""Terminal ``TaskExecution`` signals fire exactly once (Part B2)."""
import pytest
from infrasynth.scheduler.models import ScheduledTask
from infrasynth.scheduler.services import TaskService
from infrasynth.scheduler.signals import task_completed, task_failed
pytestmark = pytest.mark.django_db
def _capture(signal):
received = []
receiver = lambda **kwargs: received.append(kwargs) # noqa: E731
signal.connect(receiver, weak=False)
return received, receiver
@pytest.fixture
def plain_task():
return ScheduledTask.objects.create(
name="plain",
task_path="os.getpid",
schedule_type=ScheduledTask.ScheduleType.MANUAL,
)
class TestTaskCompleted:
def test_completed_fires_once(self, plain_task):
received, receiver = _capture(task_completed)
try:
TaskService().run_now(plain_task.id)
finally:
task_completed.disconnect(receiver)
assert len(received) == 1
assert received[0]["task_name"] == "plain"
assert received[0]["tenant_id"] == str(plain_task.tenant_id)
assert received[0]["duration_ms"] is not None
def test_completed_does_not_emit_failed(self, plain_task):
received, receiver = _capture(task_failed)
try:
TaskService().run_now(plain_task.id)
finally:
task_failed.disconnect(receiver)
assert received == []
class TestTaskFailed:
def test_unimportable_fires_once(self):
task = ScheduledTask.objects.create(
name="broken",
task_path="does.not.exist",
schedule_type=ScheduledTask.ScheduleType.MANUAL,
)
received, receiver = _capture(task_failed)
try:
TaskService().run_now(task.id)
finally:
task_failed.disconnect(receiver)
assert len(received) == 1
assert received[0]["task_name"] == "broken"
assert "Could not import" in received[0]["error"]
def test_exception_fires_once(self):
task = ScheduledTask.objects.create(
name="boom",
task_path="math.sqrt",
schedule_type=ScheduledTask.ScheduleType.MANUAL,
args=["not-a-number"],
)
received, receiver = _capture(task_failed)
try:
TaskService().run_now(task.id)
finally:
task_failed.disconnect(receiver)
assert len(received) == 1
assert received[0]["task_name"] == "boom"

View file

@ -5,7 +5,7 @@ from django.utils import timezone
from rest_framework import status
from infrasynth.security.models import Grant, Revoke, Role
from infrasynth.security.permissions import HybridPermission, require_permission
from infrasynth.security.permissions import HybridPermission
from infrasynth.security.services import AuthorizationService
@ -15,8 +15,10 @@ def authz():
@pytest.fixture
def role(db):
return Role.objects.create(name="Editor", slug="editor", permissions=["content.edit", "content.view"])
def role(db, tenant):
return Role.objects.create(
tenant=tenant, name="Editor", slug="editor", permissions=["content.edit", "content.view"]
)
class TestPermissionResolutionChain:
@ -153,8 +155,9 @@ class TestSystemUserPermissions:
class _PermissionView:
def __init__(self, required_permissions=None):
def __init__(self, required_permissions=None, require_all=False):
self.required_permissions = required_permissions
self.require_all = require_all
class TestHybridPermission:
@ -187,19 +190,25 @@ class TestHybridPermission:
assert perm.has_permission(anon, _PermissionView(None)) is False
class TestRequirePermission:
class TestRequireAll:
def test_all_permissions_required(self, user):
Grant.objects.create(user=user, codename="perm.a")
Grant.objects.create(user=user, codename="perm.b")
request = type("R", (), {"user": user})()
perm_class = require_permission("perm.a", "perm.b")
assert perm_class().has_permission(request, _PermissionView()) is True
view = _PermissionView(["perm.a", "perm.b"], require_all=True)
assert HybridPermission().has_permission(request, view) is True
def test_missing_any_denied(self, member_user):
Grant.objects.create(user=member_user, codename="perm.a")
request = type("R", (), {"user": member_user})()
perm_class = require_permission("perm.a", "perm.b")
assert perm_class().has_permission(request, _PermissionView()) is False
view = _PermissionView(["perm.a", "perm.b"], require_all=True)
assert HybridPermission().has_permission(request, view) is False
def test_any_of_by_default(self, member_user):
Grant.objects.create(user=member_user, codename="perm.a")
request = type("R", (), {"user": member_user})()
view = _PermissionView(["perm.a", "perm.b"])
assert HybridPermission().has_permission(request, view) is True
class TestRoleViewSet:

View file

@ -0,0 +1,272 @@
"""Automatic permission management: catalog, registry, auto-enforcement,
role assignments, and global grants/revokes.
"""
import pytest
from django.conf import settings
from django.test import override_settings
from django.urls import include, path
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.routers import DefaultRouter
from infrasynth.security.catalog import build_catalog, permission_for, sync_permissions
from infrasynth.security.models import Grant, Permission, Revoke, Role, RoleAssignment
from infrasynth.security.permissions import AutoPermission, automatic_permissions
from infrasynth.security.registry import PermissionRegistry
from infrasynth.security.serializers import RoleSerializer
from infrasynth.security.services import AuthorizationService
from infrasynth.security.viewsets import InfraSynthModelViewSet
from infrasynth.tenancy.context import tenant_context
from infrasynth.tenancy.models import Tenant
def sec_settings(**overrides):
return {**settings.INFRASYNTH_SECURITY, **overrides}
@pytest.fixture
def clean_permission_registry():
snapshot = dict(PermissionRegistry._permissions)
yield
PermissionRegistry._permissions = snapshot
# --- an end-to-end consumer of the kit base viewset --------------------------
class _RoleViewSet(InfraSynthModelViewSet):
from infrasynth.security.models import Role as _Role
queryset = _Role.objects.all()
serializer_class = RoleSerializer
action_permissions = {"custom": "custom.role_action"}
@action(detail=False, methods=["get"], url_path="custom")
def custom(self, request):
return Response({"ok": True})
router = DefaultRouter()
router.register("roles", _RoleViewSet, basename="auto-test-roles")
urlpatterns = [path("auto/", include(router.urls))]
AUTO_URL = "/auto/roles/"
class TestPermissionDerivation:
def test_permission_for_drf_actions(self, db):
assert permission_for(Role, "list") == "infrasynth_security.view_role"
assert permission_for(Role, "retrieve") == "infrasynth_security.view_role"
assert permission_for(Role, "create") == "infrasynth_security.add_role"
assert permission_for(Role, "update") == "infrasynth_security.change_role"
assert permission_for(Role, "partial_update") == "infrasynth_security.change_role"
assert permission_for(Role, "destroy") == "infrasynth_security.delete_role"
def test_automatic_permissions_priority(self, db):
view = _RoleViewSet()
view.action = "list"
assert automatic_permissions(view) == ["infrasynth_security.view_role"]
view.action = "custom"
assert automatic_permissions(view) == ["custom.role_action"]
view.required_permissions = ["explicit.perm"]
assert automatic_permissions(view) == ["explicit.perm"]
class TestCatalog:
def test_build_includes_model_and_custom(self, db, clean_permission_registry):
PermissionRegistry.register("helpdesk.resolve_ticket", name="Resolve", group="Helpdesk")
catalog = build_catalog()
assert "infrasynth_security.view_role" in catalog
assert catalog["infrasynth_security.view_role"].is_custom is False
assert "helpdesk.resolve_ticket" in catalog
assert catalog["helpdesk.resolve_ticket"].is_custom is True
def test_kit_custom_permissions_registered(self, db):
catalog = build_catalog()
for codename in ("configs.manage", "platform.tenants.delete", "audit.view_api_logs"):
assert codename in catalog
def test_sync_is_idempotent_and_deactivates_missing(self, db, clean_permission_registry):
first = sync_permissions()
assert first["total"] > 0
second = sync_permissions()
assert second["created"] == 0
assert second["updated"] == 0
# A stale entry is deactivated, not deleted.
Permission.objects.create(codename="stale.perm", name="Stale", app_label="stale", is_custom=True)
summary = sync_permissions()
assert summary["deactivated"] == 1
stale = Permission.objects.get(codename="stale.perm")
assert stale.is_active is False
# Re-registering reactivates.
PermissionRegistry.register("stale.perm", name="Stale")
summary = sync_permissions()
assert summary["reactivated"] == 1
assert Permission.objects.get(codename="stale.perm").is_active is True
def test_sync_command_runs(self, db):
from django.core.management import call_command
call_command("sync_permissions")
class TestAutoPermissionIntegration:
@override_settings(ROOT_URLCONF="tests.test_security.test_permissions")
def test_owner_bypasses(self, authenticated_client, db):
assert authenticated_client.get(AUTO_URL).status_code == status.HTTP_200_OK
@override_settings(ROOT_URLCONF="tests.test_security.test_permissions")
def test_member_denied_without_permission(self, member_client, db):
assert member_client.get(AUTO_URL).status_code == status.HTTP_403_FORBIDDEN
@override_settings(ROOT_URLCONF="tests.test_security.test_permissions")
def test_member_allowed_with_grant(self, member_client, member_user, db):
Grant.objects.create(user=member_user, codename="infrasynth_security.view_role")
assert member_client.get(AUTO_URL).status_code == status.HTTP_200_OK
@override_settings(ROOT_URLCONF="tests.test_security.test_permissions")
def test_member_allowed_with_tenant_role_assignment(self, member_client, member_user, tenant, db):
role = Role.objects.create(
tenant=tenant, name="Viewer", slug="viewer", permissions=["infrasynth_security.view_role"]
)
RoleAssignment.objects.create(tenant=tenant, user=member_user, role=role)
assert member_client.get(AUTO_URL).status_code == status.HTTP_200_OK
@override_settings(ROOT_URLCONF="tests.test_security.test_permissions")
def test_custom_action_codename(self, member_client, member_user, db):
assert member_client.get(f"{AUTO_URL}custom/").status_code == status.HTTP_403_FORBIDDEN
Grant.objects.create(user=member_user, codename="custom.role_action")
assert member_client.get(f"{AUTO_URL}custom/").status_code == status.HTTP_200_OK
@override_settings(ROOT_URLCONF="tests.test_security.test_permissions")
def test_create_requires_add_permission(self, member_client, member_user, db):
response = member_client.post(AUTO_URL, {"name": "New", "slug": "new-role", "permissions": []}, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
Grant.objects.create(user=member_user, codename="infrasynth_security.add_role")
response = member_client.post(AUTO_URL, {"name": "New", "slug": "new-role", "permissions": []}, format="json")
assert response.status_code == status.HTTP_201_CREATED
@override_settings(ROOT_URLCONF="tests.test_security.test_permissions")
def test_off_mode_abstains(self, member_client, db):
with override_settings(INFRASYNTH_SECURITY=sec_settings(AUTO_PERMISSIONS="off")):
assert member_client.get(AUTO_URL).status_code == status.HTTP_200_OK
def test_auto_permission_abstains_without_model(self, db, user):
class _Plain:
action = "list"
request = type("R", (), {"user": user})()
assert AutoPermission().has_permission(request, _Plain()) is True
class TestGlobalRolesAndOverrides:
def test_global_role_applies_in_every_tenant(self, user, tenant, db):
other = Tenant.objects.create(slug="other-global", name="Other")
global_role = Role.objects.create(name="Global", slug="global", permissions=["x.perm"])
global_role.users.add(user)
authz = AuthorizationService()
with tenant_context(tenant):
assert authz.has_permission(user, "x.perm") is True
with tenant_context(other):
assert authz.has_permission(user, "x.perm") is True
def test_role_assignment_is_tenant_scoped(self, user, tenant, db):
other = Tenant.objects.create(slug="other-role", name="Other")
role = Role.objects.create(tenant=tenant, name="Scoped", slug="scoped", permissions=["y.perm"])
RoleAssignment.objects.create(tenant=tenant, user=user, role=role)
authz = AuthorizationService()
with tenant_context(tenant):
assert authz.has_permission(user, "y.perm") is True
with tenant_context(other):
assert authz.has_permission(user, "y.perm") is False
def test_global_grant_applies_everywhere(self, user, tenant, db):
other = Tenant.objects.create(slug="other-grant", name="Other")
Grant(user=user, codename="z.perm").save(force_global=True)
authz = AuthorizationService()
with tenant_context(tenant):
assert authz.has_permission(user, "z.perm") is True
with tenant_context(other):
assert authz.has_permission(user, "z.perm") is True
def test_global_revoke_blocks_everywhere(self, user, tenant, db):
other = Tenant.objects.create(slug="other-revoke", name="Other")
role = Role.objects.create(name="R", slug="r", permissions=["z.perm"])
role.users.add(user)
Revoke(user=user, codename="z.perm").save(force_global=True)
authz = AuthorizationService()
with tenant_context(tenant):
assert authz.has_permission(user, "z.perm") is False
with tenant_context(other):
assert authz.has_permission(user, "z.perm") is False
def test_context_created_grant_stays_tenant_scoped(self, user, tenant, db):
Grant.objects.create(user=user, codename="t.perm")
assert Grant.objects.get(codename="t.perm").tenant_id == tenant.pk
class TestRoleAndGrantApi:
def test_tenant_role_created_in_tenant(self, authenticated_client, tenant, db):
response = authenticated_client.post(
"/api/v1/auth/roles/", {"name": "Tenant Role", "slug": "tenant-role", "permissions": []}, format="json"
)
assert response.status_code == status.HTTP_201_CREATED
assert Role.objects.get(slug="tenant-role").tenant_id == tenant.pk
def test_global_role_requires_platform_permission(self, member_client, member_user, db):
response = member_client.post(
"/api/v1/auth/roles/", {"name": "Global", "slug": "global-role", "permissions": []}, format="json"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_strict_role_validation(self, authenticated_client, db):
with override_settings(INFRASYNTH_SECURITY=sec_settings(STRICT_PERMISSION_VALIDATION=True)):
response = authenticated_client.post(
"/api/v1/auth/roles/",
{"name": "Bad", "slug": "bad-role", "permissions": ["does.not.exist"]},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_global_grant_scope_requires_platform(self, authenticated_client, user, db):
response = authenticated_client.post(
"/api/v1/auth/grants/",
{"user": user.pk, "codename": "p.perm", "scope": "global"},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_global_grant_scope_allowed_with_platform_role(self, member_client, member_user, db):
role = Role.objects.create(
name="Platform", slug="platform", permissions=["security.manage_grants", "platform.roles.manage"]
)
role.users.add(member_user)
response = member_client.post(
"/api/v1/auth/grants/",
{"user": member_user.pk, "codename": "p.perm", "scope": "global"},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert Grant.all_objects.get(codename="p.perm").tenant_id is None
class TestPermissionApi:
def test_catalog_endpoint(self, authenticated_client, db):
response = authenticated_client.get("/api/v1/auth/permissions/")
assert response.status_code == status.HTTP_200_OK
codenames = {entry["codename"] for entry in response.json()["results"]}
assert "configs.manage" in codenames
def test_catalog_requires_permission(self, member_client, db):
assert member_client.get("/api/v1/auth/permissions/").status_code == status.HTTP_403_FORBIDDEN
def test_catalog_filter_by_app(self, authenticated_client, db):
response = authenticated_client.get("/api/v1/auth/permissions/?app_label=configs")
assert response.status_code == status.HTTP_200_OK
assert all(entry["app_label"] == "configs" for entry in response.json()["results"])

View file

@ -0,0 +1,61 @@
"""``tenant_updated`` is emitted from the tenant edit path (Part B3)."""
import pytest
from infrasynth.tenancy.services import TenantService
from infrasynth.tenancy.signals import tenant_updated
pytestmark = pytest.mark.django_db
TENANTS_URL = "/api/v1/tenancy/tenants/"
def _capture(signal):
received = []
receiver = lambda **kwargs: received.append(kwargs) # noqa: E731
signal.connect(receiver, weak=False)
return received, receiver
class TestTenantUpdatedSignal:
def test_patch_emits_with_changed_fields(self, authenticated_client, tenant):
received, receiver = _capture(tenant_updated)
try:
response = authenticated_client.patch(
f"{TENANTS_URL}{tenant.pk}/", {"name": "Renamed", "locale": "en"}, format="json"
)
finally:
tenant_updated.disconnect(receiver)
assert response.status_code == 200
assert len(received) == 1
assert received[0]["tenant_id"] == str(tenant.pk)
assert set(received[0]["changes"]) == {"name", "locale"}
tenant.refresh_from_db()
assert tenant.name == "Renamed"
def test_no_signal_when_nothing_changes(self, authenticated_client, tenant):
received, receiver = _capture(tenant_updated)
try:
response = authenticated_client.patch(f"{TENANTS_URL}{tenant.pk}/", {}, format="json")
finally:
tenant_updated.disconnect(receiver)
assert response.status_code == 200
assert received == []
def test_service_update_emits(self, tenant):
received, receiver = _capture(tenant_updated)
try:
TenantService().update_tenant(tenant, timezone="Europe/Madrid")
finally:
tenant_updated.disconnect(receiver)
assert received[0]["changes"] == {"timezone": "Europe/Madrid"}
def test_service_update_ignores_disallowed_fields(self, tenant):
received, receiver = _capture(tenant_updated)
try:
TenantService().update_tenant(tenant, status="archived")
finally:
tenant_updated.disconnect(receiver)
assert received == []
tenant.refresh_from_db()
assert tenant.status == "active"