vengtoo 0.1.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-0.1.0/LICENSE +21 -0
- vengtoo-0.1.0/PKG-INFO +142 -0
- vengtoo-0.1.0/README.md +121 -0
- vengtoo-0.1.0/pyproject.toml +26 -0
- vengtoo-0.1.0/setup.cfg +4 -0
- vengtoo-0.1.0/tests/test_client.py +150 -0
- vengtoo-0.1.0/tests/test_oauth.py +231 -0
- vengtoo-0.1.0/vengtoo/__init__.py +25 -0
- vengtoo-0.1.0/vengtoo/client.py +731 -0
- vengtoo-0.1.0/vengtoo/errors.py +49 -0
- vengtoo-0.1.0/vengtoo/py.typed +0 -0
- vengtoo-0.1.0/vengtoo/types.py +181 -0
- vengtoo-0.1.0/vengtoo.egg-info/PKG-INFO +142 -0
- vengtoo-0.1.0/vengtoo.egg-info/SOURCES.txt +15 -0
- vengtoo-0.1.0/vengtoo.egg-info/dependency_links.txt +1 -0
- vengtoo-0.1.0/vengtoo.egg-info/requires.txt +6 -0
- vengtoo-0.1.0/vengtoo.egg-info/top_level.txt +1 -0
vengtoo-0.1.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-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vengtoo
|
|
3
|
+
Version: 0.1.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` |
|
vengtoo-0.1.0/README.md
ADDED
|
@@ -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 = "0.1.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*"]
|
vengtoo-0.1.0/setup.cfg
ADDED
|
@@ -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 vengtoo 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
|