pyrtan / src / pyrtan / grants.py
11,133 bytes · last changed 2026-10-28 in Agents beside the swarm: open-weights models that probe the trees, suggest, chat, remember and follow skills; release 0.15.0
| 1 | """Grants: which identities may do what on this hub. |
| 2 | |
| 3 | An identity grants nothing by itself (``_meta/identity.md`` §1): a hub |
| 4 | keeps its own list, from ``--allow`` and from its operator at ``/allow``, |
| 5 | of roots and what each may do here: ``ask`` (the routed paths behind a |
| 6 | token), ``chat`` (a seat in a lobby behind a token), ``run`` (follow as a |
| 7 | member and be handed trees) and ``operate`` (everything the token allows). |
| 8 | A device may do what its root's grant says, intersected with its note's |
| 9 | ``may``. |
| 10 | |
| 11 | A grant made by name (``alice@pyrtan.com``) resolves the name once and is |
| 12 | held against the identifier from then on: the name is the pin, and is |
| 13 | never resolved again, so a domain that later points it elsewhere moves |
| 14 | nothing here. Each grant keeps |
| 15 | the newest identity document it has seen, from a push to ``/identity``, |
| 16 | from the name's domain or from the document's own routes, re-read at |
| 17 | least hourly and on a request from a device not seen in a day; a device |
| 18 | the document revokes is refused. The list lives in a JSON file beside |
| 19 | the hub's state, or in memory without one. |
| 20 | """ |
| 21 | |
| 22 | from __future__ import annotations |
| 23 | |
| 24 | import json |
| 25 | import threading |
| 26 | import time |
| 27 | from dataclasses import dataclass, field |
| 28 | from pathlib import Path |
| 29 | from typing import Any, Callable |
| 30 | |
| 31 | from . import identity, names |
| 32 | from .identity import ABILITIES, IdentityError, Signer |
| 33 | |
| 34 | REFRESH_EVERY = 3600.0 # seconds between reads of a grant's identity document |
| 35 | DEVICE_UNSEEN = 86400.0 # a device not seen for this long makes its root's document be re-read |
| 36 | |
| 37 | # Reached with a key whatever its grants: following as a member is open, and trust is decided there. |
| 38 | OPEN = frozenset({"/inbox"}) |
| 39 | |
| 40 | |
| 41 | def ability_for(method: str, path: str) -> str | None: |
| 42 | """The ability a key-signed request needs for ``path`` on a hub with a token; None for |
| 43 | the paths anyone may reach (the public ones, the inbox, a pushed identity document).""" |
| 44 | from .service import public_path |
| 45 | if public_path(path) or path in OPEN: |
| 46 | return None |
| 47 | if path in ("/intent", "/run", "/resume") or (method == "GET" and path == "/capabilities") or (method == "POST" and path == "/suggestions"): |
| 48 | return "ask" # an agent's report is as much as an ask: its asks record gaps already |
| 49 | if path == "/chat" or path.startswith("/chat/"): |
| 50 | return "chat" |
| 51 | return "operate" |
| 52 | |
| 53 | |
| 54 | def parse_may(text: Any) -> list[str]: |
| 55 | """``"ask,run"`` (or a list) as abilities, each once, in the canonical order.""" |
| 56 | items = text if isinstance(text, list) else str(text or "").replace(" ", "").split(",") |
| 57 | items = [a for a in items if a] |
| 58 | unknown = [a for a in items if a not in ABILITIES] |
| 59 | if unknown or not items: |
| 60 | raise IdentityError(f"abilities are some of {', '.join(ABILITIES)}" + (f"; not {', '.join(unknown)}" if unknown else "")) |
| 61 | return [a for a in ABILITIES if a in items] |
| 62 | |
| 63 | |
| 64 | def parse_spec(spec: str) -> tuple[str, list[str]]: |
| 65 | """``WHO=ask,run`` as ``(who, abilities)``; WHO is ``pyr:…`` or ``name@domain``.""" |
| 66 | who, eq, may = spec.rpartition("=") |
| 67 | if not eq or not who: |
| 68 | raise IdentityError(f"{spec!r}: write WHO=ABILITIES, e.g. alice@pyrtan.com=ask,chat") |
| 69 | identity.parse(who) |
| 70 | return who, parse_may(may) |
| 71 | |
| 72 | |
| 73 | @dataclass |
| 74 | class Grant: |
| 75 | id: str |
| 76 | may: list[str] |
| 77 | name: str | None = None |
| 78 | doc: dict[str, Any] | None = None |
| 79 | fetched: float = 0.0 |
| 80 | granted: float = field(default_factory=time.time) |
| 81 | |
| 82 | def view(self) -> dict[str, Any]: |
| 83 | return {"id": self.id, "name": self.name, "may": self.may, "granted": self.granted, |
| 84 | "document": {"updated": self.doc["updated"], "routes": self.doc["routes"], "devices": len(self.doc["devices"]), "revoked": len(self.doc["revoked"])} if self.doc else None} |
| 85 | |
| 86 | def to_state(self) -> dict[str, Any]: |
| 87 | return {"id": self.id, "may": self.may, "name": self.name, "doc": self.doc, "fetched": self.fetched, "granted": self.granted} |
| 88 | |
| 89 | |
| 90 | class Grants: |
| 91 | def __init__(self, path: str | Path | None = None, *, fetch: names.Fetch | None = None, |
| 92 | on_change: Callable[[str], None] | None = None) -> None: |
| 93 | self.path = Path(path) if path else None |
| 94 | self.fetch = fetch |
| 95 | self.on_change = on_change # called with a root whose grant or document changed |
| 96 | self.grants: dict[str, Grant] = {} |
| 97 | self.seen: dict[str, float] = {} # device -> when it last signed here |
| 98 | self._lock = threading.RLock() |
| 99 | if self.path and self.path.exists(): |
| 100 | self._load() |
| 101 | |
| 102 | # ------------------------------------------------------------ the list |
| 103 | |
| 104 | def __len__(self) -> int: |
| 105 | return len(self.grants) |
| 106 | |
| 107 | def by_name(self, name: str) -> Grant | None: |
| 108 | key = str(identity.parse(name)) |
| 109 | return next((g for g in self.grants.values() if g.name == key), None) |
| 110 | |
| 111 | def allow(self, who: str, may: list[str]) -> Grant: |
| 112 | """Grant ``who`` (``pyr:…`` or a name, resolved and pinned now) the abilities ``may``.""" |
| 113 | may = parse_may(may) |
| 114 | parsed = identity.parse(who) |
| 115 | if parsed.form == "name": |
| 116 | held = self.by_name(who) |
| 117 | claim = names.resolve(who, fetch=self.fetch) if held is None else None |
| 118 | ident = held.id if held else claim["id"] |
| 119 | name = str(parsed) |
| 120 | else: |
| 121 | ident, name, claim = parsed.id, None, None |
| 122 | with self._lock: |
| 123 | grant = self.grants.get(ident) |
| 124 | if grant is None: |
| 125 | grant = self.grants[ident] = Grant(ident, may, name) |
| 126 | else: |
| 127 | grant.may = may |
| 128 | grant.name = grant.name or name |
| 129 | self._save() |
| 130 | if claim is not None and grant.doc is None: |
| 131 | self.refresh_one(ident) |
| 132 | self._changed(ident) |
| 133 | return grant |
| 134 | |
| 135 | def disallow(self, who: str) -> bool: |
| 136 | parsed = identity.parse(who) |
| 137 | grant = self.by_name(who) if parsed.form == "name" else self.grants.get(parsed.id) |
| 138 | if grant is None: |
| 139 | return False |
| 140 | with self._lock: |
| 141 | self.grants.pop(grant.id, None) |
| 142 | self._save() |
| 143 | self._changed(grant.id) |
| 144 | return True |
| 145 | |
| 146 | def view(self) -> list[dict[str, Any]]: |
| 147 | with self._lock: |
| 148 | return [g.view() for g in sorted(self.grants.values(), key=lambda g: (g.name or "~", g.id))] |
| 149 | |
| 150 | # ------------------------------------------------------------ callers |
| 151 | |
| 152 | def may(self, signer: Signer, *, now: float | None = None) -> set[str]: |
| 153 | """What ``signer`` may do here: its root's grant, capped by the device note, and |
| 154 | nothing for a device its root's document revokes. Notes a device's visit.""" |
| 155 | now = time.time() if now is None else now |
| 156 | grant = self.grants.get(signer.root) |
| 157 | if grant is None: |
| 158 | return set() |
| 159 | if signer.note is None: |
| 160 | return set(grant.may) |
| 161 | if grant.doc is not None and identity.device_status(grant.doc, signer.key, now=now) == "revoked": |
| 162 | return set() |
| 163 | last = self.seen.get(signer.key) |
| 164 | self.seen[signer.key] = now |
| 165 | if last is None or now - last > DEVICE_UNSEEN: |
| 166 | threading.Thread(target=self.refresh_one, args=(signer.root,), daemon=True).start() |
| 167 | return set(grant.may) & set(signer.note["may"]) |
| 168 | |
| 169 | def still(self, root: str, device: str, note: dict[str, Any] | None, ability: str, *, now: float | None = None) -> bool: |
| 170 | """Whether a member that followed as ``device`` for ``root`` may still do ``ability``.""" |
| 171 | if note is not None and identity.check_device(note, root=root, now=now) is not None: |
| 172 | return False |
| 173 | return ability in self.may(Signer(device, root, note), now=now) |
| 174 | |
| 175 | # ------------------------------------------------------------ documents |
| 176 | |
| 177 | def document(self, ident: str) -> dict[str, Any] | None: |
| 178 | grant = self.grants.get(ident.lower()) |
| 179 | return grant.doc if grant else None |
| 180 | |
| 181 | def take_document(self, doc: Any, *, now: float | None = None) -> str | None: |
| 182 | """Keep ``doc`` if it is a granted root's and newer than ours; None, or why not.""" |
| 183 | if not isinstance(doc, dict) or not isinstance(doc.get("id"), str): |
| 184 | return "not an identity document" |
| 185 | grant = self.grants.get(doc["id"].lower()) |
| 186 | if grant is None: |
| 187 | return "this hub grants that identity nothing, so keeps no document for it" |
| 188 | problem = identity.check_identity(doc, ident=grant.id, now=now) |
| 189 | if problem: |
| 190 | return problem |
| 191 | with self._lock: |
| 192 | if grant.doc is not None and doc["updated"] <= grant.doc["updated"]: |
| 193 | return None |
| 194 | grant.doc = doc |
| 195 | self._save() |
| 196 | self._changed(grant.id) |
| 197 | return None |
| 198 | |
| 199 | def refresh_one(self, ident: str) -> None: |
| 200 | """Look for a newer document where the grant's name and routes say it lives.""" |
| 201 | grant = self.grants.get(ident) |
| 202 | if grant is None: |
| 203 | return |
| 204 | hosts = list(grant.doc["routes"]) if grant.doc else [] |
| 205 | if grant.name: |
| 206 | hosts.insert(0, identity.parse(grant.name).domain) |
| 207 | grant.fetched = time.time() |
| 208 | for host in dict.fromkeys(hosts): |
| 209 | try: |
| 210 | doc = names.fetch_identity(ident, host, fetch=self.fetch) |
| 211 | except IdentityError: |
| 212 | continue # an onion route, for now |
| 213 | if doc is not None: |
| 214 | self.take_document(doc) |
| 215 | |
| 216 | def refresh(self, *, now: float | None = None) -> None: |
| 217 | now = time.time() if now is None else now |
| 218 | for ident in [g.id for g in list(self.grants.values()) if now - g.fetched >= REFRESH_EVERY]: |
| 219 | self.refresh_one(ident) |
| 220 | |
| 221 | # ------------------------------------------------------------ state |
| 222 | |
| 223 | def _changed(self, ident: str) -> None: |
| 224 | if self.on_change: |
| 225 | self.on_change(ident) |
| 226 | |
| 227 | def _save(self) -> None: |
| 228 | if not self.path: |
| 229 | return |
| 230 | with self._lock: |
| 231 | data = {"pyrtan-grants": 1, "grants": [g.to_state() for g in self.grants.values()]} |
| 232 | self.path.parent.mkdir(parents=True, exist_ok=True) |
| 233 | temp = self.path.with_suffix(self.path.suffix + ".tmp") |
| 234 | temp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") |
| 235 | temp.replace(self.path) |
| 236 | |
| 237 | def _load(self) -> None: |
| 238 | assert self.path |
| 239 | try: |
| 240 | data = json.loads(self.path.read_text(encoding="utf-8")) |
| 241 | except (OSError, json.JSONDecodeError) as e: |
| 242 | raise IdentityError(f"{self.path}: {e}") from None |
| 243 | if not isinstance(data, dict) or data.get("pyrtan-grants") != 1: |
| 244 | raise IdentityError(f"{self.path} is not a pyrtan grants file") |
| 245 | for d in data.get("grants", []): |
| 246 | try: |
| 247 | ident = identity.normal(d["id"]) |
| 248 | doc = d.get("doc") if identity.check_identity(d.get("doc"), ident=ident) is None else None |
| 249 | self.grants[ident] = Grant(ident, parse_may(d.get("may")), d.get("name"), doc, float(d.get("fetched", 0)), float(d.get("granted", 0))) |
| 250 | except (IdentityError, KeyError, TypeError, ValueError): |
| 251 | continue |