feat: uniform extensibility across every module

Close the remaining places where an extension point was hardcoded, and
expose a stable public import surface for every app.

- files: register_storage_backend(...) / STORAGE_BACKENDS[name]["CLASS"];
  unknown backends now fail loudly instead of silently using local
- files: PipelineStepRegistry + @pipeline_step; PIPELINE_EXECUTOR setting
- billing: INVOICE_PDF_BUILDER setting
- security: TWO_FACTOR_SERVICE / TWO_FACTOR_RECOVERY_SERVICE settings
- all app packages expose lazy public exports (PEP 562); infrasynth.shared
  re-exports its primitives eagerly
- README documents the per-module extension-point table
- tests/test_extensibility.py pins each hook plus the public surface
This commit is contained in:
jcv-dev 2026-09-24 11:08:08 -05:00
parent 21731b9887
commit 5df0be1f5c
22 changed files with 1002 additions and 20 deletions

View file

@ -10,6 +10,18 @@ not hand-pick a version (see `../AGENTS.backend-packages.md` §8).
## [Unreleased] ## [Unreleased]
### Added ### Added
- **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
executor via `PIPELINE_EXECUTOR`, the invoice PDF via `INVOICE_PDF_BUILDER`,
and the 2FA method via `TWO_FACTOR_SERVICE`/`TWO_FACTOR_RECOVERY_SERVICE` — all
through settings/registries, with no kit edits. The other modules already
resolved extensions through dotted paths (gateways, channels, tasks, inbound
handlers, scanner) and are now documented as such.
- **Lazy public API per app package.** `from infrasynth.security import
AuthorizationService`, `from infrasynth.billing import EntitlementService`,
etc. resolve via PEP 562 without importing models before the app registry is
ready; `infrasynth.shared` re-exports its primitives eagerly.
- **Composable per-endpoint gates** (`infrasynth.gates`): declare - **Composable per-endpoint gates** (`infrasynth.gates`): declare
`infrasynth_gates = [...]` (and/or `@gated(...)` on a viewset action) with `infrasynth_gates = [...]` (and/or `@gated(...)` on a viewset action) with
`TwoFactorGate`, `AltchaGate`, `EntitlementGate`, `FeatureGate`, `TwoFactorGate`, `AltchaGate`, `EntitlementGate`, `FeatureGate`,

View file

@ -73,7 +73,29 @@ class SlackChannel(BaseChannel):
def from_config(cls, config): return cls(**config) def from_config(cls, config): return cls(**config)
``` ```
Registries are populated in `apps.py:ready()`: `FeatureRegistry`, `EventRegistry`, `VariableResolverRegistry`, `DataValidatorRegistry`. Registries are populated in `apps.py:ready()`: `FeatureRegistry`, `EventRegistry`, `VariableResolverRegistry`, `DataValidatorRegistry`, `PipelineStepRegistry`.
### Extension points, per module
Every module is swappable through settings/registries — no kit edits, no forks:
| Module | How a consumer extends it |
|---|---|
| `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` |
| `audit` | `EXCLUDED_MODELS`/`EXCLUDED_FIELDS`/`SENSITIVE_KEYS`/`STORE_IN_DB`; listen to `security_event_occurred` |
| `features` | `FeatureRegistry.register(...)` or `INFRASYNTH_FEATURES["FLAGS"]`; `FeatureFlagOverride` rows |
| `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` |
| `webhooks` | `EventRegistry`; `InboundEndpoint.handler` dotted path; signature algorithm setting |
| `workflows` | `DataValidatorRegistry`; node/transition data; `WorkflowAwareModel` |
| `scheduler` | `ScheduledTask.task_path` dotted path (any Celery task or callable) |
A consuming app never imports another app's models directly — it uses the
services, registries, signals, and `infrasynth.gates` documented here.
--- ---

View file

@ -188,6 +188,8 @@ INFRASYNTH_SECURITY = {
"TWO_FACTOR_ISSUER_NAME": "InfraSynth", "TWO_FACTOR_ISSUER_NAME": "InfraSynth",
"TWO_FACTOR_RECOVERY_CODES_COUNT": 8, "TWO_FACTOR_RECOVERY_CODES_COUNT": 8,
"TWO_FACTOR_TOTP_VALIDITY_WINDOW": 1, "TWO_FACTOR_TOTP_VALIDITY_WINDOW": 1,
"TWO_FACTOR_SERVICE": "infrasynth.security.two_factor.services.TOTPService",
"TWO_FACTOR_RECOVERY_SERVICE": "infrasynth.security.two_factor.services.RecoveryCodeService",
"PRE_AUTH_TOKEN_LIFETIME_MINUTES": 5, "PRE_AUTH_TOKEN_LIFETIME_MINUTES": 5,
"ALTCHA_DIFFICULTY": 10000, "ALTCHA_DIFFICULTY": 10000,
"ALTCHA_CHALLENGE_EXPIRY_SECONDS": 300, "ALTCHA_CHALLENGE_EXPIRY_SECONDS": 300,
@ -246,6 +248,7 @@ INFRASYNTH_FILES = {
"MAX_UPLOAD_SIZE_MB": 100, "MAX_UPLOAD_SIZE_MB": 100,
"ENABLE_PROCESSING_PIPELINES": True, "ENABLE_PROCESSING_PIPELINES": True,
"PROCESSING_BACKEND": "celery", "PROCESSING_BACKEND": "celery",
"PIPELINE_EXECUTOR": "infrasynth.files.processing.PipelineExecutor",
"ENABLE_X_SENDFILE": False, "ENABLE_X_SENDFILE": False,
"VIRUS_SCANNER": "noop", "VIRUS_SCANNER": "noop",
"CLAMAV_SOCKET": "/var/run/clamav/clamd.ctl", "CLAMAV_SOCKET": "/var/run/clamav/clamd.ctl",
@ -346,6 +349,7 @@ INFRASYNTH_TENANCY = {
INFRASYNTH_BILLING = { INFRASYNTH_BILLING = {
"INVOICE_NUMBER_PREFIX": "INV-", "INVOICE_NUMBER_PREFIX": "INV-",
"INVOICE_PDF_BUILDER": "infrasynth.billing.invoice_generator._build_invoice_pdf",
"GRACE_PERIOD_DAYS": 5, "GRACE_PERIOD_DAYS": 5,
"MAX_RETRY_FAILED_PAYMENTS": 3, "MAX_RETRY_FAILED_PAYMENTS": 3,
"DEFAULT_CURRENCY": "USD", "DEFAULT_CURRENCY": "USD",

View file

@ -0,0 +1,47 @@
"""Public surface of ``infrasynth.audit``.
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.audit import ModelChangeLog, OptionalAuditableMixin
"""
from __future__ import annotations
from importlib import import_module
from typing import Any
__all__ = [
"APIInteractionLog",
"ModelChangeLog",
"OptionalAuditableMixin",
"SecurityEvent",
"model_changed",
"security_event_occurred",
]
# public name -> module (relative to the ``infrasynth`` package) that defines it
_EXPORTS: dict[str, str] = {
"APIInteractionLog": "audit.models",
"ModelChangeLog": "audit.models",
"OptionalAuditableMixin": "audit.mixins",
"SecurityEvent": "audit.models",
"model_changed": "audit.signals",
"security_event_occurred": "audit.signals",
}
def __getattr__(name: str) -> Any:
module_path = _EXPORTS.get(name)
if module_path is not None:
return getattr(import_module(f"infrasynth.{module_path}"), name)
# Let ``infrasynth.<app>.<submodule>`` resolve as usual.
try:
return import_module(f"{__name__}.{name}")
except ModuleNotFoundError as exc:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
def __dir__() -> list[str]:
return sorted(set(__all__) | set(globals()))

View file

@ -0,0 +1,55 @@
"""Public surface of ``infrasynth.billing``.
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.billing import EntitlementService, BillingService, Plan
"""
from __future__ import annotations
from importlib import import_module
from typing import Any
__all__ = [
"App",
"BasePaymentGateway",
"BillingService",
"Entitlement",
"EntitlementService",
"Invoice",
"PaymentGateway",
"PaymentTransaction",
"Plan",
"Subscription",
]
# public name -> module (relative to the ``infrasynth`` package) that defines it
_EXPORTS: dict[str, str] = {
"App": "billing.models",
"BasePaymentGateway": "billing.gateways.base",
"BillingService": "billing.services",
"Entitlement": "billing.models",
"EntitlementService": "billing.entitlements",
"Invoice": "billing.models",
"PaymentGateway": "billing.models",
"PaymentTransaction": "billing.models",
"Plan": "billing.models",
"Subscription": "billing.models",
}
def __getattr__(name: str) -> Any:
module_path = _EXPORTS.get(name)
if module_path is not None:
return getattr(import_module(f"infrasynth.{module_path}"), name)
# Let ``infrasynth.<app>.<submodule>`` resolve as usual.
try:
return import_module(f"{__name__}.{name}")
except ModuleNotFoundError as exc:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
def __dir__() -> list[str]:
return sorted(set(__all__) | set(globals()))

View file

@ -6,6 +6,20 @@ from celery import shared_task
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_invoice_pdf_builder():
"""Returns the configured PDF builder (``INFRASYNTH_BILLING["INVOICE_PDF_BUILDER"]``)."""
from django.utils.module_loading import import_string
from infrasynth.shared.settings_utils import get_setting
path = get_setting(
"INFRASYNTH_BILLING",
"INVOICE_PDF_BUILDER",
"infrasynth.billing.invoice_generator._build_invoice_pdf",
)
return import_string(path)
@shared_task( @shared_task(
name="infrasynth.billing.generate_invoice_pdf", name="infrasynth.billing.generate_invoice_pdf",
bind=True, bind=True,
@ -30,7 +44,7 @@ def generate_invoice_pdf(self, invoice_id, tenant_id=None):
return None return None
try: try:
pdf_bytes = _build_invoice_pdf(invoice) pdf_bytes = get_invoice_pdf_builder()(invoice)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.exception("PDF generation failed for invoice %s", invoice_id) logger.exception("PDF generation failed for invoice %s", invoice_id)
raise self.retry(exc=exc) from exc raise self.retry(exc=exc) from exc

View file

@ -0,0 +1,45 @@
"""Public surface of ``infrasynth.features``.
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.features import FeatureService, FeatureRegistry, feature_required
"""
from __future__ import annotations
from importlib import import_module
from typing import Any
__all__ = [
"FeatureFlag",
"FeatureFlagOverride",
"FeatureRegistry",
"FeatureService",
"feature_required",
]
# public name -> module (relative to the ``infrasynth`` package) that defines it
_EXPORTS: dict[str, str] = {
"FeatureFlag": "features.models",
"FeatureFlagOverride": "features.models",
"FeatureRegistry": "features.registry",
"FeatureService": "features.services",
"feature_required": "features.decorators",
}
def __getattr__(name: str) -> Any:
module_path = _EXPORTS.get(name)
if module_path is not None:
return getattr(import_module(f"infrasynth.{module_path}"), name)
# Let ``infrasynth.<app>.<submodule>`` resolve as usual.
try:
return import_module(f"{__name__}.{name}")
except ModuleNotFoundError as exc:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
def __dir__() -> list[str]:
return sorted(set(__all__) | set(globals()))

View file

@ -0,0 +1,65 @@
"""Public surface of ``infrasynth.files``.
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.files import FileService, get_storage_backend, PipelineStepRegistry
"""
from __future__ import annotations
from importlib import import_module
from typing import Any
__all__ = [
"FileCategory",
"FileService",
"PipelineExecution",
"PipelineExecutor",
"PipelineStepRegistry",
"ProcessingPipeline",
"ScanResult",
"StoredFile",
"VirusScanner",
"get_pipeline_executor",
"get_scanner",
"get_storage_backend",
"pipeline_step",
"register_storage_backend",
"save_file",
]
# public name -> module (relative to the ``infrasynth`` package) that defines it
_EXPORTS: dict[str, str] = {
"FileCategory": "files.models",
"FileService": "files.services",
"PipelineExecution": "files.models",
"PipelineExecutor": "files.processing",
"PipelineStepRegistry": "files.processing",
"ProcessingPipeline": "files.models",
"ScanResult": "files.scanner",
"StoredFile": "files.models",
"VirusScanner": "files.scanner",
"get_pipeline_executor": "files.processing",
"get_scanner": "files.scanner",
"get_storage_backend": "files.storage",
"pipeline_step": "files.processing",
"register_storage_backend": "files.storage",
"save_file": "files.storage",
}
def __getattr__(name: str) -> Any:
module_path = _EXPORTS.get(name)
if module_path is not None:
return getattr(import_module(f"infrasynth.{module_path}"), name)
# Let ``infrasynth.<app>.<submodule>`` resolve as usual.
try:
return import_module(f"{__name__}.{name}")
except ModuleNotFoundError as exc:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
def __dir__() -> list[str]:
return sorted(set(__all__) | set(globals()))

View file

@ -1,5 +1,7 @@
import io import io
import logging import logging
from collections.abc import Callable
from typing import Any
from celery import shared_task from celery import shared_task
from django.utils import timezone from django.utils import timezone
@ -7,6 +9,61 @@ from django.utils import timezone
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class PipelineStepRegistry:
"""Registry of processing steps so apps add steps without editing the kit.
Register in ``apps.py:ready()``::
from infrasynth.files.processing import PipelineStepRegistry
def strip_exif(data: bytes, mime_type: str, params: dict):
...
return data, mime_type
PipelineStepRegistry.register("strip_exif", strip_exif)
Or decorate: ``@pipeline_step("strip_exif")``. A registered step takes
``(data, mime_type, params)`` and returns ``(data, mime_type)``.
"""
_steps: dict[str, Callable] = {}
@classmethod
def register(cls, name: str, func: Callable | None = None):
if func is None:
def decorator(inner: Callable) -> Callable:
cls._steps[name] = inner
return inner
return decorator
cls._steps[name] = func
return func
@classmethod
def get(cls, name: str) -> Callable | None:
return cls._steps.get(name)
@classmethod
def all(cls) -> dict[str, Callable]:
return dict(cls._steps)
def pipeline_step(name: str):
"""Decorator form of :meth:`PipelineStepRegistry.register`."""
return PipelineStepRegistry.register(name)
def get_pipeline_executor() -> Any:
"""Returns an executor instance (``INFRASYNTH_FILES["PIPELINE_EXECUTOR"]``)."""
from django.utils.module_loading import import_string
from infrasynth.shared.settings_utils import get_setting
path = get_setting("INFRASYNTH_FILES", "PIPELINE_EXECUTOR", "infrasynth.files.processing.PipelineExecutor")
return import_string(path)()
class PipelineExecutor: class PipelineExecutor:
"""Executes a processing pipeline over a stored file, step by step.""" """Executes a processing pipeline over a stored file, step by step."""
@ -25,7 +82,8 @@ class PipelineExecutor:
for step in steps: for step in steps:
step_type = step.get("type") step_type = step.get("type")
params = step.get("params", {}) params = step.get("params", {})
handler = getattr(self, f"_step_{step_type}", None) registered = PipelineStepRegistry.get(step_type)
handler = registered or getattr(self, f"_step_{step_type}", None)
if handler is None: if handler is None:
raise ValueError(f"Unknown pipeline step type: '{step_type}'") raise ValueError(f"Unknown pipeline step type: '{step_type}'")
data, mime_type = handler(data, mime_type, params) data, mime_type = handler(data, mime_type, params)
@ -153,4 +211,4 @@ def run_pipeline_execution(self, execution_id, tenant_id=None):
tenant = Tenant.objects.filter(pk=tenant_id).first() if tenant_id else execution.tenant tenant = Tenant.objects.filter(pk=tenant_id).first() if tenant_id else execution.tenant
with tenant_context(tenant): with tenant_context(tenant):
return PipelineExecutor().execute(execution) return get_pipeline_executor().execute(execution)

View file

@ -195,9 +195,9 @@ class FileService:
) )
backend = get_setting("INFRASYNTH_FILES", "PROCESSING_BACKEND", "celery") backend = get_setting("INFRASYNTH_FILES", "PROCESSING_BACKEND", "celery")
if backend == "sync": if backend == "sync":
from .processing import PipelineExecutor from .processing import get_pipeline_executor
PipelineExecutor().execute(execution) get_pipeline_executor().execute(execution)
else: else:
from .processing import run_pipeline_execution from .processing import run_pipeline_execution

View file

@ -135,7 +135,7 @@ class _CloudinaryStorage:
return cloudinary.utils.cloudinary_url(name, sign_url=True, expires_at=expiry_seconds)[0] return cloudinary.utils.cloudinary_url(name, sign_url=True, expires_at=expiry_seconds)[0]
_BACKEND_CLASSES = { _BUILTIN_BACKENDS: dict[str, type] = {
"local": _LocalStorage, "local": _LocalStorage,
"S3": _S3Storage, "S3": _S3Storage,
"s3": _S3Storage, "s3": _S3Storage,
@ -143,17 +143,51 @@ _BACKEND_CLASSES = {
"cloudinary": _CloudinaryStorage, "cloudinary": _CloudinaryStorage,
} }
# Apps register their own backends at startup (``apps.py:ready()``) so a custom
# storage backend never requires editing the kit.
_CUSTOM_BACKENDS: dict[str, object] = {}
def register_storage_backend(name: str, factory: object) -> None:
"""Registers a storage backend factory/class under ``name``.
``factory`` is called with the backend's config dict and must implement the
storage protocol (``save/open/exists/url/delete/generate_signed_url``).
"""
_CUSTOM_BACKENDS[str(name)] = factory
def get_storage_backend(backend_name: str | None = None): def get_storage_backend(backend_name: str | None = None):
"""Returns a storage backend instance by name (defaults to settings config).""" """Returns a storage backend instance by name (defaults to settings config).
Resolution order: ``STORAGE_BACKENDS[<name>]["CLASS"]`` (dotted path) →
a backend registered via :func:`register_storage_backend` → the built-in
backends. An unknown name raises rather than silently falling back to local.
"""
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
config = getattr(settings, "INFRASYNTH_FILES", {}) config = getattr(settings, "INFRASYNTH_FILES", {})
if not backend_name: if not backend_name:
backend_name = get_setting("INFRASYNTH_FILES", "DEFAULT_STORAGE_BACKEND", "local") backend_name = get_setting("INFRASYNTH_FILES", "DEFAULT_STORAGE_BACKEND", "local")
backend_config = config.get("STORAGE_BACKENDS", {}).get(backend_name, {}) backend_name = str(backend_name)
backend_class = _BACKEND_CLASSES.get(str(backend_name), _LocalStorage) backend_config = (config.get("STORAGE_BACKENDS", {}) or {}).get(backend_name, {}) or {}
return backend_class(backend_config or {})
class_path = (backend_config or {}).get("CLASS")
if class_path:
backend_class = import_string(class_path)
elif backend_name in _CUSTOM_BACKENDS:
backend_class = _CUSTOM_BACKENDS[backend_name]
else:
backend_class = _BUILTIN_BACKENDS.get(backend_name)
if backend_class is None:
raise ImproperlyConfigured(
f"Unknown storage backend '{backend_name}'. Register it with "
"infrasynth.files.storage.register_storage_backend or set "
f'INFRASYNTH_FILES["STORAGE_BACKENDS"]["{backend_name}"]["CLASS"].'
)
return backend_class(backend_config)
def save_file(file_obj, storage_key: str, backend: str = "local"): def save_file(file_obj, storage_key: str, backend: str = "local"):

View file

@ -0,0 +1,49 @@
"""Public surface of ``infrasynth.notifications``.
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.notifications import NotificationService, ChannelConfig, BaseChannel
"""
from __future__ import annotations
from importlib import import_module
from typing import Any
__all__ = [
"Attachment",
"BaseChannel",
"ChannelConfig",
"NotificationDispatch",
"NotificationService",
"NotificationTemplate",
"VariableResolverRegistry",
]
# public name -> module (relative to the ``infrasynth`` package) that defines it
_EXPORTS: dict[str, str] = {
"Attachment": "notifications.channels.base",
"BaseChannel": "notifications.channels.base",
"ChannelConfig": "notifications.models",
"NotificationDispatch": "notifications.models",
"NotificationService": "notifications.services",
"NotificationTemplate": "notifications.models",
"VariableResolverRegistry": "notifications.resolvers",
}
def __getattr__(name: str) -> Any:
module_path = _EXPORTS.get(name)
if module_path is not None:
return getattr(import_module(f"infrasynth.{module_path}"), name)
# Let ``infrasynth.<app>.<submodule>`` resolve as usual.
try:
return import_module(f"{__name__}.{name}")
except ModuleNotFoundError as exc:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
def __dir__() -> list[str]:
return sorted(set(__all__) | set(globals()))

View file

@ -0,0 +1,41 @@
"""Public surface of ``infrasynth.scheduler``.
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.scheduler import TaskService, ScheduledTask, TaskExecution
"""
from __future__ import annotations
from importlib import import_module
from typing import Any
__all__ = [
"ScheduledTask",
"TaskExecution",
"TaskService",
]
# public name -> module (relative to the ``infrasynth`` package) that defines it
_EXPORTS: dict[str, str] = {
"ScheduledTask": "scheduler.models",
"TaskExecution": "scheduler.models",
"TaskService": "scheduler.services",
}
def __getattr__(name: str) -> Any:
module_path = _EXPORTS.get(name)
if module_path is not None:
return getattr(import_module(f"infrasynth.{module_path}"), name)
# Let ``infrasynth.<app>.<submodule>`` resolve as usual.
try:
return import_module(f"{__name__}.{name}")
except ModuleNotFoundError as exc:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
def __dir__() -> list[str]:
return sorted(set(__all__) | set(globals()))

View file

@ -0,0 +1,67 @@
"""Public surface of ``infrasynth.security``.
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.security import AuthorizationService, HybridPermission, CookieJWTAuthentication
"""
from __future__ import annotations
from importlib import import_module
from typing import Any
__all__ = [
"ALTCHAService",
"APIKeyAuthentication",
"AuthorizationService",
"CookieJWTAuthentication",
"EmailOrUsernameBackend",
"HybridPermission",
"IsAuthenticatedAndPermitted",
"LoginAttemptGuard",
"PasswordPolicyValidator",
"RecoveryCodeService",
"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
_EXPORTS: dict[str, str] = {
"ALTCHAService": "security.altcha.services",
"APIKeyAuthentication": "security.auth.api_keys",
"AuthorizationService": "security.services",
"CookieJWTAuthentication": "security.auth.cookies",
"EmailOrUsernameBackend": "security.auth.backends",
"HybridPermission": "security.permissions",
"IsAuthenticatedAndPermitted": "security.permissions",
"LoginAttemptGuard": "security.throttling",
"PasswordPolicyValidator": "security.password_validation",
"RecoveryCodeService": "security.two_factor.services",
"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",
}
def __getattr__(name: str) -> Any:
module_path = _EXPORTS.get(name)
if module_path is not None:
return getattr(import_module(f"infrasynth.{module_path}"), name)
# Let ``infrasynth.<app>.<submodule>`` resolve as usual.
try:
return import_module(f"{__name__}.{name}")
except ModuleNotFoundError as exc:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
def __dir__() -> list[str]:
return sorted(set(__all__) | set(globals()))

View file

@ -9,6 +9,36 @@ from infrasynth.shared.crypto import decrypt, encrypt
from infrasynth.shared.settings_utils import get_setting from infrasynth.shared.settings_utils import get_setting
def get_two_factor_service():
"""Returns the configured 2FA method service (swappable via settings).
Override ``INFRASYNTH_SECURITY["TWO_FACTOR_SERVICE"]`` with a dotted path to
plug an email/SMS OTP implementation; it must expose ``generate_secret``,
``get_provisioning_uri``, ``generate_qr_base64``, ``verify``,
``encrypt_secret`` and ``decrypt_secret``.
"""
from django.utils.module_loading import import_string
path = get_setting(
"INFRASYNTH_SECURITY",
"TWO_FACTOR_SERVICE",
"infrasynth.security.two_factor.services.TOTPService",
)
return import_string(path)()
def get_recovery_code_service():
"""Returns the configured recovery-code service (swappable via settings)."""
from django.utils.module_loading import import_string
path = get_setting(
"INFRASYNTH_SECURITY",
"TWO_FACTOR_RECOVERY_SERVICE",
"infrasynth.security.two_factor.services.RecoveryCodeService",
)
return import_string(path)()
class TOTPService: class TOTPService:
def __init__(self): def __init__(self):
issuer = get_setting("INFRASYNTH_SECURITY", "TWO_FACTOR_ISSUER_NAME", "InfraSynth") issuer = get_setting("INFRASYNTH_SECURITY", "TWO_FACTOR_ISSUER_NAME", "InfraSynth")

View file

@ -40,7 +40,7 @@ from .signals import (
user_logged_out, user_logged_out,
) )
from .throttling import LoginAttemptGuard from .throttling import LoginAttemptGuard
from .two_factor.services import RecoveryCodeService, TOTPService from .two_factor.services import get_recovery_code_service, get_two_factor_service
from .two_factor.utils import generate_pre_auth_token from .two_factor.utils import generate_pre_auth_token
UserModel = get_user_model() UserModel = get_user_model()
@ -384,7 +384,7 @@ class TwoFactorViewSet(_AuthSupport, viewsets.GenericViewSet):
@action(detail=False, methods=["post"]) @action(detail=False, methods=["post"])
def setup(self, request): # type: ignore[override] def setup(self, request): # type: ignore[override]
totp = TOTPService() totp = get_two_factor_service()
secret = totp.generate_secret() secret = totp.generate_secret()
qr_base64 = totp.generate_qr_base64(secret, request.user.email) qr_base64 = totp.generate_qr_base64(secret, request.user.email)
provisioning_uri = totp.get_provisioning_uri(secret, request.user.email) provisioning_uri = totp.get_provisioning_uri(secret, request.user.email)
@ -405,10 +405,10 @@ class TwoFactorViewSet(_AuthSupport, viewsets.GenericViewSet):
code = request.data.get("code") code = request.data.get("code")
if not code: if not code:
raise AuthenticationFailed("Code is required.") raise AuthenticationFailed("Code is required.")
totp = TOTPService() totp = get_two_factor_service()
if not totp.verify(secret, code): if not totp.verify(secret, code):
raise AuthenticationFailed("Invalid code.") raise AuthenticationFailed("Invalid code.")
rcs = RecoveryCodeService() rcs = get_recovery_code_service()
recovery_codes = rcs.generate_codes() recovery_codes = rcs.generate_codes()
config, _ = TwoFactorConfig.objects.get_or_create(user=request.user) config, _ = TwoFactorConfig.objects.get_or_create(user=request.user)
config.is_enabled = True config.is_enabled = True
@ -431,7 +431,7 @@ class TwoFactorViewSet(_AuthSupport, viewsets.GenericViewSet):
code = request.data.get("code") code = request.data.get("code")
if not code: if not code:
raise AuthenticationFailed("Code is required.") raise AuthenticationFailed("Code is required.")
totp = TOTPService() totp = get_two_factor_service()
secret = totp.decrypt_secret(config.secret_key_encrypted) secret = totp.decrypt_secret(config.secret_key_encrypted)
if not totp.verify(secret, code): if not totp.verify(secret, code):
raise AuthenticationFailed("Invalid code.") raise AuthenticationFailed("Invalid code.")
@ -462,7 +462,7 @@ class TwoFactorViewSet(_AuthSupport, viewsets.GenericViewSet):
config = TwoFactorConfig.objects.get(user_id=user_id, is_enabled=True) config = TwoFactorConfig.objects.get(user_id=user_id, is_enabled=True)
except TwoFactorConfig.DoesNotExist: except TwoFactorConfig.DoesNotExist:
raise AuthenticationFailed("2FA not configured.") raise AuthenticationFailed("2FA not configured.")
rcs = RecoveryCodeService() rcs = get_recovery_code_service()
if not rcs.verify_code(recovery_code, config.recovery_codes_encrypted): if not rcs.verify_code(recovery_code, config.recovery_codes_encrypted):
raise AuthenticationFailed("Invalid recovery code.") raise AuthenticationFailed("Invalid recovery code.")
updated = rcs.remove_used_code(recovery_code, config.recovery_codes_encrypted) updated = rcs.remove_used_code(recovery_code, config.recovery_codes_encrypted)

View file

@ -0,0 +1,62 @@
"""Public surface of ``infrasynth.shared`` (zero-Django primitives).
Safe to import eagerly — nothing here imports Django models or app state.
"""
from .crypto import decrypt, encrypt, generate_key, get_fernet, rotate_keys
from .enums import (
ApprovalStrategy,
AuditAction,
BillingInterval,
ChannelType,
EntitlementStatus,
EventSeverity,
InvoiceStatus,
MonetizationModel,
PlanInterval,
SubscriptionStatus,
)
from .exceptions import (
AppError,
AuthError,
ConflictError,
EntitlementError,
NotFoundError,
RateLimitError,
ServerError,
ValidationAppError,
)
from .protocols import AuditableProtocol, EventProtocol, TenantProtocol
from .results import Result
from .settings_utils import get_setting
__all__ = [
"AppError",
"ApprovalStrategy",
"AuditAction",
"AuditableProtocol",
"AuthError",
"BillingInterval",
"ChannelType",
"ConflictError",
"EntitlementError",
"EntitlementStatus",
"EventProtocol",
"EventSeverity",
"InvoiceStatus",
"MonetizationModel",
"NotFoundError",
"PlanInterval",
"RateLimitError",
"Result",
"ServerError",
"SubscriptionStatus",
"TenantProtocol",
"ValidationAppError",
"decrypt",
"encrypt",
"generate_key",
"get_fernet",
"get_setting",
"rotate_keys",
]

View file

@ -0,0 +1,67 @@
"""Public surface of ``infrasynth.tenancy``.
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.tenancy import TenantService, TenantManager, current_tenant
"""
from __future__ import annotations
from importlib import import_module
from typing import Any
__all__ = [
"AllObjectsManager",
"GlobalOrTenantManager",
"GlobalOrTenantModel",
"PlatformStaff",
"Tenant",
"TenantInvitation",
"TenantManager",
"TenantMembership",
"TenantMiddleware",
"TenantOwnedModel",
"TenantService",
"current_tenant",
"get_current_tenant",
"reset_current_tenant",
"set_current_tenant",
"tenant_context",
]
# public name -> module (relative to the ``infrasynth`` package) that defines it
_EXPORTS: dict[str, str] = {
"AllObjectsManager": "tenancy.managers",
"GlobalOrTenantManager": "tenancy.managers",
"GlobalOrTenantModel": "tenancy.mixins",
"PlatformStaff": "tenancy.models",
"Tenant": "tenancy.models",
"TenantInvitation": "tenancy.models",
"TenantManager": "tenancy.managers",
"TenantMembership": "tenancy.models",
"TenantMiddleware": "tenancy.middleware",
"TenantOwnedModel": "tenancy.mixins",
"TenantService": "tenancy.services",
"current_tenant": "tenancy.context",
"get_current_tenant": "tenancy.context",
"reset_current_tenant": "tenancy.context",
"set_current_tenant": "tenancy.context",
"tenant_context": "tenancy.context",
}
def __getattr__(name: str) -> Any:
module_path = _EXPORTS.get(name)
if module_path is not None:
return getattr(import_module(f"infrasynth.{module_path}"), name)
# Let ``infrasynth.<app>.<submodule>`` resolve as usual.
try:
return import_module(f"{__name__}.{name}")
except ModuleNotFoundError as exc:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
def __dir__() -> list[str]:
return sorted(set(__all__) | set(globals()))

View file

@ -0,0 +1,59 @@
"""Public surface of ``infrasynth.webhooks``.
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.webhooks import EventRegistry, sign_payload, BaseInboundHandler
"""
from __future__ import annotations
from importlib import import_module
from typing import Any
__all__ = [
"BaseInboundHandler",
"EventRegistry",
"HMACInboundHandler",
"InboundEndpoint",
"InboundEvent",
"OutboundDelivery",
"OutboundEndpoint",
"OutboundSubscription",
"deliver_webhook",
"process_inbound_event",
"sign_payload",
"verify_signature",
]
# public name -> module (relative to the ``infrasynth`` package) that defines it
_EXPORTS: dict[str, str] = {
"BaseInboundHandler": "webhooks.inbound.handlers",
"EventRegistry": "webhooks.registry",
"HMACInboundHandler": "webhooks.inbound.handlers",
"InboundEndpoint": "webhooks.models",
"InboundEvent": "webhooks.models",
"OutboundDelivery": "webhooks.models",
"OutboundEndpoint": "webhooks.models",
"OutboundSubscription": "webhooks.models",
"deliver_webhook": "webhooks.dispatch",
"process_inbound_event": "webhooks.dispatch",
"sign_payload": "webhooks.signature",
"verify_signature": "webhooks.signature",
}
def __getattr__(name: str) -> Any:
module_path = _EXPORTS.get(name)
if module_path is not None:
return getattr(import_module(f"infrasynth.{module_path}"), name)
# Let ``infrasynth.<app>.<submodule>`` resolve as usual.
try:
return import_module(f"{__name__}.{name}")
except ModuleNotFoundError as exc:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
def __dir__() -> list[str]:
return sorted(set(__all__) | set(globals()))

View file

@ -0,0 +1,55 @@
"""Public surface of ``infrasynth.workflows``.
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.workflows import WorkflowEngine, DataValidatorRegistry, Workflow
"""
from __future__ import annotations
from importlib import import_module
from typing import Any
__all__ = [
"DataValidatorProtocol",
"DataValidatorRegistry",
"NodeAssignment",
"Transition",
"Workflow",
"WorkflowAwareModel",
"WorkflowEngine",
"WorkflowInstance",
"WorkflowNode",
"WorkflowObserver",
]
# public name -> module (relative to the ``infrasynth`` package) that defines it
_EXPORTS: dict[str, str] = {
"DataValidatorProtocol": "workflows.validators",
"DataValidatorRegistry": "workflows.validators",
"NodeAssignment": "workflows.models",
"Transition": "workflows.models",
"Workflow": "workflows.models",
"WorkflowAwareModel": "workflows.models",
"WorkflowEngine": "workflows.engine",
"WorkflowInstance": "workflows.models",
"WorkflowNode": "workflows.models",
"WorkflowObserver": "workflows.models",
}
def __getattr__(name: str) -> Any:
module_path = _EXPORTS.get(name)
if module_path is not None:
return getattr(import_module(f"infrasynth.{module_path}"), name)
# Let ``infrasynth.<app>.<submodule>`` resolve as usual.
try:
return import_module(f"{__name__}.{name}")
except ModuleNotFoundError as exc:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
def __dir__() -> list[str]:
return sorted(set(__all__) | set(globals()))

194
tests/test_extensibility.py Normal file
View file

@ -0,0 +1,194 @@
"""Every module must be extensible without editing the kit.
These tests pin the extension points that let a pip-installed consumer add
storage backends, pipeline steps, an invoice builder, or a 2FA method purely
through settings/registries, and confirm the public import surface.
"""
import io
import pytest
from django.core.exceptions import ImproperlyConfigured
pytestmark = pytest.mark.django_db
# --- fakes referenced by dotted path from settings --------------------------
class FakeStorage:
def __init__(self, config):
self.config = config
self.saved = {}
def save(self, name, content):
self.saved[name] = content.read()
return name
def open(self, name, mode="rb"):
return io.BytesIO(self.saved[name])
def exists(self, name):
return name in self.saved
def url(self, name):
return f"/fake/{name}"
def delete(self, name):
self.saved.pop(name, None)
def generate_signed_url(self, name, expiry_seconds=3600):
return f"/fake/{name}?signed=1"
class RecordingExecutor:
def execute(self, execution):
execution.status = "completed"
execution.save(update_fields=["status"])
return execution
def fake_invoice_builder(invoice):
return b"%PDF-1.4 custom-builder"
class FakeTwoFactorService:
def generate_secret(self):
return "FAKESECRET"
class TestPublicImportSurface:
def test_app_packages_expose_public_names(self):
from infrasynth.audit import ModelChangeLog
from infrasynth.billing import EntitlementService, Plan
from infrasynth.features import FeatureService
from infrasynth.files import FileService, get_storage_backend
from infrasynth.gates import GatePermission
from infrasynth.notifications import NotificationService
from infrasynth.scheduler import TaskService
from infrasynth.security import AuthorizationService, HybridPermission
from infrasynth.shared import Result
from infrasynth.tenancy import TenantManager
from infrasynth.webhooks import EventRegistry
from infrasynth.workflows import WorkflowEngine
assert all(
x is not None
for x in (
ModelChangeLog,
EntitlementService,
Plan,
FeatureService,
FileService,
get_storage_backend,
NotificationService,
TaskService,
AuthorizationService,
HybridPermission,
Result,
TenantManager,
EventRegistry,
WorkflowEngine,
GatePermission,
)
)
def test_unknown_attribute_raises(self):
import infrasynth.security as security
with pytest.raises(AttributeError):
security.ThisDoesNotExist
class TestStorageExtension:
def test_custom_backend_via_settings_class(self, settings):
settings.INFRASYNTH_FILES = {
**settings.INFRASYNTH_FILES,
"STORAGE_BACKENDS": {
**settings.INFRASYNTH_FILES.get("STORAGE_BACKENDS", {}),
"fake": {"CLASS": "tests.test_extensibility.FakeStorage", "REGION": "x"},
},
}
storage = get_backend("fake")
assert isinstance(storage, FakeStorage)
assert storage.config.get("REGION") == "x"
def test_custom_backend_via_registry(self):
from infrasynth.files.storage import register_storage_backend
register_storage_backend("factory-made", FakeStorage)
assert isinstance(get_backend("factory-made"), FakeStorage)
def test_unknown_backend_raises(self):
from infrasynth.files.storage import get_storage_backend
with pytest.raises(ImproperlyConfigured):
get_storage_backend("does-not-exist")
class TestPipelineExtension:
def test_registered_step_is_used(self, settings, user, media_root):
from django.core.files.uploadedfile import SimpleUploadedFile
from infrasynth.files.models import PipelineExecution, ProcessingPipeline
from infrasynth.files.processing import PipelineExecutor, pipeline_step
from infrasynth.files.services import FileService
calls = []
@pipeline_step("test.tag")
def tag_step(data, mime_type, params):
calls.append(params)
return data, mime_type
uploaded = FileService().upload(
SimpleUploadedFile("a.txt", b"hello", content_type="text/plain"),
filename="a.txt",
user=user,
)
pipeline = ProcessingPipeline.objects.create(
name="Tag", slug="tag", steps=[{"type": "test.tag", "params": {"k": "v"}}]
)
execution = PipelineExecution.objects.create(file=uploaded, pipeline=pipeline)
PipelineExecutor().execute(execution)
execution.refresh_from_db()
assert execution.status == PipelineExecution.Status.COMPLETED
assert calls == [{"k": "v"}]
def test_executor_is_swappable(self, settings):
from infrasynth.files.processing import get_pipeline_executor
settings.INFRASYNTH_FILES = {
**settings.INFRASYNTH_FILES,
"PIPELINE_EXECUTOR": "tests.test_extensibility.RecordingExecutor",
}
assert isinstance(get_pipeline_executor(), RecordingExecutor)
class TestInvoiceBuilderExtension:
def test_builder_is_swappable(self, settings):
from infrasynth.billing.invoice_generator import get_invoice_pdf_builder
settings.INFRASYNTH_BILLING = {
**settings.INFRASYNTH_BILLING,
"INVOICE_PDF_BUILDER": "tests.test_extensibility.fake_invoice_builder",
}
assert get_invoice_pdf_builder()(object()) == b"%PDF-1.4 custom-builder"
class TestTwoFactorExtension:
def test_service_is_swappable(self, settings):
from infrasynth.security.two_factor.services import get_two_factor_service
settings.INFRASYNTH_SECURITY = {
**settings.INFRASYNTH_SECURITY,
"TWO_FACTOR_SERVICE": "tests.test_extensibility.FakeTwoFactorService",
}
assert get_two_factor_service().generate_secret() == "FAKESECRET"
def get_backend(name):
from infrasynth.files.storage import get_storage_backend
return get_storage_backend(name)

View file

@ -44,9 +44,11 @@ class TestGetStorageBackend:
storage = get_storage_backend() storage = get_storage_backend()
assert isinstance(storage, _LocalStorage) assert isinstance(storage, _LocalStorage)
def test_unknown_backend_falls_back_to_local(self): def test_unknown_backend_raises(self):
storage = get_storage_backend("nonexistent") from django.core.exceptions import ImproperlyConfigured
assert isinstance(storage, _LocalStorage)
with pytest.raises(ImproperlyConfigured):
get_storage_backend("nonexistent")
def test_local_backend_instantiation(self): def test_local_backend_instantiation(self):
assert isinstance(get_storage_backend("local"), _LocalStorage) assert isinstance(get_storage_backend("local"), _LocalStorage)