rowstile 0.1.0-alpha.2 → 0.1.0-alpha.4

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 (37) hide show
  1. package/command/authzlib/__init__.py +4 -1
  2. package/command/authzlib/base.py +46 -6
  3. package/command/authzlib/compiler.py +598 -265
  4. package/command/authzlib/conditions.py +17 -8
  5. package/command/authzlib/connection.py +1 -0
  6. package/command/authzlib/coverage.py +12 -5
  7. package/command/authzlib/database.py +333 -124
  8. package/command/authzlib/devtools.py +31 -8
  9. package/command/authzlib/draft.py +235 -62
  10. package/command/authzlib/errors.py +205 -104
  11. package/command/authzlib/evaluate.py +92 -44
  12. package/command/authzlib/fmt.py +33 -16
  13. package/command/authzlib/governance.py +105 -50
  14. package/command/authzlib/grant.py +100 -52
  15. package/command/authzlib/hardening.py +122 -52
  16. package/command/authzlib/identity.py +22 -15
  17. package/command/authzlib/insight.py +161 -86
  18. package/command/authzlib/migrate.py +193 -102
  19. package/command/authzlib/output.py +441 -195
  20. package/command/authzlib/parse.py +249 -98
  21. package/command/authzlib/perf.py +78 -32
  22. package/command/authzlib/prove.py +23 -11
  23. package/command/authzlib/refusals.py +107 -48
  24. package/command/authzlib/review.py +361 -139
  25. package/command/authzlib/sqlutil.py +196 -33
  26. package/command/authzlib/statements.py +26 -19
  27. package/command/authzlib/testing.py +98 -45
  28. package/command/authzlib/trees.py +427 -275
  29. package/command/cli/init.py +27 -8
  30. package/command/cli/lsp.py +140 -51
  31. package/command/cli/mcp.py +107 -45
  32. package/command/cli/migrations.py +74 -21
  33. package/command/cli/pgwire.py +161 -66
  34. package/command/cli/rowstile_cli.py +351 -105
  35. package/command/cli/stack.py +67 -30
  36. package/command/cli/studio.py +258 -84
  37. package/package.json +8 -8
@@ -1,4 +1,5 @@
1
1
  """Compiling a policy into views, closure tables, triggers and RLS policies."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  import hashlib
@@ -70,15 +71,16 @@ Inherit: TypeAlias = "tuple[str, str | None, str]"
70
71
 
71
72
  class NextTree(TypedDict):
72
73
  """How to build a tree beside the one in use, while the app runs (TreeMixin.next_tree, migrate.py)."""
74
+
73
75
  types: list[str]
74
76
  tables: list[str]
75
77
  link_tables: list[str]
76
- next: str # the table built beside
78
+ next: str # the table built beside
77
79
  next_name: str
78
- table: str # the tree's table, quoted
80
+ table: str # the tree's table, quoted
79
81
  objects: list[tuple[str, str]] # (kind, key) of what the first migration makes
80
- build: str # the first migration's SQL
81
- catch_up: str # the second's: what changed since the first one's snapshot
82
+ build: str # the first migration's SQL
83
+ catch_up: str # the second's: what changed since the first one's snapshot
82
84
 
83
85
 
84
86
  def rule_name(rule: Rule) -> str:
@@ -135,7 +137,7 @@ def once(items: Sequence[Expr]) -> list[Expr]:
135
137
  # for one read grows with every permission named more than once on the way (Core.expansions counts them).
136
138
  VIEW_BODY = re.compile(r'CREATE VIEW authz_int\.("(?:[^"]|"")+"|\w+) AS\n(.*?);(?=\nCREATE VIEW authz_gen|\Z)', re.S)
137
139
  VIEW_NAME = re.compile(r'authz_(?:int|gen)\.("(?:[^"]|"")+"|\w+)')
138
- # the policies here stay under 15; from about 70, planning a read takes a tenth of a second and more
140
+ # the policies here stay under 25; from about 70, planning a read can take a tenth of a second and more
139
141
  MANY_EXPANSIONS = 50
140
142
 
141
143
 
@@ -143,8 +145,10 @@ class Core:
143
145
  def expansions(self, sql: str) -> int:
144
146
  """How many view definitions Postgres writes out to plan `sql` (a rule's check): each view it names,
145
147
  and for each of those the views it names, again for every use."""
148
+
146
149
  def name(x: str) -> str:
147
150
  return x[1:-1].replace('""', '"') if x.startswith('"') else x
151
+
148
152
  bodies: dict[str, str] = {}
149
153
  for entry in self.view_sql:
150
154
  m = VIEW_BODY.search(entry)
@@ -154,9 +158,10 @@ class Core:
154
158
 
155
159
  def count(view: str) -> int:
156
160
  if view not in counted:
157
- counted[view] = 1 # (a view never names itself: trees and functions stand between)
161
+ counted[view] = 1 # (a view never names itself: trees and functions stand between)
158
162
  counted[view] = 1 + sum(count(name(r)) for r in VIEW_NAME.findall(bodies[view]) if name(r) in bodies)
159
163
  return counted[view]
164
+
160
165
  return sum(count(name(r)) for r in VIEW_NAME.findall(sql) if name(r) in bodies)
161
166
 
162
167
  def line_sql(self, what: str | None, loc: Loc | str | None, key_sql: str | None = None) -> str:
@@ -175,48 +180,58 @@ class Core:
175
180
 
176
181
  def lines_sql(self) -> str:
177
182
  rows = ",\n ".join(f"({lit(w)}, {lit(loc)})" for w, loc in sorted(self.lines.items()))
178
- return ("-- where each rule, relation and invariant is written, for messages (Core.line_sql)\n"
179
- "CREATE TABLE authz_gen.policy_lines (what text PRIMARY KEY, loc text NOT NULL);"
180
- + (f"\nINSERT INTO authz_gen.policy_lines VALUES\n {rows};" if rows else ""))
183
+ return (
184
+ "-- where each rule, relation and invariant is written, for messages (Core.line_sql)\n"
185
+ "CREATE TABLE authz_gen.policy_lines (what text PRIMARY KEY, loc text NOT NULL);"
186
+ + (f"\nINSERT INTO authz_gen.policy_lines VALUES\n {rows};" if rows else "")
187
+ )
181
188
 
182
189
  def __init__(self, pol: Policy) -> None:
183
190
  self.pol = pol
184
191
  if pol.role is None:
185
- fail(1, "name the app role, the Postgres role your app connects as, which the rules apply to: "
186
- "app role app_user", "AZ111")
192
+ fail(
193
+ 1,
194
+ "name the app role, the Postgres role your app connects as, which the rules apply to: "
195
+ "app role app_user",
196
+ "AZ111",
197
+ )
187
198
  self.role: str = pol.role
188
199
  self.types, self.rules, self.tests = pol.types, pol.rules, pol.tests
189
- self.view_state: dict[str, str] = {} # view name -> 'busy' | 'done'
190
- self.view_sql: list[str] = [] # CREATE VIEW / FUNCTION statements, dependency order
191
- self.trees: dict[TreeKey, str] = {} # tree key -> tree name
200
+ self.view_state: dict[str, str] = {} # view name -> 'busy' | 'done'
201
+ self.view_sql: list[str] = [] # CREATE VIEW / FUNCTION statements, dependency order
202
+ self.trees: dict[TreeKey, str] = {} # tree key -> tree name
192
203
  # tree name -> (hash of its definition, tables it reads): kept across applies
193
204
  self.tree_keep: dict[str, tuple[str, list[str]]] = {}
194
205
  self.tree_sql: list[str] = []
195
- self.tree_names: list[str] = [] # the name of each tree_sql entry
206
+ self.tree_names: list[str] = [] # the name of each tree_sql entry
196
207
  # tree name -> how to build it beside the one in use (migrate.py, two phases)
197
208
  self.tree_next: dict[str, NextTree] = {}
198
- self.lines: dict[str, str] = {} # what -> where it is written ('line 12'), for run-time messages
199
- self.nested: dict[str, str] = {} # relation views that expand nested groups -> their type's key type
200
- self.point_checks: dict[str, str] = {} # recursive permission view -> its function checking one object
201
- self.direct_checks: dict[str, str] = {} # recursive permission view -> view of what its columns give
202
- self.direct_sql: dict[str, str] = {} # such views -> their SQL (permissions with the same share one)
203
- self.locked_types: set[str] = set() # types with closure tables (one lock row each)
209
+ self.lines: dict[str, str] = {} # what -> where it is written ('line 12'), for run-time messages
210
+ self.nested: dict[str, str] = {} # relation views that expand nested groups -> their type's key type
211
+ self.point_checks: dict[str, str] = {} # recursive permission view -> its function checking one object
212
+ self.direct_checks: dict[str, str] = {} # recursive permission view -> view of what its columns give
213
+ self.direct_sql: dict[str, str] = {} # such views -> their SQL (permissions with the same share one)
214
+ self.locked_types: set[str] = set() # types with closure tables (one lock row each)
204
215
  self.cond_lines: dict[tuple[str, str], Loc] = {} # (type, condition) -> loc, for apply-time errors
205
- self.columns: list[tuple[str, str, Loc]] = [] # (table, column, loc) to check exist
216
+ self.columns: list[tuple[str, str, Loc]] = [] # (table, column, loc) to check exist
206
217
  self.pk_checks: list[tuple[str, str, str, Loc]] = [] # (table, pk, pktype, loc)
207
- self.recursive: dict[Name, SccKey] = {} # (type, perm) -> scc key
218
+ self.recursive: dict[Name, SccKey] = {} # (type, perm) -> scc key
208
219
  self.scc_members: dict[SccKey, list[Name]] = {} # scc key -> [(type, perm)]
209
- self.denies: dict[Name, list[Expr]] = {} # (type, perm) with a deny -> its `not` items
210
- self._invalid = "NULL" # what dispatch_type's functions return for an id that isn't one
220
+ self.denies: dict[Name, list[Expr]] = {} # (type, perm) with a deny -> its `not` items
221
+ self._invalid = "NULL" # what dispatch_type's functions return for an id that isn't one
211
222
  # the compiled policy in parts, (mode, name, SQL): what a migration does with each (output.compile)
212
223
  self.parts: list[tuple[str, str, str]] = []
213
- self._list_cases = "" # authz.list's branches (output.list_cases_sql)
224
+ self._list_cases = "" # authz.list's branches (output.list_cases_sql)
214
225
  if "user" not in self.types:
215
226
  fail(1, "declare the user type, e.g.: type user = app.users", "AZ202")
216
227
  for t in self.types.values():
217
228
  if t.principal and t.composite:
218
- fail(t.loc, f"the {t.name} type signs in, so it needs a key of one column (who is signed in "
219
- f"is one value); give its table one, e.g. an identity column", "AZ206")
229
+ fail(
230
+ t.loc,
231
+ f"the {t.name} type signs in, so it needs a key of one column (who is signed in "
232
+ f"is one value); give its table one, e.g. an identity column",
233
+ "AZ206",
234
+ )
220
235
  self.add_custom_roles()
221
236
  self.split_denies()
222
237
  self.validate()
@@ -253,35 +268,62 @@ class Core:
253
268
  is one more relation of the `or` it is written in."""
254
269
  for rule in self.rules:
255
270
  if names_roles(rule.expr):
256
- fail(rule.loc, f"`{ROLES}` goes in a permission, which says what a custom role gives: write it there "
257
- f"(`can edit = editor or roles`) and name the permission here", "AZ210")
271
+ fail(
272
+ rule.loc,
273
+ f"`{ROLES}` goes in a permission, which says what a custom role gives: write it there "
274
+ f"(`can edit = editor or roles`) and name the permission here",
275
+ "AZ210",
276
+ )
258
277
  for inv in self.pol.invariants:
259
278
  if names_roles(inv.expr):
260
- fail(inv.loc, f"`{ROLES}` goes in a permission, which says what a custom role gives: name the "
261
- f"permission here", "AZ210")
279
+ fail(
280
+ inv.loc,
281
+ f"`{ROLES}` goes in a permission, which says what a custom role gives: name the permission here",
282
+ "AZ210",
283
+ )
262
284
  for t in self.types.values():
263
285
  for p in t.perms.values():
264
286
  if any(isinstance(x, Arrow) and x.rel == ROLES for x in leaves(p.expr)):
265
- fail(p.loc, f"{t.name}.{p.name}: `{ROLES}` gives the permission it is written in; it isn't a "
266
- f"relation to follow with a dot", "AZ210")
287
+ fail(
288
+ p.loc,
289
+ f"{t.name}.{p.name}: `{ROLES}` gives the permission it is written in; it isn't a "
290
+ f"relation to follow with a dot",
291
+ "AZ210",
292
+ )
267
293
  named = [p for p in t.perms.values() if names_roles(p.expr)]
268
294
  if not t.roles:
269
295
  for p in named:
270
- fail(p.loc, f"{t.name}.{p.name} writes `{ROLES}`, but {t.name} has no `roles : ...` line saying who "
271
- f"may hold custom roles, e.g. roles : user from org", "AZ210")
296
+ fail(
297
+ p.loc,
298
+ f"{t.name}.{p.name} writes `{ROLES}`, but {t.name} has no `roles : ...` line saying who "
299
+ f"may hold custom roles, e.g. roles : user from org",
300
+ "AZ210",
301
+ )
272
302
  continue
273
303
  subjects, _, loc = t.roles
274
304
  if t.roles_from:
275
- self.role_owner_type(t) # refuses a relation that can't name an owner
305
+ self.role_owner_type(t) # refuses a relation that can't name an owner
276
306
  if not named:
277
- fail(loc, f"{t.name} says who may hold custom roles, but no permission writes `{ROLES}`: write it "
278
- f"where a role gives the permission, e.g. can view = viewer or roles", "AZ210")
307
+ fail(
308
+ loc,
309
+ f"{t.name} says who may hold custom roles, but no permission writes `{ROLES}`: write it "
310
+ f"where a role gives the permission, e.g. can view = viewer or roles",
311
+ "AZ210",
312
+ )
279
313
  for p in named:
280
314
  if any(names_roles(p.expr)):
281
- fail(p.loc, f"{t.name}.{p.name}: `not {ROLES}` would take a permission away from whoever holds a "
282
- f"custom role that includes it; a role only gives", "AZ210")
283
- rel = Relation(f"roles:{p.name}", loc,
284
- [Source("roles", subjects, loc, perm=p.name, owner=t.roles_from)], synthetic=True)
315
+ fail(
316
+ p.loc,
317
+ f"{t.name}.{p.name}: `not {ROLES}` would take a permission away from whoever holds a "
318
+ f"custom role that includes it; a role only gives",
319
+ "AZ210",
320
+ )
321
+ rel = Relation(
322
+ f"roles:{p.name}",
323
+ loc,
324
+ [Source("roles", subjects, loc, perm=p.name, owner=t.roles_from)],
325
+ synthetic=True,
326
+ )
285
327
  t.relations[rel.name] = rel
286
328
  p.expr = renamed(p.expr, ROLES, rel.name)
287
329
 
@@ -294,12 +336,20 @@ class Core:
294
336
  kinds = {src.kind for src in r.sources} if r else set()
295
337
  subjects = {s for src in r.sources for s in src.subjects} if r else set()
296
338
  if r is None or not kinds <= {"column", "table"} or len(subjects) != 1:
297
- fail(loc, f"custom roles on {t.name} come from '{name}', which must be a relation of {t.name} kept in "
298
- f"a column or a table and linking to one type, as `org : org = org_id`", "AZ211")
339
+ fail(
340
+ loc,
341
+ f"custom roles on {t.name} come from '{name}', which must be a relation of {t.name} kept in "
342
+ f"a column or a table and linking to one type, as `org : org = org_id`",
343
+ "AZ211",
344
+ )
299
345
  st, sr = next(iter(subjects))
300
346
  if sr is not None or st not in self.types:
301
- fail(loc, f"custom roles on {t.name} come from '{name}', which must link to objects of a type "
302
- f"(the roles' owner), not to {st}{'#' + sr if sr else ''}", "AZ211")
347
+ fail(
348
+ loc,
349
+ f"custom roles on {t.name} come from '{name}', which must link to objects of a type "
350
+ f"(the roles' owner), not to {st}{'#' + sr if sr else ''}",
351
+ "AZ211",
352
+ )
303
353
  return st
304
354
 
305
355
  def role_rel_sql(self, t: Type, src: Source, obj: str) -> str:
@@ -308,8 +358,10 @@ class Core:
308
358
  sql = f"g.relation = ANY ((SELECT authz_int.role_relations({lit(t.name)}, {lit(src.perm)}))::text[])"
309
359
  if not src.owner:
310
360
  return sql
311
- return (f"{sql} AND EXISTS (SELECT 1 FROM authz.roles ro WHERE 'role:' || ro.id = g.relation "
312
- f"AND ro.owner_type = {lit(self.role_owner_type(t))} AND ro.owner_id IN ({self.role_owners_sql(t, obj)}))")
361
+ return (
362
+ f"{sql} AND EXISTS (SELECT 1 FROM authz.roles ro WHERE 'role:' || ro.id = g.relation "
363
+ f"AND ro.owner_type = {lit(self.role_owner_type(t))} AND ro.owner_id IN ({self.role_owners_sql(t, obj)}))"
364
+ )
313
365
 
314
366
  def role_owners_sql(self, t: Type, obj: str) -> str:
315
367
  """The ids (as text) of what `roles : ... from rel` links object obj to: the owners whose roles count."""
@@ -318,12 +370,16 @@ class Core:
318
370
  owners = []
319
371
  for o in t.relations[t.roles_from].sources:
320
372
  if o.kind == "column":
321
- owners.append(f"SELECT ({self.subject_id(o, 'w', ot)})::text FROM {qt(t.table)} w "
322
- f"WHERE {self.key_is(t, 'w', obj)}")
373
+ owners.append(
374
+ f"SELECT ({self.subject_id(o, 'w', ot)})::text FROM {qt(t.table)} w "
375
+ f"WHERE {self.key_is(t, 'w', obj)}"
376
+ )
323
377
  else:
324
378
  where = f" AND coalesce(({row_cond(o.where, 'w')}), false)" if o.where else ""
325
- owners.append(f"SELECT ({self.subject_id(o, 'w', ot)})::text FROM {qt(self.source_table(o))} w "
326
- f"WHERE {self.key_is(t, 'w', obj, o.obj_col)}{where}")
379
+ owners.append(
380
+ f"SELECT ({self.subject_id(o, 'w', ot)})::text FROM {qt(self.source_table(o))} w "
381
+ f"WHERE {self.key_is(t, 'w', obj, o.obj_col)}{where}"
382
+ )
327
383
  return " UNION ALL ".join(owners)
328
384
 
329
385
  def split_denies(self) -> None:
@@ -367,28 +423,45 @@ class Core:
367
423
  case _:
368
424
  rest.append(x)
369
425
  if len(rest) > 1:
370
- fail(p.loc, f"{t.name}.{p.name} inherits, so `and` can only join its inheritance with "
371
- f"{{conditions}} and `not <permission>`, e.g. (viewer or parent.{p.name}) and not denied", "AZ304")
426
+ fail(
427
+ p.loc,
428
+ f"{t.name}.{p.name} inherits, so `and` can only join its inheritance with "
429
+ f"{{conditions}} and `not <permission>`, e.g. (viewer or parent.{p.name}) and not denied",
430
+ "AZ304",
431
+ )
372
432
  if not rest:
373
- continue # recursion through a negation: analyze_recursion says why not
433
+ continue # recursion through a negation: analyze_recursion says why not
374
434
  first = rest[0]
375
435
  items = list(first.items) if isinstance(first, Or) else [first]
376
- items = [(And("and", [*i.items, *conds]) if isinstance(i, And) else And("and", [i, *conds])) if conds else i
377
- for i in items]
436
+ items = [
437
+ (And("and", [*i.items, *conds]) if isinstance(i, And) else And("and", [i, *conds])) if conds else i
438
+ for i in items
439
+ ]
378
440
  inherit = items[0] if len(items) == 1 else Or("or", items)
379
441
  if not negs:
380
442
  p.expr = inherit
381
443
  continue
382
444
  for x in negs:
383
445
  if not (isinstance(x, Not) and isinstance(x.item, Ref)):
384
- fail(p.loc, f"{t.name}.{p.name} inherits, so a deny on it is `not <permission of "
385
- f"{t.name}>` that inherits the same way, e.g. `can denied = blocked or "
386
- f"parent.denied` and `... and not denied`", "AZ306")
446
+ fail(
447
+ p.loc,
448
+ f"{t.name}.{p.name} inherits, so a deny on it is `not <permission of "
449
+ f"{t.name}>` that inherits the same way, e.g. `can denied = blocked or "
450
+ f"parent.denied` and `... and not denied`",
451
+ "AZ306",
452
+ )
387
453
  base = f"{p.name}__base"
388
- t.perms = {**{k: v for k, v in t.perms.items() if k != p.name},
389
- base: Perm(base, self.rename_arrows(t, inherit, p.name, base, p.loc),
390
- f"{p.src} (its inheritance, before the deny)", p.loc, hidden=True),
391
- p.name: p}
454
+ t.perms = {
455
+ **{k: v for k, v in t.perms.items() if k != p.name},
456
+ base: Perm(
457
+ base,
458
+ self.rename_arrows(t, inherit, p.name, base, p.loc),
459
+ f"{p.src} (its inheritance, before the deny)",
460
+ p.loc,
461
+ hidden=True,
462
+ ),
463
+ p.name: p,
464
+ }
392
465
  p.expr, p.base = And("and", [Ref("ref", base), *negs]), base
393
466
  self.denies[(t.name, p.name)] = negs
394
467
  graph[(t.name, base)] = set()
@@ -400,8 +473,12 @@ class Core:
400
473
  subjects = t.relations[rel].subjects()
401
474
  if any(st == t.name and not sr for st, sr in subjects):
402
475
  if any(st != t.name for st, _ in subjects):
403
- fail(loc, f"{t.name}.{old} has a deny and inherits through {rel}, which also points at "
404
- f"other types; give those their own relation", "AZ306")
476
+ fail(
477
+ loc,
478
+ f"{t.name}.{old} has a deny and inherits through {rel}, which also points at "
479
+ f"other types; give those their own relation",
480
+ "AZ306",
481
+ )
405
482
  return Arrow("arrow", rel, new)
406
483
  case ("not", item):
407
484
  return Not("not", self.rename_arrows(t, item, old, new, loc))
@@ -419,32 +496,48 @@ class Core:
419
496
  for (tn, pn), negs in self.denies.items():
420
497
  t = self.T(tn)
421
498
  base_name = t.perms[pn].base
422
- assert base_name is not None # split_denies gave every permission with a deny its base
499
+ assert base_name is not None # split_denies gave every permission with a deny its base
423
500
  base = t.perms[base_name]
424
501
  key = self.recursive.get((tn, base.name))
425
502
  if key is None or len(self.scc_members[key]) != 1:
426
- fail(base.loc, f"{tn}.{pn} has a deny, so it can only inherit within {tn} (like parent.{pn}), "
427
- f"not through permissions of other types", "AZ306")
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
+ )
428
509
  edges = self.perm_edges(t, base) or frozenset()
429
510
  for x in negs:
430
511
  if not (isinstance(x, Not) and isinstance(x.item, Ref)):
431
- continue # split_denies refused anything else
512
+ continue # split_denies refused anything else
432
513
  name = x.item.name
433
514
  self.lookup(t, name, base.loc)
434
515
  if name not in t.perms:
435
- fail(base.loc, f"{tn}.{pn}: `not {name}` must cover everything below, and {name} is a "
436
- f"relation: make a permission that inherits, `can {name}_below = {name} or "
437
- f"parent.{name}_below`, and deny with it", "AZ306")
516
+ fail(
517
+ base.loc,
518
+ f"{tn}.{pn}: `not {name}` must cover everything below, and {name} is a "
519
+ f"relation: make a permission that inherits, `can {name}_below = {name} or "
520
+ f"parent.{name}_below`, and deny with it",
521
+ "AZ306",
522
+ )
438
523
  if (tn, name) in self.denies:
439
- fail(base.loc, f"{tn}.{pn}: `not {name}` must cover everything below, and {name} has a deny of "
440
- f"its own, which can cut it below a denied object: deny with a permission "
441
- f"without one", "AZ306")
524
+ fail(
525
+ base.loc,
526
+ f"{tn}.{pn}: `not {name}` must cover everything below, and {name} has a deny of "
527
+ f"its own, which can cut it below a denied object: deny with a permission "
528
+ f"without one",
529
+ "AZ306",
530
+ )
442
531
  got = self.perm_edges(t, t.perms[name]) or frozenset()
443
532
  missing = sorted({e[1] for e in edges if e not in got and (e[0], e[1], None, e[3]) not in got})
444
533
  if missing:
445
- fail(base.loc, f"{tn}.{pn}: `not {name}` must cover everything below, so {name} must inherit "
446
- f"through {', '.join(missing)} as {pn} does, e.g. `can {name} = ... or "
447
- f"{missing[0]}.{name}`", "AZ306")
534
+ fail(
535
+ base.loc,
536
+ f"{tn}.{pn}: `not {name}` must cover everything below, so {name} must inherit "
537
+ f"through {', '.join(missing)} as {pn} does, e.g. `can {name} = ... or "
538
+ f"{missing[0]}.{name}`",
539
+ "AZ306",
540
+ )
448
541
 
449
542
  def validate(self) -> None:
450
543
  for t in self.types.values():
@@ -459,8 +552,12 @@ class Core:
459
552
  if st not in self.types:
460
553
  fail(src.loc, f"{t.name}.{r.name}: unknown type '{st}'", "AZ201")
461
554
  if sr == "*" and not self.T(st).principal:
462
- fail(src.loc, f"{t.name}.{r.name}: {st}:* means any signed-in {st}, but {st} doesn't sign "
463
- f"in: mark it `type {st} = ... principal`", "AZ204")
555
+ fail(
556
+ src.loc,
557
+ f"{t.name}.{r.name}: {st}:* means any signed-in {st}, but {st} doesn't sign "
558
+ f"in: mark it `type {st} = ... principal`",
559
+ "AZ204",
560
+ )
464
561
  if sr and sr != "*" and sr not in self.T(st).relations and sr not in self.T(st).perms:
465
562
  fail(src.loc, f"{t.name}.{r.name}: {st} has no relation or permission '{sr}'", "AZ203")
466
563
  if src.kind == "column":
@@ -476,18 +573,30 @@ class Core:
476
573
  self.columns.append((src.table, c, src.loc))
477
574
  elif src.kind == "shared":
478
575
  if src.shared_by and src.shared_by not in t.perms:
479
- fail(src.loc, f"{t.name}.{r.name} is shared by '{src.shared_by}', but {t.name} "
480
- f"has no such permission", "AZ207")
576
+ fail(
577
+ src.loc,
578
+ f"{t.name}.{r.name} is shared by '{src.shared_by}', but {t.name} "
579
+ f"has no such permission",
580
+ "AZ207",
581
+ )
481
582
  if not src.shared_by and "share" not in t.perms:
482
- fail(src.loc, f"{t.name}.{r.name} is shared, which needs a share permission on "
483
- f"{t.name}: add `can share = ...` or write `shared by <permission>`", "AZ207")
583
+ fail(
584
+ src.loc,
585
+ f"{t.name}.{r.name} is shared, which needs a share permission on "
586
+ f"{t.name}: add `can share = ...` or write `shared by <permission>`",
587
+ "AZ207",
588
+ )
484
589
  for rule in self.rules:
485
590
  mapped = [t.name for t in self.types.values() if t.table == rule.table]
486
591
  if not mapped:
487
592
  fail(rule.loc, f"rules for {rule.table}, but no type maps to that table", "AZ401")
488
593
  if len(mapped) > 1:
489
- fail(rule.loc, f"rules for {rule.table}, which types {' and '.join(mapped)} both map to: rules name "
490
- f"one type's permissions, so give each type its own table (or view)", "AZ401")
594
+ fail(
595
+ rule.loc,
596
+ f"rules for {rule.table}, which types {' and '.join(mapped)} both map to: rules name "
597
+ f"one type's permissions, so give each type its own table (or view)",
598
+ "AZ401",
599
+ )
491
600
  for c in rule.columns:
492
601
  self.columns.append((rule.table, c, rule.loc))
493
602
  masked: dict[tuple[str, str], Loc] = {}
@@ -495,7 +604,11 @@ class Core:
495
604
  if rule.command != "mask":
496
605
  continue
497
606
  if rule.table not in self.pol.views:
498
- fail(rule.loc, f"masks are applied by a view: write 'rules {rule.table} view <schema.view_name>'", "AZ402")
607
+ fail(
608
+ rule.loc,
609
+ f"masks are applied by a view: write 'rules {rule.table} view <schema.view_name>'",
610
+ "AZ402",
611
+ )
499
612
  for c in rule.columns:
500
613
  if (rule.table, c) in masked:
501
614
  fail(rule.loc, f"{rule.table}.{c} is masked twice (also on {masked[rule.table, c]})", "AZ109")
@@ -503,8 +616,9 @@ class Core:
503
616
  for table, view in self.pol.views.items():
504
617
  view_loc = self.pol.view_locs[table]
505
618
  if not any(r.table == table and r.command == "select" and not r.columns for r in self.rules):
506
- fail(view_loc, f"the view {view} shows the rows 'select' allows, "
507
- f"so {table} needs a select rule", "AZ402")
619
+ fail(
620
+ view_loc, f"the view {view} shows the rows 'select' allows, so {table} needs a select rule", "AZ402"
621
+ )
508
622
  if view == table or any(t.table == view for t in self.types.values()):
509
623
  fail(view_loc, f"{view} is a table of the policy; name a new view", "AZ402")
510
624
  for sc in self.pol.scopes.values():
@@ -532,25 +646,35 @@ class Core:
532
646
 
533
647
  def check_this(self) -> None:
534
648
  """`this.` names the row a condition is about; a caveat has none, and `this` is no other name."""
649
+
535
650
  def conds(node: Expr) -> list[str]:
536
651
  return [x.sql for x in leaves(node) if isinstance(x, Cond)]
652
+
537
653
  written: list[tuple[str, Loc]] = []
538
654
  for t in self.types.values():
539
655
  written += [(c, p.loc) for p in t.perms.values() for c in conds(p.expr)]
540
656
  written += [(t.where, t.loc)] if t.where else []
541
- written += [(c, src.loc) for r in t.relations.values() for src in r.sources
542
- for c in (src.where, src.shared_if) if c]
657
+ written += [
658
+ (c, src.loc) for r in t.relations.values() for src in r.sources for c in (src.where, src.shared_if) if c
659
+ ]
543
660
  written += [(c, r.loc) for r in self.rules for c in conds(r.expr)]
544
661
  written += [(c, i.loc) for i in self.pol.invariants for c in conds(i.expr)]
545
662
  for sql, loc in written:
546
663
  if this_alone(sql):
547
- fail(loc, f"{{{sql}}}: `this` is the row the condition is about (this.column); call the table "
548
- f"something else", "AZ112")
664
+ fail(
665
+ loc,
666
+ f"{{{sql}}}: `this` is the row the condition is about (this.column); call the table something else",
667
+ "AZ112",
668
+ )
549
669
  for cv in self.pol.caveats.values():
550
670
  if names_this(cv.sql) or this_alone(cv.sql):
551
- fail(cv.loc, f"caveat {cv.name}: a caveat is checked with each request, on any share it goes with, "
552
- f"so there is no row for `this`: it reads the request (authz.ctx('ip')) and what the "
553
- f"share was made with (arg('ip'))", "AZ112")
671
+ fail(
672
+ cv.loc,
673
+ f"caveat {cv.name}: a caveat is checked with each request, on any share it goes with, "
674
+ f"so there is no row for `this`: it reads the request (authz.ctx('ip')) and what the "
675
+ f"share was made with (arg('ip'))",
676
+ "AZ112",
677
+ )
554
678
 
555
679
  def check_runtime_permissions(self) -> None:
556
680
  """The permissions the runtime asks for by name (beside `share`, which shared relations check) are
@@ -559,30 +683,56 @@ class Core:
559
683
  roles_anywhere = any(t.roles and not t.roles_from for t in self.types.values())
560
684
  for t in self.types.values():
561
685
  if "impersonate" in t.perms and t.name != "user":
562
- fail(t.perms["impersonate"].loc, f"{t.name}.impersonate: authz.view_as asks for `impersonate` on the "
563
- f"user type (who may view the app as that user); on {t.name} nothing asks for it", "AZ307")
686
+ fail(
687
+ t.perms["impersonate"].loc,
688
+ f"{t.name}.impersonate: authz.view_as asks for `impersonate` on the "
689
+ f"user type (who may view the app as that user); on {t.name} nothing asks for it",
690
+ "AZ307",
691
+ )
564
692
  if "manage_keys" in t.perms and not t.principal:
565
- fail(t.perms["manage_keys"].loc, f"{t.name}.manage_keys: API keys are made for types that sign in, "
566
- f"and {t.name} doesn't (type {t.name} = ... principal)", "AZ307")
693
+ fail(
694
+ t.perms["manage_keys"].loc,
695
+ f"{t.name}.manage_keys: API keys are made for types that sign in, "
696
+ f"and {t.name} doesn't (type {t.name} = ... principal)",
697
+ "AZ307",
698
+ )
567
699
  if "manage_roles" in t.perms and not roles_anywhere and t.name not in owners_of_roles:
568
- fail(t.perms["manage_roles"].loc, f"{t.name}.manage_roles lets people create custom roles owned by a "
569
- f"{t.name}, but no roles line counts them: write `roles : user from <relation to {t.name}>` on "
570
- f"the type they are for, and `roles` in the permissions they may give", "AZ307")
700
+ fail(
701
+ t.perms["manage_roles"].loc,
702
+ f"{t.name}.manage_roles lets people create custom roles owned by a "
703
+ f"{t.name}, but no roles line counts them: write `roles : user from <relation to {t.name}>` on "
704
+ f"the type they are for, and `roles` in the permissions they may give",
705
+ "AZ307",
706
+ )
571
707
  if "break_glass" in t.perms and not any(
572
- src.kind == "shared" and any(st == "user" and not sr for st, sr in src.subjects)
573
- for r in t.relations.values() for src in r.sources):
574
- fail(t.perms["break_glass"].loc, f"{t.name}.break_glass: authz.break_glass gives its holder a relation "
575
- f"of {t.name} shared with a user for a while, and {t.name} has none (`viewer : user shared`)", "AZ307")
708
+ src.kind == "shared" and any(st == "user" and not sr for st, sr in src.subjects)
709
+ for r in t.relations.values()
710
+ for src in r.sources
711
+ ):
712
+ fail(
713
+ t.perms["break_glass"].loc,
714
+ f"{t.name}.break_glass: authz.break_glass gives its holder a relation "
715
+ f"of {t.name} shared with a user for a while, and {t.name} has none (`viewer : user shared`)",
716
+ "AZ307",
717
+ )
576
718
  if t.roles and t.roles_from:
577
719
  owner = self.role_owner_type(t)
578
720
  if "manage_roles" not in self.T(owner).perms:
579
- fail(t.roles[2], f"custom roles on {t.name} are the roles of its {t.roles_from}, which people with "
580
- f"manage_roles on a {owner} create, and {owner} has no `can manage_roles`", "AZ307")
721
+ fail(
722
+ t.roles[2],
723
+ f"custom roles on {t.name} are the roles of its {t.roles_from}, which people with "
724
+ f"manage_roles on a {owner} create, and {owner} has no `can manage_roles`",
725
+ "AZ307",
726
+ )
581
727
  if roles_anywhere and not any("manage_roles" in t.perms for t in self.types.values()):
582
728
  t = next(t for t in self.types.values() if t.roles and not t.roles_from)
583
729
  assert t.roles is not None
584
- fail(t.roles[2], f"custom roles on {t.name} are made by people with manage_roles on their owner, and no "
585
- f"type has `can manage_roles` (on the org, say: can manage_roles = admin)", "AZ307")
730
+ fail(
731
+ t.roles[2],
732
+ f"custom roles on {t.name} are made by people with manage_roles on their owner, and no "
733
+ f"type has `can manage_roles` (on the org, say: can manage_roles = admin)",
734
+ "AZ307",
735
+ )
586
736
 
587
737
  @staticmethod
588
738
  def source_table(src: Source) -> str:
@@ -603,17 +753,22 @@ class Core:
603
753
  assert columns is not None, f"a {src.kind} source without a subject column"
604
754
  return columns
605
755
 
606
- def check_key_columns(self, t: Type, r: Relation, src: Source, columns: Cols, targets: list[str],
607
- side: str) -> None:
756
+ def check_key_columns(
757
+ self, t: Type, r: Relation, src: Source, columns: Cols, targets: list[str], side: str
758
+ ) -> None:
608
759
  """[a, b] where the key has two columns, one column where it has one."""
609
760
  n = len(cols(columns))
610
761
  for tn in targets:
611
762
  k = len(self.T(tn).key) if tn in self.types else 1
612
763
  if n != k:
613
764
  shown = "[" + ", ".join(c for c, _ in self.T(tn).key) + "]" if k > 1 else "one column"
614
- fail(src.loc, f"{t.name}.{r.name}: the {side} is a {tn}, whose key is {shown}, "
615
- f"but this source gives {n} column{'s' if n > 1 else ''}"
616
- + (": write them in square brackets, [col1, col2]" if k > 1 else ""), "AZ206")
765
+ fail(
766
+ src.loc,
767
+ f"{t.name}.{r.name}: the {side} is a {tn}, whose key is {shown}, "
768
+ f"but this source gives {n} column{'s' if n > 1 else ''}"
769
+ + (": write them in square brackets, [col1, col2]" if k > 1 else ""),
770
+ "AZ206",
771
+ )
617
772
 
618
773
  def check_unused_relations(self) -> None:
619
774
  """A relation nothing uses is almost always a typo (a second source of `member` spelt `memeber`
@@ -633,7 +788,7 @@ class Core:
633
788
  self.lookup(t, rel, loc)
634
789
  used.add((t.name, rel))
635
790
  r = t.relations.get(rel)
636
- for st, _sr in (r.subjects() if r else []):
791
+ for st, _sr in r.subjects() if r else []:
637
792
  used.add((st, perm))
638
793
  case ("not", item):
639
794
  walk(t, item, loc)
@@ -643,7 +798,7 @@ class Core:
643
798
 
644
799
  for t in self.types.values():
645
800
  if t.roles_from:
646
- used.add((t.name, t.roles_from)) # the roles' owner
801
+ used.add((t.name, t.roles_from)) # the roles' owner
647
802
  for p in t.perms.values():
648
803
  walk(t, p.expr, p.loc)
649
804
  for r in t.relations.values():
@@ -663,9 +818,13 @@ class Core:
663
818
  for r in t.relations.values():
664
819
  if r.synthetic or (t.name, r.name) in used or any(s.kind in ("shared", "roles") for s in r.sources):
665
820
  continue
666
- fail(r.loc, f"{t.name}.{r.name} is declared but nothing uses it (no permission, rule, test or "
667
- f"group refers to it): remove it, or if it is another source of an existing relation, "
668
- f"give it that relation's name", "AZ208")
821
+ fail(
822
+ r.loc,
823
+ f"{t.name}.{r.name} is declared but nothing uses it (no permission, rule, test or "
824
+ f"group refers to it): remove it, or if it is another source of an existing relation, "
825
+ f"give it that relation's name",
826
+ "AZ208",
827
+ )
669
828
 
670
829
  def lookup(self, t: Type, name: str, loc: Loc) -> None:
671
830
  if name in t.perms or name in t.relations:
@@ -752,6 +911,7 @@ class Core:
752
911
  if w == v:
753
912
  break
754
913
  sccs.append(comp)
914
+
755
915
  for v in graph:
756
916
  if v not in index:
757
917
  visit(v)
@@ -777,17 +937,35 @@ class Core:
777
937
  self.deps(t, item, inner)
778
938
  if inner & members and isinstance(item, And):
779
939
  for x in item.items:
780
- if isinstance(x, Arrow) and x.rel in t.relations and any(
781
- not sr and (st, x.perm) in members for st, sr in t.relations[x.rel].subjects()):
782
- fail(perm.loc, f"inheritance through {x.rel} can only be narrowed with "
783
- f"{{conditions}} on the row, e.g. ({x.rel}.{pn} and {{inherit}})", "AZ304")
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
+ )
784
953
  if inner & members:
785
- fail(perm.loc, f"{tn}.{pn} depends on itself; a permission can only recurse as "
786
- f"'or rel.perm' (optionally 'and {{condition}}') through a relation "
787
- f"to objects, and a group only as 'member : group#member'", "AZ302")
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
+ )
788
961
  if len(set(tnames)) != len(tnames):
789
- fail(first.loc, "permissions " + ", ".join(f"{a}.{b}" for a, b in sorted(comp)) +
790
- " inherit through each other; a recursion can use one permission per type", "AZ302")
962
+ fail(
963
+ first.loc,
964
+ "permissions "
965
+ + ", ".join(f"{a}.{b}" for a, b in sorted(comp))
966
+ + " inherit through each other; a recursion can use one permission per type",
967
+ "AZ302",
968
+ )
791
969
  key = tuple(sorted(comp))
792
970
  self.scc_members[key] = sorted(comp)
793
971
  for v in comp:
@@ -796,8 +974,12 @@ class Core:
796
974
  def check_tree_relation(self, t: Type, r: Relation, perm: Perm) -> None:
797
975
  for src in r.sources:
798
976
  if src.kind == "shared" and any(sr == "*" or st in ("anyone", "link") for st, sr in src.subjects):
799
- fail(perm.loc, f"{t.name}.{r.name} is used for inheritance, so it can't be shared with "
800
- f"user:* (or another type:*), anyone or link", "AZ301")
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
+ )
801
983
 
802
984
  @staticmethod
803
985
  def top_items(perm: Perm) -> list[Expr]:
@@ -824,7 +1006,7 @@ class Core:
824
1006
  item = arrows[0]
825
1007
  if not isinstance(item, Arrow) or item.rel not in t.relations:
826
1008
  return None, None
827
- self.targets(t, item.rel, perm.loc) # following users is an error in any case
1009
+ self.targets(t, item.rel, perm.loc) # following users is an error in any case
828
1010
  return item, conds
829
1011
 
830
1012
  def recursive_parts(self, t: Type, perm: Perm, item: Expr) -> tuple[list[Inherit], Expr | None]:
@@ -840,8 +1022,12 @@ class Core:
840
1022
  if isinstance(x, Arrow) and x.rel in t.relations:
841
1023
  for st, sr in t.relations[x.rel].subjects():
842
1024
  if not sr and (st, x.perm) in members:
843
- fail(perm.loc, f"inheritance through {x.rel} can only be narrowed with "
844
- f"{{conditions}} on the row, e.g. ({x.rel}.{perm.name} and {{inherit}})", "AZ304")
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
+ )
845
1031
  return [], item
846
1032
  r = t.relations[arrow.rel]
847
1033
  inside = [st for st, _ in r.subjects() if (st, arrow.perm) in members]
@@ -868,22 +1054,32 @@ class Core:
868
1054
  # only returns ids the current user holds.
869
1055
  def add_view(self, name: str, sql: str, header: str, t: Type, public: bool = True) -> None:
870
1056
  if t.where:
871
- sql = (f"SELECT x.id FROM (\n {sql}) x\n"
872
- f" WHERE EXISTS (SELECT 1 FROM {qt(t.table)} w WHERE {self.key_is(t, 'w', 'x.id')} "
873
- f"AND coalesce(({row_cond(t.where, 'w')}), false))")
1057
+ sql = (
1058
+ f"SELECT x.id FROM (\n {sql}) x\n"
1059
+ f" WHERE EXISTS (SELECT 1 FROM {qt(t.table)} w WHERE {self.key_is(t, 'w', 'x.id')} "
1060
+ f"AND coalesce(({row_cond(t.where, 'w')}), false))"
1061
+ )
874
1062
  self.view_sql.append(
875
- f"{header}CREATE VIEW authz_int.{q(name)} AS\n {sql};" + (
1063
+ f"{header}CREATE VIEW authz_int.{q(name)} AS\n {sql};"
1064
+ + (
876
1065
  f"\nCREATE VIEW authz_gen.{q(name)} WITH (security_barrier) AS SELECT id FROM authz_int.{q(name)};"
877
- if public else ""))
1066
+ if public
1067
+ else ""
1068
+ )
1069
+ )
878
1070
 
879
1071
  def begin_view(self, name: str, loc: Loc, what: str) -> bool:
880
1072
  state = self.view_state.get(name)
881
1073
  if state == "done":
882
1074
  return False
883
1075
  if state == "busy":
884
- fail(loc, f"{what} depends on itself; a permission can only recurse as "
885
- f"'or rel.perm' through a relation to objects, and a group only as "
886
- f"'member : group#member'", "AZ302")
1076
+ fail(
1077
+ loc,
1078
+ f"{what} depends on itself; a permission can only recurse as "
1079
+ f"'or rel.perm' through a relation to objects, and a group only as "
1080
+ f"'member : group#member'",
1081
+ "AZ302",
1082
+ )
887
1083
  self.view_state[name] = "busy"
888
1084
  return True
889
1085
 
@@ -903,8 +1099,10 @@ class Core:
903
1099
  if view in self.direct_checks:
904
1100
  # what the object's own columns give first: one index lookup, where most users who reach a
905
1101
  # lot (an org admin: every folder) stop, instead of building every object they start from
906
- return (f"(EXISTS (SELECT 1 FROM authz_gen.{q(self.direct_checks[view])} v WHERE v.id = {expr})"
907
- f"\n OR EXISTS (SELECT 1 FROM authz_gen.{q(view)} v WHERE v.id = {expr}))")
1102
+ return (
1103
+ f"(EXISTS (SELECT 1 FROM authz_gen.{q(self.direct_checks[view])} v WHERE v.id = {expr})"
1104
+ f"\n OR EXISTS (SELECT 1 FROM authz_gen.{q(view)} v WHERE v.id = {expr}))"
1105
+ )
908
1106
  if view in self.nested:
909
1107
  # a user's memberships are few: work them out once per query
910
1108
  return f"coalesce({expr} = ANY ({self.group_ids(view)}), false)"
@@ -926,13 +1124,16 @@ class Core:
926
1124
  """A share counts between its start and its expiry, and while its caveat holds."""
927
1125
  caveats = self.pol.caveats
928
1126
  if caveats:
929
- cases = " ".join(f"WHEN {lit(c.name)} THEN coalesce(({self.caveat_sql(c, alias)}), false)"
930
- for c in caveats.values())
1127
+ cases = " ".join(
1128
+ f"WHEN {lit(c.name)} THEN coalesce(({self.caveat_sql(c, alias)}), false)" for c in caveats.values()
1129
+ )
931
1130
  cav = f"({alias}.caveat IS NULL OR CASE {alias}.caveat {cases} ELSE false END)"
932
1131
  else:
933
1132
  cav = f"{alias}.caveat IS NULL"
934
- return (f"({alias}.expires_at IS NULL OR {alias}.expires_at > now()) "
935
- f"AND ({alias}.starts_at IS NULL OR {alias}.starts_at <= now()) AND {cav}")
1133
+ return (
1134
+ f"({alias}.expires_at IS NULL OR {alias}.expires_at > now()) "
1135
+ f"AND ({alias}.starts_at IS NULL OR {alias}.starts_at <= now()) AND {cav}"
1136
+ )
936
1137
 
937
1138
  @staticmethod
938
1139
  def caveat_sql(c: Caveat, alias: str = "g") -> str:
@@ -949,11 +1150,16 @@ class Core:
949
1150
  body = per_type(t)
950
1151
  if body is None:
951
1152
  continue
952
- branches.append(f" WHEN {lit(t.name)} THEN\n"
953
- f" IF NOT pg_catalog.pg_input_is_valid(p_id, {lit(t.keytype)}) THEN RETURN {self._invalid}; END IF;\n"
954
- f" v_{t.pktype} := p_id::{t.keytype}{'::text' if t.composite else ''};\n{body}")
955
- return (" CASE p_type\n" + "\n".join(branches) +
956
- f"\n ELSE\n {missing_type}\n END CASE;") if branches else f" {missing_type}"
1153
+ branches.append(
1154
+ f" WHEN {lit(t.name)} THEN\n"
1155
+ f" IF NOT pg_catalog.pg_input_is_valid(p_id, {lit(t.keytype)}) THEN RETURN {self._invalid}; END IF;\n"
1156
+ f" v_{t.pktype} := p_id::{t.keytype}{'::text' if t.composite else ''};\n{body}"
1157
+ )
1158
+ return (
1159
+ (" CASE p_type\n" + "\n".join(branches) + f"\n ELSE\n {missing_type}\n END CASE;")
1160
+ if branches
1161
+ else f" {missing_type}"
1162
+ )
957
1163
 
958
1164
  # --- keys: one column, or several (a composite key, whose ids are its canonical row text) ----
959
1165
  @staticmethod
@@ -973,8 +1179,11 @@ class Core:
973
1179
  columns = cols(columns)
974
1180
  if not t.composite:
975
1181
  return f"{a}.{q(columns[0])}"
976
- return (f"(CASE WHEN ({', '.join(f'{a}.{q(c)}' for c in columns)}) IS NOT NULL THEN ROW("
977
- + ", ".join(f"{a}.{q(c)}::{ty}" for c, (_, ty) in zip(columns, t.key, strict=True)) + ")::text END)")
1182
+ return (
1183
+ f"(CASE WHEN ({', '.join(f'{a}.{q(c)}' for c in columns)}) IS NOT NULL THEN ROW("
1184
+ + ", ".join(f"{a}.{q(c)}::{ty}" for c, (_, ty) in zip(columns, t.key, strict=True))
1185
+ + ")::text END)"
1186
+ )
978
1187
 
979
1188
  def key_is(self, t: Type, a: str, v: str, columns: Cols | None = None) -> str:
980
1189
  """Row a's columns (t's key by default) hold the id v of a t, compared column by column so
@@ -983,17 +1192,18 @@ class Core:
983
1192
  if not t.composite:
984
1193
  return f"{a}.{q(columns[0])} = {v}"
985
1194
  k = f"(({v})::{t.keytype})"
986
- return (f"({', '.join(f'{a}.{q(c)}' for c in columns)}) = "
987
- f"({', '.join(f'{k}.{q(kc)}' for kc, _ in t.key)})")
1195
+ return f"({', '.join(f'{a}.{q(c)}' for c in columns)}) = ({', '.join(f'{k}.{q(kc)}' for kc, _ in t.key)})"
988
1196
 
989
1197
  def key_in(self, t: Type, a: str, sub: str, columns: Cols | None = None) -> str:
990
1198
  """Row a's columns (t's key by default) hold one of the ids that sub (a query of id) selects."""
991
1199
  columns = cols(columns) if columns else tuple(c for c, _ in t.key)
992
1200
  if not t.composite:
993
1201
  return f"{a}.{q(columns[0])} IN ({sub})"
994
- return (f"({', '.join(f'{a}.{q(c)}' for c in columns)}) IN (SELECT "
995
- + ", ".join(f"(k.k).{q(kc)}" for kc, _ in t.key)
996
- + f" FROM (SELECT x.id::{t.keytype} AS k FROM ({sub}) x) k)")
1202
+ return (
1203
+ f"({', '.join(f'{a}.{q(c)}' for c in columns)}) IN (SELECT "
1204
+ + ", ".join(f"(k.k).{q(kc)}" for kc, _ in t.key)
1205
+ + f" FROM (SELECT x.id::{t.keytype} AS k FROM ({sub}) x) k)"
1206
+ )
997
1207
 
998
1208
  def bare_key_text(self, t: Type) -> str:
999
1209
  """A row's id as text, its columns unqualified (for dynamic SQL over the table or a view of it)."""
@@ -1009,8 +1219,9 @@ class Core:
1009
1219
  return self.ref_text(st, a, col) if st else f"{a}.{q(cols(col)[0])}::text"
1010
1220
  if not any(self.T(st).composite for st, _ in src.subjects if st in self.types):
1011
1221
  return f"{a}.{q(cols(col)[0])}::text"
1012
- whens = " ".join(f"WHEN {lit(st)} THEN {self.ref_text(self.T(st), a, col)}"
1013
- for st, _ in src.subjects if st in self.types)
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
+ )
1014
1225
  return f"(CASE {a}.{q(src.type_col)} {whens} END)"
1015
1226
 
1016
1227
  def key_text(self, t: Type, a: str) -> str:
@@ -1042,7 +1253,9 @@ class Core:
1042
1253
  assert st is not None, f"{subj_type}: a source with a type column links to types"
1043
1254
  if st.composite:
1044
1255
  return f"(CASE WHEN {alias}.{q(src.type_col)} = {lit(subj_type)} THEN {self.ref(st, alias, col)} END)"
1045
- return f"(CASE WHEN {alias}.{q(src.type_col)} = {lit(subj_type)} THEN {alias}.{q(cols(col)[0])} END)::{st.pktype}"
1256
+ return (
1257
+ f"(CASE WHEN {alias}.{q(src.type_col)} = {lit(subj_type)} THEN {alias}.{q(cols(col)[0])} END)::{st.pktype}"
1258
+ )
1046
1259
 
1047
1260
  def is_principal(self, st: str) -> bool:
1048
1261
  return st in self.types and self.T(st).principal
@@ -1065,16 +1278,22 @@ class Core:
1065
1278
  else:
1066
1279
  return None
1067
1280
  if src.kind == "column":
1068
- return (f"SELECT {self.key(t, 'r')} AS id FROM {qt(t.table)} r "
1069
- f"WHERE {self.subject_id(src, 'r', st)} {match}")
1281
+ return (
1282
+ f"SELECT {self.key(t, 'r')} AS id FROM {qt(t.table)} r "
1283
+ f"WHERE {self.subject_id(src, 'r', st)} {match}"
1284
+ )
1070
1285
  assert src.table is not None and src.obj_col is not None
1071
1286
  extra = f" AND coalesce(({row_cond(src.where, 's')}), false)" if src.where else ""
1072
- return (f"SELECT {self.ref(t, 's', src.obj_col)} AS id FROM {qt(src.table)} s "
1073
- f"WHERE {self.subject_id(src, 's', st)} {match}{extra}")
1287
+ return (
1288
+ f"SELECT {self.ref(t, 's', src.obj_col)} AS id FROM {qt(src.table)} s "
1289
+ f"WHERE {self.subject_id(src, 's', st)} {match}{extra}"
1290
+ )
1074
1291
  # shares and custom roles live in authz.shares
1075
1292
  if sr == "*":
1076
- who = (f"g.subject_type = {lit(st)} AND g.subject_relation = '' AND g.subject_id = '*' "
1077
- f"AND {self.me(st)} IS NOT NULL")
1293
+ who = (
1294
+ f"g.subject_type = {lit(st)} AND g.subject_relation = '' AND g.subject_id = '*' "
1295
+ f"AND {self.me(st)} IS NOT NULL"
1296
+ )
1078
1297
  elif st == "anyone":
1079
1298
  who = "g.subject_type = 'anyone'"
1080
1299
  elif st == "link":
@@ -1083,19 +1302,34 @@ class Core:
1083
1302
  who = f"g.subject_type = {lit(st)} AND g.subject_relation = '' AND g.subject_id = {self.me(st, text=True)}"
1084
1303
  elif sr:
1085
1304
  inner = self.view_ref(self.T(st), sr, src.loc)
1086
- who = (f"g.subject_type = {lit(st)} AND g.subject_relation = {lit(sr)} "
1087
- f"AND g.subject_id {self.ids_in(inner, text=True)}")
1305
+ who = (
1306
+ f"g.subject_type = {lit(st)} AND g.subject_relation = {lit(sr)} "
1307
+ f"AND g.subject_id {self.ids_in(inner, text=True)}"
1308
+ )
1088
1309
  else:
1089
1310
  return None
1090
1311
  if src.kind == "roles":
1091
1312
  rel = self.role_rel_sql(t, src, f"g.object_id::{t.pktype}")
1092
1313
  else:
1093
1314
  rel = f"g.relation = {lit(r.name)}"
1094
- return (f"SELECT g.object_id::{t.pktype} AS id FROM authz.shares g "
1095
- f"WHERE g.object_type = {lit(t.name)} AND {rel} AND {who} AND {self.live()}")
1096
-
1097
- def pair_sql(self, t: Type, r: Relation, src: Source, subj_type: str, subj_rel: str | None, obj: str, subj: str,
1098
- extra: str = "", tree: bool = False, match: tuple[str, str] | None = None) -> str:
1315
+ return (
1316
+ f"SELECT g.object_id::{t.pktype} AS id FROM authz.shares g "
1317
+ f"WHERE g.object_type = {lit(t.name)} AND {rel} AND {who} AND {self.live()}"
1318
+ )
1319
+
1320
+ def pair_sql(
1321
+ self,
1322
+ t: Type,
1323
+ r: Relation,
1324
+ src: Source,
1325
+ subj_type: str,
1326
+ subj_rel: str | None,
1327
+ obj: str,
1328
+ subj: str,
1329
+ extra: str = "",
1330
+ tree: bool = False,
1331
+ match: tuple[str, str] | None = None,
1332
+ ) -> str:
1099
1333
  """(object id, subject id) pairs of one source of relation r, for subjects of
1100
1334
  type subj_type (#subj_rel). Links used for inheritance ignore expiry: shares
1101
1335
  on them can't have any (a stored closure can't follow the clock). match=(side, v):
@@ -1112,19 +1346,25 @@ class Core:
1112
1346
  m = " AND " + self.subject_is(src, a, subj_type, v)
1113
1347
  sid = self.subject_id(src, a, subj_type)
1114
1348
  if src.kind == "column":
1115
- return (f"SELECT {extra}{self.key(t, 'r')} AS {obj}, {sid} AS {subj} "
1116
- f"FROM {qt(t.table)} r WHERE {sid} IS NOT NULL{m}")
1349
+ return (
1350
+ f"SELECT {extra}{self.key(t, 'r')} AS {obj}, {sid} AS {subj} "
1351
+ f"FROM {qt(t.table)} r WHERE {sid} IS NOT NULL{m}"
1352
+ )
1117
1353
  assert src.table is not None and src.obj_col is not None
1118
1354
  where = f" AND coalesce(({on_row(src.where, 's')}), false)" if src.where else ""
1119
1355
  oid = self.ref(t, "s", src.obj_col)
1120
- return (f"SELECT {extra}{oid} AS {obj}, {sid} AS {subj} "
1121
- f"FROM {qt(src.table)} s WHERE {sid} IS NOT NULL "
1122
- f"AND {oid} IS NOT NULL{where}{m}")
1356
+ return (
1357
+ f"SELECT {extra}{oid} AS {obj}, {sid} AS {subj} "
1358
+ f"FROM {qt(src.table)} s WHERE {sid} IS NOT NULL "
1359
+ f"AND {oid} IS NOT NULL{where}{m}"
1360
+ )
1123
1361
  live = "" if tree else f" AND {self.live()}"
1124
1362
  m = {"obj": f" AND g.object_id = ({v})::text", "subj": f" AND g.subject_id = ({v})::text"}.get(side or "", "")
1125
- return (f"SELECT {extra}g.object_id::{t.pktype} AS {obj}, g.subject_id::{s.pktype} AS {subj} "
1126
- f"FROM authz.shares g WHERE g.object_type = {lit(t.name)} AND g.relation = {lit(r.name)} "
1127
- f"AND g.subject_type = {lit(subj_type)} AND g.subject_relation = {lit(subj_rel)}{live}{m}")
1363
+ return (
1364
+ f"SELECT {extra}g.object_id::{t.pktype} AS {obj}, g.subject_id::{s.pktype} AS {subj} "
1365
+ f"FROM authz.shares g WHERE g.object_type = {lit(t.name)} AND g.relation = {lit(r.name)} "
1366
+ f"AND g.subject_type = {lit(subj_type)} AND g.subject_relation = {lit(subj_rel)}{live}{m}"
1367
+ )
1128
1368
 
1129
1369
  def is_group_loop(self, t: Type, r: Relation, st: str, sr: str | None) -> bool:
1130
1370
  return st == t.name and sr == r.name
@@ -1155,8 +1395,12 @@ class Core:
1155
1395
  if ext:
1156
1396
  direct.append(f"SELECT NULL::{t.pktype} AS id WHERE false")
1157
1397
  else:
1158
- fail(loc, f"{t.name}.{r.name} links to {r.subjects()[0][0]} objects, not users; "
1159
- f"follow it with a dot, e.g. {r.name}.view", "AZ301")
1398
+ fail(
1399
+ loc,
1400
+ f"{t.name}.{r.name} links to {r.subjects()[0][0]} objects, not users; "
1401
+ f"follow it with a dot, e.g. {r.name}.view",
1402
+ "AZ301",
1403
+ )
1160
1404
  if loops:
1161
1405
  # nested groups: members of a sub-group are members of the group, to
1162
1406
  # any depth; UNION stops on loops in the nesting. Nested through columns or
@@ -1166,19 +1410,29 @@ class Core:
1166
1410
  # each read. Nested through shares, even this estimates high (the random-change
1167
1411
  # tests: 5.8M, JIT inlining every read), so those keep the join.
1168
1412
  # A group that fails the type's where passes nothing on: the walk doesn't go through it.
1169
- valid = (f"EXISTS (SELECT 1 FROM {qt(t.table)} w WHERE {self.key_is(t, 'w', 'm.id')} "
1170
- f"AND coalesce(({row_cond(t.where, 'w')}), false))") if t.where else ""
1413
+ valid = (
1414
+ (
1415
+ f"EXISTS (SELECT 1 FROM {qt(t.table)} w WHERE {self.key_is(t, 'w', 'm.id')} "
1416
+ f"AND coalesce(({row_cond(t.where, 'w')}), false))"
1417
+ )
1418
+ if t.where
1419
+ else ""
1420
+ )
1171
1421
  if loop_kinds <= {"column", "table"}:
1172
- step = (f"SELECT unnest(ARRAY(SELECT e.obj FROM (\n {union(loops)}) e WHERE e.subj = m.id)) FROM m"
1173
- + (f"\n WHERE {valid}" if valid else ""))
1422
+ step = f"SELECT unnest(ARRAY(SELECT e.obj FROM (\n {union(loops)}) e WHERE e.subj = m.id)) FROM m" + (
1423
+ f"\n WHERE {valid}" if valid else ""
1424
+ )
1174
1425
  else:
1175
- step = (f"SELECT e.obj FROM (\n {union(loops)}) e JOIN m ON e.subj = m.id"
1176
- + (f"\n WHERE {valid}" if valid else ""))
1177
- sql = (f"WITH RECURSIVE m(id) AS (\n"
1178
- f" SELECT id FROM (\n {union(direct)}) d\n"
1179
- f" UNION\n"
1180
- f" {step})\n"
1181
- f" SELECT id FROM m")
1426
+ step = f"SELECT e.obj FROM (\n {union(loops)}) e JOIN m ON e.subj = m.id" + (
1427
+ f"\n WHERE {valid}" if valid else ""
1428
+ )
1429
+ sql = (
1430
+ f"WITH RECURSIVE m(id) AS (\n"
1431
+ f" SELECT id FROM (\n {union(direct)}) d\n"
1432
+ f" UNION\n"
1433
+ f" {step})\n"
1434
+ f" SELECT id FROM m"
1435
+ )
1182
1436
  self.nested[name] = t.pktype
1183
1437
  else:
1184
1438
  sql = union(direct)
@@ -1192,12 +1446,14 @@ class Core:
1192
1446
  f"-- {t.name}.{label} for the user signed in, as an array (reads ask once per statement)\n"
1193
1447
  f"CREATE FUNCTION authz_gen.{q(name + '__ids')}() RETURNS {t.pktype}[]\n"
1194
1448
  f"LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path FROM CURRENT AS $f$\n"
1195
- f"BEGIN\n RETURN ARRAY(SELECT id FROM authz_int.{q(name)});\nEND $f$;")
1449
+ f"BEGIN\n RETURN ARRAY(SELECT id FROM authz_int.{q(name)});\nEND $f$;"
1450
+ )
1196
1451
  self.view_state[name] = "done"
1197
1452
  return name
1198
1453
 
1199
- def ensure_link_view(self, t: Type, r: Relation, perm_name: str, loc: Loc,
1200
- targets: Sequence[str] | None = None, ext: bool = False) -> str:
1454
+ def ensure_link_view(
1455
+ self, t: Type, r: Relation, perm_name: str, loc: Loc, targets: Sequence[str] | None = None, ext: bool = False
1456
+ ) -> str:
1201
1457
  """Ids of t whose r points at something the user holds perm_name on."""
1202
1458
  all_targets = [st for st, _ in r.subjects()]
1203
1459
  targets = list(targets) if targets else all_targets
@@ -1214,18 +1470,24 @@ class Core:
1214
1470
  if (st, None) not in src.subjects:
1215
1471
  continue
1216
1472
  if src.kind == "column":
1217
- parts.append(f"SELECT {self.key(t, 'r')} AS id FROM {qt(t.table)} r "
1218
- f"WHERE {self.subject_id(src, 'r', st)} {self.ids_in(inner)}")
1473
+ parts.append(
1474
+ f"SELECT {self.key(t, 'r')} AS id FROM {qt(t.table)} r "
1475
+ f"WHERE {self.subject_id(src, 'r', st)} {self.ids_in(inner)}"
1476
+ )
1219
1477
  elif src.kind == "table":
1220
1478
  assert src.table is not None and src.obj_col is not None
1221
1479
  extra = f" AND coalesce(({row_cond(src.where, 's')}), false)" if src.where else ""
1222
- parts.append(f"SELECT {self.ref(t, 's', src.obj_col)} AS id FROM {qt(src.table)} s "
1223
- f"WHERE {self.subject_id(src, 's', st)} {self.ids_in(inner)}{extra}")
1480
+ parts.append(
1481
+ f"SELECT {self.ref(t, 's', src.obj_col)} AS id FROM {qt(src.table)} s "
1482
+ f"WHERE {self.subject_id(src, 's', st)} {self.ids_in(inner)}{extra}"
1483
+ )
1224
1484
  else:
1225
- parts.append(f"SELECT g.object_id::{t.pktype} AS id FROM authz.shares g "
1226
- f"WHERE g.object_type = {lit(t.name)} AND g.relation = {lit(r.name)} "
1227
- f"AND g.subject_type = {lit(st)} AND g.subject_relation = '' "
1228
- f"AND g.subject_id {self.ids_in(inner, text=True)} AND {self.live()}")
1485
+ parts.append(
1486
+ f"SELECT g.object_id::{t.pktype} AS id FROM authz.shares g "
1487
+ f"WHERE g.object_type = {lit(t.name)} AND g.relation = {lit(r.name)} "
1488
+ f"AND g.subject_type = {lit(st)} AND g.subject_relation = '' "
1489
+ f"AND g.subject_id {self.ids_in(inner, text=True)} AND {self.live()}"
1490
+ )
1229
1491
  if not parts:
1230
1492
  parts.append(f"SELECT NULL::{t.pktype} AS id WHERE false")
1231
1493
  self.add_view(view, union(parts), f"-- {t.name}.{r.name}.{perm_name}\n", t)
@@ -1261,7 +1523,9 @@ class Core:
1261
1523
  if not items:
1262
1524
  key = self.recursive.get((t.name, perm.name))
1263
1525
  members = self.scc_members[key] if key is not None else [(t.name, perm.name)]
1264
- if len(members) == 1 or not any(self.own_base_items(self.T(tn), self.T(tn).perms[pn]) for tn, pn in members):
1526
+ if len(members) == 1 or not any(
1527
+ self.own_base_items(self.T(tn), self.T(tn).perms[pn]) for tn, pn in members
1528
+ ):
1265
1529
  fail(perm.loc, f"{t.name}.{perm.name} needs a starting point besides inheritance", "AZ303")
1266
1530
  return items
1267
1531
 
@@ -1278,7 +1542,11 @@ class Core:
1278
1542
  header = f"-- {t.name}.{perm.name} ({perm.loc}): {perm.src}\n"
1279
1543
  key = self.recursive.get((t.name, perm.name))
1280
1544
  if key is None:
1281
- sql = self.set_sql(t, perm.expr, perm.loc)
1545
+ # as the row checks do (row_sql): the permissions of its type written out in place, so a lookup
1546
+ # another one covers is dropped. view names parent.view and, through edit, parent.edit, which
1547
+ # parent.view includes: named as views, each would be written out again at every level above
1548
+ items = self.prune(t, self.flat_items(t, perm, views=True))
1549
+ sql = union([self.set_sql(t, i, perm.loc) for i in items])
1282
1550
  else:
1283
1551
  members = self.scc_members[key]
1284
1552
  edges = self.scc_edges(key)
@@ -1288,8 +1556,7 @@ class Core:
1288
1556
  # session instead of with every query that uses this permission.
1289
1557
  tree = self.ensure_tree(t, edges)
1290
1558
  start = self.start_function(t, perm, (t.name, edges))
1291
- sql = (f"SELECT c.descendant AS id FROM authz_int.{q(tree)} c\n"
1292
- f" WHERE c.ancestor IN (SELECT {start}())")
1559
+ sql = f"SELECT c.descendant AS id FROM authz_int.{q(tree)} c\n WHERE c.ancestor IN (SELECT {start}())"
1293
1560
  self.add_view(name, sql, header, t)
1294
1561
  self.view_state[name] = "done"
1295
1562
  self.point_check_function(t, perm, tree, (t.name, edges))
@@ -1302,9 +1569,12 @@ class Core:
1302
1569
  mt = self.T(tn)
1303
1570
  fn = self.start_function(mt, mt.perms[pn], None)
1304
1571
  starts.append(f"SELECT {lit(tn)}::text, x::text FROM {fn}() x")
1305
- sql = (f"SELECT c.did::{t.pktype} AS id FROM authz_int.{q(tree)} c\n"
1306
- f" WHERE c.dtype = {lit(t.name)} AND (c.atype, c.aid) IN (\n "
1307
- + "\n UNION ALL\n ".join(starts) + ")")
1572
+ sql = (
1573
+ f"SELECT c.did::{t.pktype} AS id FROM authz_int.{q(tree)} c\n"
1574
+ f" WHERE c.dtype = {lit(t.name)} AND (c.atype, c.aid) IN (\n "
1575
+ + "\n UNION ALL\n ".join(starts)
1576
+ + ")"
1577
+ )
1308
1578
  self.add_view(name, sql, header, t)
1309
1579
  self.view_state[name] = "done"
1310
1580
 
@@ -1312,12 +1582,17 @@ class Core:
1312
1582
  """The ids where a recursive permission starts for the current user: what it holds directly (none, for
1313
1583
  a permission of a loop that only inherits: base_items)."""
1314
1584
  items = self.base_items(t, perm)
1315
- body = (union([self.set_sql(t, i, perm.loc, flatten) for i in items]) if items
1316
- else f"SELECT NULL::{t.pktype} AS id WHERE false")
1585
+ body = (
1586
+ union([self.set_sql(t, i, perm.loc, flatten) for i in items])
1587
+ if items
1588
+ else f"SELECT NULL::{t.pktype} AS id WHERE false"
1589
+ )
1317
1590
  if t.where:
1318
- body = (f"SELECT x.id FROM (\n {body}) x\n"
1319
- f" WHERE EXISTS (SELECT 1 FROM {qt(t.table)} w WHERE {self.key_is(t, 'w', 'x.id')} "
1320
- f"AND coalesce(({row_cond(t.where, 'w')}), false))")
1591
+ body = (
1592
+ f"SELECT x.id FROM (\n {body}) x\n"
1593
+ f" WHERE EXISTS (SELECT 1 FROM {qt(t.table)} w WHERE {self.key_is(t, 'w', 'x.id')} "
1594
+ f"AND coalesce(({row_cond(t.where, 'w')}), false))"
1595
+ )
1321
1596
  return body
1322
1597
 
1323
1598
  def point_check_function(self, t: Type, perm: Perm, tree: str, flatten: Flatten) -> None:
@@ -1328,8 +1603,14 @@ class Core:
1328
1603
  name = f"{t.name}__{perm.name}__has"
1329
1604
  self.point_checks[f"{t.name}__{perm.name}"] = name
1330
1605
  # like every view (add_view): an object failing its type's where holds nothing
1331
- where = (f"\n AND EXISTS (SELECT 1 FROM {qt(t.table)} w WHERE {self.key_is(t, 'w', 'p_id')} "
1332
- f"AND coalesce(({row_cond(t.where, 'w')}), false))") if t.where else ""
1606
+ where = (
1607
+ (
1608
+ f"\n AND EXISTS (SELECT 1 FROM {qt(t.table)} w WHERE {self.key_is(t, 'w', 'p_id')} "
1609
+ f"AND coalesce(({row_cond(t.where, 'w')}), false))"
1610
+ )
1611
+ if t.where
1612
+ else ""
1613
+ )
1333
1614
  self.view_sql.append(
1334
1615
  f"-- {t.name}.{perm.name} for one object (checks of single rows and authz.can)\n"
1335
1616
  f"CREATE FUNCTION authz_gen.{q(name)}(p_id {t.pktype}) RETURNS boolean\n"
@@ -1338,7 +1619,8 @@ class Core:
1338
1619
  f" RETURN EXISTS (SELECT 1 FROM authz_int.{q(tree)} c\n"
1339
1620
  f" WHERE c.descendant = p_id\n"
1340
1621
  f" AND EXISTS (SELECT 1 FROM (\n {self.start_sql(t, perm, flatten)}) s WHERE s.id = c.ancestor)){where};\n"
1341
- f"END $f$;")
1622
+ f"END $f$;"
1623
+ )
1342
1624
 
1343
1625
  def direct_view(self, t: Type, perm: Perm, view: str) -> None:
1344
1626
  """The starting points of t.perm that an object's own columns decide (owner, org.admin, {cond}).
@@ -1369,8 +1651,7 @@ class Core:
1369
1651
  if isinstance(item, Ref) and item.name in t.perms:
1370
1652
  inner = t.perms[item.name]
1371
1653
  e = self.perm_edges(t, inner)
1372
- if (e is not None and e <= edges
1373
- and len(self.scc_members[self.recursive[(t.name, inner.name)]]) == 1):
1654
+ if e is not None and e <= edges and len(self.scc_members[self.recursive[(t.name, inner.name)]]) == 1:
1374
1655
  out += self.start_items(t, inner, edges)
1375
1656
  continue
1376
1657
  out.append(item)
@@ -1387,8 +1668,9 @@ class Core:
1387
1668
  return all(self.own_columns(t, x) for x in items)
1388
1669
  case ("ref", name) | ("arrow", name, _) | ("arrow_on", name, _, _) if name in t.relations:
1389
1670
  r = t.relations[name]
1390
- return (all(src.kind == "column" for src in r.sources)
1391
- and not any(sr and sr != "*" and self.is_group_loop(t, r, st, sr) for st, sr in r.subjects()))
1671
+ return all(src.kind == "column" for src in r.sources) and not any(
1672
+ sr and sr != "*" and self.is_group_loop(t, r, st, sr) for st, sr in r.subjects()
1673
+ )
1392
1674
  return False
1393
1675
 
1394
1676
  def item_cost(self, t: Type, item: Expr) -> int:
@@ -1412,7 +1694,8 @@ class Core:
1412
1694
  if name in t.relations:
1413
1695
  rn = t.relations[name]
1414
1696
  direct = all(src.kind == "column" for src in rn.sources) and all(
1415
- self.is_principal(st) and not sr for st, sr in rn.subjects())
1697
+ self.is_principal(st) and not sr for st, sr in rn.subjects()
1698
+ )
1416
1699
  return 0 if direct else 1
1417
1700
  if (t.name, name) in self.recursive:
1418
1701
  return 2
@@ -1431,7 +1714,8 @@ class Core:
1431
1714
  f"-- {t.name}.{perm.name}: where inheritance starts, for the current user\n"
1432
1715
  f"CREATE FUNCTION {fn}() RETURNS SETOF {t.pktype}\n"
1433
1716
  f"LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path FROM CURRENT ROWS 100 AS $f$\n"
1434
- f"BEGIN\n RETURN QUERY\n {body};\nEND $f$;")
1717
+ f"BEGIN\n RETURN QUERY\n {body};\nEND $f$;"
1718
+ )
1435
1719
  return fn
1436
1720
 
1437
1721
  # --- set SQL: ids of t for which the expression holds -----------------
@@ -1444,8 +1728,13 @@ class Core:
1444
1728
  return f"SELECT id FROM authz_int.{q(self.ensure_rel_view(t, t.relations[name], loc))}"
1445
1729
  perm = t.perms[name]
1446
1730
  edges = self.perm_edges(t, perm)
1447
- if (flatten and edges is not None and flatten[0] == t.name and edges <= flatten[1]
1448
- and len(self.scc_members[self.recursive[(t.name, perm.name)]]) == 1):
1731
+ if (
1732
+ flatten
1733
+ and edges is not None
1734
+ and flatten[0] == t.name
1735
+ and edges <= flatten[1]
1736
+ and len(self.scc_members[self.recursive[(t.name, perm.name)]]) == 1
1737
+ ):
1449
1738
  # its links are a subset of the ones being walked: one walk covers both
1450
1739
  return union([self.set_sql(t, i, loc, flatten) for i in self.base_items(t, perm)])
1451
1740
  self.ensure_view(t, perm)
@@ -1469,21 +1758,23 @@ class Core:
1469
1758
  case ("not", ("cond", sql)):
1470
1759
  where.append(f"NOT coalesce(({row_cond(sql, 'r')}), false)")
1471
1760
  case ("not", inner):
1472
- where.append(f"NOT EXISTS (SELECT 1 FROM ({self.set_sql(t, inner, loc)}) x "
1473
- f"WHERE x.id = {rid})")
1761
+ where.append(f"NOT EXISTS (SELECT 1 FROM ({self.set_sql(t, inner, loc)}) x WHERE x.id = {rid})")
1474
1762
  case _:
1475
1763
  where.append(self.key_in(t, "r", self.set_sql(t, item, loc)))
1476
1764
  return f"SELECT {rid} AS id FROM {tbl} r\n WHERE " + "\n AND ".join(where)
1477
1765
 
1478
1766
  # --- policies: flatten same-type permissions, drop redundant lookups ---
1479
- def flat_items(self, t: Type, perm: Perm, stack: tuple[Name, ...] = ()) -> list[Expr]:
1767
+ def flat_items(self, t: Type, perm: Perm, stack: tuple[Name, ...] = (), views: bool = False) -> list[Expr]:
1768
+ """t.perm's operands, with the permissions of its type it names written out in place. views: for the
1769
+ permission's view, where one that inherits stays a name (its view walks a tree, which its operands
1770
+ written out here would not)."""
1480
1771
  key = (t.name, perm.name)
1481
1772
  if key in stack:
1482
1773
  fail(perm.loc, f"{t.name}.{perm.name} depends on itself", "AZ302")
1483
1774
  out = []
1484
1775
  for item in self.top_items(perm):
1485
- if isinstance(item, Ref) and item.name in t.perms:
1486
- out += self.flat_items(t, t.perms[item.name], (*stack, key))
1776
+ if isinstance(item, Ref) and item.name in t.perms and not (views and (t.name, item.name) in self.recursive):
1777
+ out += self.flat_items(t, t.perms[item.name], (*stack, key), views)
1487
1778
  else:
1488
1779
  out.append(item)
1489
1780
  return out
@@ -1507,11 +1798,17 @@ class Core:
1507
1798
  conds = [x for x in item.items if isinstance(x, Cond)]
1508
1799
  if len(arrows) == 1 and len(conds) == 1:
1509
1800
  item, cond = arrows[0], conds[0].sql
1510
- if isinstance(item, Arrow) and item.rel in t.relations and len(t.relations[item.rel].subjects()) == 1:
1801
+ if (
1802
+ isinstance(item, Arrow)
1803
+ and item.rel in t.relations
1804
+ and all(not sr for _, sr in t.relations[item.rel].subjects())
1805
+ ):
1511
1806
  return (item.rel, item.perm, cond)
1512
1807
  return None
1513
1808
 
1514
1809
  def prune(self, t: Type, items: list[Expr]) -> list[Expr]:
1810
+ """items (the operands of an or) without those said twice, and without a lookup another one covers:
1811
+ parent.edit beside parent.view, when view includes edit on every type parent points at (reach)."""
1515
1812
  keep = []
1516
1813
  for i, item in enumerate(items):
1517
1814
  if item in items[:i]:
@@ -1519,12 +1816,17 @@ class Core:
1519
1816
  ki = self.lookup_key(t, item)
1520
1817
  covered = False
1521
1818
  if ki:
1522
- target = self.T(t.relations[ki[0]].subjects()[0][0])
1819
+ targets = [self.T(st) for st, _ in t.relations[ki[0]].subjects()]
1820
+
1821
+ def within(inner: str, outer: str, targets: list[Type] = targets) -> bool:
1822
+ return bool(targets) and all(inner in self.reach(target, outer) for target in targets)
1823
+
1523
1824
  for j, other in enumerate(items):
1524
1825
  kj = self.lookup_key(t, other)
1525
1826
  if j == i or not kj or kj[0] != ki[0] or kj[2] != ki[2]:
1526
1827
  continue
1527
- if ki[1] in self.reach(target, kj[1]) and not (kj[1] in self.reach(target, ki[1]) and j > i):
1828
+ # (two that include each other say the same: the first stays)
1829
+ if within(ki[1], kj[1]) and not (within(kj[1], ki[1]) and j > i):
1528
1830
  covered = True
1529
1831
  break
1530
1832
  if not covered:
@@ -1537,8 +1839,16 @@ class Core:
1537
1839
  # invoker: the SQL runs as the app role (row-level security, column rules' triggers, refusals). There
1538
1840
  # nothing reads another table itself: what the policy reads, it reads with its own rights, in views and
1539
1841
  # functions that run as the owner, so a permission means the same wherever it is checked.
1540
- def row_sql(self, t: Type, alias: str, node: Expr, loc: Loc, stack: tuple[Name, ...] = (),
1541
- point: bool = False, invoker: bool = False) -> str:
1842
+ def row_sql(
1843
+ self,
1844
+ t: Type,
1845
+ alias: str,
1846
+ node: Expr,
1847
+ loc: Loc,
1848
+ stack: tuple[Name, ...] = (),
1849
+ point: bool = False,
1850
+ invoker: bool = False,
1851
+ ) -> str:
1542
1852
  match node:
1543
1853
  case ("cond", sql):
1544
1854
  if invoker and reads_more(sql):
@@ -1551,8 +1861,14 @@ class Core:
1551
1861
  case ("and", items) | ("or", items):
1552
1862
  # cheapest first: Postgres evaluates AND and OR from the left and stops once the answer is known
1553
1863
  joiner = f"\n {node[0].upper()} "
1554
- return "(" + joiner.join(self.row_sql(t, alias, x, loc, stack, point, invoker)
1555
- for x in self.cheapest_first(t, once(items))) + ")"
1864
+ return (
1865
+ "("
1866
+ + joiner.join(
1867
+ self.row_sql(t, alias, x, loc, stack, point, invoker)
1868
+ for x in self.cheapest_first(t, once(items))
1869
+ )
1870
+ + ")"
1871
+ )
1556
1872
  case ("arrow", rel, perm_name) | ("arrow_on", rel, perm_name, _):
1557
1873
  return self.row_arrow(t, alias, node, rel, perm_name, loc, point, invoker)
1558
1874
  case ("ref", name):
@@ -1576,10 +1892,13 @@ class Core:
1576
1892
  f"-- {t.name}: a condition that reads other rows, run with the policy's rights wherever it is checked\n"
1577
1893
  f"CREATE FUNCTION {fn}(p_row {qt(t.table)}) RETURNS boolean\n"
1578
1894
  f"LANGUAGE sql STABLE SECURITY DEFINER SET search_path FROM CURRENT AS $f$\n"
1579
- f" SELECT coalesce(({row_cond(sql, bare)}), false) FROM (SELECT (p_row).*) AS {bare}\n$f$;")
1895
+ f" SELECT coalesce(({row_cond(sql, bare)}), false) FROM (SELECT (p_row).*) AS {bare}\n$f$;"
1896
+ )
1580
1897
  return f"{fn}(ROW({alias}.*)::{qt(t.table)})"
1581
1898
 
1582
- def link_checks(self, t: Type, r: Relation, names: list[str], checks: Mapping[str, str | None], obj: str) -> list[str]:
1899
+ def link_checks(
1900
+ self, t: Type, r: Relation, names: list[str], checks: Mapping[str, str | None], obj: str
1901
+ ) -> list[str]:
1583
1902
  """rel.perm through the relation's link tables and shares, for the object whose id is obj: its links, each
1584
1903
  target checked on its own (what ensure_link_view selects)."""
1585
1904
  parts = []
@@ -1592,18 +1911,23 @@ class Core:
1592
1911
  if src.kind == "table":
1593
1912
  assert src.table is not None and src.obj_col is not None
1594
1913
  extra = f" AND coalesce(({row_cond(src.where, 's')}), false)" if src.where else ""
1595
- parts.append(f"EXISTS (SELECT 1 FROM {qt(src.table)} s WHERE "
1596
- f"{self.key_is(t, 's', obj, src.obj_col)}{extra} "
1597
- f"AND {fn}({self.subject_id(src, 's', st)}))")
1914
+ parts.append(
1915
+ f"EXISTS (SELECT 1 FROM {qt(src.table)} s WHERE "
1916
+ f"{self.key_is(t, 's', obj, src.obj_col)}{extra} "
1917
+ f"AND {fn}({self.subject_id(src, 's', st)}))"
1918
+ )
1598
1919
  else:
1599
- parts.append(f"EXISTS (SELECT 1 FROM authz.shares g WHERE g.object_type = {lit(t.name)} "
1600
- f"AND g.object_id = {obj}::text AND g.relation = {lit(r.name)} "
1601
- f"AND g.subject_type = {lit(st)} AND g.subject_relation = '' AND {self.live()} "
1602
- f"AND {fn}(g.subject_id::{self.T(st).pktype}))")
1920
+ parts.append(
1921
+ f"EXISTS (SELECT 1 FROM authz.shares g WHERE g.object_type = {lit(t.name)} "
1922
+ f"AND g.object_id = {obj}::text AND g.relation = {lit(r.name)} "
1923
+ f"AND g.subject_type = {lit(st)} AND g.subject_relation = '' AND {self.live()} "
1924
+ f"AND {fn}(g.subject_id::{self.T(st).pktype}))"
1925
+ )
1603
1926
  return parts
1604
1927
 
1605
- def definer_links(self, t: Type, r: Relation, perm_name: str, names: list[str],
1606
- checks: Mapping[str, str | None], alias: str) -> str:
1928
+ def definer_links(
1929
+ self, t: Type, r: Relation, perm_name: str, names: list[str], checks: Mapping[str, str | None], alias: str
1930
+ ) -> str:
1607
1931
  """link_checks where the app role checks the row: a function that runs as the owner (the app role may read
1608
1932
  neither authz.shares nor, maybe, the link tables), on the row's id."""
1609
1933
  suffix = "" if names == [st for st, _ in r.subjects()] else "__on_" + "_".join(names)
@@ -1616,11 +1940,13 @@ class Core:
1616
1940
  f"-- {t.name}.{r.name}.{perm_name} for one object, through link tables and shares: as the owner\n"
1617
1941
  f"CREATE FUNCTION {fn}(p_id {t.pktype}) RETURNS boolean\n"
1618
1942
  f"LANGUAGE sql STABLE SECURITY DEFINER SET search_path FROM CURRENT AS $f$\n"
1619
- f" SELECT {body}\n$f$;")
1943
+ f" SELECT {body}\n$f$;"
1944
+ )
1620
1945
  return f"{fn}({self.key(t, alias)})"
1621
1946
 
1622
- def row_arrow(self, t: Type, alias: str, node: Expr, rel: str, perm_name: str, loc: Loc, point: bool,
1623
- invoker: bool = False) -> str:
1947
+ def row_arrow(
1948
+ self, t: Type, alias: str, node: Expr, rel: str, perm_name: str, loc: Loc, point: bool, invoker: bool = False
1949
+ ) -> str:
1624
1950
  """rel.perm on the row itself: its columns looked up, then the relation's other sources."""
1625
1951
  r, targets = self.targets(t, rel, loc)
1626
1952
  on = only_on(node)
@@ -1665,18 +1991,25 @@ class Core:
1665
1991
  elif sr:
1666
1992
  parts.append(self.lookup_sql(self.view_ref(self.T(st), sr, loc), sid, point))
1667
1993
  if any(src.kind != "column" for src in r.sources):
1668
- ext = any(src.kind == "column" for src in r.sources) # only split off when needed
1994
+ ext = any(src.kind == "column" for src in r.sources) # only split off when needed
1669
1995
  parts.append(self.lookup_sql(self.ensure_rel_view(t, r, loc, ext=ext), self.key(t, alias), point))
1670
1996
  if not parts:
1671
- fail(loc, f"{t.name}.{r.name} links to {r.subjects()[0][0]} objects, not users; "
1672
- f"follow it with a dot, e.g. {r.name}.view", "AZ301")
1997
+ fail(
1998
+ loc,
1999
+ f"{t.name}.{r.name} links to {r.subjects()[0][0]} objects, not users; "
2000
+ f"follow it with a dot, e.g. {r.name}.view",
2001
+ "AZ301",
2002
+ )
1673
2003
  return or_join(parts)
1674
2004
 
1675
2005
  def rule_sql(self, t: Type, alias: str, rule: Rule, point: bool = False, invoker: bool = False) -> str:
1676
2006
  """The rule on the row aliased alias, and the type's where; invoker: as the app role checks it (row_sql)."""
1677
2007
  sql = self.row_sql(t, alias, rule.expr, rule.loc, point=point, invoker=invoker)
1678
2008
  if t.where:
1679
- where = (self.definer_cond(t, t.where, alias) if invoker and reads_more(t.where)
1680
- else f"coalesce(({row_cond(t.where, alias)}), false)")
2009
+ where = (
2010
+ self.definer_cond(t, t.where, alias)
2011
+ if invoker and reads_more(t.where)
2012
+ else f"coalesce(({row_cond(t.where, alias)}), false)"
2013
+ )
1681
2014
  sql = f"({sql}\n AND {where})"
1682
2015
  return sql