horasis 1.0.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.
- horasis/__init__.py +106 -0
- horasis/__main__.py +3 -0
- horasis/access/__init__.py +42 -0
- horasis/access/guard.py +192 -0
- horasis/access/keys.py +204 -0
- horasis/access/policy.py +47 -0
- horasis/access/principal.py +32 -0
- horasis/access/quota.py +170 -0
- horasis/access/ratelimit.py +240 -0
- horasis/access/stores.py +137 -0
- horasis/access/tiers.py +22 -0
- horasis/api/__init__.py +24 -0
- horasis/api/async_client.py +305 -0
- horasis/api/client.py +744 -0
- horasis/backend/__init__.py +26 -0
- horasis/backend/_common.py +79 -0
- horasis/backend/base.py +92 -0
- horasis/backend/easyocr_adapter.py +111 -0
- horasis/backend/face_adapter.py +153 -0
- horasis/backend/moderation_adapter.py +117 -0
- horasis/backend/onnx_adapter.py +407 -0
- horasis/backend/registry.py +200 -0
- horasis/backend/remote_adapter.py +270 -0
- horasis/backend/tesseract_adapter.py +103 -0
- horasis/backend/ultralytics_adapter.py +510 -0
- horasis/cli.py +342 -0
- horasis/config/default.yaml +34 -0
- horasis/foundation/__init__.py +95 -0
- horasis/foundation/config.py +222 -0
- horasis/foundation/errors.py +148 -0
- horasis/foundation/frames.py +72 -0
- horasis/foundation/imaging.py +403 -0
- horasis/foundation/schemas.py +776 -0
- horasis/foundation/telemetry.py +170 -0
- horasis/models.py +157 -0
- horasis/orchestration/__init__.py +7 -0
- horasis/orchestration/builtin_hooks.py +119 -0
- horasis/orchestration/hooks.py +89 -0
- horasis/orchestration/pipeline.py +164 -0
- horasis/plugins/__init__.py +5 -0
- horasis/plugins/loader.py +63 -0
- horasis/py.typed +0 -0
- horasis/server.py +229 -0
- horasis/viz.py +527 -0
- horasis-1.0.0.dist-info/METADATA +498 -0
- horasis-1.0.0.dist-info/RECORD +50 -0
- horasis-1.0.0.dist-info/WHEEL +5 -0
- horasis-1.0.0.dist-info/entry_points.txt +2 -0
- horasis-1.0.0.dist-info/licenses/LICENSE +201 -0
- horasis-1.0.0.dist-info/top_level.txt +1 -0
horasis/__init__.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Computer vision SDK with API-key access control.
|
|
2
|
+
|
|
3
|
+
Applications import from this module and :mod:`horasis.api`. The
|
|
4
|
+
`foundation`, `access`, `backend`, `orchestration` and `plugins` packages
|
|
5
|
+
are for extension authors (a new `Backend`, a custom `Telemetry` sink) and
|
|
6
|
+
are not covered by the stability guarantee of the top-level API.
|
|
7
|
+
|
|
8
|
+
Quick start:
|
|
9
|
+
>>> from horasis import Client, InMemoryApiKeyValidator, Tier
|
|
10
|
+
>>> validator = InMemoryApiKeyValidator()
|
|
11
|
+
>>> validator.register_simple("demo-key", principal_id="alice", tier=Tier.FREE)
|
|
12
|
+
>>> client = Client.build(validator=validator)
|
|
13
|
+
>>> result = client.detection.detect("photo.jpg", api_key="demo-key")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from horasis.access.keys import ApiKeyValidator, HashedApiKeyValidator, InMemoryApiKeyValidator
|
|
17
|
+
from horasis.access.quota import UsageReport
|
|
18
|
+
from horasis.access.tiers import Tier
|
|
19
|
+
from horasis.api.async_client import AsyncClient
|
|
20
|
+
from horasis.api.client import (
|
|
21
|
+
Client,
|
|
22
|
+
ContentModerator,
|
|
23
|
+
FaceDetector,
|
|
24
|
+
ImageClassifier,
|
|
25
|
+
ObjectDetector,
|
|
26
|
+
TextExtractor,
|
|
27
|
+
)
|
|
28
|
+
from horasis.foundation.config import Config, InputPolicy
|
|
29
|
+
from horasis.foundation.errors import (
|
|
30
|
+
AccessDeniedError,
|
|
31
|
+
AdapterError,
|
|
32
|
+
BackendUnavailableError,
|
|
33
|
+
ConfigurationError,
|
|
34
|
+
ExpiredApiKeyError,
|
|
35
|
+
HorasisError,
|
|
36
|
+
InvalidApiKeyError,
|
|
37
|
+
ModelDownloadError,
|
|
38
|
+
NoBackendError,
|
|
39
|
+
QuotaExceededError,
|
|
40
|
+
RateLimitedError,
|
|
41
|
+
RemoteError,
|
|
42
|
+
RevokedApiKeyError,
|
|
43
|
+
ValidationError,
|
|
44
|
+
VisualizationUnavailableError,
|
|
45
|
+
)
|
|
46
|
+
from horasis.foundation.schemas import (
|
|
47
|
+
BatchItem,
|
|
48
|
+
ClassificationResult,
|
|
49
|
+
DetectionResult,
|
|
50
|
+
Face,
|
|
51
|
+
FaceResult,
|
|
52
|
+
Image,
|
|
53
|
+
ModerationResult,
|
|
54
|
+
OcrResult,
|
|
55
|
+
Task,
|
|
56
|
+
TextRegion,
|
|
57
|
+
Video,
|
|
58
|
+
VideoDetectionResult,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
__version__ = "1.0.0"
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"AccessDeniedError",
|
|
65
|
+
"AdapterError",
|
|
66
|
+
"ApiKeyValidator",
|
|
67
|
+
"AsyncClient",
|
|
68
|
+
"BackendUnavailableError",
|
|
69
|
+
"BatchItem",
|
|
70
|
+
"ClassificationResult",
|
|
71
|
+
"Client",
|
|
72
|
+
"Config",
|
|
73
|
+
"ConfigurationError",
|
|
74
|
+
"ContentModerator",
|
|
75
|
+
"DetectionResult",
|
|
76
|
+
"ExpiredApiKeyError",
|
|
77
|
+
"Face",
|
|
78
|
+
"FaceDetector",
|
|
79
|
+
"FaceResult",
|
|
80
|
+
"HashedApiKeyValidator",
|
|
81
|
+
"HorasisError",
|
|
82
|
+
"Image",
|
|
83
|
+
"ImageClassifier",
|
|
84
|
+
"InMemoryApiKeyValidator",
|
|
85
|
+
"InputPolicy",
|
|
86
|
+
"InvalidApiKeyError",
|
|
87
|
+
"ModelDownloadError",
|
|
88
|
+
"ModerationResult",
|
|
89
|
+
"NoBackendError",
|
|
90
|
+
"ObjectDetector",
|
|
91
|
+
"OcrResult",
|
|
92
|
+
"QuotaExceededError",
|
|
93
|
+
"RateLimitedError",
|
|
94
|
+
"RemoteError",
|
|
95
|
+
"RevokedApiKeyError",
|
|
96
|
+
"Task",
|
|
97
|
+
"TextExtractor",
|
|
98
|
+
"TextRegion",
|
|
99
|
+
"Tier",
|
|
100
|
+
"UsageReport",
|
|
101
|
+
"ValidationError",
|
|
102
|
+
"Video",
|
|
103
|
+
"VideoDetectionResult",
|
|
104
|
+
"VisualizationUnavailableError",
|
|
105
|
+
"__version__",
|
|
106
|
+
]
|
horasis/__main__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Layer 4: API keys, tiers, quotas and rate limits.
|
|
2
|
+
|
|
3
|
+
Imports only from the foundation layer.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from horasis.access.guard import AccessContext, requires_access
|
|
7
|
+
from horasis.access.keys import ApiKeyValidator, HashedApiKeyValidator, InMemoryApiKeyValidator
|
|
8
|
+
from horasis.access.policy import Policy
|
|
9
|
+
from horasis.access.principal import AnonymousPrincipal, Principal
|
|
10
|
+
from horasis.access.quota import (
|
|
11
|
+
InMemoryQuotaStore,
|
|
12
|
+
QuotaManager,
|
|
13
|
+
QuotaStore,
|
|
14
|
+
UsageReport,
|
|
15
|
+
WindowUsage,
|
|
16
|
+
)
|
|
17
|
+
from horasis.access.ratelimit import RateLimiter, RedisRateLimiter, TokenBucketLimiter
|
|
18
|
+
from horasis.access.stores import RedisQuotaStore, SQLiteQuotaStore, redis_backends
|
|
19
|
+
from horasis.access.tiers import Tier
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AccessContext",
|
|
23
|
+
"AnonymousPrincipal",
|
|
24
|
+
"ApiKeyValidator",
|
|
25
|
+
"HashedApiKeyValidator",
|
|
26
|
+
"InMemoryApiKeyValidator",
|
|
27
|
+
"InMemoryQuotaStore",
|
|
28
|
+
"Policy",
|
|
29
|
+
"Principal",
|
|
30
|
+
"QuotaManager",
|
|
31
|
+
"QuotaStore",
|
|
32
|
+
"RateLimiter",
|
|
33
|
+
"RedisQuotaStore",
|
|
34
|
+
"RedisRateLimiter",
|
|
35
|
+
"SQLiteQuotaStore",
|
|
36
|
+
"Tier",
|
|
37
|
+
"TokenBucketLimiter",
|
|
38
|
+
"UsageReport",
|
|
39
|
+
"WindowUsage",
|
|
40
|
+
"redis_backends",
|
|
41
|
+
"requires_access",
|
|
42
|
+
]
|
horasis/access/guard.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""The access-control decorator.
|
|
2
|
+
|
|
3
|
+
Every public-API call passes through :func:`requires_access` before it
|
|
4
|
+
reaches orchestration. The API key is resolved to a
|
|
5
|
+
:class:`~horasis.access.principal.Principal`,
|
|
6
|
+
:class:`~horasis.access.policy.Policy` authorizes the task, rate limits and
|
|
7
|
+
quota are charged, and :class:`~horasis.foundation.telemetry.Telemetry`
|
|
8
|
+
hooks fire around the call.
|
|
9
|
+
|
|
10
|
+
It decorates methods of any object that keeps an :class:`AccessContext` in
|
|
11
|
+
``_access`` and takes ``api_key`` as a keyword argument.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import functools
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any, TypeVar
|
|
21
|
+
|
|
22
|
+
from horasis.access.keys import ApiKeyValidator
|
|
23
|
+
from horasis.access.policy import Policy
|
|
24
|
+
from horasis.access.principal import Principal
|
|
25
|
+
from horasis.access.quota import QuotaManager, UsageReport
|
|
26
|
+
from horasis.access.ratelimit import RateLimiter, TokenBucketLimiter
|
|
27
|
+
from horasis.foundation.config import Config, QuotaRule
|
|
28
|
+
from horasis.foundation.errors import HorasisError, QuotaExceededError, ValidationError
|
|
29
|
+
from horasis.foundation.imaging import to_image
|
|
30
|
+
from horasis.foundation.schemas import Task
|
|
31
|
+
from horasis.foundation.telemetry import NullTelemetry, Telemetry
|
|
32
|
+
|
|
33
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class AccessContext:
|
|
38
|
+
"""Everything `requires_access` needs to guard a call."""
|
|
39
|
+
|
|
40
|
+
config: Config
|
|
41
|
+
validator: ApiKeyValidator
|
|
42
|
+
policy: Policy
|
|
43
|
+
quota_manager: QuotaManager
|
|
44
|
+
rate_limiter: RateLimiter
|
|
45
|
+
telemetry: Telemetry = field(default_factory=NullTelemetry)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def build(
|
|
49
|
+
cls,
|
|
50
|
+
*,
|
|
51
|
+
config: Config,
|
|
52
|
+
validator: ApiKeyValidator,
|
|
53
|
+
quota_manager: QuotaManager | None = None,
|
|
54
|
+
rate_limiter: RateLimiter | None = None,
|
|
55
|
+
telemetry: Telemetry | None = None,
|
|
56
|
+
) -> AccessContext:
|
|
57
|
+
return cls(
|
|
58
|
+
config=config,
|
|
59
|
+
validator=validator,
|
|
60
|
+
policy=Policy(config),
|
|
61
|
+
quota_manager=quota_manager or QuotaManager(),
|
|
62
|
+
rate_limiter=rate_limiter or TokenBucketLimiter(),
|
|
63
|
+
telemetry=telemetry or NullTelemetry(),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def charge(self, principal: Principal, task: Task, *, units: int = 1) -> None:
|
|
68
|
+
"""Rate-limit and meter `units` calls of `task` for `principal`.
|
|
69
|
+
|
|
70
|
+
Each unit costs ``Config.cost_for(task)``. All-or-nothing: if any
|
|
71
|
+
limit rejects, everything taken so far is given back.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
RateLimitedError: per-minute bucket empty (has ``retry_after``).
|
|
75
|
+
QuotaExceededError: window quota exhausted (has ``retry_after``,
|
|
76
|
+
``limit`` and ``scope``).
|
|
77
|
+
"""
|
|
78
|
+
rule = self.config.tier(principal.tier.value)
|
|
79
|
+
pid, name = principal.principal_id, task.value
|
|
80
|
+
cost = self.config.cost_for(name) * units
|
|
81
|
+
self.rate_limiter.acquire(pid, name, rule, cost)
|
|
82
|
+
try:
|
|
83
|
+
self.quota_manager.check_and_consume(pid, rule.quota, cost=cost)
|
|
84
|
+
task_rule = self._task_rule(rule.task_quotas.get(name), rule.quota)
|
|
85
|
+
try:
|
|
86
|
+
self.quota_manager.check_and_consume(pid, task_rule, cost=cost, scope=f"task:{name}")
|
|
87
|
+
except QuotaExceededError:
|
|
88
|
+
self.quota_manager.refund(pid, rule.quota, cost=cost)
|
|
89
|
+
raise
|
|
90
|
+
except QuotaExceededError:
|
|
91
|
+
self.rate_limiter.refund(pid, name, rule, cost)
|
|
92
|
+
raise
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _task_rule(specific: QuotaRule | None, overall: QuotaRule) -> QuotaRule:
|
|
96
|
+
return specific or overall
|
|
97
|
+
|
|
98
|
+
def usage(self, principal: Principal) -> UsageReport:
|
|
99
|
+
"""Return used and remaining units for `principal`, overall and per task."""
|
|
100
|
+
rule = self.config.tier(principal.tier.value)
|
|
101
|
+
pid = principal.principal_id
|
|
102
|
+
per_task = {
|
|
103
|
+
name: self.quota_manager.window_usage(
|
|
104
|
+
pid, self._task_rule(rule.task_quotas.get(name), rule.quota), scope=f"task:{name}"
|
|
105
|
+
)
|
|
106
|
+
for name in rule.allowed_tasks
|
|
107
|
+
if principal.scopes is None or name in principal.scopes
|
|
108
|
+
}
|
|
109
|
+
return UsageReport(
|
|
110
|
+
principal_id=pid,
|
|
111
|
+
tier=principal.tier.value,
|
|
112
|
+
overall=self.quota_manager.window_usage(pid, rule.quota),
|
|
113
|
+
per_task=per_task,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _validated_inputs(
|
|
118
|
+
access: AccessContext, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
119
|
+
) -> tuple[tuple[Any, ...], dict[str, Any]]:
|
|
120
|
+
"""Decode and validate the image argument (first positional, or ``image=``).
|
|
121
|
+
|
|
122
|
+
The decoded `Image` replaces the raw input, so the wrapped method does not
|
|
123
|
+
decode twice and malformed input is rejected before anything is charged.
|
|
124
|
+
"""
|
|
125
|
+
policy = access.config.inputs
|
|
126
|
+
if "image" in kwargs:
|
|
127
|
+
return args, {**kwargs, "image": to_image(kwargs["image"], policy)}
|
|
128
|
+
if args:
|
|
129
|
+
return (to_image(args[0], policy), *args[1:]), kwargs
|
|
130
|
+
return args, kwargs
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def requires_access(task: Task, *, validate_image: bool = True) -> Callable[[F], F]:
|
|
134
|
+
"""Authenticate, authorize and charge quota before running the method.
|
|
135
|
+
|
|
136
|
+
The method must be called with an ``api_key`` keyword and its instance
|
|
137
|
+
must keep an :class:`AccessContext` in ``_access``. It receives the
|
|
138
|
+
resolved ``principal`` as a keyword argument.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
task: Task the method performs, for authorization and telemetry.
|
|
142
|
+
validate_image: Decode and validate the image argument before
|
|
143
|
+
charging, so invalid input costs nothing. Set False for video.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def decorator(func: F) -> F:
|
|
147
|
+
@functools.wraps(func)
|
|
148
|
+
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
149
|
+
access: AccessContext | None = getattr(self, "_access", None)
|
|
150
|
+
if access is None:
|
|
151
|
+
raise ValidationError(
|
|
152
|
+
f"{type(self).__name__} has no AccessContext; cannot enforce @requires_access"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
api_key = kwargs.pop("api_key", None)
|
|
156
|
+
if not api_key:
|
|
157
|
+
raise ValidationError("api_key is required")
|
|
158
|
+
|
|
159
|
+
principal: Principal = access.validator.validate(api_key)
|
|
160
|
+
access.policy.authorize(principal, task)
|
|
161
|
+
if validate_image:
|
|
162
|
+
args, kwargs = _validated_inputs(access, args, kwargs)
|
|
163
|
+
access.charge(principal, task)
|
|
164
|
+
|
|
165
|
+
access.telemetry.on_call_start(task=task.value, principal_id=principal.principal_id)
|
|
166
|
+
start = time.perf_counter()
|
|
167
|
+
try:
|
|
168
|
+
result = func(self, *args, principal=principal, **kwargs)
|
|
169
|
+
except HorasisError as exc:
|
|
170
|
+
access.telemetry.on_error(
|
|
171
|
+
task=task.value, principal_id=principal.principal_id, error=exc
|
|
172
|
+
)
|
|
173
|
+
raise
|
|
174
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
175
|
+
access.telemetry.on_error(
|
|
176
|
+
task=task.value, principal_id=principal.principal_id, error=exc
|
|
177
|
+
)
|
|
178
|
+
raise
|
|
179
|
+
latency_ms = (time.perf_counter() - start) * 1000
|
|
180
|
+
backend = getattr(result, "backend", "unknown")
|
|
181
|
+
access.telemetry.on_call_end(
|
|
182
|
+
task=task.value,
|
|
183
|
+
principal_id=principal.principal_id,
|
|
184
|
+
backend=backend,
|
|
185
|
+
latency_ms=latency_ms,
|
|
186
|
+
success=True,
|
|
187
|
+
)
|
|
188
|
+
return result
|
|
189
|
+
|
|
190
|
+
return wrapper # type: ignore[return-value]
|
|
191
|
+
|
|
192
|
+
return decorator
|
horasis/access/keys.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""API key validators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from horasis.access.principal import Principal
|
|
14
|
+
from horasis.access.tiers import Tier
|
|
15
|
+
from horasis.foundation.errors import ExpiredApiKeyError, InvalidApiKeyError, RevokedApiKeyError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ApiKeyValidator(Protocol):
|
|
19
|
+
"""Resolves a raw API key string into an authenticated :class:`Principal`."""
|
|
20
|
+
|
|
21
|
+
def validate(self, api_key: str) -> Principal:
|
|
22
|
+
"""Return the :class:`Principal` for `api_key`.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
InvalidApiKeyError: if the key is missing, malformed, or unknown.
|
|
26
|
+
ExpiredApiKeyError: if the key existed but has passed its expiry.
|
|
27
|
+
RevokedApiKeyError: if the key was explicitly revoked.
|
|
28
|
+
"""
|
|
29
|
+
raise NotImplementedError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class InMemoryApiKeyValidator:
|
|
33
|
+
"""Validator backed by a dict of plaintext keys, for tests and demos.
|
|
34
|
+
|
|
35
|
+
Use :class:`HashedApiKeyValidator` for real deployments.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._keys: dict[str, tuple[Principal, float | None, bool]] = {}
|
|
40
|
+
|
|
41
|
+
def register(
|
|
42
|
+
self,
|
|
43
|
+
api_key: str,
|
|
44
|
+
principal: Principal,
|
|
45
|
+
*,
|
|
46
|
+
expires_at: float | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Register `api_key`, optionally with an expiry (Unix epoch seconds)."""
|
|
49
|
+
self._keys[api_key] = (principal, expires_at, False)
|
|
50
|
+
|
|
51
|
+
def register_simple(
|
|
52
|
+
self,
|
|
53
|
+
api_key: str,
|
|
54
|
+
*,
|
|
55
|
+
principal_id: str,
|
|
56
|
+
tier: Tier,
|
|
57
|
+
expires_at: float | None = None,
|
|
58
|
+
scopes: list[str] | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self.register(
|
|
61
|
+
api_key,
|
|
62
|
+
Principal(principal_id=principal_id, tier=tier, scopes=scopes),
|
|
63
|
+
expires_at=expires_at,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def revoke(self, api_key: str) -> None:
|
|
67
|
+
"""Revoke `api_key` but keep its record.
|
|
68
|
+
|
|
69
|
+
`validate` then raises `RevokedApiKeyError` rather than `InvalidApiKeyError`.
|
|
70
|
+
"""
|
|
71
|
+
if api_key in self._keys:
|
|
72
|
+
principal, expires_at, _ = self._keys[api_key]
|
|
73
|
+
self._keys[api_key] = (principal, expires_at, True)
|
|
74
|
+
|
|
75
|
+
def validate(self, api_key: str) -> Principal:
|
|
76
|
+
if not api_key:
|
|
77
|
+
raise InvalidApiKeyError("API key is empty")
|
|
78
|
+
|
|
79
|
+
entry = self._keys.get(api_key)
|
|
80
|
+
if entry is None:
|
|
81
|
+
raise InvalidApiKeyError("API key is unknown")
|
|
82
|
+
|
|
83
|
+
principal, expires_at, revoked = entry
|
|
84
|
+
if revoked:
|
|
85
|
+
raise RevokedApiKeyError(
|
|
86
|
+
"API key has been revoked", context={"principal_id": principal.principal_id}
|
|
87
|
+
)
|
|
88
|
+
if expires_at is not None and time.time() >= expires_at:
|
|
89
|
+
raise ExpiredApiKeyError(
|
|
90
|
+
"API key has expired", context={"principal_id": principal.principal_id}
|
|
91
|
+
)
|
|
92
|
+
return principal
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class _KeyRecord:
|
|
97
|
+
salt: bytes
|
|
98
|
+
digest: bytes
|
|
99
|
+
principal: Principal
|
|
100
|
+
expires_at: float | None
|
|
101
|
+
revoked: bool = False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _digest(salt: bytes, secret: str) -> bytes:
|
|
105
|
+
return hashlib.sha256(salt + secret.encode()).digest()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class HashedApiKeyValidator:
|
|
109
|
+
"""Validator that never stores a key, only a per-key salted SHA-256 hash.
|
|
110
|
+
|
|
111
|
+
Keys look like ``hrs_<key_id>.<secret>``. The public ``key_id`` finds the
|
|
112
|
+
record; the 256-bit random ``secret`` is checked against its salted hash
|
|
113
|
+
with a constant-time comparison. (A fast hash is appropriate because the
|
|
114
|
+
secret is high-entropy random, not a human password.) A key is shown once,
|
|
115
|
+
at :meth:`issue` time, and cannot be recovered afterwards.
|
|
116
|
+
|
|
117
|
+
Supports expiry, revocation and scopes (which tasks the key may call).
|
|
118
|
+
Records live in memory; use :meth:`export_records` / :meth:`load_records`
|
|
119
|
+
to persist them in your own store.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
PREFIX = "hrs_"
|
|
123
|
+
|
|
124
|
+
def __init__(self) -> None:
|
|
125
|
+
self._records: dict[str, _KeyRecord] = {}
|
|
126
|
+
|
|
127
|
+
def issue(
|
|
128
|
+
self,
|
|
129
|
+
*,
|
|
130
|
+
principal_id: str,
|
|
131
|
+
tier: Tier,
|
|
132
|
+
expires_at: float | None = None,
|
|
133
|
+
scopes: list[str] | None = None,
|
|
134
|
+
metadata: dict[str, str] | None = None,
|
|
135
|
+
) -> str:
|
|
136
|
+
"""Create a key and return it. This is the only time it is available."""
|
|
137
|
+
key_id = secrets.token_hex(6)
|
|
138
|
+
secret = secrets.token_urlsafe(32)
|
|
139
|
+
salt = os.urandom(16)
|
|
140
|
+
principal = Principal(
|
|
141
|
+
principal_id=principal_id, tier=tier, scopes=scopes, metadata=metadata or {}
|
|
142
|
+
)
|
|
143
|
+
self._records[key_id] = _KeyRecord(salt, _digest(salt, secret), principal, expires_at)
|
|
144
|
+
return f"{self.PREFIX}{key_id}.{secret}"
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def _split(api_key: str) -> tuple[str, str] | None:
|
|
148
|
+
if not api_key.startswith(HashedApiKeyValidator.PREFIX) or "." not in api_key:
|
|
149
|
+
return None
|
|
150
|
+
key_id, _, secret = api_key[len(HashedApiKeyValidator.PREFIX) :].partition(".")
|
|
151
|
+
return (key_id, secret) if key_id and secret else None
|
|
152
|
+
|
|
153
|
+
def revoke(self, api_key_or_id: str) -> None:
|
|
154
|
+
"""Revoke by full key or by ``key_id``."""
|
|
155
|
+
parts = self._split(api_key_or_id)
|
|
156
|
+
record = self._records.get(parts[0] if parts else api_key_or_id)
|
|
157
|
+
if record is not None:
|
|
158
|
+
record.revoked = True
|
|
159
|
+
|
|
160
|
+
def validate(self, api_key: str) -> Principal:
|
|
161
|
+
parts = self._split(api_key) if api_key else None
|
|
162
|
+
if parts is None:
|
|
163
|
+
raise InvalidApiKeyError("API key is malformed or empty")
|
|
164
|
+
key_id, secret = parts
|
|
165
|
+
record = self._records.get(key_id)
|
|
166
|
+
# Hash even for unknown ids so timing doesn't reveal which ids exist.
|
|
167
|
+
salt = record.salt if record else bytes(16)
|
|
168
|
+
expected = record.digest if record else bytes(32)
|
|
169
|
+
ok = hmac.compare_digest(_digest(salt, secret), expected)
|
|
170
|
+
if record is None or not ok:
|
|
171
|
+
raise InvalidApiKeyError("API key is unknown")
|
|
172
|
+
if record.revoked:
|
|
173
|
+
raise RevokedApiKeyError(
|
|
174
|
+
"API key has been revoked", context={"principal_id": record.principal.principal_id}
|
|
175
|
+
)
|
|
176
|
+
if record.expires_at is not None and time.time() >= record.expires_at:
|
|
177
|
+
raise ExpiredApiKeyError(
|
|
178
|
+
"API key has expired", context={"principal_id": record.principal.principal_id}
|
|
179
|
+
)
|
|
180
|
+
return record.principal
|
|
181
|
+
|
|
182
|
+
def export_records(self) -> dict[str, dict[str, object]]:
|
|
183
|
+
"""Return a JSON-friendly snapshot for persistence (hashes only, never keys)."""
|
|
184
|
+
return {
|
|
185
|
+
kid: {
|
|
186
|
+
"salt": r.salt.hex(),
|
|
187
|
+
"digest": r.digest.hex(),
|
|
188
|
+
"principal": r.principal.model_dump(mode="json"),
|
|
189
|
+
"expires_at": r.expires_at,
|
|
190
|
+
"revoked": r.revoked,
|
|
191
|
+
}
|
|
192
|
+
for kid, r in self._records.items()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
def load_records(self, records: dict[str, dict[str, object]]) -> None:
|
|
196
|
+
"""Restore a snapshot made by :meth:`export_records`."""
|
|
197
|
+
for kid, r in records.items():
|
|
198
|
+
self._records[kid] = _KeyRecord(
|
|
199
|
+
salt=bytes.fromhex(str(r["salt"])),
|
|
200
|
+
digest=bytes.fromhex(str(r["digest"])),
|
|
201
|
+
principal=Principal.model_validate(r["principal"]),
|
|
202
|
+
expires_at=r["expires_at"], # type: ignore[arg-type]
|
|
203
|
+
revoked=bool(r["revoked"]),
|
|
204
|
+
)
|
horasis/access/policy.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from horasis.access.principal import Principal
|
|
4
|
+
from horasis.foundation.config import Config
|
|
5
|
+
from horasis.foundation.errors import AccessDeniedError, ConfigurationError
|
|
6
|
+
from horasis.foundation.schemas import Task
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Policy:
|
|
10
|
+
"""Checks whether a principal's tier and key scopes allow a task."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, config: Config) -> None:
|
|
13
|
+
self._config = config
|
|
14
|
+
|
|
15
|
+
def authorize(self, principal: Principal, task: Task) -> None:
|
|
16
|
+
"""Raise :class:`AccessDeniedError` unless `principal` may call `task`.
|
|
17
|
+
|
|
18
|
+
The tier must be configured and allow `task`, and the key's scopes,
|
|
19
|
+
if set, must include it.
|
|
20
|
+
"""
|
|
21
|
+
try:
|
|
22
|
+
rule = self._config.tier(principal.tier.value)
|
|
23
|
+
except ConfigurationError as exc:
|
|
24
|
+
raise AccessDeniedError(
|
|
25
|
+
f"tier {principal.tier.value!r} is not configured",
|
|
26
|
+
context={"principal_id": principal.principal_id},
|
|
27
|
+
) from exc
|
|
28
|
+
|
|
29
|
+
if task.value not in rule.allowed_tasks:
|
|
30
|
+
raise AccessDeniedError(
|
|
31
|
+
f"tier {principal.tier.value!r} may not call {task.value!r}",
|
|
32
|
+
context={
|
|
33
|
+
"principal_id": principal.principal_id,
|
|
34
|
+
"tier": principal.tier.value,
|
|
35
|
+
"task": task.value,
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if principal.scopes is not None and task.value not in principal.scopes:
|
|
40
|
+
raise AccessDeniedError(
|
|
41
|
+
f"API key is not scoped for {task.value!r}",
|
|
42
|
+
context={
|
|
43
|
+
"principal_id": principal.principal_id,
|
|
44
|
+
"task": task.value,
|
|
45
|
+
"scopes": list(principal.scopes),
|
|
46
|
+
},
|
|
47
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
from horasis.access.tiers import Tier
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Principal(BaseModel):
|
|
9
|
+
"""An authenticated caller.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
principal_id: Stable identifier for the caller (never the raw key).
|
|
13
|
+
tier: The caller's tier.
|
|
14
|
+
metadata: Free-form data attached by the key-validation backend
|
|
15
|
+
(e.g. organization name, plan expiry).
|
|
16
|
+
scopes: Task values this caller's key may invoke, or ``None`` for
|
|
17
|
+
"everything the tier allows". Scopes only narrow the tier.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(frozen=True)
|
|
21
|
+
|
|
22
|
+
principal_id: str
|
|
23
|
+
tier: Tier
|
|
24
|
+
metadata: dict[str, str] = Field(default_factory=dict)
|
|
25
|
+
scopes: list[str] | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AnonymousPrincipal(Principal):
|
|
29
|
+
"""Principal for tests and local development."""
|
|
30
|
+
|
|
31
|
+
principal_id: str = "anonymous"
|
|
32
|
+
tier: Tier = Tier.FREE
|