austial-tenancy 0.2.0__tar.gz
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-0.2.0/.gitignore +12 -0
- austial_tenancy-0.2.0/PKG-INFO +9 -0
- austial_tenancy-0.2.0/pyproject.toml +30 -0
- austial_tenancy-0.2.0/src/austial/tenancy/__init__.py +33 -0
- austial_tenancy-0.2.0/src/austial/tenancy/decorators.py +29 -0
- austial_tenancy-0.2.0/src/austial/tenancy/errors.py +19 -0
- austial_tenancy-0.2.0/src/austial/tenancy/tenancy_module.py +19 -0
- austial_tenancy-0.2.0/src/austial/tenancy/tenant_context.py +70 -0
- austial_tenancy-0.2.0/src/austial/tenancy/tenant_guard.py +74 -0
- austial_tenancy-0.2.0/src/austial/tenancy/tenanted.py +23 -0
- austial_tenancy-0.2.0/test/tenancy_spec.py +197 -0
|
@@ -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,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "austial-tenancy"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "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
|
+
dependencies = [
|
|
7
|
+
"austial-common",
|
|
8
|
+
"austial-core",
|
|
9
|
+
"austial-auth",
|
|
10
|
+
"austial-orm",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
15
|
+
build-backend = "hatchling.build"
|
|
16
|
+
|
|
17
|
+
[tool.hatch.build.targets.wheel]
|
|
18
|
+
packages = ["src/austial"]
|
|
19
|
+
|
|
20
|
+
[tool.uv.sources]
|
|
21
|
+
austial-common = { workspace = true }
|
|
22
|
+
austial-core = { workspace = true }
|
|
23
|
+
austial-auth = { workspace = true }
|
|
24
|
+
austial-orm = { workspace = true }
|
|
25
|
+
|
|
26
|
+
[tool.hatch.version]
|
|
27
|
+
source = "vcs"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.version.raw-options]
|
|
30
|
+
root = "../.."
|
|
@@ -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,197 @@
|
|
|
1
|
+
"""``austial.tenancy`` unit tests.
|
|
2
|
+
|
|
3
|
+
Exercises the four load-bearing behaviours:
|
|
4
|
+
|
|
5
|
+
- ``TenantContext`` is request-scoped: setting it in one task does not leak into
|
|
6
|
+
another; a reset restores the prior value.
|
|
7
|
+
- ``TenantContext.require()`` raises when unset.
|
|
8
|
+
- ``TenantGuard`` populates the context from the ``tenant`` claim of the
|
|
9
|
+
authenticated principal.
|
|
10
|
+
- ``TenantGuard`` fails-closed on ``@RequireTenant`` handlers with no claim,
|
|
11
|
+
and fails-closed on a path-tenant that disagrees with the claim.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
from types import SimpleNamespace
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from austial.common import ExecutionContext
|
|
22
|
+
from austial.tenancy import (
|
|
23
|
+
MissingTenantError,
|
|
24
|
+
RequireTenant,
|
|
25
|
+
TenantContext,
|
|
26
|
+
TenantContextValue,
|
|
27
|
+
TenantGuard,
|
|
28
|
+
TenantMismatchError,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _FakeExecutionContext(ExecutionContext):
|
|
33
|
+
def __init__(self, request, controller_class, handler):
|
|
34
|
+
self._request = request
|
|
35
|
+
self._controller_class = controller_class
|
|
36
|
+
self._handler = handler
|
|
37
|
+
|
|
38
|
+
def get_request(self):
|
|
39
|
+
return self._request
|
|
40
|
+
|
|
41
|
+
def get_class(self):
|
|
42
|
+
return self._controller_class
|
|
43
|
+
|
|
44
|
+
def get_handler(self):
|
|
45
|
+
return self._handler
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _principal(tenant: str | None):
|
|
49
|
+
return SimpleNamespace(claims={"tenant": tenant} if tenant is not None else {})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _request(principal, path_params: dict | None = None):
|
|
53
|
+
return SimpleNamespace(
|
|
54
|
+
state=SimpleNamespace(user=principal),
|
|
55
|
+
path_params=path_params or {},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# TenantContext
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_context_starts_unset():
|
|
64
|
+
assert TenantContext.get() is None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_context_require_raises_when_unset():
|
|
68
|
+
with pytest.raises(MissingTenantError):
|
|
69
|
+
TenantContext.require()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_context_set_and_reset_round_trip():
|
|
73
|
+
token = TenantContext.set(TenantContextValue(tenant_id="acme"))
|
|
74
|
+
try:
|
|
75
|
+
assert TenantContext.require().tenant_id == "acme"
|
|
76
|
+
finally:
|
|
77
|
+
TenantContext.reset(token)
|
|
78
|
+
assert TenantContext.get() is None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.asyncio
|
|
82
|
+
async def test_context_is_request_scoped_across_tasks():
|
|
83
|
+
# Two concurrent tasks set different tenants; neither should see the other's.
|
|
84
|
+
async def task(tenant: str):
|
|
85
|
+
token = TenantContext.set(TenantContextValue(tenant_id=tenant))
|
|
86
|
+
try:
|
|
87
|
+
await asyncio.sleep(0) # yield to the scheduler
|
|
88
|
+
return TenantContext.require().tenant_id
|
|
89
|
+
finally:
|
|
90
|
+
TenantContext.reset(token)
|
|
91
|
+
|
|
92
|
+
seen = await asyncio.gather(task("acme"), task("initech"), task("hooli"))
|
|
93
|
+
assert sorted(seen) == ["acme", "hooli", "initech"]
|
|
94
|
+
# After all tasks complete, the outer context is still unset
|
|
95
|
+
assert TenantContext.get() is None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# TenantGuard
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.mark.asyncio
|
|
103
|
+
async def test_guard_populates_context_from_claim():
|
|
104
|
+
guard = TenantGuard()
|
|
105
|
+
|
|
106
|
+
def handler():
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
req = _request(_principal("acme"))
|
|
110
|
+
ctx = _FakeExecutionContext(req, object, handler)
|
|
111
|
+
assert await guard.can_activate(ctx) is True
|
|
112
|
+
assert TenantContext.require().tenant_id == "acme"
|
|
113
|
+
guard.teardown(req)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@pytest.mark.asyncio
|
|
117
|
+
async def test_guard_is_permissive_without_require_marker():
|
|
118
|
+
guard = TenantGuard()
|
|
119
|
+
|
|
120
|
+
def handler():
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
# No principal at all: guard allows through so a @Public route can still run
|
|
124
|
+
req = _request(None)
|
|
125
|
+
ctx = _FakeExecutionContext(req, object, handler)
|
|
126
|
+
assert await guard.can_activate(ctx) is True
|
|
127
|
+
assert TenantContext.get() is None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@pytest.mark.asyncio
|
|
131
|
+
async def test_guard_rejects_missing_tenant_when_required():
|
|
132
|
+
guard = TenantGuard()
|
|
133
|
+
|
|
134
|
+
@RequireTenant()
|
|
135
|
+
def handler():
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
req = _request(_principal(None))
|
|
139
|
+
ctx = _FakeExecutionContext(req, object, handler)
|
|
140
|
+
with pytest.raises(MissingTenantError):
|
|
141
|
+
await guard.can_activate(ctx)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@pytest.mark.asyncio
|
|
145
|
+
async def test_guard_rejects_tenant_path_mismatch():
|
|
146
|
+
guard = TenantGuard()
|
|
147
|
+
|
|
148
|
+
def handler():
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
req = _request(_principal("acme"), path_params={"tenant_id": "hooli"})
|
|
152
|
+
ctx = _FakeExecutionContext(req, object, handler)
|
|
153
|
+
with pytest.raises(TenantMismatchError):
|
|
154
|
+
await guard.can_activate(ctx)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@pytest.mark.asyncio
|
|
158
|
+
async def test_guard_publishes_tenant_on_request_state_for_the_audit_interceptor():
|
|
159
|
+
guard = TenantGuard()
|
|
160
|
+
|
|
161
|
+
def handler():
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
req = _request(_principal("acme"))
|
|
165
|
+
assert await guard.can_activate(_FakeExecutionContext(req, object, handler)) is True
|
|
166
|
+
assert req.state.tenant == "acme"
|
|
167
|
+
guard.teardown(req)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@pytest.mark.asyncio
|
|
171
|
+
async def test_require_by_default_subclass_rejects_undecorated_handler_without_tenant():
|
|
172
|
+
class StrictTenantGuard(TenantGuard):
|
|
173
|
+
require_by_default = True
|
|
174
|
+
|
|
175
|
+
def handler():
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
with pytest.raises(MissingTenantError):
|
|
179
|
+
await StrictTenantGuard().can_activate(_FakeExecutionContext(_request(_principal(None)), object, handler))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_tenanted_mixin_adds_a_required_indexed_tenant_id_column():
|
|
183
|
+
from austial.orm import Entity, PrimaryColumn
|
|
184
|
+
from austial.orm.decorators.entity import get_entity_metadata
|
|
185
|
+
from austial.orm.metadata.column_types import String
|
|
186
|
+
from austial.tenancy import Tenanted
|
|
187
|
+
|
|
188
|
+
@Entity("tenanted_widgets")
|
|
189
|
+
class Widget(Tenanted):
|
|
190
|
+
id: str = PrimaryColumn(type_=String(length=36))
|
|
191
|
+
|
|
192
|
+
column = get_entity_metadata(Widget).column("tenant_id")
|
|
193
|
+
assert column is not None and column.index is True and column.nullable is False
|
|
194
|
+
# mypy caveat (documented in content/techniques/multi-tenancy.mdx#tenanted-entities): `Tenanted`
|
|
195
|
+
# is a plain mixin, not itself part of the dataclass_transform chain, so mypy doesn't see
|
|
196
|
+
# `tenant_id` as a constructor parameter even though it works correctly at runtime.
|
|
197
|
+
assert Widget(id="w1", tenant_id="acme").tenant_id == "acme" # type: ignore[call-arg]
|