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.
Files changed (36) hide show
  1. package/command/authzlib/__init__.py +18 -8
  2. package/command/authzlib/assembled.py +9 -0
  3. package/command/authzlib/compiler.py +95 -119
  4. package/command/authzlib/conditions.py +67 -19
  5. package/command/authzlib/connection.py +0 -17
  6. package/command/authzlib/database.py +246 -89
  7. package/command/authzlib/devtools.py +3 -3
  8. package/command/authzlib/draft.py +40 -6
  9. package/command/authzlib/errors.py +82 -33
  10. package/command/authzlib/evaluate.py +282 -71
  11. package/command/authzlib/fmt.py +7 -11
  12. package/command/authzlib/governance.py +87 -22
  13. package/command/authzlib/grant.py +469 -107
  14. package/command/authzlib/hardening.py +54 -21
  15. package/command/authzlib/identity.py +17 -6
  16. package/command/authzlib/insight.py +21 -20
  17. package/command/authzlib/migrate.py +38 -40
  18. package/command/authzlib/output.py +156 -85
  19. package/command/authzlib/parse.py +23 -27
  20. package/command/authzlib/perf.py +58 -32
  21. package/command/authzlib/prove.py +49 -26
  22. package/command/authzlib/refusals.py +10 -10
  23. package/command/authzlib/review.py +264 -120
  24. package/command/authzlib/sqlutil.py +97 -1
  25. package/command/authzlib/statements.py +33 -25
  26. package/command/authzlib/testing.py +5 -4
  27. package/command/cli/init.py +4 -6
  28. package/command/cli/lsp.py +77 -40
  29. package/command/cli/mcp.py +42 -17
  30. package/command/cli/migrations.py +2 -1
  31. package/command/cli/pgwire.py +560 -122
  32. package/command/cli/rowstile_cli.py +320 -84
  33. package/command/cli/stack.py +2 -2
  34. package/command/cli/studio/app.js +16 -5
  35. package/command/cli/studio.py +151 -113
  36. 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 .devtools import DevMixin
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
- class Compiler(TestMixin, DevMixin, OutputMixin):
11
- """A policy compiled: each part of the output is one of its bases (OutputMixin assembles them)."""
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.6"
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
- BUILD = _build()
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
- if rule.command == "mask":
88
- return "mask " + ", ".join(rule.columns)
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
- continue # recursion through a negation: analyze_recursion says why not
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
- if not (isinstance(x, Not) and isinstance(x.item, Ref)):
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
- roles_anywhere = any(t.roles and not t.roles_from for t in self.types.values())
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 = next(t for t in self.types.values() if t.roles and not t.roles_from)
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 | None) -> None:
797
+ def walk(t: Type, node: Expr, loc: Loc) -> None:
781
798
  match node:
782
799
  case ("ref", name):
783
- if loc is not None:
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
- if loc is not None:
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
- r = t.relations.get(rel)
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
- governing = [t for t in self.types.values() if t.table == rule.table]
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
- # every dependency inside the component must be a top-level inheritance item
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
- arrow, _conds = self.split_inherit(t, perm, item)
929
- if arrow:
930
- r = t.relations.get(arrow.rel)
931
- if r:
932
- for st, sr in r.subjects():
933
- if not sr and st in self.types and (st, arrow.perm) in members:
934
- self.check_tree_relation(t, r, perm)
935
- else:
936
- inner: set[Name] = set()
937
- self.deps(t, item, inner)
938
- if inner & members and isinstance(item, And):
939
- for x in item.items:
940
- if (
941
- isinstance(x, Arrow)
942
- and x.rel in t.relations
943
- and any(
944
- not sr and (st, x.perm) in members for st, sr in t.relations[x.rel].subjects()
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
- # an 'and' with the inheritance arrow and something other than conditions
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(re.sub(r"\barg\s*\(\s*'([^']*)'\s*\)", rf"({alias}.caveat_args ->> '\1')", c.sql))
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 | None], missing_type: str = NO_SUCH_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
- if ext:
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
- if not parts:
1492
- parts.append(f"SELECT NULL::{t.pktype} AS id WHERE false")
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
- if r is None:
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 ("ref", name):
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
- if key in stack:
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 _, sr in t.relations[item.rel].subjects())
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 ("ref", name):
1875
- self.lookup(t, name, loc)
1876
- if name in t.relations:
1877
- return self.row_relation(t, t.relations[name], alias, loc, point)
1878
- perm = t.perms[name]
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
- if negated:
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
- return float(text) if "." in text else int(text)
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
- return sum(isinstance(x, Col) for x in (a, b)) == 1 and all(
203
- isinstance(x, (Col, Const, Uid)) for x in (a, b)
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 IsNull(item=x) | In(item=x):
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
- elif isinstance(other, Const):
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 Bool(items=items):
241
- for x in items:
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; numbers as numbers, the rest as their text."""
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
- if isinstance(a, (int, float)) and isinstance(b, (int, float)):
258
- x, y = float(a), float(b)
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 Uid():
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 Bool(op="or", items=items):
300
- got = [truth(x, row, uid) for x in items]
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: