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.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .env
5
+ *.db
6
+ .pytest_cache/
7
+ .coverage
8
+ htmlcov/
9
+ dist/
10
+ build/
11
+ *.egg-info/
12
+ .DS_Store
@@ -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]