purview-authz 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.
- purview_authz-0.1.0/.github/workflows/ci.yml +53 -0
- purview_authz-0.1.0/.github/workflows/release.yml +58 -0
- purview_authz-0.1.0/.gitignore +22 -0
- purview_authz-0.1.0/.hypothesis/.gitignore +9 -0
- purview_authz-0.1.0/.hypothesis/constants/0491a2f633fabf1a +4 -0
- purview_authz-0.1.0/.hypothesis/constants/0cceecc88269056c +4 -0
- purview_authz-0.1.0/.hypothesis/constants/10920446925b3c49 +4 -0
- purview_authz-0.1.0/.hypothesis/constants/12edbb823e339999 +4 -0
- purview_authz-0.1.0/.hypothesis/constants/1efe0babaf0dad63 +4 -0
- purview_authz-0.1.0/.hypothesis/constants/3287b6f78b0466bd +4 -0
- purview_authz-0.1.0/.hypothesis/constants/49f91488b563af82 +4 -0
- purview_authz-0.1.0/.hypothesis/constants/67b0a8ccf18bf5d2 +4 -0
- purview_authz-0.1.0/.hypothesis/constants/73e1fa72b4f3b23f +4 -0
- purview_authz-0.1.0/.hypothesis/constants/75add87656b58a41 +4 -0
- purview_authz-0.1.0/.hypothesis/constants/93b313a9c8da98f9 +4 -0
- purview_authz-0.1.0/.hypothesis/constants/9e7c9f6cf266c3e0 +4 -0
- purview_authz-0.1.0/.hypothesis/constants/9e8df1d173bc1388 +4 -0
- purview_authz-0.1.0/.hypothesis/constants/a9f7838e55cc30ba +4 -0
- purview_authz-0.1.0/.hypothesis/constants/bce7a709d8a6c54f +4 -0
- purview_authz-0.1.0/.hypothesis/constants/c16d1e3f871ab3c2 +4 -0
- purview_authz-0.1.0/.hypothesis/constants/d2bddefade20f961 +4 -0
- purview_authz-0.1.0/.hypothesis/constants/e0ab8b84d69fb0f0 +4 -0
- purview_authz-0.1.0/.hypothesis/constants/e286a1d7130798ef +4 -0
- purview_authz-0.1.0/.hypothesis/constants/e9394f423b7d6fd0 +4 -0
- purview_authz-0.1.0/.hypothesis/constants/eed7e26d6983f61b +4 -0
- purview_authz-0.1.0/.hypothesis/constants/eedd831ee9ad9322 +4 -0
- purview_authz-0.1.0/.hypothesis/constants/f6a412b0488163fc +4 -0
- purview_authz-0.1.0/.hypothesis/constants/fc154c4d6ab1ace6 +4 -0
- purview_authz-0.1.0/.hypothesis/constants/fd12ac1cd14d2099 +4 -0
- purview_authz-0.1.0/CHANGELOG.md +28 -0
- purview_authz-0.1.0/LICENSE +21 -0
- purview_authz-0.1.0/PKG-INFO +239 -0
- purview_authz-0.1.0/README.md +201 -0
- purview_authz-0.1.0/RELEASING.md +59 -0
- purview_authz-0.1.0/docs/DESIGN.md +88 -0
- purview_authz-0.1.0/pyproject.toml +86 -0
- purview_authz-0.1.0/src/purview/__init__.py +45 -0
- purview_authz-0.1.0/src/purview/core/__init__.py +25 -0
- purview_authz-0.1.0/src/purview/core/actions.py +18 -0
- purview_authz-0.1.0/src/purview/core/combine.py +45 -0
- purview_authz-0.1.0/src/purview/core/context.py +48 -0
- purview_authz-0.1.0/src/purview/core/registry.py +86 -0
- purview_authz-0.1.0/src/purview/core/types.py +15 -0
- purview_authz-0.1.0/src/purview/exceptions.py +42 -0
- purview_authz-0.1.0/src/purview/fastapi/__init__.py +18 -0
- purview_authz-0.1.0/src/purview/fastapi/dependencies.py +46 -0
- purview_authz-0.1.0/src/purview/fastapi/errors.py +22 -0
- purview_authz-0.1.0/src/purview/fastapi/guards.py +54 -0
- purview_authz-0.1.0/src/purview/py.typed +0 -0
- purview_authz-0.1.0/src/purview/sqlalchemy/__init__.py +33 -0
- purview_authz-0.1.0/src/purview/sqlalchemy/binding.py +33 -0
- purview_authz-0.1.0/src/purview/sqlalchemy/bypass.py +56 -0
- purview_authz-0.1.0/src/purview/sqlalchemy/checking.py +77 -0
- purview_authz-0.1.0/src/purview/sqlalchemy/creating.py +28 -0
- purview_authz-0.1.0/src/purview/sqlalchemy/discovery.py +56 -0
- purview_authz-0.1.0/src/purview/sqlalchemy/enforcer.py +155 -0
- purview_authz-0.1.0/src/purview/sqlalchemy/filtering.py +36 -0
- purview_authz-0.1.0/src/purview/sqlalchemy/predicates.py +74 -0
- purview_authz-0.1.0/src/purview/sqlalchemy/read_guard.py +60 -0
- purview_authz-0.1.0/src/purview/sqlalchemy/write_guard.py +64 -0
- purview_authz-0.1.0/tests/examples/test_blog_app.py +256 -0
- purview_authz-0.1.0/tests/integration/conftest.py +109 -0
- purview_authz-0.1.0/tests/integration/models.py +109 -0
- purview_authz-0.1.0/tests/integration/test_action_fallback.py +35 -0
- purview_authz-0.1.0/tests/integration/test_adversarial.py +134 -0
- purview_authz-0.1.0/tests/integration/test_batch_check.py +25 -0
- purview_authz-0.1.0/tests/integration/test_before_flush.py +35 -0
- purview_authz-0.1.0/tests/integration/test_bypass.py +27 -0
- purview_authz-0.1.0/tests/integration/test_create_validation.py +22 -0
- purview_authz-0.1.0/tests/integration/test_discovery_validation.py +51 -0
- purview_authz-0.1.0/tests/integration/test_enforcer_edges.py +45 -0
- purview_authz-0.1.0/tests/integration/test_exists_check.py +37 -0
- purview_authz-0.1.0/tests/integration/test_get_behavior.py +34 -0
- purview_authz-0.1.0/tests/integration/test_polymorphic.py +22 -0
- purview_authz-0.1.0/tests/integration/test_read_filter.py +39 -0
- purview_authz-0.1.0/tests/integration/test_relationship_loads.py +32 -0
- purview_authz-0.1.0/tests/integration/test_strict_mode.py +87 -0
- purview_authz-0.1.0/tests/integration/test_write_guard_edges.py +39 -0
- purview_authz-0.1.0/tests/unit/test_combine_default_deny.py +60 -0
- purview_authz-0.1.0/tests/unit/test_combine_or.py +82 -0
- purview_authz-0.1.0/tests/unit/test_context.py +42 -0
- purview_authz-0.1.0/tests/unit/test_registry.py +65 -0
- purview_authz-0.1.0/uv.lock +962 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
10
|
+
cancel-in-progress: true
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
test:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
strategy:
|
|
16
|
+
fail-fast: false
|
|
17
|
+
matrix:
|
|
18
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
19
|
+
|
|
20
|
+
services:
|
|
21
|
+
postgres:
|
|
22
|
+
image: postgres:16
|
|
23
|
+
env:
|
|
24
|
+
POSTGRES_PASSWORD: postgres
|
|
25
|
+
POSTGRES_DB: purview_test
|
|
26
|
+
ports:
|
|
27
|
+
- 5432:5432
|
|
28
|
+
options: >-
|
|
29
|
+
--health-cmd pg_isready
|
|
30
|
+
--health-interval 10s
|
|
31
|
+
--health-timeout 5s
|
|
32
|
+
--health-retries 5
|
|
33
|
+
|
|
34
|
+
env:
|
|
35
|
+
UV_PYTHON: ${{ matrix.python-version }}
|
|
36
|
+
PURVIEW_TEST_POSTGRES_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/purview_test
|
|
37
|
+
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/checkout@v4
|
|
40
|
+
|
|
41
|
+
- name: Install uv
|
|
42
|
+
uses: astral-sh/setup-uv@v5
|
|
43
|
+
with:
|
|
44
|
+
python-version: ${{ matrix.python-version }}
|
|
45
|
+
|
|
46
|
+
- name: Lint
|
|
47
|
+
run: uv run --extra dev ruff check .
|
|
48
|
+
|
|
49
|
+
- name: Type-check (strict)
|
|
50
|
+
run: uv run --extra dev mypy
|
|
51
|
+
|
|
52
|
+
- name: Test (SQLite + Postgres) with coverage gate
|
|
53
|
+
run: uv run --extra dev pytest --cov=purview --cov-report=term-missing --cov-fail-under=90
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Publishes to PyPI on a version tag (e.g. v0.1.0) via Trusted Publishing (OIDC) —
|
|
4
|
+
# no API token is stored. See RELEASING.md for the one-time PyPI/GitHub setup.
|
|
5
|
+
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
tags: ["v*"]
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
build:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
with:
|
|
19
|
+
fetch-depth: 0 # hatch-vcs derives the version from tags/history
|
|
20
|
+
|
|
21
|
+
- name: Install uv
|
|
22
|
+
uses: astral-sh/setup-uv@v5
|
|
23
|
+
with:
|
|
24
|
+
python-version: "3.12"
|
|
25
|
+
|
|
26
|
+
- name: Verify (lint, type-check, tests)
|
|
27
|
+
run: |
|
|
28
|
+
uv run --extra dev ruff check .
|
|
29
|
+
uv run --extra dev mypy
|
|
30
|
+
uv run --extra dev pytest -q
|
|
31
|
+
|
|
32
|
+
- name: Build sdist + wheel
|
|
33
|
+
run: uv build
|
|
34
|
+
|
|
35
|
+
- name: Check metadata
|
|
36
|
+
run: uvx twine check dist/*
|
|
37
|
+
|
|
38
|
+
- uses: actions/upload-artifact@v4
|
|
39
|
+
with:
|
|
40
|
+
name: dist
|
|
41
|
+
path: dist/
|
|
42
|
+
|
|
43
|
+
publish:
|
|
44
|
+
needs: build
|
|
45
|
+
runs-on: ubuntu-latest
|
|
46
|
+
environment:
|
|
47
|
+
name: pypi
|
|
48
|
+
url: https://pypi.org/p/purview-authz
|
|
49
|
+
permissions:
|
|
50
|
+
id-token: write # required for Trusted Publishing
|
|
51
|
+
steps:
|
|
52
|
+
- uses: actions/download-artifact@v4
|
|
53
|
+
with:
|
|
54
|
+
name: dist
|
|
55
|
+
path: dist/
|
|
56
|
+
|
|
57
|
+
- name: Publish to PyPI
|
|
58
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
|
|
7
|
+
# Builds
|
|
8
|
+
build/
|
|
9
|
+
dist/
|
|
10
|
+
|
|
11
|
+
# Environments
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
|
|
15
|
+
# Tooling caches
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
.mypy_cache/
|
|
18
|
+
.ruff_cache/
|
|
19
|
+
.coverage
|
|
20
|
+
.coverage.*
|
|
21
|
+
htmlcov/
|
|
22
|
+
coverage.xml
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# This .gitignore file was automatically created by Hypothesis. Hypothesis gitignores
|
|
2
|
+
# .hypothesis by default, because we generally recommend that .hypothesis not be checked
|
|
3
|
+
# into version control.
|
|
4
|
+
#
|
|
5
|
+
# If you *would* like to check .hypothesis into version control, you should delete this
|
|
6
|
+
# file. Hypothesis will not re-create this .gitignore unless .hypothesis is deleted (and
|
|
7
|
+
# if it does, that's a bug - please report it!)
|
|
8
|
+
|
|
9
|
+
*
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
# file: /home/runner/work/purview/purview/src/purview/__init__.py
|
|
2
|
+
# hypothesis_version: 6.155.1
|
|
3
|
+
|
|
4
|
+
['0.0.0+unknown', 'Action', 'CREATE', 'Context', 'CrossTenantWrite', 'DELETE', 'Policy', 'PurviewError', 'PurviewForbidden', 'READ', 'TenantMismatch', 'UPDATE', 'UnscopedModel', '__version__', 'purview-authz']
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres
|
|
5
|
+
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Core** (`purview.core`): `Context`, the `Policy` rule registry, and the
|
|
12
|
+
default-deny predicate combinator — framework-agnostic, no ORM-execution imports.
|
|
13
|
+
- **SQLAlchemy engine** (`purview.sqlalchemy`): secure-by-default model discovery
|
|
14
|
+
with install-time validation; the `do_orm_execute` read guard (tenant scope +
|
|
15
|
+
fine-grained read predicates, propagating to lazy and eager relationship loads);
|
|
16
|
+
the `before_flush` write guard (tenant auto-stamp, forged-insert and
|
|
17
|
+
cross-tenant-move rejection); single and batch EXISTS checks; explicit
|
|
18
|
+
`authorized_select`; and the `bypass` escape hatch.
|
|
19
|
+
- **FastAPI adapter** (`purview.fastapi`): `context_binder`, the `requires` route
|
|
20
|
+
guard, `authorize_or_403`, and a `PurviewForbidden`/`CrossTenantWrite` → 403
|
|
21
|
+
handler.
|
|
22
|
+
- `install(..., strict=True)` for within-tenant default deny: a scoped model with
|
|
23
|
+
no read rule denies instead of defaulting to tenant-scope-only.
|
|
24
|
+
- Test suite: unit (100% branch coverage on the combinator), integration
|
|
25
|
+
(parametrized over SQLite and Postgres), an adversarial leak suite, and a
|
|
26
|
+
runnable FastAPI example app.
|
|
27
|
+
|
|
28
|
+
[Unreleased]: https://github.com/erichare/purview/commits/main
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eric Hare
|
|
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,239 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: purview-authz
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Row-level authorization and multi-tenancy for FastAPI + SQLAlchemy: define a policy once as column expressions and get both yes/no checks and query filtering from the same rule.
|
|
5
|
+
Project-URL: Homepage, https://github.com/erichare/purview
|
|
6
|
+
Project-URL: Repository, https://github.com/erichare/purview
|
|
7
|
+
Project-URL: Issues, https://github.com/erichare/purview/issues
|
|
8
|
+
Author: Eric Hare
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: abac,async,authorization,fastapi,multi-tenancy,rbac,row-level-security,sqlalchemy
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: FastAPI
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: sqlalchemy[asyncio]<2.1,>=2.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: aiosqlite>=0.20; extra == 'dev'
|
|
26
|
+
Requires-Dist: asyncpg>=0.29; extra == 'dev'
|
|
27
|
+
Requires-Dist: fastapi>=0.110; extra == 'dev'
|
|
28
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
29
|
+
Requires-Dist: hypothesis>=6; extra == 'dev'
|
|
30
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
34
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
35
|
+
Provides-Extra: fastapi
|
|
36
|
+
Requires-Dist: fastapi>=0.110; extra == 'fastapi'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# Purview
|
|
40
|
+
|
|
41
|
+
**Row-level authorization and multi-tenancy for FastAPI + SQLAlchemy.** Define a
|
|
42
|
+
policy once as SQLAlchemy column expressions and get both yes/no checks and query
|
|
43
|
+
filtering from the same rule — so the check and the filter can never disagree.
|
|
44
|
+
|
|
45
|
+
> Drift between "can this actor do this?" and "which rows can they see?" is a data
|
|
46
|
+
> leak. Purview makes both come from one definition, so they cannot drift.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
@policy.rule(Post, "read")
|
|
50
|
+
def read_post(ctx: Context) -> list[ColumnElement[bool]]:
|
|
51
|
+
rules = []
|
|
52
|
+
if ctx.has_role("author"):
|
|
53
|
+
rules.append(Post.author_id == ctx.user_id) # authors see their own
|
|
54
|
+
if ctx.has_role("org_admin"):
|
|
55
|
+
rules.append(true()) # admins see the whole tenant
|
|
56
|
+
return rules # OR-combined; empty = deny
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
That one rule now powers a filtered `select(Post)` **and** an
|
|
60
|
+
`authorize(session, "read", post)` check.
|
|
61
|
+
|
|
62
|
+
## Why this exists
|
|
63
|
+
|
|
64
|
+
Authentication generalizes; authorization does not, because it is welded to your
|
|
65
|
+
domain model and data layer. The hard, valuable part is **data filtering**:
|
|
66
|
+
shaping queries so a user only ever loads rows they're allowed to see, pushed into
|
|
67
|
+
SQL rather than filtered in Python after the fact. [Oso](https://www.osohq.com/)
|
|
68
|
+
solved this well and then deprecated its open-source library, leaving no idiomatic
|
|
69
|
+
Python answer. Purview targets that gap for the FastAPI + SQLAlchemy stack
|
|
70
|
+
specifically — in-process, async-first, no external policy service.
|
|
71
|
+
|
|
72
|
+
## Install
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install purview-authz # core + SQLAlchemy
|
|
76
|
+
pip install "purview-authz[fastapi]" # plus the FastAPI adapter
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The distribution is `purview-authz`; the import package is `purview`. Requires
|
|
80
|
+
Python 3.11+ and SQLAlchemy 2.0+.
|
|
81
|
+
|
|
82
|
+
## Quickstart
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from sqlalchemy import true
|
|
86
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
87
|
+
from purview import Context, Policy, READ
|
|
88
|
+
from purview.sqlalchemy import install
|
|
89
|
+
|
|
90
|
+
class Base(DeclarativeBase): ...
|
|
91
|
+
|
|
92
|
+
class Org(Base): # the tenant root — global
|
|
93
|
+
__tablename__ = "org"
|
|
94
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
95
|
+
|
|
96
|
+
class Post(Base): # tenant-scoped (has the tenant column)
|
|
97
|
+
__tablename__ = "post"
|
|
98
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
99
|
+
org_id: Mapped[int] = mapped_column()
|
|
100
|
+
author_id: Mapped[int] = mapped_column()
|
|
101
|
+
|
|
102
|
+
policy = Policy()
|
|
103
|
+
policy.global_model(Org) # opt Org out of tenant scoping
|
|
104
|
+
|
|
105
|
+
@policy.rule(Post, READ)
|
|
106
|
+
def read_post(ctx: Context):
|
|
107
|
+
return [Post.author_id == ctx.user_id] if ctx.has_role("author") else []
|
|
108
|
+
|
|
109
|
+
pv = install(Base, policy, tenant_column="org_id") # wires the guards; validates models
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Bind a request's session to its actor, then query normally — reads are filtered
|
|
113
|
+
automatically:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
async with async_session() as session:
|
|
117
|
+
pv.bind(session, Context(user_id=42, tenant_id=1, roles={"author"}))
|
|
118
|
+
|
|
119
|
+
posts = await session.scalars(select(Post)) # only org 1 + authored by 42
|
|
120
|
+
one = await session.get(Post, 99) # None if not visible
|
|
121
|
+
ok = await pv.authorize(session, "update", post) # yes/no for one object
|
|
122
|
+
ids = await pv.authorized_ids(session, "read", Post, [1, 2, 3]) # the allowed subset
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Core concepts
|
|
126
|
+
|
|
127
|
+
**One definition, two forms.** A rule returns boolean `ColumnElement` predicates.
|
|
128
|
+
As a `.where(...)` they filter a collection; wrapped in
|
|
129
|
+
`EXISTS (SELECT 1 ... AND <predicate>)` they check a single object. The database
|
|
130
|
+
evaluates both, so relationship and join predicates work without re-implementing
|
|
131
|
+
SQL in Python.
|
|
132
|
+
|
|
133
|
+
**Roles select predicates.** An actor's tenant-scoped roles decide which
|
|
134
|
+
predicates apply for `(action, model)`. Grants are OR-combined; **no granting role
|
|
135
|
+
means no rows — default deny.**
|
|
136
|
+
|
|
137
|
+
**Tenancy is the session boundary.** One session is bound to exactly one tenant.
|
|
138
|
+
A `do_orm_execute` hook scopes every read (including lazy and eager relationship
|
|
139
|
+
loads) to that tenant; a `before_flush` hook auto-stamps the tenant on inserts and
|
|
140
|
+
refuses writes that would cross the boundary.
|
|
141
|
+
|
|
142
|
+
**Secure by default.** Every mapped model is tenant-scoped automatically. Opt a
|
|
143
|
+
model out with `policy.global_model(...)`. `install()` **raises** if a non-global
|
|
144
|
+
model lacks the tenant column — an unscoped table fails at startup, never leaks at
|
|
145
|
+
runtime.
|
|
146
|
+
|
|
147
|
+
**`read` drives filtering.** It is the action that shapes collections. Other
|
|
148
|
+
actions are instance-level checks; `update`/`delete` reuse the `read` predicate
|
|
149
|
+
unless you register a stricter rule for them.
|
|
150
|
+
|
|
151
|
+
**Within-tenant default.** A scoped model with *no* read rule is visible
|
|
152
|
+
tenant-wide (tenant isolation still applies). Pass `install(..., strict=True)` to
|
|
153
|
+
flip this to within-tenant **default deny** — every model then needs an explicit
|
|
154
|
+
rule to grant any access. The cross-tenant boundary is enforced identically in
|
|
155
|
+
both modes.
|
|
156
|
+
|
|
157
|
+
## The enforcement boundary
|
|
158
|
+
|
|
159
|
+
Inside the boundary: ORM `select`s, `session.get`, relationship loads, and flushes
|
|
160
|
+
on a bound session.
|
|
161
|
+
|
|
162
|
+
Outside the boundary (documented, not enforced):
|
|
163
|
+
|
|
164
|
+
- **Raw SQL and Core `text()`** — Purview shapes ORM statements, not hand-written SQL.
|
|
165
|
+
- **Implicit lazy loads under async** — these raise `MissingGreenlet` in SQLAlchemy
|
|
166
|
+
regardless; use `selectinload(...)` or `await obj.awaitable_attrs.x`. Eager and
|
|
167
|
+
awaitable lazy loads *are* filtered.
|
|
168
|
+
- **Unbound sessions** — a session with no bound context is not filtered (this is
|
|
169
|
+
how you seed and run migrations).
|
|
170
|
+
|
|
171
|
+
### Escape hatch
|
|
172
|
+
|
|
173
|
+
One loud, greppable bypass for admin tooling and migrations:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
from purview.sqlalchemy import bypass
|
|
177
|
+
|
|
178
|
+
with bypass(reason="nightly billing rollup"):
|
|
179
|
+
... # enforcement stands down on this task; the reason is logged at WARNING
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## FastAPI
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
from purview.fastapi import context_binder, authorize_or_403, install_error_handlers
|
|
186
|
+
|
|
187
|
+
install_error_handlers(app) # PurviewForbidden -> 403
|
|
188
|
+
bound = context_binder(pv, get_session, get_context) # binds the actor per request
|
|
189
|
+
|
|
190
|
+
@app.get("/posts")
|
|
191
|
+
async def list_posts(session: AsyncSession = Depends(bound)):
|
|
192
|
+
return (await session.scalars(select(Post))).all() # auto-filtered
|
|
193
|
+
|
|
194
|
+
@app.patch("/posts/{post_id}")
|
|
195
|
+
async def edit(post_id: int, session: AsyncSession = Depends(bound)):
|
|
196
|
+
post = await session.get(Post, post_id) # 404 if not visible
|
|
197
|
+
await authorize_or_403(pv, session, "update", post) # 403 if not permitted
|
|
198
|
+
...
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
See [`tests/examples/test_blog_app.py`](tests/examples/test_blog_app.py) for a
|
|
202
|
+
complete, runnable app.
|
|
203
|
+
|
|
204
|
+
## How it compares
|
|
205
|
+
|
|
206
|
+
| | Purview | Oso (OSS) | Cerbos | Casbin |
|
|
207
|
+
|--------------------------|:-------:|:---------:|:------:|:------:|
|
|
208
|
+
| In-process (no network) | ✅ | ✅ | ❌ service | ✅ |
|
|
209
|
+
| SQL data filtering | ✅ | ✅ | ✅ | ❌ |
|
|
210
|
+
| One def → check + filter | ✅ | ✅ | ➖ | ❌ |
|
|
211
|
+
| SQLAlchemy 2.0 async | ✅ | ❌ | ✅ adapter | ➖ |
|
|
212
|
+
| Policy in native Python | ✅ | Polar DSL | YAML | model+CSV |
|
|
213
|
+
| Maintained | ✅ | deprecated 2023 | ✅ | ✅ |
|
|
214
|
+
|
|
215
|
+
## Scope (v1)
|
|
216
|
+
|
|
217
|
+
**In:** row-level filtering, multi-tenancy as a structural concern, yes/no checks
|
|
218
|
+
and query filtering from one definition, SQLAlchemy 2.0 async, FastAPI adapter.
|
|
219
|
+
|
|
220
|
+
**Out (for now):** field-level authorization (belongs in serialization),
|
|
221
|
+
non-SQLAlchemy ORMs, a hosted policy service, Postgres RLS as a compile target.
|
|
222
|
+
|
|
223
|
+
## Development
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
uv run --extra dev pytest # unit + integration + the example app
|
|
227
|
+
uv run --extra dev mypy # strict typing is a project invariant
|
|
228
|
+
uv run --extra dev ruff check .
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Postgres fidelity is exercised in CI; set `PURVIEW_TEST_POSTGRES_URL` to run the
|
|
232
|
+
integration matrix against a local Postgres too.
|
|
233
|
+
|
|
234
|
+
Releases publish to PyPI on a version tag via Trusted Publishing (no stored token);
|
|
235
|
+
the version is derived from the tag. See [RELEASING.md](RELEASING.md).
|
|
236
|
+
|
|
237
|
+
## License
|
|
238
|
+
|
|
239
|
+
MIT — see [LICENSE](LICENSE).
|