rowstile 0.1.0-alpha.2 → 0.1.0-alpha.3

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.
@@ -10,7 +10,7 @@ class Compiler(TestMixin, DevMixin, OutputMixin):
10
10
  """A policy compiled: each part of the output is one of its bases (OutputMixin assembles them)."""
11
11
 
12
12
 
13
- __version__ = "0.1.0-alpha.2" # set with packaging/version.py (the packages copy it); authz.policy_versions records it on each apply
13
+ __version__ = "0.1.0-alpha.3" # set with packaging/version.py (the packages copy it); authz.policy_versions records it on each apply
14
14
 
15
15
 
16
16
  def _build() -> str:
@@ -182,4 +182,5 @@ GENERATED_FUNCTIONS = ("act_as", "can", "list", "perms", "perms_of", "share", "u
182
182
  "create_api_key", "list_api_keys", "revoke_api_key", "login_key", "login_jwt", "view_as",
183
183
  "sync_members", "changes_since", "trim_changes", "trim_audit", "request_access", "pending_requests", "decide_request",
184
184
  "cancel_request", "break_glass", "start_review", "review_items", "review_decide",
185
- "close_review", "check_invariants", "lint", "connection_check", "explain_rule", "who_among")
185
+ "close_review", "check_invariants", "lint", "connection_check", "explain_rule", "who_among",
186
+ "list_links", "revoke_link")
@@ -135,7 +135,7 @@ def once(items: Sequence[Expr]) -> list[Expr]:
135
135
  # for one read grows with every permission named more than once on the way (Core.expansions counts them).
136
136
  VIEW_BODY = re.compile(r'CREATE VIEW authz_int\.("(?:[^"]|"")+"|\w+) AS\n(.*?);(?=\nCREATE VIEW authz_gen|\Z)', re.S)
137
137
  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
138
+ # the policies here stay under 25; from about 70, planning a read can take a tenth of a second and more
139
139
  MANY_EXPANSIONS = 50
140
140
 
141
141
 
@@ -1278,7 +1278,11 @@ class Core:
1278
1278
  header = f"-- {t.name}.{perm.name} ({perm.loc}): {perm.src}\n"
1279
1279
  key = self.recursive.get((t.name, perm.name))
1280
1280
  if key is None:
1281
- sql = self.set_sql(t, perm.expr, perm.loc)
1281
+ # as the row checks do (row_sql): the permissions of its type written out in place, so a lookup
1282
+ # another one covers is dropped. view names parent.view and, through edit, parent.edit, which
1283
+ # parent.view includes: named as views, each would be written out again at every level above
1284
+ items = self.prune(t, self.flat_items(t, perm, views=True))
1285
+ sql = union([self.set_sql(t, i, perm.loc) for i in items])
1282
1286
  else:
1283
1287
  members = self.scc_members[key]
1284
1288
  edges = self.scc_edges(key)
@@ -1476,14 +1480,17 @@ class Core:
1476
1480
  return f"SELECT {rid} AS id FROM {tbl} r\n WHERE " + "\n AND ".join(where)
1477
1481
 
1478
1482
  # --- policies: flatten same-type permissions, drop redundant lookups ---
1479
- def flat_items(self, t: Type, perm: Perm, stack: tuple[Name, ...] = ()) -> list[Expr]:
1483
+ def flat_items(self, t: Type, perm: Perm, stack: tuple[Name, ...] = (), views: bool = False) -> list[Expr]:
1484
+ """t.perm's operands, with the permissions of its type it names written out in place. views: for the
1485
+ permission's view, where one that inherits stays a name (its view walks a tree, which its operands
1486
+ written out here would not)."""
1480
1487
  key = (t.name, perm.name)
1481
1488
  if key in stack:
1482
1489
  fail(perm.loc, f"{t.name}.{perm.name} depends on itself", "AZ302")
1483
1490
  out = []
1484
1491
  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))
1492
+ if isinstance(item, Ref) and item.name in t.perms and not (views and (t.name, item.name) in self.recursive):
1493
+ out += self.flat_items(t, t.perms[item.name], (*stack, key), views)
1487
1494
  else:
1488
1495
  out.append(item)
1489
1496
  return out
@@ -1507,11 +1514,14 @@ class Core:
1507
1514
  conds = [x for x in item.items if isinstance(x, Cond)]
1508
1515
  if len(arrows) == 1 and len(conds) == 1:
1509
1516
  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:
1517
+ if (isinstance(item, Arrow) and item.rel in t.relations
1518
+ and all(not sr for _, sr in t.relations[item.rel].subjects())):
1511
1519
  return (item.rel, item.perm, cond)
1512
1520
  return None
1513
1521
 
1514
1522
  def prune(self, t: Type, items: list[Expr]) -> list[Expr]:
1523
+ """items (the operands of an or) without those said twice, and without a lookup another one covers:
1524
+ parent.edit beside parent.view, when view includes edit on every type parent points at (reach)."""
1515
1525
  keep = []
1516
1526
  for i, item in enumerate(items):
1517
1527
  if item in items[:i]:
@@ -1519,12 +1529,17 @@ class Core:
1519
1529
  ki = self.lookup_key(t, item)
1520
1530
  covered = False
1521
1531
  if ki:
1522
- target = self.T(t.relations[ki[0]].subjects()[0][0])
1532
+ targets = [self.T(st) for st, _ in t.relations[ki[0]].subjects()]
1533
+
1534
+ def within(inner: str, outer: str, targets: list[Type] = targets) -> bool:
1535
+ return bool(targets) and all(inner in self.reach(target, outer) for target in targets)
1536
+
1523
1537
  for j, other in enumerate(items):
1524
1538
  kj = self.lookup_key(t, other)
1525
1539
  if j == i or not kj or kj[0] != ki[0] or kj[2] != ki[2]:
1526
1540
  continue
1527
- if ki[1] in self.reach(target, kj[1]) and not (kj[1] in self.reach(target, ki[1]) and j > i):
1541
+ # (two that include each other say the same: the first stays)
1542
+ if within(ki[1], kj[1]) and not (within(kj[1], ki[1]) and j > i):
1528
1543
  covered = True
1529
1544
  break
1530
1545
  if not covered:
@@ -252,6 +252,13 @@ export function authz(db: Queryable) {{
252
252
  /** A share with anyone holding the returned token (shown once; only its hash is stored). */
253
253
  createLink: async <T extends SharedType>(type: T, id: Id, relation: SharedRelation<T>, expiresAt?: Date): Promise<string> =>
254
254
  (await db.query("SELECT authz.create_link($1, $2::text, $3, $4) AS token", [type, s(id), relation, expiresAt ?? null])).rows[0].token,
255
+ /** The links on an object (id, relation, created_by, created_at, expires_at), for those who can share it or may
256
+ * make such links. */
257
+ listLinks: async <T extends ObjectType>(type: T, id: Id): Promise<any[]> =>
258
+ (await db.query("SELECT * FROM authz.list_links($1, $2::text)", [type, s(id)])).rows,
259
+ /** Turns a link off, by its id in listLinks (not its token). */
260
+ revokeLink: async <T extends ObjectType>(type: T, id: Id, linkId: string) =>
261
+ db.query("SELECT authz.revoke_link($1, $2::text, $3)", [type, s(id), linkId]),
255
262
  /** Link tokens the request presents, for this transaction. */
256
263
  useLinks: async (tokens: string[]) => db.query("SELECT set_config('authz_ctx.links', $1, true)", [tokens.join(",")]),
257
264
  cancelRequest: async (requestId: Id) => db.query("SELECT authz.cancel_request($1::bigint)", [s(requestId)]),
@@ -537,6 +544,15 @@ class Authz:
537
544
  raise ValueError(f"{{type_}}.{{relation}} is not shared in the policy")
538
545
  return str(self._one("SELECT authz.create_link(%s, %s::text, %s, %s)", (type_, _id(id_), relation, expires_at)))
539
546
 
547
+ def list_links(self, type_: ObjectType, id_: Id) -> Sequence[Row]:
548
+ """The links on an object (id, relation, created_by, created_at, expires_at), for those who can share it
549
+ or may make such links."""
550
+ return self._rows("SELECT * FROM authz.list_links(%s, %s::text)", (type_, _id(id_)))
551
+
552
+ def revoke_link(self, type_: ObjectType, id_: Id, link_id: str) -> None:
553
+ """Turns a link off, by its id in list_links (not its token)."""
554
+ self._one("SELECT authz.revoke_link(%s, %s::text, %s)", (type_, _id(id_), link_id))
555
+
540
556
  def use_links(self, tokens: Sequence[str]) -> None:
541
557
  """Link tokens the request presents, for this transaction (SET LOCAL authz_ctx.links)."""
542
558
  self._one("SELECT set_config('authz_ctx.links', %s, true)", (",".join(tokens),))
@@ -553,8 +553,9 @@ CODES = {
553
553
  "AZ705": Code(
554
554
  "Not allowed",
555
555
  "The signed-in person asked an `authz.*` function for something the policy doesn't let them do: share or "
556
- "unshare this object, give a role, manage roles or keys, see who has access or the shares, decide a request "
557
- "or a review, break the glass. The message says what; `authz.explain(type, id, perm)` says why. Answer 403.",
556
+ "unshare this object, give a role, manage roles or keys, see who has access, the shares or the links, turn "
557
+ "a link off, decide a request or a review, break the glass. The message says what; "
558
+ "`authz.explain(type, id, perm)` says why. Answer 403.",
558
559
  when="runtime"),
559
560
  "AZ706": Code(
560
561
  "The policy doesn't allow this share",
@@ -571,7 +572,7 @@ CODES = {
571
572
  "AZ708": Code(
572
573
  "No such thing",
573
574
  "The call names something that isn't there: the subject to share with, an active user to view as, a "
574
- "pending request, an item of a review, an API key of yours. Answer 404.",
575
+ "pending request, an item of a review, an API key of yours, a link on the object. Answer 404.",
575
576
  when="runtime"),
576
577
  "AZ709": Code(
577
578
  "A write refused by a rule",
@@ -80,6 +80,7 @@ SELECT p.oid::regprocedure::text, gw.who, a.privilege_type,
80
80
  "authz.unshare(text, bigint, text, text, bigint, text)",
81
81
  "authz.unshare(text, bigint, text, text, text, text)",
82
82
  "authz.create_link(text, text, text, timestamptz)", "authz.create_link(text, bigint, text, timestamptz)",
83
+ "authz.list_links(text, text)", "authz.revoke_link(text, text, text)",
83
84
  "authz.create_role(text, text, text, text, text[])", "authz.create_role(text, bigint, text, text, text[])",
84
85
  "authz.set_role_permissions(bigint, text[])", "authz.delete_role(bigint)", "authz.roles_of(text, text)",
85
86
  "authz.roles_of(text, bigint)",
@@ -97,7 +98,8 @@ SELECT p.oid::regprocedure::text, gw.who, a.privilege_type,
97
98
  "authz.review_items(bigint)",
98
99
  "authz.review_decide(bigint, integer, boolean)", "authz.close_review(bigint, boolean)"]
99
100
  executes += [f"authz.{fn}(text, {pt}{args})" for pt in sorted({t.pktype for t in typed})
100
- for fn, args in (("who", ", text"), ("explain", ", text, text"), ("list_shares", ""))]
101
+ for fn, args in (("who", ", text"), ("explain", ", text, text"), ("list_shares", ""), ("list_links", ""),
102
+ ("revoke_link", ", text"))]
101
103
  executes += [f"authz.can(text, {pt}, text)" for pt in sorted({t.pktype for t in typed})]
102
104
  executes += [f"authz.who_among(text, {pt}, text, text[], text)" for pt in sorted({t.pktype for t in typed})]
103
105
  executes += [f"authz.perms(text, {pt})" for pt in sorted({t.pktype for t in typed})]
@@ -28,6 +28,8 @@ DEFINER = "SECURITY DEFINER SET search_path = pg_catalog, pg_temp"
28
28
  # for functions that run the policy's own SQL: the path applying vetted (search_path_sql), as views and policies resolve it
29
29
  DEFINER_FROM_CURRENT = "SECURITY DEFINER SET search_path FROM CURRENT"
30
30
  PT = "pg_catalog.current_setting('authz.principal_type', true)" # the signed-in principal's type ('': a user)
31
+ # a link's id, from its share g: the start of its token's hash (enough to name it among an object's links)
32
+ LINK_ID = "left(g.subject_id, 16)"
31
33
 
32
34
 
33
35
  def subject_key(st: str, sr: str | None) -> str:
@@ -395,6 +397,13 @@ END $f$;
395
397
  CREATE TRIGGER authz_role_gone AFTER DELETE ON authz.roles
396
398
  REFERENCING OLD TABLE AS old_rows FOR EACH STATEMENT EXECUTE FUNCTION authz_int.role_gone();"""
397
399
 
400
+ link_columns = "id text, relation text, created_by text, created_at timestamptz, expires_at timestamptz"
401
+ link_overloads = "\n".join(
402
+ f"CREATE FUNCTION authz.list_links(p_type text, p_id {pt}) RETURNS TABLE ({link_columns}) LANGUAGE sql STABLE AS\n"
403
+ f" $$ SELECT * FROM authz.list_links(p_type, p_id::text) $$;\n"
404
+ f"CREATE FUNCTION authz.revoke_link(p_type text, p_id {pt}, p_link text) RETURNS void LANGUAGE sql AS\n"
405
+ f" $$ SELECT authz.revoke_link(p_type, p_id::text, p_link) $$;"
406
+ for pt in sorted({t.pktype for t in self.types.values()} - {"text"}))
398
407
  api = f"""-- Share: needs the relation's 'shared by' permission on the object, and every permission
399
408
  -- the relation grants (you can't grant more than you hold); the subject must exist
400
409
  CREATE FUNCTION authz.share(p_type text, p_id text, p_relation text,
@@ -552,6 +561,63 @@ END $f$;
552
561
  CREATE FUNCTION authz.create_link(p_type text, p_id bigint, p_relation text, p_expires_at timestamptz DEFAULT NULL)
553
562
  RETURNS text LANGUAGE sql AS $$ SELECT authz.create_link(p_type, p_id::text, p_relation, p_expires_at) $$;
554
563
 
564
+ -- The relations whose links on an object the caller may see: every one it has links of if they may inspect it
565
+ -- (they can share it, or are an administrator), else those they may share with a link. NULL: none
566
+ CREATE FUNCTION authz_int.link_relations(p_type text, p_id text) RETURNS text[]
567
+ LANGUAGE plpgsql STABLE {DEFINER} AS $f$
568
+ BEGIN
569
+ IF authz_int.may_inspect(p_type, p_id) THEN
570
+ RETURN coalesce((SELECT array_agg(DISTINCT g.relation) FROM authz.shares g
571
+ WHERE g.object_type = p_type AND g.object_id = p_id AND g.subject_type = 'link'), '{{}}');
572
+ END IF;
573
+ RETURN (SELECT array_agg(s.relation) FROM authz_int.shared_relations s
574
+ WHERE s.object_type = p_type AND s.subject = 'link'
575
+ AND authz_int.may_manage(p_type, p_id, s.relation, 'link'));
576
+ END $f$;
577
+ -- The links on an object: SELECT * FROM authz.list_links('folder', 3). For those who can share it, or may make
578
+ -- such links. A link's id names it for authz.revoke_link: it is not the token, and opens nothing.
579
+ CREATE FUNCTION authz.list_links(p_type text, p_id text)
580
+ RETURNS TABLE (id text, relation text, created_by text, created_at timestamptz, expires_at timestamptz)
581
+ LANGUAGE plpgsql STABLE {DEFINER} AS $f$
582
+ DECLARE v_relations text[];
583
+ BEGIN
584
+ p_id := authz_int.canon(p_type, p_id);
585
+ v_relations := authz_int.link_relations(p_type, p_id);
586
+ IF v_relations IS NULL THEN
587
+ RAISE EXCEPTION 'you cannot see the links of % %', p_type, p_id USING ERRCODE = 'insufficient_privilege', HINT = 'rowstile help AZ705';
588
+ END IF;
589
+ RETURN QUERY SELECT {LINK_ID}, g.relation, g.created_by, g.created_at, g.expires_at
590
+ FROM authz.shares g WHERE g.object_type = p_type AND g.object_id = p_id AND g.subject_type = 'link'
591
+ AND g.relation = ANY (v_relations) ORDER BY g.created_at, g.subject_id;
592
+ END $f$;
593
+ -- Turn a link off, by its id in authz.list_links: needs what unsharing it needs
594
+ CREATE FUNCTION authz.revoke_link(p_type text, p_id text, p_link text) RETURNS void
595
+ LANGUAGE plpgsql {DEFINER} AS $f$
596
+ DECLARE v_relations text[]; v record; v_found boolean := false;
597
+ BEGIN
598
+ PERFORM authz_int.check_writable();
599
+ p_id := authz_int.canon(p_type, p_id);
600
+ v_relations := authz_int.link_relations(p_type, p_id);
601
+ IF v_relations IS NULL THEN
602
+ RAISE EXCEPTION 'you cannot turn off the links of % %', p_type, p_id USING ERRCODE = 'insufficient_privilege', HINT = 'rowstile help AZ705';
603
+ END IF;
604
+ FOR v IN SELECT g.relation, g.subject_id FROM authz.shares g
605
+ WHERE g.object_type = p_type AND g.object_id = p_id AND g.subject_type = 'link'
606
+ AND g.relation = ANY (v_relations) AND {LINK_ID} = p_link LOOP
607
+ IF NOT authz_int.may_manage(p_type, p_id, v.relation, 'link') THEN
608
+ RAISE EXCEPTION 'you cannot turn off link % of % % (you could not unshare it)', p_link, p_type, p_id
609
+ USING ERRCODE = 'insufficient_privilege', HINT = 'rowstile help AZ705';
610
+ END IF;
611
+ DELETE FROM authz.shares g WHERE g.object_type = p_type AND g.object_id = p_id AND g.relation = v.relation
612
+ AND g.subject_type = 'link' AND g.subject_id = v.subject_id;
613
+ v_found := true;
614
+ END LOOP;
615
+ IF NOT v_found THEN
616
+ RAISE EXCEPTION 'no link % on % %', p_link, p_type, p_id USING HINT = 'rowstile help AZ708';
617
+ END IF;
618
+ END $f$;
619
+ {link_overloads}
620
+
555
621
  -- Custom roles: defined per owner (e.g. an org) by people with 'manage_roles' there
556
622
  CREATE FUNCTION authz.create_role(p_owner_type text, p_owner_id text, p_object_type text, p_name text,
557
623
  p_permissions text[]) RETURNS bigint LANGUAGE plpgsql {DEFINER} AS $f$
@@ -604,6 +604,7 @@ def _tls(sock: socket.socket, host: str, sslmode: str, sslrootcert: str | None)
604
604
  context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
605
605
  context.check_hostname = False
606
606
  context.verify_mode = ssl.CERT_NONE # encrypted, the server not checked: what require means
607
+ context.minimum_version = ssl.TLSVersion.TLSv1_2 # Python's default since 3.10, said for code scanners
607
608
  try:
608
609
  return context.wrap_socket(sock, server_hostname=host)
609
610
  except ssl.SSLError as e:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rowstile",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.3",
4
4
  "description": "Access rules for Postgres: the rowstile command (policies compiled to row-level security, shipped as migrations), with its own Python",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -20,12 +20,12 @@
20
20
  "node": ">=18"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@rowstile/cli-linux-x64": "0.1.0-alpha.2",
24
- "@rowstile/cli-linux-arm64": "0.1.0-alpha.2",
25
- "@rowstile/cli-linux-x64-musl": "0.1.0-alpha.2",
26
- "@rowstile/cli-linux-arm64-musl": "0.1.0-alpha.2",
27
- "@rowstile/cli-darwin-x64": "0.1.0-alpha.2",
28
- "@rowstile/cli-darwin-arm64": "0.1.0-alpha.2",
29
- "@rowstile/cli-win32-x64": "0.1.0-alpha.2"
23
+ "@rowstile/cli-linux-x64": "0.1.0-alpha.3",
24
+ "@rowstile/cli-linux-arm64": "0.1.0-alpha.3",
25
+ "@rowstile/cli-linux-x64-musl": "0.1.0-alpha.3",
26
+ "@rowstile/cli-linux-arm64-musl": "0.1.0-alpha.3",
27
+ "@rowstile/cli-darwin-x64": "0.1.0-alpha.3",
28
+ "@rowstile/cli-darwin-arm64": "0.1.0-alpha.3",
29
+ "@rowstile/cli-win32-x64": "0.1.0-alpha.3"
30
30
  }
31
31
  }