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.
- fastplace_tenancy-0.1.0/LICENSE +21 -0
- fastplace_tenancy-0.1.0/PKG-INFO +193 -0
- fastplace_tenancy-0.1.0/README.md +178 -0
- fastplace_tenancy-0.1.0/pyproject.toml +24 -0
- fastplace_tenancy-0.1.0/setup.cfg +4 -0
- fastplace_tenancy-0.1.0/src/fastplace_tenancy/__init__.py +78 -0
- fastplace_tenancy-0.1.0/src/fastplace_tenancy/authorization.py +52 -0
- fastplace_tenancy-0.1.0/src/fastplace_tenancy/cache.py +55 -0
- fastplace_tenancy-0.1.0/src/fastplace_tenancy/context.py +87 -0
- fastplace_tenancy-0.1.0/src/fastplace_tenancy/documents.py +115 -0
- fastplace_tenancy-0.1.0/src/fastplace_tenancy/middleware.py +91 -0
- fastplace_tenancy-0.1.0/src/fastplace_tenancy/models.py +165 -0
- fastplace_tenancy-0.1.0/src/fastplace_tenancy/queue.py +79 -0
- fastplace_tenancy-0.1.0/src/fastplace_tenancy/rls.py +97 -0
- fastplace_tenancy-0.1.0/src/fastplace_tenancy/search.py +63 -0
- fastplace_tenancy-0.1.0/src/fastplace_tenancy/storage.py +57 -0
- fastplace_tenancy-0.1.0/src/fastplace_tenancy/vectors.py +56 -0
- fastplace_tenancy-0.1.0/src/fastplace_tenancy.egg-info/PKG-INFO +193 -0
- fastplace_tenancy-0.1.0/src/fastplace_tenancy.egg-info/SOURCES.txt +20 -0
- fastplace_tenancy-0.1.0/src/fastplace_tenancy.egg-info/dependency_links.txt +1 -0
- fastplace_tenancy-0.1.0/src/fastplace_tenancy.egg-info/requires.txt +1 -0
- fastplace_tenancy-0.1.0/src/fastplace_tenancy.egg-info/top_level.txt +1 -0
|
@@ -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,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
|