austial-tenancy 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ """``austial.tenancy`` -- multi-tenant primitives.
2
+
3
+ Provides ``TenantContext`` (request-scoped, backed by ``contextvars`` so it works with
4
+ async and thread executors), ``TenantGuard`` (populates the context from the
5
+ authenticated JWT's ``tenant`` claim and fails-closed on absence), the ``@Tenanted``
6
+ entity mixin (adds a ``tenant_id`` column), and ``@RequireTenant`` (marker decorator
7
+ that requires ``TenantGuard`` to have populated the context before the handler runs).
8
+
9
+ The framework enforces the tenant boundary at the request perimeter; product code
10
+ still writes ``.where(tenant_id=current)`` in queries or uses a scoped repository, so
11
+ the tenant filter is explicit at the persistence boundary (visible in every query
12
+ plan review) rather than magically injected.
13
+ """
14
+
15
+ from austial.tenancy.decorators import REQUIRE_TENANT_METADATA, RequireTenant, is_tenant_required
16
+ from austial.tenancy.errors import MissingTenantError, TenantMismatchError
17
+ from austial.tenancy.tenancy_module import TenancyModule
18
+ from austial.tenancy.tenant_context import TenantContext, TenantContextValue
19
+ from austial.tenancy.tenant_guard import TenantGuard
20
+ from austial.tenancy.tenanted import Tenanted
21
+
22
+ __all__ = [
23
+ "MissingTenantError",
24
+ "REQUIRE_TENANT_METADATA",
25
+ "RequireTenant",
26
+ "TenancyModule",
27
+ "Tenanted",
28
+ "TenantContext",
29
+ "TenantContextValue",
30
+ "TenantGuard",
31
+ "TenantMismatchError",
32
+ "is_tenant_required",
33
+ ]
@@ -0,0 +1,29 @@
1
+ """``@RequireTenant`` -- marker decorator that ``TenantGuard`` checks.
2
+
3
+ Apply at controller or handler level to require a populated ``TenantContext``.
4
+ Requests without a tenant claim (or with a claim that fails product-level
5
+ validation) are rejected with 403 before the handler runs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ from austial.core.metadata import get_metadata, set_metadata
14
+
15
+ REQUIRE_TENANT_METADATA = "austial:tenancy:require"
16
+
17
+
18
+ def RequireTenant() -> Callable: # noqa: N802 - decorator naming
19
+ """Mark a controller or handler as requiring a tenant context."""
20
+
21
+ def decorator(target):
22
+ set_metadata(REQUIRE_TENANT_METADATA, True)(target)
23
+ return target
24
+
25
+ return decorator
26
+
27
+
28
+ def is_tenant_required(target: Any) -> bool:
29
+ return bool(get_metadata(REQUIRE_TENANT_METADATA, target, False))
@@ -0,0 +1,19 @@
1
+ """Tenancy-specific exceptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from austial.common.exceptions import ForbiddenException
6
+
7
+
8
+ class MissingTenantError(ForbiddenException):
9
+ """Raised when a request that requires a tenant lacks one."""
10
+
11
+ def __init__(self, message: str = "Tenant context is required"):
12
+ super().__init__(message)
13
+
14
+
15
+ class TenantMismatchError(ForbiddenException):
16
+ """Raised when an operation targets a tenant other than the caller's."""
17
+
18
+ def __init__(self, expected: str, actual: str):
19
+ super().__init__(f"Tenant mismatch: expected {expected!r}, got {actual!r}")
@@ -0,0 +1,19 @@
1
+ """``TenancyModule.for_root(...)`` -- provides ``TenantContext`` and ``TenantGuard``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from austial.common.decorators.modules import Module
6
+ from austial.core.dynamic_module import DynamicModule
7
+ from austial.tenancy.tenant_context import TenantContext
8
+ from austial.tenancy.tenant_guard import TenantGuard
9
+
10
+
11
+ @Module()
12
+ class TenancyModule:
13
+ @staticmethod
14
+ def for_root(*, is_global: bool = True) -> DynamicModule:
15
+ return DynamicModule(
16
+ module=TenancyModule,
17
+ providers=[TenantContext, TenantGuard],
18
+ exports=[TenantContext, TenantGuard],
19
+ )
@@ -0,0 +1,70 @@
1
+ """``TenantContext`` -- request-scoped tenant id.
2
+
3
+ Backed by ``contextvars.ContextVar`` so the value is isolated per request even
4
+ across ``await`` points and threadpool executors. Set by ``TenantGuard`` at the
5
+ perimeter; read by services, repositories and the ``@RequireTenant`` decorator.
6
+
7
+ Two access styles are supported:
8
+
9
+ - Injectable: ``TenantContext`` is a provider; inject and call ``current_id()``.
10
+ - Direct: ``TenantContext.get()`` is a classmethod for places DI is inconvenient
11
+ (a mixin, a pure function, a background job that has been handed a snapshot).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextvars
17
+ from dataclasses import dataclass
18
+ from typing import Any
19
+
20
+ from austial.common.decorators.injectable import Injectable
21
+
22
+ _current_tenant: contextvars.ContextVar[TenantContextValue | None] = contextvars.ContextVar(
23
+ "austial.tenancy.tenant", default=None
24
+ )
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class TenantContextValue:
29
+ """Snapshot of the current tenant. ``metadata`` carries whatever the
30
+ ``TenantGuard`` chose to expose (e.g. tenant name, plan) for downstream code."""
31
+
32
+ tenant_id: str
33
+ metadata: dict[str, Any] | None = None
34
+
35
+
36
+ @Injectable()
37
+ class TenantContext:
38
+ """Injectable facade over the contextvar."""
39
+
40
+ @classmethod
41
+ def get(cls) -> TenantContextValue | None:
42
+ """Return the current value, or ``None`` if unset."""
43
+ return _current_tenant.get()
44
+
45
+ @classmethod
46
+ def require(cls) -> TenantContextValue:
47
+ """Return the current value, or raise ``MissingTenantError`` if unset."""
48
+ from austial.tenancy.errors import MissingTenantError
49
+
50
+ value = _current_tenant.get()
51
+ if value is None:
52
+ raise MissingTenantError()
53
+ return value
54
+
55
+ @classmethod
56
+ def set(cls, value: TenantContextValue | None) -> contextvars.Token:
57
+ """Set the current value; return a token the caller can use to reset."""
58
+ return _current_tenant.set(value)
59
+
60
+ @classmethod
61
+ def reset(cls, token: contextvars.Token) -> None:
62
+ _current_tenant.reset(token)
63
+
64
+ def current(self) -> TenantContextValue | None:
65
+ """Instance form for injected use."""
66
+ return _current_tenant.get()
67
+
68
+ def current_id(self) -> str:
69
+ """Instance form; raises ``MissingTenantError`` if unset."""
70
+ return self.__class__.require().tenant_id
@@ -0,0 +1,74 @@
1
+ """``TenantGuard`` -- extracts tenant from the authenticated principal and
2
+ populates ``TenantContext``.
3
+
4
+ Runs after ``AuthGuard`` (which sets ``request.state.user``). Reads the
5
+ ``tenant`` claim from the principal, populates the context, and stores a
6
+ reset token on ``request.state`` so the framework can tear the context down
7
+ at end-of-request.
8
+
9
+ On success the tenant id is published both on ``TenantContext`` (contextvar) and on
10
+ ``request.state.tenant`` (read by ``austial.audit`` so audit rows carry the tenant).
11
+
12
+ Fails closed:
13
+
14
+ - If the handler/controller carries ``@RequireTenant`` and the principal has
15
+ no ``tenant`` claim, raises ``MissingTenantError`` (403).
16
+ - If the URL/body contains a ``tenant_id`` that disagrees with the claim,
17
+ raises ``TenantMismatchError`` (403). Product code opts in by overriding
18
+ ``read_body_tenant`` -- the base guard does not scan bodies to keep the
19
+ hot path fast.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any
25
+
26
+ from austial.common.decorators.injectable import Injectable
27
+ from austial.common.guards.base import CanActivate, ExecutionContext
28
+ from austial.tenancy.decorators import is_tenant_required
29
+ from austial.tenancy.errors import MissingTenantError, TenantMismatchError
30
+ from austial.tenancy.tenant_context import TenantContext, TenantContextValue
31
+
32
+
33
+ @Injectable()
34
+ class TenantGuard(CanActivate):
35
+ #: Treat every route as ``@RequireTenant`` (deny-by-default). Subclass and set ``True`` for a product
36
+ #: whose every non-public route is tenant-scoped, so a forgotten decorator cannot ship an endpoint
37
+ #: that runs without a tenant. The subclass is responsible for letting public routes through
38
+ #: *before* delegating here.
39
+ require_by_default: bool = False
40
+
41
+ async def can_activate(self, context: ExecutionContext) -> bool:
42
+ handler = context.get_handler()
43
+ controller = context.get_class()
44
+ required = self.require_by_default or is_tenant_required(handler) or is_tenant_required(controller)
45
+
46
+ request = context.get_request()
47
+ principal = getattr(request.state, "user", None)
48
+ claim = None
49
+ if principal is not None:
50
+ claim = principal.claims.get("tenant") if isinstance(principal.claims, dict) else None
51
+
52
+ if not claim:
53
+ if required:
54
+ raise MissingTenantError()
55
+ return True
56
+
57
+ path_tenant = request.path_params.get("tenant_id") if hasattr(request, "path_params") else None
58
+ if path_tenant is not None and str(path_tenant) != str(claim):
59
+ raise TenantMismatchError(str(claim), str(path_tenant))
60
+
61
+ token = TenantContext.set(TenantContextValue(tenant_id=str(claim)))
62
+ request.state.tenant_reset_token = token
63
+ # ``AuditedInterceptor`` reads the tenant from ``request.state.tenant``.
64
+ request.state.tenant = str(claim)
65
+ return True
66
+
67
+ def teardown(self, request: Any) -> None:
68
+ """Reset the contextvar after the response is sent. The framework's
69
+ request lifecycle should call this from a teardown hook; if it doesn't,
70
+ the contextvar is still request-scoped in async contexts and gets
71
+ released with the task."""
72
+ token = getattr(request.state, "tenant_reset_token", None)
73
+ if token is not None:
74
+ TenantContext.reset(token)
@@ -0,0 +1,23 @@
1
+ """``Tenanted`` -- entity mixin adding a ``tenant_id`` column.
2
+
3
+ Add to any entity that must be scoped to a tenant. The column is a required
4
+ string (raises at flush time if unset) so a row without a tenant is a hard
5
+ persistence error rather than a silent write to the wrong table.
6
+
7
+ Product code sets ``tenant_id`` explicitly at insert (typically from
8
+ ``TenantContext.require().tenant_id`` in the service that creates the row),
9
+ which keeps the write path auditable. On read, the repository or service
10
+ filters by ``tenant_id`` -- ``TenantGuard`` guarantees the value exists.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from austial.orm.decorators.column import Column
16
+
17
+
18
+ class Tenanted:
19
+ """Mixin -- inherit alongside your ``@Entity()``-decorated dataclass to add
20
+ a ``tenant_id`` column. Order matters: put ``Tenanted`` before other
21
+ mixins but after your primary-key column."""
22
+
23
+ tenant_id: str = Column(nullable=False, index=True)
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.5
2
+ Name: austial-tenancy
3
+ Version: 0.2.0
4
+ Summary: Multi-tenant primitives -- request-scoped TenantContext, TenantGuard, Tenanted entity mixin, tenant-scope enforcement (mirrors @nestjs/microservices' scope patterns, adapted for HTTP).
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: austial-auth
7
+ Requires-Dist: austial-common
8
+ Requires-Dist: austial-core
9
+ Requires-Dist: austial-orm
@@ -0,0 +1,10 @@
1
+ austial/tenancy/__init__.py,sha256=H9SN-6IJgBcsJyRFLm-k1DTwhKvXIiVr7S6_DHg5V7U,1428
2
+ austial/tenancy/decorators.py,sha256=xOPk2k_GbhrMhfpn5BEArfmLhha4mdSYXAbkBagSCxI,887
3
+ austial/tenancy/errors.py,sha256=BWA5M5o6-cUWRnGmH0nXj8AcYdKSHWjyVEkqjKiqjuU,610
4
+ austial/tenancy/tenancy_module.py,sha256=XReXao8L269ArytkdeqL6OgnPrhoBamBqDBFRjjsZxE,632
5
+ austial/tenancy/tenant_context.py,sha256=MUnpWVnE1Glg2czN9vb9_gR-WojySw_Q5i7NGWuYvIk,2362
6
+ austial/tenancy/tenant_guard.py,sha256=4It_6wo6x50OlwHLv78kt6LQmkPx1TjkX3pG-mubdDU,3323
7
+ austial/tenancy/tenanted.py,sha256=LToJwMv_0LlHgwMHx1utU9lLTCh4PLmzTfNRGSCBfgM,939
8
+ austial_tenancy-0.2.0.dist-info/METADATA,sha256=cH0DG5Iripe4utb4rvunRYjHXLTRQYl4mkCel2SLjJQ,390
9
+ austial_tenancy-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
10
+ austial_tenancy-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any