diff --git a/CHANGELOG.md b/CHANGELOG.md index 78eb555..6d65e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ not hand-pick a version (see `../AGENTS.backend-packages.md` §8). ## [Unreleased] ### 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 `infrasynth_gates = [...]` (and/or `@gated(...)` on a viewset action) with `TwoFactorGate`, `AltchaGate`, `EntitlementGate`, `FeatureGate`, diff --git a/README.md b/README.md index f722191..b7acd7a 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,29 @@ class SlackChannel(BaseChannel): 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. --- diff --git a/config/settings/base.py b/config/settings/base.py index ddb696d..49dc756 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -188,6 +188,8 @@ INFRASYNTH_SECURITY = { "TWO_FACTOR_ISSUER_NAME": "InfraSynth", "TWO_FACTOR_RECOVERY_CODES_COUNT": 8, "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, "ALTCHA_DIFFICULTY": 10000, "ALTCHA_CHALLENGE_EXPIRY_SECONDS": 300, @@ -246,6 +248,7 @@ INFRASYNTH_FILES = { "MAX_UPLOAD_SIZE_MB": 100, "ENABLE_PROCESSING_PIPELINES": True, "PROCESSING_BACKEND": "celery", + "PIPELINE_EXECUTOR": "infrasynth.files.processing.PipelineExecutor", "ENABLE_X_SENDFILE": False, "VIRUS_SCANNER": "noop", "CLAMAV_SOCKET": "/var/run/clamav/clamd.ctl", @@ -346,6 +349,7 @@ INFRASYNTH_TENANCY = { INFRASYNTH_BILLING = { "INVOICE_NUMBER_PREFIX": "INV-", + "INVOICE_PDF_BUILDER": "infrasynth.billing.invoice_generator._build_invoice_pdf", "GRACE_PERIOD_DAYS": 5, "MAX_RETRY_FAILED_PAYMENTS": 3, "DEFAULT_CURRENCY": "USD", diff --git a/infrasynth/audit/__init__.py b/infrasynth/audit/__init__.py index e69de29..2f3ae02 100644 --- a/infrasynth/audit/__init__.py +++ b/infrasynth/audit/__init__.py @@ -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..`` 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/billing/__init__.py b/infrasynth/billing/__init__.py index e69de29..a6bfcf2 100644 --- a/infrasynth/billing/__init__.py +++ b/infrasynth/billing/__init__.py @@ -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..`` 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/billing/invoice_generator.py b/infrasynth/billing/invoice_generator.py index 5d7a581..bc2eff8 100644 --- a/infrasynth/billing/invoice_generator.py +++ b/infrasynth/billing/invoice_generator.py @@ -6,6 +6,20 @@ from celery import shared_task 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( name="infrasynth.billing.generate_invoice_pdf", bind=True, @@ -30,7 +44,7 @@ def generate_invoice_pdf(self, invoice_id, tenant_id=None): return None try: - pdf_bytes = _build_invoice_pdf(invoice) + pdf_bytes = get_invoice_pdf_builder()(invoice) except Exception as exc: # noqa: BLE001 logger.exception("PDF generation failed for invoice %s", invoice_id) raise self.retry(exc=exc) from exc diff --git a/infrasynth/features/__init__.py b/infrasynth/features/__init__.py index e69de29..1f99d29 100644 --- a/infrasynth/features/__init__.py +++ b/infrasynth/features/__init__.py @@ -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..`` 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/files/__init__.py b/infrasynth/files/__init__.py index e69de29..d832846 100644 --- a/infrasynth/files/__init__.py +++ b/infrasynth/files/__init__.py @@ -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..`` 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/files/processing.py b/infrasynth/files/processing.py index b6052af..780383e 100644 --- a/infrasynth/files/processing.py +++ b/infrasynth/files/processing.py @@ -1,5 +1,7 @@ import io import logging +from collections.abc import Callable +from typing import Any from celery import shared_task from django.utils import timezone @@ -7,6 +9,61 @@ from django.utils import timezone 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: """Executes a processing pipeline over a stored file, step by step.""" @@ -25,7 +82,8 @@ class PipelineExecutor: for step in steps: step_type = step.get("type") 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: raise ValueError(f"Unknown pipeline step type: '{step_type}'") 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 with tenant_context(tenant): - return PipelineExecutor().execute(execution) + return get_pipeline_executor().execute(execution) diff --git a/infrasynth/files/services.py b/infrasynth/files/services.py index f4a91da..3b9264b 100644 --- a/infrasynth/files/services.py +++ b/infrasynth/files/services.py @@ -195,9 +195,9 @@ class FileService: ) backend = get_setting("INFRASYNTH_FILES", "PROCESSING_BACKEND", "celery") if backend == "sync": - from .processing import PipelineExecutor + from .processing import get_pipeline_executor - PipelineExecutor().execute(execution) + get_pipeline_executor().execute(execution) else: from .processing import run_pipeline_execution diff --git a/infrasynth/files/storage.py b/infrasynth/files/storage.py index 728a33c..a91979f 100644 --- a/infrasynth/files/storage.py +++ b/infrasynth/files/storage.py @@ -135,7 +135,7 @@ class _CloudinaryStorage: return cloudinary.utils.cloudinary_url(name, sign_url=True, expires_at=expiry_seconds)[0] -_BACKEND_CLASSES = { +_BUILTIN_BACKENDS: dict[str, type] = { "local": _LocalStorage, "S3": _S3Storage, "s3": _S3Storage, @@ -143,17 +143,51 @@ _BACKEND_CLASSES = { "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): - """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[]["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.core.exceptions import ImproperlyConfigured + from django.utils.module_loading import import_string config = getattr(settings, "INFRASYNTH_FILES", {}) if not backend_name: backend_name = get_setting("INFRASYNTH_FILES", "DEFAULT_STORAGE_BACKEND", "local") - backend_config = config.get("STORAGE_BACKENDS", {}).get(backend_name, {}) - backend_class = _BACKEND_CLASSES.get(str(backend_name), _LocalStorage) - return backend_class(backend_config or {}) + backend_name = str(backend_name) + backend_config = (config.get("STORAGE_BACKENDS", {}) or {}).get(backend_name, {}) 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"): diff --git a/infrasynth/notifications/__init__.py b/infrasynth/notifications/__init__.py index e69de29..aea434d 100644 --- a/infrasynth/notifications/__init__.py +++ b/infrasynth/notifications/__init__.py @@ -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..`` 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/scheduler/__init__.py b/infrasynth/scheduler/__init__.py index e69de29..715ea51 100644 --- a/infrasynth/scheduler/__init__.py +++ b/infrasynth/scheduler/__init__.py @@ -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..`` 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/security/__init__.py b/infrasynth/security/__init__.py index e69de29..8a93d0c 100644 --- a/infrasynth/security/__init__.py +++ b/infrasynth/security/__init__.py @@ -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..`` 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/security/two_factor/services.py b/infrasynth/security/two_factor/services.py index 0772959..731096b 100644 --- a/infrasynth/security/two_factor/services.py +++ b/infrasynth/security/two_factor/services.py @@ -9,6 +9,36 @@ from infrasynth.shared.crypto import decrypt, encrypt 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: def __init__(self): issuer = get_setting("INFRASYNTH_SECURITY", "TWO_FACTOR_ISSUER_NAME", "InfraSynth") diff --git a/infrasynth/security/views.py b/infrasynth/security/views.py index 9dc4287..7047121 100644 --- a/infrasynth/security/views.py +++ b/infrasynth/security/views.py @@ -40,7 +40,7 @@ from .signals import ( user_logged_out, ) 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 UserModel = get_user_model() @@ -384,7 +384,7 @@ class TwoFactorViewSet(_AuthSupport, viewsets.GenericViewSet): @action(detail=False, methods=["post"]) def setup(self, request): # type: ignore[override] - totp = TOTPService() + totp = get_two_factor_service() secret = totp.generate_secret() qr_base64 = totp.generate_qr_base64(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") if not code: raise AuthenticationFailed("Code is required.") - totp = TOTPService() + totp = get_two_factor_service() if not totp.verify(secret, code): raise AuthenticationFailed("Invalid code.") - rcs = RecoveryCodeService() + rcs = get_recovery_code_service() recovery_codes = rcs.generate_codes() config, _ = TwoFactorConfig.objects.get_or_create(user=request.user) config.is_enabled = True @@ -431,7 +431,7 @@ class TwoFactorViewSet(_AuthSupport, viewsets.GenericViewSet): code = request.data.get("code") if not code: raise AuthenticationFailed("Code is required.") - totp = TOTPService() + totp = get_two_factor_service() secret = totp.decrypt_secret(config.secret_key_encrypted) if not totp.verify(secret, 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) except TwoFactorConfig.DoesNotExist: raise AuthenticationFailed("2FA not configured.") - rcs = RecoveryCodeService() + rcs = get_recovery_code_service() if not rcs.verify_code(recovery_code, config.recovery_codes_encrypted): raise AuthenticationFailed("Invalid recovery code.") updated = rcs.remove_used_code(recovery_code, config.recovery_codes_encrypted) diff --git a/infrasynth/shared/__init__.py b/infrasynth/shared/__init__.py index e69de29..eba65ff 100644 --- a/infrasynth/shared/__init__.py +++ b/infrasynth/shared/__init__.py @@ -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", +] diff --git a/infrasynth/tenancy/__init__.py b/infrasynth/tenancy/__init__.py index e69de29..d6ea8bf 100644 --- a/infrasynth/tenancy/__init__.py +++ b/infrasynth/tenancy/__init__.py @@ -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..`` 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/webhooks/__init__.py b/infrasynth/webhooks/__init__.py index e69de29..8e8a241 100644 --- a/infrasynth/webhooks/__init__.py +++ b/infrasynth/webhooks/__init__.py @@ -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..`` 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/workflows/__init__.py b/infrasynth/workflows/__init__.py index e69de29..4ce4ec0 100644 --- a/infrasynth/workflows/__init__.py +++ b/infrasynth/workflows/__init__.py @@ -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..`` 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/tests/test_extensibility.py b/tests/test_extensibility.py new file mode 100644 index 0000000..7ca1b51 --- /dev/null +++ b/tests/test_extensibility.py @@ -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) diff --git a/tests/test_files/test_storage.py b/tests/test_files/test_storage.py index 1e1f2a4..1c1c197 100644 --- a/tests/test_files/test_storage.py +++ b/tests/test_files/test_storage.py @@ -44,9 +44,11 @@ class TestGetStorageBackend: storage = get_storage_backend() assert isinstance(storage, _LocalStorage) - def test_unknown_backend_falls_back_to_local(self): - storage = get_storage_backend("nonexistent") - assert isinstance(storage, _LocalStorage) + def test_unknown_backend_raises(self): + from django.core.exceptions import ImproperlyConfigured + + with pytest.raises(ImproperlyConfigured): + get_storage_backend("nonexistent") def test_local_backend_instantiation(self): assert isinstance(get_storage_backend("local"), _LocalStorage)