badgerflow 0.1.0__py3-none-any.whl
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.
- agiel/__init__.py +57 -0
- badgerflow/__init__.py +38 -0
- badgerflow/auth/__init__.py +26 -0
- badgerflow/auth/run_token.py +333 -0
- badgerflow/cli/__init__.py +5 -0
- badgerflow/cli/main.py +1324 -0
- badgerflow/cli/preview.py +494 -0
- badgerflow/cli/project.py +198 -0
- badgerflow/cli/scaffold.py +270 -0
- badgerflow/client/__init__.py +40 -0
- badgerflow/client/adapters/__init__.py +0 -0
- badgerflow/client/adapters/autogen.py +94 -0
- badgerflow/client/adapters/crewai.py +93 -0
- badgerflow/client/adapters/langchain.py +85 -0
- badgerflow/client/cli/__init__.py +0 -0
- badgerflow/client/cli/main.py +327 -0
- badgerflow/client/cli/manifest.py +81 -0
- badgerflow/client/client.py +153 -0
- badgerflow/client/exceptions.py +44 -0
- badgerflow/client/resources/__init__.py +0 -0
- badgerflow/client/resources/agents.py +149 -0
- badgerflow/client/resources/auth.py +53 -0
- badgerflow/client/resources/dependencies.py +136 -0
- badgerflow/client/resources/guardrail_profiles.py +226 -0
- badgerflow/client/resources/guardrails.py +137 -0
- badgerflow/client/resources/models.py +59 -0
- badgerflow/client/resources/namespaces.py +58 -0
- badgerflow/client/resources/rag.py +43 -0
- badgerflow/client/resources/releases.py +95 -0
- badgerflow/client/resources/webhooks.py +57 -0
- badgerflow/client/resources/workflows.py +60 -0
- badgerflow/connectors/__init__.py +13 -0
- badgerflow/connectors/client.py +69 -0
- badgerflow/contract/README.md +72 -0
- badgerflow/contract/__init__.py +70 -0
- badgerflow/contract/constants.py +258 -0
- badgerflow/contract/event.schema.json +293 -0
- badgerflow/contract/examples/event-complete.json +10 -0
- badgerflow/contract/examples/event-pause_review.json +34 -0
- badgerflow/contract/examples/event-policy_violation.json +11 -0
- badgerflow/contract/examples/event-step_completed.json +23 -0
- badgerflow/contract/examples/event-step_started.json +12 -0
- badgerflow/contract/examples/invoke-request.json +21 -0
- badgerflow/contract/examples/invoke-response-completed.json +7 -0
- badgerflow/contract/examples/invoke-response-failed.json +7 -0
- badgerflow/contract/examples/invoke-response-paused.json +32 -0
- badgerflow/contract/examples/ir.hash +1 -0
- badgerflow/contract/examples/ir.json +131 -0
- badgerflow/contract/examples/manifest.json +198 -0
- badgerflow/contract/hash.py +161 -0
- badgerflow/contract/invoke-request.schema.json +95 -0
- badgerflow/contract/invoke-response.schema.json +56 -0
- badgerflow/contract/ir.schema.json +132 -0
- badgerflow/contract/manifest.schema.json +334 -0
- badgerflow/contract/pause.py +92 -0
- badgerflow/contract/validate.py +124 -0
- badgerflow/events/__init__.py +42 -0
- badgerflow/events/emitter.py +612 -0
- badgerflow/events/steps.py +309 -0
- badgerflow/guardrails/__init__.py +32 -0
- badgerflow/guardrails/client.py +201 -0
- badgerflow/knowledge/__init__.py +12 -0
- badgerflow/knowledge/client.py +221 -0
- badgerflow/langgraph/__init__.py +51 -0
- badgerflow/langgraph/agent.py +396 -0
- badgerflow/langgraph/callback.py +243 -0
- badgerflow/langgraph/chat.py +131 -0
- badgerflow/langgraph/checkpoint.py +155 -0
- badgerflow/langgraph/compile.py +479 -0
- badgerflow/langgraph/uses.py +139 -0
- badgerflow/llm/__init__.py +15 -0
- badgerflow/llm/client.py +425 -0
- badgerflow/ontology/__init__.py +35 -0
- badgerflow/ontology/client.py +218 -0
- badgerflow/otel/__init__.py +73 -0
- badgerflow/otel/logs.py +162 -0
- badgerflow/otel/metrics.py +355 -0
- badgerflow/otel/scope.py +185 -0
- badgerflow/otel/spans.py +392 -0
- badgerflow/otel/tracing.py +242 -0
- badgerflow/procode/__init__.py +64 -0
- badgerflow/procode/app.py +1131 -0
- badgerflow/procode/clients.py +43 -0
- badgerflow/procode/config.py +154 -0
- badgerflow/procode/context.py +274 -0
- badgerflow/procode/current.py +59 -0
- badgerflow/procode/errors.py +127 -0
- badgerflow/procode/stepctx.py +82 -0
- badgerflow/procode/transport.py +129 -0
- badgerflow/tools/__init__.py +13 -0
- badgerflow/tools/client.py +128 -0
- badgerflow-0.1.0.dist-info/METADATA +210 -0
- badgerflow-0.1.0.dist-info/RECORD +98 -0
- badgerflow-0.1.0.dist-info/WHEEL +5 -0
- badgerflow-0.1.0.dist-info/entry_points.txt +3 -0
- badgerflow-0.1.0.dist-info/licenses/LICENSE +202 -0
- badgerflow-0.1.0.dist-info/licenses/NOTICE +11 -0
- badgerflow-0.1.0.dist-info/top_level.txt +2 -0
agiel/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``agiel`` — deprecated import path for :mod:`badgerflow.client`.
|
|
3
|
+
|
|
4
|
+
The ``agiel`` distribution moved into the ``badgerflow`` package as
|
|
5
|
+
``badgerflow.client``. This shim keeps ``from agiel import AgiellClient`` (and
|
|
6
|
+
the resource classes and exceptions) working for one release cycle so existing
|
|
7
|
+
scripts, generated exports and docs do not break at import time.
|
|
8
|
+
|
|
9
|
+
It warns once on import. New code should import from ``badgerflow.client``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import warnings
|
|
13
|
+
|
|
14
|
+
from badgerflow import __version__
|
|
15
|
+
from badgerflow.client import (
|
|
16
|
+
AgiellClient,
|
|
17
|
+
AgiellError,
|
|
18
|
+
AuthError,
|
|
19
|
+
ConflictError,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
ServerError,
|
|
22
|
+
ValidationError,
|
|
23
|
+
)
|
|
24
|
+
from badgerflow.client.resources.agents import AgentClient
|
|
25
|
+
from badgerflow.client.resources.auth import AuthClient
|
|
26
|
+
from badgerflow.client.resources.guardrail_profiles import GuardrailProfilesClient
|
|
27
|
+
from badgerflow.client.resources.guardrails import GuardrailsClient
|
|
28
|
+
from badgerflow.client.resources.models import ModelRegistryClient
|
|
29
|
+
from badgerflow.client.resources.namespaces import NamespaceClient
|
|
30
|
+
from badgerflow.client.resources.rag import RAGClient
|
|
31
|
+
from badgerflow.client.resources.webhooks import WebhookClient
|
|
32
|
+
|
|
33
|
+
warnings.warn(
|
|
34
|
+
"the 'agiel' package is deprecated; import from 'badgerflow.client' instead "
|
|
35
|
+
"(e.g. 'from badgerflow.client import AgiellClient')",
|
|
36
|
+
DeprecationWarning,
|
|
37
|
+
stacklevel=2,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"__version__",
|
|
42
|
+
"AgiellClient",
|
|
43
|
+
"AgiellError",
|
|
44
|
+
"AuthError",
|
|
45
|
+
"ConflictError",
|
|
46
|
+
"NotFoundError",
|
|
47
|
+
"ServerError",
|
|
48
|
+
"ValidationError",
|
|
49
|
+
"AgentClient",
|
|
50
|
+
"AuthClient",
|
|
51
|
+
"GuardrailProfilesClient",
|
|
52
|
+
"GuardrailsClient",
|
|
53
|
+
"ModelRegistryClient",
|
|
54
|
+
"NamespaceClient",
|
|
55
|
+
"RAGClient",
|
|
56
|
+
"WebhookClient",
|
|
57
|
+
]
|
badgerflow/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
badgerflow — the BadgerFlow Python SDK
|
|
3
|
+
======================================
|
|
4
|
+
|
|
5
|
+
One installable package with two halves:
|
|
6
|
+
|
|
7
|
+
* ``badgerflow.client`` — the async REST client (``AgiellClient``) and the
|
|
8
|
+
``agiel`` CLI that used to ship as the ``agiel`` distribution. The old
|
|
9
|
+
``agiel`` import path still works through a deprecated shim.
|
|
10
|
+
* ``badgerflow.procode`` and friends — the pro-code surface (a governed
|
|
11
|
+
``@app.run`` server, ``Context``, the LangGraph bridge, the ``bf`` CLI).
|
|
12
|
+
These packages are skeletons filled in by later work packages; each one's
|
|
13
|
+
docstring names the WP that owns it.
|
|
14
|
+
|
|
15
|
+
The package never imports the platform's ``libs/`` or ``services/``. The
|
|
16
|
+
constants both sides must agree on are vendored under ``badgerflow.contract``
|
|
17
|
+
and pinned to ``CONTRACT_VERSION``.
|
|
18
|
+
|
|
19
|
+
This module stays import-light on purpose: ``import badgerflow`` must not
|
|
20
|
+
pull in httpx, typer or any optional framework, so the version is the only
|
|
21
|
+
thing defined eagerly. ``badgerflow.current()`` — the running invocation's
|
|
22
|
+
``Context`` — is resolved lazily on first access so that the server package
|
|
23
|
+
(fastapi, prometheus_client) loads only for code that actually calls it.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
|
|
30
|
+
__all__ = ["__version__", "current"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def __getattr__(name: str) -> Any:
|
|
34
|
+
if name == "current":
|
|
35
|
+
from .procode.current import current
|
|
36
|
+
|
|
37
|
+
return current
|
|
38
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""badgerflow.auth — run-token verification (JWKS) for the ``/invocations`` bearer.
|
|
2
|
+
|
|
3
|
+
Vendors the platform's Ed25519/JWKS verification (``run_token.py``); never
|
|
4
|
+
imports the platform's ``libs/``. Actor/exec token *forwarding* lives with the
|
|
5
|
+
governed clients (S0.4) — the server here only carries them into ``Context``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .run_token import (
|
|
9
|
+
RunTokenClaims,
|
|
10
|
+
RunTokenError,
|
|
11
|
+
RunTokenVerifier,
|
|
12
|
+
Verifier,
|
|
13
|
+
auth_disabled_from_env,
|
|
14
|
+
keys_from_jwks,
|
|
15
|
+
verifier_from_env,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"RunTokenClaims",
|
|
20
|
+
"RunTokenError",
|
|
21
|
+
"RunTokenVerifier",
|
|
22
|
+
"Verifier",
|
|
23
|
+
"auth_disabled_from_env",
|
|
24
|
+
"keys_from_jwks",
|
|
25
|
+
"verifier_from_env",
|
|
26
|
+
]
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Verify the run token that arrives as the bearer of every ``/invocations``.
|
|
2
|
+
|
|
3
|
+
The platform mints run tokens through ``libs/common/session_jwt.py`` (Ed25519,
|
|
4
|
+
``alg=EdDSA``, a ``kid`` header) and publishes the public keys as a JWKS at
|
|
5
|
+
``{BADGERFLOW_JWKS_URL}`` — ``http://auth-service:8090/v1/auth/jwks`` inside a
|
|
6
|
+
cluster. This module *vendors* that verification: it never imports the
|
|
7
|
+
platform, it reads the same JWKS any other verifier reads, and it checks the
|
|
8
|
+
four facts the contract fixes (``constants.py``, "Run token"):
|
|
9
|
+
|
|
10
|
+
* signature, against the key the ``kid`` names;
|
|
11
|
+
* ``iss == RUN_TOKEN_ISSUER`` and ``tok == RUN_TOKEN_TYPE`` — a session or
|
|
12
|
+
actor token signed by the same key must not verify as a run token;
|
|
13
|
+
* ``aud`` contains ``procode:<workflow_id>`` — a run token for a *different*
|
|
14
|
+
pro-code workflow, or a Tier-2 token whose audience is the platform's own
|
|
15
|
+
services, is refused;
|
|
16
|
+
* ``exp`` in the future (60 s leeway, the platform's own tolerance is 30 s).
|
|
17
|
+
|
|
18
|
+
Two more checks are the caller's: ``jti == body.run_id`` is done by the server
|
|
19
|
+
because it needs the body, and ``wf``, the workflow claim the platform stamps on
|
|
20
|
+
pro-code tokens, is checked *when present* — it was introduced on a separate
|
|
21
|
+
platform branch and a token without it is still a valid token for the audience
|
|
22
|
+
it names.
|
|
23
|
+
|
|
24
|
+
Key handling copies ``libs/common/signing.py``: keys are looked up by ``kid``,
|
|
25
|
+
cached for ``cache_ttl`` seconds, and a ``kid`` the cache does not know
|
|
26
|
+
triggers exactly one refetch before the token is refused — that is what makes
|
|
27
|
+
a key rotation invisible to a running service without letting a forged ``kid``
|
|
28
|
+
turn every request into a JWKS fetch.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import asyncio
|
|
34
|
+
import base64
|
|
35
|
+
import logging
|
|
36
|
+
import os
|
|
37
|
+
import time
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from datetime import datetime, timezone
|
|
40
|
+
from typing import Any, Mapping, Protocol
|
|
41
|
+
|
|
42
|
+
import httpx
|
|
43
|
+
import jwt
|
|
44
|
+
|
|
45
|
+
from badgerflow.contract.constants import RUN_TOKEN_ISSUER, RUN_TOKEN_TYPE, procode_audience
|
|
46
|
+
|
|
47
|
+
log = logging.getLogger("badgerflow.auth")
|
|
48
|
+
|
|
49
|
+
#: Leeway on ``exp``. Generous next to the platform's 30 s because a customer's
|
|
50
|
+
#: cluster and the platform's are two clocks that nobody syncs on purpose.
|
|
51
|
+
EXP_LEEWAY_SECONDS = 60
|
|
52
|
+
|
|
53
|
+
#: Environment the server reads when the caller passes nothing explicit.
|
|
54
|
+
ENV_JWKS_URL = "BADGERFLOW_JWKS_URL"
|
|
55
|
+
ENV_WORKFLOW_ID = "BADGERFLOW_WORKFLOW_ID"
|
|
56
|
+
ENV_AUTH_DISABLED = "BADGERFLOW_AUTH_DISABLED"
|
|
57
|
+
ENV_ENV = "BADGERFLOW_ENV"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RunTokenError(Exception):
|
|
61
|
+
"""The bearer is not a run token this service accepts. The message is safe
|
|
62
|
+
to log; the server answers 401 ``unauthorized`` without echoing it."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class RunTokenClaims:
|
|
67
|
+
"""What a verified run token asserts. ``run_id`` is the ``jti`` — the binding
|
|
68
|
+
the server compares to ``body.run_id`` — and ``workflow_id`` is the id the
|
|
69
|
+
verifier was configured with (equal to the token's ``wf`` when it has one)."""
|
|
70
|
+
|
|
71
|
+
run_id: str
|
|
72
|
+
namespace: str
|
|
73
|
+
trust_tier: str
|
|
74
|
+
workflow_id: str
|
|
75
|
+
expires_at: datetime
|
|
76
|
+
subject: str = ""
|
|
77
|
+
audience: tuple[str, ...] = ()
|
|
78
|
+
claims: Mapping[str, Any] = field(default_factory=dict, repr=False)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Verifier(Protocol):
|
|
82
|
+
"""What the server needs from a verifier; :class:`RunTokenVerifier` is the
|
|
83
|
+
real one, tests inject a stand-in with the same single method."""
|
|
84
|
+
|
|
85
|
+
async def verify(self, token: str) -> RunTokenClaims: ...
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _b64url_decode(data: str) -> bytes:
|
|
89
|
+
return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _public_key_from_jwk(jwk: Mapping[str, Any]) -> Any:
|
|
93
|
+
"""An ``Ed25519PublicKey`` from an OKP JWK; ``None`` for any other key type.
|
|
94
|
+
|
|
95
|
+
The platform's JWKS carries ``{"kty": "OKP", "crv": "Ed25519", "kid", "x"}``
|
|
96
|
+
(``libs/common/signing.get_public_key_jwk``). Keys of another type are
|
|
97
|
+
skipped rather than refused so a JWKS that one day also lists an RSA key
|
|
98
|
+
keeps working for the Ed25519 tokens this verifier is for.
|
|
99
|
+
"""
|
|
100
|
+
if jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519" or not jwk.get("x"):
|
|
101
|
+
return None
|
|
102
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
return Ed25519PublicKey.from_public_bytes(_b64url_decode(str(jwk["x"])))
|
|
106
|
+
except (ValueError, TypeError):
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def keys_from_jwks(jwks: Mapping[str, Any]) -> dict[str, list[Any]]:
|
|
111
|
+
"""``kid -> [public keys]`` for every Ed25519 key in a JWKS document.
|
|
112
|
+
|
|
113
|
+
A list per ``kid`` because the platform may publish a rotation predecessor
|
|
114
|
+
under the same id for the length of a rollout (``_public_keys_for_kid``);
|
|
115
|
+
verification tries each.
|
|
116
|
+
"""
|
|
117
|
+
out: dict[str, list[Any]] = {}
|
|
118
|
+
for entry in jwks.get("keys") or ():
|
|
119
|
+
if not isinstance(entry, Mapping):
|
|
120
|
+
continue
|
|
121
|
+
key = _public_key_from_jwk(entry)
|
|
122
|
+
if key is None:
|
|
123
|
+
continue
|
|
124
|
+
out.setdefault(str(entry.get("kid", "")), []).append(key)
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class RunTokenVerifier:
|
|
129
|
+
"""Verify run tokens for one pro-code workflow against the platform's JWKS.
|
|
130
|
+
|
|
131
|
+
``jwks`` injects a static JWKS document (unit tests, air-gapped dev); with
|
|
132
|
+
it no HTTP fetch happens and a ``kid`` miss is simply a miss. Otherwise the
|
|
133
|
+
document is fetched from ``jwks_url`` on first use, re-fetched after
|
|
134
|
+
``cache_ttl`` seconds, and re-fetched once, immediately, on a ``kid`` the
|
|
135
|
+
cache does not hold. Concurrent misses share one fetch.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
jwks_url: str | None,
|
|
141
|
+
workflow_id: str,
|
|
142
|
+
*,
|
|
143
|
+
cache_ttl: float = 300.0,
|
|
144
|
+
jwks: Mapping[str, Any] | None = None,
|
|
145
|
+
http_client: httpx.AsyncClient | None = None,
|
|
146
|
+
timeout: float = 5.0,
|
|
147
|
+
):
|
|
148
|
+
if not workflow_id:
|
|
149
|
+
raise ValueError("RunTokenVerifier needs the workflow id the audience is checked against")
|
|
150
|
+
if jwks is None and not jwks_url:
|
|
151
|
+
raise ValueError("RunTokenVerifier needs a jwks_url (or an injected jwks document)")
|
|
152
|
+
self.jwks_url = jwks_url
|
|
153
|
+
self.workflow_id = workflow_id
|
|
154
|
+
self.audience = procode_audience(workflow_id)
|
|
155
|
+
self.cache_ttl = cache_ttl
|
|
156
|
+
self._static = jwks is not None
|
|
157
|
+
self._keys: dict[str, list[Any]] = keys_from_jwks(jwks) if jwks is not None else {}
|
|
158
|
+
self._fetched_at: float = time.monotonic() if jwks is not None else float("-inf")
|
|
159
|
+
self._client = http_client
|
|
160
|
+
self._timeout = timeout
|
|
161
|
+
self._lock = asyncio.Lock()
|
|
162
|
+
self.fetch_count = 0
|
|
163
|
+
|
|
164
|
+
# ── JWKS ─────────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
async def _fetch(self) -> None:
|
|
167
|
+
assert self.jwks_url
|
|
168
|
+
client = self._client
|
|
169
|
+
try:
|
|
170
|
+
if client is None:
|
|
171
|
+
async with httpx.AsyncClient(timeout=self._timeout) as own:
|
|
172
|
+
resp = await own.get(self.jwks_url)
|
|
173
|
+
else:
|
|
174
|
+
resp = await client.get(self.jwks_url)
|
|
175
|
+
resp.raise_for_status()
|
|
176
|
+
doc = resp.json()
|
|
177
|
+
except (httpx.HTTPError, ValueError) as exc:
|
|
178
|
+
raise RunTokenError(f"JWKS fetch from {self.jwks_url} failed: {exc}") from exc
|
|
179
|
+
if not isinstance(doc, dict):
|
|
180
|
+
raise RunTokenError("JWKS document is not an object")
|
|
181
|
+
self._keys = keys_from_jwks(doc)
|
|
182
|
+
self._fetched_at = time.monotonic()
|
|
183
|
+
self.fetch_count += 1
|
|
184
|
+
log.debug("badgerflow: loaded %d signing key ids from %s", len(self._keys), self.jwks_url)
|
|
185
|
+
|
|
186
|
+
async def _keys_for(self, kid: str) -> list[Any]:
|
|
187
|
+
"""Keys for *kid*, refreshing the cache when it is stale or does not
|
|
188
|
+
know the id. Refresh happens at most once per call, so a token naming
|
|
189
|
+
an unknown ``kid`` costs one fetch, not a retry storm."""
|
|
190
|
+
if self._static:
|
|
191
|
+
return self._keys.get(kid, [])
|
|
192
|
+
async with self._lock:
|
|
193
|
+
stale = time.monotonic() - self._fetched_at > self.cache_ttl
|
|
194
|
+
if stale or kid not in self._keys:
|
|
195
|
+
await self._fetch()
|
|
196
|
+
return self._keys.get(kid, [])
|
|
197
|
+
|
|
198
|
+
# ── verification ─────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
async def verify(self, token: str) -> RunTokenClaims:
|
|
201
|
+
"""Return the claims of a run token valid for this workflow, or raise
|
|
202
|
+
:class:`RunTokenError`. Every rejection reason is one exception type so
|
|
203
|
+
the server can map all of them to 401 without inspecting them."""
|
|
204
|
+
if not token:
|
|
205
|
+
raise RunTokenError("missing bearer token")
|
|
206
|
+
try:
|
|
207
|
+
header = jwt.get_unverified_header(token)
|
|
208
|
+
except jwt.PyJWTError as exc:
|
|
209
|
+
raise RunTokenError(f"malformed token: {exc}") from exc
|
|
210
|
+
if header.get("alg") not in ("EdDSA", "Ed25519"):
|
|
211
|
+
raise RunTokenError(f"unsupported alg {header.get('alg')!r}")
|
|
212
|
+
kid = str(header.get("kid") or "")
|
|
213
|
+
candidates = await self._keys_for(kid)
|
|
214
|
+
if not candidates:
|
|
215
|
+
raise RunTokenError(f"no signing key for kid={kid!r}")
|
|
216
|
+
|
|
217
|
+
payload: dict[str, Any] | None = None
|
|
218
|
+
last: Exception | None = None
|
|
219
|
+
for key in candidates:
|
|
220
|
+
try:
|
|
221
|
+
payload = jwt.decode(
|
|
222
|
+
token,
|
|
223
|
+
key=key,
|
|
224
|
+
algorithms=["EdDSA"],
|
|
225
|
+
audience=self.audience,
|
|
226
|
+
issuer=RUN_TOKEN_ISSUER,
|
|
227
|
+
leeway=EXP_LEEWAY_SECONDS,
|
|
228
|
+
options={"require": ["exp", "iss", "aud", "jti"]},
|
|
229
|
+
)
|
|
230
|
+
break
|
|
231
|
+
except jwt.InvalidSignatureError as exc:
|
|
232
|
+
last = exc # a rotation predecessor under the same kid may still match
|
|
233
|
+
except jwt.PyJWTError as exc:
|
|
234
|
+
raise RunTokenError(f"run token rejected: {exc}") from exc
|
|
235
|
+
if payload is None:
|
|
236
|
+
raise RunTokenError(f"run token rejected: {last}")
|
|
237
|
+
|
|
238
|
+
if payload.get("tok") != RUN_TOKEN_TYPE:
|
|
239
|
+
raise RunTokenError("not a run token (tok != run)")
|
|
240
|
+
wf = payload.get("wf")
|
|
241
|
+
if wf is not None and str(wf) != self.workflow_id:
|
|
242
|
+
raise RunTokenError(f"run token is for workflow {wf!r}, not {self.workflow_id!r}")
|
|
243
|
+
run_id = str(payload.get("jti") or "")
|
|
244
|
+
if not run_id:
|
|
245
|
+
raise RunTokenError("run token has an empty jti")
|
|
246
|
+
|
|
247
|
+
aud = payload.get("aud") or ()
|
|
248
|
+
return RunTokenClaims(
|
|
249
|
+
run_id=run_id,
|
|
250
|
+
namespace=str(payload.get("ns") or ""),
|
|
251
|
+
trust_tier=str(payload.get("trust_tier") or ""),
|
|
252
|
+
workflow_id=self.workflow_id,
|
|
253
|
+
expires_at=datetime.fromtimestamp(int(payload["exp"]), tz=timezone.utc),
|
|
254
|
+
subject=str(payload.get("sub") or ""),
|
|
255
|
+
audience=(aud,) if isinstance(aud, str) else tuple(str(a) for a in aud),
|
|
256
|
+
claims=dict(payload),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _truthy(value: str | None) -> bool:
|
|
261
|
+
return (value or "").strip().lower() in ("1", "true", "yes", "on")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def auth_disabled_from_env(environ: Mapping[str, str] | None = None) -> bool:
|
|
265
|
+
"""``True`` only when ``BADGERFLOW_AUTH_DISABLED`` is set *and*
|
|
266
|
+
``BADGERFLOW_ENV=dev``. Set outside dev it raises rather than being ignored:
|
|
267
|
+
a deployment that asked for no auth and silently kept it would be as
|
|
268
|
+
surprising as one that silently dropped it.
|
|
269
|
+
"""
|
|
270
|
+
env = os.environ if environ is None else environ
|
|
271
|
+
if not _truthy(env.get(ENV_AUTH_DISABLED)):
|
|
272
|
+
return False
|
|
273
|
+
if (env.get(ENV_ENV) or "").strip().lower() != "dev":
|
|
274
|
+
raise RuntimeError(
|
|
275
|
+
f"{ENV_AUTH_DISABLED} is only honoured with {ENV_ENV}=dev; "
|
|
276
|
+
"refusing to start without run-token verification"
|
|
277
|
+
)
|
|
278
|
+
log.warning(
|
|
279
|
+
"badgerflow: %s=true — run tokens are NOT verified; every /invocations caller is "
|
|
280
|
+
"trusted. This is a dev-only setting.",
|
|
281
|
+
ENV_AUTH_DISABLED,
|
|
282
|
+
)
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def verifier_from_env(
|
|
287
|
+
workflow_id: str | None = None,
|
|
288
|
+
jwks_url: str | None = None,
|
|
289
|
+
*,
|
|
290
|
+
environ: Mapping[str, str] | None = None,
|
|
291
|
+
cache_ttl: float = 300.0,
|
|
292
|
+
) -> RunTokenVerifier:
|
|
293
|
+
"""A :class:`RunTokenVerifier` from explicit values or ``BADGERFLOW_JWKS_URL``
|
|
294
|
+
/ ``BADGERFLOW_WORKFLOW_ID``. Raises ``RuntimeError`` naming the missing
|
|
295
|
+
variable — the server calls this at build so a misconfigured deployment
|
|
296
|
+
fails at startup, not on its first request."""
|
|
297
|
+
env = os.environ if environ is None else environ
|
|
298
|
+
wf = workflow_id or env.get(ENV_WORKFLOW_ID) or ""
|
|
299
|
+
url = jwks_url or env.get(ENV_JWKS_URL) or ""
|
|
300
|
+
if not wf:
|
|
301
|
+
raise RuntimeError(f"{ENV_WORKFLOW_ID} is not set (registration sets it)")
|
|
302
|
+
if not url:
|
|
303
|
+
raise RuntimeError(f"{ENV_JWKS_URL} is not set (e.g. http://auth-service:8090/v1/auth/jwks)")
|
|
304
|
+
return RunTokenVerifier(url, wf, cache_ttl=cache_ttl)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def unverified_claims(token: str | None) -> dict[str, Any]:
|
|
308
|
+
"""The payload of *token* WITHOUT verification — for the dev escape hatch
|
|
309
|
+
only, so a locally minted token still populates the context. Never
|
|
310
|
+
authentication."""
|
|
311
|
+
if not token:
|
|
312
|
+
return {}
|
|
313
|
+
try:
|
|
314
|
+
return dict(jwt.decode(token, options={"verify_signature": False}))
|
|
315
|
+
except jwt.PyJWTError:
|
|
316
|
+
return {}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
__all__ = [
|
|
320
|
+
"ENV_AUTH_DISABLED",
|
|
321
|
+
"ENV_ENV",
|
|
322
|
+
"ENV_JWKS_URL",
|
|
323
|
+
"ENV_WORKFLOW_ID",
|
|
324
|
+
"EXP_LEEWAY_SECONDS",
|
|
325
|
+
"RunTokenClaims",
|
|
326
|
+
"RunTokenError",
|
|
327
|
+
"RunTokenVerifier",
|
|
328
|
+
"Verifier",
|
|
329
|
+
"auth_disabled_from_env",
|
|
330
|
+
"keys_from_jwks",
|
|
331
|
+
"unverified_claims",
|
|
332
|
+
"verifier_from_env",
|
|
333
|
+
]
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""badgerflow.cli — the ``bf`` CLI: ``init``, ``dev``, ``validate``, ``register``,
|
|
2
|
+
``status``, ``version`` (S0.5). S2.1 adds ``release submit | status``, ``validate
|
|
3
|
+
--against`` and ``compile --check``. ``project.py`` is the ``badgerflow.yaml`` +
|
|
4
|
+
``agent.py`` loader, ``scaffold.py`` the ``bf init`` templates. The legacy ``agiel``
|
|
5
|
+
CLI lives in ``badgerflow.client.cli``."""
|