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.
Files changed (50) hide show
  1. horasis/__init__.py +106 -0
  2. horasis/__main__.py +3 -0
  3. horasis/access/__init__.py +42 -0
  4. horasis/access/guard.py +192 -0
  5. horasis/access/keys.py +204 -0
  6. horasis/access/policy.py +47 -0
  7. horasis/access/principal.py +32 -0
  8. horasis/access/quota.py +170 -0
  9. horasis/access/ratelimit.py +240 -0
  10. horasis/access/stores.py +137 -0
  11. horasis/access/tiers.py +22 -0
  12. horasis/api/__init__.py +24 -0
  13. horasis/api/async_client.py +305 -0
  14. horasis/api/client.py +744 -0
  15. horasis/backend/__init__.py +26 -0
  16. horasis/backend/_common.py +79 -0
  17. horasis/backend/base.py +92 -0
  18. horasis/backend/easyocr_adapter.py +111 -0
  19. horasis/backend/face_adapter.py +153 -0
  20. horasis/backend/moderation_adapter.py +117 -0
  21. horasis/backend/onnx_adapter.py +407 -0
  22. horasis/backend/registry.py +200 -0
  23. horasis/backend/remote_adapter.py +270 -0
  24. horasis/backend/tesseract_adapter.py +103 -0
  25. horasis/backend/ultralytics_adapter.py +510 -0
  26. horasis/cli.py +342 -0
  27. horasis/config/default.yaml +34 -0
  28. horasis/foundation/__init__.py +95 -0
  29. horasis/foundation/config.py +222 -0
  30. horasis/foundation/errors.py +148 -0
  31. horasis/foundation/frames.py +72 -0
  32. horasis/foundation/imaging.py +403 -0
  33. horasis/foundation/schemas.py +776 -0
  34. horasis/foundation/telemetry.py +170 -0
  35. horasis/models.py +157 -0
  36. horasis/orchestration/__init__.py +7 -0
  37. horasis/orchestration/builtin_hooks.py +119 -0
  38. horasis/orchestration/hooks.py +89 -0
  39. horasis/orchestration/pipeline.py +164 -0
  40. horasis/plugins/__init__.py +5 -0
  41. horasis/plugins/loader.py +63 -0
  42. horasis/py.typed +0 -0
  43. horasis/server.py +229 -0
  44. horasis/viz.py +527 -0
  45. horasis-1.0.0.dist-info/METADATA +498 -0
  46. horasis-1.0.0.dist-info/RECORD +50 -0
  47. horasis-1.0.0.dist-info/WHEEL +5 -0
  48. horasis-1.0.0.dist-info/entry_points.txt +2 -0
  49. horasis-1.0.0.dist-info/licenses/LICENSE +201 -0
  50. 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,3 @@
1
+ from horasis.cli import main
2
+
3
+ raise SystemExit(main())
@@ -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
+ ]
@@ -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
+ )
@@ -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