Skip to content

Commit 461ce12

Browse files
authored
fix(relay): enforce scalar-only claims and surface untracked privacy budget (#632)
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
1 parent e765082 commit 461ce12

14 files changed

Lines changed: 1545 additions & 72 deletions

File tree

crates/registry-relay/CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,26 @@
2121
the unresolved reference instead fails every record at request time, so SP DCI
2222
generic search, details, and support answer `500 internal.unhandled` for any
2323
non-empty result.
24+
- BREAKING: Attribute-release claim values are scalar-only: a string, a
25+
number, or a boolean. The stable v0.15.0 contract accepted arbitrary JSON
26+
claim values; a claim whose projected or computed value is an object or an
27+
array is now treated as unavailable instead of being released, so a
28+
required claim of that shape denies the release and an optional one is
29+
omitted, with a value-free warning naming the profile, version, and claim.
30+
A claim expression that always produces a list or map (a top-level literal
31+
or a `map()`/`filter()` comprehension) is rejected at configuration
32+
validation and by `registryctl check`. Profiles that need structured
33+
values should model them in Registry Evidence, whose signed,
34+
minimum-disclosure assertions carry explicit output schemas and limits.
35+
The OpenAPI contract documents the scalar-only claim bundle and
36+
top-level-only claim selection.
37+
- A dataset classified `personal`, `confidential`, or `secret` with a
38+
dataset-level `access.aggregate_only_execution` aggregate now raises the
39+
`relay.aggregates.privacy_budget_untracked` deployment finding (warn at
40+
every bound profile) and a dedicated boot-log warning, surfacing that
41+
aggregate routes track no longitudinal privacy budget. The warning may be
42+
left active as an accepted-limitation signal or acknowledged with a
43+
deployment waiver naming the finding.
2444

2545
## 0.16.3 - 2026-08-01
2646

crates/registry-relay/docs/api.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,20 @@ registry row, never a raw or hashed subject value. A `source` block
508508
only when the profile sets `response.include_source_metadata: true`; it is
509509
absent by default.
510510

511+
Claim values are scalar-only: a string, a number, or a boolean. A claim whose
512+
projected or computed value is an object or an array is unavailable — an
513+
optional claim is omitted and a required claim denies the release through the
514+
collapsed `release.subject_denied` below — and the structured content never
515+
reaches the body. A claim expression that always produces a structured value
516+
(a top-level list or map literal, or a `map()`/`filter()` comprehension) is
517+
rejected at configuration validation and by `registryctl check`, before the
518+
route ever serves. A shape only some rows produce is caught at resolve time
519+
and logged once per profile version and claim as
520+
`attribute_release.claim.non_scalar_value`, a value-free operator signal
521+
carrying the profile id, version, claim name, and JSON type tag. Structured
522+
claim values belong to Registry Evidence, whose signed, minimum-disclosure
523+
assertions carry explicit output schemas and limits.
524+
511525
Every denial after profile resolution collapses to one public code, so a
512526
caller cannot distinguish "no such subject" from "subject exists but was
513527
denied":

crates/registry-relay/docs/configuration.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1407,16 +1407,19 @@ Waiver references and summaries are visible only in the restricted posture tier;
14071407
| `relay.audit.retention_local_only` | (not bound) | warn | startup_fail |
14081408
| `relay.audit.shipping_unverified` | (not bound) | warn | startup_fail |
14091409
| `relay.audit.shipping_stale` | (not bound) | error | readiness_fail |
1410+
| `relay.aggregates.privacy_budget_untracked` | warn | warn | warn |
14101411

14111412
`relay.audit.retention_local_only` fires when the audit sink is a local rotating `file` sink and `evidence.audit_offhost_shipping` is not declared: a local rotating file caps retention, and an attacker with host access can destroy the audit trail. `stdout` sinks are exempt (retention is the orchestrator's log pipeline's concern) and `syslog` sinks are exempt (forwarding is the syslog daemon's own surface).
14121413

14131414
`relay.audit.shipping_unverified` and `relay.audit.shipping_stale` read the ack cursor's observed health. `shipping_unverified` fires when any shipping target (`stdout`, `syslog`, or an attested local `file` sink) lacks `evidence.audit_ack_cursor_path`. It warns under `production` and refuses startup under `evidence_grade`, because a missing observation capability cannot heal at runtime. `shipping_stale` fires when a cursor is configured but is missing, unreadable, malformed, too old, too slow to read, or names a `last_acked_hash` other than the live keyed audit-chain tail. It fails readiness under `evidence_grade` and recovers when the trusted shipper advances a fresh cursor to the current tail. Neither hard gate is waivable. Runtime tail equality establishes that the claimed watermark belongs to this chain and the local backlog is zero; the unsigned local cursor is not cryptographic proof of remote receipt. Offline `doctor` cannot bind to a live chain and therefore reports a fresh cursor as `unverified`, never `ok`; an evidence-grade offline check consequently reports the hard shipping gate. The signed-bundle acceptance audit advances the tail before Relay serves requests, so the shipper must run independently of application readiness and acknowledge that boot record before `/ready` can return 200. Remediation: configure the cursor maintained by the off-host shipper, restore shipping, adjust `evidence.audit_ack_max_age_secs` if the cadence is legitimately slower, or repair a path or watermark mismatch. Removing the cursor does not satisfy `evidence_grade`.
14141415

1416+
`relay.aggregates.privacy_budget_untracked` fires when a dataset classified `personal`, `confidential`, or `secret` has a dataset-level aggregate with `access.aggregate_only_execution: true`. Only dataset-level declarations count: they are the only ones the aggregate query routes serve, so table-level and entity-level declarations raise no finding. Aggregate routes apply per-result minimum cell-size suppression (`disclosure_control.min_cell_size`), but track no longitudinal query budget: `query_budget.tracked` is always false. This is a documented, accepted limitation, not a fixable misconfiguration, so it warns at every bound profile, including `evidence_grade`, rather than blocking startup or readiness. Leaving the finding active is a legitimate steady state that keeps the posture honest; a deployment waiver naming the finding records a dated operator acknowledgement instead, at the cost of re-issuing the waiver whenever it expires. See "Aggregates are not privacy-budgeted" in [Known limitations and non-guarantees](https://docs.registrystack.org/explanation/known-limitations/).
1417+
14151418
The current deployment profile, its findings, and active waivers are reported under `deployment` in the operations posture (`GET /admin/v1/posture`).
14161419

14171420
### Boot-time visibility
14181421

1419-
Reduced posture is loud at boot, not only visible on the posture surface. Every config load warns once per waiver-suppressed finding (`deployment.gate_waived`, with the finding id, reference, optional summary, and expiry), once per expired waiver (`deployment.waiver_expired`), and once when the profile is undeclared (`deployment.profile_undeclared`). The serve path additionally writes one operational audit record per waived gate at boot, once the audit pipeline exists: event `deployment.gate_waived` at audit path `/__events/deployment.gate_waived`, with `error_code` set to the gate id. That minimized audit record does not copy waiver metadata.
1422+
Reduced posture is loud at boot, not only visible on the posture surface. Every config load warns once per waiver-suppressed finding (`deployment.gate_waived`, with the finding id, reference, optional summary, and expiry), once per expired waiver (`deployment.waiver_expired`), and once when the profile is undeclared (`deployment.profile_undeclared`). A sensitive dataset's aggregate-only-execution aggregate additionally warns once per config load with `deployment.privacy_budget_untracked`, naming the finding id; a waived occurrence is reported through the generic `deployment.gate_waived` line instead, not a duplicate line. The serve path additionally writes one operational audit record per waived gate at boot, once the audit pipeline exists: event `deployment.gate_waived` at audit path `/__events/deployment.gate_waived`, with `error_code` set to the gate id. That minimized audit record does not copy waiver metadata.
14201423

14211424
This boot-time audit write inherits `audit.write_policy` (see below). Under `fail_closed` (the default), a failed write aborts startup. Under `availability_first`, the failure is logged (`audit.operational_event_write_failed`) and startup continues, so the durable record is best-effort; the per-gate boot log warnings above remain the guaranteed floor.
14221425

@@ -1735,6 +1738,8 @@ aggregates:
17351738

17361739
Supported aggregate functions include the configured V1 set used by tests and examples, such as `count`, `sum`, and `avg`. The runtime config key remains `indicators` for compatibility; public aggregate APIs expose these configured series as measures. `temporal_field` is optional; when present, native aggregate `temporal.from` and `temporal.to` are translated into the declared range-capable allowed filter for that source-entity field. Dataset measure and dimension discovery is derived from these aggregate declarations, so keep ids stable and labels consumer-friendly. Keep disclosure thresholds explicit and reviewable.
17371740

1741+
`disclosure_control` and `access.aggregate_only_execution` bound per-result minimum cell-size suppression on a single query; they do not track a longitudinal query budget across repeated aggregate queries. On a `personal`, `confidential`, or `secret` dataset, a dataset-level `aggregate_only_execution` aggregate raises the `relay.aggregates.privacy_budget_untracked` deployment finding described in the findings catalog under "Deployment profile" above; see "Aggregates are not privacy-budgeted" in [Known limitations and non-guarantees](https://docs.registrystack.org/explanation/known-limitations/) for the underlying limitation.
1742+
17381743
### Spatial EDR aggregates
17391744

17401745
Spatial EDR exposure is opt-in. Requires `--features ogcapi-edr`.

crates/registry-relay/openapi/registry-relay.openapi.json

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,7 +1184,7 @@
11841184
"type": "array"
11851185
},
11861186
"claim_names": {
1187-
"description": "Names of all claims that may be returned by this profile.",
1187+
"description": "Top-level names of all claims that may be returned by this profile. Every released value is a scalar (string, number, or boolean).",
11881188
"items": {
11891189
"type": "string"
11901190
},
@@ -1268,7 +1268,7 @@
12681268
"description": "Request body for resolving an attribute release profile against one subject.",
12691269
"properties": {
12701270
"claims": {
1271-
"description": "Optional subset of claim names to return. Absent means the profile default set; an empty array is rejected (400); duplicate or over-bound arrays are rejected (400); any explicit subset must include every required claim; any unknown claim name is denied.",
1271+
"description": "Optional subset of claim names to return. Entries are whole top-level claim names; there is no sub-selection inside a claim value. Absent means the profile default set; an empty array is rejected (400); duplicate or over-bound arrays are rejected (400); any explicit subset must include every required claim; any unknown claim name is denied.",
12721272
"items": {
12731273
"type": "string"
12741274
},
@@ -1314,8 +1314,14 @@
13141314
"description": "Resolved attribute release claim bundle. Contains only the approved, minimised claims for the matched subject. Never includes raw source rows, subject identifiers outside released claims, or private source internals.",
13151315
"properties": {
13161316
"claims": {
1317-
"additionalProperties": true,
1318-
"description": "Released claim bundle. Keys are claim names; values are the projected or computed claim values.",
1317+
"additionalProperties": {
1318+
"type": [
1319+
"string",
1320+
"number",
1321+
"boolean"
1322+
]
1323+
},
1324+
"description": "Released claim bundle. Keys are top-level claim names; values are scalar-only in v1 (string, number, or boolean). Structured object or array values are never released: a claim whose projected or computed value is not a scalar is treated as unavailable, so a required claim of that shape denies the release and an optional one is omitted. Claim selection is by top-level claim name only; there is no sub-selection inside a claim value.",
13191325
"type": "object"
13201326
},
13211327
"profile_id": {

crates/registry-relay/src/api/admin.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1153,6 +1153,62 @@ datasets: []
11531153
assert!(ids.contains(&"relay.ingress.rate_limit_missing"));
11541154
}
11551155

1156+
/// A sensitive dataset with an aggregate-only-execution aggregate surfaces
1157+
/// `relay.aggregates.privacy_budget_untracked` on the posture surface at
1158+
/// `finding_warn`, the same generic path every other catalog gate takes.
1159+
#[test]
1160+
fn deployment_summary_reports_untracked_privacy_budget_finding() {
1161+
let yaml = r#"
1162+
server:
1163+
bind: "127.0.0.1:8080"
1164+
catalog:
1165+
title: "Test Registry"
1166+
base_url: "https://data.example.test"
1167+
publisher: "Test Ministry"
1168+
auth:
1169+
mode: api_key
1170+
api_keys: []
1171+
audit:
1172+
sink: stdout
1173+
datasets:
1174+
- id: sensitive_ds
1175+
title: "Sensitive Dataset"
1176+
description: "desc"
1177+
owner: "owner"
1178+
sensitivity: personal
1179+
access_rights: restricted
1180+
update_frequency: daily
1181+
tables:
1182+
- id: t1
1183+
source:
1184+
type: file
1185+
path: "data/t1.csv"
1186+
refresh:
1187+
mode: manual
1188+
schema:
1189+
fields: []
1190+
aggregates:
1191+
- id: agg1
1192+
description: "test aggregate"
1193+
source_entity: record
1194+
disclosure_control:
1195+
min_group_size: 2
1196+
access:
1197+
aggregate_only_execution: true
1198+
"#;
1199+
let mut config = parse_minimal_config(yaml);
1200+
config.deployment.profile = Some(DeploymentProfile::HostedLab);
1201+
let summary = deployment_summary(&config, ConfigSource::LocalFile);
1202+
let finding = summary["findings"]
1203+
.as_array()
1204+
.expect("findings array")
1205+
.iter()
1206+
.find(|finding| finding["id"] == "relay.aggregates.privacy_budget_untracked")
1207+
.expect("privacy-budget finding is reported");
1208+
assert_eq!(finding["severity"], "finding_warn");
1209+
assert_eq!(finding["status"], "active");
1210+
}
1211+
11561212
/// The full posture document is schema-valid for every declared profile and
11571213
/// for the undeclared default. `evidence_grade` from a local file would
11581214
/// trip a startup gate at load time, so its posture is exercised with a

0 commit comments

Comments
 (0)