Skip to content

Commit 682986e

Browse files
authored
Merge pull request #11 from codefly-dev/feat/authorization-revision-client
Provide shared authorization revision clients for Python
2 parents 97a5996 + 1d2d5c6 commit 682986e

6 files changed

Lines changed: 904 additions & 3 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
with:
1818
python-version: ${{ matrix.python-version }}
1919
- run: python -m pip install --upgrade pip
20-
- run: pip install -e ".[test]"
20+
- run: pip install -e ".[test,revision-connect,revision-grpc]"
2121
# Runs the Work Context wire-golden parity test, which pins the token
2222
# byte-for-byte to sdk-go's TestWorkContextWireGolden.
2323
- run: python -m pytest -q

‎README.md‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Everything ships under a single top-level package, `saas_sdk`, so installing it
99
never claims a generic name (`saas`, `buf`, `datasource`) in the consumer's
1010
import namespace.
1111

12-
Three layers:
12+
Packages:
1313

1414
- **`saas_sdk._gen`** — the generated protobuf message bindings (`*_pb2`), from
1515
the accounts public proto at the ref recorded in `SOURCE.txt`. Only message
@@ -117,6 +117,50 @@ raising `WorkContextError` (or `WorkContextDenied` from `require_scope`) on any
117117
failure. Replay policy is reported on the claims; enforcing single-use
118118
consumption still requires a durable replay store the caller owns.
119119

120+
## Current authorization revision
121+
122+
`saas_sdk.authorization_revision` implements Accounts' current revision protocol
123+
for **already verified** `WorkContext` claims. It projects the owner and every
124+
actor's original scopes, including resource IDs and revision. The consumer keeps
125+
signature/lifetime verification, action/resource policy and public error mapping.
126+
127+
```python
128+
from saas_sdk.authorization_revision import GRPCClient, RevisionDenied, RevisionUnavailable
129+
130+
client = GRPCClient("https://accounts.internal:50051", current_internal_credential)
131+
try:
132+
await client.check(verified_claims)
133+
finally:
134+
await client.aclose()
135+
```
136+
137+
Install `saas-sdk-python[revision-grpc]` for the canonical TLS gRPC client. Where an
138+
endpoint explicitly serves Connect, install `[revision-connect]` and select
139+
`ConnectClient`; selecting that compatibility transport is a composition choice,
140+
never an automatic fallback. `revision_request(claims)` needs neither networking
141+
extra and returns the canonical generated protobuf request.
142+
143+
Each check loads the current internal service credential through an async callback
144+
and makes one query. It does not cache authorization, retry, inherit environment
145+
proxies, follow redirects or forward caller bearer tokens, cookies or Work Context
146+
headers. One three-second budget covers credential loading, connection and the
147+
complete response; caller cancellation propagates. The callback and any injected
148+
fixture transport must cooperate with cancellation. `tls_context` selects verified
149+
trust roots; TLS verification cannot be disabled. Connect's explicit `transport`
150+
injection exists for fixtures and is trusted to implement its own transport
151+
security; production should use the default transport.
152+
153+
Only gRPC `PermissionDenied`/`FailedPrecondition`, or their coherent Connect
154+
status/code pairs, raise `RevisionDenied`. Credential, transport and malformed
155+
response failures raise `RevisionUnavailable`. Responses must be the canonical
156+
empty message, and error strings omit remote details and credentials. Neither
157+
error grants authority; consumers explicitly map them onto their public API.
158+
159+
The projection fixture at `tests/fixtures/authorization_revision_projection.json`
160+
is shared byte-for-byte with the Go SDK's
161+
`authorizationrevision/testdata/projection.json`. Owner/actor scope projection is
162+
therefore verified independently of transport and consumer policy.
163+
120164
## Versioning
121165

122166
This SDK's release version tracks the **saas-starter module version**

‎pyproject.toml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ requires-python = ">=3.10"
1414
dependencies = ["protobuf>=5.29.3", "cryptography>=41"]
1515

1616
[project.optional-dependencies]
17-
test = ["pytest>=8"]
17+
test = ["pytest>=8", "pytest-asyncio>=0.24"]
18+
revision-connect = ["httpx>=0.27,<1"]
19+
revision-grpc = ["grpcio>=1.66"]
1820

1921
[tool.setuptools.packages.find]
2022
where = ["src"]
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
"""Accounts current-revision protocol for already verified Work Context claims.
2+
3+
The caller retains signature, lifetime, scope and resource policy. Every check
4+
refreshes its internal credential and contacts the authority; no decision is cached.
5+
Networking dependencies are optional extras so projection remains lightweight.
6+
"""
7+
8+
import asyncio
9+
import json
10+
import ssl
11+
from collections.abc import Awaitable, Callable
12+
from urllib.parse import urlsplit
13+
14+
from google.protobuf.json_format import MessageToDict
15+
16+
from ._gen import work_contexts_pb2 as pb
17+
from .work_context import WorkContext
18+
19+
try:
20+
import grpc
21+
except ImportError:
22+
grpc = None
23+
try:
24+
import httpx
25+
except ImportError:
26+
httpx = None
27+
28+
_SERVICE = pb.DESCRIPTOR.services_by_name["WorkContextService"]
29+
_METHOD = _SERVICE.methods_by_name["CheckAuthorizationRevision"]
30+
REVISION_PATH = "/" + _SERVICE.full_name + "/" + _METHOD.name
31+
TIMEOUT = 3.0
32+
33+
34+
class RevisionDenied(Exception):
35+
"""Accounts reported PermissionDenied or a stale/revoked revision."""
36+
37+
38+
class RevisionUnavailable(Exception):
39+
"""Internal credential, transport or response-contract failure; no authority granted."""
40+
41+
42+
def _decode(data):
43+
def pairs(items):
44+
value = {}
45+
for key, item in items:
46+
if key in value:
47+
raise ValueError("Duplicate JSON key")
48+
value[key] = item
49+
return value
50+
51+
def invalid(value):
52+
raise ValueError("Non-finite JSON value")
53+
54+
return json.loads(data, object_pairs_hook=pairs, parse_constant=invalid)
55+
56+
57+
async def _internal_credential(provider: Callable[[], Awaitable[str]]) -> str:
58+
try:
59+
token = await provider()
60+
except Exception:
61+
raise RevisionUnavailable("Current authority is unavailable") from None
62+
if (
63+
not isinstance(token, str)
64+
or not 1 <= len(token) <= 8192
65+
or any(ord(c) < 33 or ord(c) > 126 for c in token)
66+
):
67+
raise RevisionUnavailable("Current authority is unavailable")
68+
return token
69+
70+
71+
def _https_origin(value: str) -> str:
72+
url = urlsplit(value)
73+
if (
74+
url.scheme != "https"
75+
or not url.hostname
76+
or url.username is not None
77+
or url.password is not None
78+
or url.path not in ("", "/")
79+
or url.query
80+
or url.fragment
81+
or "\\" in value
82+
or any(c.isspace() or ord(c) < 32 for c in value)
83+
or url.port == 0
84+
):
85+
raise ValueError("Accounts requires an explicit HTTPS internal origin")
86+
return value.rstrip("/")
87+
88+
89+
def revision_request(claims: WorkContext) -> pb.CheckAuthorizationRevisionRequest:
90+
"""Copy owner and every actor scope; the caller owns verification and policy."""
91+
if (
92+
not isinstance(claims, WorkContext)
93+
or not claims.tenant_id
94+
or not claims.owner_principal_id
95+
):
96+
raise RevisionUnavailable("Current authority is unavailable")
97+
98+
def subject(principal, scopes):
99+
if not principal:
100+
raise RevisionUnavailable("Current authority is unavailable")
101+
return pb.WorkContextRevisionSubject(
102+
principal_id=principal,
103+
scopes=[
104+
pb.WorkContextScope(
105+
resource_kind=s.resource_kind,
106+
actions=s.actions,
107+
resource_ids=s.resource_ids,
108+
)
109+
for s in scopes
110+
],
111+
)
112+
113+
return pb.CheckAuthorizationRevisionRequest(
114+
org_id=claims.tenant_id,
115+
owner_principal_id=claims.owner_principal_id,
116+
authorization_revision=claims.authorization_revision,
117+
subjects=[
118+
subject(claims.owner_principal_id, claims.authority_scopes),
119+
*(subject(a.principal_id, a.granted_scopes) for a in claims.actor_chain),
120+
],
121+
)
122+
123+
124+
class ConnectClient:
125+
"""Connect compatibility client; select only when the authority endpoint serves Connect."""
126+
127+
def __init__(self, origin, credential, *, transport=None, tls_context=None):
128+
if httpx is None:
129+
raise ImportError(
130+
"Install saas-sdk-python[revision-connect] for Connect revision checks"
131+
)
132+
self.endpoint = _https_origin(origin) + REVISION_PATH
133+
self.credential = credential
134+
if tls_context is not None and (
135+
not tls_context.check_hostname
136+
or tls_context.verify_mode != ssl.CERT_REQUIRED
137+
):
138+
raise ValueError("Accounts TLS verification cannot be disabled")
139+
self.client = httpx.AsyncClient(
140+
transport=transport
141+
if transport is not None
142+
else httpx.AsyncHTTPTransport(
143+
retries=0, trust_env=False, verify=tls_context if tls_context else True
144+
),
145+
trust_env=False,
146+
follow_redirects=False,
147+
)
148+
149+
async def check(self, claims: WorkContext) -> None:
150+
try:
151+
await asyncio.wait_for(self._check(claims), timeout=TIMEOUT)
152+
except asyncio.TimeoutError:
153+
raise RevisionUnavailable("Current authority is unavailable") from None
154+
155+
async def _check(self, claims):
156+
response = None
157+
try:
158+
# Includes credential refresh, pool wait, connection and the complete body.
159+
token = await _internal_credential(self.credential)
160+
body = MessageToDict(
161+
revision_request(claims), preserving_proto_field_name=True
162+
)
163+
# Construct directly: never inherit caller headers, client cookies or auth.
164+
request = httpx.Request(
165+
"POST",
166+
self.endpoint,
167+
headers={
168+
"content-type": "application/json",
169+
"accept": "application/json",
170+
"accept-encoding": "identity",
171+
"connect-protocol-version": "1",
172+
"x-codefly-internal-token": token,
173+
},
174+
content=json.dumps(body).encode(),
175+
extensions={
176+
"timeout": dict.fromkeys(("connect", "read", "write", "pool"), 3)
177+
},
178+
)
179+
response = await self.client.send(request, stream=True, auth=None)
180+
if response.status_code not in (200, 400, 403):
181+
raise ValueError("Accounts unavailable")
182+
if (
183+
response.headers.get("content-type", "").split(";", 1)[0]
184+
!= "application/json"
185+
):
186+
raise ValueError("Invalid Accounts response type")
187+
if response.headers.get("content-encoding", "identity") != "identity":
188+
raise ValueError("Compressed Accounts responses are not supported")
189+
data = bytearray()
190+
async for part in response.aiter_bytes():
191+
data.extend(part)
192+
if len(data) > 8192:
193+
raise ValueError("Accounts response exceeds bound")
194+
payload = _decode(data)
195+
if response.status_code != 200:
196+
# Connect maps gRPC FailedPrecondition to 400, not HTTP 412.
197+
# An internal 401 is a service credential outage, never caller denial.
198+
if isinstance(payload, dict) and (
199+
response.status_code,
200+
payload.get("code"),
201+
) in (
202+
(400, "failed_precondition"),
203+
(403, "permission_denied"),
204+
):
205+
raise RevisionDenied("Current authority was not confirmed")
206+
raise ValueError("Invalid Accounts error response")
207+
if payload != {}:
208+
raise ValueError("Expected canonical empty revision response")
209+
except (httpx.HTTPError, OSError, ValueError, TimeoutError):
210+
raise RevisionUnavailable("Current authority is unavailable") from None
211+
finally:
212+
if response is not None:
213+
await response.aclose()
214+
215+
async def aclose(self) -> None:
216+
await self.client.aclose()
217+
218+
219+
class GRPCClient:
220+
"""TLS gRPC on the canonical internal RPC; no retries, fallback or authority cache."""
221+
222+
def __init__(self, origin, credential, *, tls_context=None):
223+
if grpc is None:
224+
raise ImportError(
225+
"Install saas-sdk-python[revision-grpc] for gRPC revision checks"
226+
)
227+
url = urlsplit(_https_origin(origin))
228+
self.credential = credential
229+
context = (
230+
tls_context if tls_context is not None else ssl.create_default_context()
231+
)
232+
if not context.check_hostname or context.verify_mode != ssl.CERT_REQUIRED:
233+
raise ValueError("Accounts TLS verification cannot be disabled")
234+
# Use the deployment-selected trust roots, not gRPC environment overrides.
235+
roots = b"".join(
236+
ssl.DER_cert_to_PEM_cert(cert).encode("ascii")
237+
for cert in context.get_ca_certs(binary_form=True)
238+
)
239+
if not roots:
240+
raise ValueError("Accounts requires trusted TLS roots")
241+
host = f"[{url.hostname}]" if ":" in url.hostname else url.hostname
242+
self.channel = grpc.aio.secure_channel(
243+
f"{host}:{url.port or 443}",
244+
grpc.ssl_channel_credentials(root_certificates=roots),
245+
options=(
246+
("grpc.enable_retries", 0),
247+
("grpc.enable_http_proxy", 0),
248+
("grpc.max_receive_message_length", 8192),
249+
("grpc.max_send_message_length", 65536),
250+
),
251+
)
252+
self.rpc = self.channel.unary_unary(
253+
REVISION_PATH,
254+
request_serializer=pb.CheckAuthorizationRevisionRequest.SerializeToString,
255+
# The canonical response is google.protobuf.Empty. Reject unknown fields
256+
# rather than silently accepting a different response contract.
257+
response_deserializer=lambda data: data,
258+
)
259+
260+
async def check(self, claims: WorkContext) -> None:
261+
try:
262+
await asyncio.wait_for(self._check(claims), timeout=TIMEOUT)
263+
except asyncio.TimeoutError:
264+
raise RevisionUnavailable("Current authority is unavailable") from None
265+
266+
async def _check(self, claims):
267+
try:
268+
# One total budget includes credential refresh, connection and RPC body.
269+
deadline = asyncio.get_running_loop().time() + TIMEOUT
270+
token = await _internal_credential(self.credential)
271+
remaining = deadline - asyncio.get_running_loop().time()
272+
if remaining <= 0:
273+
raise TimeoutError
274+
response = await self.rpc(
275+
revision_request(claims),
276+
metadata=(("x-codefly-internal-token", token),),
277+
timeout=remaining,
278+
wait_for_ready=False,
279+
)
280+
if response != b"":
281+
raise ValueError("Expected canonical empty revision response")
282+
except grpc.aio.AioRpcError as error:
283+
if error.code() in (
284+
grpc.StatusCode.PERMISSION_DENIED,
285+
grpc.StatusCode.FAILED_PRECONDITION,
286+
):
287+
raise RevisionDenied("Current authority was not confirmed") from None
288+
# Internal credential failures are service outages, never caller denials.
289+
# Do not expose provider details, trailers, metadata or credentials.
290+
raise RevisionUnavailable("Current authority is unavailable") from None
291+
except (OSError, ValueError, TimeoutError):
292+
raise RevisionUnavailable("Current authority is unavailable") from None
293+
294+
async def aclose(self) -> None:
295+
await self.channel.close()

0 commit comments

Comments
 (0)