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.
Files changed (83) hide show
  1. purview_authz-0.1.0/.github/workflows/ci.yml +53 -0
  2. purview_authz-0.1.0/.github/workflows/release.yml +58 -0
  3. purview_authz-0.1.0/.gitignore +22 -0
  4. purview_authz-0.1.0/.hypothesis/.gitignore +9 -0
  5. purview_authz-0.1.0/.hypothesis/constants/0491a2f633fabf1a +4 -0
  6. purview_authz-0.1.0/.hypothesis/constants/0cceecc88269056c +4 -0
  7. purview_authz-0.1.0/.hypothesis/constants/10920446925b3c49 +4 -0
  8. purview_authz-0.1.0/.hypothesis/constants/12edbb823e339999 +4 -0
  9. purview_authz-0.1.0/.hypothesis/constants/1efe0babaf0dad63 +4 -0
  10. purview_authz-0.1.0/.hypothesis/constants/3287b6f78b0466bd +4 -0
  11. purview_authz-0.1.0/.hypothesis/constants/49f91488b563af82 +4 -0
  12. purview_authz-0.1.0/.hypothesis/constants/67b0a8ccf18bf5d2 +4 -0
  13. purview_authz-0.1.0/.hypothesis/constants/73e1fa72b4f3b23f +4 -0
  14. purview_authz-0.1.0/.hypothesis/constants/75add87656b58a41 +4 -0
  15. purview_authz-0.1.0/.hypothesis/constants/93b313a9c8da98f9 +4 -0
  16. purview_authz-0.1.0/.hypothesis/constants/9e7c9f6cf266c3e0 +4 -0
  17. purview_authz-0.1.0/.hypothesis/constants/9e8df1d173bc1388 +4 -0
  18. purview_authz-0.1.0/.hypothesis/constants/a9f7838e55cc30ba +4 -0
  19. purview_authz-0.1.0/.hypothesis/constants/bce7a709d8a6c54f +4 -0
  20. purview_authz-0.1.0/.hypothesis/constants/c16d1e3f871ab3c2 +4 -0
  21. purview_authz-0.1.0/.hypothesis/constants/d2bddefade20f961 +4 -0
  22. purview_authz-0.1.0/.hypothesis/constants/e0ab8b84d69fb0f0 +4 -0
  23. purview_authz-0.1.0/.hypothesis/constants/e286a1d7130798ef +4 -0
  24. purview_authz-0.1.0/.hypothesis/constants/e9394f423b7d6fd0 +4 -0
  25. purview_authz-0.1.0/.hypothesis/constants/eed7e26d6983f61b +4 -0
  26. purview_authz-0.1.0/.hypothesis/constants/eedd831ee9ad9322 +4 -0
  27. purview_authz-0.1.0/.hypothesis/constants/f6a412b0488163fc +4 -0
  28. purview_authz-0.1.0/.hypothesis/constants/fc154c4d6ab1ace6 +4 -0
  29. purview_authz-0.1.0/.hypothesis/constants/fd12ac1cd14d2099 +4 -0
  30. purview_authz-0.1.0/CHANGELOG.md +28 -0
  31. purview_authz-0.1.0/LICENSE +21 -0
  32. purview_authz-0.1.0/PKG-INFO +239 -0
  33. purview_authz-0.1.0/README.md +201 -0
  34. purview_authz-0.1.0/RELEASING.md +59 -0
  35. purview_authz-0.1.0/docs/DESIGN.md +88 -0
  36. purview_authz-0.1.0/pyproject.toml +86 -0
  37. purview_authz-0.1.0/src/purview/__init__.py +45 -0
  38. purview_authz-0.1.0/src/purview/core/__init__.py +25 -0
  39. purview_authz-0.1.0/src/purview/core/actions.py +18 -0
  40. purview_authz-0.1.0/src/purview/core/combine.py +45 -0
  41. purview_authz-0.1.0/src/purview/core/context.py +48 -0
  42. purview_authz-0.1.0/src/purview/core/registry.py +86 -0
  43. purview_authz-0.1.0/src/purview/core/types.py +15 -0
  44. purview_authz-0.1.0/src/purview/exceptions.py +42 -0
  45. purview_authz-0.1.0/src/purview/fastapi/__init__.py +18 -0
  46. purview_authz-0.1.0/src/purview/fastapi/dependencies.py +46 -0
  47. purview_authz-0.1.0/src/purview/fastapi/errors.py +22 -0
  48. purview_authz-0.1.0/src/purview/fastapi/guards.py +54 -0
  49. purview_authz-0.1.0/src/purview/py.typed +0 -0
  50. purview_authz-0.1.0/src/purview/sqlalchemy/__init__.py +33 -0
  51. purview_authz-0.1.0/src/purview/sqlalchemy/binding.py +33 -0
  52. purview_authz-0.1.0/src/purview/sqlalchemy/bypass.py +56 -0
  53. purview_authz-0.1.0/src/purview/sqlalchemy/checking.py +77 -0
  54. purview_authz-0.1.0/src/purview/sqlalchemy/creating.py +28 -0
  55. purview_authz-0.1.0/src/purview/sqlalchemy/discovery.py +56 -0
  56. purview_authz-0.1.0/src/purview/sqlalchemy/enforcer.py +155 -0
  57. purview_authz-0.1.0/src/purview/sqlalchemy/filtering.py +36 -0
  58. purview_authz-0.1.0/src/purview/sqlalchemy/predicates.py +74 -0
  59. purview_authz-0.1.0/src/purview/sqlalchemy/read_guard.py +60 -0
  60. purview_authz-0.1.0/src/purview/sqlalchemy/write_guard.py +64 -0
  61. purview_authz-0.1.0/tests/examples/test_blog_app.py +256 -0
  62. purview_authz-0.1.0/tests/integration/conftest.py +109 -0
  63. purview_authz-0.1.0/tests/integration/models.py +109 -0
  64. purview_authz-0.1.0/tests/integration/test_action_fallback.py +35 -0
  65. purview_authz-0.1.0/tests/integration/test_adversarial.py +134 -0
  66. purview_authz-0.1.0/tests/integration/test_batch_check.py +25 -0
  67. purview_authz-0.1.0/tests/integration/test_before_flush.py +35 -0
  68. purview_authz-0.1.0/tests/integration/test_bypass.py +27 -0
  69. purview_authz-0.1.0/tests/integration/test_create_validation.py +22 -0
  70. purview_authz-0.1.0/tests/integration/test_discovery_validation.py +51 -0
  71. purview_authz-0.1.0/tests/integration/test_enforcer_edges.py +45 -0
  72. purview_authz-0.1.0/tests/integration/test_exists_check.py +37 -0
  73. purview_authz-0.1.0/tests/integration/test_get_behavior.py +34 -0
  74. purview_authz-0.1.0/tests/integration/test_polymorphic.py +22 -0
  75. purview_authz-0.1.0/tests/integration/test_read_filter.py +39 -0
  76. purview_authz-0.1.0/tests/integration/test_relationship_loads.py +32 -0
  77. purview_authz-0.1.0/tests/integration/test_strict_mode.py +87 -0
  78. purview_authz-0.1.0/tests/integration/test_write_guard_edges.py +39 -0
  79. purview_authz-0.1.0/tests/unit/test_combine_default_deny.py +60 -0
  80. purview_authz-0.1.0/tests/unit/test_combine_or.py +82 -0
  81. purview_authz-0.1.0/tests/unit/test_context.py +42 -0
  82. purview_authz-0.1.0/tests/unit/test_registry.py +65 -0
  83. 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/fastapi/__init__.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ ['authorize_or_403', 'context_binder', 'requires']
@@ -0,0 +1,4 @@
1
+ # file: /home/runner/work/purview/purview/src/purview/sqlalchemy/__init__.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ ['Purview', 'authorized_select', 'bind_context', 'bypass', 'context_of', 'install', 'is_bypassed']
@@ -0,0 +1,4 @@
1
+ # file: /home/runner/work/purview/purview/.venv/bin/pytest
2
+ # hypothesis_version: 6.155.1
3
+
4
+ ['-script.pyw', '.exe', '__main__']
@@ -0,0 +1,4 @@
1
+ # file: /home/runner/work/purview/purview/src/purview/core/registry.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ []
@@ -0,0 +1,4 @@
1
+ # file: /home/runner/work/purview/purview/src/purview/core/combine.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ []
@@ -0,0 +1,4 @@
1
+ # file: /home/runner/work/purview/purview/src/purview/sqlalchemy/predicates.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ []
@@ -0,0 +1,4 @@
1
+ # file: /home/runner/work/purview/purview/src/purview/fastapi/dependencies.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ []
@@ -0,0 +1,4 @@
1
+ # file: /usr/lib/python3.12/sitecustomize.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ []
@@ -0,0 +1,4 @@
1
+ # file: /home/runner/work/purview/purview/src/purview/sqlalchemy/write_guard.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ []
@@ -0,0 +1,4 @@
1
+ # file: /home/runner/work/purview/purview/src/purview/fastapi/errors.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ [403, 'detail', 'forbidden']
@@ -0,0 +1,4 @@
1
+ # file: /home/runner/work/purview/purview/src/purview/sqlalchemy/enforcer.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ ['before_flush', 'do_orm_execute', 'tenant_id']
@@ -0,0 +1,4 @@
1
+ # file: /home/runner/work/purview/purview/src/purview/core/types.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ ['Context[Any, Any]']
@@ -0,0 +1,4 @@
1
+ # file: /home/runner/work/purview/purview/src/purview/sqlalchemy/filtering.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ []
@@ -0,0 +1,4 @@
1
+ # file: /home/runner/work/purview/purview/src/purview/fastapi/guards.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ ['R']
@@ -0,0 +1,4 @@
1
+ # file: /home/runner/work/purview/purview/src/purview/sqlalchemy/bypass.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ ['purview.bypass', 'purview_bypass']
@@ -0,0 +1,4 @@
1
+ # file: /home/runner/work/purview/purview/src/purview/sqlalchemy/creating.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ []
@@ -0,0 +1,4 @@
1
+ # file: /home/runner/work/purview/purview/src/purview/exceptions.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ []
@@ -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,4 @@
1
+ # file: /home/runner/work/purview/purview/src/purview/sqlalchemy/binding.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ ['purview_context']
@@ -0,0 +1,4 @@
1
+ # file: /home/runner/work/purview/purview/src/purview/sqlalchemy/discovery.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ ['mappers', 'registry']
@@ -0,0 +1,4 @@
1
+ # file: /home/runner/work/purview/purview/src/purview/core/__init__.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ ['Action', 'CREATE', 'Context', 'DELETE', 'Policy', 'READ', 'RuleFn', 'UPDATE', 'evaluate_predicate']
@@ -0,0 +1,4 @@
1
+ # file: /home/runner/work/purview/purview/src/purview/sqlalchemy/read_guard.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ []
@@ -0,0 +1,4 @@
1
+ # file: /home/runner/work/purview/purview/src/purview/core/actions.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ ['create', 'delete', 'read', 'update']
@@ -0,0 +1,4 @@
1
+ # file: /home/runner/work/purview/purview/src/purview/core/context.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ ['T', 'U', 'roles']
@@ -0,0 +1,4 @@
1
+ # file: /home/runner/work/purview/purview/src/purview/sqlalchemy/checking.py
2
+ # hypothesis_version: 6.155.1
3
+
4
+ []
@@ -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).