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.
@@ -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,3 @@
1
+ {
2
+ "arch-allow-no-manual-scope-filtering": 1
3
+ }
@@ -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"]
@@ -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"]