unitysvc-core 0.2.36__tar.gz → 0.2.37__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.2.36/src/unitysvc_core.egg-info → unitysvc_core-0.2.37}/PKG-INFO +39 -12
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/README.md +38 -11
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/pyproject.toml +1 -1
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/grants.py +82 -45
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/offering_data.py +4 -2
- unitysvc_core-0.2.37/src/unitysvc_core/parameter_usage.py +243 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/schema/offering_v1.json +1 -1
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/validator.py +6 -1
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37/src/unitysvc_core.egg-info}/PKG-INFO +39 -12
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_grants.py +60 -14
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_parameter_usage.py +132 -0
- unitysvc_core-0.2.36/src/unitysvc_core/parameter_usage.py +0 -120
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/LICENSE +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/setup.cfg +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/__init__.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/__init__.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/base.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/documents.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/listing_data.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/listing_v1.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/offering_v1.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/pricing.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/promotion_data.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/provider_data.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/provider_v1.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/service.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/service_group_data.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/validators.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/py.typed +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/schema/base.json +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/schema/listing_v1.json +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/schema/promotion_v1.json +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/schema/provider_v1.json +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/schema/service_group_v1.json +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/utils.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core.egg-info/SOURCES.txt +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core.egg-info/requires.txt +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core.egg-info/top_level.txt +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_access_interface_routing.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_canonical_interfaces.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_effective_price.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_hidden_path_skip.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_listing_audience.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_offering_data.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_pricing.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_provider_account_rate_limits.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_service_group.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_service_status.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_utils.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_validator.py +0 -0
- {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_validators.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: unitysvc-core
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.37
|
|
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>
|
|
@@ -190,9 +190,10 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
|
|
|
190
190
|
}
|
|
191
191
|
```
|
|
192
192
|
|
|
193
|
-
- `
|
|
194
|
-
- `permissions
|
|
195
|
-
- `
|
|
193
|
+
- **Without `permissions`** (most services, including one that declares only `options`), the service exports one permission, `invoke`, on every route (`"* /*"`); `service:<name>` and the `service:*` wildcard grant it.
|
|
194
|
+
- **With `permissions`**, the service exports exactly those, and `invoke` only if it declares it too. A data service (`me`, `log`, `needs`) declares `read` / `write` and no `invoke`: it holds the customer's own data, so only a grant that names its permissions reaches it, never `invoke` or a wildcard.
|
|
195
|
+
- `permissions`: a name (lowercase letters, digits and `_`) → the routes it covers, each `"<METHOD> /<path>"` (`*` for any method, `*` globs in the path). Permissions are never inferred from HTTP methods.
|
|
196
|
+
- `options`: a JSON Schema (draft 7) object of the customizations a grant may set on the service, like an enrollment's `user_parameters_schema`, with an optional react-jsonschema-form `options_ui_schema`; the frontend renders it with the same form component. It covers limits only the service can apply (a message size). Restrictions so a grant can write every value in query-string syntax: property names are lowercase letters, digits and `_`; each property is a `string`, `integer`, `number`, `boolean` or an `array` of strings, serialized as an OpenAPI form-style query parameter with arrays comma-joined (`style: form, explode: false`); options are optional (no `required`) and only declared properties are accepted; the schema is self-contained (no `$ref`), so validating against it never fetches anything. A property's `default` is what the service does when the policy does not set the option.
|
|
196
197
|
|
|
197
198
|
**2. A customer access request (`upstream_access_config.<channel>.customer_access_requests`), and the policy entry it becomes.** Any channel may declare its own list; each approved request becomes an entry (a grant) in the issued key's policy, in the same shape:
|
|
198
199
|
|
|
@@ -204,21 +205,47 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
|
|
|
204
205
|
```
|
|
205
206
|
|
|
206
207
|
- `object` names one service (`service:<name>`, no wildcards). `enrollment:<CODE>` (one enrollment of an enrollable service) may be allowed later.
|
|
207
|
-
- `options` is
|
|
208
|
+
- `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.
|
|
208
209
|
- **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.
|
|
209
210
|
- `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.
|
|
210
211
|
|
|
211
|
-
**3. How options reach the service.**
|
|
212
|
+
**3. How options reach the service.** Not through the upstream URL: the gateway never changes a target's `base_url` or query to carry policy. How an option is applied is up to the service's implementation:
|
|
212
213
|
|
|
213
|
-
-
|
|
214
|
-
-
|
|
215
|
-
-
|
|
216
|
-
-
|
|
217
|
-
-
|
|
214
|
+
- **A service implemented in the gateway** (`me`, `log`, `ai`, `notify`, `email`, `bill`) reads the calling key's policy from the routing context and applies the option itself (`me` gives values only for the grant's `profile` paths; `log` would drop entries below a grant's `log_level`).
|
|
215
|
+
- **A proxied service** can only be influenced through its own upstream's interface, so it applies an option only where its configuration maps it into the upstream request in a form that upstream understands, for example a request transformer setting a body field. Until such a mapping exists, options are enforced by gateway-implemented services only.
|
|
216
|
+
- The policy itself never reaches a seller: a seller's upstream sees at most the effect of an option, in its own vocabulary.
|
|
217
|
+
- An option the policy does not set takes the service's `default`. A plain `service:*` key sets none.
|
|
218
|
+
- Enrollment parameters are unchanged and keep working alongside: `{{ params.X }}` still renders into the channel config.
|
|
218
219
|
|
|
219
220
|
**4. Where exports are published.** In the public catalog bundle: every service row carries its `exports` exactly as declared (`null` when none). `GET /v1/catalog/bundle` returns the bundle's URL; a `service:<name>` is the row whose `name` is `<name>`.
|
|
220
221
|
|
|
221
|
-
**5. Validating `customer_access_requests`.** `validate_channel_access_requests(upstream_access_config, resolve)` checks every channel's requests. Limits
|
|
222
|
+
**5. Validating `customer_access_requests`.** `validate_channel_access_requests(upstream_access_config, resolve)` checks every channel's requests. Limits need no lookup. Every claim's permissions and options, `invoke` included (a service that declares permissions may not export it), are looked up: `resolve(name)` returns the target's `exports` from the catalog (`{}` when it declares none) or `None` when no such service is published; core parses it with `parse_exports` and checks the claimed permissions and the options (names, then values against its `options` schema) against it, refusing an unpublished target. Without a resolver only the shape is checked.
|
|
222
223
|
|
|
223
224
|
- `DataValidator(data_dir, schema_dir, exports_resolver=...)` runs both checks on every `offering_v1` (`exports` and each channel's `customer_access_requests`). The seller SDK passes a resolver that downloads the catalog bundle once and reads the target row's `exports`; offline it passes none and gets the shape checks.
|
|
224
225
|
- Backend ingest calls the same function with a resolver over the published services, so an unknown target, permission or option fails at upload.
|
|
226
|
+
|
|
227
|
+
### Enrollment parameters on channels
|
|
228
|
+
|
|
229
|
+
A channel may declare its own enrollment parameters, so each plan of a service asks for what it needs (unitysvc/unitysvc#2726):
|
|
230
|
+
|
|
231
|
+
```json
|
|
232
|
+
"upstream_access_config": {
|
|
233
|
+
"plus": {
|
|
234
|
+
"base_url": "https://u/{{ params.folder }}",
|
|
235
|
+
"params": {
|
|
236
|
+
"schema": {"type": "object", "properties": {"folder": {"type": "string"}}, "required": ["folder"]},
|
|
237
|
+
"ui_schema": {},
|
|
238
|
+
"testing_defaults": {"folder": "ops"}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
`params.schema` is a JSON Schema of the parameters (a property's own `default` pre-fills the customer's form), `params.ui_schema` holds form hints, and `params.testing_defaults` holds the values the platform tests the channel with. Once any channel declares `params`:
|
|
245
|
+
|
|
246
|
+
- a channel reads only the parameters its `params.schema` declares, and a channel that reads `{{ params.* }}` must declare its own `params` (`validate_channel_parameters`, on every `offering_v1`);
|
|
247
|
+
- each required parameter has a value in that channel's `params.testing_defaults`;
|
|
248
|
+
- each required parameter is read by its own channel or by document content; another channel's use does not count (`validate_required_parameter_usage`, which backend ingest also calls);
|
|
249
|
+
- the listing must not also declare `user_parameters_schema`.
|
|
250
|
+
|
|
251
|
+
When no channel declares `params`, the listing-level `user_parameters_schema` applies to the whole service as before.
|
|
@@ -155,9 +155,10 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
|
|
|
155
155
|
}
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
-
- `
|
|
159
|
-
- `permissions
|
|
160
|
-
- `
|
|
158
|
+
- **Without `permissions`** (most services, including one that declares only `options`), the service exports one permission, `invoke`, on every route (`"* /*"`); `service:<name>` and the `service:*` wildcard grant it.
|
|
159
|
+
- **With `permissions`**, the service exports exactly those, and `invoke` only if it declares it too. A data service (`me`, `log`, `needs`) declares `read` / `write` and no `invoke`: it holds the customer's own data, so only a grant that names its permissions reaches it, never `invoke` or a wildcard.
|
|
160
|
+
- `permissions`: a name (lowercase letters, digits and `_`) → the routes it covers, each `"<METHOD> /<path>"` (`*` for any method, `*` globs in the path). Permissions are never inferred from HTTP methods.
|
|
161
|
+
- `options`: a JSON Schema (draft 7) object of the customizations a grant may set on the service, like an enrollment's `user_parameters_schema`, with an optional react-jsonschema-form `options_ui_schema`; the frontend renders it with the same form component. It covers limits only the service can apply (a message size). Restrictions so a grant can write every value in query-string syntax: property names are lowercase letters, digits and `_`; each property is a `string`, `integer`, `number`, `boolean` or an `array` of strings, serialized as an OpenAPI form-style query parameter with arrays comma-joined (`style: form, explode: false`); options are optional (no `required`) and only declared properties are accepted; the schema is self-contained (no `$ref`), so validating against it never fetches anything. A property's `default` is what the service does when the policy does not set the option.
|
|
161
162
|
|
|
162
163
|
**2. A customer access request (`upstream_access_config.<channel>.customer_access_requests`), and the policy entry it becomes.** Any channel may declare its own list; each approved request becomes an entry (a grant) in the issued key's policy, in the same shape:
|
|
163
164
|
|
|
@@ -169,21 +170,47 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
|
|
|
169
170
|
```
|
|
170
171
|
|
|
171
172
|
- `object` names one service (`service:<name>`, no wildcards). `enrollment:<CODE>` (one enrollment of an enrollable service) may be allowed later.
|
|
172
|
-
- `options` is
|
|
173
|
+
- `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.
|
|
173
174
|
- **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.
|
|
174
175
|
- `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.
|
|
175
176
|
|
|
176
|
-
**3. How options reach the service.**
|
|
177
|
+
**3. How options reach the service.** Not through the upstream URL: the gateway never changes a target's `base_url` or query to carry policy. How an option is applied is up to the service's implementation:
|
|
177
178
|
|
|
178
|
-
-
|
|
179
|
-
-
|
|
180
|
-
-
|
|
181
|
-
-
|
|
182
|
-
-
|
|
179
|
+
- **A service implemented in the gateway** (`me`, `log`, `ai`, `notify`, `email`, `bill`) reads the calling key's policy from the routing context and applies the option itself (`me` gives values only for the grant's `profile` paths; `log` would drop entries below a grant's `log_level`).
|
|
180
|
+
- **A proxied service** can only be influenced through its own upstream's interface, so it applies an option only where its configuration maps it into the upstream request in a form that upstream understands, for example a request transformer setting a body field. Until such a mapping exists, options are enforced by gateway-implemented services only.
|
|
181
|
+
- The policy itself never reaches a seller: a seller's upstream sees at most the effect of an option, in its own vocabulary.
|
|
182
|
+
- An option the policy does not set takes the service's `default`. A plain `service:*` key sets none.
|
|
183
|
+
- Enrollment parameters are unchanged and keep working alongside: `{{ params.X }}` still renders into the channel config.
|
|
183
184
|
|
|
184
185
|
**4. Where exports are published.** In the public catalog bundle: every service row carries its `exports` exactly as declared (`null` when none). `GET /v1/catalog/bundle` returns the bundle's URL; a `service:<name>` is the row whose `name` is `<name>`.
|
|
185
186
|
|
|
186
|
-
**5. Validating `customer_access_requests`.** `validate_channel_access_requests(upstream_access_config, resolve)` checks every channel's requests. Limits
|
|
187
|
+
**5. Validating `customer_access_requests`.** `validate_channel_access_requests(upstream_access_config, resolve)` checks every channel's requests. Limits need no lookup. Every claim's permissions and options, `invoke` included (a service that declares permissions may not export it), are looked up: `resolve(name)` returns the target's `exports` from the catalog (`{}` when it declares none) or `None` when no such service is published; core parses it with `parse_exports` and checks the claimed permissions and the options (names, then values against its `options` schema) against it, refusing an unpublished target. Without a resolver only the shape is checked.
|
|
187
188
|
|
|
188
189
|
- `DataValidator(data_dir, schema_dir, exports_resolver=...)` runs both checks on every `offering_v1` (`exports` and each channel's `customer_access_requests`). The seller SDK passes a resolver that downloads the catalog bundle once and reads the target row's `exports`; offline it passes none and gets the shape checks.
|
|
189
190
|
- Backend ingest calls the same function with a resolver over the published services, so an unknown target, permission or option fails at upload.
|
|
191
|
+
|
|
192
|
+
### Enrollment parameters on channels
|
|
193
|
+
|
|
194
|
+
A channel may declare its own enrollment parameters, so each plan of a service asks for what it needs (unitysvc/unitysvc#2726):
|
|
195
|
+
|
|
196
|
+
```json
|
|
197
|
+
"upstream_access_config": {
|
|
198
|
+
"plus": {
|
|
199
|
+
"base_url": "https://u/{{ params.folder }}",
|
|
200
|
+
"params": {
|
|
201
|
+
"schema": {"type": "object", "properties": {"folder": {"type": "string"}}, "required": ["folder"]},
|
|
202
|
+
"ui_schema": {},
|
|
203
|
+
"testing_defaults": {"folder": "ops"}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`params.schema` is a JSON Schema of the parameters (a property's own `default` pre-fills the customer's form), `params.ui_schema` holds form hints, and `params.testing_defaults` holds the values the platform tests the channel with. Once any channel declares `params`:
|
|
210
|
+
|
|
211
|
+
- a channel reads only the parameters its `params.schema` declares, and a channel that reads `{{ params.* }}` must declare its own `params` (`validate_channel_parameters`, on every `offering_v1`);
|
|
212
|
+
- each required parameter has a value in that channel's `params.testing_defaults`;
|
|
213
|
+
- each required parameter is read by its own channel or by document content; another channel's use does not count (`validate_required_parameter_usage`, which backend ingest also calls);
|
|
214
|
+
- the listing must not also declare `user_parameters_schema`.
|
|
215
|
+
|
|
216
|
+
When no channel declares `params`, the listing-level `user_parameters_schema` applies to the whole service as before.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "unitysvc-core"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.37"
|
|
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" }]
|
|
@@ -12,17 +12,24 @@ Three stages (unitysvc/unitysvc#2720):
|
|
|
12
12
|
"max_size": {"type": "integer", "minimum": 1}}}
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
``service:<name>``
|
|
18
|
-
|
|
15
|
+
A service that declares no ``permissions`` has one implicit permission,
|
|
16
|
+
``invoke``, covering every route (``"* /*"``); that is what
|
|
17
|
+
``service:<name>`` and the ``service:*`` wildcard grant. A service that
|
|
18
|
+
declares ``permissions`` has exactly those, each covering the routes it
|
|
19
|
+
lists, and ``invoke`` only if it declares it too. A data service (``me``,
|
|
20
|
+
``log``, ``needs``) therefore declares ``read`` / ``write`` and no
|
|
21
|
+
``invoke``: holding the customer's own data, it is reached only by a grant
|
|
22
|
+
that names its permissions, never by ``invoke`` or a wildcard. Permissions
|
|
23
|
+
name the routes they cover; they are never inferred from HTTP methods
|
|
19
24
|
(``POST /chat/completions`` modifies nothing). ``options`` is a JSON Schema,
|
|
20
25
|
like an enrollment's ``user_parameters_schema`` (with an optional
|
|
21
|
-
``options_ui_schema``), of the
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
Each property is a string, integer, number, boolean or array of
|
|
25
|
-
|
|
26
|
+
``options_ui_schema``), of the customizations a grant may set on the
|
|
27
|
+
service, including limits only it can apply (a message size); a property's
|
|
28
|
+
``default`` is what the service does when the policy does not set the
|
|
29
|
+
option. Each property is a string, integer, number, boolean or array of
|
|
30
|
+
strings, so a grant can write its options in query-string syntax
|
|
31
|
+
(``"log_level=warn"``, arrays comma-joined). That is only how a grant stores
|
|
32
|
+
them, not how they reach the service (below).
|
|
26
33
|
|
|
27
34
|
* **Customer access requests** — what a seller asks the enrolling customer to
|
|
28
35
|
approve: to use other services through that customer's account and
|
|
@@ -48,11 +55,18 @@ customer's delegated identity, within the approved permissions, options and
|
|
|
48
55
|
limits. Looking up a target's ``exports`` validates a request's *capabilities*;
|
|
49
56
|
customer consent and key issuance establish its *authority*.
|
|
50
57
|
|
|
51
|
-
* **
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
* **The service applies its options; the upstream URL is never the channel.**
|
|
59
|
+
The gateway does not change a target's upstream URL (its ``base_url`` or
|
|
60
|
+
query) to carry policy. A service implemented in the gateway (``me``,
|
|
61
|
+
``log``, ``ai``, ``notify``, ``email``, ``bill``) reads the calling key's
|
|
62
|
+
policy from the routing context and applies the option itself. A proxied
|
|
63
|
+
service can only be influenced through its own upstream's interface, so it
|
|
64
|
+
applies an option only where its configuration maps it into the upstream
|
|
65
|
+
request in a form that upstream understands (for example a request
|
|
66
|
+
transformer setting a body field); until such a mapping exists, options are
|
|
67
|
+
enforced by gateway-implemented services only. Either way the policy itself
|
|
68
|
+
never reaches a seller. An option the policy does not set takes the
|
|
69
|
+
service's ``default``.
|
|
56
70
|
|
|
57
71
|
* **Limits are universal**: the platform meters every call, so any service can
|
|
58
72
|
be limited by ``count`` or ``amount`` per hour, day or month, or in total,
|
|
@@ -61,12 +75,13 @@ customer consent and key issuance establish its *authority*.
|
|
|
61
75
|
Today a request's ``object`` is ``service:<name>``; ``enrollment:<CODE>`` (one
|
|
62
76
|
enrollment of an enrollable service) may follow.
|
|
63
77
|
|
|
64
|
-
There is no central list of services. Core does no I/O:
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
Without a resolver, only the
|
|
78
|
+
There is no central list of services. Core does no I/O: every request's
|
|
79
|
+
permissions and options are checked against its target's ``exports`` when the
|
|
80
|
+
caller passes an :data:`ExportsResolver` that looks the target up in the
|
|
81
|
+
published catalog bundle, where every service row carries its ``exports``. An
|
|
82
|
+
``invoke`` claim is looked up too, since a service that declares permissions
|
|
83
|
+
may not export ``invoke``. Limits need no lookup. Without a resolver, only the
|
|
84
|
+
shape of each entry is checked.
|
|
70
85
|
"""
|
|
71
86
|
|
|
72
87
|
from __future__ import annotations
|
|
@@ -94,8 +109,10 @@ ExportsResolver = Callable[[str], dict[str, Any] | None]
|
|
|
94
109
|
or ``None`` when the catalog has no such service."""
|
|
95
110
|
|
|
96
111
|
_ROUTE = re.compile(r"^(\*|GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS) /\S*$")
|
|
97
|
-
#
|
|
112
|
+
# A grant writes its options in query-string syntax, so an option's name stays
|
|
113
|
+
# plain; so does a permission's.
|
|
98
114
|
_OPTION_NAME = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
115
|
+
_PERMISSION_NAME = _OPTION_NAME
|
|
99
116
|
# ``count_per_day``, ``amount_per_month``, ``count_total`` ...
|
|
100
117
|
_LIMIT = re.compile(r"^(count|amount)_(per_hour|per_day|per_month|total)$")
|
|
101
118
|
|
|
@@ -114,14 +131,17 @@ class Exports:
|
|
|
114
131
|
|
|
115
132
|
@property
|
|
116
133
|
def permission_names(self) -> tuple[str, ...]:
|
|
117
|
-
return (
|
|
134
|
+
return tuple(self.permissions)
|
|
118
135
|
|
|
119
136
|
@property
|
|
120
137
|
def option_names(self) -> tuple[str, ...]:
|
|
121
138
|
return tuple(self.options.get("properties", {}))
|
|
122
139
|
|
|
123
140
|
|
|
124
|
-
|
|
141
|
+
# Every route, for the implicit ``invoke`` of a service that declares no permissions.
|
|
142
|
+
ALL_ROUTES = "* /*"
|
|
143
|
+
DEFAULT_PERMISSIONS: dict[str, tuple[str, ...]] = {INVOKE: (ALL_ROUTES,)}
|
|
144
|
+
DEFAULT_EXPORTS = Exports(DEFAULT_PERMISSIONS, {})
|
|
125
145
|
|
|
126
146
|
_REQUEST_KEYS = frozenset({"object", "permissions", "required", "justification", "options", "limits"})
|
|
127
147
|
|
|
@@ -140,6 +160,9 @@ def parse_exports(where: str, declared: Any) -> Exports:
|
|
|
140
160
|
"""Read an offering's ``exports``, refusing a malformed one.
|
|
141
161
|
|
|
142
162
|
``None`` or ``{}`` is the default: ``invoke`` on every route, no options.
|
|
163
|
+
Without ``permissions`` (options only), ``invoke`` is still the one
|
|
164
|
+
permission. With ``permissions``, the service exports exactly those; it
|
|
165
|
+
may declare ``invoke`` among them.
|
|
143
166
|
"""
|
|
144
167
|
if declared is None:
|
|
145
168
|
return DEFAULT_EXPORTS
|
|
@@ -148,12 +171,15 @@ def parse_exports(where: str, declared: Any) -> Exports:
|
|
|
148
171
|
if extra := set(declared) - {"permissions", "options", "options_ui_schema"}:
|
|
149
172
|
raise ValueError(f"{where}: unknown keys {sorted(extra)}")
|
|
150
173
|
permissions = declared.get("permissions", {})
|
|
151
|
-
if not isinstance(permissions, dict):
|
|
152
|
-
raise ValueError(
|
|
174
|
+
if not isinstance(permissions, dict) or ("permissions" in declared and not permissions):
|
|
175
|
+
raise ValueError(
|
|
176
|
+
f'{where}.permissions must be {{"<name>": ["<METHOD> /<path>", ...]}} with at least one '
|
|
177
|
+
f"permission; omit it to export only '{INVOKE}' on every route"
|
|
178
|
+
)
|
|
153
179
|
for name, routes in permissions.items():
|
|
154
180
|
at = f"{where}.permissions.{name}"
|
|
155
|
-
if name
|
|
156
|
-
raise ValueError(f"{at}:
|
|
181
|
+
if not _PERMISSION_NAME.match(name):
|
|
182
|
+
raise ValueError(f"{at}: a permission name is lowercase letters, digits and '_'")
|
|
157
183
|
if not _strings(routes) or not all(_ROUTE.match(r) for r in routes):
|
|
158
184
|
raise ValueError(f"{at} must be a non-empty list of routes like 'GET /records/*'")
|
|
159
185
|
options = declared.get("options", {})
|
|
@@ -162,6 +188,8 @@ def parse_exports(where: str, declared: Any) -> Exports:
|
|
|
162
188
|
ui_schema = declared.get("options_ui_schema")
|
|
163
189
|
if ui_schema is not None and not isinstance(ui_schema, dict):
|
|
164
190
|
raise ValueError(f"{where}.options_ui_schema must be an object")
|
|
191
|
+
if not permissions:
|
|
192
|
+
return Exports(DEFAULT_PERMISSIONS, options)
|
|
165
193
|
return Exports({name: tuple(routes) for name, routes in permissions.items()}, options)
|
|
166
194
|
|
|
167
195
|
|
|
@@ -185,7 +213,7 @@ def _check_options_schema(where: str, schema: Any) -> None:
|
|
|
185
213
|
for name, prop in schema["properties"].items():
|
|
186
214
|
at = f"{where}.properties.{name}"
|
|
187
215
|
if not _OPTION_NAME.match(name):
|
|
188
|
-
raise ValueError(f"{at}: an option
|
|
216
|
+
raise ValueError(f"{at}: an option name is lowercase letters, digits and '_'")
|
|
189
217
|
kind = prop.get("type") if isinstance(prop, dict) else None
|
|
190
218
|
if not isinstance(kind, str):
|
|
191
219
|
raise ValueError(f"{at}.type must be one type, one of {sorted(_SCALAR_TYPES | {'array'})}")
|
|
@@ -309,10 +337,11 @@ def validate_access_requests(where: str, requests: Any, resolve: ExportsResolver
|
|
|
309
337
|
|
|
310
338
|
``where`` prefixes error messages
|
|
311
339
|
(``upstream_access_config.<channel>.customer_access_requests``).
|
|
312
|
-
With ``resolve``,
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
need no lookup. Without ``resolve``, only the shape is
|
|
340
|
+
With ``resolve``, every entry's permissions and options are checked
|
|
341
|
+
against its target's ``exports`` (``invoke`` included: a service that
|
|
342
|
+
declares permissions may not export it), and an unknown target is
|
|
343
|
+
refused. Limits need no lookup. Without ``resolve``, only the shape is
|
|
344
|
+
checked.
|
|
316
345
|
"""
|
|
317
346
|
if not isinstance(requests, list):
|
|
318
347
|
raise ValueError(f"{where} must be a list of access request objects")
|
|
@@ -346,23 +375,31 @@ def validate_access_requests(where: str, requests: Any, resolve: ExportsResolver
|
|
|
346
375
|
raise ValueError(f"{at}.permissions must be a non-empty list of distinct names")
|
|
347
376
|
options = parse_options(f"{at}.options", entry.get("options", ""))
|
|
348
377
|
validate_limits(f"{at}.limits", entry.get("limits", {}))
|
|
349
|
-
|
|
350
|
-
# exports, so only a claim beyond it needs the target's exports.
|
|
351
|
-
if resolve is None or (set(permissions) <= {INVOKE} and not options):
|
|
378
|
+
if resolve is None:
|
|
352
379
|
continue
|
|
353
380
|
declared = resolve(name)
|
|
354
381
|
if declared is None:
|
|
355
382
|
raise ValueError(f"{at}: {obj} is not a published service")
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
383
|
+
check_claim(at, obj, permissions, options, parse_exports(f"{obj} exports", declared))
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def check_claim(where: str, obj: str, permissions: list[str], options: dict[str, str], exports: Exports) -> None:
|
|
387
|
+
"""Refuse a claim on ``obj`` (``service:<name>``) that its ``exports`` don't cover.
|
|
388
|
+
|
|
389
|
+
The check shared by a seller's access request and a key's policy entry:
|
|
390
|
+
every permission must be one the target exports (``invoke`` only if it
|
|
391
|
+
does), and every option, already split by :func:`parse_options`, must be
|
|
392
|
+
one it declares, with a value valid for that option's schema.
|
|
393
|
+
"""
|
|
394
|
+
if not set(permissions) <= set(exports.permission_names):
|
|
395
|
+
raise ValueError(f"{where}.permissions: {obj} exports only {list(exports.permission_names)}")
|
|
396
|
+
if unknown := sorted(set(options) - set(exports.option_names)):
|
|
397
|
+
raise ValueError(
|
|
398
|
+
f"{where}.options: {obj} accepts no option {unknown[0]!r} "
|
|
399
|
+
f"(options: {sorted(exports.option_names) or 'none'})"
|
|
400
|
+
)
|
|
401
|
+
if error := _options_error(exports.options, options):
|
|
402
|
+
raise ValueError(f"{where}.options: {error}")
|
|
366
403
|
|
|
367
404
|
|
|
368
405
|
def validate_channel_access_requests(config: Any, resolve: ExportsResolver | None = None) -> None:
|
|
@@ -108,9 +108,11 @@ class ServiceOfferingData(BaseModel):
|
|
|
108
108
|
description=(
|
|
109
109
|
"How other services may use this one (what a customer access request can ask for): "
|
|
110
110
|
'{"permissions": {<name>: ["<METHOD> /<path>", ...]}, '
|
|
111
|
-
'"options": <JSON Schema object of the
|
|
111
|
+
'"options": <JSON Schema object of the options a grant may set>, '
|
|
112
112
|
'"options_ui_schema": <rjsf uiSchema>}. '
|
|
113
|
-
"
|
|
113
|
+
"Without permissions, the service exports one, invoke, on every route; with permissions, "
|
|
114
|
+
"exactly those (invoke only if declared), so a data service declaring read/write is never "
|
|
115
|
+
"reached by invoke or service:*. Omitted: invoke only, no options. "
|
|
114
116
|
"See unitysvc_core.grants."
|
|
115
117
|
),
|
|
116
118
|
)
|