decern 0.1.1__tar.gz
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.
- decern-0.1.1/.gitignore +3 -0
- decern-0.1.1/PKG-INFO +58 -0
- decern-0.1.1/README.md +50 -0
- decern-0.1.1/decern/__init__.py +7 -0
- decern-0.1.1/decern/client.py +84 -0
- decern-0.1.1/pyproject.toml +16 -0
- decern-0.1.1/tests/__init__.py +1 -0
- decern-0.1.1/tests/test_client.py +107 -0
decern-0.1.1/.gitignore
ADDED
decern-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: decern
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Dependency-free Python client for the decern PDP (AuthZEN 1.0 Access Evaluation).
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# decern Python SDK
|
|
10
|
+
|
|
11
|
+
Dependency-free Python client for the decern PDP, speaking
|
|
12
|
+
[AuthZEN 1.0](https://openid.net/specs/authorization-api-1_0.html) Access Evaluation.
|
|
13
|
+
Standard library only — no `requests`/`httpx`.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pip install .
|
|
19
|
+
# or
|
|
20
|
+
uv pip install .
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
Start the PDP:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
decern-serve
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from decern import Client
|
|
35
|
+
|
|
36
|
+
c = Client("http://127.0.0.1:8080")
|
|
37
|
+
|
|
38
|
+
d = c.evaluate(
|
|
39
|
+
subject={"type": "Principal", "id": "corp"},
|
|
40
|
+
action="Read", # or {"name": "Read"}
|
|
41
|
+
resource={"type": "Resource", "id": "claim1"},
|
|
42
|
+
context={"now": 100}, # optional; PDP injects `now` if omitted
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
print(d.allowed) # True / False
|
|
46
|
+
print(d.reasons) # e.g. ["policy0"] on allow
|
|
47
|
+
print(d.errors) # e.g. ["no_policy"] on deny
|
|
48
|
+
|
|
49
|
+
c.pubkey() # ed25519 public key id (hex)
|
|
50
|
+
c.healthy() # True if /healthz == "ok"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Test
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
cd sdks/python
|
|
57
|
+
python -m unittest discover -v
|
|
58
|
+
```
|
decern-0.1.1/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# decern Python SDK
|
|
2
|
+
|
|
3
|
+
Dependency-free Python client for the decern PDP, speaking
|
|
4
|
+
[AuthZEN 1.0](https://openid.net/specs/authorization-api-1_0.html) Access Evaluation.
|
|
5
|
+
Standard library only — no `requests`/`httpx`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pip install .
|
|
11
|
+
# or
|
|
12
|
+
uv pip install .
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
Start the PDP:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
decern-serve
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from decern import Client
|
|
27
|
+
|
|
28
|
+
c = Client("http://127.0.0.1:8080")
|
|
29
|
+
|
|
30
|
+
d = c.evaluate(
|
|
31
|
+
subject={"type": "Principal", "id": "corp"},
|
|
32
|
+
action="Read", # or {"name": "Read"}
|
|
33
|
+
resource={"type": "Resource", "id": "claim1"},
|
|
34
|
+
context={"now": 100}, # optional; PDP injects `now` if omitted
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
print(d.allowed) # True / False
|
|
38
|
+
print(d.reasons) # e.g. ["policy0"] on allow
|
|
39
|
+
print(d.errors) # e.g. ["no_policy"] on deny
|
|
40
|
+
|
|
41
|
+
c.pubkey() # ed25519 public key id (hex)
|
|
42
|
+
c.healthy() # True if /healthz == "ok"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Test
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
cd sdks/python
|
|
49
|
+
python -m unittest discover -v
|
|
50
|
+
```
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""AuthZEN 1.0 Access Evaluation client for the decern PDP."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Dict, List, Optional, Union
|
|
11
|
+
|
|
12
|
+
Entity = Dict[str, Any] # {"type": ..., "id": ...}
|
|
13
|
+
Action = Union[str, Dict[str, Any]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DecernError(Exception):
|
|
17
|
+
"""Transport failure or non-2xx response from the PDP."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Decision:
|
|
22
|
+
allowed: bool
|
|
23
|
+
reasons: List[str] = field(default_factory=list)
|
|
24
|
+
errors: List[str] = field(default_factory=list)
|
|
25
|
+
context: Optional[Dict[str, Any]] = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Client:
|
|
29
|
+
"""Client for a decern PDP speaking AuthZEN 1.0 Access Evaluation."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, base_url: str = "http://127.0.0.1:8080", timeout: float = 5.0):
|
|
32
|
+
self.base_url = base_url.rstrip("/")
|
|
33
|
+
self.timeout = timeout
|
|
34
|
+
|
|
35
|
+
# --- HTTP ---------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def _request(self, method: str, path: str, body: Optional[dict] = None) -> Any:
|
|
38
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
39
|
+
headers = {"Content-Type": "application/json"} if data else {}
|
|
40
|
+
req = urllib.request.Request(
|
|
41
|
+
self.base_url + path, data=data, headers=headers, method=method
|
|
42
|
+
)
|
|
43
|
+
try:
|
|
44
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
45
|
+
raw = resp.read().decode()
|
|
46
|
+
except urllib.error.HTTPError as e:
|
|
47
|
+
raise DecernError(f"{method} {path} -> {e.code} {e.reason}") from e
|
|
48
|
+
except urllib.error.URLError as e:
|
|
49
|
+
raise DecernError(f"{method} {path} failed: {e.reason}") from e
|
|
50
|
+
return raw
|
|
51
|
+
|
|
52
|
+
# --- API ----------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def evaluate(
|
|
55
|
+
self,
|
|
56
|
+
subject: Entity,
|
|
57
|
+
action: Action,
|
|
58
|
+
resource: Entity,
|
|
59
|
+
context: Optional[Dict[str, Any]] = None,
|
|
60
|
+
) -> Decision:
|
|
61
|
+
act = {"name": action} if isinstance(action, str) else action
|
|
62
|
+
body: Dict[str, Any] = {"subject": subject, "action": act, "resource": resource}
|
|
63
|
+
if context is not None:
|
|
64
|
+
body["context"] = context
|
|
65
|
+
raw = self._request("POST", "/access/v1/evaluation", body)
|
|
66
|
+
resp = json.loads(raw)
|
|
67
|
+
ctx = resp.get("context") or None
|
|
68
|
+
reasons = list(ctx.get("reasons", [])) if ctx else []
|
|
69
|
+
errors = list(ctx.get("errors", [])) if ctx else []
|
|
70
|
+
return Decision(
|
|
71
|
+
allowed=bool(resp.get("decision", False)),
|
|
72
|
+
reasons=reasons,
|
|
73
|
+
errors=errors,
|
|
74
|
+
context=ctx,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def pubkey(self) -> str:
|
|
78
|
+
return json.loads(self._request("GET", "/pubkey"))["kid"]
|
|
79
|
+
|
|
80
|
+
def healthy(self) -> bool:
|
|
81
|
+
try:
|
|
82
|
+
return self._request("GET", "/healthz").strip() == "ok"
|
|
83
|
+
except DecernError:
|
|
84
|
+
return False
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
[build-system]
|
|
3
|
+
requires = ["hatchling"]
|
|
4
|
+
build-backend = "hatchling.build"
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "decern"
|
|
8
|
+
version = "0.1.1"
|
|
9
|
+
description = "Dependency-free Python client for the decern PDP (AuthZEN 1.0 Access Evaluation)."
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
license = "Apache-2.0"
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[tool.hatch.build.targets.wheel]
|
|
16
|
+
packages = ["decern"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Tests for the decern client. No network/server required."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import unittest
|
|
6
|
+
from unittest import mock
|
|
7
|
+
|
|
8
|
+
from decern import Client, Decision, DecernError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FakeTransport:
|
|
12
|
+
"""Records requests and returns a queued raw response string."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, raw="{}"):
|
|
15
|
+
self.raw = raw
|
|
16
|
+
self.calls = []
|
|
17
|
+
|
|
18
|
+
def __call__(self, method, path, body=None):
|
|
19
|
+
self.calls.append((method, path, body))
|
|
20
|
+
return self.raw
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def client_with(raw="{}"):
|
|
24
|
+
c = Client()
|
|
25
|
+
t = FakeTransport(raw)
|
|
26
|
+
c._request = t # inject fake transport
|
|
27
|
+
return c, t
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestEvaluateBody(unittest.TestCase):
|
|
31
|
+
def test_builds_exact_authzen_body(self):
|
|
32
|
+
c, t = client_with('{"decision": true}')
|
|
33
|
+
c.evaluate(
|
|
34
|
+
{"type": "Principal", "id": "corp"},
|
|
35
|
+
"Read",
|
|
36
|
+
{"type": "Resource", "id": "claim1"},
|
|
37
|
+
{"now": 100},
|
|
38
|
+
)
|
|
39
|
+
method, path, body = t.calls[0]
|
|
40
|
+
self.assertEqual(method, "POST")
|
|
41
|
+
self.assertEqual(path, "/access/v1/evaluation")
|
|
42
|
+
self.assertEqual(
|
|
43
|
+
body,
|
|
44
|
+
{
|
|
45
|
+
"subject": {"type": "Principal", "id": "corp"},
|
|
46
|
+
"action": {"name": "Read"},
|
|
47
|
+
"resource": {"type": "Resource", "id": "claim1"},
|
|
48
|
+
"context": {"now": 100},
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def test_action_dict_passthrough_and_context_omitted(self):
|
|
53
|
+
c, t = client_with('{"decision": false}')
|
|
54
|
+
c.evaluate(
|
|
55
|
+
{"type": "Principal", "id": "corp"},
|
|
56
|
+
{"name": "Write", "extra": 1},
|
|
57
|
+
{"type": "Resource", "id": "r"},
|
|
58
|
+
)
|
|
59
|
+
_, _, body = t.calls[0]
|
|
60
|
+
self.assertEqual(body["action"], {"name": "Write", "extra": 1})
|
|
61
|
+
self.assertNotIn("context", body)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestResponseParsing(unittest.TestCase):
|
|
65
|
+
def test_allow_with_reasons(self):
|
|
66
|
+
c, _ = client_with(json.dumps({"decision": True, "context": {"reasons": ["policy0"]}}))
|
|
67
|
+
d = c.evaluate({"id": "a"}, "Read", {"id": "b"})
|
|
68
|
+
self.assertIsInstance(d, Decision)
|
|
69
|
+
self.assertTrue(d.allowed)
|
|
70
|
+
self.assertEqual(d.reasons, ["policy0"])
|
|
71
|
+
self.assertEqual(d.errors, [])
|
|
72
|
+
|
|
73
|
+
def test_deny_with_errors(self):
|
|
74
|
+
c, _ = client_with(json.dumps({"decision": False, "context": {"errors": ["no_policy"]}}))
|
|
75
|
+
d = c.evaluate({"id": "a"}, "Read", {"id": "b"})
|
|
76
|
+
self.assertFalse(d.allowed)
|
|
77
|
+
self.assertEqual(d.errors, ["no_policy"])
|
|
78
|
+
self.assertEqual(d.reasons, [])
|
|
79
|
+
|
|
80
|
+
def test_bare_decision(self):
|
|
81
|
+
c, _ = client_with('{"decision": true}')
|
|
82
|
+
d = c.evaluate({"id": "a"}, "Read", {"id": "b"})
|
|
83
|
+
self.assertTrue(d.allowed)
|
|
84
|
+
self.assertIsNone(d.context)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TestPubkeyHealth(unittest.TestCase):
|
|
88
|
+
def test_pubkey(self):
|
|
89
|
+
c, _ = client_with('{"kid": "deadbeef"}')
|
|
90
|
+
self.assertEqual(c.pubkey(), "deadbeef")
|
|
91
|
+
|
|
92
|
+
def test_healthy_ok(self):
|
|
93
|
+
c, _ = client_with("ok")
|
|
94
|
+
self.assertTrue(c.healthy())
|
|
95
|
+
|
|
96
|
+
def test_healthy_bad_body(self):
|
|
97
|
+
c, _ = client_with("nope")
|
|
98
|
+
self.assertFalse(c.healthy())
|
|
99
|
+
|
|
100
|
+
def test_healthy_transport_error(self):
|
|
101
|
+
c = Client()
|
|
102
|
+
c._request = mock.Mock(side_effect=DecernError("boom"))
|
|
103
|
+
self.assertFalse(c.healthy())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
unittest.main()
|