sqlalchemy-tolap 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.
- sqlalchemy_tolap-0.1.0/.gitignore +15 -0
- sqlalchemy_tolap-0.1.0/LICENSE +202 -0
- sqlalchemy_tolap-0.1.0/NOTICE +7 -0
- sqlalchemy_tolap-0.1.0/PKG-INFO +78 -0
- sqlalchemy_tolap-0.1.0/README.md +52 -0
- sqlalchemy_tolap-0.1.0/pyproject.toml +36 -0
- sqlalchemy_tolap-0.1.0/sqlalchemy_tolap/__init__.py +9 -0
- sqlalchemy_tolap-0.1.0/sqlalchemy_tolap/enforce.py +52 -0
- sqlalchemy_tolap-0.1.0/sqlalchemy_tolap/exceptions.py +15 -0
- sqlalchemy_tolap-0.1.0/sqlalchemy_tolap/inspect.py +189 -0
- sqlalchemy_tolap-0.1.0/sqlalchemy_tolap/matching.py +46 -0
- sqlalchemy_tolap-0.1.0/sqlalchemy_tolap/precheck.py +56 -0
- sqlalchemy_tolap-0.1.0/sqlalchemy_tolap/pushdown.py +317 -0
- sqlalchemy_tolap-0.1.0/sqlalchemy_tolap/py.typed +0 -0
- sqlalchemy_tolap-0.1.0/sqlalchemy_tolap/rules.py +61 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
django-tolap and sqlalchemy-tolap
|
|
2
|
+
Copyright 2026 Sharukh Hasan
|
|
3
|
+
|
|
4
|
+
Adapters for TOLAP (Tool-Object Level Access Protocol), https://github.com/awslabs/tolap,
|
|
5
|
+
Copyright Amazon.com, Inc. or its affiliates, licensed under the Apache License 2.0.
|
|
6
|
+
This project depends on the published tolap-core and tolap-store packages and does not
|
|
7
|
+
include their source.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sqlalchemy-tolap
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: TOLAP policies enforced on SQLAlchemy Select statements with ORM-native pushdown
|
|
5
|
+
Project-URL: Homepage, https://github.com/smhasan94/django-tolap
|
|
6
|
+
Project-URL: Issues, https://github.com/smhasan94/django-tolap/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/smhasan94/django-tolap/blob/main/CHANGELOG.md
|
|
8
|
+
Author: Sharukh Hasan
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
License-File: NOTICE
|
|
12
|
+
Keywords: access-control,ai-agents,mcp,sqlalchemy,tolap
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Database
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: sqlalchemy<2.2,>=2.0
|
|
24
|
+
Requires-Dist: tolap-core<2,>=1.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# sqlalchemy-tolap
|
|
28
|
+
|
|
29
|
+
**TOLAP policies enforced on SQLAlchemy `Select` statements with ORM-native pushdown.**
|
|
30
|
+
|
|
31
|
+
[TOLAP](https://github.com/awslabs/tolap) (Tool-Object Level Access Protocol, AWS,
|
|
32
|
+
Apache-2.0) decides what an AI agent's tool may *return*: which tables, columns and rows,
|
|
33
|
+
how fields are masked, how many results. `sqlalchemy-tolap` enforces a signed TOLAP
|
|
34
|
+
context on a SQLAlchemy 2.x `Select` before it runs, and applies TOLAP's own
|
|
35
|
+
post-execution pass afterwards.
|
|
36
|
+
|
|
37
|
+
- **Pre-execution checks.** Signature and expiry, `canQuery`, object access for every table
|
|
38
|
+
in the statement, referenced columns against hidden and allowed sets. Refuses rather than
|
|
39
|
+
narrows.
|
|
40
|
+
- **Pushdown.** Row filters become `WHERE` clauses, the result limit a `LIMIT`, hidden
|
|
41
|
+
columns leave the projection. Only where the dialect's semantics match TOLAP's; anything
|
|
42
|
+
else is left to the post pass and reported, never approximated.
|
|
43
|
+
- **Differential proof.** Pushdown plus post pass returns the same rows as post pass alone,
|
|
44
|
+
on SQLite and PostgreSQL, against upstream's fixtures and a Hypothesis property.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install sqlalchemy-tolap
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Python 3.11+, SQLAlchemy 2.0 or 2.1. Pulls `tolap-core` from PyPI.
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from sqlalchemy import select
|
|
58
|
+
from sqlalchemy_tolap import enforce
|
|
59
|
+
|
|
60
|
+
rows = enforce(
|
|
61
|
+
select(Patient).where(Patient.full_name.ilike(f"%{q}%")),
|
|
62
|
+
context, # a signed TOLAP SecurityContext from wherever you resolve policies
|
|
63
|
+
session, # Session or Connection; its dialect decides what can be pushed
|
|
64
|
+
signing_key=KEY,
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Entity selects (`select(Patient)`) are the default projection; named columns and labels are
|
|
69
|
+
explicit references. `text()`, `literal_column()`, derived tables in `FROM` and set
|
|
70
|
+
operations are refused. `enforce(..., mode="postOnly")` skips the pushdown but not the
|
|
71
|
+
checks or the post pass.
|
|
72
|
+
|
|
73
|
+
Design notes, the vendor rules and the gap measurement against upstream's string rewriter
|
|
74
|
+
are in the [repository README](https://github.com/smhasan94/django-tolap#readme).
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
Apache-2.0. Not affiliated with AWS; TOLAP is their project.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# sqlalchemy-tolap
|
|
2
|
+
|
|
3
|
+
**TOLAP policies enforced on SQLAlchemy `Select` statements with ORM-native pushdown.**
|
|
4
|
+
|
|
5
|
+
[TOLAP](https://github.com/awslabs/tolap) (Tool-Object Level Access Protocol, AWS,
|
|
6
|
+
Apache-2.0) decides what an AI agent's tool may *return*: which tables, columns and rows,
|
|
7
|
+
how fields are masked, how many results. `sqlalchemy-tolap` enforces a signed TOLAP
|
|
8
|
+
context on a SQLAlchemy 2.x `Select` before it runs, and applies TOLAP's own
|
|
9
|
+
post-execution pass afterwards.
|
|
10
|
+
|
|
11
|
+
- **Pre-execution checks.** Signature and expiry, `canQuery`, object access for every table
|
|
12
|
+
in the statement, referenced columns against hidden and allowed sets. Refuses rather than
|
|
13
|
+
narrows.
|
|
14
|
+
- **Pushdown.** Row filters become `WHERE` clauses, the result limit a `LIMIT`, hidden
|
|
15
|
+
columns leave the projection. Only where the dialect's semantics match TOLAP's; anything
|
|
16
|
+
else is left to the post pass and reported, never approximated.
|
|
17
|
+
- **Differential proof.** Pushdown plus post pass returns the same rows as post pass alone,
|
|
18
|
+
on SQLite and PostgreSQL, against upstream's fixtures and a Hypothesis property.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install sqlalchemy-tolap
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Python 3.11+, SQLAlchemy 2.0 or 2.1. Pulls `tolap-core` from PyPI.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from sqlalchemy import select
|
|
32
|
+
from sqlalchemy_tolap import enforce
|
|
33
|
+
|
|
34
|
+
rows = enforce(
|
|
35
|
+
select(Patient).where(Patient.full_name.ilike(f"%{q}%")),
|
|
36
|
+
context, # a signed TOLAP SecurityContext from wherever you resolve policies
|
|
37
|
+
session, # Session or Connection; its dialect decides what can be pushed
|
|
38
|
+
signing_key=KEY,
|
|
39
|
+
)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Entity selects (`select(Patient)`) are the default projection; named columns and labels are
|
|
43
|
+
explicit references. `text()`, `literal_column()`, derived tables in `FROM` and set
|
|
44
|
+
operations are refused. `enforce(..., mode="postOnly")` skips the pushdown but not the
|
|
45
|
+
checks or the post pass.
|
|
46
|
+
|
|
47
|
+
Design notes, the vendor rules and the gap measurement against upstream's string rewriter
|
|
48
|
+
are in the [repository README](https://github.com/smhasan94/django-tolap#readme).
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
Apache-2.0. Not affiliated with AWS; TOLAP is their project.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sqlalchemy-tolap"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "TOLAP policies enforced on SQLAlchemy Select statements with ORM-native pushdown"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "Sharukh Hasan" }]
|
|
13
|
+
keywords = ["sqlalchemy", "tolap", "access-control", "ai-agents", "mcp"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"License :: OSI Approved :: Apache Software License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
"Topic :: Database",
|
|
23
|
+
"Topic :: Security",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"SQLAlchemy>=2.0,<2.2",
|
|
27
|
+
"tolap-core>=1.0,<2",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/smhasan94/django-tolap"
|
|
32
|
+
Issues = "https://github.com/smhasan94/django-tolap/issues"
|
|
33
|
+
Changelog = "https://github.com/smhasan94/django-tolap/blob/main/CHANGELOG.md"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["sqlalchemy_tolap"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""sqlalchemy-tolap: TOLAP policies enforced on SQLAlchemy Select statements."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy_tolap.enforce import enforce
|
|
4
|
+
from sqlalchemy_tolap.exceptions import TolapDenied, Uninspectable
|
|
5
|
+
from sqlalchemy_tolap.pushdown import EnforcementMode
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
__all__ = ["EnforcementMode", "TolapDenied", "Uninspectable", "enforce"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""``enforce()``: verify, pre-check, push down, execute, post-pass. The post pass always runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from tolap_core import SecurityContext, validate_context, validate_expiry
|
|
8
|
+
|
|
9
|
+
from sqlalchemy_tolap.exceptions import TolapDenied
|
|
10
|
+
from sqlalchemy_tolap.pushdown import EnforcementMode, Preparation, finalize, prepare_select
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate(context: SecurityContext, signing_key: str) -> None:
|
|
14
|
+
if not validate_context(context, signing_key):
|
|
15
|
+
raise TolapDenied("invalid signature")
|
|
16
|
+
expiry_reason = validate_expiry(context)
|
|
17
|
+
if expiry_reason is not None:
|
|
18
|
+
raise TolapDenied(expiry_reason)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def dialect_name(executor: Any) -> str:
|
|
22
|
+
"""The dialect of a Session, Connection or Engine."""
|
|
23
|
+
if hasattr(executor, "get_bind"):
|
|
24
|
+
return str(executor.get_bind().dialect.name)
|
|
25
|
+
if hasattr(executor, "dialect"):
|
|
26
|
+
return str(executor.dialect.name)
|
|
27
|
+
if hasattr(executor, "engine"):
|
|
28
|
+
return str(executor.engine.dialect.name)
|
|
29
|
+
raise TypeError("executor must be a Session, Connection or Engine")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def enforce(
|
|
33
|
+
stmt: Any,
|
|
34
|
+
context: SecurityContext,
|
|
35
|
+
executor: Any,
|
|
36
|
+
*,
|
|
37
|
+
signing_key: str,
|
|
38
|
+
hash_salt: str | bytes | None = None,
|
|
39
|
+
mode: EnforcementMode | str = EnforcementMode.rewrite_and_post,
|
|
40
|
+
) -> list[dict[str, Any]]:
|
|
41
|
+
"""Rows of ``stmt`` the signed ``context``'s policy allows, as dicts.
|
|
42
|
+
|
|
43
|
+
``executor`` is a Session or Connection (anything with ``execute``); the dialect it is
|
|
44
|
+
bound to decides which filters can be pushed. Raises :class:`TolapDenied` on refusal.
|
|
45
|
+
"""
|
|
46
|
+
validate(context, signing_key)
|
|
47
|
+
policy = context.effective_policy
|
|
48
|
+
prep: Preparation = prepare_select(stmt, policy, dialect=dialect_name(executor), mode=mode)
|
|
49
|
+
if not prep.allowed or prep.statement is None:
|
|
50
|
+
raise TolapDenied(prep.denial_reason or "access denied")
|
|
51
|
+
rows = [dict(row) for row in executor.execute(prep.statement).mappings().all()]
|
|
52
|
+
return finalize(prep, rows, policy, hash_salt)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Exceptions raised by sqlalchemy-tolap. Messages carry a reason, never row data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TolapDenied(PermissionError):
|
|
7
|
+
def __init__(self, reason: str) -> None:
|
|
8
|
+
super().__init__(f"Access denied: {reason}")
|
|
9
|
+
self.reason = reason
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Uninspectable(TolapDenied):
|
|
13
|
+
def __init__(self, why: str) -> None:
|
|
14
|
+
super().__init__(f"query cannot be inspected: {why}")
|
|
15
|
+
self.why = why
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Determine which tables and columns a ``Select`` references, or refuse.
|
|
2
|
+
|
|
3
|
+
Walks the statement's FROM list, WHERE, ORDER BY, GROUP BY, HAVING and projection with
|
|
4
|
+
``sqlalchemy.sql.visitors.iterate`` (which descends into correlated subqueries and EXISTS).
|
|
5
|
+
Anything opaque -- ``text()``, ``literal_column()``, unattached ``column()``, derived tables
|
|
6
|
+
(subquery/CTE/lateral in FROM), set operations -- is refused: fetching less is never a risk,
|
|
7
|
+
returning more is.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from sqlalchemy import Column, Table
|
|
17
|
+
from sqlalchemy.sql import visitors
|
|
18
|
+
from sqlalchemy.sql.elements import ColumnClause, Label, TextClause
|
|
19
|
+
from sqlalchemy.sql.selectable import Alias, CompoundSelect, FromClause, Join, Select, TextualSelect
|
|
20
|
+
|
|
21
|
+
from sqlalchemy_tolap.exceptions import Uninspectable
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, order=True)
|
|
25
|
+
class ColRef:
|
|
26
|
+
table: str
|
|
27
|
+
name: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Inspection:
|
|
32
|
+
root: Table
|
|
33
|
+
root_from: FromClause # the FROM element itself: the Table, or an Alias of it
|
|
34
|
+
tables: dict[str, Table]
|
|
35
|
+
referenced: frozenset[ColRef] # explicit references only; never a default projection
|
|
36
|
+
projected: tuple[str, ...] | None # names in the caller's explicit projection, else None
|
|
37
|
+
selected: tuple[Any, ...] # the selected column elements, in order
|
|
38
|
+
annotations: dict[str, frozenset[ColRef]]
|
|
39
|
+
limit: int | None
|
|
40
|
+
offset: int | None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def canonical(table: Table) -> Table:
|
|
44
|
+
"""The MetaData-registered Table (ORM and aliasing hand out annotated copies)."""
|
|
45
|
+
registered = table.metadata.tables.get(table.key)
|
|
46
|
+
return registered if registered is not None else table
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def base_table(column: Column[Any]) -> Table:
|
|
50
|
+
owner = column.table
|
|
51
|
+
if isinstance(owner, Table):
|
|
52
|
+
return canonical(owner)
|
|
53
|
+
if isinstance(owner, Alias) and isinstance(owner.element, Table):
|
|
54
|
+
return canonical(owner.element)
|
|
55
|
+
raise Uninspectable(f"column {column.name!r} belongs to a derived table")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _Walker:
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
self.refs: set[ColRef] = set()
|
|
61
|
+
self.tables: dict[str, Table] = {}
|
|
62
|
+
|
|
63
|
+
def table(self, table: Table) -> None:
|
|
64
|
+
table = canonical(table)
|
|
65
|
+
self.tables[table.name] = table
|
|
66
|
+
|
|
67
|
+
def froms(self, froms: Iterable[FromClause]) -> None:
|
|
68
|
+
for f in froms:
|
|
69
|
+
self.from_(f)
|
|
70
|
+
|
|
71
|
+
def from_(self, f: FromClause) -> None:
|
|
72
|
+
if isinstance(f, Table):
|
|
73
|
+
self.table(f)
|
|
74
|
+
elif isinstance(f, Join):
|
|
75
|
+
self.from_(f.left)
|
|
76
|
+
self.from_(f.right)
|
|
77
|
+
if f.onclause is not None:
|
|
78
|
+
self.nodes(f.onclause)
|
|
79
|
+
elif isinstance(f, Alias) and isinstance(f.element, Table):
|
|
80
|
+
self.table(f.element)
|
|
81
|
+
else:
|
|
82
|
+
raise Uninspectable(f"{type(f).__name__} in FROM is not supported")
|
|
83
|
+
|
|
84
|
+
def nodes(self, element: Any) -> None:
|
|
85
|
+
for node in visitors.iterate(element):
|
|
86
|
+
self.node(node)
|
|
87
|
+
|
|
88
|
+
def node(self, node: Any) -> None:
|
|
89
|
+
if isinstance(node, TextClause | TextualSelect | CompoundSelect):
|
|
90
|
+
raise Uninspectable(f"{type(node).__name__} is not supported")
|
|
91
|
+
if isinstance(node, Select):
|
|
92
|
+
# A nested select: its FROM list must be inspectable too (iterate does not
|
|
93
|
+
# visit FROM tables as nodes).
|
|
94
|
+
self.froms(node.get_final_froms())
|
|
95
|
+
return
|
|
96
|
+
if isinstance(node, Column):
|
|
97
|
+
table = base_table(node)
|
|
98
|
+
self.table(table)
|
|
99
|
+
self.refs.add(ColRef(table.name, node.name))
|
|
100
|
+
return
|
|
101
|
+
if isinstance(node, ColumnClause):
|
|
102
|
+
if node.is_literal and node.name == "*":
|
|
103
|
+
return # the ``SELECT *`` inside ``exists()``; discloses nothing by itself
|
|
104
|
+
what = "literal_column()" if node.is_literal else "an unattached column()"
|
|
105
|
+
raise Uninspectable(f"{what} is not supported")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _entity_tables(stmt: Select[Any]) -> tuple[list[Table], int]:
|
|
109
|
+
"""Tables selected whole (``select(Entity)``, ``select(table)``) and the raw column count."""
|
|
110
|
+
raw = list(stmt._raw_columns)
|
|
111
|
+
tables: list[Table] = []
|
|
112
|
+
for rc in raw:
|
|
113
|
+
if isinstance(rc, Table):
|
|
114
|
+
tables.append(canonical(rc))
|
|
115
|
+
elif isinstance(rc, Alias) and isinstance(rc.element, Table):
|
|
116
|
+
tables.append(canonical(rc.element))
|
|
117
|
+
return tables, len(raw)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def inspect(stmt: Any) -> Inspection:
|
|
121
|
+
if not isinstance(stmt, Select):
|
|
122
|
+
raise Uninspectable(f"{type(stmt).__name__} is not a Select")
|
|
123
|
+
walker = _Walker()
|
|
124
|
+
froms = stmt.get_final_froms()
|
|
125
|
+
if not froms:
|
|
126
|
+
raise Uninspectable("statement has no FROM")
|
|
127
|
+
walker.froms(froms)
|
|
128
|
+
root_from: FromClause = froms[0]
|
|
129
|
+
while isinstance(root_from, Join):
|
|
130
|
+
root_from = root_from.left
|
|
131
|
+
if isinstance(root_from, Table):
|
|
132
|
+
root = canonical(root_from)
|
|
133
|
+
elif isinstance(root_from, Alias) and isinstance(root_from.element, Table):
|
|
134
|
+
root = canonical(root_from.element)
|
|
135
|
+
else:
|
|
136
|
+
raise Uninspectable(f"{type(root_from).__name__} as the root FROM is not supported")
|
|
137
|
+
|
|
138
|
+
if stmt.whereclause is not None:
|
|
139
|
+
walker.nodes(stmt.whereclause)
|
|
140
|
+
for clause in (*stmt._order_by_clauses, *stmt._group_by_clauses, *stmt._having_criteria):
|
|
141
|
+
walker.nodes(clause)
|
|
142
|
+
|
|
143
|
+
# Projection: an entity/table select is a default projection (checked at prepare time,
|
|
144
|
+
# like SELECT *); named columns and labelled expressions are explicit references.
|
|
145
|
+
entity_tables, raw_count = _entity_tables(stmt)
|
|
146
|
+
explicit = not entity_tables
|
|
147
|
+
if entity_tables and raw_count != len(entity_tables):
|
|
148
|
+
raise Uninspectable("mixing an entity with columns in the projection is not supported")
|
|
149
|
+
if entity_tables and any(t is not root for t in entity_tables):
|
|
150
|
+
raise Uninspectable("projection of an entity other than the root table is not supported")
|
|
151
|
+
|
|
152
|
+
selected = tuple(stmt.selected_columns)
|
|
153
|
+
projected: list[str] = []
|
|
154
|
+
annotations: dict[str, frozenset[ColRef]] = {}
|
|
155
|
+
if explicit:
|
|
156
|
+
for col in selected:
|
|
157
|
+
if isinstance(col, Column):
|
|
158
|
+
table = base_table(col)
|
|
159
|
+
if table is not root:
|
|
160
|
+
raise Uninspectable(
|
|
161
|
+
f"projection of {table.name}.{col.name} is not a root-table column"
|
|
162
|
+
)
|
|
163
|
+
walker.refs.add(ColRef(table.name, col.name))
|
|
164
|
+
projected.append(col.name)
|
|
165
|
+
elif isinstance(col, Label):
|
|
166
|
+
if col.name in root.columns:
|
|
167
|
+
# A row filter on that column would otherwise be evaluated against the
|
|
168
|
+
# label's value in the post pass. Django refuses the same shadowing.
|
|
169
|
+
raise Uninspectable(f"label {col.name!r} shadows a column of {root.name}")
|
|
170
|
+
sub = _Walker()
|
|
171
|
+
sub.tables = walker.tables
|
|
172
|
+
sub.nodes(col.element)
|
|
173
|
+
walker.refs |= sub.refs
|
|
174
|
+
annotations[col.name] = frozenset(sub.refs)
|
|
175
|
+
projected.append(col.name)
|
|
176
|
+
else:
|
|
177
|
+
raise Uninspectable("an unlabelled expression in the projection is not supported")
|
|
178
|
+
|
|
179
|
+
return Inspection(
|
|
180
|
+
root=root,
|
|
181
|
+
root_from=root_from,
|
|
182
|
+
tables=dict(walker.tables),
|
|
183
|
+
referenced=frozenset(walker.refs),
|
|
184
|
+
projected=tuple(projected) if explicit else None,
|
|
185
|
+
selected=selected,
|
|
186
|
+
annotations=annotations,
|
|
187
|
+
limit=stmt._limit,
|
|
188
|
+
offset=stmt._offset,
|
|
189
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Field and object name matching with upstream's semantics (connector spec section 3).
|
|
2
|
+
|
|
3
|
+
Mirrors ``tolap_core.enforcement._pattern_matches`` and ``_field_name_matches``, which are
|
|
4
|
+
private upstream. They are reproduced here (not imported) so a refactor upstream cannot
|
|
5
|
+
silently change our pre-execution decisions; ``tests/sqlalchemy/test_matching.py`` asserts parity
|
|
6
|
+
against the upstream functions on a corpus so drift is caught instead.
|
|
7
|
+
|
|
8
|
+
Rules: ``*`` and ``?`` are the only metacharacters, brackets are literal, matching is
|
|
9
|
+
case-insensitive and platform-independent, and a field reference may be bare (``ssn``) or
|
|
10
|
+
qualified (``patients.ssn``) on either side.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from fnmatch import fnmatchcase
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _literal_brackets(pattern: str) -> str:
|
|
19
|
+
return pattern.replace("[", "[[]")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def object_matches(pattern: str, name: str) -> bool:
|
|
23
|
+
"""Case-insensitive glob over the whole name, brackets literal."""
|
|
24
|
+
return fnmatchcase(name.lower(), _literal_brackets(pattern).lower())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _forms(name: str) -> set[str]:
|
|
28
|
+
lowered = name.lower()
|
|
29
|
+
forms = {lowered}
|
|
30
|
+
if "." in lowered:
|
|
31
|
+
forms.add(lowered.split(".", 1)[1])
|
|
32
|
+
forms.add(lowered.rsplit(".", 1)[1])
|
|
33
|
+
return forms
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def field_matches(rule: str, key: str) -> bool:
|
|
37
|
+
"""Whether a policy field reference refers to a record key, either side qualified."""
|
|
38
|
+
return any(
|
|
39
|
+
fnmatchcase(key_form, _literal_brackets(rule_form))
|
|
40
|
+
for rule_form in _forms(rule)
|
|
41
|
+
for key_form in _forms(key)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_pattern(name: str) -> bool:
|
|
46
|
+
return "*" in name or "?" in name
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Pre-execution checks (connector spec section 5), same order and reasons as django-tolap."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from tolap_core import AccessResult, EffectivePolicy, validate_access
|
|
8
|
+
|
|
9
|
+
from sqlalchemy_tolap.exceptions import Uninspectable
|
|
10
|
+
from sqlalchemy_tolap.inspect import Inspection, inspect
|
|
11
|
+
from sqlalchemy_tolap.rules import FieldRules, field_visible, is_masked, unknown_fields
|
|
12
|
+
|
|
13
|
+
UNKNOWN_FIELD = "policy references unknown field: {name}"
|
|
14
|
+
FIELD_DENIED = "denied fields: {names}"
|
|
15
|
+
MASKED_ANNOTATION = "annotation exposes masked field: {name}"
|
|
16
|
+
CANNOT_INSPECT = "query cannot be inspected: {why}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def field_denied(names: list[str]) -> str:
|
|
20
|
+
return FIELD_DENIED.format(names=", ".join(names))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def precheck_inspection(ins: Inspection, policy: EffectivePolicy) -> AccessResult:
|
|
24
|
+
if not policy.permissions.can_query:
|
|
25
|
+
return AccessResult(allowed=False, reason="query not permitted")
|
|
26
|
+
root_access = validate_access(ins.root.name, policy)
|
|
27
|
+
if not root_access.allowed:
|
|
28
|
+
return root_access
|
|
29
|
+
for name in sorted(n for n in ins.tables if n != ins.root.name):
|
|
30
|
+
access = validate_access(name, policy)
|
|
31
|
+
if not access.allowed:
|
|
32
|
+
return access
|
|
33
|
+
rules = FieldRules.of(policy)
|
|
34
|
+
unknown = unknown_fields(ins.root, rules)
|
|
35
|
+
if unknown:
|
|
36
|
+
return AccessResult(allowed=False, reason=UNKNOWN_FIELD.format(name=unknown[0]))
|
|
37
|
+
denied = sorted(
|
|
38
|
+
f"{r.table}.{r.name}"
|
|
39
|
+
for r in ins.referenced
|
|
40
|
+
if not field_visible(rules, f"{r.table}.{r.name}")
|
|
41
|
+
)
|
|
42
|
+
if denied:
|
|
43
|
+
return AccessResult(allowed=False, reason=field_denied(denied))
|
|
44
|
+
for label, sources in sorted(ins.annotations.items()):
|
|
45
|
+
for ref in sorted(sources):
|
|
46
|
+
if is_masked(rules, f"{ref.table}.{ref.name}"):
|
|
47
|
+
return AccessResult(allowed=False, reason=MASKED_ANNOTATION.format(name=label))
|
|
48
|
+
return AccessResult(allowed=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def precheck(stmt: Any, policy: EffectivePolicy) -> AccessResult:
|
|
52
|
+
try:
|
|
53
|
+
ins = inspect(stmt)
|
|
54
|
+
except Uninspectable as exc:
|
|
55
|
+
return AccessResult(allowed=False, reason=CANNOT_INSPECT.format(why=exc.why))
|
|
56
|
+
return precheck_inspection(ins, policy)
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Compile TOLAP row filters into SQLAlchemy criteria, or decline; prepare a whole Select.
|
|
2
|
+
|
|
3
|
+
Same contract and semantics as ``django_tolap.pushdown`` (a pushed predicate selects exactly
|
|
4
|
+
the rows upstream's post pass keeps, on this dialect). Unlike Django's ORM, SQLAlchemy does
|
|
5
|
+
not add a null arm to negations, so ``notEquals``/``notIn``/``notLike`` are rendered as
|
|
6
|
+
``(col <> x OR col IS NULL)`` here, as upstream's own rewriter does.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from dataclasses import field as dataclass_field
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Any, Literal
|
|
16
|
+
|
|
17
|
+
from sqlalchemy import Column, Table, and_, false, or_, true
|
|
18
|
+
from sqlalchemy import types as sa_types
|
|
19
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
20
|
+
from sqlalchemy.sql.selectable import Select
|
|
21
|
+
from tolap_core import EffectivePolicy, FilterOperator, RowFilter, apply_result_pipeline
|
|
22
|
+
|
|
23
|
+
from sqlalchemy_tolap.exceptions import Uninspectable
|
|
24
|
+
from sqlalchemy_tolap.inspect import inspect
|
|
25
|
+
from sqlalchemy_tolap.precheck import CANNOT_INSPECT, precheck_inspection
|
|
26
|
+
from sqlalchemy_tolap.rules import FieldRules, field_visible
|
|
27
|
+
|
|
28
|
+
_LOG = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
Kind = Literal["str", "int", "float", "bool", "other"]
|
|
31
|
+
NEVER_PUSHED = frozenset(
|
|
32
|
+
{FilterOperator.contains, FilterOperator.starts_with, FilterOperator.matches}
|
|
33
|
+
)
|
|
34
|
+
MAX_LIKE_PATTERN_LENGTH = 1024
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class EnforcementMode(Enum):
|
|
38
|
+
rewrite_and_post = "rewriteAndPost"
|
|
39
|
+
post_only = "postOnly"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class DialectRules:
|
|
44
|
+
string_equality: bool
|
|
45
|
+
string_order: bool
|
|
46
|
+
like: bool
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
DIALECTS: dict[str, DialectRules] = {
|
|
50
|
+
"postgresql": DialectRules(string_equality=True, string_order=False, like=True),
|
|
51
|
+
"sqlite": DialectRules(string_equality=True, string_order=True, like=False),
|
|
52
|
+
"mysql": DialectRules(string_equality=False, string_order=False, like=False),
|
|
53
|
+
"mariadb": DialectRules(string_equality=False, string_order=False, like=False),
|
|
54
|
+
"oracle": DialectRules(string_equality=False, string_order=False, like=False),
|
|
55
|
+
"mssql": DialectRules(string_equality=False, string_order=False, like=False),
|
|
56
|
+
}
|
|
57
|
+
_warned: set[str] = set()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def dialect_rules(name: str) -> DialectRules | None:
|
|
61
|
+
rules = DIALECTS.get(name)
|
|
62
|
+
if rules is None and name not in _warned:
|
|
63
|
+
_warned.add(name)
|
|
64
|
+
_LOG.warning("TOLAP pushdown disabled: unknown dialect %r", name)
|
|
65
|
+
return rules
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def column_kind(column: Column[Any]) -> Kind:
|
|
69
|
+
t = column.type
|
|
70
|
+
if isinstance(t, sa_types.Boolean):
|
|
71
|
+
return "bool"
|
|
72
|
+
if isinstance(t, sa_types.String):
|
|
73
|
+
return "str"
|
|
74
|
+
if isinstance(t, sa_types.Integer):
|
|
75
|
+
return "int"
|
|
76
|
+
if isinstance(t, sa_types.Float):
|
|
77
|
+
return "float"
|
|
78
|
+
return "other"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def value_fits(kind: Kind, value: Any) -> bool:
|
|
82
|
+
if isinstance(value, bool):
|
|
83
|
+
return kind == "bool"
|
|
84
|
+
if kind == "str":
|
|
85
|
+
return isinstance(value, str)
|
|
86
|
+
if kind == "int":
|
|
87
|
+
return isinstance(value, int)
|
|
88
|
+
if kind == "float":
|
|
89
|
+
return isinstance(value, int | float)
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def resolve_column(rf: RowFilter, table: Table) -> Column[Any] | None:
|
|
94
|
+
qualifier, _, leaf = rf.field.rpartition(".")
|
|
95
|
+
if qualifier and qualifier.lower() != table.name.lower():
|
|
96
|
+
return None
|
|
97
|
+
for column in table.columns:
|
|
98
|
+
if column.name.lower() == leaf.lower():
|
|
99
|
+
return column
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _nothing() -> ColumnElement[bool]:
|
|
104
|
+
return false()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
_ORDERING = {
|
|
108
|
+
FilterOperator.greater_than: "__gt__",
|
|
109
|
+
FilterOperator.greater_than_or_equal: "__ge__",
|
|
110
|
+
FilterOperator.less_than: "__lt__",
|
|
111
|
+
FilterOperator.less_than_or_equal: "__le__",
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def compile_filter(
|
|
116
|
+
rf: RowFilter, table: Table, dialect: str, *, source: Any = None
|
|
117
|
+
) -> ColumnElement[bool] | None:
|
|
118
|
+
"""Criteria for ``rf`` on ``table``'s column, taken from ``source`` (an alias) if given."""
|
|
119
|
+
rules = dialect_rules(dialect)
|
|
120
|
+
if rules is None or rf.operator in NEVER_PUSHED:
|
|
121
|
+
return None
|
|
122
|
+
resolved = resolve_column(rf, table)
|
|
123
|
+
if resolved is None or resolved.foreign_keys:
|
|
124
|
+
return None
|
|
125
|
+
col: Column[Any] = source.c[resolved.name] if source is not None else resolved
|
|
126
|
+
op = rf.operator
|
|
127
|
+
if op is FilterOperator.is_null:
|
|
128
|
+
return col.is_(None)
|
|
129
|
+
if op is FilterOperator.is_not_null:
|
|
130
|
+
return col.is_not(None)
|
|
131
|
+
|
|
132
|
+
kind = column_kind(col)
|
|
133
|
+
if kind == "other":
|
|
134
|
+
return None
|
|
135
|
+
if kind == "str" and getattr(col.type, "collation", None):
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
if op in (FilterOperator.equals, FilterOperator.not_equals):
|
|
139
|
+
negated = op is FilterOperator.not_equals
|
|
140
|
+
if rf.value is None:
|
|
141
|
+
return col.is_not(None) if negated else col.is_(None)
|
|
142
|
+
if not value_fits(kind, rf.value) or (kind == "str" and not rules.string_equality):
|
|
143
|
+
return None
|
|
144
|
+
return or_(col != rf.value, col.is_(None)) if negated else col == rf.value
|
|
145
|
+
|
|
146
|
+
if op in (FilterOperator.in_, FilterOperator.not_in):
|
|
147
|
+
values = list(rf.values or [])
|
|
148
|
+
has_null = any(v is None for v in values)
|
|
149
|
+
members = [v for v in values if v is not None]
|
|
150
|
+
if any(not value_fits(kind, v) for v in members):
|
|
151
|
+
return None
|
|
152
|
+
if kind == "str" and members and not rules.string_equality:
|
|
153
|
+
return None
|
|
154
|
+
if op is FilterOperator.in_:
|
|
155
|
+
if not values:
|
|
156
|
+
return _nothing()
|
|
157
|
+
crit = col.in_(members) if members else _nothing()
|
|
158
|
+
return or_(crit, col.is_(None)) if has_null else crit
|
|
159
|
+
if not values:
|
|
160
|
+
return true()
|
|
161
|
+
crit = col.not_in(members) if members else true()
|
|
162
|
+
return and_(crit, col.is_not(None)) if has_null else or_(crit, col.is_(None))
|
|
163
|
+
|
|
164
|
+
if op in _ORDERING:
|
|
165
|
+
if rf.value is None:
|
|
166
|
+
return _nothing()
|
|
167
|
+
if not value_fits(kind, rf.value) or kind == "bool":
|
|
168
|
+
return None
|
|
169
|
+
if kind == "str" and not rules.string_order:
|
|
170
|
+
return None
|
|
171
|
+
result: ColumnElement[bool] = getattr(col, _ORDERING[op])(rf.value)
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
if op is FilterOperator.between:
|
|
175
|
+
bounds = list(rf.values or [])
|
|
176
|
+
if len(bounds) < 2 or bounds[0] is None or bounds[1] is None:
|
|
177
|
+
return _nothing()
|
|
178
|
+
if kind == "bool" or not all(value_fits(kind, b) for b in bounds[:2]):
|
|
179
|
+
return None
|
|
180
|
+
if kind == "str" and not rules.string_order:
|
|
181
|
+
return None
|
|
182
|
+
return col.between(bounds[0], bounds[1])
|
|
183
|
+
|
|
184
|
+
if op in (FilterOperator.like, FilterOperator.not_like):
|
|
185
|
+
if kind != "str" or not rules.like or not isinstance(rf.value, str):
|
|
186
|
+
return None
|
|
187
|
+
if len(rf.value) > MAX_LIKE_PATTERN_LENGTH:
|
|
188
|
+
return None
|
|
189
|
+
if op is FilterOperator.like:
|
|
190
|
+
return col.like(rf.value, escape="\\")
|
|
191
|
+
return or_(col.not_like(rf.value, escape="\\"), col.is_(None))
|
|
192
|
+
|
|
193
|
+
return None # pragma: no cover
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
NO_FIELDS_VISIBLE = "no fields visible"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass
|
|
200
|
+
class Preparation:
|
|
201
|
+
allowed: bool
|
|
202
|
+
statement: Select[Any] | None
|
|
203
|
+
denial_reason: str | None = None
|
|
204
|
+
unpushable_filters: list[RowFilter] = dataclass_field(default_factory=list)
|
|
205
|
+
pushed_filters: list[RowFilter] = dataclass_field(default_factory=list)
|
|
206
|
+
visible_fields: tuple[str, ...] = ()
|
|
207
|
+
projection: tuple[str, ...] = ()
|
|
208
|
+
extra_fields: tuple[str, ...] = ()
|
|
209
|
+
max_results: int | None = None
|
|
210
|
+
mode: EnforcementMode = EnforcementMode.rewrite_and_post
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def fully_pushed_down(self) -> bool:
|
|
214
|
+
return self.allowed and not self.unpushable_filters
|
|
215
|
+
|
|
216
|
+
@classmethod
|
|
217
|
+
def denied(cls, reason: str) -> Preparation:
|
|
218
|
+
return cls(allowed=False, statement=None, denial_reason=reason)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def prepare_select(
|
|
222
|
+
stmt: Any,
|
|
223
|
+
policy: EffectivePolicy,
|
|
224
|
+
*,
|
|
225
|
+
dialect: str,
|
|
226
|
+
mode: EnforcementMode | str = EnforcementMode.rewrite_and_post,
|
|
227
|
+
) -> Preparation:
|
|
228
|
+
"""Pre-check, then push filters, projection and limit into a new Select."""
|
|
229
|
+
resolved_mode = EnforcementMode(mode) if isinstance(mode, str) else mode
|
|
230
|
+
try:
|
|
231
|
+
ins = inspect(stmt)
|
|
232
|
+
except Uninspectable as exc:
|
|
233
|
+
return Preparation.denied(CANNOT_INSPECT.format(why=exc.why))
|
|
234
|
+
access = precheck_inspection(ins, policy)
|
|
235
|
+
if not access.allowed:
|
|
236
|
+
return Preparation.denied(access.reason or "access denied")
|
|
237
|
+
|
|
238
|
+
root = ins.root
|
|
239
|
+
rules = FieldRules.of(policy)
|
|
240
|
+
by_name = {c.name: ins.root_from.c[c.name] for c in root.columns} # alias-aware
|
|
241
|
+
visible = tuple(c.name for c in root.columns if field_visible(rules, f"{root.name}.{c.name}"))
|
|
242
|
+
|
|
243
|
+
if ins.projected is None:
|
|
244
|
+
base: list[Any] = [by_name[n] for n in visible]
|
|
245
|
+
names = list(visible)
|
|
246
|
+
else:
|
|
247
|
+
base = []
|
|
248
|
+
names = []
|
|
249
|
+
for col in ins.selected:
|
|
250
|
+
if isinstance(col, Column):
|
|
251
|
+
if col.name in visible:
|
|
252
|
+
base.append(col)
|
|
253
|
+
names.append(col.name)
|
|
254
|
+
else: # a Label already checked by the pre-check
|
|
255
|
+
base.append(col)
|
|
256
|
+
names.append(col.name)
|
|
257
|
+
if not base:
|
|
258
|
+
return Preparation.denied(NO_FIELDS_VISIBLE)
|
|
259
|
+
|
|
260
|
+
row_filters = list(policy.object_rules.row_filters or ()) if policy.object_rules else []
|
|
261
|
+
extra: list[str] = []
|
|
262
|
+
for rf in row_filters:
|
|
263
|
+
col = resolve_column(rf, root)
|
|
264
|
+
if col is not None and col.name not in names and col.name not in extra:
|
|
265
|
+
extra.append(col.name)
|
|
266
|
+
for name in extra:
|
|
267
|
+
base.append(by_name[name])
|
|
268
|
+
|
|
269
|
+
sliced = ins.limit is not None or ins.offset is not None
|
|
270
|
+
push = resolved_mode is EnforcementMode.rewrite_and_post and not sliced
|
|
271
|
+
pushed: list[RowFilter] = []
|
|
272
|
+
unpushable: list[RowFilter] = []
|
|
273
|
+
prepared: Select[Any] = stmt
|
|
274
|
+
for rf in row_filters:
|
|
275
|
+
crit = compile_filter(rf, root, dialect, source=ins.root_from) if push else None
|
|
276
|
+
if crit is None:
|
|
277
|
+
unpushable.append(rf)
|
|
278
|
+
else:
|
|
279
|
+
pushed.append(rf)
|
|
280
|
+
prepared = prepared.where(crit)
|
|
281
|
+
|
|
282
|
+
prepared = prepared.with_only_columns(*base, maintain_column_froms=True)
|
|
283
|
+
|
|
284
|
+
# The limit is pushed only when every row filter was pushed (see django_tolap.pushdown).
|
|
285
|
+
max_results = policy.limits.max_results if policy.limits else None
|
|
286
|
+
if (
|
|
287
|
+
max_results is not None
|
|
288
|
+
and resolved_mode is EnforcementMode.rewrite_and_post
|
|
289
|
+
and not unpushable
|
|
290
|
+
):
|
|
291
|
+
if ins.limit is None or max_results < ins.limit:
|
|
292
|
+
prepared = prepared.limit(max_results)
|
|
293
|
+
|
|
294
|
+
return Preparation(
|
|
295
|
+
allowed=True,
|
|
296
|
+
statement=prepared,
|
|
297
|
+
unpushable_filters=unpushable,
|
|
298
|
+
pushed_filters=pushed,
|
|
299
|
+
visible_fields=visible,
|
|
300
|
+
projection=(*names, *extra),
|
|
301
|
+
extra_fields=tuple(extra),
|
|
302
|
+
max_results=max_results,
|
|
303
|
+
mode=resolved_mode,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def finalize(
|
|
308
|
+
prep: Preparation,
|
|
309
|
+
rows: list[dict[str, Any]],
|
|
310
|
+
policy: EffectivePolicy,
|
|
311
|
+
hash_salt: str | bytes | None,
|
|
312
|
+
) -> list[dict[str, Any]]:
|
|
313
|
+
result: list[dict[str, Any]] = apply_result_pipeline(rows, policy, hash_salt)
|
|
314
|
+
if not prep.extra_fields:
|
|
315
|
+
return result
|
|
316
|
+
extra = set(prep.extra_fields)
|
|
317
|
+
return [{k: v for k, v in row.items() if k not in extra} for row in result]
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Field-rule helpers shared by the pre-check and the projection (mirrors django_tolap.precheck)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import Table
|
|
8
|
+
from tolap_core import EffectivePolicy, PolicyDefinition
|
|
9
|
+
|
|
10
|
+
from sqlalchemy_tolap.matching import field_matches, is_pattern
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class FieldRules:
|
|
15
|
+
hidden: tuple[str, ...]
|
|
16
|
+
allowed: tuple[str, ...] | None
|
|
17
|
+
masked: tuple[str, ...]
|
|
18
|
+
filtered: tuple[str, ...]
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def of(cls, policy: EffectivePolicy | PolicyDefinition) -> FieldRules:
|
|
22
|
+
rules = policy.object_rules
|
|
23
|
+
fr = rules.field_rules if rules else None
|
|
24
|
+
return cls(
|
|
25
|
+
hidden=tuple(fr.hidden_fields or ()) if fr else (),
|
|
26
|
+
allowed=tuple(fr.allowed_fields) if fr and fr.allowed_fields is not None else None,
|
|
27
|
+
masked=tuple(m.field for m in (fr.masked_fields or ())) if fr else (),
|
|
28
|
+
filtered=tuple(f.field for f in (rules.row_filters or ())) if rules else (),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_hidden(rules: FieldRules, key: str) -> bool:
|
|
33
|
+
return any(field_matches(rule, key) for rule in rules.hidden)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_allowed(rules: FieldRules, key: str) -> bool:
|
|
37
|
+
return rules.allowed is None or any(field_matches(rule, key) for rule in rules.allowed)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_masked(rules: FieldRules, key: str) -> bool:
|
|
41
|
+
return any(field_matches(rule, key) for rule in rules.masked)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def field_visible(rules: FieldRules, key: str) -> bool:
|
|
45
|
+
return not is_hidden(rules, key) and is_allowed(rules, key)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def unknown_fields(table: Table, rules: FieldRules) -> list[str]:
|
|
49
|
+
"""Policy field names that must exist on ``table`` but do not (bare or table-qualified)."""
|
|
50
|
+
obj = table.name.lower()
|
|
51
|
+
columns = {c.name.lower() for c in table.columns}
|
|
52
|
+
unknown: list[str] = []
|
|
53
|
+
for name in (*rules.filtered, *rules.hidden, *(rules.allowed or ()), *rules.masked):
|
|
54
|
+
if is_pattern(name):
|
|
55
|
+
continue
|
|
56
|
+
qualifier, _, leaf = name.rpartition(".")
|
|
57
|
+
if qualifier and qualifier.lower() != obj:
|
|
58
|
+
continue
|
|
59
|
+
if leaf.lower() not in columns:
|
|
60
|
+
unknown.append(name)
|
|
61
|
+
return unknown
|