vengtoo 1.0.0__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.
vengtoo-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Vengtoo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
vengtoo-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: vengtoo
3
+ Version: 1.0.0
4
+ Summary: Vengtoo Python SDK — authorization client for Vengtoo Cloud and the Vengtoo Agent
5
+ Author-email: Vengtoo <hello@vengtoo.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://vengtoo.com
8
+ Project-URL: Documentation, https://docs.vengtoo.com
9
+ Project-URL: Repository, https://github.com/vengtoo/vengtoo-python
10
+ Project-URL: Issues, https://github.com/vengtoo/vengtoo-python/issues
11
+ Keywords: authorization,vengtoo,rbac,abac,access-control,ai-agents
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: httpx>=0.25.0
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest; extra == "dev"
18
+ Requires-Dist: pytest-asyncio; extra == "dev"
19
+ Requires-Dist: respx; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ # Vengtoo Python SDK
23
+
24
+ Python client for [Vengtoo](https://vengtoo.com) — works with both Vengtoo Cloud and the Vengtoo Agent.
25
+
26
+ Supports sync and async. Requires Python 3.9+.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install vengtoo
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ### Cloud Mode
37
+
38
+ ```python
39
+ from vengtoo import Vengtoo, Subject, Resource
40
+
41
+ client = Vengtoo(api_key="azx_...")
42
+
43
+ allowed = client.check(
44
+ subject=Subject(id="user:123", type="user", roles=["editor"]),
45
+ action="read",
46
+ resource=Resource(type="document", id="doc:456"),
47
+ )
48
+ ```
49
+
50
+ ### OAuth2 Client Credentials
51
+
52
+ For service-to-service auth, pass `client_id` and `client_secret` (secret is prefixed `azx_cs_`). The SDK exchanges credentials at the token endpoint, caches the JWT in memory, and refreshes ~60s before expiry. Both sync and async calls share the same cache.
53
+
54
+ ```python
55
+ client = Vengtoo(
56
+ client_id="my-client-id",
57
+ client_secret="azx_cs_...",
58
+ )
59
+ ```
60
+
61
+ Equivalent curl for the underlying token exchange:
62
+
63
+ ```bash
64
+ curl -X POST https://api.vengtoo.com/identity-srv/v1/oauth/token \
65
+ -d grant_type=client_credentials \
66
+ -d client_id=my-client-id \
67
+ -d client_secret=azx_cs_...
68
+ ```
69
+
70
+ Providing both `api_key` and OAuth credentials is rejected at construction. A bad `client_id` / `client_secret` surfaces as `VengtooOAuthError` (distinct from `VengtooError`) with a message pointing you at the OAuth exchange.
71
+
72
+ ### Agent Mode (local)
73
+
74
+ ```python
75
+ client = Vengtoo(base_url="http://localhost:8181")
76
+ ```
77
+
78
+ ### Full Evaluate Response
79
+
80
+ ```python
81
+ from vengtoo import AuthorizeRequest, Action
82
+
83
+ resp = client.authorize(AuthorizeRequest(
84
+ subject=Subject(id="user:123", type="user"),
85
+ resource=Resource(type="document", id="doc:456"),
86
+ action=Action(name="read"),
87
+ context={"ip": "10.0.0.1"},
88
+ ))
89
+ # resp.decision, resp.context.reason, resp.context.policy_id, resp.context.access_path
90
+ ```
91
+
92
+ ### Async
93
+
94
+ ```python
95
+ allowed = await client.async_check(
96
+ subject=Subject(id="user:123", type="user"),
97
+ action="read",
98
+ resource=Resource(type="document", id="doc:456"),
99
+ )
100
+
101
+ resp = await client.async_authorize(request)
102
+ ```
103
+
104
+ ### FastAPI Dependency
105
+
106
+ ```python
107
+ from fastapi import FastAPI, Depends
108
+
109
+ app = FastAPI()
110
+ vengtoo = Vengtoo(api_key="azx_...")
111
+
112
+ @app.get("/documents/{id}")
113
+ async def get_doc(id: str, _=Depends(vengtoo.require("document", "read"))):
114
+ return {"id": id}
115
+ ```
116
+
117
+ The `require()` dependency extracts subject ID from the `X-User-ID` header by default. Customize with:
118
+
119
+ ```python
120
+ vengtoo.require("document", "read", subject_header="authorization-user-id")
121
+ ```
122
+
123
+ ### Options
124
+
125
+ ```python
126
+ Vengtoo(
127
+ api_key="azx_...", # API key for cloud mode
128
+ base_url="http://localhost:8181", # Custom URL (agent mode)
129
+ timeout=5.0, # Request timeout in seconds (default: 10)
130
+ )
131
+ ```
132
+
133
+ ## Types
134
+
135
+ | Type | Fields |
136
+ | ------------------- | --------------------------------------------------- |
137
+ | `Subject` | `id`, `type`, `attributes`, `properties`, `roles` |
138
+ | `Resource` | `id`, `type`, `attributes`, `properties` |
139
+ | `Action` | `name`, `properties` |
140
+ | `AuthorizeRequest` | `subject`, `resource`, `action`, `context` |
141
+ | `AuthorizeContext` | `reason`, `reason_code`, `policy_id`, `access_path` |
142
+ | `AuthorizeResponse` | `decision`, `context` |
@@ -0,0 +1,121 @@
1
+ # Vengtoo Python SDK
2
+
3
+ Python client for [Vengtoo](https://vengtoo.com) — works with both Vengtoo Cloud and the Vengtoo Agent.
4
+
5
+ Supports sync and async. Requires Python 3.9+.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install vengtoo
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ### Cloud Mode
16
+
17
+ ```python
18
+ from vengtoo import Vengtoo, Subject, Resource
19
+
20
+ client = Vengtoo(api_key="azx_...")
21
+
22
+ allowed = client.check(
23
+ subject=Subject(id="user:123", type="user", roles=["editor"]),
24
+ action="read",
25
+ resource=Resource(type="document", id="doc:456"),
26
+ )
27
+ ```
28
+
29
+ ### OAuth2 Client Credentials
30
+
31
+ For service-to-service auth, pass `client_id` and `client_secret` (secret is prefixed `azx_cs_`). The SDK exchanges credentials at the token endpoint, caches the JWT in memory, and refreshes ~60s before expiry. Both sync and async calls share the same cache.
32
+
33
+ ```python
34
+ client = Vengtoo(
35
+ client_id="my-client-id",
36
+ client_secret="azx_cs_...",
37
+ )
38
+ ```
39
+
40
+ Equivalent curl for the underlying token exchange:
41
+
42
+ ```bash
43
+ curl -X POST https://api.vengtoo.com/identity-srv/v1/oauth/token \
44
+ -d grant_type=client_credentials \
45
+ -d client_id=my-client-id \
46
+ -d client_secret=azx_cs_...
47
+ ```
48
+
49
+ Providing both `api_key` and OAuth credentials is rejected at construction. A bad `client_id` / `client_secret` surfaces as `VengtooOAuthError` (distinct from `VengtooError`) with a message pointing you at the OAuth exchange.
50
+
51
+ ### Agent Mode (local)
52
+
53
+ ```python
54
+ client = Vengtoo(base_url="http://localhost:8181")
55
+ ```
56
+
57
+ ### Full Evaluate Response
58
+
59
+ ```python
60
+ from vengtoo import AuthorizeRequest, Action
61
+
62
+ resp = client.authorize(AuthorizeRequest(
63
+ subject=Subject(id="user:123", type="user"),
64
+ resource=Resource(type="document", id="doc:456"),
65
+ action=Action(name="read"),
66
+ context={"ip": "10.0.0.1"},
67
+ ))
68
+ # resp.decision, resp.context.reason, resp.context.policy_id, resp.context.access_path
69
+ ```
70
+
71
+ ### Async
72
+
73
+ ```python
74
+ allowed = await client.async_check(
75
+ subject=Subject(id="user:123", type="user"),
76
+ action="read",
77
+ resource=Resource(type="document", id="doc:456"),
78
+ )
79
+
80
+ resp = await client.async_authorize(request)
81
+ ```
82
+
83
+ ### FastAPI Dependency
84
+
85
+ ```python
86
+ from fastapi import FastAPI, Depends
87
+
88
+ app = FastAPI()
89
+ vengtoo = Vengtoo(api_key="azx_...")
90
+
91
+ @app.get("/documents/{id}")
92
+ async def get_doc(id: str, _=Depends(vengtoo.require("document", "read"))):
93
+ return {"id": id}
94
+ ```
95
+
96
+ The `require()` dependency extracts subject ID from the `X-User-ID` header by default. Customize with:
97
+
98
+ ```python
99
+ vengtoo.require("document", "read", subject_header="authorization-user-id")
100
+ ```
101
+
102
+ ### Options
103
+
104
+ ```python
105
+ Vengtoo(
106
+ api_key="azx_...", # API key for cloud mode
107
+ base_url="http://localhost:8181", # Custom URL (agent mode)
108
+ timeout=5.0, # Request timeout in seconds (default: 10)
109
+ )
110
+ ```
111
+
112
+ ## Types
113
+
114
+ | Type | Fields |
115
+ | ------------------- | --------------------------------------------------- |
116
+ | `Subject` | `id`, `type`, `attributes`, `properties`, `roles` |
117
+ | `Resource` | `id`, `type`, `attributes`, `properties` |
118
+ | `Action` | `name`, `properties` |
119
+ | `AuthorizeRequest` | `subject`, `resource`, `action`, `context` |
120
+ | `AuthorizeContext` | `reason`, `reason_code`, `policy_id`, `access_path` |
121
+ | `AuthorizeResponse` | `decision`, `context` |
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "vengtoo"
7
+ version = "1.0.0"
8
+ description = "Vengtoo Python SDK — authorization client for Vengtoo Cloud and the Vengtoo Agent"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "MIT"}
12
+ keywords = ["authorization", "vengtoo", "rbac", "abac", "access-control", "ai-agents"]
13
+ authors = [{name = "Vengtoo", email = "hello@vengtoo.com"}]
14
+ dependencies = ["httpx>=0.25.0"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://vengtoo.com"
18
+ Documentation = "https://docs.vengtoo.com"
19
+ Repository = "https://github.com/vengtoo/vengtoo-python"
20
+ Issues = "https://github.com/vengtoo/vengtoo-python/issues"
21
+
22
+ [project.optional-dependencies]
23
+ dev = ["pytest", "pytest-asyncio", "respx"]
24
+
25
+ [tool.setuptools.packages.find]
26
+ include = ["vengtoo*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,150 @@
1
+ import json
2
+ from http.server import HTTPServer, BaseHTTPRequestHandler
3
+ import threading
4
+
5
+ import pytest
6
+
7
+ from vengtoo import Vengtoo, Subject, Resource, Action, AuthorizeRequest, VengtooError
8
+
9
+
10
+ class MockHandler(BaseHTTPRequestHandler):
11
+ response_data = {"decision": True, "context": {"reason": "ok"}}
12
+ status_code = 200
13
+ call_count = 0
14
+
15
+ def do_POST(self):
16
+ MockHandler.call_count += 1
17
+ content_length = int(self.headers.get("Content-Length", 0))
18
+ self.rfile.read(content_length)
19
+
20
+ self.send_response(self.status_code)
21
+ self.send_header("Content-Type", "application/json")
22
+ self.end_headers()
23
+ self.wfile.write(json.dumps(self.response_data).encode())
24
+
25
+ def log_message(self, format, *args):
26
+ pass # suppress logs
27
+
28
+
29
+ @pytest.fixture
30
+ def mock_server():
31
+ MockHandler.call_count = 0
32
+ MockHandler.status_code = 200
33
+ MockHandler.response_data = {"decision": True, "context": {"reason": "ok"}}
34
+
35
+ server = HTTPServer(("127.0.0.1", 0), MockHandler)
36
+ port = server.server_address[1]
37
+ thread = threading.Thread(target=server.serve_forever)
38
+ thread.daemon = True
39
+ thread.start()
40
+ yield f"http://127.0.0.1:{port}"
41
+ server.shutdown()
42
+
43
+
44
+ def test_check_allowed(mock_server):
45
+ MockHandler.response_data = {"decision": True, "context": {"reason": "role_match"}}
46
+ client = Vengtoo(api_key="test-key", base_url=mock_server)
47
+ allowed = client.check(
48
+ subject=Subject(id="user-1"),
49
+ action="read",
50
+ resource=Resource(id="doc-1"),
51
+ )
52
+ assert allowed is True
53
+
54
+
55
+ def test_check_denied(mock_server):
56
+ MockHandler.response_data = {"decision": False, "context": {"reason": "no policy"}}
57
+ client = Vengtoo(api_key="test-key", base_url=mock_server)
58
+ allowed = client.check(
59
+ subject=Subject(id="user-1"),
60
+ action="delete",
61
+ resource=Resource(id="doc-1"),
62
+ )
63
+ assert allowed is False
64
+
65
+
66
+ def test_authorize_full_response(mock_server):
67
+ MockHandler.response_data = {
68
+ "decision": True,
69
+ "context": {
70
+ "reason": "direct_access",
71
+ "policy_id": "pol-123",
72
+ "access_path": "direct",
73
+ },
74
+ }
75
+ client = Vengtoo(api_key="test-key", base_url=mock_server)
76
+ resp = client.authorize(AuthorizeRequest(
77
+ subject=Subject(id="user-1"),
78
+ resource=Resource(id="doc-1"),
79
+ action=Action(name="read"),
80
+ ))
81
+ assert resp.decision is True
82
+ assert resp.context is not None
83
+ assert resp.context.policy_id == "pol-123"
84
+ assert resp.context.access_path == "direct"
85
+
86
+
87
+ def test_auth_error(mock_server):
88
+ MockHandler.status_code = 401
89
+ MockHandler.response_data = "invalid key"
90
+ client = Vengtoo(api_key="bad-key", base_url=mock_server)
91
+ with pytest.raises(VengtooError) as exc_info:
92
+ client.check(subject=Subject(id="user-1"), action="read", resource=Resource(id="doc-1"))
93
+ assert exc_info.value.status_code == 401
94
+ assert exc_info.value.is_auth_error
95
+
96
+
97
+ def test_retry_on_500(mock_server):
98
+ call_sequence = [0]
99
+
100
+ original_handler = MockHandler.do_POST
101
+
102
+ def custom_handler(self):
103
+ content_length = int(self.headers.get("Content-Length", 0))
104
+ self.rfile.read(content_length)
105
+ call_sequence[0] += 1
106
+ if call_sequence[0] < 3:
107
+ body = json.dumps({"error": "internal"}).encode()
108
+ self.send_response(500)
109
+ self.send_header("Content-Type", "application/json")
110
+ self.send_header("Content-Length", str(len(body)))
111
+ self.end_headers()
112
+ self.wfile.write(body)
113
+ return
114
+ body = json.dumps({"decision": True, "context": {"reason": "ok"}}).encode()
115
+ self.send_response(200)
116
+ self.send_header("Content-Type", "application/json")
117
+ self.send_header("Content-Length", str(len(body)))
118
+ self.end_headers()
119
+ self.wfile.write(body)
120
+
121
+ MockHandler.do_POST = custom_handler
122
+ try:
123
+ client = Vengtoo(api_key="test-key", base_url=mock_server, max_retries=2)
124
+ allowed = client.check(subject=Subject(id="user-1"), action="read", resource=Resource(id="doc-1"))
125
+ assert allowed is True
126
+ assert call_sequence[0] == 3
127
+ finally:
128
+ MockHandler.do_POST = original_handler
129
+
130
+
131
+ def test_no_retry_on_400(mock_server):
132
+ MockHandler.status_code = 400
133
+ MockHandler.call_count = 0
134
+ client = Vengtoo(api_key="test-key", base_url=mock_server)
135
+ with pytest.raises(VengtooError) as exc_info:
136
+ client.check(subject=Subject(id="user-1"), action="read", resource=Resource(id="doc-1"))
137
+ assert exc_info.value.status_code == 400
138
+ assert MockHandler.call_count == 1
139
+
140
+
141
+ def test_subject_type_optional():
142
+ s = Subject(id="user-1")
143
+ d = s.to_dict()
144
+ assert "type" not in d
145
+
146
+
147
+ def test_resource_type_optional():
148
+ r = Resource(id="doc-1")
149
+ d = r.to_dict()
150
+ assert "type" not in d
@@ -0,0 +1,231 @@
1
+ """OAuth2 Client Credentials tests for the Vengtoo Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ from http.server import BaseHTTPRequestHandler, HTTPServer
8
+ from urllib.parse import parse_qs
9
+
10
+ import pytest
11
+
12
+ from authzx import (
13
+ Vengtoo,
14
+ VengtooError,
15
+ VengtooOAuthError,
16
+ Resource,
17
+ Subject,
18
+ )
19
+
20
+
21
+ class _State:
22
+ """Mutable state shared between the test and the mock server handler."""
23
+
24
+ token_calls: int = 0
25
+ api_calls: int = 0
26
+ # Token endpoint behavior
27
+ token_status: int = 200
28
+ token_body: dict | str = {"access_token": "tok-1", "token_type": "Bearer", "expires_in": 3600}
29
+ # Per-call token generation (overrides token_body when set)
30
+ token_sequence: list[dict] | None = None
31
+ # API behavior — either a dict (fixed response), or a callable
32
+ # (auth_header: str) -> tuple[int, dict].
33
+ api_responder = None
34
+ # Record the Authorization header seen on the last API call.
35
+ last_api_auth: str = ""
36
+ # Record the last token form body
37
+ last_token_form: dict[str, list[str]] | None = None
38
+
39
+
40
+ def _reset_state() -> None:
41
+ _State.token_calls = 0
42
+ _State.api_calls = 0
43
+ _State.token_status = 200
44
+ _State.token_body = {"access_token": "tok-1", "token_type": "Bearer", "expires_in": 3600}
45
+ _State.token_sequence = None
46
+ _State.api_responder = None
47
+ _State.last_api_auth = ""
48
+ _State.last_token_form = None
49
+
50
+
51
+ class _OAuthHandler(BaseHTTPRequestHandler):
52
+ def log_message(self, fmt, *args): # noqa: D401
53
+ pass
54
+
55
+ def do_POST(self): # noqa: N802
56
+ length = int(self.headers.get("Content-Length", 0))
57
+ raw = self.rfile.read(length)
58
+
59
+ if self.path == "/oauth/token":
60
+ _State.token_calls += 1
61
+ _State.last_token_form = parse_qs(raw.decode())
62
+ if _State.token_sequence:
63
+ body = _State.token_sequence[min(_State.token_calls - 1, len(_State.token_sequence) - 1)]
64
+ status = 200
65
+ else:
66
+ body = _State.token_body
67
+ status = _State.token_status
68
+ payload = json.dumps(body).encode() if isinstance(body, dict) else body.encode()
69
+ self.send_response(status)
70
+ self.send_header("Content-Type", "application/json")
71
+ self.send_header("Content-Length", str(len(payload)))
72
+ self.end_headers()
73
+ self.wfile.write(payload)
74
+ return
75
+
76
+ # API path
77
+ _State.api_calls += 1
78
+ auth_header = self.headers.get("Authorization", "")
79
+ _State.last_api_auth = auth_header
80
+ responder = _State.api_responder
81
+ if responder is not None:
82
+ status, body = responder(auth_header)
83
+ else:
84
+ status, body = 200, {"decision": True, "context": {"reason": "ok"}}
85
+ payload = json.dumps(body).encode()
86
+ self.send_response(status)
87
+ self.send_header("Content-Type", "application/json")
88
+ self.send_header("Content-Length", str(len(payload)))
89
+ self.end_headers()
90
+ self.wfile.write(payload)
91
+
92
+
93
+ @pytest.fixture
94
+ def oauth_server():
95
+ _reset_state()
96
+ server = HTTPServer(("127.0.0.1", 0), _OAuthHandler)
97
+ port = server.server_address[1]
98
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
99
+ thread.start()
100
+ base = f"http://127.0.0.1:{port}"
101
+ yield {"base_url": base, "token_url": f"{base}/oauth/token"}
102
+ server.shutdown()
103
+
104
+
105
+ def test_oauth_token_exchange_happy_path(oauth_server):
106
+ client = Vengtoo(
107
+ client_id="cid",
108
+ client_secret="azx_cs_secret",
109
+ base_url=oauth_server["base_url"],
110
+ token_url=oauth_server["token_url"],
111
+ )
112
+ allowed = client.check(
113
+ subject=Subject(id="u-1"),
114
+ action="read",
115
+ resource=Resource(id="d-1"),
116
+ )
117
+ assert allowed is True
118
+ assert _State.token_calls == 1
119
+ assert _State.api_calls == 1
120
+ assert _State.last_api_auth == "Bearer tok-1"
121
+ assert _State.last_token_form is not None
122
+ assert _State.last_token_form.get("grant_type") == ["client_credentials"]
123
+ assert _State.last_token_form.get("client_id") == ["cid"]
124
+ assert _State.last_token_form.get("client_secret") == ["azx_cs_secret"]
125
+
126
+
127
+ def test_oauth_invalid_client_clear_error(oauth_server):
128
+ _State.token_status = 401
129
+ _State.token_body = {"error": "invalid_client"}
130
+
131
+ client = Vengtoo(
132
+ client_id="cid",
133
+ client_secret="azx_cs_wrong",
134
+ base_url=oauth_server["base_url"],
135
+ token_url=oauth_server["token_url"],
136
+ )
137
+ with pytest.raises(VengtooOAuthError) as exc_info:
138
+ client.check(
139
+ subject=Subject(id="u-1"),
140
+ action="read",
141
+ resource=Resource(id="d-1"),
142
+ )
143
+ assert "check client_id/client_secret" in str(exc_info.value)
144
+ assert exc_info.value.code == "invalid_client"
145
+ assert _State.api_calls == 0, "API should not be called on token exchange failure"
146
+
147
+
148
+ def test_oauth_cached_token_reused(oauth_server):
149
+ client = Vengtoo(
150
+ client_id="cid",
151
+ client_secret="azx_cs_secret",
152
+ base_url=oauth_server["base_url"],
153
+ token_url=oauth_server["token_url"],
154
+ )
155
+ for _ in range(3):
156
+ client.check(
157
+ subject=Subject(id="u-1"),
158
+ action="read",
159
+ resource=Resource(id="d-1"),
160
+ )
161
+ assert _State.token_calls == 1
162
+ assert _State.api_calls == 3
163
+
164
+
165
+ def test_oauth_401_triggers_refresh_and_retry(oauth_server):
166
+ _State.token_sequence = [
167
+ {"access_token": "tok-stale", "token_type": "Bearer", "expires_in": 3600},
168
+ {"access_token": "tok-fresh", "token_type": "Bearer", "expires_in": 3600},
169
+ ]
170
+
171
+ def responder(auth_header: str):
172
+ if auth_header == "Bearer tok-stale":
173
+ return 401, {"error": "stale"}
174
+ return 200, {"decision": True, "context": {"reason": "ok"}}
175
+
176
+ _State.api_responder = responder
177
+
178
+ client = Vengtoo(
179
+ client_id="cid",
180
+ client_secret="azx_cs_secret",
181
+ base_url=oauth_server["base_url"],
182
+ token_url=oauth_server["token_url"],
183
+ )
184
+ allowed = client.check(
185
+ subject=Subject(id="u-1"),
186
+ action="read",
187
+ resource=Resource(id="d-1"),
188
+ )
189
+ assert allowed is True
190
+ assert _State.token_calls == 2, "expected initial + refresh"
191
+ assert _State.api_calls == 2, "expected 401 + retry"
192
+
193
+
194
+ def test_oauth_401_retry_only_once(oauth_server):
195
+ def responder(auth_header: str):
196
+ return 401, {"error": "bad token"}
197
+
198
+ _State.api_responder = responder
199
+
200
+ client = Vengtoo(
201
+ client_id="cid",
202
+ client_secret="azx_cs_secret",
203
+ base_url=oauth_server["base_url"],
204
+ token_url=oauth_server["token_url"],
205
+ )
206
+ with pytest.raises(VengtooError) as exc_info:
207
+ client.check(
208
+ subject=Subject(id="u-1"),
209
+ action="read",
210
+ resource=Resource(id="d-1"),
211
+ )
212
+ assert exc_info.value.status_code == 401
213
+ # One retry allowed — so 2 API calls total, no infinite loop.
214
+ assert _State.api_calls == 2
215
+
216
+
217
+ def test_oauth_apikey_plus_oauth_is_construction_error():
218
+ with pytest.raises(ValueError) as exc_info:
219
+ Vengtoo(
220
+ api_key="azx_key",
221
+ client_id="cid",
222
+ client_secret="azx_cs_secret",
223
+ )
224
+ assert "either api_key or OAuth" in str(exc_info.value)
225
+
226
+
227
+ def test_oauth_partial_credentials_is_construction_error():
228
+ with pytest.raises(ValueError):
229
+ Vengtoo(client_id="cid") # missing client_secret
230
+ with pytest.raises(ValueError):
231
+ Vengtoo(client_secret="azx_cs_secret") # missing client_id
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: vengtoo
3
+ Version: 1.0.0
4
+ Summary: Vengtoo Python SDK — authorization client for Vengtoo Cloud and the Vengtoo Agent
5
+ Author-email: Vengtoo <hello@vengtoo.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://vengtoo.com
8
+ Project-URL: Documentation, https://docs.vengtoo.com
9
+ Project-URL: Repository, https://github.com/vengtoo/vengtoo-python
10
+ Project-URL: Issues, https://github.com/vengtoo/vengtoo-python/issues
11
+ Keywords: authorization,vengtoo,rbac,abac,access-control,ai-agents
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: httpx>=0.25.0
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest; extra == "dev"
18
+ Requires-Dist: pytest-asyncio; extra == "dev"
19
+ Requires-Dist: respx; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ # Vengtoo Python SDK
23
+
24
+ Python client for [Vengtoo](https://vengtoo.com) — works with both Vengtoo Cloud and the Vengtoo Agent.
25
+
26
+ Supports sync and async. Requires Python 3.9+.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install vengtoo
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ### Cloud Mode
37
+
38
+ ```python
39
+ from vengtoo import Vengtoo, Subject, Resource
40
+
41
+ client = Vengtoo(api_key="azx_...")
42
+
43
+ allowed = client.check(
44
+ subject=Subject(id="user:123", type="user", roles=["editor"]),
45
+ action="read",
46
+ resource=Resource(type="document", id="doc:456"),
47
+ )
48
+ ```
49
+
50
+ ### OAuth2 Client Credentials
51
+
52
+ For service-to-service auth, pass `client_id` and `client_secret` (secret is prefixed `azx_cs_`). The SDK exchanges credentials at the token endpoint, caches the JWT in memory, and refreshes ~60s before expiry. Both sync and async calls share the same cache.
53
+
54
+ ```python
55
+ client = Vengtoo(
56
+ client_id="my-client-id",
57
+ client_secret="azx_cs_...",
58
+ )
59
+ ```
60
+
61
+ Equivalent curl for the underlying token exchange:
62
+
63
+ ```bash
64
+ curl -X POST https://api.vengtoo.com/identity-srv/v1/oauth/token \
65
+ -d grant_type=client_credentials \
66
+ -d client_id=my-client-id \
67
+ -d client_secret=azx_cs_...
68
+ ```
69
+
70
+ Providing both `api_key` and OAuth credentials is rejected at construction. A bad `client_id` / `client_secret` surfaces as `VengtooOAuthError` (distinct from `VengtooError`) with a message pointing you at the OAuth exchange.
71
+
72
+ ### Agent Mode (local)
73
+
74
+ ```python
75
+ client = Vengtoo(base_url="http://localhost:8181")
76
+ ```
77
+
78
+ ### Full Evaluate Response
79
+
80
+ ```python
81
+ from vengtoo import AuthorizeRequest, Action
82
+
83
+ resp = client.authorize(AuthorizeRequest(
84
+ subject=Subject(id="user:123", type="user"),
85
+ resource=Resource(type="document", id="doc:456"),
86
+ action=Action(name="read"),
87
+ context={"ip": "10.0.0.1"},
88
+ ))
89
+ # resp.decision, resp.context.reason, resp.context.policy_id, resp.context.access_path
90
+ ```
91
+
92
+ ### Async
93
+
94
+ ```python
95
+ allowed = await client.async_check(
96
+ subject=Subject(id="user:123", type="user"),
97
+ action="read",
98
+ resource=Resource(type="document", id="doc:456"),
99
+ )
100
+
101
+ resp = await client.async_authorize(request)
102
+ ```
103
+
104
+ ### FastAPI Dependency
105
+
106
+ ```python
107
+ from fastapi import FastAPI, Depends
108
+
109
+ app = FastAPI()
110
+ vengtoo = Vengtoo(api_key="azx_...")
111
+
112
+ @app.get("/documents/{id}")
113
+ async def get_doc(id: str, _=Depends(vengtoo.require("document", "read"))):
114
+ return {"id": id}
115
+ ```
116
+
117
+ The `require()` dependency extracts subject ID from the `X-User-ID` header by default. Customize with:
118
+
119
+ ```python
120
+ vengtoo.require("document", "read", subject_header="authorization-user-id")
121
+ ```
122
+
123
+ ### Options
124
+
125
+ ```python
126
+ Vengtoo(
127
+ api_key="azx_...", # API key for cloud mode
128
+ base_url="http://localhost:8181", # Custom URL (agent mode)
129
+ timeout=5.0, # Request timeout in seconds (default: 10)
130
+ )
131
+ ```
132
+
133
+ ## Types
134
+
135
+ | Type | Fields |
136
+ | ------------------- | --------------------------------------------------- |
137
+ | `Subject` | `id`, `type`, `attributes`, `properties`, `roles` |
138
+ | `Resource` | `id`, `type`, `attributes`, `properties` |
139
+ | `Action` | `name`, `properties` |
140
+ | `AuthorizeRequest` | `subject`, `resource`, `action`, `context` |
141
+ | `AuthorizeContext` | `reason`, `reason_code`, `policy_id`, `access_path` |
142
+ | `AuthorizeResponse` | `decision`, `context` |
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ tests/test_client.py
5
+ tests/test_oauth.py
6
+ vengtoo.egg-info/PKG-INFO
7
+ vengtoo.egg-info/SOURCES.txt
8
+ vengtoo.egg-info/dependency_links.txt
9
+ vengtoo.egg-info/requires.txt
10
+ vengtoo.egg-info/top_level.txt
@@ -0,0 +1,6 @@
1
+ httpx>=0.25.0
2
+
3
+ [dev]
4
+ pytest
5
+ pytest-asyncio
6
+ respx