mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
fix(six): send the body /v1/authz/check reads, and stop stamping instants nobody set
policy.check was built from the route's OpenAPI description, which is stale.
The handler cloud pins — hanzoai/authz v1.10.37, serve/use.go — binds
{subject, verb, path} and answers {allow, subject, verb, path}; a body of
{sub, obj, act} gets 400 "subject, verb, path required". go-sdk and js-sdk both
send the served spelling. The caller-facing surface is unchanged: check(sub,
act, obj) -> Decision{allow, sub, act, obj, reason}.
graph left an unset instant on the body as "". Cloud reads an absent `seen` as
`at` and an absent `as_of` as now, but reads "" as a timestamp it refuses:
POST /v1/graph/ingest with at:"" answers 400 timestamp "" is not RFC 3339. An
instant nobody set is now left off.
kb.Edge takes PEP 8's keyword escape, from_ and to, rather than renaming the
pair to source and target — the word is `from`, which is what the other two
spell it. Document names and connector providers are escaped into their path
segment, as they already are in Go and TypeScript.
Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+22
-8
@@ -48,18 +48,22 @@ class Fact:
|
||||
confidence: float = 0.0
|
||||
|
||||
def write(self) -> Dict[str, Any]:
|
||||
"""This fact as cloud reads it."""
|
||||
return {
|
||||
"""This fact as cloud reads it.
|
||||
|
||||
An instant nobody set is left out rather than sent as ``""``: cloud
|
||||
reads an absent `seen` as `at`, and an empty string as a timestamp that
|
||||
is not RFC 3339.
|
||||
"""
|
||||
out: Dict[str, Any] = {
|
||||
"entity": self.entity,
|
||||
"relation": self.relation,
|
||||
"value": self.value,
|
||||
"names": self.names,
|
||||
"at": wire.stamp(self.at),
|
||||
"seen": wire.stamp(self.seen),
|
||||
"source": self.source,
|
||||
"evidence": self.evidence,
|
||||
"confidence": self.confidence,
|
||||
}
|
||||
return {**out, **_when(at=self.at, seen=self.seen)}
|
||||
|
||||
@classmethod
|
||||
def read(cls, body: Any) -> "Fact":
|
||||
@@ -260,12 +264,12 @@ class Graph:
|
||||
return tuple(Fact.read(f) for f in wire.rows(body, "assertions"))
|
||||
|
||||
def resolve(self, entity: str, relation: str, at: Optional[datetime] = None) -> Resolution:
|
||||
"""Which claim about `entity`'s `relation` wins, as of `at`."""
|
||||
"""Which claim about `entity`'s `relation` wins, as of `at`. No `at` asks about now."""
|
||||
return Resolution.read(
|
||||
self.client.read(
|
||||
"POST",
|
||||
"/v1/graph/resolve",
|
||||
body={"entity": entity, "relation": relation, "as_of": wire.stamp(at)},
|
||||
body={"entity": entity, "relation": relation, **_when(as_of=at)},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -291,7 +295,7 @@ class Graph:
|
||||
"relation": relation,
|
||||
"direction": direction,
|
||||
"depth": depth,
|
||||
"as_of": wire.stamp(at),
|
||||
**_when(as_of=at),
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -327,4 +331,14 @@ class Graph:
|
||||
|
||||
def _source(text: str, source: str, subject: str, at: Optional[datetime]) -> Dict[str, Any]:
|
||||
"""The body both readers of a document take."""
|
||||
return {"text": text, "source": source, "subject": subject, "at": wire.stamp(at)}
|
||||
return {"text": text, "source": source, "subject": subject, **_when(at=at)}
|
||||
|
||||
|
||||
def _when(**instants: Optional[datetime]) -> Dict[str, str]:
|
||||
"""The instants that were set, RFC 3339.
|
||||
|
||||
An instant nobody set is left off the body. Cloud reads an absent one as its
|
||||
own default — now, or `at` for a `seen` — and an empty string as a timestamp
|
||||
it refuses.
|
||||
"""
|
||||
return {name: wire.stamp(t) for name, t in instants.items() if t is not None}
|
||||
|
||||
+21
-10
@@ -17,6 +17,7 @@ import json
|
||||
from typing import TYPE_CHECKING, Any, Dict, Tuple, Optional
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import quote
|
||||
|
||||
from hanzoai import answer
|
||||
from hanzoai.page import Page
|
||||
@@ -186,15 +187,20 @@ class Node:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Edge:
|
||||
"""A link between two documents: a parent, a wikilink, a provenance edge."""
|
||||
"""A link between two documents: a parent, a wikilink, a provenance edge.
|
||||
|
||||
source: str = ""
|
||||
target: str = ""
|
||||
The trailing underscore on `from_` is PEP 8's escape for a keyword, the same
|
||||
one :meth:`hanzoai.Client.as_` and :meth:`hanzoai.graph.Graph.assert_` take.
|
||||
The word is still ``from``, which is what Go and TypeScript spell it.
|
||||
"""
|
||||
|
||||
from_: str = ""
|
||||
to: str = ""
|
||||
kind: str = ""
|
||||
|
||||
@classmethod
|
||||
def read(cls, body: Any) -> "Edge":
|
||||
return cls(source=text(body, "from"), target=text(body, "to"), kind=text(body, "kind"))
|
||||
return cls(from_=text(body, "from"), to=text(body, "to"), kind=text(body, "kind"))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -228,7 +234,7 @@ class Kb:
|
||||
"""Write a document. A name that already exists is replaced; absent, one is created."""
|
||||
path = _path(doc.kind)
|
||||
reply: Reply = (
|
||||
self.client.send("PUT", path + "/" + doc.name, body=doc.write())
|
||||
self.client.send("PUT", path + "/" + _seg(doc.name), body=doc.write())
|
||||
if doc.name
|
||||
else self.client.send("POST", path, body=doc.write())
|
||||
)
|
||||
@@ -236,7 +242,7 @@ class Kb:
|
||||
|
||||
def get(self, kind: str, name: str) -> Doc:
|
||||
"""One document by name."""
|
||||
return Doc.read(self.client.read("GET", _path(kind) + "/" + name), kind)
|
||||
return Doc.read(self.client.read("GET", _path(kind) + "/" + _seg(name)), kind)
|
||||
|
||||
def list(self, kind: str, *, project: Optional[str] = None, limit: Optional[int] = None) -> Page[Doc]:
|
||||
"""The org's documents of one kind, newest-updated first.
|
||||
@@ -254,7 +260,7 @@ class Kb:
|
||||
|
||||
def drop(self, kind: str, name: str) -> Answer[None]:
|
||||
"""Remove one document."""
|
||||
return answer.read(self.client.send("DELETE", _path(kind) + "/" + name), lambda _: None)
|
||||
return answer.read(self.client.send("DELETE", _path(kind) + "/" + _seg(name)), lambda _: None)
|
||||
|
||||
def import_(self, export: bytes, *, format: str, project: Optional[str] = None) -> Answer[Import]:
|
||||
"""File an exported vault as a tree of pages, links intact.
|
||||
@@ -283,15 +289,15 @@ class Kb:
|
||||
|
||||
def connect(self, provider: str) -> Link:
|
||||
"""Where to send a person to authorize `provider`."""
|
||||
return Link.read(self.client.read("GET", "/v1/knowledge/connectors/" + provider + "/connect"))
|
||||
return Link.read(self.client.read("GET", "/v1/knowledge/connectors/" + _seg(provider) + "/connect"))
|
||||
|
||||
def sync(self, provider: str) -> Answer[Sync]:
|
||||
"""Pull what `provider` has now."""
|
||||
return answer.read(self.client.send("POST", "/v1/knowledge/connectors/" + provider + "/sync"), Sync.read)
|
||||
return answer.read(self.client.send("POST", "/v1/knowledge/connectors/" + _seg(provider) + "/sync"), Sync.read)
|
||||
|
||||
def revoke(self, provider: str) -> Answer[None]:
|
||||
"""Disconnect `provider`."""
|
||||
return answer.read(self.client.send("DELETE", "/v1/knowledge/connectors/" + provider), lambda _: None)
|
||||
return answer.read(self.client.send("DELETE", "/v1/knowledge/connectors/" + _seg(provider)), lambda _: None)
|
||||
|
||||
def links(self) -> Links:
|
||||
"""The corpus's own parent, wikilink and provenance edges."""
|
||||
@@ -311,3 +317,8 @@ def _path(kind: str) -> str:
|
||||
if kind not in KINDS:
|
||||
raise ValueError("kind is one of {0}, not {1!r}".format(", ".join(KINDS), kind))
|
||||
return "/v1/framework/kb." + kind
|
||||
|
||||
|
||||
def _seg(name: str) -> str:
|
||||
"""One path segment. A name with a slash in it addresses one document, not two."""
|
||||
return quote(name, safe="")
|
||||
|
||||
@@ -39,9 +39,9 @@ class Decision:
|
||||
def read(cls, body: Any, sub: str, act: str, obj: str) -> "Decision":
|
||||
return cls(
|
||||
allow=flag(body, "allow"),
|
||||
sub=text(body, "sub") or sub,
|
||||
act=text(body, "act") or act,
|
||||
obj=text(body, "obj") or obj,
|
||||
sub=text(body, "subject") or sub,
|
||||
act=text(body, "verb") or act,
|
||||
obj=text(body, "path") or obj,
|
||||
reason=text(body, "reason"),
|
||||
)
|
||||
|
||||
@@ -56,12 +56,13 @@ class Policy:
|
||||
"""Ask whether `sub` may `act` on `obj`. All three are required.
|
||||
|
||||
The arguments read in the order the sentence does — subject, verb,
|
||||
object. Cloud's body orders its members ``{sub, obj, act}``; the request
|
||||
is built that way here, once, so no caller has to hold both orders.
|
||||
object. The route reads ``{subject, verb, path}`` and answers
|
||||
``{allow, subject, verb, path}``; the two spellings meet here, once, so
|
||||
no caller has to hold both.
|
||||
|
||||
Hand-written rather than generated: the route declares neither a request
|
||||
body nor a response, so every generator emits a method that takes
|
||||
nothing and returns nothing.
|
||||
"""
|
||||
body = self.client.read("POST", "/v1/authz/check", body={"sub": sub, "obj": obj, "act": act})
|
||||
body = self.client.read("POST", "/v1/authz/check", body={"subject": sub, "verb": act, "path": obj})
|
||||
return Decision.read(body, sub, act, obj)
|
||||
|
||||
+12
-13
@@ -160,27 +160,27 @@ def test_spent_pages_the_charged_ledger():
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_check_takes_the_sentence_order_and_sends_cloud_s_member_order():
|
||||
"""A person says subject, verb, object. Cloud's body says `{sub, obj, act}`.
|
||||
def test_check_takes_the_sentence_order_and_sends_the_route_s_member_names():
|
||||
"""A person says subject, verb, object. The route reads `{subject, verb, path}`.
|
||||
|
||||
The difference is spelled once, here, and nowhere a caller can see it.
|
||||
"""
|
||||
calls = Calls(ok({"allow": True, "sub": "usr_7", "obj": "graph:acme", "act": "write"}))
|
||||
decision = Policy(calls).check("usr_7", "write", "graph:acme")
|
||||
calls = Calls(ok({"allow": True, "subject": "usr_7", "path": "acme/graph", "verb": "write"}))
|
||||
decision = Policy(calls).check("usr_7", "write", "acme/graph")
|
||||
|
||||
assert calls.asked_once.body == {"sub": "usr_7", "obj": "graph:acme", "act": "write"}
|
||||
assert calls.asked_once.body == {"subject": "usr_7", "verb": "write", "path": "acme/graph"}
|
||||
assert decision.allow is True
|
||||
assert (decision.sub, decision.act, decision.obj) == ("usr_7", "write", "graph:acme")
|
||||
assert (decision.sub, decision.act, decision.obj) == ("usr_7", "write", "acme/graph")
|
||||
|
||||
|
||||
def test_a_denial_is_a_verdict_not_a_refusal():
|
||||
"""Asking whether you may is a question with an answer. `allow` is False, and that is all."""
|
||||
decision = Policy(Calls(ok({"allow": False, "reason": "no grant at or above graph:acme"}))).check(
|
||||
"usr_7", "write", "graph:acme"
|
||||
decision = Policy(Calls(ok({"allow": False, "reason": "no grant at or above acme/graph"}))).check(
|
||||
"usr_7", "write", "acme/graph"
|
||||
)
|
||||
assert decision.allow is False
|
||||
assert decision.reason == "no grant at or above graph:acme"
|
||||
assert decision.obj == "graph:acme", "the question survives even when cloud echoes nothing"
|
||||
assert decision.reason == "no grant at or above acme/graph"
|
||||
assert decision.obj == "acme/graph", "the question survives even when cloud echoes nothing"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -470,7 +470,7 @@ def test_the_corpus_map_states_its_own_incompleteness():
|
||||
|
||||
assert links.partial is True
|
||||
assert links.nodes[0].name == "runbook"
|
||||
assert (links.edges[0].source, links.edges[0].target, links.edges[0].kind) == (
|
||||
assert (links.edges[0].from_, links.edges[0].to, links.edges[0].kind) == (
|
||||
"kb.page/runbook",
|
||||
"kb.page/ops",
|
||||
"parent",
|
||||
@@ -619,8 +619,7 @@ def test_a_walk_follows_edges_in_the_direction_it_was_given():
|
||||
"relation": "owns",
|
||||
"direction": "both",
|
||||
"depth": 2,
|
||||
"as_of": "",
|
||||
}
|
||||
}, "an instant nobody set is left off the body, not sent as an empty timestamp"
|
||||
assert walk.entities == ("acme", "acme-eu")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user