firetail-finchly 0.0.1__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.
- firetail_finchly/__init__.py +37 -0
- firetail_finchly/_version.py +1 -0
- firetail_finchly/config.py +90 -0
- firetail_finchly/errors.py +48 -0
- firetail_finchly/identity.py +322 -0
- firetail_finchly/manifest.py +97 -0
- firetail_finchly/platform_api.py +24 -0
- firetail_finchly/py.typed +0 -0
- firetail_finchly/service.py +351 -0
- firetail_finchly-0.0.1.dist-info/METADATA +242 -0
- firetail_finchly-0.0.1.dist-info/RECORD +12 -0
- firetail_finchly-0.0.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Python SDK for the Finchly platform."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .config import DEFAULT_MANIFEST_PATH, PlatformConfig, load_config
|
|
5
|
+
from .errors import IDENTITY_REJECTIONS, FinchlyError, IdentityError
|
|
6
|
+
from .identity import IDENTITY_HEADER, Identity, IdentityVerifier
|
|
7
|
+
from .manifest import check_manifest, load_manifest
|
|
8
|
+
from .platform_api import (
|
|
9
|
+
MANIFEST_VERSION,
|
|
10
|
+
PLATFORM_API_VERSION,
|
|
11
|
+
SDK_NAME,
|
|
12
|
+
SDK_VERSION,
|
|
13
|
+
SUPPORTED_PLATFORM_API_VERSIONS,
|
|
14
|
+
)
|
|
15
|
+
from .service import ServiceClient, ServiceResponse
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"DEFAULT_MANIFEST_PATH",
|
|
19
|
+
"FinchlyError",
|
|
20
|
+
"IDENTITY_HEADER",
|
|
21
|
+
"IDENTITY_REJECTIONS",
|
|
22
|
+
"Identity",
|
|
23
|
+
"IdentityError",
|
|
24
|
+
"IdentityVerifier",
|
|
25
|
+
"MANIFEST_VERSION",
|
|
26
|
+
"PLATFORM_API_VERSION",
|
|
27
|
+
"PlatformConfig",
|
|
28
|
+
"SDK_NAME",
|
|
29
|
+
"SDK_VERSION",
|
|
30
|
+
"SUPPORTED_PLATFORM_API_VERSIONS",
|
|
31
|
+
"ServiceClient",
|
|
32
|
+
"ServiceResponse",
|
|
33
|
+
"__version__",
|
|
34
|
+
"check_manifest",
|
|
35
|
+
"load_config",
|
|
36
|
+
"load_manifest",
|
|
37
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Settings a service is deployed with. See spec/registration.md, section 1."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Mapping
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from .errors import FinchlyError
|
|
11
|
+
|
|
12
|
+
#: Where the manifest lives in an image unless PLATFORM_MANIFEST_PATH says otherwise.
|
|
13
|
+
DEFAULT_MANIFEST_PATH = "/finchly/manifest.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class PlatformConfig:
|
|
18
|
+
#: The gateway's internal address, without a trailing slash.
|
|
19
|
+
gateway_url: str
|
|
20
|
+
#: Path of the manifest inside the container.
|
|
21
|
+
manifest_path: str = DEFAULT_MANIFEST_PATH
|
|
22
|
+
#: Address the gateway should use to reach this service, if set.
|
|
23
|
+
app_address: str | None = None
|
|
24
|
+
#: Enrolment token for self-registration, if set. Left out of the repr,
|
|
25
|
+
#: so it does not appear when the config is logged.
|
|
26
|
+
enrolment_token: str | None = field(default=None, repr=False)
|
|
27
|
+
#: The credential a built-in service registers its instance key with,
|
|
28
|
+
#: if set. Left out of the repr, like the enrolment token.
|
|
29
|
+
service_credential: str | None = field(default=None, repr=False)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _http_url(name: str, value: str, problems: list[str]) -> str | None:
|
|
33
|
+
try:
|
|
34
|
+
parts = urlsplit(value)
|
|
35
|
+
# Reading these can raise for a malformed port or host.
|
|
36
|
+
_ = parts.port, parts.hostname
|
|
37
|
+
except ValueError:
|
|
38
|
+
problems.append(f"{name} is not a URL")
|
|
39
|
+
return None
|
|
40
|
+
if not parts.scheme or " " in value:
|
|
41
|
+
problems.append(f"{name} is not a URL")
|
|
42
|
+
return None
|
|
43
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
44
|
+
problems.append(f"{name} must be an http or https URL")
|
|
45
|
+
return None
|
|
46
|
+
if parts.username or parts.password:
|
|
47
|
+
problems.append(f"{name} must not contain credentials")
|
|
48
|
+
return None
|
|
49
|
+
if parts.query or parts.fragment:
|
|
50
|
+
problems.append(f"{name} must not have a query string or fragment")
|
|
51
|
+
return None
|
|
52
|
+
return value.rstrip("/")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_config(env: Mapping[str, str] | None = None) -> PlatformConfig:
|
|
56
|
+
"""Read the platform settings from the environment.
|
|
57
|
+
|
|
58
|
+
Raises a FinchlyError listing every problem, so a misconfigured
|
|
59
|
+
container fails once with the full picture.
|
|
60
|
+
"""
|
|
61
|
+
env = os.environ if env is None else env
|
|
62
|
+
problems: list[str] = []
|
|
63
|
+
|
|
64
|
+
gateway_url: str | None = None
|
|
65
|
+
raw_gateway = (env.get("PLATFORM_GATEWAY_URL") or "").strip()
|
|
66
|
+
if not raw_gateway:
|
|
67
|
+
problems.append("PLATFORM_GATEWAY_URL is not set")
|
|
68
|
+
else:
|
|
69
|
+
gateway_url = _http_url("PLATFORM_GATEWAY_URL", raw_gateway, problems)
|
|
70
|
+
|
|
71
|
+
raw_address = (env.get("PLATFORM_APP_ADDRESS") or "").strip()
|
|
72
|
+
app_address = _http_url("PLATFORM_APP_ADDRESS", raw_address, problems) if raw_address else None
|
|
73
|
+
|
|
74
|
+
manifest_path = (env.get("PLATFORM_MANIFEST_PATH") or "").strip() or DEFAULT_MANIFEST_PATH
|
|
75
|
+
if not manifest_path.startswith("/"):
|
|
76
|
+
problems.append("PLATFORM_MANIFEST_PATH must be an absolute path")
|
|
77
|
+
|
|
78
|
+
enrolment_token = (env.get("PLATFORM_ENROLMENT_TOKEN") or "").strip() or None
|
|
79
|
+
service_credential = (env.get("PLATFORM_SERVICE_CREDENTIAL") or "").strip() or None
|
|
80
|
+
|
|
81
|
+
if problems or gateway_url is None:
|
|
82
|
+
raise FinchlyError("config_invalid", "Platform settings are invalid", problems)
|
|
83
|
+
|
|
84
|
+
return PlatformConfig(
|
|
85
|
+
gateway_url=gateway_url,
|
|
86
|
+
manifest_path=manifest_path,
|
|
87
|
+
app_address=app_address,
|
|
88
|
+
enrolment_token=enrolment_token,
|
|
89
|
+
service_credential=service_credential,
|
|
90
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Sequence
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FinchlyError(Exception):
|
|
7
|
+
"""Every error this SDK raises on purpose.
|
|
8
|
+
|
|
9
|
+
``code`` is stable and safe to branch on. ``problems`` lists each thing
|
|
10
|
+
that was wrong, so a caller can report all of them at once instead of
|
|
11
|
+
one per restart.
|
|
12
|
+
|
|
13
|
+
Codes: ``config_invalid``, ``manifest_invalid``, ``manifest_unreadable``,
|
|
14
|
+
``identity_invalid``, ``gateway_unreachable``, ``platform_api_unsupported``,
|
|
15
|
+
``service_call_invalid``, ``service_key_rejected``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, code: str, message: str, problems: Sequence[str] = ()) -> None:
|
|
19
|
+
self.code = code
|
|
20
|
+
self.problems: tuple[str, ...] = tuple(problems)
|
|
21
|
+
text = f"{message}: {'; '.join(self.problems)}" if self.problems else message
|
|
22
|
+
super().__init__(text)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
#: Why an identity token can be rejected. Defined in spec/identity-token.md.
|
|
26
|
+
IDENTITY_REJECTIONS = (
|
|
27
|
+
"missing",
|
|
28
|
+
"malformed",
|
|
29
|
+
"algorithm",
|
|
30
|
+
"unknown_key",
|
|
31
|
+
"signature",
|
|
32
|
+
"issuer",
|
|
33
|
+
"audience",
|
|
34
|
+
"expired",
|
|
35
|
+
"claims",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class IdentityError(FinchlyError):
|
|
40
|
+
"""The request carries no acceptable identity.
|
|
41
|
+
|
|
42
|
+
Treat it as unauthenticated (HTTP 401). ``reason`` is one of
|
|
43
|
+
``IDENTITY_REJECTIONS`` and is for logs; do not show it to the caller.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, reason: str) -> None:
|
|
47
|
+
super().__init__("identity_invalid", f"Identity token rejected: {reason}")
|
|
48
|
+
self.reason = reason
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Verifies the x-platform-identity token.
|
|
2
|
+
|
|
3
|
+
Every check here is required by spec/identity-token.md in
|
|
4
|
+
firetail-io/finchly, and the conformance vectors in tests/conformance pin
|
|
5
|
+
the verdict for each kind of bad token.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from types import MappingProxyType
|
|
18
|
+
from typing import Any, Callable, Mapping
|
|
19
|
+
from urllib.parse import urlsplit
|
|
20
|
+
|
|
21
|
+
import jwt
|
|
22
|
+
|
|
23
|
+
from .errors import FinchlyError, IdentityError
|
|
24
|
+
from .platform_api import SUPPORTED_PLATFORM_API_VERSIONS
|
|
25
|
+
|
|
26
|
+
#: Header the gateway sets on every request it proxies.
|
|
27
|
+
IDENTITY_HEADER = "x-platform-identity"
|
|
28
|
+
|
|
29
|
+
_REQUIRED_CLAIMS = ("iss", "sub", "aud", "iat", "exp")
|
|
30
|
+
_DEFAULT_CLOCK_SKEW_SECONDS = 30
|
|
31
|
+
_KEY_CACHE_SECONDS = 60 * 60
|
|
32
|
+
_KEY_REFETCH_COOLDOWN_SECONDS = 30
|
|
33
|
+
_GATEWAY_TIMEOUT_SECONDS = 5.0
|
|
34
|
+
_SLUG = re.compile(r"[a-z][a-z0-9]*(-[a-z0-9]+)*")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Identity:
|
|
39
|
+
"""Who is calling, as asserted by the gateway."""
|
|
40
|
+
|
|
41
|
+
#: The user's platform id, or ``service:<slug>`` for a service calling as itself.
|
|
42
|
+
subject: str
|
|
43
|
+
#: True when the caller is a service calling as itself, with no user.
|
|
44
|
+
service: bool
|
|
45
|
+
email: str | None
|
|
46
|
+
name: str | None
|
|
47
|
+
#: Platform group names.
|
|
48
|
+
groups: tuple[str, ...]
|
|
49
|
+
#: The user's roles for this service.
|
|
50
|
+
roles: tuple[str, ...]
|
|
51
|
+
#: How the caller was authenticated.
|
|
52
|
+
amr: tuple[str, ...]
|
|
53
|
+
#: ``service:<slug>`` when a service is acting on the user's behalf.
|
|
54
|
+
actor: str | None
|
|
55
|
+
session_id: str | None
|
|
56
|
+
#: Every claim in the token. Read-only.
|
|
57
|
+
claims: Mapping[str, Any]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_number(value: Any) -> bool:
|
|
61
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _optional_string(claims: Mapping[str, Any], name: str) -> str | None:
|
|
65
|
+
value = claims.get(name)
|
|
66
|
+
if value is None and name not in claims:
|
|
67
|
+
return None
|
|
68
|
+
if not isinstance(value, str):
|
|
69
|
+
raise IdentityError("claims")
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _string_tuple(claims: Mapping[str, Any], name: str) -> tuple[str, ...]:
|
|
74
|
+
if name not in claims:
|
|
75
|
+
return ()
|
|
76
|
+
value = claims[name]
|
|
77
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
78
|
+
raise IdentityError("claims")
|
|
79
|
+
return tuple(value)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _keys_by_id(jwks: Any) -> dict[str, Any]:
|
|
83
|
+
"""Index a JWKS by kid, keeping only keys usable for ES256."""
|
|
84
|
+
keys: dict[str, Any] = {}
|
|
85
|
+
entries = jwks.get("keys") if isinstance(jwks, dict) else None
|
|
86
|
+
if not isinstance(entries, list):
|
|
87
|
+
raise ValueError("key set has no keys list")
|
|
88
|
+
for entry in entries:
|
|
89
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("kid"), str):
|
|
90
|
+
continue
|
|
91
|
+
if entry.get("kty") != "EC" or entry.get("crv") != "P-256":
|
|
92
|
+
continue
|
|
93
|
+
try:
|
|
94
|
+
keys[entry["kid"]] = jwt.PyJWK(entry, algorithm="ES256").key
|
|
95
|
+
except jwt.PyJWTError:
|
|
96
|
+
continue
|
|
97
|
+
return keys
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class IdentityVerifier:
|
|
101
|
+
"""Verifies identity tokens for one service.
|
|
102
|
+
|
|
103
|
+
Create one at startup and reuse it: it caches the gateway's issuer and
|
|
104
|
+
signing keys. It is safe to share between threads.
|
|
105
|
+
|
|
106
|
+
Give ``gateway_url`` (``PLATFORM_GATEWAY_URL``); the issuer and keys are
|
|
107
|
+
fetched from it. ``issuer`` and ``jwks`` together fix them instead, for
|
|
108
|
+
tests.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
*,
|
|
114
|
+
slug: str,
|
|
115
|
+
gateway_url: str | None = None,
|
|
116
|
+
issuer: str | None = None,
|
|
117
|
+
jwks: Mapping[str, Any] | None = None,
|
|
118
|
+
clock_skew_seconds: float = _DEFAULT_CLOCK_SKEW_SECONDS,
|
|
119
|
+
now: Callable[[], float] | None = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
problems: list[str] = []
|
|
122
|
+
if not isinstance(slug, str) or not _SLUG.fullmatch(slug):
|
|
123
|
+
problems.append("slug must be this service's slug")
|
|
124
|
+
fixed = issuer is not None or jwks is not None
|
|
125
|
+
if fixed and gateway_url is not None:
|
|
126
|
+
problems.append("give either gateway_url, or issuer and jwks, not both")
|
|
127
|
+
elif fixed and (issuer is None or jwks is None):
|
|
128
|
+
problems.append("issuer and jwks must be given together")
|
|
129
|
+
elif not fixed and not gateway_url:
|
|
130
|
+
problems.append("gateway_url is required")
|
|
131
|
+
elif not fixed and urlsplit(gateway_url or "").scheme not in ("http", "https"):
|
|
132
|
+
# urllib would otherwise follow file: and other schemes.
|
|
133
|
+
problems.append("gateway_url must be an http or https URL")
|
|
134
|
+
if not _is_number(clock_skew_seconds) or clock_skew_seconds < 0:
|
|
135
|
+
problems.append("clock_skew_seconds must be zero or more")
|
|
136
|
+
if problems:
|
|
137
|
+
raise FinchlyError("config_invalid", "Identity verifier options are invalid", problems)
|
|
138
|
+
|
|
139
|
+
self._slug = slug
|
|
140
|
+
self._skew = clock_skew_seconds
|
|
141
|
+
#: Seconds since the epoch.
|
|
142
|
+
self._now = now or time.time
|
|
143
|
+
self._gateway_url = gateway_url.rstrip("/") if gateway_url else None
|
|
144
|
+
self._lock = threading.Lock()
|
|
145
|
+
|
|
146
|
+
self._issuer: str | None = issuer
|
|
147
|
+
self._keys: dict[str, Any] | None = None
|
|
148
|
+
self._keys_fetched_at = 0.0
|
|
149
|
+
if jwks is not None:
|
|
150
|
+
try:
|
|
151
|
+
self._keys = _keys_by_id(dict(jwks))
|
|
152
|
+
except ValueError as exc:
|
|
153
|
+
raise FinchlyError(
|
|
154
|
+
"config_invalid", "Identity verifier options are invalid", [f"jwks: {exc}"]
|
|
155
|
+
) from exc
|
|
156
|
+
|
|
157
|
+
# ── Gateway ────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
def _get_json(self, path: str) -> Any:
|
|
160
|
+
url = f"{self._gateway_url}{path}"
|
|
161
|
+
try:
|
|
162
|
+
with urllib.request.urlopen(url, timeout=_GATEWAY_TIMEOUT_SECONDS) as response:
|
|
163
|
+
return json.loads(response.read().decode("utf-8"))
|
|
164
|
+
except (urllib.error.URLError, OSError, ValueError) as exc:
|
|
165
|
+
raise FinchlyError("gateway_unreachable", f"Cannot read {url}", [str(exc)]) from exc
|
|
166
|
+
|
|
167
|
+
def _discovered_issuer(self) -> str:
|
|
168
|
+
"""The gateway's issuer. A failed discovery is not cached."""
|
|
169
|
+
if self._issuer is not None:
|
|
170
|
+
return self._issuer
|
|
171
|
+
with self._lock:
|
|
172
|
+
if self._issuer is None:
|
|
173
|
+
path = "/_gateway/.well-known/platform.json"
|
|
174
|
+
doc = self._get_json(path)
|
|
175
|
+
issuer = doc.get("issuer") if isinstance(doc, dict) else None
|
|
176
|
+
if not isinstance(issuer, str) or not issuer:
|
|
177
|
+
raise FinchlyError(
|
|
178
|
+
"gateway_unreachable", f"{self._gateway_url}{path} does not name an issuer"
|
|
179
|
+
)
|
|
180
|
+
served = doc.get("platformApi")
|
|
181
|
+
served = served if isinstance(served, list) else []
|
|
182
|
+
if not any(v in SUPPORTED_PLATFORM_API_VERSIONS for v in served):
|
|
183
|
+
raise FinchlyError(
|
|
184
|
+
"platform_api_unsupported",
|
|
185
|
+
f"The gateway serves platform API {json.dumps(served)}; "
|
|
186
|
+
f"this SDK speaks {', '.join(SUPPORTED_PLATFORM_API_VERSIONS)}",
|
|
187
|
+
)
|
|
188
|
+
self._issuer = issuer
|
|
189
|
+
return self._issuer
|
|
190
|
+
|
|
191
|
+
def _key_for(self, kid: Any) -> Any:
|
|
192
|
+
"""The published key with this id, refetching when it is not known."""
|
|
193
|
+
if not isinstance(kid, str):
|
|
194
|
+
raise IdentityError("unknown_key")
|
|
195
|
+
if self._gateway_url is None:
|
|
196
|
+
key = (self._keys or {}).get(kid)
|
|
197
|
+
if key is None:
|
|
198
|
+
raise IdentityError("unknown_key")
|
|
199
|
+
return key
|
|
200
|
+
|
|
201
|
+
with self._lock:
|
|
202
|
+
age = time.monotonic() - self._keys_fetched_at
|
|
203
|
+
known = self._keys is not None and kid in self._keys
|
|
204
|
+
stale = self._keys is None or age >= _KEY_CACHE_SECONDS
|
|
205
|
+
# An unknown kid may mean the gateway has rotated its key, so
|
|
206
|
+
# look again, but not more often than the cooldown allows.
|
|
207
|
+
may_refetch = self._keys is None or age >= _KEY_REFETCH_COOLDOWN_SECONDS
|
|
208
|
+
if stale or (not known and may_refetch):
|
|
209
|
+
try:
|
|
210
|
+
fetched = _keys_by_id(self._get_json("/_gateway/.well-known/jwks.json"))
|
|
211
|
+
except ValueError as exc:
|
|
212
|
+
raise FinchlyError(
|
|
213
|
+
"gateway_unreachable", "The gateway's key set is not usable", [str(exc)]
|
|
214
|
+
) from exc
|
|
215
|
+
self._keys = fetched
|
|
216
|
+
self._keys_fetched_at = time.monotonic()
|
|
217
|
+
key = (self._keys or {}).get(kid)
|
|
218
|
+
if key is None:
|
|
219
|
+
raise IdentityError("unknown_key")
|
|
220
|
+
return key
|
|
221
|
+
|
|
222
|
+
# ── Verification ───────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
def verify(self, token: str | None) -> Identity:
|
|
225
|
+
"""Verify a token. Raises IdentityError if it is not acceptable."""
|
|
226
|
+
if not isinstance(token, str) or not token:
|
|
227
|
+
raise IdentityError("missing")
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
header = jwt.get_unverified_header(token)
|
|
231
|
+
except jwt.PyJWTError as exc:
|
|
232
|
+
raise IdentityError("malformed") from exc
|
|
233
|
+
# Checked first and by hand, so that no other property of the token
|
|
234
|
+
# can influence what happens to one with the wrong algorithm.
|
|
235
|
+
if header.get("alg") != "ES256":
|
|
236
|
+
raise IdentityError("algorithm")
|
|
237
|
+
|
|
238
|
+
issuer = self._discovered_issuer()
|
|
239
|
+
key = self._key_for(header.get("kid"))
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
# Only the signature is checked here. The claims are checked
|
|
243
|
+
# below, against this verifier's clock, so that the rules are the
|
|
244
|
+
# ones in the specification and not the library's defaults.
|
|
245
|
+
claims = jwt.decode(
|
|
246
|
+
token,
|
|
247
|
+
key=key,
|
|
248
|
+
algorithms=["ES256"],
|
|
249
|
+
options={
|
|
250
|
+
"verify_signature": True,
|
|
251
|
+
"verify_exp": False,
|
|
252
|
+
"verify_nbf": False,
|
|
253
|
+
"verify_iat": False,
|
|
254
|
+
"verify_aud": False,
|
|
255
|
+
"verify_iss": False,
|
|
256
|
+
"verify_sub": False,
|
|
257
|
+
"verify_jti": False,
|
|
258
|
+
"require": [],
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
except jwt.InvalidSignatureError as exc:
|
|
262
|
+
raise IdentityError("signature") from exc
|
|
263
|
+
except jwt.InvalidAlgorithmError as exc:
|
|
264
|
+
raise IdentityError("algorithm") from exc
|
|
265
|
+
except jwt.DecodeError as exc:
|
|
266
|
+
raise IdentityError("malformed") from exc
|
|
267
|
+
except jwt.PyJWTError as exc:
|
|
268
|
+
raise IdentityError("claims") from exc
|
|
269
|
+
|
|
270
|
+
return self._identity(claims, issuer)
|
|
271
|
+
|
|
272
|
+
def _identity(self, claims: dict[str, Any], issuer: str) -> Identity:
|
|
273
|
+
for name in _REQUIRED_CLAIMS:
|
|
274
|
+
if name not in claims:
|
|
275
|
+
raise IdentityError("claims")
|
|
276
|
+
if claims["iss"] != issuer:
|
|
277
|
+
raise IdentityError("issuer")
|
|
278
|
+
# A token is for exactly one service. A list of audiences that
|
|
279
|
+
# happens to include this one is not accepted.
|
|
280
|
+
if claims["aud"] != self._slug or not isinstance(claims["aud"], str):
|
|
281
|
+
raise IdentityError("audience")
|
|
282
|
+
if not isinstance(claims["sub"], str) or not claims["sub"]:
|
|
283
|
+
raise IdentityError("claims")
|
|
284
|
+
if not _is_number(claims["iat"]) or not _is_number(claims["exp"]):
|
|
285
|
+
raise IdentityError("claims")
|
|
286
|
+
# Rejected once the full skew has passed since exp.
|
|
287
|
+
if claims["exp"] <= self._now() - self._skew:
|
|
288
|
+
raise IdentityError("expired")
|
|
289
|
+
|
|
290
|
+
actor: str | None = None
|
|
291
|
+
if "act" in claims:
|
|
292
|
+
act = claims["act"]
|
|
293
|
+
if not isinstance(act, dict) or not isinstance(act.get("sub"), str):
|
|
294
|
+
raise IdentityError("claims")
|
|
295
|
+
actor = act["sub"]
|
|
296
|
+
|
|
297
|
+
subject: str = claims["sub"]
|
|
298
|
+
return Identity(
|
|
299
|
+
subject=subject,
|
|
300
|
+
service=subject.startswith("service:"),
|
|
301
|
+
email=_optional_string(claims, "email"),
|
|
302
|
+
name=_optional_string(claims, "name"),
|
|
303
|
+
groups=_string_tuple(claims, "groups"),
|
|
304
|
+
roles=_string_tuple(claims, "roles"),
|
|
305
|
+
amr=_string_tuple(claims, "amr"),
|
|
306
|
+
actor=actor,
|
|
307
|
+
session_id=_optional_string(claims, "sid"),
|
|
308
|
+
claims=MappingProxyType(dict(claims)),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
def from_headers(self, headers: Mapping[str, Any]) -> Identity:
|
|
312
|
+
"""Verify the token in a request's headers.
|
|
313
|
+
|
|
314
|
+
Header names are matched without regard to case. The gateway sets the
|
|
315
|
+
header exactly once, so more than one value is rejected.
|
|
316
|
+
"""
|
|
317
|
+
values = [v for k, v in headers.items() if isinstance(k, str) and k.lower() == IDENTITY_HEADER]
|
|
318
|
+
if not values:
|
|
319
|
+
raise IdentityError("missing")
|
|
320
|
+
if len(values) > 1 or isinstance(values[0], (list, tuple)):
|
|
321
|
+
raise IdentityError("malformed")
|
|
322
|
+
return self.verify(values[0])
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Load a Finchly manifest and check what this SDK depends on.
|
|
2
|
+
|
|
3
|
+
The schema is in firetail-io/finchly under spec/manifest.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .config import DEFAULT_MANIFEST_PATH
|
|
13
|
+
from .errors import FinchlyError
|
|
14
|
+
from .platform_api import MANIFEST_VERSION, SUPPORTED_PLATFORM_API_VERSIONS
|
|
15
|
+
|
|
16
|
+
# Used with fullmatch: in Python "$" also matches before a trailing newline,
|
|
17
|
+
# which would let "slug\n" through.
|
|
18
|
+
_SLUG = re.compile(r"[a-z][a-z0-9]*(-[a-z0-9]+)*")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_int(value: Any) -> bool:
|
|
22
|
+
# bool is a subclass of int in Python; True is not a port.
|
|
23
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check_manifest(value: Any) -> dict[str, Any]:
|
|
27
|
+
"""Check the parts of a manifest this SDK itself depends on.
|
|
28
|
+
|
|
29
|
+
This is deliberately not the full schema. The platform runs the full
|
|
30
|
+
check when the service registers, and is the authority. The point here
|
|
31
|
+
is to stop a container at startup, with a clear message, when the
|
|
32
|
+
manifest is missing something the SDK cannot work without.
|
|
33
|
+
"""
|
|
34
|
+
if not isinstance(value, dict):
|
|
35
|
+
raise FinchlyError("manifest_invalid", "Manifest is not a JSON object")
|
|
36
|
+
|
|
37
|
+
problems: list[str] = []
|
|
38
|
+
|
|
39
|
+
version = value.get("manifestVersion")
|
|
40
|
+
if not _is_int(version) or version != MANIFEST_VERSION:
|
|
41
|
+
problems.append(f"manifestVersion must be {MANIFEST_VERSION}")
|
|
42
|
+
|
|
43
|
+
platform_api = value.get("platformApi")
|
|
44
|
+
if not isinstance(platform_api, str):
|
|
45
|
+
problems.append("platformApi must be a string")
|
|
46
|
+
elif platform_api not in SUPPORTED_PLATFORM_API_VERSIONS:
|
|
47
|
+
problems.append(
|
|
48
|
+
f'platformApi "{platform_api}" is not one this SDK speaks '
|
|
49
|
+
f"({', '.join(SUPPORTED_PLATFORM_API_VERSIONS)})"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
slug = value.get("slug")
|
|
53
|
+
slug_ok = isinstance(slug, str) and 2 <= len(slug) <= 40 and bool(_SLUG.fullmatch(slug))
|
|
54
|
+
if not slug_ok:
|
|
55
|
+
problems.append(
|
|
56
|
+
"slug must be 2 to 40 lowercase letters, digits and single hyphens, starting with a letter"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
name = value.get("name")
|
|
60
|
+
if not isinstance(name, str) or not name:
|
|
61
|
+
problems.append("name must be a non-empty string")
|
|
62
|
+
|
|
63
|
+
release = value.get("version")
|
|
64
|
+
if not isinstance(release, str) or not release:
|
|
65
|
+
problems.append("version must be a non-empty string")
|
|
66
|
+
|
|
67
|
+
port = value.get("port")
|
|
68
|
+
# isinstance is spelled out here so type checkers narrow `port` to int.
|
|
69
|
+
if not (isinstance(port, int) and not isinstance(port, bool) and 1 <= port <= 65535):
|
|
70
|
+
problems.append("port must be an integer from 1 to 65535")
|
|
71
|
+
|
|
72
|
+
health_path = value.get("healthPath")
|
|
73
|
+
if not isinstance(health_path, str):
|
|
74
|
+
problems.append("healthPath must be a string")
|
|
75
|
+
elif slug_ok and not health_path.startswith(f"/{slug}/"):
|
|
76
|
+
problems.append(f"healthPath must start with /{slug}/")
|
|
77
|
+
|
|
78
|
+
if problems:
|
|
79
|
+
raise FinchlyError("manifest_invalid", "Manifest is invalid", problems)
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_manifest(path: str = DEFAULT_MANIFEST_PATH) -> dict[str, Any]:
|
|
84
|
+
"""Read and check the manifest from the container's filesystem."""
|
|
85
|
+
try:
|
|
86
|
+
with open(path, encoding="utf-8") as handle:
|
|
87
|
+
text = handle.read()
|
|
88
|
+
except OSError as exc:
|
|
89
|
+
raise FinchlyError(
|
|
90
|
+
"manifest_unreadable", f"Cannot read manifest at {path}", [str(exc)]
|
|
91
|
+
) from exc
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
parsed = json.loads(text)
|
|
95
|
+
except ValueError as exc:
|
|
96
|
+
raise FinchlyError("manifest_invalid", f"Manifest at {path} is not valid JSON") from exc
|
|
97
|
+
return check_manifest(parsed)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""The platform API versions this SDK release speaks.
|
|
2
|
+
|
|
3
|
+
The platform API is specified in firetail-io/finchly under spec/. This
|
|
4
|
+
package is one implementation of it and defines no protocol of its own.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ._version import __version__
|
|
10
|
+
|
|
11
|
+
#: Platform API version this SDK release speaks by default.
|
|
12
|
+
PLATFORM_API_VERSION = "v1"
|
|
13
|
+
|
|
14
|
+
#: Every platform API version this SDK release can speak.
|
|
15
|
+
SUPPORTED_PLATFORM_API_VERSIONS: tuple[str, ...] = ("v1",)
|
|
16
|
+
|
|
17
|
+
#: Manifest schema version this SDK release reads.
|
|
18
|
+
MANIFEST_VERSION = 1
|
|
19
|
+
|
|
20
|
+
#: Package name, as reported to the platform at registration.
|
|
21
|
+
SDK_NAME = "firetail_finchly"
|
|
22
|
+
|
|
23
|
+
#: Package version, as reported to the platform at registration.
|
|
24
|
+
SDK_VERSION = __version__
|
|
File without changes
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Calls from this service to another, through the gateway.
|
|
2
|
+
|
|
3
|
+
Services never address each other directly. A call goes to the gateway's
|
|
4
|
+
internal address with a token this instance signs with its own key; the
|
|
5
|
+
gateway checks it, decides whether the call is allowed, and forwards it.
|
|
6
|
+
The token format is in spec/service-token.md and key registration in
|
|
7
|
+
spec/registration.md, both in firetail-io/finchly.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
import uuid
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from typing import Any, Callable, Mapping
|
|
24
|
+
|
|
25
|
+
import jwt
|
|
26
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
27
|
+
|
|
28
|
+
from .errors import FinchlyError
|
|
29
|
+
|
|
30
|
+
_SLUG = re.compile(r"[a-z][a-z0-9]*(-[a-z0-9]+)*")
|
|
31
|
+
_USER_ID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
|
|
32
|
+
_DOT_SEGMENT = re.compile(r"(^|/)\.\.?(/|$)")
|
|
33
|
+
#: The audience of a token meant for the gateway itself.
|
|
34
|
+
_GATEWAY_AUDIENCE = "gateway"
|
|
35
|
+
_KEYS_PATH = "/_gateway/keys"
|
|
36
|
+
#: How long a token is valid. The specification allows up to 300 seconds.
|
|
37
|
+
_TOKEN_TTL_SECONDS = 60
|
|
38
|
+
_REGISTRATION_TIMEOUT_SECONDS = 10.0
|
|
39
|
+
#: After a call is refused as unauthenticated, wait this long before registering again.
|
|
40
|
+
_REREGISTER_COOLDOWN_SECONDS = 60.0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
44
|
+
"""Hand a redirect back instead of following it.
|
|
45
|
+
|
|
46
|
+
A credential or a token is for the gateway and goes nowhere a response
|
|
47
|
+
points.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_OPENER = urllib.request.build_opener(_NoRedirect)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class ServiceResponse:
|
|
59
|
+
"""What the target answered."""
|
|
60
|
+
|
|
61
|
+
status: int
|
|
62
|
+
#: Header names in lower case.
|
|
63
|
+
headers: Mapping[str, str]
|
|
64
|
+
body: bytes
|
|
65
|
+
|
|
66
|
+
def json(self) -> Any:
|
|
67
|
+
return json.loads(self.body.decode("utf-8"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _b64url(data: bytes) -> str:
|
|
71
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _invalid(message: str) -> FinchlyError:
|
|
75
|
+
return FinchlyError("service_call_invalid", message)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _send(request: urllib.request.Request, timeout: float) -> ServiceResponse:
|
|
79
|
+
"""Send a request and return the response, whatever its status."""
|
|
80
|
+
try:
|
|
81
|
+
with _OPENER.open(request, timeout=timeout) as response:
|
|
82
|
+
return ServiceResponse(
|
|
83
|
+
response.status, {k.lower(): v for k, v in response.headers.items()}, response.read()
|
|
84
|
+
)
|
|
85
|
+
except urllib.error.HTTPError as err:
|
|
86
|
+
with err:
|
|
87
|
+
return ServiceResponse(err.code, {k.lower(): v for k, v in err.headers.items()}, err.read())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ServiceClient:
|
|
91
|
+
"""Calls other services, as this service or for a user.
|
|
92
|
+
|
|
93
|
+
Create one at startup and share it; it is safe to use from several
|
|
94
|
+
threads.
|
|
95
|
+
|
|
96
|
+
:param slug: this service's slug.
|
|
97
|
+
:param gateway_url: the gateway's internal address (PLATFORM_GATEWAY_URL).
|
|
98
|
+
:param credential: the credential this built-in service was deployed
|
|
99
|
+
with (PLATFORM_SERVICE_CREDENTIAL). It is what gets the instance
|
|
100
|
+
key accepted. An app has none; its key is accepted when it
|
|
101
|
+
registers.
|
|
102
|
+
:param now: clock, in seconds since the epoch. For tests.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
*,
|
|
108
|
+
slug: str,
|
|
109
|
+
gateway_url: str,
|
|
110
|
+
credential: str | None = None,
|
|
111
|
+
now: Callable[[], float] | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
if not isinstance(slug, str) or not _SLUG.fullmatch(slug):
|
|
114
|
+
raise _invalid(f"slug {slug!r} is not a slug")
|
|
115
|
+
if not isinstance(gateway_url, str) or not re.match(r"https?://", gateway_url):
|
|
116
|
+
raise _invalid("gateway_url must be an http or https URL")
|
|
117
|
+
self._slug = slug
|
|
118
|
+
self._issuer = f"service:{slug}"
|
|
119
|
+
self._gateway_url = gateway_url.rstrip("/")
|
|
120
|
+
self._credential = credential or None
|
|
121
|
+
self._now = now or time.time
|
|
122
|
+
self._lock = threading.Lock()
|
|
123
|
+
|
|
124
|
+
self._private_key: ec.EllipticCurvePrivateKey | None = None
|
|
125
|
+
self._public_jwk: dict[str, str] = {}
|
|
126
|
+
self._kid: str | None = None
|
|
127
|
+
#: When the gateway stops accepting the key, and when to extend it.
|
|
128
|
+
self._expires_at = 0.0
|
|
129
|
+
self._renew_at = 0.0
|
|
130
|
+
#: Set when a call came back 401: the gateway may no longer know the key.
|
|
131
|
+
self._doubted = False
|
|
132
|
+
self._last_forced_at = float("-inf")
|
|
133
|
+
|
|
134
|
+
def __repr__(self) -> str:
|
|
135
|
+
# Never the credential.
|
|
136
|
+
return f"ServiceClient(slug={self._slug!r}, gateway_url={self._gateway_url!r})"
|
|
137
|
+
|
|
138
|
+
# The instance key -----------------------------------------------------
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def key_id(self) -> str | None:
|
|
142
|
+
"""The id of this instance's key, once the gateway has accepted one."""
|
|
143
|
+
return self._kid if self._expires_at else None
|
|
144
|
+
|
|
145
|
+
def start(self) -> None:
|
|
146
|
+
"""Make the instance key and have the gateway accept it.
|
|
147
|
+
|
|
148
|
+
Optional: the first call does this. Call it at startup to fail
|
|
149
|
+
early instead.
|
|
150
|
+
"""
|
|
151
|
+
self._instance_key()
|
|
152
|
+
|
|
153
|
+
def _generate(self) -> None:
|
|
154
|
+
private_key = ec.generate_private_key(ec.SECP256R1())
|
|
155
|
+
numbers = private_key.public_key().public_numbers()
|
|
156
|
+
jwk = {
|
|
157
|
+
"kty": "EC",
|
|
158
|
+
"crv": "P-256",
|
|
159
|
+
"x": _b64url(numbers.x.to_bytes(32, "big")),
|
|
160
|
+
"y": _b64url(numbers.y.to_bytes(32, "big")),
|
|
161
|
+
}
|
|
162
|
+
# RFC 7638: the required members, in order, with no spaces.
|
|
163
|
+
canonical = json.dumps(
|
|
164
|
+
{name: jwk[name] for name in ("crv", "kty", "x", "y")}, separators=(",", ":")
|
|
165
|
+
)
|
|
166
|
+
self._private_key = private_key
|
|
167
|
+
self._public_jwk = jwk
|
|
168
|
+
self._kid = _b64url(hashlib.sha256(canonical.encode("ascii")).digest())
|
|
169
|
+
|
|
170
|
+
def _sign(self, audience: str, subject: str) -> str:
|
|
171
|
+
issued_at = int(self._now())
|
|
172
|
+
return jwt.encode(
|
|
173
|
+
{
|
|
174
|
+
"iss": self._issuer,
|
|
175
|
+
"sub": subject,
|
|
176
|
+
"aud": audience,
|
|
177
|
+
"iat": issued_at,
|
|
178
|
+
"exp": issued_at + _TOKEN_TTL_SECONDS,
|
|
179
|
+
"jti": str(uuid.uuid4()),
|
|
180
|
+
},
|
|
181
|
+
self._private_key, # type: ignore[arg-type]
|
|
182
|
+
algorithm="ES256",
|
|
183
|
+
headers={"kid": self._kid, "typ": "JWT"},
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def _register(self, authorization: str, with_slug: bool) -> ServiceResponse:
|
|
187
|
+
"""Ask the gateway to accept the key, or to extend it."""
|
|
188
|
+
body: dict[str, Any] = {"key": {"kid": self._kid, "jwk": self._public_jwk}}
|
|
189
|
+
if with_slug:
|
|
190
|
+
body["slug"] = self._slug
|
|
191
|
+
request = urllib.request.Request(
|
|
192
|
+
f"{self._gateway_url}{_KEYS_PATH}",
|
|
193
|
+
data=json.dumps(body).encode("utf-8"),
|
|
194
|
+
method="POST",
|
|
195
|
+
headers={
|
|
196
|
+
"Authorization": authorization,
|
|
197
|
+
"Content-Type": "application/json",
|
|
198
|
+
"Accept": "application/json",
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
try:
|
|
202
|
+
return _send(request, _REGISTRATION_TIMEOUT_SECONDS)
|
|
203
|
+
except (urllib.error.URLError, OSError) as err:
|
|
204
|
+
reason = getattr(err, "reason", err)
|
|
205
|
+
raise FinchlyError(
|
|
206
|
+
"gateway_unreachable",
|
|
207
|
+
"Cannot register this instance's key with the gateway",
|
|
208
|
+
[str(reason)],
|
|
209
|
+
) from None
|
|
210
|
+
|
|
211
|
+
def _accept(self, response: ServiceResponse) -> None:
|
|
212
|
+
body: Any = None
|
|
213
|
+
try:
|
|
214
|
+
body = response.json()
|
|
215
|
+
except ValueError:
|
|
216
|
+
pass
|
|
217
|
+
expires_at: float | None = None
|
|
218
|
+
if response.status == 200 and isinstance(body, dict) and isinstance(body.get("keyExpiresAt"), str):
|
|
219
|
+
try:
|
|
220
|
+
expires_at = datetime.fromisoformat(body["keyExpiresAt"].replace("Z", "+00:00")).timestamp()
|
|
221
|
+
except ValueError:
|
|
222
|
+
expires_at = None
|
|
223
|
+
if expires_at is None:
|
|
224
|
+
reason = body.get("error") if isinstance(body, dict) and isinstance(body.get("error"), str) else None
|
|
225
|
+
raise FinchlyError(
|
|
226
|
+
"service_key_rejected",
|
|
227
|
+
"The gateway did not accept this instance's key",
|
|
228
|
+
[reason or f"status {response.status}"],
|
|
229
|
+
)
|
|
230
|
+
now = self._now()
|
|
231
|
+
self._expires_at = expires_at
|
|
232
|
+
# Extend it when two thirds of its life has gone.
|
|
233
|
+
self._renew_at = now + (expires_at - now) * 2 / 3
|
|
234
|
+
|
|
235
|
+
def _with_credential(self) -> None:
|
|
236
|
+
if not self._credential:
|
|
237
|
+
raise FinchlyError(
|
|
238
|
+
"service_key_rejected",
|
|
239
|
+
"This instance has no key the gateway accepts, and no credential to register one",
|
|
240
|
+
[
|
|
241
|
+
"A built-in service is deployed with PLATFORM_SERVICE_CREDENTIAL.",
|
|
242
|
+
"An app's key is accepted when it registers, which this SDK release cannot do yet.",
|
|
243
|
+
],
|
|
244
|
+
)
|
|
245
|
+
self._accept(self._register(f"Service-Credential {self._credential}", True))
|
|
246
|
+
|
|
247
|
+
def _instance_key(self) -> None:
|
|
248
|
+
"""Make sure the instance key exists, is registered and is current."""
|
|
249
|
+
with self._lock:
|
|
250
|
+
now = self._now()
|
|
251
|
+
# Only a credential can re-register, so only then is it worth trying.
|
|
252
|
+
force = (
|
|
253
|
+
self._doubted
|
|
254
|
+
and self._credential is not None
|
|
255
|
+
and now - self._last_forced_at >= _REREGISTER_COOLDOWN_SECONDS
|
|
256
|
+
)
|
|
257
|
+
if self._expires_at and not force and now < self._renew_at:
|
|
258
|
+
return
|
|
259
|
+
if self._private_key is None:
|
|
260
|
+
self._generate()
|
|
261
|
+
if force:
|
|
262
|
+
self._last_forced_at = now
|
|
263
|
+
self._doubted = False
|
|
264
|
+
elif self._expires_at and now < self._expires_at:
|
|
265
|
+
# Still accepted: extend it with a token it signs itself.
|
|
266
|
+
response = self._register(f"Bearer {self._sign(_GATEWAY_AUDIENCE, self._issuer)}", False)
|
|
267
|
+
if response.status != 401:
|
|
268
|
+
self._accept(response)
|
|
269
|
+
return
|
|
270
|
+
# New, expired, or no longer known to the gateway: use the credential.
|
|
271
|
+
self._with_credential()
|
|
272
|
+
|
|
273
|
+
# Calls ----------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
def _subject(self, as_user: str | None) -> str:
|
|
276
|
+
if as_user is None:
|
|
277
|
+
return self._issuer
|
|
278
|
+
if not isinstance(as_user, str) or not _USER_ID.fullmatch(as_user):
|
|
279
|
+
raise _invalid("as_user must be a user's platform id")
|
|
280
|
+
return as_user
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def _check_target(target: str) -> None:
|
|
284
|
+
if not isinstance(target, str) or not _SLUG.fullmatch(target):
|
|
285
|
+
raise _invalid(f"target {target!r} is not a slug")
|
|
286
|
+
|
|
287
|
+
def token(self, target: str, *, as_user: str | None = None) -> str:
|
|
288
|
+
"""A signed token for a call to ``target``. Most code wants ``call``."""
|
|
289
|
+
self._check_target(target)
|
|
290
|
+
subject = self._subject(as_user)
|
|
291
|
+
self._instance_key()
|
|
292
|
+
return self._sign(target, subject)
|
|
293
|
+
|
|
294
|
+
def url(self, target: str, path: str) -> str:
|
|
295
|
+
"""The gateway URL for a path under ``target``. ``path`` starts with "/"."""
|
|
296
|
+
self._check_target(target)
|
|
297
|
+
# The path is joined as text. It must not be able to climb out of the target.
|
|
298
|
+
if (
|
|
299
|
+
not isinstance(path, str)
|
|
300
|
+
or not path.startswith("/")
|
|
301
|
+
or "\\" in path
|
|
302
|
+
or _DOT_SEGMENT.search(path.split("?", 1)[0])
|
|
303
|
+
):
|
|
304
|
+
raise _invalid('path must start with "/" and contain no "." or ".." segments')
|
|
305
|
+
return f"{self._gateway_url}/{target}{path}"
|
|
306
|
+
|
|
307
|
+
def headers(self, target: str, *, as_user: str | None = None) -> dict[str, str]:
|
|
308
|
+
"""The header to add to a request to ``target``, for use with another HTTP client.
|
|
309
|
+
|
|
310
|
+
Build the address with ``url``. A token is for one target and is
|
|
311
|
+
valid for a minute: get fresh headers for every request.
|
|
312
|
+
"""
|
|
313
|
+
return {"Authorization": f"Bearer {self.token(target, as_user=as_user)}"}
|
|
314
|
+
|
|
315
|
+
def call(
|
|
316
|
+
self,
|
|
317
|
+
target: str,
|
|
318
|
+
path: str,
|
|
319
|
+
*,
|
|
320
|
+
method: str = "GET",
|
|
321
|
+
headers: Mapping[str, str] | None = None,
|
|
322
|
+
body: bytes | None = None,
|
|
323
|
+
as_user: str | None = None,
|
|
324
|
+
timeout: float = 30.0,
|
|
325
|
+
) -> ServiceResponse:
|
|
326
|
+
"""Call another service and return its response, whatever the status.
|
|
327
|
+
|
|
328
|
+
A 403 means this service's manifest does not declare the target in
|
|
329
|
+
``calls``, or the user has no access to it. A redirect is handed
|
|
330
|
+
back, not followed. Nothing is retried.
|
|
331
|
+
|
|
332
|
+
:param as_user: the id of the user this call is made for. The
|
|
333
|
+
target then sees that user, and this service as the one acting
|
|
334
|
+
for them. Leave it out to call as the service itself.
|
|
335
|
+
"""
|
|
336
|
+
url = self.url(target, path)
|
|
337
|
+
token = self.token(target, as_user=as_user)
|
|
338
|
+
sent = {name: value for name, value in (headers or {}).items() if name.lower() != "authorization"}
|
|
339
|
+
# Whatever the caller set, the gateway reads this one.
|
|
340
|
+
sent["Authorization"] = f"Bearer {token}"
|
|
341
|
+
request = urllib.request.Request(url, data=body, method=method.upper(), headers=sent)
|
|
342
|
+
try:
|
|
343
|
+
response = _send(request, timeout)
|
|
344
|
+
except (urllib.error.URLError, OSError) as err:
|
|
345
|
+
reason = getattr(err, "reason", err)
|
|
346
|
+
raise FinchlyError(
|
|
347
|
+
"gateway_unreachable", f"Cannot reach {target} through the gateway", [str(reason)]
|
|
348
|
+
) from None
|
|
349
|
+
if response.status == 401:
|
|
350
|
+
self._doubted = True
|
|
351
|
+
return response
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: firetail_finchly
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Python SDK for the Finchly platform
|
|
5
|
+
Project-URL: Repository, https://github.com/firetail-io/finchly-sdk-py
|
|
6
|
+
Project-URL: Changelog, https://github.com/firetail-io/finchly-sdk-py/blob/main/CHANGELOG.md
|
|
7
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Requires-Dist: pyjwt[crypto]<3,>=2.8
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# firetail_finchly
|
|
22
|
+
|
|
23
|
+
Python SDK for the [Finchly](https://github.com/firetail-io/finchly)
|
|
24
|
+
platform. A service or app uses it to register with a Finchly deployment,
|
|
25
|
+
verify who is calling it, and reach the platform's database, object store,
|
|
26
|
+
queue and other services.
|
|
27
|
+
|
|
28
|
+
Status: early. The package is tested, but only the first four modules
|
|
29
|
+
exist.
|
|
30
|
+
|
|
31
|
+
The SDK implements the platform API, which is specified in
|
|
32
|
+
`firetail-io/finchly` under `spec/`. This release speaks platform API `v1`
|
|
33
|
+
(draft).
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
pip install firetail_finchly
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Requires Python 3.10 or later. Depends on PyJWT with its `crypto` extra.
|
|
42
|
+
|
|
43
|
+
## Use
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from firetail_finchly import load_config, load_manifest
|
|
47
|
+
|
|
48
|
+
config = load_config() # reads PLATFORM_* settings
|
|
49
|
+
manifest = load_manifest(config.manifest_path)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Both raise a `FinchlyError` with a stable `code` and a `problems` tuple that
|
|
53
|
+
names everything wrong, so a misconfigured container fails once with the
|
|
54
|
+
full picture.
|
|
55
|
+
|
|
56
|
+
### Who is calling
|
|
57
|
+
|
|
58
|
+
The gateway puts a short-lived signed token in the `x-platform-identity`
|
|
59
|
+
header of every request it proxies. It is the only identity a service
|
|
60
|
+
should accept.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from firetail_finchly import IdentityError, IdentityVerifier
|
|
64
|
+
|
|
65
|
+
# Once, at startup. It caches the gateway's issuer and signing keys, and
|
|
66
|
+
# is safe to share between threads.
|
|
67
|
+
identity = IdentityVerifier(slug=manifest["slug"], gateway_url=config.gateway_url)
|
|
68
|
+
|
|
69
|
+
# Per request.
|
|
70
|
+
try:
|
|
71
|
+
caller = identity.from_headers(request.headers)
|
|
72
|
+
caller.subject # user id, or "service:<slug>"
|
|
73
|
+
caller.email # None for a service calling as itself
|
|
74
|
+
caller.groups # platform groups
|
|
75
|
+
caller.roles # roles for this service: "user", "operator"
|
|
76
|
+
caller.actor # "service:<slug>" when a service acts for the user
|
|
77
|
+
except IdentityError as err:
|
|
78
|
+
... # Unauthenticated: respond 401. err.reason is for logs only.
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Any other `FinchlyError` from the verifier means the gateway could not be
|
|
82
|
+
reached; respond 503.
|
|
83
|
+
|
|
84
|
+
A token is rejected unless it is signed with ES256 by a key the gateway
|
|
85
|
+
publishes, names that gateway as issuer and this service as its only
|
|
86
|
+
audience, and has not expired. The rules are in `spec/identity-token.md` in
|
|
87
|
+
`firetail-io/finchly`.
|
|
88
|
+
|
|
89
|
+
No other header is ever consulted. On a path the manifest declares in
|
|
90
|
+
`publicPaths` there is no token, and the service authenticates the request
|
|
91
|
+
itself.
|
|
92
|
+
|
|
93
|
+
### Calling another service
|
|
94
|
+
|
|
95
|
+
Services never address each other directly. A call goes through the
|
|
96
|
+
gateway, signed by this instance's own key.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from firetail_finchly import ServiceClient
|
|
100
|
+
|
|
101
|
+
# Once, at startup. Safe to share between threads.
|
|
102
|
+
service = ServiceClient(
|
|
103
|
+
slug=manifest["slug"],
|
|
104
|
+
gateway_url=config.gateway_url,
|
|
105
|
+
credential=config.service_credential,
|
|
106
|
+
)
|
|
107
|
+
service.start() # optional: fail now, not on the first call
|
|
108
|
+
|
|
109
|
+
# As the service itself.
|
|
110
|
+
response = service.call(
|
|
111
|
+
"notification-service",
|
|
112
|
+
"/api/send",
|
|
113
|
+
method="POST",
|
|
114
|
+
headers={"Content-Type": "application/json"},
|
|
115
|
+
body=b'{"text": "hello"}',
|
|
116
|
+
)
|
|
117
|
+
response.status, response.headers, response.body, response.json()
|
|
118
|
+
|
|
119
|
+
# For a user: the target sees that user, and this service acting for them.
|
|
120
|
+
service.call("notification-service", "/api/inbox", as_user=caller.subject)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`call` returns the response, whatever its status.
|
|
124
|
+
|
|
125
|
+
- **403** means this service's manifest does not list the target in
|
|
126
|
+
`calls`, or the user has no access to the target.
|
|
127
|
+
- **A redirect is handed back, not followed.**
|
|
128
|
+
- **Nothing is retried.** After a 401 the next call registers the key
|
|
129
|
+
again, in case the gateway no longer knows it.
|
|
130
|
+
|
|
131
|
+
To use another HTTP client, such as `requests` or `httpx`, take the address
|
|
132
|
+
and the header from the client and send the request yourself. A token is
|
|
133
|
+
for one target and is valid for a minute, so get fresh headers each time:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
requests.post(
|
|
137
|
+
service.url("notification-service", "/api/send"),
|
|
138
|
+
headers=service.headers("notification-service"),
|
|
139
|
+
json={"text": "hello"},
|
|
140
|
+
allow_redirects=False,
|
|
141
|
+
)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The instance makes an ES256 key pair in memory the first time it is needed.
|
|
145
|
+
The private half never leaves the process. The public half is registered
|
|
146
|
+
with the gateway using the credential the service was deployed with, and
|
|
147
|
+
extended by itself for as long as the instance runs. The rules are in
|
|
148
|
+
`spec/service-token.md` and `spec/registration.md` in `firetail-io/finchly`.
|
|
149
|
+
|
|
150
|
+
Only a built-in service has a credential. An app's key is accepted when it
|
|
151
|
+
registers, which the `app` module will do; until then an app cannot use
|
|
152
|
+
`service`.
|
|
153
|
+
|
|
154
|
+
### Settings
|
|
155
|
+
|
|
156
|
+
| Variable | Required | Meaning |
|
|
157
|
+
|---|---|---|
|
|
158
|
+
| `PLATFORM_GATEWAY_URL` | yes | The gateway's internal address |
|
|
159
|
+
| `PLATFORM_MANIFEST_PATH` | no | Manifest path in the container. Default `/finchly/manifest.json` |
|
|
160
|
+
| `PLATFORM_APP_ADDRESS` | no | Address the gateway should use to reach this service |
|
|
161
|
+
| `PLATFORM_ENROLMENT_TOKEN` | no | Token for self-registration |
|
|
162
|
+
| `PLATFORM_SERVICE_CREDENTIAL` | no | What a built-in service registers its instance key with |
|
|
163
|
+
|
|
164
|
+
The enrolment token and the service credential are left out of the
|
|
165
|
+
config's `repr`, so logging the config does not leak them.
|
|
166
|
+
|
|
167
|
+
## Modules
|
|
168
|
+
|
|
169
|
+
The Python SDK may trail the Node SDK for modules that only Node services
|
|
170
|
+
use.
|
|
171
|
+
|
|
172
|
+
| Module | What it does | State |
|
|
173
|
+
|---|---|---|
|
|
174
|
+
| `config` | Read and validate the platform settings | Done |
|
|
175
|
+
| `manifest` | Load the manifest and check what the SDK depends on | Done |
|
|
176
|
+
| `identity` | Verify `x-platform-identity` | Done. Directory lookups (`list_users`, `members_of`) and `require_*` helpers are planned |
|
|
177
|
+
| `app` | Register with the platform, serve `/_platform/*`, renew keys, fetch settings | Next |
|
|
178
|
+
| `service` | Call other services through the gateway, as the service or for a user | Done for built-in services. An app's first key waits on `app` |
|
|
179
|
+
| `db` | PostgreSQL: queries, transactions, advisory locks, migrations | Planned |
|
|
180
|
+
| `blobs` | Object storage with conditional writes | Planned |
|
|
181
|
+
| `queue` | Publish, lease, ack, nack | Planned |
|
|
182
|
+
| `secrets` | Secrets and envelope encryption | Planned |
|
|
183
|
+
| `llm` | Model calls through the platform | Planned |
|
|
184
|
+
| `audit`, `usage`, `notify`, `schedule`, `connectors` | Clients for platform services | Planned |
|
|
185
|
+
| `ui` | Shared header, footer and theme | Not planned; Node only |
|
|
186
|
+
|
|
187
|
+
The manifest check here is not the full schema. The platform runs the full
|
|
188
|
+
check at registration and is the authority.
|
|
189
|
+
|
|
190
|
+
## Develop
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
python -m venv .venv && . .venv/bin/activate
|
|
194
|
+
pip install -e ".[dev]"
|
|
195
|
+
python -m pytest
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Without a local Python environment, run them in a container:
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
docker run --rm -v "$PWD":/src:ro python:3.12-slim sh -c \
|
|
202
|
+
"cp -r /src /w && cd /w && pip install -q -e '.[dev]' && python -m pytest -q"
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Conformance vectors
|
|
206
|
+
|
|
207
|
+
`tests/conformance/` holds test vectors copied from `firetail-io/finchly`
|
|
208
|
+
(`conformance/vectors/`). Both SDKs must reach the verdict each vector
|
|
209
|
+
states. Do not edit them here: change them in `finchly`, regenerate, and
|
|
210
|
+
copy the file to both SDK repositories. Until `finchly` publishes its
|
|
211
|
+
conformance suite as an image, the copy is made by hand.
|
|
212
|
+
|
|
213
|
+
## Release
|
|
214
|
+
|
|
215
|
+
Each package version follows semver, independent of the Node SDK and of the
|
|
216
|
+
platform.
|
|
217
|
+
|
|
218
|
+
1. Changes land on `main` by pull request. Each one adds a line to
|
|
219
|
+
`CHANGELOG.md` under "Unreleased".
|
|
220
|
+
2. To release, set the version in `src/firetail_finchly/_version.py`, move
|
|
221
|
+
the changelog entries under that version, merge, and tag the merge
|
|
222
|
+
commit `v` plus the version, for example `v1.2.0`.
|
|
223
|
+
3. The `release` workflow checks the tag matches the version, runs the
|
|
224
|
+
tests, builds the sdist and wheel, and publishes.
|
|
225
|
+
4. A release candidate uses the PEP 440 form, `1.2.0rc1`, tagged
|
|
226
|
+
`v1.2.0rc1`. pip does not install pre-releases unless asked.
|
|
227
|
+
5. A bad release is yanked on PyPI and followed by a patch release.
|
|
228
|
+
|
|
229
|
+
Publishing uses PyPI trusted publishing: the workflow proves its identity to
|
|
230
|
+
PyPI with its GitHub Actions token, so no PyPI token is stored in this
|
|
231
|
+
repository.
|
|
232
|
+
|
|
233
|
+
One-time setup, not yet done:
|
|
234
|
+
|
|
235
|
+
- On PyPI, add a pending trusted publisher for the project
|
|
236
|
+
`firetail_finchly`: owner `firetail-io`, repository `finchly-sdk-py`,
|
|
237
|
+
workflow `release.yml`, environment `pypi`.
|
|
238
|
+
- Create a GitHub environment called `pypi` in this repository, restricted
|
|
239
|
+
to tags.
|
|
240
|
+
|
|
241
|
+
Still to add: the conformance suite from `firetail-io/finchly`, run in CI on
|
|
242
|
+
every change, once that suite exists.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
firetail_finchly/__init__.py,sha256=IheK8HWBcojxM7Bk_zIZIwaRdO2aFphjdJSotlg0FJo,982
|
|
2
|
+
firetail_finchly/_version.py,sha256=sXLh7g3KC4QCFxcZGBTpG2scR7hmmBsMjq6LqRptkRg,22
|
|
3
|
+
firetail_finchly/config.py,sha256=WgaVUdXrGzGIJ-s9z5jdrzAYzSG4gFGtAz3EiKod5Ms,3506
|
|
4
|
+
firetail_finchly/errors.py,sha256=v6r_Ca0qkHmjmHAjq2Bj8BR_0__amEfh3jaivNEEfsI,1471
|
|
5
|
+
firetail_finchly/identity.py,sha256=ojLOhYKCU8b3tUR9W5U2Y3Rl-oxc04qxxQnrwt9NdVw,13135
|
|
6
|
+
firetail_finchly/manifest.py,sha256=JDGUjVVDiwKi9GCgA0FYKKYoF5-5FDEAk2i0bgufoiM,3601
|
|
7
|
+
firetail_finchly/platform_api.py,sha256=4PkNaiMohm2TAh4VAAyZ4GVULht0O40AoewKapo49qE,738
|
|
8
|
+
firetail_finchly/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
firetail_finchly/service.py,sha256=EVqrTTQr8cL_iPSdcNC9jb8lRxesVZaa3PLZcHADtH4,13682
|
|
10
|
+
firetail_finchly-0.0.1.dist-info/METADATA,sha256=hTWqTCeewX06a-gPZUAXSTXbqlp2vh1d9n0hkR6YaWU,9177
|
|
11
|
+
firetail_finchly-0.0.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
12
|
+
firetail_finchly-0.0.1.dist-info/RECORD,,
|