Getting Started
Installation
The supported install paths during the alpha series:
From TestPyPI (recommended for cohort adopters from v0.6.0-alpha onward)
pip install --index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
tenantshield
Or with uv:
uv add tenantshield --index https://test.pypi.org/simple/
The TestPyPI publication is automated via GitHub Actions on each tag
push (see .github/workflows/publish-testpypi.yml).
Until v1.0.0 ships to public PyPI, TestPyPI is the canonical
distribution channel for alpha releases.
Best practice: explicit = true in [[tool.uv.index]]
When integrating TestPyPI as a package source via uv configuration
(not just the one-off --index flag), the adopter SHOULD configure
the index with explicit = true to isolate TestPyPI consultation to
TenantShield only:
# In adopter's pyproject.toml:
[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
explicit = true # CRITICAL: required for clean transitive dep resolution
[project]
dependencies = [
"tenantshield==0.6.0a0",
# ... other dependencies resolved from PyPI public
]
[tool.uv.sources]
tenantshield = { index = "testpypi" }
Why explicit = true is critical: Without it, uv consults
TestPyPI for the entire dependency tree. TestPyPI is a staging
index — it does not host the full transitive surface that PyPI
public does (e.g., cryptography, pydantic). The resolver breaks
with missing transitive deps.
explicit = true scopes TestPyPI consultation to packages explicitly
listed in [tool.uv.sources] while letting uv resolve everything
else from PyPI public. The result is a clean lockfile that pins
TenantShield from TestPyPI and all transitives from PyPI.
This pattern was discovered empirically by Counterbook (TenantShield reference adopter, mini-ticket #011) during the v0.6.0-alpha TestPyPI cutover. The same pattern applies to any adopter consuming TenantShield from TestPyPI until the v1.0.0 public PyPI release.
From a GitHub tag (alternative for pre-v0.6.0-alpha versions)
pip install git+https://github.com/Jhoelperaltap/tenantshield.git@v0.5.4-alpha
From the source tree (editable install for cohort dogfood)
git clone https://github.com/Jhoelperaltap/tenantshield
cd tenantshield
pip install -e .
Or with uv:
uv add "git+https://github.com/Jhoelperaltap/tenantshield.git@v0.5.4-alpha"
Adapter extras
Install only the adapters you use:
pip install "tenantshield[django] @ git+https://github.com/Jhoelperaltap/tenantshield.git@v0.5.4-alpha"
pip install "tenantshield[sqlalchemy] @ git+https://github.com/Jhoelperaltap/tenantshield.git@v0.5.4-alpha"
pip install "tenantshield[drf] @ git+https://github.com/Jhoelperaltap/tenantshield.git@v0.5.4-alpha"
pip install "tenantshield[jwt] @ git+https://github.com/Jhoelperaltap/tenantshield.git@v0.5.4-alpha"
Troubleshooting installation
uv: command not found (no pip-only install path)
The repository ships with uv as the recommended package manager,
but pip is fully supported. Use the pip install -e . editable
install shown above; uv build is not required for adopters
who only need to install the library.
Windows multi-Python: pip resolves outside the virtualenv
On Windows, having multiple Python installations can leave pip.exe
pointing at the global interpreter even when a venv is active. Verify:
Get-Command pip # check the resolved path
Get-Command python # check the resolved path (should be in .venv\Scripts)
If pip resolves outside the active venv, always invoke through the
venv's Python explicitly:
python -m pip install -e "C:\path\to\tenantshield"
python -m pip uses the active interpreter's bundled pip, regardless
of which pip.exe is first in PATH.
Venv created without pip (Microsoft Store Python, --without-pip)
Some Python distributions create venvs without bundling pip. Symptom:
No module named pip
Bootstrap pip into the venv with Python core's built-in ensurepip:
python -m ensurepip --upgrade
python -m pip --version # verify pip is now in the venv
Then proceed with the editable install above.
SQLAlchemy adapter: with_for_update() interaction
SQLAlchemy adopters frequently use Query.with_for_update() (the
SA equivalent of Django's select_for_update()) to acquire row
locks during read-modify-write workflows. The interaction with
TenantShield's enforcement layer is straightforward but worth
documenting explicitly so the adopter does not assume the lock
acquisition is a bypass surface.
Pattern:
from sqlalchemy.orm import Session
with tenant_scope(bind_tenant(TenantId("acme"))):
with Session(engine) as session:
bind_session_to_tenant(session, current_tenant())
# with_for_update() preserves the tenant filter applied at
# query construction; the SELECT ... FOR UPDATE clause carries
# WHERE tenant_id = 'acme' just like any other scoped query.
invoice = (
session.query(Invoice)
.filter(Invoice.id == 42)
.with_for_update()
.one()
)
invoice.amount = 999
session.commit()
Two facts to anchor:
- Tenant scope is applied at query construction, not at lock
acquisition. The
WHERE tenant_id = <ctx>clause is added by the bound Session'sdo_orm_executeevent listener; the lock is an attribute of the SQL statement that the query builder emits. Both compose orthogonally. - Cross-tenant attempts during the locked operation still
raise. If the same caller attempted
session.query(Invoice).filter(Invoice.id == 42).with_for_update().one()withid=42belonging to tenantglobexinstead ofacme, the SQL would return zero rows (the tenant filter narrows). If the caller then attempted toUPDATEon a manually-constructed cross-tenant target, thebefore_updateevent listener would raiseCrossTenantAccessErrorbefore the SQL executes — see Security posture §SQLAlchemy for the always-on pre-SQL enforcement model.
Unlike Django's select_for_update() (which interacts with the
TenantAwareQuerySet's manager filter at the queryset level), SA's
with_for_update() interacts with the do_orm_execute event
listener at the SQL emission level. The architectural surfaces
differ but the security guarantee is the same: lock acquisition
does not bypass tenant enforcement.
A minimal example
from tenantshield import (
DenyByDefaultPolicy,
Operation,
OperationType,
TenantId,
bind_tenant,
evaluate_and_audit,
register_sink,
StructLogSink,
tenant_scope,
)
# 1. Register a sink so audit events go somewhere.
register_sink(StructLogSink())
# 2. Define a policy.
policy = DenyByDefaultPolicy()
# 3. Enter a tenant scope.
ctx = bind_tenant(TenantId("acme"))
with tenant_scope(ctx):
# 4. Evaluate an operation.
operation = Operation(
model="app.Invoice",
operation_type=OperationType.READ,
tenant_context=ctx,
)
decision = evaluate_and_audit(policy, operation)
print(decision) # Allow()
Outside a tenant scope, the same evaluation would return
Deny(reason="No tenant context active for read on 'app.Invoice'").
Next steps
- Concepts — understand the building blocks.
- API Reference — the complete API.