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.
- austial/tenancy/__init__.py +33 -0
- austial/tenancy/decorators.py +29 -0
- austial/tenancy/errors.py +19 -0
- austial/tenancy/tenancy_module.py +19 -0
- austial/tenancy/tenant_context.py +70 -0
- austial/tenancy/tenant_guard.py +74 -0
- austial/tenancy/tenanted.py +23 -0
- austial_tenancy-0.2.0.dist-info/METADATA +9 -0
- austial_tenancy-0.2.0.dist-info/RECORD +10 -0
- austial_tenancy-0.2.0.dist-info/WHEEL +4 -0
|
@@ -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,,
|