From a2930426b40445a1a941845d03c540258a53d68f Mon Sep 17 00:00:00 2001 From: jcv-dev Date: Tue, 29 Sep 2026 17:06:54 -0500 Subject: [PATCH] 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) --- .forgejo/workflows/publish.yml | 49 +++ AGENTS.md | 22 +- CHANGELOG.md | 46 +- PLAN.md | 313 +++++++++++-- README.md | 124 +++++- config/settings/base.py | 21 + config/settings/test.py | 1 + config/urls.py | 1 + infrasynth/audit/receivers.py | 45 +- infrasynth/billing/views.py | 13 +- infrasynth/configs/__init__.py | 49 +++ infrasynth/configs/admin.py | 11 + infrasynth/configs/apps.py | 28 ++ infrasynth/configs/migrations/0001_initial.py | 56 +++ infrasynth/configs/migrations/__init__.py | 0 infrasynth/configs/models.py | 49 +++ infrasynth/configs/registry.py | 221 ++++++++++ infrasynth/configs/serializers.py | 28 ++ infrasynth/configs/services.py | 410 ++++++++++++++++++ infrasynth/configs/signals.py | 23 + infrasynth/configs/urls.py | 12 + infrasynth/configs/views.py | 148 +++++++ infrasynth/features/services.py | 10 + infrasynth/features/urls.py | 4 +- infrasynth/features/views.py | 75 +++- infrasynth/files/views.py | 9 +- infrasynth/notifications/views.py | 9 +- infrasynth/scheduler/apps.py | 2 + infrasynth/scheduler/receivers.py | 65 +++ infrasynth/scheduler/services.py | 17 +- infrasynth/scheduler/views.py | 9 +- infrasynth/security/__init__.py | 14 +- infrasynth/security/apps.py | 59 +++ infrasynth/security/catalog.py | 174 ++++++++ infrasynth/security/management/__init__.py | 0 .../security/management/commands/__init__.py | 0 .../management/commands/sync_permissions.py | 35 ++ ...eassignment_alter_grant_tenant_and_more.py | 128 ++++++ infrasynth/security/models.py | 89 +++- infrasynth/security/permissions.py | 124 +++++- infrasynth/security/registry.py | 71 +++ infrasynth/security/serializers.py | 81 +++- infrasynth/security/services.py | 10 +- infrasynth/security/urls.py | 2 + infrasynth/security/views.py | 76 +++- infrasynth/security/viewsets.py | 44 ++ infrasynth/tenancy/mixins.py | 22 +- infrasynth/tenancy/services.py | 22 + infrasynth/tenancy/views.py | 15 +- infrasynth/webhooks/views.py | 13 +- infrasynth/workflows/views.py | 15 +- pyproject.toml | 5 + tests/conftest.py | 11 + tests/test_audit/test_receivers.py | 73 +++- tests/test_configs/__init__.py | 0 tests/test_configs/test_isolation.py | 76 ++++ tests/test_configs/test_registry.py | 135 ++++++ tests/test_configs/test_services.py | 180 ++++++++ tests/test_configs/test_signals.py | 90 ++++ tests/test_configs/test_views.py | 168 +++++++ tests/test_features/test_signals.py | 101 +++++ tests/test_scheduler/test_signals.py | 78 ++++ tests/test_security/test_authorization.py | 27 +- tests/test_security/test_permissions.py | 272 ++++++++++++ tests/test_tenancy/test_signals.py | 61 +++ 65 files changed, 3992 insertions(+), 149 deletions(-) create mode 100644 .forgejo/workflows/publish.yml create mode 100644 infrasynth/configs/__init__.py create mode 100644 infrasynth/configs/admin.py create mode 100644 infrasynth/configs/apps.py create mode 100644 infrasynth/configs/migrations/0001_initial.py create mode 100644 infrasynth/configs/migrations/__init__.py create mode 100644 infrasynth/configs/models.py create mode 100644 infrasynth/configs/registry.py create mode 100644 infrasynth/configs/serializers.py create mode 100644 infrasynth/configs/services.py create mode 100644 infrasynth/configs/signals.py create mode 100644 infrasynth/configs/urls.py create mode 100644 infrasynth/configs/views.py create mode 100644 infrasynth/scheduler/receivers.py create mode 100644 infrasynth/security/catalog.py create mode 100644 infrasynth/security/management/__init__.py create mode 100644 infrasynth/security/management/commands/__init__.py create mode 100644 infrasynth/security/management/commands/sync_permissions.py create mode 100644 infrasynth/security/migrations/0002_permission_roleassignment_alter_grant_tenant_and_more.py create mode 100644 infrasynth/security/registry.py create mode 100644 infrasynth/security/viewsets.py create mode 100644 tests/test_configs/__init__.py create mode 100644 tests/test_configs/test_isolation.py create mode 100644 tests/test_configs/test_registry.py create mode 100644 tests/test_configs/test_services.py create mode 100644 tests/test_configs/test_signals.py create mode 100644 tests/test_configs/test_views.py create mode 100644 tests/test_features/test_signals.py create mode 100644 tests/test_scheduler/test_signals.py create mode 100644 tests/test_security/test_permissions.py create mode 100644 tests/test_tenancy/test_signals.py diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml new file mode 100644 index 0000000..408de77 --- /dev/null +++ b/.forgejo/workflows/publish.yml @@ -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//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/* diff --git a/AGENTS.md b/AGENTS.md index cd1fb4a..c1603d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d65e23..395c7c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//`), 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//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 diff --git a/PLAN.md b/PLAN.md index 0c5e875..539b09c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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//permissions` y `/roles` +- [x] Permisos cableados: `HybridPermission` en security y audit, bypass de owner del tenant, rotación de API keys, endpoints `users//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//`) 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//` | GET | autenticado | Valor efectivo + metadata (`type`, `is_secret`, `is_overridden`, `default`) | +| `/configs//` | PUT | `configs.manage` | Set del override del tenant (coercido/validado) | +| `/configs//` | DELETE | `configs.manage` | Reset del override al global/default | +| `/configs/definitions/` | GET | autenticado | Esquema de claves registradas (para formularios) | +| `/configs/global//` | 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//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 | diff --git a/README.md b/README.md index b7acd7a..b0746cc 100644 --- a/README.md +++ b/README.md @@ -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//`, `GET /api/v1/configs/definitions/`, + and `PUT /api/v1/configs/global//`. 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//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: ::` 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` | diff --git a/config/settings/base.py b/config/settings/base.py index 49dc756..e623e50 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -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" diff --git a/config/settings/test.py b/config/settings/test.py index f86b214..e4bd161 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -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, diff --git a/config/urls.py b/config/urls.py index 2894071..ea3e827 100644 --- a/config/urls.py +++ b/config/urls.py @@ -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")), ] diff --git a/infrasynth/audit/receivers.py b/infrasynth/audit/receivers.py index 82c4001..abd1576 100644 --- a/infrasynth/audit/receivers.py +++ b/infrasynth/audit/receivers.py @@ -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: diff --git a/infrasynth/billing/views.py b/infrasynth/billing/views.py index 8b2c102..b94ea81 100644 --- a/infrasynth/billing/views.py +++ b/infrasynth/billing/views.py @@ -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): diff --git a/infrasynth/configs/__init__.py b/infrasynth/configs/__init__.py new file mode 100644 index 0000000..45da435 --- /dev/null +++ b/infrasynth/configs/__init__.py @@ -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..`` 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())) diff --git a/infrasynth/configs/admin.py b/infrasynth/configs/admin.py new file mode 100644 index 0000000..348c0f4 --- /dev/null +++ b/infrasynth/configs/admin.py @@ -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",) diff --git a/infrasynth/configs/apps.py b/infrasynth/configs/apps.py new file mode 100644 index 0000000..6fbd3c0 --- /dev/null +++ b/infrasynth/configs/apps.py @@ -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) diff --git a/infrasynth/configs/migrations/0001_initial.py b/infrasynth/configs/migrations/0001_initial.py new file mode 100644 index 0000000..e989124 --- /dev/null +++ b/infrasynth/configs/migrations/0001_initial.py @@ -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" + ), + ], + }, + ), + ] diff --git a/infrasynth/configs/migrations/__init__.py b/infrasynth/configs/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/configs/models.py b/infrasynth/configs/models.py new file mode 100644 index 0000000..01a4139 --- /dev/null +++ b/infrasynth/configs/models.py @@ -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}" diff --git a/infrasynth/configs/registry.py b/infrasynth/configs/registry.py new file mode 100644 index 0000000..5235f48 --- /dev/null +++ b/infrasynth/configs/registry.py @@ -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\d+(?:\.\d+)?)\s*(?P[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]) diff --git a/infrasynth/configs/serializers.py b/infrasynth/configs/serializers.py new file mode 100644 index 0000000..6a909da --- /dev/null +++ b/infrasynth/configs/serializers.py @@ -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] diff --git a/infrasynth/configs/services.py b/infrasynth/configs/services.py new file mode 100644 index 0000000..d919e4a --- /dev/null +++ b/infrasynth/configs/services.py @@ -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()) diff --git a/infrasynth/configs/signals.py b/infrasynth/configs/signals.py new file mode 100644 index 0000000..3dec54e --- /dev/null +++ b/infrasynth/configs/signals.py @@ -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() diff --git a/infrasynth/configs/urls.py b/infrasynth/configs/urls.py new file mode 100644 index 0000000..fb5dbcb --- /dev/null +++ b/infrasynth/configs/urls.py @@ -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//", ConfigGlobalDetailView.as_view(), name="global-detail"), + path("/", ConfigDetailView.as_view(), name="detail"), +] diff --git a/infrasynth/configs/views.py b/infrasynth/configs/views.py new file mode 100644 index 0000000..6d8abd9 --- /dev/null +++ b/infrasynth/configs/views.py @@ -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))) diff --git a/infrasynth/features/services.py b/infrasynth/features/services.py index 84aa641..2b0ac92 100644 --- a/infrasynth/features/services.py +++ b/infrasynth/features/services.py @@ -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}") diff --git a/infrasynth/features/urls.py b/infrasynth/features/urls.py index adeeb60..95bd46e 100644 --- a/infrasynth/features/urls.py +++ b/infrasynth/features/urls.py @@ -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[^/.]+)/$`` shadows ``overrides/``. router.register(r"overrides", FeatureFlagOverrideViewSet, basename="feature-overrides") +router.register(r"", FeatureFlagViewSet, basename="feature-flags") urlpatterns = [ path("", include(router.urls)), diff --git a/infrasynth/features/views.py b/infrasynth/features/views.py index 0c4bd57..f49041b 100644 --- a/infrasynth/features/views.py +++ b/infrasynth/features/views.py @@ -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, + ) diff --git a/infrasynth/files/views.py b/infrasynth/files/views.py index edbbe41..5f32bba 100644 --- a/infrasynth/files/views.py +++ b/infrasynth/files/views.py @@ -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 diff --git a/infrasynth/notifications/views.py b/infrasynth/notifications/views.py index 5d57c8b..ab848e6 100644 --- a/infrasynth/notifications/views.py +++ b/infrasynth/notifications/views.py @@ -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): diff --git a/infrasynth/scheduler/apps.py b/infrasynth/scheduler/apps.py index 61fb73c..55481da 100644 --- a/infrasynth/scheduler/apps.py +++ b/infrasynth/scheduler/apps.py @@ -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", diff --git a/infrasynth/scheduler/receivers.py b/infrasynth/scheduler/receivers.py new file mode 100644 index 0000000..9ddffb8 --- /dev/null +++ b/infrasynth/scheduler/receivers.py @@ -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) diff --git a/infrasynth/scheduler/services.py b/infrasynth/scheduler/services.py index 64dac6f..12cd088 100644 --- a/infrasynth/scheduler/services.py +++ b/infrasynth/scheduler/services.py @@ -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) diff --git a/infrasynth/scheduler/views.py b/infrasynth/scheduler/views.py index b6b3492..5b4568a 100644 --- a/infrasynth/scheduler/views.py +++ b/infrasynth/scheduler/views.py @@ -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 diff --git a/infrasynth/security/__init__.py b/infrasynth/security/__init__.py index 8a93d0c..5d0816c 100644 --- a/infrasynth/security/__init__.py +++ b/infrasynth/security/__init__.py @@ -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", } diff --git a/infrasynth/security/apps.py b/infrasynth/security/apps.py index c63c82f..07bfdc7 100644 --- a/infrasynth/security/apps.py +++ b/infrasynth/security/apps.py @@ -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", + ) diff --git a/infrasynth/security/catalog.py b/infrasynth/security/catalog.py new file mode 100644 index 0000000..1c7f78c --- /dev/null +++ b/infrasynth/security/catalog.py @@ -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), + } diff --git a/infrasynth/security/management/__init__.py b/infrasynth/security/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/security/management/commands/__init__.py b/infrasynth/security/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/security/management/commands/sync_permissions.py b/infrasynth/security/management/commands/sync_permissions.py new file mode 100644 index 0000000..e165a68 --- /dev/null +++ b/infrasynth/security/management/commands/sync_permissions.py @@ -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)." + ) + ) diff --git a/infrasynth/security/migrations/0002_permission_roleassignment_alter_grant_tenant_and_more.py b/infrasynth/security/migrations/0002_permission_roleassignment_alter_grant_tenant_and_more.py new file mode 100644 index 0000000..599244b --- /dev/null +++ b/infrasynth/security/migrations/0002_permission_roleassignment_alter_grant_tenant_and_more.py @@ -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"), + ), + ] diff --git a/infrasynth/security/models.py b/infrasynth/security/models.py index 5395ee2..4d6052f 100644 --- a/infrasynth/security/models.py +++ b/infrasynth/security/models.py @@ -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"), ] diff --git a/infrasynth/security/permissions.py b/infrasynth/security/permissions.py index 566d9ad..fe61ead 100644 --- a/infrasynth/security/permissions.py +++ b/infrasynth/security/permissions.py @@ -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) diff --git a/infrasynth/security/registry.py b/infrasynth/security/registry.py new file mode 100644 index 0000000..9139bf0 --- /dev/null +++ b/infrasynth/security/registry.py @@ -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() diff --git a/infrasynth/security/serializers.py b/infrasynth/security/serializers.py index b7d39ac..b49c17c 100644 --- a/infrasynth/security/serializers.py +++ b/infrasynth/security/serializers.py @@ -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) diff --git a/infrasynth/security/services.py b/infrasynth/security/services.py index 15bf0d2..ac4f7f2 100644 --- a/infrasynth/security/services.py +++ b/infrasynth/security/services.py @@ -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 ) diff --git a/infrasynth/security/urls.py b/infrasynth/security/urls.py index 2d36928..3c4fdd8 100644 --- a/infrasynth/security/urls.py +++ b/infrasynth/security/urls.py @@ -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") diff --git a/infrasynth/security/views.py b/infrasynth/security/views.py index 7047121..dced8c2 100644 --- a/infrasynth/security/views.py +++ b/infrasynth/security/views.py @@ -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))}) diff --git a/infrasynth/security/viewsets.py b/infrasynth/security/viewsets.py new file mode 100644 index 0000000..8adc70c --- /dev/null +++ b/infrasynth/security/viewsets.py @@ -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] diff --git a/infrasynth/tenancy/mixins.py b/infrasynth/tenancy/mixins.py index 1390c67..7e2a988 100644 --- a/infrasynth/tenancy/mixins.py +++ b/infrasynth/tenancy/mixins.py @@ -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) diff --git a/infrasynth/tenancy/services.py b/infrasynth/tenancy/services.py index 7c64ed6..b4e3e0d 100644 --- a/infrasynth/tenancy/services.py +++ b/infrasynth/tenancy/services.py @@ -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 diff --git a/infrasynth/tenancy/views.py b/infrasynth/tenancy/views.py index d1aa0df..ea370f6 100644 --- a/infrasynth/tenancy/views.py +++ b/infrasynth/tenancy/views.py @@ -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") diff --git a/infrasynth/webhooks/views.py b/infrasynth/webhooks/views.py index d91d0d0..338c852 100644 --- a/infrasynth/webhooks/views.py +++ b/infrasynth/webhooks/views.py @@ -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): diff --git a/infrasynth/workflows/views.py b/infrasynth/workflows/views.py index 143b558..5568794 100644 --- a/infrasynth/workflows/views.py +++ b/infrasynth/workflows/views.py @@ -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): diff --git a/pyproject.toml b/pyproject.toml index 28e90f6..cbdabb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/conftest.py b/tests/conftest.py index c4aa172..2834bdd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_audit/test_receivers.py b/tests/test_audit/test_receivers.py index 528b6fe..9d24741 100644 --- a/tests/test_audit/test_receivers.py +++ b/tests/test_audit/test_receivers.py @@ -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) diff --git a/tests/test_configs/__init__.py b/tests/test_configs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_configs/test_isolation.py b/tests/test_configs/test_isolation.py new file mode 100644 index 0000000..83ce2b9 --- /dev/null +++ b/tests/test_configs/test_isolation.py @@ -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" diff --git a/tests/test_configs/test_registry.py b/tests/test_configs/test_registry.py new file mode 100644 index 0000000..e6010fa --- /dev/null +++ b/tests/test_configs/test_registry.py @@ -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" diff --git a/tests/test_configs/test_services.py b/tests/test_configs/test_services.py new file mode 100644 index 0000000..9a39bec --- /dev/null +++ b/tests/test_configs/test_services.py @@ -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) diff --git a/tests/test_configs/test_signals.py b/tests/test_configs/test_signals.py new file mode 100644 index 0000000..57ef3bd --- /dev/null +++ b/tests/test_configs/test_signals.py @@ -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 == [] diff --git a/tests/test_configs/test_views.py b/tests/test_configs/test_views.py new file mode 100644 index 0000000..becdc5f --- /dev/null +++ b/tests/test_configs/test_views.py @@ -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 diff --git a/tests/test_features/test_signals.py b/tests/test_features/test_signals.py new file mode 100644 index 0000000..f08f190 --- /dev/null +++ b/tests/test_features/test_signals.py @@ -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" diff --git a/tests/test_scheduler/test_signals.py b/tests/test_scheduler/test_signals.py new file mode 100644 index 0000000..451a179 --- /dev/null +++ b/tests/test_scheduler/test_signals.py @@ -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" diff --git a/tests/test_security/test_authorization.py b/tests/test_security/test_authorization.py index 6625a96..e2e3d8e 100644 --- a/tests/test_security/test_authorization.py +++ b/tests/test_security/test_authorization.py @@ -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: diff --git a/tests/test_security/test_permissions.py b/tests/test_security/test_permissions.py new file mode 100644 index 0000000..2acc1e1 --- /dev/null +++ b/tests/test_security/test_permissions.py @@ -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"]) diff --git a/tests/test_tenancy/test_signals.py b/tests/test_tenancy/test_signals.py new file mode 100644 index 0000000..e401c52 --- /dev/null +++ b/tests/test_tenancy/test_signals.py @@ -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"