fastplace-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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Firoz Anam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,193 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastplace-tenancy
3
+ Version: 0.1.0
4
+ Summary: First-party, opt-in multi-tenancy for Fastplace — company-scoped models, defense-in-depth isolation.
5
+ Author: Firoz Anam
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://fastplace.dev
8
+ Project-URL: Repository, https://github.com/fastplace-dev/fastplace.dev
9
+ Project-URL: Issues, https://github.com/fastplace-dev/fastplace.dev/issues
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: fastplace>=0.1.0
14
+ Dynamic: license-file
15
+
16
+ # fastplace-tenancy
17
+
18
+ First-party, **opt-in** multi-tenancy for
19
+ [Fastplace](https://fastplace.dev). The core framework stays tenant-agnostic;
20
+ applications that need company scoping install this package and get the same
21
+ class of guarantees the core gives soft deletes — invariants enforced by the
22
+ framework, not by developer discipline.
23
+
24
+ pip install fastplace-tenancy
25
+
26
+ ## The isolation boundary
27
+
28
+ Single database, company-scoped rows: `Account → Company → company_id →
29
+ company-scoped records`. Every company-owned table carries a `company_id`
30
+ column, and this package keeps that column meaningful at every layer a
31
+ request touches (blueprint §8, "Multi-Tenant Data Architecture"):
32
+
33
+ | Layer | Strategy |
34
+ | :--- | :--- |
35
+ | ORM | Automatic `company` global scope on `CompanyScopedModel` |
36
+ | API | `CompanyContextMiddleware` binds the request to one company |
37
+ | Authorization | Membership checks (`require_membership`) for services |
38
+ | Database | PostgreSQL Row-Level Security helpers (`fastplace_tenancy.rls`) |
39
+ | Cache | `company:{id}:…` key prefix (`CompanyCacheStore`) |
40
+ | Queue | Company context carried in job metadata (`TenantQueue` + `@tenant_job`) |
41
+ | Files | Company prefixes under `storage/` with traversal guards |
42
+ | Search | Company filter applied over the active `SearchService` |
43
+ | Vector store | Company filter on similarity results |
44
+ | MongoDB | Automatic `company_id` filters (`CompanyDocument`) |
45
+
46
+ ## Automatic company scoping
47
+
48
+ Company-owned models extend `CompanyScopedModel`; the package ships the
49
+ tenant entities themselves:
50
+
51
+ ```python
52
+ from fastplace_tenancy import Company, CompanyMembership, CompanyScopedModel
53
+
54
+
55
+ class Project(CompanyScopedModel):
56
+ __tablename__ = "projects"
57
+ __unique_per_company__ = [("slug",)] # UNIQUE(company_id, slug)
58
+
59
+ title: str
60
+ slug: str
61
+ ```
62
+
63
+ Every read now receives the tenant filter — `SELECT … WHERE company_id = ?` —
64
+ and every `create()` stamps the column from the active company context. The
65
+ `company_id` column is guarded from mass assignment: a payload cannot move a
66
+ row into (or read it as) another company.
67
+
68
+ ### Fail-closed by design
69
+
70
+ Querying a `CompanyScopedModel` **without** a company context raises
71
+ `MissingCompanyContext` — a missing tenant is a bug, not "return everything".
72
+ Cross-company work (admin shells, exports) is an explicit escape:
73
+
74
+ ```python
75
+ await Project.without_global_scope("company").where(...) # this query only
76
+ async with company_context(company.id): # bind a context
77
+ await Project.all()
78
+ ```
79
+
80
+ ## Request binding
81
+
82
+ ```python
83
+ # config/app.py
84
+ MIDDLEWARE = [
85
+ "app.http.middleware.resolve_user.ResolveUserMiddleware",
86
+ "fastplace_tenancy.middleware.CompanyContextMiddleware",
87
+ "app.http.middleware.csrf.CsrfMiddleware",
88
+ ]
89
+ ```
90
+
91
+ The middleware resolves the company for the authenticated user:
92
+
93
+ 1. a session `company_id` — **validated against membership**. The stored
94
+ value is a request, not a grant: it binds only when the user actually
95
+ holds a membership in that company, otherwise it is ignored;
96
+ 2. else the user's earliest membership (ordered by membership id — a
97
+ documented key, not whatever the query planner returns first);
98
+ 3. else nothing — the request stays unbound and company-scoped queries fail
99
+ closed.
100
+
101
+ Controllers read the binding through the helper (there is no
102
+ `request.state.company_id`):
103
+
104
+ ```python
105
+ from fastplace_tenancy.middleware import request_company_id
106
+
107
+ company_id = request_company_id(request) # None when unbound
108
+ ```
109
+
110
+ A company-switch endpoint should pair the session write with a membership
111
+ check, so a forged or stale value never even reaches the middleware:
112
+
113
+ ```python
114
+ async def switch(self, request):
115
+ body = await request.json()
116
+ await require_membership(request.user.id, body["company_id"])
117
+ request.session["company_id"] = body["company_id"]
118
+ ```
119
+
120
+ Override `_resolve_company_id()` to source the company differently
121
+ (subdomain, header, JWT claim).
122
+
123
+ ## Services check membership
124
+
125
+ ```python
126
+ from fastplace_tenancy import require_membership
127
+
128
+
129
+ async def invite(request, company_id: int):
130
+ await require_membership(request.user.id, company_id, roles={"owner", "admin"})
131
+ ...
132
+ ```
133
+
134
+ ## Queue jobs carry the company
135
+
136
+ Wrap the driver once and decorate handlers; the context travels as job
137
+ metadata and re-binds inside the worker. Install the wrapper as the
138
+ process-wide queue so domain-event auto-dispatch flows through it too:
139
+
140
+ ```python
141
+ from fastplace.queue import set_queue
142
+
143
+ from fastplace_tenancy import TenantQueue, tenant_job
144
+
145
+ set_queue(TenantQueue(existing_driver))
146
+
147
+
148
+ @Job()
149
+ @tenant_job
150
+ async def rebuild_report(project_id: int): ...
151
+ ```
152
+
153
+ `@tenant_job` requires an `async def` handler — a sync function fails at
154
+ decoration with a `TypeError`, the same guard `@Job` itself applies.
155
+
156
+ ## Multi-tenant indexing & uniqueness
157
+
158
+ High-volume company tables index with `company_id` first — the base model's
159
+ `company_id` column is indexed; declare composites with
160
+ `__index_per_company__`. Uniqueness distinguishes globally unique
161
+ (`Field(unique=True)`) from unique-within-a-company
162
+ (`__unique_per_company__` → `UNIQUE(company_id, …)`).
163
+
164
+ ## Row-Level Security (PostgreSQL)
165
+
166
+ Where the backend supports native RLS (see the capability registry — MySQL
167
+ and SQLite never claim it), `fastplace_tenancy.rls` can push the same policy
168
+ into the database itself as defense-in-depth below the ORM scope:
169
+
170
+ ```python
171
+ from fastplace_tenancy.rls import enable_company_rls, set_rls_company
172
+
173
+ await enable_company_rls(Project) # once, at migration time
174
+
175
+ async with db.connection(): # per transaction
176
+ await set_rls_company(company.id)
177
+ ...
178
+ ```
179
+
180
+ `set_rls_company` uses `SET LOCAL`, so the setting **dies with the
181
+ transaction** — a pooled connection carries nothing into the next borrower's
182
+ transaction. Deployment duties the DDL cannot do for you: the connecting role
183
+ must not own the table or carry `BYPASSRLS` (owners bypass policies
184
+ silently), and every transaction must set the company before touching
185
+ company tables.
186
+
187
+ ## Tenant-isolation contract suite
188
+
189
+ `tests/tenancy/` is the contract: two companies, one code path — cross-company
190
+ reads come back empty, writes stamp the right tenant, cache keys never
191
+ collide, jobs re-bind their company, and uniqueness is per-company. The suite
192
+ runs on the same portable matrix as the core (SQLite always; PostgreSQL /
193
+ MySQL when `TEST_*_URL` is set).
@@ -0,0 +1,178 @@
1
+ # fastplace-tenancy
2
+
3
+ First-party, **opt-in** multi-tenancy for
4
+ [Fastplace](https://fastplace.dev). The core framework stays tenant-agnostic;
5
+ applications that need company scoping install this package and get the same
6
+ class of guarantees the core gives soft deletes — invariants enforced by the
7
+ framework, not by developer discipline.
8
+
9
+ pip install fastplace-tenancy
10
+
11
+ ## The isolation boundary
12
+
13
+ Single database, company-scoped rows: `Account → Company → company_id →
14
+ company-scoped records`. Every company-owned table carries a `company_id`
15
+ column, and this package keeps that column meaningful at every layer a
16
+ request touches (blueprint §8, "Multi-Tenant Data Architecture"):
17
+
18
+ | Layer | Strategy |
19
+ | :--- | :--- |
20
+ | ORM | Automatic `company` global scope on `CompanyScopedModel` |
21
+ | API | `CompanyContextMiddleware` binds the request to one company |
22
+ | Authorization | Membership checks (`require_membership`) for services |
23
+ | Database | PostgreSQL Row-Level Security helpers (`fastplace_tenancy.rls`) |
24
+ | Cache | `company:{id}:…` key prefix (`CompanyCacheStore`) |
25
+ | Queue | Company context carried in job metadata (`TenantQueue` + `@tenant_job`) |
26
+ | Files | Company prefixes under `storage/` with traversal guards |
27
+ | Search | Company filter applied over the active `SearchService` |
28
+ | Vector store | Company filter on similarity results |
29
+ | MongoDB | Automatic `company_id` filters (`CompanyDocument`) |
30
+
31
+ ## Automatic company scoping
32
+
33
+ Company-owned models extend `CompanyScopedModel`; the package ships the
34
+ tenant entities themselves:
35
+
36
+ ```python
37
+ from fastplace_tenancy import Company, CompanyMembership, CompanyScopedModel
38
+
39
+
40
+ class Project(CompanyScopedModel):
41
+ __tablename__ = "projects"
42
+ __unique_per_company__ = [("slug",)] # UNIQUE(company_id, slug)
43
+
44
+ title: str
45
+ slug: str
46
+ ```
47
+
48
+ Every read now receives the tenant filter — `SELECT … WHERE company_id = ?` —
49
+ and every `create()` stamps the column from the active company context. The
50
+ `company_id` column is guarded from mass assignment: a payload cannot move a
51
+ row into (or read it as) another company.
52
+
53
+ ### Fail-closed by design
54
+
55
+ Querying a `CompanyScopedModel` **without** a company context raises
56
+ `MissingCompanyContext` — a missing tenant is a bug, not "return everything".
57
+ Cross-company work (admin shells, exports) is an explicit escape:
58
+
59
+ ```python
60
+ await Project.without_global_scope("company").where(...) # this query only
61
+ async with company_context(company.id): # bind a context
62
+ await Project.all()
63
+ ```
64
+
65
+ ## Request binding
66
+
67
+ ```python
68
+ # config/app.py
69
+ MIDDLEWARE = [
70
+ "app.http.middleware.resolve_user.ResolveUserMiddleware",
71
+ "fastplace_tenancy.middleware.CompanyContextMiddleware",
72
+ "app.http.middleware.csrf.CsrfMiddleware",
73
+ ]
74
+ ```
75
+
76
+ The middleware resolves the company for the authenticated user:
77
+
78
+ 1. a session `company_id` — **validated against membership**. The stored
79
+ value is a request, not a grant: it binds only when the user actually
80
+ holds a membership in that company, otherwise it is ignored;
81
+ 2. else the user's earliest membership (ordered by membership id — a
82
+ documented key, not whatever the query planner returns first);
83
+ 3. else nothing — the request stays unbound and company-scoped queries fail
84
+ closed.
85
+
86
+ Controllers read the binding through the helper (there is no
87
+ `request.state.company_id`):
88
+
89
+ ```python
90
+ from fastplace_tenancy.middleware import request_company_id
91
+
92
+ company_id = request_company_id(request) # None when unbound
93
+ ```
94
+
95
+ A company-switch endpoint should pair the session write with a membership
96
+ check, so a forged or stale value never even reaches the middleware:
97
+
98
+ ```python
99
+ async def switch(self, request):
100
+ body = await request.json()
101
+ await require_membership(request.user.id, body["company_id"])
102
+ request.session["company_id"] = body["company_id"]
103
+ ```
104
+
105
+ Override `_resolve_company_id()` to source the company differently
106
+ (subdomain, header, JWT claim).
107
+
108
+ ## Services check membership
109
+
110
+ ```python
111
+ from fastplace_tenancy import require_membership
112
+
113
+
114
+ async def invite(request, company_id: int):
115
+ await require_membership(request.user.id, company_id, roles={"owner", "admin"})
116
+ ...
117
+ ```
118
+
119
+ ## Queue jobs carry the company
120
+
121
+ Wrap the driver once and decorate handlers; the context travels as job
122
+ metadata and re-binds inside the worker. Install the wrapper as the
123
+ process-wide queue so domain-event auto-dispatch flows through it too:
124
+
125
+ ```python
126
+ from fastplace.queue import set_queue
127
+
128
+ from fastplace_tenancy import TenantQueue, tenant_job
129
+
130
+ set_queue(TenantQueue(existing_driver))
131
+
132
+
133
+ @Job()
134
+ @tenant_job
135
+ async def rebuild_report(project_id: int): ...
136
+ ```
137
+
138
+ `@tenant_job` requires an `async def` handler — a sync function fails at
139
+ decoration with a `TypeError`, the same guard `@Job` itself applies.
140
+
141
+ ## Multi-tenant indexing & uniqueness
142
+
143
+ High-volume company tables index with `company_id` first — the base model's
144
+ `company_id` column is indexed; declare composites with
145
+ `__index_per_company__`. Uniqueness distinguishes globally unique
146
+ (`Field(unique=True)`) from unique-within-a-company
147
+ (`__unique_per_company__` → `UNIQUE(company_id, …)`).
148
+
149
+ ## Row-Level Security (PostgreSQL)
150
+
151
+ Where the backend supports native RLS (see the capability registry — MySQL
152
+ and SQLite never claim it), `fastplace_tenancy.rls` can push the same policy
153
+ into the database itself as defense-in-depth below the ORM scope:
154
+
155
+ ```python
156
+ from fastplace_tenancy.rls import enable_company_rls, set_rls_company
157
+
158
+ await enable_company_rls(Project) # once, at migration time
159
+
160
+ async with db.connection(): # per transaction
161
+ await set_rls_company(company.id)
162
+ ...
163
+ ```
164
+
165
+ `set_rls_company` uses `SET LOCAL`, so the setting **dies with the
166
+ transaction** — a pooled connection carries nothing into the next borrower's
167
+ transaction. Deployment duties the DDL cannot do for you: the connecting role
168
+ must not own the table or carry `BYPASSRLS` (owners bypass policies
169
+ silently), and every transaction must set the company before touching
170
+ company tables.
171
+
172
+ ## Tenant-isolation contract suite
173
+
174
+ `tests/tenancy/` is the contract: two companies, one code path — cross-company
175
+ reads come back empty, writes stamp the right tenant, cache keys never
176
+ collide, jobs re-bind their company, and uniqueness is per-company. The suite
177
+ runs on the same portable matrix as the core (SQLite always; PostgreSQL /
178
+ MySQL when `TEST_*_URL` is set).
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "fastplace-tenancy"
7
+ version = "0.1.0"
8
+ description = "First-party, opt-in multi-tenancy for Fastplace — company-scoped models, defense-in-depth isolation."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Firoz Anam" }]
14
+ dependencies = [
15
+ "fastplace>=0.1.0",
16
+ ]
17
+
18
+ [project.urls]
19
+ Homepage = "https://fastplace.dev"
20
+ Repository = "https://github.com/fastplace-dev/fastplace.dev"
21
+ Issues = "https://github.com/fastplace-dev/fastplace.dev/issues"
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,78 @@
1
+ """fastplace-tenancy — opt-in company-scoped multi-tenancy for Fastplace.
2
+
3
+ The core framework stays tenant-agnostic (ADR-005); installing this package
4
+ adds the ``company`` global scope, request binding, membership authorization,
5
+ and per-layer isolation (cache/queue/storage/search/vector/MongoDB/RLS).
6
+ Import paths here are ``fastplace_tenancy.*`` — the package is a sibling
7
+ distribution, never a ``fastplace.*`` submodule.
8
+ """
9
+
10
+ from fastplace_tenancy.authorization import require_membership
11
+ from fastplace_tenancy.cache import CompanyCacheStore
12
+ from fastplace_tenancy.context import (
13
+ CompanyRoleRequired,
14
+ MissingCompanyContext,
15
+ NotCompanyMember,
16
+ RLSNotSupported,
17
+ TenancyError,
18
+ company_context,
19
+ current_company_id,
20
+ require_company_context,
21
+ reset_company_context,
22
+ )
23
+ from fastplace_tenancy.documents import CompanyDocument
24
+ from fastplace_tenancy.middleware import CompanyContextMiddleware, request_company_id
25
+ from fastplace_tenancy.models import Company, CompanyMembership, CompanyScopedModel
26
+ from fastplace_tenancy.queue import TenantQueue, tenant_job
27
+ from fastplace_tenancy.rls import (
28
+ clear_rls_company,
29
+ enable_company_rls,
30
+ set_rls_company,
31
+ supports_rls,
32
+ )
33
+ from fastplace_tenancy.search import TenantSearchService
34
+ from fastplace_tenancy.storage import (
35
+ company_root,
36
+ company_storage_path,
37
+ ensure_company_path,
38
+ )
39
+ from fastplace_tenancy.vectors import TenantVectorStore
40
+
41
+ __all__ = [
42
+ # context
43
+ "CompanyRoleRequired",
44
+ "MissingCompanyContext",
45
+ "NotCompanyMember",
46
+ "RLSNotSupported",
47
+ "TenancyError",
48
+ "company_context",
49
+ "current_company_id",
50
+ "require_company_context",
51
+ "reset_company_context",
52
+ # models + authorization
53
+ "Company",
54
+ "CompanyMembership",
55
+ "CompanyScopedModel",
56
+ "require_membership",
57
+ # middleware
58
+ "CompanyContextMiddleware",
59
+ "request_company_id",
60
+ # cache
61
+ "CompanyCacheStore",
62
+ # queue
63
+ "TenantQueue",
64
+ "tenant_job",
65
+ # storage
66
+ "company_root",
67
+ "company_storage_path",
68
+ "ensure_company_path",
69
+ # search / vectors / documents
70
+ "TenantSearchService",
71
+ "TenantVectorStore",
72
+ "CompanyDocument",
73
+ # RLS
74
+ "clear_rls_company",
75
+ "enable_company_rls",
76
+ "set_rls_company",
77
+ "supports_rls",
78
+ ]
@@ -0,0 +1,52 @@
1
+ """Membership authorization — the service-layer tenant check.
2
+
3
+ Services call :func:`require_membership` before touching company data; the
4
+ errors are :class:`FastplaceError` subclasses, so the kernel answers with
5
+ 403 JSON instead of a traceback.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Collection
11
+ from typing import Any
12
+
13
+ from fastplace_tenancy.context import (
14
+ CompanyRoleRequired,
15
+ MissingCompanyContext,
16
+ NotCompanyMember,
17
+ require_company_context,
18
+ )
19
+
20
+
21
+ async def require_membership(
22
+ user_id: Any,
23
+ company_id: Any | None = None,
24
+ *,
25
+ roles: Collection[str] | None = None,
26
+ ) -> Any:
27
+ """Assert the actor belongs to the company; return the membership row.
28
+
29
+ ``company_id`` defaults to the bound company context. With ``roles``,
30
+ the membership's role must be one of them — possession of *a* role in
31
+ one company never authorizes the *other* company's resources.
32
+ """
33
+ if company_id is None:
34
+ company_id = require_company_context()
35
+
36
+ from fastplace_tenancy.models import CompanyMembership
37
+
38
+ membership = await CompanyMembership.where(
39
+ CompanyMembership.user_id == user_id,
40
+ CompanyMembership.company_id == company_id,
41
+ ).first()
42
+ if membership is None:
43
+ raise NotCompanyMember(f"user {user_id!r} has no membership in company {company_id!r}")
44
+ if roles is not None and membership.role not in roles:
45
+ raise CompanyRoleRequired(
46
+ f"membership role {membership.role!r} is not one of "
47
+ f"{sorted(roles)} — company {company_id!r} requires it"
48
+ )
49
+ return membership
50
+
51
+
52
+ __all__ = ["require_membership", "MissingCompanyContext"]
@@ -0,0 +1,55 @@
1
+ """Cache isolation — ``company:{id}:`` prefixed keys over any CacheStore.
2
+
3
+ Wrap the application's store once (composition, not a subclass — the store
4
+ underneath can be Redis, memory, or anything implementing the protocol)::
5
+
6
+ cache = CompanyCacheStore(existing_store)
7
+
8
+ Keys are prefixed with the bound company; without a bound company every
9
+ operation fails closed. ``flush()`` is refused outright: a company-scoped
10
+ wrapper cannot know what else lives in the shared physical store, and
11
+ "forget everyone's cache" must never be one tenant's side effect.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from fastplace_tenancy.context import require_company_context
19
+
20
+
21
+ class CompanyCacheStore:
22
+ """A CacheStore whose keys are namespaced to the bound company."""
23
+
24
+ def __init__(self, inner: Any) -> None:
25
+ self._inner = inner
26
+
27
+ def _key(self, key: str) -> str:
28
+ company_id = require_company_context()
29
+ return f"company:{company_id}:{key}"
30
+
31
+ async def get(self, key: str) -> Any:
32
+ return await self._inner.get(self._key(key))
33
+
34
+ async def put(self, key: str, value: Any, ttl: int | float | None = None) -> None:
35
+ await self._inner.put(self._key(key), value, ttl)
36
+
37
+ async def forget(self, key: str) -> None:
38
+ await self._inner.forget(self._key(key))
39
+
40
+ async def remember(
41
+ self,
42
+ key: str,
43
+ ttl: int | float | None = None,
44
+ factory: Any = lambda: None,
45
+ ) -> Any:
46
+ # Same optionality as the CacheStore protocol — remember(key) is a
47
+ # legal call and must stay one through the wrapper.
48
+ return await self._inner.remember(self._key(key), ttl, factory)
49
+
50
+ async def flush(self) -> None:
51
+ raise NotImplementedError(
52
+ "flush() is refused on a company-scoped store — it would clear "
53
+ "every tenant's entries in the shared physical store; flush the "
54
+ "underlying store explicitly if that is truly intended"
55
+ )
@@ -0,0 +1,87 @@
1
+ """The company context — one contextvar every isolation layer reads.
2
+
3
+ The bound company is request-scoped state, exactly like the ORM's
4
+ session scope: the middleware binds it per request, ``company_context()``
5
+ binds it for scripts/tests/jobs, and everything else only *reads* it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import AsyncIterator
11
+ from contextlib import asynccontextmanager
12
+ from contextvars import ContextVar, Token
13
+ from typing import Any
14
+
15
+ from fastplace.errors import FastplaceError
16
+
17
+ _current_company_id: ContextVar[Any | None] = ContextVar("fastplace_company_id", default=None)
18
+
19
+
20
+ def current_company_id() -> Any | None:
21
+ """The company bound to this task — ``None`` when no context is active."""
22
+ return _current_company_id.get()
23
+
24
+
25
+ def require_company_context() -> Any:
26
+ """The bound company, or :class:`MissingCompanyContext` when absent.
27
+
28
+ Fail-closed is deliberate: a company-scoped query with no company bound
29
+ is a missing tenant, not "every tenant" — the latter is a data breach
30
+ wearing a default's clothing.
31
+ """
32
+ company_id = _current_company_id.get()
33
+ if company_id is None:
34
+ raise MissingCompanyContext(
35
+ "no company context is bound — wrap the work in company_context(id) "
36
+ "or register CompanyContextMiddleware"
37
+ )
38
+ return company_id
39
+
40
+
41
+ @asynccontextmanager
42
+ async def company_context(company_id: Any) -> AsyncIterator[None]:
43
+ """Bind ``company_id`` for the block; restore the previous binding on exit.
44
+
45
+ Nestable (an inner block shadows, the outer binding returns) and
46
+ exception-safe — a failing inner block never leak a stale binding.
47
+ """
48
+ token: Token[Any | None] = _current_company_id.set(company_id)
49
+ try:
50
+ yield
51
+ finally:
52
+ _current_company_id.reset(token)
53
+
54
+
55
+ def reset_company_context() -> None:
56
+ """Clear any binding — test isolation and CLI/boot boundaries."""
57
+ _current_company_id.set(None)
58
+
59
+
60
+ class TenancyError(FastplaceError):
61
+ """Base for package errors — rides the kernel's JSON error translation."""
62
+
63
+ status_code = 400
64
+
65
+
66
+ class MissingCompanyContext(TenancyError):
67
+ """A company-scoped operation ran with no company bound."""
68
+
69
+ status_code = 400
70
+
71
+
72
+ class NotCompanyMember(TenancyError):
73
+ """The actor has no membership in the target company."""
74
+
75
+ status_code = 403
76
+
77
+
78
+ class CompanyRoleRequired(TenancyError):
79
+ """The actor's membership lacks the role the operation demands."""
80
+
81
+ status_code = 403
82
+
83
+
84
+ class RLSNotSupported(TenancyError):
85
+ """The active backend has no native Row-Level Security (capability gate)."""
86
+
87
+ status_code = 400