rowstile 0.1.0-alpha.6 → 0.1.0-alpha.7
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.
- package/command/authzlib/__init__.py +18 -8
- package/command/authzlib/assembled.py +9 -0
- package/command/authzlib/compiler.py +95 -119
- package/command/authzlib/conditions.py +67 -19
- package/command/authzlib/connection.py +0 -17
- package/command/authzlib/database.py +246 -89
- package/command/authzlib/devtools.py +3 -3
- package/command/authzlib/draft.py +40 -6
- package/command/authzlib/errors.py +82 -33
- package/command/authzlib/evaluate.py +282 -71
- package/command/authzlib/fmt.py +7 -11
- package/command/authzlib/governance.py +87 -22
- package/command/authzlib/grant.py +469 -107
- package/command/authzlib/hardening.py +54 -21
- package/command/authzlib/identity.py +17 -6
- package/command/authzlib/insight.py +21 -20
- package/command/authzlib/migrate.py +38 -40
- package/command/authzlib/output.py +156 -85
- package/command/authzlib/parse.py +23 -27
- package/command/authzlib/perf.py +58 -32
- package/command/authzlib/prove.py +49 -26
- package/command/authzlib/refusals.py +10 -10
- package/command/authzlib/review.py +264 -120
- package/command/authzlib/sqlutil.py +97 -1
- package/command/authzlib/statements.py +33 -25
- package/command/authzlib/testing.py +5 -4
- package/command/cli/init.py +4 -6
- package/command/cli/lsp.py +77 -40
- package/command/cli/mcp.py +42 -17
- package/command/cli/migrations.py +2 -1
- package/command/cli/pgwire.py +560 -122
- package/command/cli/rowstile_cli.py +320 -84
- package/command/cli/stack.py +2 -2
- package/command/cli/studio/app.js +16 -5
- package/command/cli/studio.py +151 -113
- package/package.json +8 -8
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
"""authzlib: compile a .authz policy file into PostgreSQL (views, closure tables,
|
|
2
2
|
triggers, row-level security policies and an API for app code)."""
|
|
3
3
|
|
|
4
|
-
from
|
|
5
|
-
from .output import OutputMixin
|
|
6
|
-
from .parse import Loc, PolicyError, parse_policy
|
|
7
|
-
from .testing import TestMixin
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
8
5
|
|
|
6
|
+
from .parse import Loc, PolicyError, parse_policy
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .assembled import Compiler
|
|
12
10
|
|
|
11
|
+
BUILD: str
|
|
13
12
|
|
|
14
13
|
# set with packaging/version.py (the packages copy it); authz.policy_versions records it on each apply
|
|
15
|
-
__version__ = "0.1.0-alpha.
|
|
14
|
+
__version__ = "0.1.0-alpha.7"
|
|
16
15
|
|
|
17
16
|
|
|
18
17
|
def _build() -> str:
|
|
@@ -31,6 +30,17 @@ def _build() -> str:
|
|
|
31
30
|
return f"{__version__}+{h.hexdigest()[:12]}"
|
|
32
31
|
|
|
33
32
|
|
|
34
|
-
|
|
33
|
+
def __getattr__(name: str) -> object:
|
|
34
|
+
"""Compiler and BUILD, made when first asked for: a command that compiles nothing (--version, help, can, fmt)
|
|
35
|
+
doesn't import the compiler, and only one that records or compares the version reads every source file."""
|
|
36
|
+
if name == "Compiler":
|
|
37
|
+
from .assembled import Compiler as value
|
|
38
|
+
elif name == "BUILD":
|
|
39
|
+
value = _build()
|
|
40
|
+
else:
|
|
41
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
42
|
+
globals()[name] = value
|
|
43
|
+
return value
|
|
44
|
+
|
|
35
45
|
|
|
36
46
|
__all__ = ["BUILD", "Compiler", "Loc", "PolicyError", "__version__", "parse_policy"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""The compiler, its parts stacked: authzlib.Compiler, imported when first asked for (see __init__.py)."""
|
|
2
|
+
|
|
3
|
+
from .devtools import DevMixin
|
|
4
|
+
from .output import OutputMixin
|
|
5
|
+
from .testing import TestMixin
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Compiler(TestMixin, DevMixin, OutputMixin):
|
|
9
|
+
"""A policy compiled: each part of the output is one of its bases (OutputMixin assembles them)."""
|
|
@@ -33,6 +33,7 @@ from .parse import (
|
|
|
33
33
|
names_roles,
|
|
34
34
|
)
|
|
35
35
|
from .sqlutil import (
|
|
36
|
+
CAVEAT_ARG,
|
|
36
37
|
check_stable_condition,
|
|
37
38
|
lit,
|
|
38
39
|
names_this,
|
|
@@ -84,13 +85,21 @@ class NextTree(TypedDict):
|
|
|
84
85
|
|
|
85
86
|
|
|
86
87
|
def rule_name(rule: Rule) -> str:
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
"""A write rule's head, as the policy writes it (`update owner_id after`), for messages."""
|
|
89
|
+
# (two masks of one column are refused as masked twice before two rules alike, and refusals explain writes)
|
|
90
|
+
assert rule.command != "mask", "a mask is never named in a message about rules"
|
|
89
91
|
if not rule.columns:
|
|
90
92
|
return "update after" if rule.command == "update check" else rule.command
|
|
91
93
|
return "update " + ", ".join(rule.columns) + (" after" if rule.command == "update check" else "")
|
|
92
94
|
|
|
93
95
|
|
|
96
|
+
# what AZ302 says of a permission that depends on itself otherwise than by inheriting
|
|
97
|
+
DEPENDS_ON_ITSELF = (
|
|
98
|
+
"{} depends on itself; a permission can only recurse as 'or rel.perm' (optionally 'and {{condition}}') "
|
|
99
|
+
"through a relation to objects, and a group only as 'member : group#member'"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
94
103
|
def only_on(node: Expr) -> list[str] | None:
|
|
95
104
|
"""The subject types an arrow the compiler narrowed (arrow_on) follows; None for any other item."""
|
|
96
105
|
match node:
|
|
@@ -255,6 +264,12 @@ class Core:
|
|
|
255
264
|
def T(self, name: str) -> Type:
|
|
256
265
|
return self.types[name]
|
|
257
266
|
|
|
267
|
+
def governing(self, table: str) -> Type:
|
|
268
|
+
"""The type whose permissions a table's rules name."""
|
|
269
|
+
found = [t for t in self.types.values() if t.table == table]
|
|
270
|
+
assert len(found) == 1, "validate refuses rules for a table no type or two types map to (AZ401)"
|
|
271
|
+
return found[0]
|
|
272
|
+
|
|
258
273
|
@staticmethod
|
|
259
274
|
def public_perms(t: Type) -> list[str]:
|
|
260
275
|
"""The permissions the policy declares, without the ones the compiler made."""
|
|
@@ -429,8 +444,8 @@ class Core:
|
|
|
429
444
|
f"{{conditions}} and `not <permission>`, e.g. (viewer or parent.{p.name}) and not denied",
|
|
430
445
|
"AZ304",
|
|
431
446
|
)
|
|
432
|
-
if not rest:
|
|
433
|
-
|
|
447
|
+
if not rest: # it comes back to itself only through a `not`
|
|
448
|
+
fail(p.loc, DEPENDS_ON_ITSELF.format(f"{t.name}.{p.name}"), "AZ302")
|
|
434
449
|
first = rest[0]
|
|
435
450
|
items = list(first.items) if isinstance(first, Or) else [first]
|
|
436
451
|
items = [
|
|
@@ -492,24 +507,16 @@ class Core:
|
|
|
492
507
|
"""`view = view__base and not denied` means what `(... or parent.view) and not denied` says only if
|
|
493
508
|
denied holds on everything below a denied object: denied inherits through every link view__base
|
|
494
509
|
does (without a condition, or with the same one). Otherwise the deny would cut inheritance at the
|
|
495
|
-
denied object only, which needs each user's paths, and rowstile stores objects' ancestors.
|
|
510
|
+
denied object only, which needs each user's paths, and rowstile stores objects' ancestors. (A deny whose
|
|
511
|
+
inheritance goes through another type's permission comes back to it: analyze_recursion says so.)"""
|
|
496
512
|
for (tn, pn), negs in self.denies.items():
|
|
497
513
|
t = self.T(tn)
|
|
498
514
|
base_name = t.perms[pn].base
|
|
499
515
|
assert base_name is not None # split_denies gave every permission with a deny its base
|
|
500
516
|
base = t.perms[base_name]
|
|
501
|
-
key = self.recursive.get((tn, base.name))
|
|
502
|
-
if key is None or len(self.scc_members[key]) != 1:
|
|
503
|
-
fail(
|
|
504
|
-
base.loc,
|
|
505
|
-
f"{tn}.{pn} has a deny, so it can only inherit within {tn} (like parent.{pn}), "
|
|
506
|
-
f"not through permissions of other types",
|
|
507
|
-
"AZ306",
|
|
508
|
-
)
|
|
509
517
|
edges = self.perm_edges(t, base) or frozenset()
|
|
510
518
|
for x in negs:
|
|
511
|
-
|
|
512
|
-
continue # split_denies refused anything else
|
|
519
|
+
assert isinstance(x, Not) and isinstance(x.item, Ref), "split_denies refuses any other deny (AZ306)"
|
|
513
520
|
name = x.item.name
|
|
514
521
|
self.lookup(t, name, base.loc)
|
|
515
522
|
if name not in t.perms:
|
|
@@ -613,6 +620,7 @@ class Core:
|
|
|
613
620
|
if (rule.table, c) in masked:
|
|
614
621
|
fail(rule.loc, f"{rule.table}.{c} is masked twice (also on {masked[rule.table, c]})", "AZ109")
|
|
615
622
|
masked[rule.table, c] = rule.loc
|
|
623
|
+
shows: dict[str, str] = {} # each view, as the SQL names it -> the table it shows
|
|
616
624
|
for table, view in self.pol.views.items():
|
|
617
625
|
view_loc = self.pol.view_locs[table]
|
|
618
626
|
if not any(r.table == table and r.command == "select" and not r.columns for r in self.rules):
|
|
@@ -621,6 +629,14 @@ class Core:
|
|
|
621
629
|
)
|
|
622
630
|
if view == table or any(t.table == view for t in self.types.values()):
|
|
623
631
|
fail(view_loc, f"{view} is a table of the policy; name a new view", "AZ402")
|
|
632
|
+
other = shows.setdefault(qt(view), table)
|
|
633
|
+
if other != table:
|
|
634
|
+
fail(
|
|
635
|
+
view_loc,
|
|
636
|
+
f"{view} is already the view of {other} ({self.pol.view_locs[other]}): give {table} a view of "
|
|
637
|
+
f"its own",
|
|
638
|
+
"AZ109",
|
|
639
|
+
)
|
|
624
640
|
for sc in self.pol.scopes.values():
|
|
625
641
|
for kind, qual, word in sc.items:
|
|
626
642
|
if kind == "cmd" and qual and not any(t.table == qual for t in self.types.values()):
|
|
@@ -680,7 +696,8 @@ class Core:
|
|
|
680
696
|
"""The permissions the runtime asks for by name (beside `share`, which shared relations check) are
|
|
681
697
|
declared where it asks: anywhere else nothing would ever use them, or what they allow could never be done."""
|
|
682
698
|
owners_of_roles = {self.role_owner_type(t) for t in self.types.values() if t.roles and t.roles_from}
|
|
683
|
-
|
|
699
|
+
# the types whose custom roles any owner may make (a roles line without `from`)
|
|
700
|
+
roles_anywhere = [t for t in self.types.values() if t.roles and not t.roles_from]
|
|
684
701
|
for t in self.types.values():
|
|
685
702
|
if "impersonate" in t.perms and t.name != "user":
|
|
686
703
|
fail(
|
|
@@ -725,7 +742,7 @@ class Core:
|
|
|
725
742
|
"AZ307",
|
|
726
743
|
)
|
|
727
744
|
if roles_anywhere and not any("manage_roles" in t.perms for t in self.types.values()):
|
|
728
|
-
t =
|
|
745
|
+
t = roles_anywhere[0]
|
|
729
746
|
assert t.roles is not None
|
|
730
747
|
fail(
|
|
731
748
|
t.roles[2],
|
|
@@ -777,18 +794,15 @@ class Core:
|
|
|
777
794
|
own line: a typo in a relation's only use would otherwise be reported as the relation being unused."""
|
|
778
795
|
used: set[Name] = set()
|
|
779
796
|
|
|
780
|
-
def walk(t: Type, node: Expr, loc: Loc
|
|
797
|
+
def walk(t: Type, node: Expr, loc: Loc) -> None:
|
|
781
798
|
match node:
|
|
782
799
|
case ("ref", name):
|
|
783
|
-
|
|
784
|
-
self.lookup(t, name, loc)
|
|
800
|
+
self.lookup(t, name, loc)
|
|
785
801
|
used.add((t.name, name))
|
|
786
802
|
case ("arrow", rel, perm) | ("arrow_on", rel, perm, _):
|
|
787
|
-
|
|
788
|
-
self.lookup(t, rel, loc)
|
|
803
|
+
r, _ = self.targets(t, rel, loc) # what a dot follows: a relation to objects
|
|
789
804
|
used.add((t.name, rel))
|
|
790
|
-
|
|
791
|
-
for st, _sr in r.subjects() if r else []:
|
|
805
|
+
for st, _sr in r.subjects():
|
|
792
806
|
used.add((st, perm))
|
|
793
807
|
case ("not", item):
|
|
794
808
|
walk(t, item, loc)
|
|
@@ -806,10 +820,7 @@ class Core:
|
|
|
806
820
|
if sr and sr != "*":
|
|
807
821
|
used.add((st, sr))
|
|
808
822
|
for rule in self.rules:
|
|
809
|
-
|
|
810
|
-
for t in governing:
|
|
811
|
-
# names are looked up here when one type governs the table (with several, each reads its own)
|
|
812
|
-
walk(t, rule.expr, rule.loc if len(governing) == 1 else None)
|
|
823
|
+
walk(self.governing(rule.table), rule.expr, rule.loc)
|
|
813
824
|
for inv in self.pol.invariants:
|
|
814
825
|
walk(self.T(inv.type), inv.expr, inv.loc)
|
|
815
826
|
for test in self.tests:
|
|
@@ -921,48 +932,45 @@ class Core:
|
|
|
921
932
|
continue
|
|
922
933
|
tnames = [tn for tn, _ in comp]
|
|
923
934
|
first = self.T(comp[0][0]).perms[comp[0][1]]
|
|
924
|
-
#
|
|
935
|
+
# a deny's inheritance (its base, split_denies) that comes back to it through another type's permission
|
|
936
|
+
for tn, pn in comp:
|
|
937
|
+
deny = self.T(tn).perms[pn]
|
|
938
|
+
if deny.base is not None and (tn, deny.base) in members and len(set(tnames)) > 1:
|
|
939
|
+
fail(
|
|
940
|
+
deny.loc,
|
|
941
|
+
f"{tn}.{pn} has a deny, so it can only inherit within {tn} (like parent.{pn}), "
|
|
942
|
+
f"not through permissions of other types",
|
|
943
|
+
"AZ306",
|
|
944
|
+
)
|
|
945
|
+
# every dependency inside the component must be a top-level inheritance item. split_inherit reads one
|
|
946
|
+
# (and refuses following a relation to groups, type:*, anyone or links: a tree links objects alone)
|
|
925
947
|
for tn, pn in comp:
|
|
926
948
|
t, perm = self.T(tn), self.T(tn).perms[pn]
|
|
927
949
|
for item in self.top_items(perm):
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
fail(
|
|
948
|
-
perm.loc,
|
|
949
|
-
f"inheritance through {x.rel} can only be narrowed with "
|
|
950
|
-
f"{{conditions}} on the row, e.g. ({x.rel}.{pn} and {{inherit}})",
|
|
951
|
-
"AZ304",
|
|
952
|
-
)
|
|
953
|
-
if inner & members:
|
|
954
|
-
fail(
|
|
955
|
-
perm.loc,
|
|
956
|
-
f"{tn}.{pn} depends on itself; a permission can only recurse as "
|
|
957
|
-
f"'or rel.perm' (optionally 'and {{condition}}') through a relation "
|
|
958
|
-
f"to objects, and a group only as 'member : group#member'",
|
|
959
|
-
"AZ302",
|
|
960
|
-
)
|
|
950
|
+
if self.split_inherit(t, perm, item)[0] is not None:
|
|
951
|
+
continue
|
|
952
|
+
inner: set[Name] = set()
|
|
953
|
+
self.deps(t, item, inner)
|
|
954
|
+
if inner & members and isinstance(item, And):
|
|
955
|
+
for x in item.items:
|
|
956
|
+
if (
|
|
957
|
+
isinstance(x, Arrow)
|
|
958
|
+
and x.rel in t.relations
|
|
959
|
+
and any(not sr and (st, x.perm) in members for st, sr in t.relations[x.rel].subjects())
|
|
960
|
+
):
|
|
961
|
+
fail(
|
|
962
|
+
perm.loc,
|
|
963
|
+
f"inheritance through {x.rel} can only be narrowed with "
|
|
964
|
+
f"{{conditions}} on the row, e.g. ({x.rel}.{pn} and {{inherit}})",
|
|
965
|
+
"AZ304",
|
|
966
|
+
)
|
|
967
|
+
if inner & members:
|
|
968
|
+
fail(perm.loc, DEPENDS_ON_ITSELF.format(f"{tn}.{pn}"), "AZ302")
|
|
961
969
|
if len(set(tnames)) != len(tnames):
|
|
962
970
|
fail(
|
|
963
971
|
first.loc,
|
|
964
972
|
"permissions "
|
|
965
|
-
+ ", ".join(f"{a}.{b}" for a, b in sorted(comp))
|
|
973
|
+
+ ", ".join([f"{a}.{b}" for a, b in sorted(comp)])
|
|
966
974
|
+ " inherit through each other; a recursion can use one permission per type",
|
|
967
975
|
"AZ302",
|
|
968
976
|
)
|
|
@@ -971,16 +979,6 @@ class Core:
|
|
|
971
979
|
for v in comp:
|
|
972
980
|
self.recursive[v] = key
|
|
973
981
|
|
|
974
|
-
def check_tree_relation(self, t: Type, r: Relation, perm: Perm) -> None:
|
|
975
|
-
for src in r.sources:
|
|
976
|
-
if src.kind == "shared" and any(sr == "*" or st in ("anyone", "link") for st, sr in src.subjects):
|
|
977
|
-
fail(
|
|
978
|
-
perm.loc,
|
|
979
|
-
f"{t.name}.{r.name} is used for inheritance, so it can't be shared with "
|
|
980
|
-
f"user:* (or another type:*), anyone or link",
|
|
981
|
-
"AZ301",
|
|
982
|
-
)
|
|
983
|
-
|
|
984
982
|
@staticmethod
|
|
985
983
|
def top_items(perm: Perm) -> list[Expr]:
|
|
986
984
|
return perm.expr.items if isinstance(perm.expr, Or) else [perm.expr]
|
|
@@ -1016,18 +1014,7 @@ class Core:
|
|
|
1016
1014
|
members = set(self.scc_members[key])
|
|
1017
1015
|
arrow, conds = self.split_inherit(t, perm, item)
|
|
1018
1016
|
if arrow is None or conds is None:
|
|
1019
|
-
#
|
|
1020
|
-
if isinstance(item, And):
|
|
1021
|
-
for x in item.items:
|
|
1022
|
-
if isinstance(x, Arrow) and x.rel in t.relations:
|
|
1023
|
-
for st, sr in t.relations[x.rel].subjects():
|
|
1024
|
-
if not sr and (st, x.perm) in members:
|
|
1025
|
-
fail(
|
|
1026
|
-
perm.loc,
|
|
1027
|
-
f"inheritance through {x.rel} can only be narrowed with "
|
|
1028
|
-
f"{{conditions}} on the row, e.g. ({x.rel}.{perm.name} and {{inherit}})",
|
|
1029
|
-
"AZ304",
|
|
1030
|
-
)
|
|
1017
|
+
# a plain item: analyze_recursion refused one that narrows the inheritance with more than conditions (AZ304)
|
|
1031
1018
|
return [], item
|
|
1032
1019
|
r = t.relations[arrow.rel]
|
|
1033
1020
|
inside = [st for st, _ in r.subjects() if (st, arrow.perm) in members]
|
|
@@ -1137,19 +1124,17 @@ class Core:
|
|
|
1137
1124
|
|
|
1138
1125
|
@staticmethod
|
|
1139
1126
|
def caveat_sql(c: Caveat, alias: str = "g") -> str:
|
|
1140
|
-
return with_uid(
|
|
1127
|
+
return with_uid(CAVEAT_ARG.sub(rf"({alias}.caveat_args ->> '\1')", c.sql))
|
|
1141
1128
|
|
|
1142
1129
|
# --- functions over every type: one branch per type, the id converted to its key ----------------
|
|
1143
1130
|
def id_vars(self) -> str:
|
|
1144
1131
|
return "".join(f" v_{pt} {pt};" for pt in sorted({t.pktype for t in self.types.values()}))
|
|
1145
1132
|
|
|
1146
|
-
def dispatch_type(self, per_type: Callable[[Type], str
|
|
1133
|
+
def dispatch_type(self, per_type: Callable[[Type], str], missing_type: str = NO_SUCH_TYPE) -> str:
|
|
1147
1134
|
"""CASE over types, with p_id checked and converted to the type's key in v_<pktype>."""
|
|
1148
1135
|
branches = []
|
|
1149
1136
|
for t in self.types.values():
|
|
1150
1137
|
body = per_type(t)
|
|
1151
|
-
if body is None:
|
|
1152
|
-
continue
|
|
1153
1138
|
branches.append(
|
|
1154
1139
|
f" WHEN {lit(t.name)} THEN\n"
|
|
1155
1140
|
f" IF NOT pg_catalog.pg_input_is_valid(p_id, {lit(t.keytype)}) THEN RETURN {self._invalid}; END IF;\n"
|
|
@@ -1211,19 +1196,6 @@ class Core:
|
|
|
1211
1196
|
return f"{q(self.pk(t))}::text"
|
|
1212
1197
|
return "ROW(" + ", ".join(f"{q(c)}::{ty}" for c, ty in t.key) + ")::text"
|
|
1213
1198
|
|
|
1214
|
-
def subject_text(self, src: Source, a: str) -> str:
|
|
1215
|
-
"""A column/table source's subject id as text (for any of its subject types)."""
|
|
1216
|
-
col = self.source_columns(src)
|
|
1217
|
-
if not src.type_col:
|
|
1218
|
-
st = self.types.get(src.subjects[0][0])
|
|
1219
|
-
return self.ref_text(st, a, col) if st else f"{a}.{q(cols(col)[0])}::text"
|
|
1220
|
-
if not any(self.T(st).composite for st, _ in src.subjects if st in self.types):
|
|
1221
|
-
return f"{a}.{q(cols(col)[0])}::text"
|
|
1222
|
-
whens = " ".join(
|
|
1223
|
-
f"WHEN {lit(st)} THEN {self.ref_text(self.T(st), a, col)}" for st, _ in src.subjects if st in self.types
|
|
1224
|
-
)
|
|
1225
|
-
return f"(CASE {a}.{q(src.type_col)} {whens} END)"
|
|
1226
|
-
|
|
1227
1199
|
def key_text(self, t: Type, a: str) -> str:
|
|
1228
1200
|
return self.key(t, a) if t.composite else f"{self.key(t, a)}::text"
|
|
1229
1201
|
|
|
@@ -1392,7 +1364,10 @@ class Core:
|
|
|
1392
1364
|
if not direct:
|
|
1393
1365
|
if loops:
|
|
1394
1366
|
fail(r.loc, f"{t.name}.{r.name} only contains itself; add a source of users", "AZ209")
|
|
1395
|
-
|
|
1367
|
+
# the column sources left out (row_relation checks them on the row) may give users: then these give none
|
|
1368
|
+
if ext and any(
|
|
1369
|
+
sr or self.is_principal(st) for src in r.sources if src.kind == "column" for st, sr in src.subjects
|
|
1370
|
+
):
|
|
1396
1371
|
direct.append(f"SELECT NULL::{t.pktype} AS id WHERE false")
|
|
1397
1372
|
else:
|
|
1398
1373
|
fail(
|
|
@@ -1488,8 +1463,9 @@ class Core:
|
|
|
1488
1463
|
f"AND g.subject_type = {lit(st)} AND g.subject_relation = '' "
|
|
1489
1464
|
f"AND g.subject_id {self.ids_in(inner, text=True)} AND {self.live()}"
|
|
1490
1465
|
)
|
|
1491
|
-
|
|
1492
|
-
|
|
1466
|
+
# each type is named by a source read here: without the column sources (ext, row_arrow), the arrow follows
|
|
1467
|
+
# every type of the relation, which the sources left name some of
|
|
1468
|
+
assert parts, f"no source of {t.name}.{r.name} names {', '.join(targets)}"
|
|
1493
1469
|
self.add_view(view, union(parts), f"-- {t.name}.{r.name}.{perm_name}\n", t)
|
|
1494
1470
|
self.view_state[view] = "done"
|
|
1495
1471
|
return view
|
|
@@ -1685,12 +1661,13 @@ class Core:
|
|
|
1685
1661
|
return max(self.item_cost(t, x) for x in items)
|
|
1686
1662
|
case ("arrow", rel, perm) | ("arrow_on", rel, perm, _):
|
|
1687
1663
|
r = t.relations.get(rel)
|
|
1688
|
-
|
|
1689
|
-
return 2
|
|
1664
|
+
assert r is not None, "validate refuses a dot that follows anything but a relation (AZ301)"
|
|
1690
1665
|
on = only_on(item)
|
|
1691
1666
|
names = on if on is not None else [st for st, _ in r.subjects()]
|
|
1692
1667
|
return max(max(1, self.item_cost(self.T(st), Ref("ref", perm))) for st in names)
|
|
1693
|
-
case
|
|
1668
|
+
case _:
|
|
1669
|
+
assert isinstance(item, Ref), f"not an expression: {item!r}"
|
|
1670
|
+
name = item.name
|
|
1694
1671
|
if name in t.relations:
|
|
1695
1672
|
rn = t.relations[name]
|
|
1696
1673
|
direct = all(src.kind == "column" for src in rn.sources) and all(
|
|
@@ -1701,7 +1678,6 @@ class Core:
|
|
|
1701
1678
|
return 2
|
|
1702
1679
|
perm_ = t.perms.get(name)
|
|
1703
1680
|
return max((self.item_cost(t, i) for i in self.top_items(perm_)), default=0) if perm_ else 2
|
|
1704
|
-
return 2
|
|
1705
1681
|
|
|
1706
1682
|
def start_function(self, t: Type, perm: Perm, flatten: Flatten) -> str:
|
|
1707
1683
|
name = f"{t.name}__{perm.name}__start"
|
|
@@ -1769,8 +1745,7 @@ class Core:
|
|
|
1769
1745
|
permission's view, where one that inherits stays a name (its view walks a tree, which its operands
|
|
1770
1746
|
written out here would not)."""
|
|
1771
1747
|
key = (t.name, perm.name)
|
|
1772
|
-
|
|
1773
|
-
fail(perm.loc, f"{t.name}.{perm.name} depends on itself", "AZ302")
|
|
1748
|
+
assert key not in stack, "analyze_recursion refuses a permission that depends on itself (AZ302)"
|
|
1774
1749
|
out = []
|
|
1775
1750
|
for item in self.top_items(perm):
|
|
1776
1751
|
if isinstance(item, Ref) and item.name in t.perms and not (views and (t.name, item.name) in self.recursive):
|
|
@@ -1791,7 +1766,8 @@ class Core:
|
|
|
1791
1766
|
return seen
|
|
1792
1767
|
|
|
1793
1768
|
def lookup_key(self, t: Type, item: Expr) -> tuple[str, str, str | None] | None:
|
|
1794
|
-
"""(relation, permission, condition) for rel.perm or (rel.perm and {cond})
|
|
1769
|
+
"""(relation, permission, condition) for rel.perm or (rel.perm and {cond}), where rel links to objects (one
|
|
1770
|
+
to anyone or links is refused where the arrow is made, targets)."""
|
|
1795
1771
|
cond = None
|
|
1796
1772
|
if isinstance(item, And) and len(item.items) == 2:
|
|
1797
1773
|
arrows = [x for x in item.items if isinstance(x, Arrow)]
|
|
@@ -1801,7 +1777,7 @@ class Core:
|
|
|
1801
1777
|
if (
|
|
1802
1778
|
isinstance(item, Arrow)
|
|
1803
1779
|
and item.rel in t.relations
|
|
1804
|
-
and all(not sr for
|
|
1780
|
+
and all(not sr and st in self.types for st, sr in t.relations[item.rel].subjects())
|
|
1805
1781
|
):
|
|
1806
1782
|
return (item.rel, item.perm, cond)
|
|
1807
1783
|
return None
|
|
@@ -1871,14 +1847,14 @@ class Core:
|
|
|
1871
1847
|
)
|
|
1872
1848
|
case ("arrow", rel, perm_name) | ("arrow_on", rel, perm_name, _):
|
|
1873
1849
|
return self.row_arrow(t, alias, node, rel, perm_name, loc, point, invoker)
|
|
1874
|
-
case
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1850
|
+
case _:
|
|
1851
|
+
assert isinstance(node, Ref), f"not an expression: {node!r}"
|
|
1852
|
+
self.lookup(t, node.name, loc)
|
|
1853
|
+
if node.name in t.relations:
|
|
1854
|
+
return self.row_relation(t, t.relations[node.name], alias, loc, point)
|
|
1855
|
+
perm = t.perms[node.name]
|
|
1879
1856
|
items = self.cheapest_first(t, self.prune(t, self.flat_items(t, perm, stack)))
|
|
1880
1857
|
return or_join([self.row_sql(t, alias, item, perm.loc, point=point, invoker=invoker) for item in items])
|
|
1881
|
-
raise AssertionError(f"not an expression: {node!r}")
|
|
1882
1858
|
|
|
1883
1859
|
def definer_cond(self, t: Type, sql: str, alias: str) -> str:
|
|
1884
1860
|
"""A condition that reads more than its row's columns, where the app role checks it: a call of a function
|
|
@@ -10,12 +10,19 @@ which (evaluate.Data.cond).
|
|
|
10
10
|
The grammar: or < and < not < a comparison (`=`, `<>`, `!=`, `<`, `<=`, `>`, `>=`), `is [not] null`,
|
|
11
11
|
`[not] in (...)`, of a column (`col`, `this.col`) with a constant (`'text'`, a number, `true`, `false`, `null`)
|
|
12
12
|
or `authz.uid()`; a column alone is a boolean. Values follow SQL's three-valued logic: NULL compares to nothing.
|
|
13
|
+
|
|
14
|
+
Only what means the same whatever the column's type and collation, which a condition doesn't say: no text in
|
|
15
|
+
order (`{name < 'b'}` follows the database's collation), no text Postgres would read as the column's number or
|
|
16
|
+
boolean (`{size = '10'}`, `{done = 't'}`), no order against authz.uid() (the user key's type decides it). Those
|
|
17
|
+
are left to the database too. A column a relation reads holds the linked ids' text: compared with a number, as a
|
|
18
|
+
number (tests/conditions_test.py asks Postgres the same questions).
|
|
13
19
|
"""
|
|
14
20
|
|
|
15
21
|
from __future__ import annotations
|
|
16
22
|
|
|
17
23
|
import re
|
|
18
24
|
from collections.abc import Mapping
|
|
25
|
+
from decimal import Decimal
|
|
19
26
|
from functools import cache
|
|
20
27
|
from typing import NamedTuple, TypeAlias
|
|
21
28
|
|
|
@@ -156,9 +163,7 @@ class Parser:
|
|
|
156
163
|
values.append(self.constant())
|
|
157
164
|
self.take("p", ")")
|
|
158
165
|
return In(left, tuple(values), negated)
|
|
159
|
-
|
|
160
|
-
raise NotSimple("not")
|
|
161
|
-
return left
|
|
166
|
+
return left # (`not` was taken only before an `in`)
|
|
162
167
|
|
|
163
168
|
def operand(self) -> Node:
|
|
164
169
|
if self.peek("p", "("):
|
|
@@ -178,7 +183,10 @@ class Parser:
|
|
|
178
183
|
return self.take("str")[1:-1].replace("''", "'")
|
|
179
184
|
if self.peek("num"):
|
|
180
185
|
text = self.take("num")
|
|
181
|
-
|
|
186
|
+
try:
|
|
187
|
+
return float(text) if "." in text else int(text)
|
|
188
|
+
except ValueError: # more digits than Python reads as a whole number (4300): the database's to read
|
|
189
|
+
raise NotSimple(text[:40]) from None
|
|
182
190
|
if self.peek("word", "true") or self.peek("word", "false"):
|
|
183
191
|
return self.take("word") == "true"
|
|
184
192
|
if self.peek("word", "null"):
|
|
@@ -187,6 +195,32 @@ class Parser:
|
|
|
187
195
|
raise NotSimple(str(self.toks[self.i :]))
|
|
188
196
|
|
|
189
197
|
|
|
198
|
+
# text Postgres reads as a number when the column is one ('10', ' 1.5e3', '0x1A', 'NaN'), or as a boolean: these
|
|
199
|
+
# words, any start of them ('t', 'of'), whatever the case
|
|
200
|
+
CASTS = re.compile(
|
|
201
|
+
r"\s*[+-]?(?:\d[\d_]*(?:\.[\d_]*)?(?:e[+-]?\d+)?|\.\d[\d_]*(?:e[+-]?\d+)?|0[xob][\da-f_]+|nan|inf|infinity)\s*",
|
|
202
|
+
re.IGNORECASE,
|
|
203
|
+
)
|
|
204
|
+
BOOLEANS = ("true", "false", "yes", "no", "on", "off", "1", "0")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def plain(v: Scalar) -> bool:
|
|
208
|
+
"""Whether a constant means the same whatever the column's type: not text Postgres could read as a number or
|
|
209
|
+
a boolean."""
|
|
210
|
+
if not isinstance(v, str):
|
|
211
|
+
return True
|
|
212
|
+
word = v.strip().lower()
|
|
213
|
+
return not (CASTS.fullmatch(v) or (word and any(b.startswith(word) for b in BOOLEANS)))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def number(v: Scalar) -> Decimal | None:
|
|
217
|
+
"""A number as one, and so a linked id's text that is one ('10': owner_id holds the id of its user)."""
|
|
218
|
+
if isinstance(v, bool) or v is None:
|
|
219
|
+
return None
|
|
220
|
+
# (Decimal reads every int and float as text, and every run of digits, whatever the script)
|
|
221
|
+
return Decimal(str(v)) if isinstance(v, (int, float)) or re.fullmatch(r"-?\d+", v) else None
|
|
222
|
+
|
|
223
|
+
|
|
190
224
|
@cache
|
|
191
225
|
def simple(sql: str) -> Node | None:
|
|
192
226
|
"""The condition as facts about its row's columns, or None when it is more than that."""
|
|
@@ -198,11 +232,21 @@ def simple(sql: str) -> Node | None:
|
|
|
198
232
|
# a column compared with a column, or a constant on its own, is not one the evaluator reads
|
|
199
233
|
def fine(n: Node) -> bool:
|
|
200
234
|
match n:
|
|
201
|
-
case Cmp(left=a, right=b):
|
|
202
|
-
|
|
203
|
-
|
|
235
|
+
case Cmp(op=op, left=a, right=b):
|
|
236
|
+
ordered = op not in ("=", "<>")
|
|
237
|
+
return (
|
|
238
|
+
sum(isinstance(x, Col) for x in (a, b)) == 1
|
|
239
|
+
and all(isinstance(x, (Col, Const, Uid)) for x in (a, b))
|
|
240
|
+
and all(
|
|
241
|
+
plain(x.value) and not (ordered and isinstance(x.value, str))
|
|
242
|
+
for x in (a, b)
|
|
243
|
+
if isinstance(x, Const)
|
|
244
|
+
)
|
|
245
|
+
and not (ordered and any(isinstance(x, Uid) for x in (a, b)))
|
|
204
246
|
)
|
|
205
|
-
case
|
|
247
|
+
case In(item=x, values=values):
|
|
248
|
+
return isinstance(x, Col) and all(plain(v) for v in values)
|
|
249
|
+
case IsNull(item=x):
|
|
206
250
|
return isinstance(x, Col)
|
|
207
251
|
case Bool(items=items):
|
|
208
252
|
return all(fine(x) for x in items)
|
|
@@ -230,15 +274,17 @@ def columns_of(node: Node) -> dict[str, list[tuple[str, Scalar]]]:
|
|
|
230
274
|
assert isinstance(col, Col)
|
|
231
275
|
if isinstance(other, Uid):
|
|
232
276
|
add(col.name, "uid")
|
|
233
|
-
|
|
277
|
+
else:
|
|
278
|
+
assert isinstance(other, Const) # (simple() compares a column with a constant or authz.uid())
|
|
234
279
|
add(col.name, op, other.value)
|
|
235
280
|
case IsNull(item=Col(name=c)):
|
|
236
281
|
add(c, "null")
|
|
237
282
|
case In(item=Col(name=c), values=values):
|
|
238
283
|
for v in values:
|
|
239
284
|
add(c, "in", v)
|
|
240
|
-
case
|
|
241
|
-
|
|
285
|
+
case _:
|
|
286
|
+
assert isinstance(n, Bool), f"not a condition: {n!r}" # (simple() gives nothing else)
|
|
287
|
+
for x in n.items:
|
|
242
288
|
walk(x)
|
|
243
289
|
|
|
244
290
|
walk(node)
|
|
@@ -251,25 +297,27 @@ def order(op: str, sign: int) -> bool:
|
|
|
251
297
|
|
|
252
298
|
|
|
253
299
|
def compare(op: str, a: Scalar, b: Scalar) -> bool | None:
|
|
254
|
-
"""a op b as SQL compares them: NULL with anything is NULL;
|
|
300
|
+
"""a op b as SQL compares them: NULL with anything is NULL; a number with a number (or an id's text that is
|
|
301
|
+
one) as numbers, the rest as their text."""
|
|
255
302
|
if a is None or b is None:
|
|
256
303
|
return None
|
|
257
|
-
|
|
258
|
-
|
|
304
|
+
x, y = number(a), number(b)
|
|
305
|
+
if x is not None and y is not None and not (isinstance(a, str) and isinstance(b, str)):
|
|
259
306
|
return order(op, (x > y) - (x < y))
|
|
260
307
|
s, t = str(a).lower() if isinstance(a, bool) else str(a), str(b).lower() if isinstance(b, bool) else str(b)
|
|
261
308
|
return order(op, (s > t) - (s < t))
|
|
262
309
|
|
|
263
310
|
|
|
264
311
|
def value(n: Node, row: Mapping[str, Scalar], uid: str | None) -> Scalar:
|
|
312
|
+
"""An operand's value: a column's, a constant, or the user's id (simple() compares nothing else)."""
|
|
265
313
|
match n:
|
|
266
314
|
case Col(name=c):
|
|
267
315
|
return row.get(c)
|
|
268
316
|
case Const(value=v):
|
|
269
317
|
return v
|
|
270
|
-
case
|
|
318
|
+
case _:
|
|
319
|
+
assert isinstance(n, Uid), f"not an operand: {n!r}"
|
|
271
320
|
return uid
|
|
272
|
-
return truth(n, row, uid)
|
|
273
321
|
|
|
274
322
|
|
|
275
323
|
def truth(n: Node, row: Mapping[str, Scalar], uid: str | None) -> bool | None:
|
|
@@ -296,10 +344,10 @@ def truth(n: Node, row: Mapping[str, Scalar], uid: str | None) -> bool | None:
|
|
|
296
344
|
case Bool(op="and", items=items):
|
|
297
345
|
got = [truth(x, row, uid) for x in items]
|
|
298
346
|
return False if False in got else (None if None in got else True)
|
|
299
|
-
case
|
|
300
|
-
|
|
347
|
+
case _:
|
|
348
|
+
assert isinstance(n, Bool) and n.op == "or", f"not a condition: {n!r}" # (simple() gives nothing else)
|
|
349
|
+
got = [truth(x, row, uid) for x in n.items]
|
|
301
350
|
return True if True in got else (None if None in got else False)
|
|
302
|
-
raise ValueError(f"not a condition: {n!r}")
|
|
303
351
|
|
|
304
352
|
|
|
305
353
|
def uses_uid(node: Node) -> bool:
|