unitysvc-core 0.3.0__tar.gz → 0.3.1__tar.gz
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.
- {unitysvc_core-0.3.0/src/unitysvc_core.egg-info → unitysvc_core-0.3.1}/PKG-INFO +10 -2
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/README.md +9 -1
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/pyproject.toml +1 -1
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/grants.py +56 -3
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1/src/unitysvc_core.egg-info}/PKG-INFO +10 -2
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_grants.py +81 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/LICENSE +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/setup.cfg +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/__init__.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/__init__.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/base.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/channel.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/documents.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/listing_data.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/listing_v1.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/offering_data.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/offering_v1.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/pricing.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/promotion_data.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/provider_data.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/provider_v1.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/service.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/service_group_data.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/validators.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/parameter_usage.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/py.typed +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/schema/base.json +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/schema/listing_v1.json +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/schema/offering_v1.json +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/schema/promotion_v1.json +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/schema/provider_v1.json +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/schema/service_group_v1.json +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/utils.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/validator.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core.egg-info/SOURCES.txt +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core.egg-info/requires.txt +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core.egg-info/top_level.txt +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_access_interface_routing.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_canonical_interfaces.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_channel.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_effective_price.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_hidden_path_skip.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_listing_audience.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_offering_data.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_parameter_usage.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_pricing.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_provider_account_rate_limits.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_service_group.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_service_status.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_utils.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_validate_service.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_validator.py +0 -0
- {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_validators.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: unitysvc-core
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Shared data models and validation helpers for UnitySVC packages
|
|
5
5
|
Author-email: Bo Peng <bo.peng@unitysvc.com>
|
|
6
6
|
Maintainer-email: Bo Peng <bo.peng@unitysvc.com>
|
|
@@ -208,7 +208,7 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
|
|
|
208
208
|
"limits": {"count_per_day": 1000}}]
|
|
209
209
|
```
|
|
210
210
|
|
|
211
|
-
- `object` names one service (`service:<name>`, no wildcards), or `apikey` for the leased key's lifespan (below).
|
|
211
|
+
- `object` names one service (`service:<name>`, no wildcards), or `apikey` for the leased key's lifespan (below).
|
|
212
212
|
- `options` is written in query-string syntax (`parse_options`; a notation, not how options are delivered): each name once, each value non-empty; with a catalog, each value is converted to its declared type and validated against the target's `options` schema.
|
|
213
213
|
- **Enrollment is where options and limits are set.** In a request, `options` and `limits` only pre-fill the enrollment form for convenience. When enrolling, the customer confirms and sets the options (a form rendered from the target's `options` schema) and the limits, and adds their own limits; the result is the issued key's policy.
|
|
214
214
|
- `limits` (`validate_limits`): `count_per_hour` / `count_per_day` / `count_per_month` / `count_total` (positive integer) and the same for `amount_` (finite positive decimal, as a number or a string). Limits are universal: the platform meters every call, so any service can be limited without exporting anything. The seller's limits are a pre-fill; the customer sets them at enrollment, and an enrollment's own limits (unitysvc/unitysvc#2687) use the same shape. They are counted per policy entry (the enrollment's calls to that service) from the usage stream (counters keyed by scope, metric and window bucket), and the gateway refuses calls once one is exceeded.
|
|
@@ -241,6 +241,14 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
|
|
|
241
241
|
- The `apikey` lifespan may be shortened, never lengthened beyond the request.
|
|
242
242
|
- The result drops `required` / `justification` and empty `options` / `limits`. `api_key_lifespan(policy)` reads the lifespan back (the default when there is none).
|
|
243
243
|
|
|
244
|
+
A policy has three kinds of entry (unitysvc/unitysvc#2770):
|
|
245
|
+
|
|
246
|
+
| Entry | Means |
|
|
247
|
+
| --- | --- |
|
|
248
|
+
| `{"object": "enrollment", "limits": {"amount_per_day": "1.00"}}` | This enrollment's own limits (total spending, say). The customer adds it without a request; it takes only `limits`, at least one, at most one entry. It grants nothing. |
|
|
249
|
+
| `{"object": "service:ai", ...}` | A service the app calls for the customer. A seller may name a specific one (`service:labs/llm-premium` instead of `service:ai` with `options="service=labs/llm-premium"`), at the cost of the customer's choice of provider, which a customer may decline. |
|
|
250
|
+
| `{"object": "service:<name>", "enrollment": "<CODE>", ...}` | One of the customer's enrollments of a service that requires enrolling (a chain enrollment), called through `/e/<CODE>`. The customer picks the enrollment when approving the request for `service:<name>`, so a request never carries a code. A canonical code, no `options` (the enrollment's params are fixed), permissions and limits as for any service entry. Core checks the code's form; the caller checks the enrollment is the customer's. |
|
|
251
|
+
|
|
244
252
|
### Upstream access channels
|
|
245
253
|
|
|
246
254
|
`upstream_access_config` maps a channel name to a channel (`UpstreamAccessConfig`, `ChannelConfig`; unitysvc/unitysvc#2730). A channel describes one way the platform reaches the upstream, as a whole, and its `servers` hold the connection, typed by the channel's protocol:
|
|
@@ -173,7 +173,7 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
|
|
|
173
173
|
"limits": {"count_per_day": 1000}}]
|
|
174
174
|
```
|
|
175
175
|
|
|
176
|
-
- `object` names one service (`service:<name>`, no wildcards), or `apikey` for the leased key's lifespan (below).
|
|
176
|
+
- `object` names one service (`service:<name>`, no wildcards), or `apikey` for the leased key's lifespan (below).
|
|
177
177
|
- `options` is written in query-string syntax (`parse_options`; a notation, not how options are delivered): each name once, each value non-empty; with a catalog, each value is converted to its declared type and validated against the target's `options` schema.
|
|
178
178
|
- **Enrollment is where options and limits are set.** In a request, `options` and `limits` only pre-fill the enrollment form for convenience. When enrolling, the customer confirms and sets the options (a form rendered from the target's `options` schema) and the limits, and adds their own limits; the result is the issued key's policy.
|
|
179
179
|
- `limits` (`validate_limits`): `count_per_hour` / `count_per_day` / `count_per_month` / `count_total` (positive integer) and the same for `amount_` (finite positive decimal, as a number or a string). Limits are universal: the platform meters every call, so any service can be limited without exporting anything. The seller's limits are a pre-fill; the customer sets them at enrollment, and an enrollment's own limits (unitysvc/unitysvc#2687) use the same shape. They are counted per policy entry (the enrollment's calls to that service) from the usage stream (counters keyed by scope, metric and window bucket), and the gateway refuses calls once one is exceeded.
|
|
@@ -206,6 +206,14 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
|
|
|
206
206
|
- The `apikey` lifespan may be shortened, never lengthened beyond the request.
|
|
207
207
|
- The result drops `required` / `justification` and empty `options` / `limits`. `api_key_lifespan(policy)` reads the lifespan back (the default when there is none).
|
|
208
208
|
|
|
209
|
+
A policy has three kinds of entry (unitysvc/unitysvc#2770):
|
|
210
|
+
|
|
211
|
+
| Entry | Means |
|
|
212
|
+
| --- | --- |
|
|
213
|
+
| `{"object": "enrollment", "limits": {"amount_per_day": "1.00"}}` | This enrollment's own limits (total spending, say). The customer adds it without a request; it takes only `limits`, at least one, at most one entry. It grants nothing. |
|
|
214
|
+
| `{"object": "service:ai", ...}` | A service the app calls for the customer. A seller may name a specific one (`service:labs/llm-premium` instead of `service:ai` with `options="service=labs/llm-premium"`), at the cost of the customer's choice of provider, which a customer may decline. |
|
|
215
|
+
| `{"object": "service:<name>", "enrollment": "<CODE>", ...}` | One of the customer's enrollments of a service that requires enrolling (a chain enrollment), called through `/e/<CODE>`. The customer picks the enrollment when approving the request for `service:<name>`, so a request never carries a code. A canonical code, no `options` (the enrollment's params are fixed), permissions and limits as for any service entry. Core checks the code's form; the caller checks the enrollment is the customer's. |
|
|
216
|
+
|
|
209
217
|
### Upstream access channels
|
|
210
218
|
|
|
211
219
|
`upstream_access_config` maps a channel name to a channel (`UpstreamAccessConfig`, `ChannelConfig`; unitysvc/unitysvc#2730). A channel describes one way the platform reaches the upstream, as a whole, and its `servers` hold the connection, typed by the channel's protocol:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "unitysvc-core"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Shared data models and validation helpers for UnitySVC packages"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "Bo Peng", email = "bo.peng@unitysvc.com" }]
|
|
@@ -74,10 +74,26 @@ customer consent and key issuance establish its *authority*.
|
|
|
74
74
|
|
|
75
75
|
Today a request's ``object`` is ``service:<name>``, or ``apikey``: how long a
|
|
76
76
|
key leased for the enrollment lives (``limits.api_key_lifespan``, up to a day,
|
|
77
|
-
with a justification), which grants nothing.
|
|
78
|
-
enrollment of an enrollable service) may follow. :func:`build_policy` turns a
|
|
77
|
+
with a justification), which grants nothing. :func:`build_policy` turns a
|
|
79
78
|
customer's approvals into the policy an enrollment keeps.
|
|
80
79
|
|
|
80
|
+
A policy has three kinds of entry (unitysvc/unitysvc#2770):
|
|
81
|
+
|
|
82
|
+
* ``{"object": "enrollment", "limits": {...}}``: this enrollment's own limits
|
|
83
|
+
(what it may cost in total, say). The customer's own; no seller requests it,
|
|
84
|
+
and it grants nothing.
|
|
85
|
+
* ``{"object": "service:<name>", ...}``: a service the enrolled app calls for
|
|
86
|
+
the customer, usually a common one (``service:ai``, ``service:notify``). A
|
|
87
|
+
seller may name a specific service (``service:labs/llm-premium`` rather than
|
|
88
|
+
``service:ai`` with ``options="service=labs/llm-premium"``), at the cost of
|
|
89
|
+
the customer's choice of provider, which a customer may decline.
|
|
90
|
+
* ``{"object": "service:<name>", "enrollment": "<CODE>", ...}``: one of the
|
|
91
|
+
customer's enrollments of a service that requires enrolling (a chain
|
|
92
|
+
enrollment), which the app calls through ``/e/<CODE>``. Its params are fixed
|
|
93
|
+
by that enrollment, so it takes no ``options``. Which enrollment is the
|
|
94
|
+
customer's choice when approving, so a request never names one; core checks
|
|
95
|
+
the code's form, the caller that the enrollment is the customer's.
|
|
96
|
+
|
|
81
97
|
There is no central list of services. Core does no I/O: every request's
|
|
82
98
|
permissions and options are checked against its target's ``exports`` when the
|
|
83
99
|
caller passes an :data:`ExportsResolver` that looks the target up in the
|
|
@@ -157,6 +173,12 @@ _REQUEST_KEYS = frozenset({"object", "permissions", "required", "justification",
|
|
|
157
173
|
_APIKEY_REQUEST_KEYS = frozenset({"object", "justification", "limits"})
|
|
158
174
|
# What a policy entry (an approved request) holds.
|
|
159
175
|
_POLICY_KEYS = frozenset({"object", "permissions", "options", "limits"})
|
|
176
|
+
# This enrollment itself: an entry capping what it may cost. It grants nothing.
|
|
177
|
+
ENROLLMENT_OBJECT = "enrollment"
|
|
178
|
+
# On a service entry, one enrollment of that service (a chain enrollment).
|
|
179
|
+
ENROLLMENT_KEY = "enrollment"
|
|
180
|
+
# An enrollment code: canonical Crockford base32 (no I, L, O, U).
|
|
181
|
+
_ENROLLMENT_CODE = re.compile(r"^[0-9A-HJKMNP-TV-Z]+$")
|
|
160
182
|
|
|
161
183
|
|
|
162
184
|
def _strings(value: Any) -> bool:
|
|
@@ -469,6 +491,13 @@ def build_policy(requests: Any, approvals: Any, resolve: ExportsResolver | None
|
|
|
469
491
|
:func:`validate_limits`, not against the request. A key lifespan may only
|
|
470
492
|
be shortened. Returns the approvals as policy entries, without empty
|
|
471
493
|
``options`` / ``limits``. Raises ``ValueError``.
|
|
494
|
+
|
|
495
|
+
The customer may also add, at most once and without a request, this
|
|
496
|
+
enrollment's own limits: ``{"object": "enrollment", "limits": {...}}``,
|
|
497
|
+
with at least one limit and nothing else (unitysvc/unitysvc#2770). And an
|
|
498
|
+
approval of a service may select one of the customer's enrollments of it
|
|
499
|
+
(``"enrollment": "<CODE>"``, a chain enrollment): a canonical code, and no
|
|
500
|
+
``options``. The caller checks the enrollment is the customer's.
|
|
472
501
|
"""
|
|
473
502
|
validate_access_requests("requests", requests)
|
|
474
503
|
requested = {entry["object"]: entry for entry in requests}
|
|
@@ -480,6 +509,11 @@ def build_policy(requests: Any, approvals: Any, resolve: ExportsResolver | None
|
|
|
480
509
|
if not isinstance(approval, dict) or not isinstance(approval.get("object"), str):
|
|
481
510
|
raise ValueError(f'{at} must be {{"object": ..., "permissions": [...]}}')
|
|
482
511
|
obj = approval["object"]
|
|
512
|
+
if obj == ENROLLMENT_OBJECT:
|
|
513
|
+
if any(entry["object"] == obj for entry in policy):
|
|
514
|
+
raise ValueError(f"{at}: {obj} is listed twice")
|
|
515
|
+
policy.append(_enrollment_entry(at, approval))
|
|
516
|
+
continue
|
|
483
517
|
request = requested.get(obj)
|
|
484
518
|
if request is None:
|
|
485
519
|
raise ValueError(f"{at}: {obj} is not requested")
|
|
@@ -494,8 +528,14 @@ def build_policy(requests: Any, approvals: Any, resolve: ExportsResolver | None
|
|
|
494
528
|
raise ValueError(f"{at}.limits.{API_KEY_LIFESPAN}: the service asks for at most {asked} seconds")
|
|
495
529
|
policy.append({"object": obj, "limits": dict(approval["limits"])})
|
|
496
530
|
continue
|
|
497
|
-
if extra := set(approval) - _POLICY_KEYS:
|
|
531
|
+
if extra := set(approval) - _POLICY_KEYS - {ENROLLMENT_KEY}:
|
|
498
532
|
raise ValueError(f"{at}: unknown keys {sorted(extra)}")
|
|
533
|
+
code = approval.get(ENROLLMENT_KEY)
|
|
534
|
+
if ENROLLMENT_KEY in approval:
|
|
535
|
+
if not isinstance(code, str) or not _ENROLLMENT_CODE.match(code):
|
|
536
|
+
raise ValueError(f"{at}.{ENROLLMENT_KEY} must be an enrollment code, like JYY87Y")
|
|
537
|
+
if approval.get("options"):
|
|
538
|
+
raise ValueError(f"{at}.options: an enrollment's params are fixed; it takes no options")
|
|
499
539
|
permissions: list[str] = approval.get("permissions") or []
|
|
500
540
|
if not _strings(permissions):
|
|
501
541
|
raise ValueError(f"{at}.permissions must be a non-empty list of distinct names")
|
|
@@ -511,6 +551,8 @@ def build_policy(requests: Any, approvals: Any, resolve: ExportsResolver | None
|
|
|
511
551
|
raise ValueError(f"{at}: {obj} is not a published service")
|
|
512
552
|
check_claim(at, obj, permissions, options, parse_exports(f"{obj} exports", declared))
|
|
513
553
|
entry: dict[str, Any] = {"object": obj, "permissions": list(permissions)}
|
|
554
|
+
if code is not None:
|
|
555
|
+
entry[ENROLLMENT_KEY] = code
|
|
514
556
|
if options:
|
|
515
557
|
entry["options"] = options_text
|
|
516
558
|
if limits:
|
|
@@ -522,6 +564,17 @@ def build_policy(requests: Any, approvals: Any, resolve: ExportsResolver | None
|
|
|
522
564
|
return policy
|
|
523
565
|
|
|
524
566
|
|
|
567
|
+
def _enrollment_entry(at: str, approval: dict[str, Any]) -> dict[str, Any]:
|
|
568
|
+
"""This enrollment's own entry: limits only, and at least one."""
|
|
569
|
+
if extra := set(approval) - {"object", "limits"}:
|
|
570
|
+
raise ValueError(f"{at}: unknown keys {sorted(extra)} (the {ENROLLMENT_OBJECT} entry takes only limits)")
|
|
571
|
+
limits = approval.get("limits")
|
|
572
|
+
if not limits:
|
|
573
|
+
raise ValueError(f"{at}.limits: the {ENROLLMENT_OBJECT} entry must set at least one limit")
|
|
574
|
+
validate_limits(f"{at}.limits", limits)
|
|
575
|
+
return {"object": ENROLLMENT_OBJECT, "limits": dict(limits)}
|
|
576
|
+
|
|
577
|
+
|
|
525
578
|
def api_key_lifespan(policy: Any) -> int:
|
|
526
579
|
"""Seconds a key leased with ``policy`` lives: its ``apikey`` entry's
|
|
527
580
|
``api_key_lifespan``, else :data:`DEFAULT_API_KEY_LIFESPAN`. A malformed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: unitysvc-core
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Shared data models and validation helpers for UnitySVC packages
|
|
5
5
|
Author-email: Bo Peng <bo.peng@unitysvc.com>
|
|
6
6
|
Maintainer-email: Bo Peng <bo.peng@unitysvc.com>
|
|
@@ -208,7 +208,7 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
|
|
|
208
208
|
"limits": {"count_per_day": 1000}}]
|
|
209
209
|
```
|
|
210
210
|
|
|
211
|
-
- `object` names one service (`service:<name>`, no wildcards), or `apikey` for the leased key's lifespan (below).
|
|
211
|
+
- `object` names one service (`service:<name>`, no wildcards), or `apikey` for the leased key's lifespan (below).
|
|
212
212
|
- `options` is written in query-string syntax (`parse_options`; a notation, not how options are delivered): each name once, each value non-empty; with a catalog, each value is converted to its declared type and validated against the target's `options` schema.
|
|
213
213
|
- **Enrollment is where options and limits are set.** In a request, `options` and `limits` only pre-fill the enrollment form for convenience. When enrolling, the customer confirms and sets the options (a form rendered from the target's `options` schema) and the limits, and adds their own limits; the result is the issued key's policy.
|
|
214
214
|
- `limits` (`validate_limits`): `count_per_hour` / `count_per_day` / `count_per_month` / `count_total` (positive integer) and the same for `amount_` (finite positive decimal, as a number or a string). Limits are universal: the platform meters every call, so any service can be limited without exporting anything. The seller's limits are a pre-fill; the customer sets them at enrollment, and an enrollment's own limits (unitysvc/unitysvc#2687) use the same shape. They are counted per policy entry (the enrollment's calls to that service) from the usage stream (counters keyed by scope, metric and window bucket), and the gateway refuses calls once one is exceeded.
|
|
@@ -241,6 +241,14 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
|
|
|
241
241
|
- The `apikey` lifespan may be shortened, never lengthened beyond the request.
|
|
242
242
|
- The result drops `required` / `justification` and empty `options` / `limits`. `api_key_lifespan(policy)` reads the lifespan back (the default when there is none).
|
|
243
243
|
|
|
244
|
+
A policy has three kinds of entry (unitysvc/unitysvc#2770):
|
|
245
|
+
|
|
246
|
+
| Entry | Means |
|
|
247
|
+
| --- | --- |
|
|
248
|
+
| `{"object": "enrollment", "limits": {"amount_per_day": "1.00"}}` | This enrollment's own limits (total spending, say). The customer adds it without a request; it takes only `limits`, at least one, at most one entry. It grants nothing. |
|
|
249
|
+
| `{"object": "service:ai", ...}` | A service the app calls for the customer. A seller may name a specific one (`service:labs/llm-premium` instead of `service:ai` with `options="service=labs/llm-premium"`), at the cost of the customer's choice of provider, which a customer may decline. |
|
|
250
|
+
| `{"object": "service:<name>", "enrollment": "<CODE>", ...}` | One of the customer's enrollments of a service that requires enrolling (a chain enrollment), called through `/e/<CODE>`. The customer picks the enrollment when approving the request for `service:<name>`, so a request never carries a code. A canonical code, no `options` (the enrollment's params are fixed), permissions and limits as for any service entry. Core checks the code's form; the caller checks the enrollment is the customer's. |
|
|
251
|
+
|
|
244
252
|
### Upstream access channels
|
|
245
253
|
|
|
246
254
|
`upstream_access_config` maps a channel name to a channel (`UpstreamAccessConfig`, `ChannelConfig`; unitysvc/unitysvc#2730). A channel describes one way the platform reaches the upstream, as a whole, and its `servers` hold the connection, typed by the channel's protocol:
|
|
@@ -511,3 +511,84 @@ def test_malformed_requests_build_no_policy() -> None:
|
|
|
511
511
|
)
|
|
512
512
|
def test_a_policy_without_a_valid_lifespan_gets_the_default(policy: Any) -> None:
|
|
513
513
|
assert api_key_lifespan(policy) == DEFAULT_API_KEY_LIFESPAN
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# This enrollment's own limits (unitysvc/unitysvc#2770) ---------------------------
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def test_the_enrollment_entry_needs_no_request() -> None:
|
|
520
|
+
policy = _approve(_LOG_APPROVAL, {"object": "enrollment", "limits": {"amount_per_day": "1.00"}})
|
|
521
|
+
assert policy[-1] == {"object": "enrollment", "limits": {"amount_per_day": "1.00"}}
|
|
522
|
+
# Any enrollment may carry one, even of a channel that requests nothing.
|
|
523
|
+
assert build_policy([], [{"object": "enrollment", "limits": {"count_total": 5}}]) == [
|
|
524
|
+
{"object": "enrollment", "limits": {"count_total": 5}}
|
|
525
|
+
]
|
|
526
|
+
assert api_key_lifespan(policy) == DEFAULT_API_KEY_LIFESPAN
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
@pytest.mark.parametrize(
|
|
530
|
+
("entry", "error"),
|
|
531
|
+
[
|
|
532
|
+
({"object": "enrollment"}, "at least one limit"),
|
|
533
|
+
({"object": "enrollment", "limits": {}}, "at least one limit"),
|
|
534
|
+
({"object": "enrollment", "limits": {"calls": 1}}, "count_/amount_"),
|
|
535
|
+
({"object": "enrollment", "permissions": ["invoke"], "limits": {"count_total": 1}}, "takes only limits"),
|
|
536
|
+
({"object": "enrollment", "options": "a=b", "limits": {"count_total": 1}}, "takes only limits"),
|
|
537
|
+
({"object": "enrollment", "enrollment": "ABC123", "limits": {"count_total": 1}}, "takes only limits"),
|
|
538
|
+
],
|
|
539
|
+
)
|
|
540
|
+
def test_the_enrollment_entry_takes_only_limits(entry: dict[str, Any], error: str) -> None:
|
|
541
|
+
with pytest.raises(ValueError, match=error):
|
|
542
|
+
_approve(_LOG_APPROVAL, entry)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def test_the_enrollment_entry_appears_once() -> None:
|
|
546
|
+
entry = {"object": "enrollment", "limits": {"count_total": 1}}
|
|
547
|
+
with pytest.raises(ValueError, match="listed twice"):
|
|
548
|
+
_approve(_LOG_APPROVAL, entry, entry)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def test_an_approval_may_select_one_of_the_customers_enrollments() -> None:
|
|
552
|
+
policy = _approve(
|
|
553
|
+
_LOG_APPROVAL,
|
|
554
|
+
{"object": "service:me", "permissions": ["read"], "enrollment": "JYY87Y", "limits": {"count_per_day": 3}},
|
|
555
|
+
)
|
|
556
|
+
assert policy[-1] == {
|
|
557
|
+
"object": "service:me",
|
|
558
|
+
"permissions": ["read"],
|
|
559
|
+
"enrollment": "JYY87Y",
|
|
560
|
+
"limits": {"count_per_day": 3},
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
@pytest.mark.parametrize(
|
|
565
|
+
("extra", "error"),
|
|
566
|
+
[
|
|
567
|
+
({"enrollment": "jyy87y"}, "must be an enrollment code"),
|
|
568
|
+
({"enrollment": "AB-12"}, "must be an enrollment code"),
|
|
569
|
+
({"enrollment": "ABCDIO"}, "must be an enrollment code"),
|
|
570
|
+
({"enrollment": ""}, "must be an enrollment code"),
|
|
571
|
+
({"enrollment": 123}, "must be an enrollment code"),
|
|
572
|
+
({"enrollment": "JYY87Y", "options": "profile=address"}, "takes no options"),
|
|
573
|
+
],
|
|
574
|
+
)
|
|
575
|
+
def test_a_chain_enrollment_names_a_code_and_takes_no_options(extra: dict[str, Any], error: str) -> None:
|
|
576
|
+
with pytest.raises(ValueError, match=error):
|
|
577
|
+
_approve(_LOG_APPROVAL, {"object": "service:me", "permissions": ["read"], **extra})
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def test_a_chain_enrollment_is_still_an_approval_of_a_request() -> None:
|
|
581
|
+
with pytest.raises(ValueError, match="is not requested"):
|
|
582
|
+
_approve(_LOG_APPROVAL, {"object": "service:notify", "permissions": ["invoke"], "enrollment": "JYY87Y"})
|
|
583
|
+
with pytest.raises(ValueError, match="requests only"):
|
|
584
|
+
_approve(_LOG_APPROVAL, {"object": "service:me", "permissions": ["delete"], "enrollment": "JYY87Y"})
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def test_a_request_never_names_an_enrollment() -> None:
|
|
588
|
+
with pytest.raises(ValueError, match="unknown keys"):
|
|
589
|
+
validate_access_requests(_WHERE, [_dep("service:me", enrollment="JYY87Y")])
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def test_a_seller_cannot_request_the_enrollment_entry() -> None:
|
|
593
|
+
with pytest.raises(ValueError, match="cannot be requested"):
|
|
594
|
+
validate_access_requests(_WHERE, [{"object": "enrollment", "justification": "x", "limits": {"count_total": 1}}])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|