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.
- package/command/authzlib/__init__.py +4 -1
- package/command/authzlib/base.py +46 -6
- package/command/authzlib/compiler.py +598 -265
- package/command/authzlib/conditions.py +17 -8
- package/command/authzlib/connection.py +1 -0
- package/command/authzlib/coverage.py +12 -5
- package/command/authzlib/database.py +333 -124
- package/command/authzlib/devtools.py +31 -8
- package/command/authzlib/draft.py +235 -62
- package/command/authzlib/errors.py +205 -104
- package/command/authzlib/evaluate.py +92 -44
- package/command/authzlib/fmt.py +33 -16
- package/command/authzlib/governance.py +105 -50
- package/command/authzlib/grant.py +100 -52
- package/command/authzlib/hardening.py +122 -52
- package/command/authzlib/identity.py +22 -15
- package/command/authzlib/insight.py +161 -86
- package/command/authzlib/migrate.py +193 -102
- package/command/authzlib/output.py +441 -195
- package/command/authzlib/parse.py +249 -98
- package/command/authzlib/perf.py +78 -32
- package/command/authzlib/prove.py +23 -11
- package/command/authzlib/refusals.py +107 -48
- package/command/authzlib/review.py +361 -139
- package/command/authzlib/sqlutil.py +196 -33
- package/command/authzlib/statements.py +26 -19
- package/command/authzlib/testing.py +98 -45
- package/command/authzlib/trees.py +427 -275
- package/command/cli/init.py +27 -8
- package/command/cli/lsp.py +140 -51
- package/command/cli/mcp.py +107 -45
- package/command/cli/migrations.py +74 -21
- package/command/cli/pgwire.py +161 -66
- package/command/cli/rowstile_cli.py +351 -105
- package/command/cli/stack.py +67 -30
- package/command/cli/studio.py +258 -84
- 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
|
|
78
|
+
next: str # the table built beside
|
|
77
79
|
next_name: str
|
|
78
|
-
table: str
|
|
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
|
|
81
|
-
catch_up: str
|
|
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
|
|
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
|
|
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 (
|
|
179
|
-
|
|
180
|
-
|
|
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(
|
|
186
|
-
|
|
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] = {}
|
|
190
|
-
self.view_sql: list[str] = []
|
|
191
|
-
self.trees: dict[TreeKey, str] = {}
|
|
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] = []
|
|
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] = {}
|
|
199
|
-
self.nested: dict[str, str] = {}
|
|
200
|
-
self.point_checks: dict[str, str] = {}
|
|
201
|
-
self.direct_checks: dict[str, str] = {}
|
|
202
|
-
self.direct_sql: dict[str, str] = {}
|
|
203
|
-
self.locked_types: set[str] = set()
|
|
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]] = []
|
|
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] = {}
|
|
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]] = {}
|
|
210
|
-
self._invalid = "NULL"
|
|
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 = ""
|
|
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(
|
|
219
|
-
|
|
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(
|
|
257
|
-
|
|
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(
|
|
261
|
-
|
|
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(
|
|
266
|
-
|
|
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(
|
|
271
|
-
|
|
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)
|
|
305
|
+
self.role_owner_type(t) # refuses a relation that can't name an owner
|
|
276
306
|
if not named:
|
|
277
|
-
fail(
|
|
278
|
-
|
|
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(
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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(
|
|
298
|
-
|
|
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(
|
|
302
|
-
|
|
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 (
|
|
312
|
-
|
|
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(
|
|
322
|
-
|
|
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(
|
|
326
|
-
|
|
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(
|
|
371
|
-
|
|
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
|
|
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 = [
|
|
377
|
-
|
|
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(
|
|
385
|
-
|
|
386
|
-
|
|
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 = {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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(
|
|
404
|
-
|
|
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
|
|
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(
|
|
427
|
-
|
|
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
|
|
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(
|
|
436
|
-
|
|
437
|
-
|
|
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(
|
|
440
|
-
|
|
441
|
-
|
|
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(
|
|
446
|
-
|
|
447
|
-
|
|
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(
|
|
463
|
-
|
|
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(
|
|
480
|
-
|
|
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(
|
|
483
|
-
|
|
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(
|
|
490
|
-
|
|
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(
|
|
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(
|
|
507
|
-
|
|
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 += [
|
|
542
|
-
|
|
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(
|
|
548
|
-
|
|
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(
|
|
552
|
-
|
|
553
|
-
|
|
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(
|
|
563
|
-
|
|
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(
|
|
566
|
-
|
|
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(
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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(
|
|
580
|
-
|
|
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(
|
|
585
|
-
|
|
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(
|
|
607
|
-
|
|
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(
|
|
615
|
-
|
|
616
|
-
|
|
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
|
|
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))
|
|
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(
|
|
667
|
-
|
|
668
|
-
|
|
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
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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(
|
|
786
|
-
|
|
787
|
-
|
|
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(
|
|
790
|
-
|
|
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(
|
|
800
|
-
|
|
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)
|
|
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(
|
|
844
|
-
|
|
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 = (
|
|
872
|
-
|
|
873
|
-
|
|
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
|
|
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(
|
|
885
|
-
|
|
886
|
-
|
|
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 (
|
|
907
|
-
|
|
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(
|
|
930
|
-
|
|
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 (
|
|
935
|
-
|
|
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(
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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 (
|
|
977
|
-
|
|
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
|
|
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 (
|
|
995
|
-
|
|
996
|
-
|
|
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(
|
|
1013
|
-
|
|
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
|
|
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 (
|
|
1069
|
-
|
|
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 (
|
|
1073
|
-
|
|
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 = (
|
|
1077
|
-
|
|
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 = (
|
|
1087
|
-
|
|
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 (
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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 (
|
|
1116
|
-
|
|
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 (
|
|
1121
|
-
|
|
1122
|
-
|
|
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 (
|
|
1126
|
-
|
|
1127
|
-
|
|
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(
|
|
1159
|
-
|
|
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 = (
|
|
1170
|
-
|
|
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 =
|
|
1173
|
-
|
|
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 =
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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(
|
|
1200
|
-
|
|
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(
|
|
1218
|
-
|
|
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(
|
|
1223
|
-
|
|
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(
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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 = (
|
|
1306
|
-
|
|
1307
|
-
|
|
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 = (
|
|
1316
|
-
|
|
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 = (
|
|
1319
|
-
|
|
1320
|
-
|
|
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 = (
|
|
1332
|
-
|
|
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
|
|
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
|
|
1391
|
-
|
|
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 (
|
|
1448
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1541
|
-
|
|
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
|
|
1555
|
-
|
|
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(
|
|
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(
|
|
1596
|
-
|
|
1597
|
-
|
|
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(
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
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(
|
|
1606
|
-
|
|
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(
|
|
1623
|
-
|
|
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)
|
|
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(
|
|
1672
|
-
|
|
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 = (
|
|
1680
|
-
|
|
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
|