terp-cap-tenancy 0.1.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.
- terp_cap_tenancy-0.1.0/.gitignore +47 -0
- terp_cap_tenancy-0.1.0/PKG-INFO +8 -0
- terp_cap_tenancy-0.1.0/escape-hatch-budget.json +3 -0
- terp_cap_tenancy-0.1.0/pyproject.toml +19 -0
- terp_cap_tenancy-0.1.0/src/terp/capabilities/tenancy/__init__.py +39 -0
- terp_cap_tenancy-0.1.0/src/terp/capabilities/tenancy/context.py +53 -0
- terp_cap_tenancy-0.1.0/src/terp/capabilities/tenancy/middleware.py +56 -0
- terp_cap_tenancy-0.1.0/src/terp/capabilities/tenancy/models.py +48 -0
- terp_cap_tenancy-0.1.0/src/terp/capabilities/tenancy/py.typed +0 -0
- terp_cap_tenancy-0.1.0/src/terp/capabilities/tenancy/service.py +52 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
.venv-*/
|
|
10
|
+
venv/
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.coverage
|
|
15
|
+
htmlcov/
|
|
16
|
+
|
|
17
|
+
# uv
|
|
18
|
+
uv.lock
|
|
19
|
+
|
|
20
|
+
# Node
|
|
21
|
+
node_modules/
|
|
22
|
+
.pnpm-store/
|
|
23
|
+
*.tsbuildinfo
|
|
24
|
+
|
|
25
|
+
# Playwright (conformance e2e) artifacts
|
|
26
|
+
test-results/
|
|
27
|
+
playwright-report/
|
|
28
|
+
blob-report/
|
|
29
|
+
playwright/.cache/
|
|
30
|
+
.last-run.json
|
|
31
|
+
|
|
32
|
+
# Local frontend template render checks
|
|
33
|
+
apps/example/_frontend_tpl_check/
|
|
34
|
+
|
|
35
|
+
# Editor / OS
|
|
36
|
+
.DS_Store
|
|
37
|
+
.idea/
|
|
38
|
+
*.local
|
|
39
|
+
|
|
40
|
+
# Local environment overrides — never commit (a real .env may hold SECRET_KEY).
|
|
41
|
+
# The tracked template is `.env.example`.
|
|
42
|
+
.env
|
|
43
|
+
.env.*
|
|
44
|
+
!.env.example
|
|
45
|
+
!.env.example.jinja
|
|
46
|
+
# Rendered app-declared variables (environment.schema.json) — may hold secrets.
|
|
47
|
+
.app.env
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: terp-cap-tenancy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Terp tenancy capability — tenant isolation by construction (session-level filter + insert stamp).
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Requires-Dist: starlette>=0.37
|
|
8
|
+
Requires-Dist: terp-core==0.1.0
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "terp-cap-tenancy"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Terp tenancy capability — tenant isolation by construction (session-level filter + insert stamp)."
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"terp-core==0.1.0",
|
|
13
|
+
"starlette>=0.37",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
# PEP 420 namespace package: this distribution owns only `terp.capabilities.tenancy`.
|
|
17
|
+
[tool.hatch.build.targets.wheel]
|
|
18
|
+
sources = ["src"]
|
|
19
|
+
only-include = ["src/terp/capabilities/tenancy"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""terp.capabilities.tenancy — tenant scoping as a capability (kernel stays agnostic).
|
|
2
|
+
|
|
3
|
+
The kernel ships no tenant column, predicate, or scoping model. This capability
|
|
4
|
+
adds them: a model becomes tenant-scoped by mixing in :class:`TenantScopedMixin`;
|
|
5
|
+
the capability registers a **row-scope predicate** into the kernel's scope registry,
|
|
6
|
+
so every read of a tenant-scoped model is filtered to the current tenant centrally
|
|
7
|
+
(no ``base_query`` override, no ``super()``). Its service extends
|
|
8
|
+
:class:`TenantScopedService`, which stamps ``tenant_id`` on create — no kernel change.
|
|
9
|
+
|
|
10
|
+
The current tenant is held in a :class:`~contextvars.ContextVar` set via
|
|
11
|
+
:func:`tenant_context`. In an HTTP app, :class:`TenantMiddleware` binds it once
|
|
12
|
+
per request from the caller's verified token (the app supplies the resolver, e.g.
|
|
13
|
+
``terp.capabilities.auth.tenant_from_bearer``); tests set it directly. A missing
|
|
14
|
+
context fails closed: scoped reads return nothing and scoped writes raise
|
|
15
|
+
:class:`TenantContextError`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from terp.capabilities.tenancy.context import (
|
|
21
|
+
TenantContextError,
|
|
22
|
+
current_tenant_id,
|
|
23
|
+
require_tenant,
|
|
24
|
+
tenant_context,
|
|
25
|
+
)
|
|
26
|
+
from terp.capabilities.tenancy.middleware import TenantMiddleware, TenantResolver
|
|
27
|
+
from terp.capabilities.tenancy.models import TenantScopedMixin
|
|
28
|
+
from terp.capabilities.tenancy.service import TenantScopedService
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"TenantContextError",
|
|
32
|
+
"TenantMiddleware",
|
|
33
|
+
"TenantResolver",
|
|
34
|
+
"TenantScopedMixin",
|
|
35
|
+
"TenantScopedService",
|
|
36
|
+
"current_tenant_id",
|
|
37
|
+
"require_tenant",
|
|
38
|
+
"tenant_context",
|
|
39
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""The current-tenant context (a ``ContextVar`` + helpers)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
|
|
10
|
+
from terp.core import AppError
|
|
11
|
+
|
|
12
|
+
_current_tenant: ContextVar[uuid.UUID | None] = ContextVar(
|
|
13
|
+
"terp_current_tenant", default=None
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TenantContextError(AppError):
|
|
18
|
+
"""Raised when a tenant-scoped operation runs with no tenant in context."""
|
|
19
|
+
|
|
20
|
+
status_code = 500
|
|
21
|
+
code = "tenant_context_missing"
|
|
22
|
+
default_message = "No tenant is set for the current operation."
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def current_tenant_id() -> uuid.UUID | None:
|
|
26
|
+
"""Return the tenant for the active operation, or ``None``."""
|
|
27
|
+
return _current_tenant.get()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def require_tenant() -> uuid.UUID:
|
|
31
|
+
"""Return the current tenant, or raise :class:`TenantContextError`."""
|
|
32
|
+
tenant = _current_tenant.get()
|
|
33
|
+
if tenant is None:
|
|
34
|
+
raise TenantContextError()
|
|
35
|
+
return tenant
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@contextlib.contextmanager
|
|
39
|
+
def tenant_context(tenant_id: uuid.UUID | None) -> Iterator[None]:
|
|
40
|
+
"""Bind *tenant_id* as the current tenant for the duration of the block."""
|
|
41
|
+
token = _current_tenant.set(tenant_id)
|
|
42
|
+
try:
|
|
43
|
+
yield
|
|
44
|
+
finally:
|
|
45
|
+
_current_tenant.reset(token)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"TenantContextError",
|
|
50
|
+
"current_tenant_id",
|
|
51
|
+
"require_tenant",
|
|
52
|
+
"tenant_context",
|
|
53
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""``TenantMiddleware`` — bind the per-request tenant from the caller's request.
|
|
2
|
+
|
|
3
|
+
A **pure-ASGI** middleware (deliberately not ``BaseHTTPMiddleware``, which runs the
|
|
4
|
+
downstream app in a separate task and so would not propagate a ``ContextVar`` set
|
|
5
|
+
here to the endpoint). It calls an app-supplied ``resolve_tenant(request)`` — e.g.
|
|
6
|
+
``terp.capabilities.auth.tenant_from_bearer`` — and runs the request inside
|
|
7
|
+
:func:`~terp.capabilities.tenancy.tenant_context`, resetting it afterwards.
|
|
8
|
+
|
|
9
|
+
The tenancy capability stays decoupled from auth: it knows *how* to bind a tenant
|
|
10
|
+
per request, not *how* to read one. The app wires the two together through the
|
|
11
|
+
sanctioned ``create_app`` middleware seam (ADR 0021) — never a bare
|
|
12
|
+
``add_middleware`` (the ``no_adhoc_middleware`` rule forbids it)::
|
|
13
|
+
|
|
14
|
+
from starlette.middleware import Middleware
|
|
15
|
+
from terp.capabilities.auth import tenant_from_bearer
|
|
16
|
+
|
|
17
|
+
app = create_app(
|
|
18
|
+
specs,
|
|
19
|
+
principal_provider=get_principal,
|
|
20
|
+
middleware=[Middleware(TenantMiddleware, resolve_tenant=tenant_from_bearer)],
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
Fail-closed: a request that resolves to no tenant runs with an empty context, so
|
|
24
|
+
every ``TenantScopedService`` read returns nothing and writes raise.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import uuid
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
|
|
32
|
+
from starlette.requests import Request
|
|
33
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
34
|
+
|
|
35
|
+
from terp.capabilities.tenancy.context import tenant_context
|
|
36
|
+
|
|
37
|
+
TenantResolver = Callable[[Request], uuid.UUID | None]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TenantMiddleware:
|
|
41
|
+
"""ASGI middleware that binds ``tenant_context`` from *resolve_tenant* per request."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, app: ASGIApp, *, resolve_tenant: TenantResolver) -> None:
|
|
44
|
+
self.app = app
|
|
45
|
+
self._resolve_tenant = resolve_tenant
|
|
46
|
+
|
|
47
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
48
|
+
if scope["type"] != "http":
|
|
49
|
+
await self.app(scope, receive, send)
|
|
50
|
+
return
|
|
51
|
+
tenant = self._resolve_tenant(Request(scope))
|
|
52
|
+
with tenant_context(tenant):
|
|
53
|
+
await self.app(scope, receive, send)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = ["TenantMiddleware", "TenantResolver"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""``TenantScopedMixin`` — marks a table's rows as belonging to a tenant.
|
|
2
|
+
|
|
3
|
+
Importing this mixin registers the tenancy row-scope predicate with the kernel's
|
|
4
|
+
scope registry, so the runtime read-scope layer is installed as soon as a model opts
|
|
5
|
+
into tenancy (ADR 0017). ``TenantScopedService`` still stamps ``tenant_id`` on
|
|
6
|
+
create; the mixin owns the read predicate.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
from sqlalchemy import false
|
|
14
|
+
from sqlmodel import Field, SQLModel
|
|
15
|
+
from sqlmodel.sql.expression import SelectOfScalar
|
|
16
|
+
|
|
17
|
+
from terp.capabilities.tenancy.context import current_tenant_id
|
|
18
|
+
from terp.core import register_scope_predicate
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TenantScopedMixin(SQLModel):
|
|
22
|
+
"""Mix into a ``BaseTable`` to scope its rows to a tenant.
|
|
23
|
+
|
|
24
|
+
Adds a non-null, indexed ``tenant_id``. The registered tenant predicate filters
|
|
25
|
+
every read by the current tenant, and ``TenantScopedService`` stamps it on insert,
|
|
26
|
+
so a model is tenant-scoped purely by inheriting this — the kernel needs no
|
|
27
|
+
tenant-specific import.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
tenant_id: uuid.UUID = Field(index=True, nullable=False)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _tenant_scope_predicate(
|
|
34
|
+
model: type[SQLModel], query: SelectOfScalar
|
|
35
|
+
) -> SelectOfScalar:
|
|
36
|
+
"""Filter a tenant-scoped model's reads by the current tenant (registered centrally)."""
|
|
37
|
+
if issubclass(model, TenantScopedMixin):
|
|
38
|
+
tenant_id = current_tenant_id()
|
|
39
|
+
if tenant_id is None:
|
|
40
|
+
return query.where(false())
|
|
41
|
+
return query.where(model.tenant_id == tenant_id) # type: ignore[attr-defined] # arch-allow-no-manual-scope-filtering: this IS the central tenant predicate the rule points app modules to
|
|
42
|
+
return query
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
register_scope_predicate(_tenant_scope_predicate)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = ["TenantScopedMixin"]
|
|
File without changes
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""``TenantScopedService`` — write stamping for tenant-scoped models.
|
|
2
|
+
|
|
3
|
+
The kernel stays tenancy-agnostic. A model becomes tenant-scoped by mixing in
|
|
4
|
+
:class:`~terp.capabilities.tenancy.TenantScopedMixin`, which registers the
|
|
5
|
+
**row-scope predicate** with the kernel's scope registry. Reads are therefore
|
|
6
|
+
filtered centrally (no ``base_query`` override, no ``super()``, ADR 0017), and this
|
|
7
|
+
service only stamps ``tenant_id`` on create (rejecting an absent context).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TypeVar
|
|
13
|
+
|
|
14
|
+
from sqlmodel import Session, SQLModel
|
|
15
|
+
|
|
16
|
+
from terp.capabilities.tenancy.context import require_tenant
|
|
17
|
+
from terp.core import (
|
|
18
|
+
AuditAction,
|
|
19
|
+
BaseService,
|
|
20
|
+
BaseTable,
|
|
21
|
+
BaseUpdateSchema,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
ModelT = TypeVar("ModelT", bound=BaseTable)
|
|
25
|
+
CreateT = TypeVar("CreateT", bound=SQLModel)
|
|
26
|
+
UpdateT = TypeVar("UpdateT", bound=BaseUpdateSchema)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TenantScopedService(BaseService[ModelT, CreateT, UpdateT]):
|
|
30
|
+
"""A :class:`~terp.core.BaseService` whose model is tenant-scoped.
|
|
31
|
+
|
|
32
|
+
``model`` must mix in :class:`~terp.capabilities.tenancy.TenantScopedMixin`.
|
|
33
|
+
Reads are filtered to ``current_tenant_id()`` by the registered scope predicate
|
|
34
|
+
(``None`` matches no rows, so a missing context fails closed); this service
|
|
35
|
+
stamps the required tenant on create.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def create(self, session: Session, data: CreateT) -> ModelT:
|
|
39
|
+
# Route through the audited chokepoint so a tenant-scoped create is audited,
|
|
40
|
+
# actor-stamped, event-hooked, and 409-mapped exactly like every other write.
|
|
41
|
+
# Strip framework-managed columns first (anti over-posting, like BaseService),
|
|
42
|
+
# then stamp the tenant from context -- never from the request body. Stripping
|
|
43
|
+
# tenant_id from the payload also avoids a duplicate-keyword collision with the
|
|
44
|
+
# context-derived value below.
|
|
45
|
+
entity = self.model(
|
|
46
|
+
**self._without_managed_columns(data.model_dump()),
|
|
47
|
+
tenant_id=require_tenant(),
|
|
48
|
+
)
|
|
49
|
+
return self._save(session, entity, AuditAction.CREATED)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
__all__ = ["TenantScopedService"]
|