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.
Files changed (54) hide show
  1. {unitysvc_core-0.3.0/src/unitysvc_core.egg-info → unitysvc_core-0.3.1}/PKG-INFO +10 -2
  2. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/README.md +9 -1
  3. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/pyproject.toml +1 -1
  4. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/grants.py +56 -3
  5. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1/src/unitysvc_core.egg-info}/PKG-INFO +10 -2
  6. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_grants.py +81 -0
  7. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/LICENSE +0 -0
  8. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/setup.cfg +0 -0
  9. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/__init__.py +0 -0
  10. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/__init__.py +0 -0
  11. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/base.py +0 -0
  12. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/channel.py +0 -0
  13. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/documents.py +0 -0
  14. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/listing_data.py +0 -0
  15. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/listing_v1.py +0 -0
  16. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/offering_data.py +0 -0
  17. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/offering_v1.py +0 -0
  18. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/pricing.py +0 -0
  19. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/promotion_data.py +0 -0
  20. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/provider_data.py +0 -0
  21. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/provider_v1.py +0 -0
  22. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/service.py +0 -0
  23. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/service_group_data.py +0 -0
  24. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/models/validators.py +0 -0
  25. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/parameter_usage.py +0 -0
  26. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/py.typed +0 -0
  27. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/schema/base.json +0 -0
  28. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/schema/listing_v1.json +0 -0
  29. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/schema/offering_v1.json +0 -0
  30. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/schema/promotion_v1.json +0 -0
  31. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/schema/provider_v1.json +0 -0
  32. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/schema/service_group_v1.json +0 -0
  33. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/utils.py +0 -0
  34. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core/validator.py +0 -0
  35. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core.egg-info/SOURCES.txt +0 -0
  36. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
  37. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core.egg-info/requires.txt +0 -0
  38. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/src/unitysvc_core.egg-info/top_level.txt +0 -0
  39. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_access_interface_routing.py +0 -0
  40. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_canonical_interfaces.py +0 -0
  41. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_channel.py +0 -0
  42. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_effective_price.py +0 -0
  43. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_hidden_path_skip.py +0 -0
  44. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_listing_audience.py +0 -0
  45. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_offering_data.py +0 -0
  46. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_parameter_usage.py +0 -0
  47. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_pricing.py +0 -0
  48. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_provider_account_rate_limits.py +0 -0
  49. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_service_group.py +0 -0
  50. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_service_status.py +0 -0
  51. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_utils.py +0 -0
  52. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_validate_service.py +0 -0
  53. {unitysvc_core-0.3.0 → unitysvc_core-0.3.1}/tests/test_validator.py +0 -0
  54. {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.0
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). `enrollment:<CODE>` (one enrollment of an enrollable service) may be allowed later.
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). `enrollment:<CODE>` (one enrollment of an enrollable service) may be allowed later.
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.0"
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. ``enrollment:<CODE>`` (one
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.0
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). `enrollment:<CODE>` (one enrollment of an enrollable service) may be allowed later.
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