diff --git a/crates/registry-breg/src/api/access_log.rs b/crates/registry-breg/src/api/access_log.rs new file mode 100644 index 0000000000..800100f800 --- /dev/null +++ b/crates/registry-breg/src/api/access_log.rs @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::*; + +pub(super) fn routes(service: &HttpService) -> Router> { + let mut app = Router::new(); + for route in &service.registry.routes().routes { + if route.operation != Operation::Get || read_path_for_route(service, route).is_some() { + continue; + } + if service + .registry + .entities() + .get(&route.entity_id) + .is_some_and(|entity| entity.access_log.is_some()) + { + app = app.route( + &format!("{}/access-log", route.path), + get(read).layer(Extension(route.clone())), + ); + } + } + app +} + +async fn read( + State(service): State>, + Extension(route): Extension, + Extension(correlation): Extension, + claims: Option>, + RawQuery(raw_query): RawQuery, + headers: HeaderMap, + Path(path): Path>, +) -> Response { + let claims = claims + .map(|Extension(value)| value) + .unwrap_or_else(VerifiedRequestClaims::anonymous); + let parsed = parse_options(raw_query.as_deref()); + let (options, cursor, limit) = match parsed { + Ok(value) => value, + Err(()) => { + return audited_known_read_refusal( + &service, + &route, + &claims, + path.get("record_id"), + invalid_query(), + &correlation, + ) + .await + } + }; + let Some(surface) = authorize_route(&service, &route, &claims, &options) else { + return audited_read_concealment( + &service, + &route, + &options, + &claims, + path.get("record_id"), + &correlation, + ) + .await; + }; + let record_id = path.get("record_id"); + if claims.principal().is_none() + || record_id.is_none_or(|id| !valid_canonical_record_uuid(id)) + || headers.contains_key(crate::subject_access_log::REQUESTER_HEADER) + || headers.contains_key(crate::subject_access_log::PURPOSE_HEADER) + { + return audited_read_refusal( + &service, + &route, + &surface, + record_id, + concealed(), + &correlation, + ) + .await; + } + let request = RecordReadRequest { + entity_id: route.entity_id.clone(), + operation_id: route.id.clone(), + method: route.method, + context: surface.context, + selected_fields: surface.readable_fields.clone(), + representation: CursorRepresentation::Json, + adapter: CursorAdapter::Native, + adapter_origin: None, + geojson_next_link_prefix: None, + kind: RecordReadKind::Get { + id: record_id.expect("validated record id").clone(), + }, + maximum_records: 1, + request_history_after_proposal_version: None, + correlation: correlation.clone(), + }; + match service.records.access_log(request, cursor, limit).await { + Ok(Some(held)) => { + let mut response = ( + [(CONTENT_TYPE, HeaderValue::from_static("application/json"))], + held.body().to_vec(), + ) + .into_response(); + response + .headers_mut() + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + response + } + Ok(None) => concealed(), + Err(ReadServiceError::CursorInvalid) => cursor_invalid(), + Err(_) => unavailable(), + } +} + +fn parse_options(raw: Option<&str>) -> Result<(QueryOptions, Option, u16), ()> { + let mut profile = None; + let mut cursor = None; + let mut limit = None; + if raw.is_some_and(|value| value.len() > 2048) { + return Err(()); + } + for pair in raw + .unwrap_or_default() + .split('&') + .filter(|pair| !pair.is_empty()) + { + let (name, value) = pair.split_once('=').ok_or(())?; + let name = percent_decode(name).map_err(|_| ())?; + let value = percent_decode(value).map_err(|_| ())?; + match name.as_str() { + "accessProfile" if profile.is_none() => profile = Some(value), + "cursor" if cursor.is_none() && valid_canonical_record_uuid(&value) => { + cursor = Some(value) + } + "limit" if limit.is_none() => { + let parsed: u16 = value.parse().map_err(|_| ())?; + if !(1..=100).contains(&parsed) || parsed.to_string() != value { + return Err(()); + } + limit = Some(parsed); + } + _ => return Err(()), + } + } + let mut options = QueryOptions::default(); + options.parsed.access_profile = profile; + Ok((options, cursor, limit.unwrap_or(50))) +} diff --git a/crates/registry-breg/src/api/attachments.rs b/crates/registry-breg/src/api/attachments.rs index 44917e38a8..2fbb5659b3 100644 --- a/crates/registry-breg/src/api/attachments.rs +++ b/crates/registry-breg/src/api/attachments.rs @@ -226,6 +226,7 @@ async fn download( claims: Option>, RawQuery(raw_query): RawQuery, Path(path): Path>, + headers: HeaderMap, ) -> Response { let claims = claims .map(|Extension(value)| value) @@ -243,7 +244,7 @@ async fn download( && record_id.is_some_and(|id| valid_canonical_record_uuid(id)) && surface.readable_fields.contains(&binding.slot) }); - let Some(surface) = surface else { + let Some(mut surface) = surface else { return audited_known_read_refusal( &service, &attachment_route, @@ -254,6 +255,21 @@ async fn download( ) .await; }; + if surface + .context + .bind_access_attribution(surface.response_entity, &headers) + .is_err() + { + return audited_known_read_refusal( + &service, + &attachment_route, + &claims, + record_id, + concealed(), + &correlation, + ) + .await; + } let (_, version) = parsed.expect("authorized parsed attachment query"); let request = RecordReadRequest { entity_id: route.entity_id.clone(), diff --git a/crates/registry-breg/src/api/context.rs b/crates/registry-breg/src/api/context.rs index f4f5c7f4ac..37cecd8921 100644 --- a/crates/registry-breg/src/api/context.rs +++ b/crates/registry-breg/src/api/context.rs @@ -333,6 +333,8 @@ impl fmt::Debug for VerifiedRowBoundary { pub struct AuthorizedRequestContext { principal: Option, purpose: Option, + requester_client: Option, + forwarded_access_attribution: Option, selected_profile: String, row_boundaries: Vec, request_actions: Vec, @@ -354,6 +356,8 @@ impl AuthorizedRequestContext { Self { principal, purpose, + requester_client: None, + forwarded_access_attribution: None, selected_profile, row_boundaries, request_actions: Vec::new(), @@ -388,8 +392,29 @@ impl AuthorizedRequestContext { pub(crate) fn with_grant_audit(mut self, claims: &VerifiedRequestClaims) -> Self { self.grant_audit = crate::audit::GrantAuditContext::from_claims(claims); self.human_identity = claims.human_identity().cloned(); + self.requester_client = claims.requester_client().map(str::to_owned); self } + + pub(crate) fn requester_client(&self) -> Option<&str> { + self.requester_client.as_deref() + } + + pub(crate) fn forwarded_access_attribution( + &self, + ) -> Option<&crate::subject_access_log::ForwardedAccessAttribution> { + self.forwarded_access_attribution.as_ref() + } + + pub(crate) fn bind_access_attribution( + &mut self, + entity: &crate::model::CompiledEntity, + headers: &axum::http::HeaderMap, + ) -> Result<(), ()> { + self.forwarded_access_attribution = + crate::subject_access_log::forwarded_attribution(entity, self, headers)?; + Ok(()) + } pub(crate) fn grant_audit(&self) -> Option<&crate::audit::GrantAuditContext> { self.grant_audit.as_ref() } diff --git a/crates/registry-breg/src/api/gis.rs b/crates/registry-breg/src/api/gis.rs index 6583be7f74..14ac80def0 100644 --- a/crates/registry-breg/src/api/gis.rs +++ b/crates/registry-breg/src/api/gis.rs @@ -280,13 +280,30 @@ async fn items( claims: Option>, RawQuery(raw_query): RawQuery, Path(collection): Path, + headers: axum::http::HeaderMap, ) -> Response { let claims = claims .map(|Extension(value)| value) .unwrap_or_else(VerifiedRequestClaims::anonymous); - let Some(authorized) = authorize_gis_collection(&service, &claims, &collection) else { + let Some(mut authorized) = authorize_gis_collection(&service, &claims, &collection) else { return concealed(); }; + if authorized + .surface + .context + .bind_access_attribution(authorized.surface.response_entity, &headers) + .is_err() + { + return audited_read_refusal( + &service, + authorized.route, + &authorized.surface, + None, + concealed(), + &correlation, + ) + .await; + } // The feature surface speaks its own parameter names, so a refusal here // stays unlocated rather than naming a native query parameter. let query = match parse_items_query(raw_query.as_deref()) { diff --git a/crates/registry-breg/src/api/mod.rs b/crates/registry-breg/src/api/mod.rs index 3252d23f9b..f98dd8edd3 100644 --- a/crates/registry-breg/src/api/mod.rs +++ b/crates/registry-breg/src/api/mod.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 //! HTTP surface compiled from one immutable Registry inventory. +mod access_log; mod actions; mod attachments; #[cfg(test)] @@ -77,8 +78,10 @@ use crate::record_profile::{self, RecordRepresentation}; use uuid::Uuid; use crate::artifacts::{ + access_log_path, openapi_access_log_operation, openapi_access_log_response_schema, openapi_components, openapi_entity_input_schema, openapi_input_schema_id, openapi_operation, openapi_request_action_input_schema, OpenApiAccessProfiles, OpenApiOperationSpec, + ACCESS_LOG_RESPONSE_SCHEMA_ID, }; const MAX_MUTATION_BODY_BYTES: usize = 2 * 1024 * 1024; @@ -198,7 +201,8 @@ fn route_set(service: Arc) -> Router { } } - app.merge(attachments::routes(&service)) + app.merge(access_log::routes(&service)) + .merge(attachments::routes(&service)) .merge(gis::routes()) .merge(ingestion::routes(&service)) .fallback(not_found) @@ -361,6 +365,7 @@ async fn openapi( let mut writable_by_input_schema: BTreeMap)> = BTreeMap::new(); let mut action_input_schemas = Map::new(); + let mut has_access_log = false; for surface in &visible { let path = paths .entry(surface.route.path.clone()) @@ -386,6 +391,26 @@ async fn openapi( ), }), ); + if surface.route.operation == Operation::Get + && surface.read_path.is_none() + && surface.entity.access_log.is_some() + { + let access_log = paths + .entry(access_log_path(surface.route)) + .or_insert_with(|| Value::Object(Map::new())); + access_log + .as_object_mut() + .expect("OpenAPI paths are objects") + .insert( + "get".to_owned(), + openapi_access_log_operation( + surface.route, + surface.entity, + OpenApiAccessProfiles::Selected(surface.context.selected_profile()), + ), + ); + has_access_log = true; + } readable_by_entity .entry(surface.response_entity.id.clone()) .and_modify(|fields| { @@ -434,6 +459,12 @@ async fn openapi( )); let has_request_actions = !action_input_schemas.is_empty(); schemas.extend(action_input_schemas); + if has_access_log { + schemas.insert( + ACCESS_LOG_RESPONSE_SCHEMA_ID.to_owned(), + openapi_access_log_response_schema(), + ); + } actions::append_openapi(&visible_actions, &mut paths, &mut schemas); if service.review_completions.is_some() { crate::artifacts::append_review_completion_openapi(&mut paths, &mut schemas); @@ -621,7 +652,7 @@ async fn read_dispatch( .await; } }; - let Some(surface) = authorize_route(&service, &route, &claims, &options) else { + let Some(mut surface) = authorize_route(&service, &route, &claims, &options) else { let response = audited_read_concealment( &service, &route, @@ -633,6 +664,14 @@ async fn read_dispatch( .await; return response; }; + if surface + .context + .bind_access_attribution(surface.response_entity, &headers) + .is_err() + { + return audited_read_refusal(&service, &route, &surface, None, concealed(), &correlation) + .await; + } if let Some(refusal) = field_encryption_refusal(&service, surface.entity) { return refusal; } @@ -901,10 +940,18 @@ async fn lookup_dispatch( .await; } }; - let Some(surface) = authorize_route(&service, &route, &claims, &options) else { + let Some(mut surface) = authorize_route(&service, &route, &claims, &options) else { return audited_read_concealment(&service, &route, &options, &claims, None, &correlation) .await; }; + if surface + .context + .bind_access_attribution(surface.response_entity, &headers) + .is_err() + { + return audited_read_refusal(&service, &route, &surface, None, concealed(), &correlation) + .await; + } if let Some(refusal) = field_encryption_refusal(&service, surface.entity) { return refusal; } @@ -1082,7 +1129,7 @@ async fn revision_dispatch( .await; } }; - let Some(surface) = authorize_route(&service, &route, &claims, &options) else { + let Some(mut surface) = authorize_route(&service, &route, &claims, &options) else { return audited_revision_concealment( revisions.as_ref(), &route, @@ -1093,6 +1140,21 @@ async fn revision_dispatch( ) .await; }; + if surface + .context + .bind_access_attribution(surface.response_entity, &headers) + .is_err() + { + return audited_revision_refusal( + revisions.as_ref(), + &route, + &surface, + None, + concealed(), + &correlation, + ) + .await; + } if let Some(refusal) = field_encryption_refusal(&service, surface.entity) { return refusal; } @@ -1224,10 +1286,18 @@ async fn snapshot_dispatch( .await; } }; - let Some(surface) = authorize_route(&service, &route, &claims, &options) else { + let Some(mut surface) = authorize_route(&service, &route, &claims, &options) else { return audited_read_concealment(&service, &route, &options, &claims, None, &correlation) .await; }; + if surface + .context + .bind_access_attribution(surface.response_entity, &headers) + .is_err() + { + return audited_read_refusal(&service, &route, &surface, None, concealed(), &correlation) + .await; + } if let Some(refusal) = field_encryption_refusal(&service, surface.entity) { return refusal; } diff --git a/crates/registry-breg/src/api/service.rs b/crates/registry-breg/src/api/service.rs index 727e7e0f0a..7a025e7359 100644 --- a/crates/registry-breg/src/api/service.rs +++ b/crates/registry-breg/src/api/service.rs @@ -800,6 +800,17 @@ pub enum ReadServiceError { /// projection, result bound, and row boundaries in the database transaction; /// the HTTP response projection is only defense in depth. pub trait RecordReadService: Send + Sync { + /// Read the separate access log only after current GET and subject ownership + /// have both been established. The default backend releases nothing. + fn access_log( + &self, + _request: RecordReadRequest, + _cursor: Option, + _limit: u16, + ) -> ServiceFuture<'_, Result, ReadServiceError>> { + Box::pin(async { Err(ReadServiceError::Unavailable) }) + } + fn get( &self, request: RecordReadRequest, diff --git a/crates/registry-breg/src/artifacts.rs b/crates/registry-breg/src/artifacts.rs index ae4cc370f4..f065aadf45 100644 --- a/crates/registry-breg/src/artifacts.rs +++ b/crates/registry-breg/src/artifacts.rs @@ -1883,6 +1883,7 @@ fn openapi_document( ) -> Value { let mut paths = Map::new(); let mut input_schemas = Map::new(); + let mut has_access_log = false; for route in &routes.routes { // An import grant is exercised only through the ingestion-run // surface, which the static document does not describe. @@ -1926,6 +1927,21 @@ fn openapi_document( access_profiles: OpenApiAccessProfiles::All, }), ); + if route.operation == Operation::Get + && read_path_for_route(route, entity).is_none() + && entity.access_log.is_some() + { + let path = paths + .entry(access_log_path(route)) + .or_insert_with(|| Value::Object(Map::new())); + path.as_object_mut() + .expect("OpenAPI path entries are objects") + .insert( + "get".to_owned(), + openapi_access_log_operation(route, entity, OpenApiAccessProfiles::All), + ); + has_access_log = true; + } } for route in &routes.routes { if !matches!(route.operation, Operation::Get | Operation::Patch) @@ -2023,6 +2039,12 @@ fn openapi_document( .map(|(id, schema)| (id.clone(), schema.clone())) .collect(); component_schemas.extend(input_schemas); + if has_access_log { + component_schemas.insert( + ACCESS_LOG_RESPONSE_SCHEMA_ID.to_owned(), + openapi_access_log_response_schema(), + ); + } append_review_completion_openapi(&mut paths, &mut component_schemas); let has_request_actions = routes .routes @@ -2036,6 +2058,149 @@ fn openapi_document( }) } +pub(crate) const ACCESS_LOG_RESPONSE_SCHEMA_ID: &str = "SubjectAccessLogPage"; + +pub(crate) fn access_log_path(route: &CompiledRoute) -> String { + format!("{}/access-log", route.path) +} + +pub(crate) fn openapi_access_log_operation( + route: &CompiledRoute, + entity: &CompiledEntity, + access_profiles: OpenApiAccessProfiles<'_>, +) -> Value { + debug_assert_eq!(route.operation, Operation::Get); + debug_assert!(entity.access_log.is_some()); + let mut operation = Map::from_iter([ + ( + "operationId".to_owned(), + json!(format!("{}.access-log", route.id)), + ), + ("x-registry-entity".to_owned(), json!(entity.id)), + ("x-registry-operation".to_owned(), json!("access_log")), + ( + "x-registry-responseShape".to_owned(), + json!("BRegSubjectAccessLogV1"), + ), + ( + "description".to_owned(), + json!("Return the subject-facing access history for this record. The selected profile must currently grant get, and the record's configured subject field must exactly match the verified principal. Other records are concealed as 404."), + ), + ("security".to_owned(), json!([{"bearerAuth": []}])), + ]); + match access_profiles { + OpenApiAccessProfiles::All => { + operation.insert( + "x-registry-accessProfiles".to_owned(), + json!(route.access_profiles), + ); + } + OpenApiAccessProfiles::Selected(profile) => { + operation.insert("x-registry-accessProfile".to_owned(), json!(profile)); + } + } + operation.insert( + "parameters".to_owned(), + json!([ + path_parameter( + "record_id", + json!({"type": "string", "format": "uuid"}), + "Canonical record UUID owned by the verified subject." + ), + header_parameter( + "traceparent", + false, + traceparent_schema(), + "Optional W3C trace context. Responses carry Registry trace context for the request." + ), + access_profile_parameter(route.default_access_profile.is_none()), + query_parameter( + "limit", + false, + false, + json!({"type": "integer", "minimum": 1, "maximum": 100, "default": 50}), + "Maximum visible access events returned in this page." + ), + query_parameter( + "cursor", + false, + false, + json!({"type": "string", "format": "uuid"}), + "Opaque event cursor from the preceding page, scoped to this record and verified subject." + ) + ]), + ); + operation.insert( + "responses".to_owned(), + json!({ + "200": { + "description": "Subject-facing access events returned", + "headers": { + "traceparent": traceparent_header("Trace context for this response."), + "Cache-Control": no_store_header() + }, + "content": { + "application/json": { + "schema": {"$ref": format!("#/components/schemas/{ACCESS_LOG_RESPONSE_SCHEMA_ID}")} + } + } + }, + "400": access_log_problem_response("The query or cursor is invalid."), + "404": access_log_problem_response("The record or subject-owned access log was not found."), + "503": access_log_problem_response("The subject access-log service is unavailable.") + }), + ); + Value::Object(operation) +} + +fn access_log_problem_response(description: &str) -> Value { + json!({ + "description": description, + "headers": { + "traceparent": traceparent_header("Trace context for this problem response."), + "Cache-Control": no_store_header() + }, + "content": { + "application/problem+json": { + "schema": {"$ref": "#/components/schemas/Problem"} + } + } + }) +} + +pub(crate) fn openapi_access_log_response_schema() -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["events", "nextCursor"], + "properties": { + "events": { + "type": "array", + "maxItems": 100, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", "accessedAt", "requester", "serviceClient", "purpose", + "operationId", "visibleAfter", "exemptionReason" + ], + "properties": { + "id": {"type": "string", "format": "uuid"}, + "accessedAt": {"type": "string", "format": "date-time"}, + "requester": {"type": "string", "minLength": 1, "maxLength": 512}, + "serviceClient": {"type": ["string", "null"], "minLength": 1, "maxLength": 512}, + "purpose": {"type": ["string", "null"], "minLength": 1, "maxLength": 512}, + "operationId": {"type": "string", "minLength": 1, "maxLength": 256}, + "visibleAfter": {"type": "string", "format": "date-time"}, + "exemptionReason": {"type": ["string", "null"], "minLength": 1, "maxLength": 256} + } + } + }, + "nextCursor": {"type": ["string", "null"], "format": "uuid"} + } + }) +} + pub(crate) fn append_review_completion_openapi( paths: &mut Map, schemas: &mut Map, diff --git a/crates/registry-breg/src/compiler.rs b/crates/registry-breg/src/compiler.rs index 493e6628c3..435399972d 100644 --- a/crates/registry-breg/src/compiler.rs +++ b/crates/registry-breg/src/compiler.rs @@ -19,7 +19,9 @@ use crate::contract::{ ManifestProjectionTextSource, ModuleAssetSource, MutationMode, Operation, ReadPathPermissionSource, RegistryModule, RegistryProject, SpatialBboxPermissionSource, SpatialQueryPermissionSource, UniqueWhenPredicate, ValidTimeRole, WebhookAuthenticationProfile, - WebhookDeadLetterMode, MAX_ENCRYPTED_FIELD_PLAINTEXT_BYTES, + WebhookDeadLetterMode, MAX_ACCESS_LOG_EXEMPTIONS, MAX_ACCESS_LOG_EXEMPTION_REASON_BYTES, + MAX_ACCESS_LOG_RETENTION_DAYS, MAX_ACCESS_LOG_SUBJECT_CHARACTERS, + MAX_ACCESS_LOG_TRUSTED_INTERMEDIARIES, MAX_ENCRYPTED_FIELD_PLAINTEXT_BYTES, MAX_ENCRYPTED_FIELD_STRING_CHARACTERS, MAX_FIELD_LOOKUP_NORMALIZATION_STEPS, MAX_STRUCTURED_VALUE_BYTES, }; @@ -2208,6 +2210,7 @@ fn validate_entities( _ => {} } validate_entity_fields(entity, entities, errors); + validate_access_log(entity, entities, errors); attachments::validate(entity, errors); validate_geojson(entity, errors); validate_derived(entity, errors); @@ -2222,6 +2225,174 @@ fn validate_entities( validate_read_path_cycles(entities, errors); } +fn validate_access_log( + entity: &EntitySource, + entities: &BTreeMap, + errors: &mut Vec, +) { + let Some(access_log) = &entity.access_log else { + return; + }; + let path = format!("entities[id={}].accessLog", entity.id); + let fields = stored_field_map(entity); + match fields.get(access_log.subject_field.as_str()) { + Some(field) + if field.required + && !field.encrypted + && matches!( + field.field_type, + FieldTypeSource::String { max_length, .. } + | FieldTypeSource::Text { max_length } + if max_length <= MAX_ACCESS_LOG_SUBJECT_CHARACTERS + ) => {} + _ => errors.push(Diagnostic::error( + "access_log.subject_field.invalid", + format!("{path}.subjectField"), + &format!( + "subjectField must name a required plaintext stored string or text field with maxLength at most {MAX_ACCESS_LOG_SUBJECT_CHARACTERS}" + ), + )), + } + if !(1..=MAX_ACCESS_LOG_RETENTION_DAYS).contains(&access_log.retention_days) { + errors.push(Diagnostic::error( + "access_log.retention_days.invalid", + format!("{path}.retentionDays"), + &format!("retentionDays must be between 1 and {MAX_ACCESS_LOG_RETENTION_DAYS}"), + )); + } + if access_log.trusted_intermediaries.len() > MAX_ACCESS_LOG_TRUSTED_INTERMEDIARIES { + errors.push(Diagnostic::error( + "access_log.trusted_intermediaries.too_many", + format!("{path}.trustedIntermediaries"), + &format!( + "an access log may trust at most {MAX_ACCESS_LOG_TRUSTED_INTERMEDIARIES} intermediary clients" + ), + )); + } + if access_log.trusted_intermediaries.iter().any(|client| { + client.is_empty() + || client.len() > 512 + || client.chars().any(char::is_control) + || client.chars().any(char::is_whitespace) + }) { + errors.push(Diagnostic::error( + "access_log.trusted_intermediary.invalid", + format!("{path}.trustedIntermediaries"), + "a trusted intermediary must be a bounded non-whitespace verified client identifier", + )); + } + if access_log.exemptions.len() > MAX_ACCESS_LOG_EXEMPTIONS { + errors.push(Diagnostic::error( + "access_log.exemptions.too_many", + format!("{path}.exemptions"), + &format!("an access log may declare at most {MAX_ACCESS_LOG_EXEMPTIONS} exemptions"), + )); + } + for (profile_id, exemption) in &access_log.exemptions { + validate_id(profile_id, &format!("{path}.exemptions"), errors); + let source_entity_id = exemption.source_entity.as_deref().unwrap_or(&entity.id); + if exemption.source_entity.is_some() { + validate_id( + source_entity_id, + &format!("{path}.exemptions[{profile_id}].sourceEntity"), + errors, + ); + } + let valid_profile = entities.get(source_entity_id).is_some_and(|source_entity| { + source_entity + .access_profiles + .iter() + .find(|profile| profile.id == *profile_id) + .is_some_and(|profile| { + if source_entity_id == entity.id { + profile_has_direct_logged_read(profile) + } else { + profile_has_read_path_to(source_entity, profile, &entity.id) + } + }) + }); + if !valid_profile { + errors.push(Diagnostic::error( + "access_log.exemption.profile_invalid", + format!("{path}.exemptions[{profile_id}]"), + "an access-log exemption must name a profile on sourceEntity that directly reads the logged entity or has a declared read path to it", + )); + } + if exemption.reason.is_empty() + || exemption.reason != exemption.reason.trim() + || exemption.reason.len() > MAX_ACCESS_LOG_EXEMPTION_REASON_BYTES + || exemption.reason.chars().any(char::is_control) + { + errors.push(Diagnostic::error( + "access_log.exemption.reason_invalid", + format!("{path}.exemptions[{profile_id}].reason"), + &format!( + "an access-log exemption reason must be trimmed printable text of at most {MAX_ACCESS_LOG_EXEMPTION_REASON_BYTES} UTF-8 bytes" + ), + )); + } + if exemption.delay_days == 0 || exemption.delay_days >= access_log.retention_days { + errors.push(Diagnostic::error( + "access_log.exemption.delay_invalid", + format!("{path}.exemptions[{profile_id}].delayDays"), + "delayDays must be at least 1 and less than retentionDays", + )); + } + } + for profile in &entity.access_profiles { + if profile.anonymous && profile_has_direct_logged_read(profile) { + errors.push(Diagnostic::error( + "access_log.anonymous_read_forbidden", + format!( + "entities[id={}].accessProfiles[id={}].operations", + entity.id, profile.id + ), + "an access-logged entity cannot grant anonymous record reads because every logged reader must be named", + )); + } + } + for source_entity in entities.values() { + for profile in &source_entity.access_profiles { + if profile.anonymous && profile_has_read_path_to(source_entity, profile, &entity.id) { + errors.push(Diagnostic::error( + "access_log.anonymous_read_forbidden", + format!( + "entities[id={}].accessProfiles[id={}].readPaths", + source_entity.id, profile.id + ), + "an access-logged entity cannot be reached through an anonymous read path because every logged reader must be named", + )); + } + } + } +} + +fn profile_has_direct_logged_read(profile: &AccessProfileSource) -> bool { + profile.operations.iter().any(|operation| { + matches!( + operation, + Operation::Get + | Operation::Lookup + | Operation::List + | Operation::Revisions + | Operation::Snapshot + ) + }) +} + +fn profile_has_read_path_to( + source_entity: &EntitySource, + profile: &AccessProfileSource, + target_entity_id: &str, +) -> bool { + profile.read_paths.iter().any(|grant| { + source_entity + .read_paths + .iter() + .any(|path| path.id == grant.path && path.to == target_entity_id) + }) +} + fn validate_geojson(entity: &EntitySource, errors: &mut Vec) { let Some(GeoJsonSource { geometry_field }) = &entity.geojson else { return; @@ -5595,6 +5766,7 @@ fn compile_entities( batch: source.batch.clone(), classification: source.classification, access_requirements: source.access_requirements.clone(), + access_log: source.access_log.clone(), geojson: source .geojson .as_ref() diff --git a/crates/registry-breg/src/contract.rs b/crates/registry-breg/src/contract.rs index 3c320ea89b..5b5c34a539 100644 --- a/crates/registry-breg/src/contract.rs +++ b/crates/registry-breg/src/contract.rs @@ -405,6 +405,9 @@ pub struct EntitySource { /// Mandatory request-access requirements checked against every profile, including module contributions. #[serde(default, skip_serializing_if = "Option::is_none")] pub access_requirements: Option, + /// Subject-facing record access history, separate from the operational audit. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub access_log: Option, #[serde(default)] pub constraints: Vec, #[serde(default)] @@ -434,6 +437,77 @@ pub struct EntitySource { pub consent_record: Option, } +/// Maximum retained subject-facing access-log window. +pub const MAX_ACCESS_LOG_RETENTION_DAYS: u16 = 3_650; +/// Maximum length of a plaintext subject identifier stored on a logged entity. +pub const MAX_ACCESS_LOG_SUBJECT_CHARACTERS: u32 = 512; +/// Maximum explicit intermediary clients trusted by one logged entity. +pub const MAX_ACCESS_LOG_TRUSTED_INTERMEDIARIES: usize = 64; +/// Maximum delayed-disclosure policies declared by one logged entity. +pub const MAX_ACCESS_LOG_EXEMPTIONS: usize = 64; +/// Maximum UTF-8 bytes in a delayed-disclosure policy reason. +pub const MAX_ACCESS_LOG_EXEMPTION_REASON_BYTES: usize = 256; + +const fn default_access_log_retention_days() -> u16 { + 90 +} + +/// Governed subject-facing access-log policy for an entity. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AccessLogSource { + /// Required plaintext string or text field matched to the subject's verified principal. + #[cfg_attr( + feature = "schema", + schemars(length(min = 1, max = 64), regex(pattern = "^[a-z][a-z0-9_-]*$")) + )] + pub subject_field: String, + /// Days each entry remains available before bounded background erasure. + #[serde(default = "default_access_log_retention_days")] + #[cfg_attr(feature = "schema", schemars(range(min = 1, max = 3_650)))] + pub retention_days: u16, + /// Verified intermediary client IDs allowed to forward original requester attribution. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[cfg_attr(feature = "schema", schemars(length(max = 64)))] + pub trusted_intermediaries: BTreeSet, + /// Access profiles whose entries become subject-visible only after a policy delay. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + #[cfg_attr( + feature = "schema", + schemars(extend( + "maxProperties" = MAX_ACCESS_LOG_EXEMPTIONS, + "propertyNames" = { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9_-]*$" + } + )) + )] + pub exemptions: BTreeMap, +} + +/// Delayed subject disclosure for reads under one access profile. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AccessLogExemptionSource { + /// Entity whose access profile authorizes the read. Omitted for direct reads of the logged entity. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr( + feature = "schema", + schemars(length(min = 1, max = 64), regex(pattern = "^[a-z][a-z0-9_-]*$")) + )] + pub source_entity: Option, + /// Bounded policy reason retained and audited with each delayed entry. + #[cfg_attr(feature = "schema", schemars(length(min = 1, max = 256)))] + pub reason: String, + /// Days after the read when the entry becomes visible to the subject. + #[cfg_attr(feature = "schema", schemars(range(min = 1, max = 3_649)))] + pub delay_days: u16, +} + /// The fields of a consent-record entity the engine reads. Other fields are /// ignored by consent enforcement. #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] diff --git a/crates/registry-breg/src/evidence_source.rs b/crates/registry-breg/src/evidence_source.rs index 6e0ff36311..7923256e0c 100644 --- a/crates/registry-breg/src/evidence_source.rs +++ b/crates/registry-breg/src/evidence_source.rs @@ -437,6 +437,7 @@ pub fn export_evidence_source( )); artifacts.push(document(format!("sources/{prefix}.yaml"),&json!({ "transport":"http-json","connection":options.connection,"behaviorRevision":behavior_revision,"posture":"field-projected", + "forwardAccessAttribution":entity.access_log.is_some(), "unresolvedProblem":{"status":404,"type":crate::problem::ProblemCode::LookupUnresolved.type_uri(),"code":"lookup.unresolved"}, "request":{"method":"POST","path":route.path,"fixedHeaders":[{"name":"Accept","value":"application/json"}], "selectorInputs":[{"role":"subject","alternatives":alternatives}],"prepareScript":format!("adapters/{prefix}-prepare.rhai"), @@ -515,7 +516,7 @@ fn selected_behavior( .map(|boundary| &boundary.field) .map(|id| field(entity, id).map(|field| json!(field))) .collect::, _>>()?; - let mut behavior = json!({"protocol":"breg-evidence-lookup-v1","readSemantics":"collection-dependencies-v1/evaluation-date-transaction-v1","entity":entity.id,"route":entity.route,"canonicalId":entity.canonical_id,"tombstone":entity.tombstone,"fields":logical_fields,"selectors":selectors,"access":{"profile":access.id,"anonymous":access.anonymous,"principalClaim":access.principal_claim,"requiredScopes":access.required_scopes,"requiredPurposes":access.required_purposes,"rowBoundaries":access.row_boundaries,"boundaryFields":boundary_fields,"requestVisibility":access.request_visibility,"requirements":entity.access_requirements,"selectPolicies":select_policies(registry,entity,&options.access_profile)},"derived":derived,"sources":sources}); + let mut behavior = json!({"protocol":"breg-evidence-lookup-v2","readSemantics":"collection-dependencies-v1/evaluation-date-transaction-v1/access-attribution-v1","entity":entity.id,"route":entity.route,"canonicalId":entity.canonical_id,"tombstone":entity.tombstone,"fields":logical_fields,"selectors":selectors,"access":{"profile":access.id,"anonymous":access.anonymous,"principalClaim":access.principal_claim,"requiredScopes":access.required_scopes,"requiredPurposes":access.required_purposes,"rowBoundaries":access.row_boundaries,"boundaryFields":boundary_fields,"requestVisibility":access.request_visibility,"requirements":entity.access_requirements,"accessLog":entity.access_log,"selectPolicies":select_policies(registry,entity,&options.access_profile)},"derived":derived,"sources":sources}); let membership = membership_behavior(registry, entity, &options.access_profile)?; if !membership.is_empty() { behavior["access"]["membershipBoundaries"] = json!(membership); diff --git a/crates/registry-breg/src/evidence_source/tests.rs b/crates/registry-breg/src/evidence_source/tests.rs index 68f2b4de98..efb46e7325 100644 --- a/crates/registry-breg/src/evidence_source/tests.rs +++ b/crates/registry-breg/src/evidence_source/tests.rs @@ -75,6 +75,7 @@ fn alternatives_keep_one_route_and_selected_identity_with_stable_inventories() { assert_eq!(source["request"]["path"], "/v1/records/records:lookup"); assert_eq!(source["request"]["method"], "POST"); assert_eq!(source["connection"], "registry"); + assert_eq!(source["forwardAccessAttribution"], false); assert!(source.get("authentication").is_none()); assert!(source.get("baseUrl").is_none()); assert_eq!( @@ -130,6 +131,23 @@ fn alternatives_keep_one_route_and_selected_identity_with_stable_inventories() { .contains("rowBoundaries")); } +#[test] +fn logged_entity_export_enables_attribution_and_binds_policy_into_behavior_revision() { + let original = project(); + let before = export_evidence_source(&compiled(&original, SQL), &options()).unwrap(); + let mut logged = original.clone(); + logged["entities"][0]["accessLog"] = json!({ + "subjectField": "code", + "retentionDays": 90, + "trustedIntermediaries": ["evidence-service"] + }); + let after = export_evidence_source(&compiled(&logged, SQL), &options()).unwrap(); + let source = yaml(&after, "sources/registry-status.yaml"); + + assert_eq!(source["forwardAccessAttribution"], true); + assert_ne!(before.behavior_revision, after.behavior_revision); +} + #[test] fn consumed_behavior_ignores_unselected_fields_but_reaches_sql_and_authority() { let original = project(); diff --git a/crates/registry-breg/src/history_schema.rs b/crates/registry-breg/src/history_schema.rs index 218b5d5143..10f6f216f5 100644 --- a/crates/registry-breg/src/history_schema.rs +++ b/crates/registry-breg/src/history_schema.rs @@ -826,6 +826,7 @@ mod tests { change_request: None, classification: Classification::Restricted, access_requirements: None, + access_log: None, geojson: None, physical_table: "e_membership".to_owned(), temporal: Some(CompiledTemporal { diff --git a/crates/registry-breg/src/lib.rs b/crates/registry-breg/src/lib.rs index 34b4b1dddb..5a6f155fc3 100644 --- a/crates/registry-breg/src/lib.rs +++ b/crates/registry-breg/src/lib.rs @@ -6,6 +6,11 @@ pub mod access; #[cfg(all(feature = "runtime", feature = "tooling"))] pub mod access_preview; pub mod authority; +#[cfg(feature = "runtime")] +mod subject_access_log; +#[cfg(feature = "postgres-test")] +#[doc(hidden)] +pub use subject_access_log::expire_subject_access_log_for_test; #[cfg(feature = "runtime")] pub mod action_evidence; diff --git a/crates/registry-breg/src/model.rs b/crates/registry-breg/src/model.rs index 4bd9937c06..ae0df602e0 100644 --- a/crates/registry-breg/src/model.rs +++ b/crates/registry-breg/src/model.rs @@ -7,8 +7,8 @@ use serde::{Deserialize, Serialize}; use crate::artifacts::GeneratedArtifacts; use crate::contract::{ - AccessProfileSource, BatchSource, Classification, ConstraintSource, EventConditionSource, - FieldTypeSource, HookSource, ManifestProjectionCatalogSource, + AccessLogSource, AccessProfileSource, BatchSource, Classification, ConstraintSource, + EventConditionSource, FieldTypeSource, HookSource, ManifestProjectionCatalogSource, ManifestProjectionDataServiceSource, ManifestProjectionDatasetSource, ManifestProjectionDistributionSource, ManifestProjectionEntitySource, ManifestProjectionPublicServiceSource, ManifestProjectionVocabularySource, MutationMode, @@ -952,6 +952,8 @@ pub struct CompiledEntity { #[serde(default, skip_serializing_if = "Option::is_none")] pub access_requirements: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub access_log: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub geojson: Option, pub physical_table: String, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/registry-breg/src/mutation.rs b/crates/registry-breg/src/mutation.rs index 9e46b698fc..0f2ece4348 100644 --- a/crates/registry-breg/src/mutation.rs +++ b/crates/registry-breg/src/mutation.rs @@ -447,6 +447,9 @@ pub async fn install_mutation_schema( .await .map_err(|_| MutationError::Unavailable)?; crate::request_store::install(migration, runtime_role).await?; + crate::subject_access_log::install(migration, runtime_role) + .await + .map_err(|_| MutationError::Unavailable)?; // The run table references the authority table, so authorities install first. crate::import_authority::install(migration, runtime_role) .await diff --git a/crates/registry-breg/src/package.rs b/crates/registry-breg/src/package.rs index 551701068a..a4cd961a32 100644 --- a/crates/registry-breg/src/package.rs +++ b/crates/registry-breg/src/package.rs @@ -262,6 +262,7 @@ pub enum CompiledRegistryChangeCode { EntityMutationModeChanged, EntityClassificationChanged, EntityAccessRequirementsChanged, + EntityAccessLogChanged, EntityGeoJsonChanged, EntityTemporalChanged, ChangeRequestContractChanged, @@ -1080,6 +1081,18 @@ fn compare_entities( ), ); } + if previous_entity.access_log != candidate_entity.access_log { + push_change( + changes, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeCode::EntityAccessLogChanged, + target( + CompiledRegistryChangeTargetKind::Entity, + Some(entity_id.as_str()), + None, + ), + ); + } if previous_entity.consent_record != candidate_entity.consent_record { push_change( changes, diff --git a/crates/registry-breg/src/postgres/access_log.rs b/crates/registry-breg/src/postgres/access_log.rs new file mode 100644 index 0000000000..38f9a6910c --- /dev/null +++ b/crates/registry-breg/src/postgres/access_log.rs @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::*; + +impl PostgresRecordReadService { + pub(super) async fn read_access_log( + &self, + mut request: RecordReadRequest, + cursor: Option, + limit: u16, + ) -> Result, ReadServiceError> { + if !profile_is_keyed(self.audit.profile()) || !(1..=100).contains(&limit) { + return Err(ReadServiceError::Unavailable); + } + let plan = ReadPlan::from_request(&self.registry, &self.expected, &self.cursors, &request) + .map_err(|_| ReadServiceError::Unavailable)?; + let policy = plan + .entity + .access_log + .as_ref() + .ok_or(ReadServiceError::Unavailable)?; + let record_id = match &request.kind { + RecordReadKind::Get { id } if valid_canonical_uuid(id) => id.clone(), + _ => return Err(ReadServiceError::Unavailable), + }; + if cursor + .as_deref() + .is_some_and(|value| !valid_canonical_uuid(value)) + { + return Err(ReadServiceError::CursorInvalid); + } + let claims = strict_claim_context(&self.registry, &request.context, &request.entity_id)?; + let principal = claims.principal().ok_or(ReadServiceError::Unavailable)?; + // A distinct operation identity keeps the operational audit truthful: + // this endpoint reads access metadata and does not read the record body. + request.operation_id.push_str(":access-log"); + let _attempt = begin_pre_io_audit( + &self.audit, + &self.expected, + &claims, + PreIoAudit { + kind: PreIoAuditKind::Attempt, + method: request.method, + operation_id: &request.operation_id, + target_record: Some(&record_id), + refusal_reason: None, + correlation: &request.correlation, + }, + ) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + let mut client = self + .pool + .get() + .await + .map_err(|_| ReadServiceError::Unavailable)?; + let transaction = begin_record_transaction( + &mut client, + self.lock_key, + self.lock_timeout, + &self.expected, + &claims, + ) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + crate::mutation::install_request_visibility_context( + transaction.transaction(), + &plan.entity, + &claims, + self.audit.profile(), + &self.expected.database_id, + ) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + let subject_field = plan + .entity + .fields + .get(&policy.subject_field) + .ok_or(ReadServiceError::Unavailable)?; + // GET RLS and the stored subject binding are checked in the same SQL + // snapshot as the log page. An officer with registry-wide GET cannot + // read somebody else's history, even during a change of record owner. + // The outer join distinguishes an owned empty log from an unseen row. + let sql = format!("SELECT entry.event_id::text, + to_char(accessed_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"'), + requester, service_client, purpose, operation_id, + to_char(visible_after AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"'), + exemption_reason + FROM (SELECT record_id FROM registry_data.{} + WHERE record_id = $2::text::uuid AND {} = $5 + AND record_lifecycle = 'active' LIMIT 1) AS owned + LEFT JOIN LATERAL ( + SELECT * FROM registry_internal.registry_subject_access_log AS entry + WHERE entity_id = $1 AND record_id = owned.record_id + AND visible_after <= transaction_timestamp() AND expires_at > transaction_timestamp() + AND ($3::text IS NULL OR (accessed_at, event_id) < ( + SELECT accessed_at, event_id FROM registry_internal.registry_subject_access_log + WHERE event_id = $3::text::uuid AND entity_id = $1 AND record_id = $2::text::uuid + AND visible_after <= transaction_timestamp() AND expires_at > transaction_timestamp())) + ORDER BY accessed_at DESC, event_id DESC LIMIT $4 + ) AS entry ON TRUE + ORDER BY accessed_at DESC, entry.event_id DESC", + quote_identifier(&plan.entity.physical_table), quote_identifier(&subject_field.physical_name)); + let maximum = i64::from(limit) + 1; + let rows = transaction + .transaction() + .query_typed( + &sql, + &[ + (&plan.entity.id, Type::TEXT), + (&record_id, Type::TEXT), + (&cursor, Type::TEXT), + (&maximum, Type::INT8), + (&principal, Type::TEXT), + ], + ) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + if rows.is_empty() { + transaction + .commit() + .await + .map_err(|_| ReadServiceError::Unavailable)?; + self.record_read_terminal_audit( + &request, + self.terminal( + &request, + &claims, + &plan, + TerminalAuditOutcome::Refused, + 0, + None, + )?, + ) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + return Ok(None); + } + let rows = rows + .iter() + .filter(|row| row.get::<_, Option>(0).is_some()) + .collect::>(); + let has_more = rows.len() > usize::from(limit); + let events = rows.iter().take(usize::from(limit)).map(|row| { + json!({"id":row.get::<_,String>(0), "accessedAt":row.get::<_,String>(1), + "requester":row.get::<_,String>(2), "serviceClient":row.get::<_,Option>(3), + "purpose":row.get::<_,Option>(4), "operationId":row.get::<_,String>(5), + "visibleAfter":row.get::<_,String>(6), "exemptionReason":row.get::<_,Option>(7)}) + }).collect::>(); + let next_cursor = has_more.then(|| events.last().expect("nonempty page")["id"].clone()); + let held = HeldReadResponse::from_json(&json!({"events":events,"nextCursor":next_cursor}))?; + transaction + .commit() + .await + .map_err(|_| ReadServiceError::Unavailable)?; + self.fault.fail_at(ReadFaultPoint::BeforeTerminalAudit)?; + self.record_read_terminal_audit( + &request, + self.terminal( + &request, + &claims, + &plan, + TerminalAuditOutcome::Returned, + events.len(), + None, + )?, + ) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + Ok(Some(held)) + } +} diff --git a/crates/registry-breg/src/postgres/catalog.rs b/crates/registry-breg/src/postgres/catalog.rs index 2e32811e80..b7db584569 100644 --- a/crates/registry-breg/src/postgres/catalog.rs +++ b/crates/registry-breg/src/postgres/catalog.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -use std::{collections::BTreeSet, fmt::Write}; +use std::{borrow::Cow, collections::BTreeSet, fmt::Write}; use sha2::{Digest, Sha256}; use tokio_postgres::GenericClient; @@ -111,19 +111,55 @@ enum ManagedPolicyRole { SpatialBbox, } +/// Subject access-log table and expiry function, created by every apply. +const SUBJECT_ACCESS_LOG_TABLE: &str = "registry_internal.registry_subject_access_log"; +const SUBJECT_ACCESS_LOG_EXPIRY: &str = "registry_internal.expire_subject_access_log()"; + /// Exact managed PostgreSQL inventory accepted by catalog verification. /// /// Construction is deliberately closed to either the explicit feasibility /// kernel or one compiler-produced Registry plus the current product-owned /// mutation tables. There is no wildcard or ambient-catalog mode. +/// +/// The one alternative a compiled catalog admits is the subject access-log +/// storage: a database a release before that storage activated gains it only +/// at its next apply. Until then, a Registry that collects no subject access +/// log is checked against the catalog without it (see +/// `installed_managed_catalog`). The recorded schema fingerprint still pins +/// which of the two shapes the active package was activated against. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ExpectedManagedCatalog { objects: BTreeSet, column_privileges: BTreeSet, policies: BTreeSet, + subject_access_log_optional: bool, } impl ExpectedManagedCatalog { + /// Exact catalog of the release before subject access-log storage existed. + #[cfg(feature = "postgres-test")] + #[doc(hidden)] + #[must_use] + pub fn without_subject_access_log_for_test(self) -> Self { + self.without_subject_access_log() + } + + fn without_subject_access_log(mut self) -> Self { + self.objects.retain(|object| { + object.name != SUBJECT_ACCESS_LOG_TABLE && object.name != SUBJECT_ACCESS_LOG_EXPIRY + }); + self.subject_access_log_optional = false; + self + } + + /// Whether this catalog includes the subject access-log storage. + #[must_use] + pub(crate) fn includes_subject_access_log(&self) -> bool { + self.objects + .iter() + .any(|object| object.name == SUBJECT_ACCESS_LOG_TABLE) + } + /// Explicit compatibility inventory for the W2 feasibility kernel. #[must_use] pub fn kernel() -> Self { @@ -149,6 +185,19 @@ impl ExpectedManagedCatalog { #[must_use] pub fn compiled(registry: &CompiledRegistry) -> Self { let mut catalog = Self::base(); + catalog.table( + SUBJECT_ACCESS_LOG_TABLE, + ["SELECT", "INSERT"], + std::iter::empty::<&str>(), + Some((false, false)), + ); + catalog.function(SUBJECT_ACCESS_LOG_EXPIRY, Some("EXECUTE"), None); + // A Registry that collects a subject access log is never served + // without the storage it writes to. + catalog.subject_access_log_optional = registry + .entities() + .values() + .all(|entity| entity.access_log.is_none()); if registry.ddl().requires_postgis { catalog.grant_schema_spatial_bbox("registry_data"); catalog.grant_schema_spatial_bbox("registry_context"); @@ -361,6 +410,7 @@ impl ExpectedManagedCatalog { objects: BTreeSet::new(), column_privileges: BTreeSet::new(), policies: BTreeSet::new(), + subject_access_log_optional: false, }; for schema in MANAGED_SCHEMAS { catalog.schema(schema); @@ -1124,6 +1174,43 @@ pub async fn verify_catalog_identity( .await } +/// Resolves the exact catalog `client`'s database is checked against. +/// +/// Threat: a release that adds subject access-log storage would otherwise +/// refuse every database an earlier release activated, before any apply could +/// install it, and an apply of the active package cannot install it without +/// changing the fingerprint that package records. Enforcement: only a catalog +/// whose Registry collects no subject access log, and only while the +/// access-log table is absent, drops the access-log table and function; every +/// other check stays exact, so a partial install still fails the ownership +/// check and the recorded fingerprint still has to match. +pub(crate) async fn installed_managed_catalog<'a>( + client: &impl GenericClient, + expected_catalog: &'a ExpectedManagedCatalog, +) -> Result> { + if !expected_catalog.subject_access_log_optional { + return Ok(Cow::Borrowed(expected_catalog)); + } + let installed: bool = client + .query_one( + "SELECT EXISTS ( + SELECT 1 FROM pg_catalog.pg_class AS class + JOIN pg_catalog.pg_namespace AS namespace + ON namespace.oid = class.relnamespace + WHERE namespace.nspname = 'registry_internal' + AND class.relname = 'registry_subject_access_log' + )", + &[], + ) + .await? + .get(0); + Ok(if installed { + Cow::Borrowed(expected_catalog) + } else { + Cow::Owned(expected_catalog.clone().without_subject_access_log()) + }) +} + pub(crate) async fn verify_managed_catalog( client: &impl GenericClient, expected: &ExpectedRegistryIdentity, @@ -1131,6 +1218,7 @@ pub(crate) async fn verify_managed_catalog( migration_role: &SqlIdentifier, runtime_role: &SqlIdentifier, ) -> Result<()> { + let expected_catalog = &*installed_managed_catalog(client, expected_catalog).await?; verify_managed_owners_for_catalog(client, migration_role, runtime_role, expected_catalog) .await?; verify_closed_ambient_catalog(client).await?; @@ -1198,7 +1286,7 @@ async fn verify_closed_ambient_catalog(client: &impl GenericClient) -> Result<() JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = ANY($1::text[]) AND NOT ( - n.nspname = 'registry_context' + (n.nspname = 'registry_context' AND ((p.proname IN ('evaluation_date', 'spatial_bbox_geometry') AND pg_catalog.pg_get_function_identity_arguments(p.oid) = '') OR (p.proname ~ '^membership_[0-9a-f]{24}$' @@ -1212,7 +1300,15 @@ async fn verify_closed_ambient_catalog(client: &impl GenericClient) -> Result<() AND p.prorettype = 'uuid'::regtype AND p.proretset AND NOT p.prosecdef - AND p.provolatile = 's')) + AND p.provolatile = 's'))) + OR (n.nspname = 'registry_internal' + AND p.proname = 'expire_subject_access_log' + AND pg_catalog.pg_get_function_identity_arguments(p.oid) = '' + AND p.prorettype = 'bigint'::regtype + AND NOT p.proretset AND p.prosecdef + AND p.provolatile = 'v' + AND p.prolang = (SELECT oid FROM pg_catalog.pg_language WHERE lanname = 'sql') + AND p.proconfig = ARRAY['search_path=pg_catalog, registry_internal']) ) ), EXISTS ( @@ -1547,6 +1643,7 @@ pub(crate) async fn runtime_grants_missing( runtime_role: &SqlIdentifier, expected_catalog: &ExpectedManagedCatalog, ) -> Result { + let expected_catalog = &*installed_managed_catalog(client, expected_catalog).await?; let held: BTreeSet<(String, String, String)> = query_categorized_acl(client, runtime_role) .await? .into_iter() @@ -1701,6 +1798,7 @@ pub async fn managed_schema_fingerprint( runtime_role: &SqlIdentifier, expected_catalog: &ExpectedManagedCatalog, ) -> Result { + let expected_catalog = &*installed_managed_catalog(client, expected_catalog).await?; let migration_role = current_role(client).await?; verify_managed_owners_for_catalog(client, &migration_role, runtime_role, expected_catalog) .await?; diff --git a/crates/registry-breg/src/postgres/context.rs b/crates/registry-breg/src/postgres/context.rs index 2425b48615..f587e27983 100644 --- a/crates/registry-breg/src/postgres/context.rs +++ b/crates/registry-breg/src/postgres/context.rs @@ -3210,6 +3210,7 @@ mod tests { change_control: None, change_request: None, consent_record: None, + access_log: None, fields: vec![ FieldSource { pattern: None, diff --git a/crates/registry-breg/src/postgres/history_read.rs b/crates/registry-breg/src/postgres/history_read.rs index 5ef7304798..2cef86b16e 100644 --- a/crates/registry-breg/src/postgres/history_read.rs +++ b/crates/registry-breg/src/postgres/history_read.rs @@ -398,6 +398,18 @@ impl PostgresSnapshotReadService { ) }) .collect::, _>>()?; + crate::subject_access_log::record_reads( + transaction, + &plan.entity, + &request.entity_id, + &request.context, + &rows.iter().map(|row| row.id.clone()).collect::>(), + &request.operation_id, + &self.expected, + &request.correlation, + &self.audit, + ) + .await?; guarded .commit() .await diff --git a/crates/registry-breg/src/postgres/mod.rs b/crates/registry-breg/src/postgres/mod.rs index 2cc121f111..179ee301c9 100644 --- a/crates/registry-breg/src/postgres/mod.rs +++ b/crates/registry-breg/src/postgres/mod.rs @@ -33,7 +33,9 @@ pub use catalog::{ verify_catalog_identity, verify_catalog_identity_for_catalog, CatalogIdentity, ExpectedManagedCatalog, ExpectedRegistryIdentity, }; -pub(crate) use catalog::{registry_state_shape, runtime_grants_missing, RegistryStateShape}; +pub(crate) use catalog::{ + installed_managed_catalog, registry_state_shape, runtime_grants_missing, RegistryStateShape, +}; pub(crate) use config::MAX_POOL_TIMEOUT; pub use config::{set_application_name, ConnectionConfig, PoolBounds, RuntimePool, TlsPolicy}; pub(crate) use context::{ diff --git a/crates/registry-breg/src/postgres/read.rs b/crates/registry-breg/src/postgres/read.rs index 7c382b4b86..b1d652ce9f 100644 --- a/crates/registry-breg/src/postgres/read.rs +++ b/crates/registry-breg/src/postgres/read.rs @@ -2,6 +2,8 @@ //! Concrete PostgreSQL record read service with durable audit release gates. +#[path = "access_log.rs"] +mod access_log; #[path = "request_read.rs"] mod request_read; @@ -393,6 +395,23 @@ impl PostgresRecordReadService { Ok(None) => (TerminalAuditOutcome::Empty, 0, None), Err(_) => (TerminalAuditOutcome::Refused, 0, None), }; + if count > 0 { + let record_id = target_record(&request.kind) + .ok_or(ReadServiceError::Unavailable)? + .to_owned(); + crate::subject_access_log::record_reads( + transaction.transaction(), + &plan.entity, + &request.entity_id, + &request.context, + &[record_id], + &request.operation_id, + &self.expected, + &request.correlation, + &self.audit, + ) + .await?; + } let terminal = self.terminal(&request, &claims, &plan, outcome, count, revision)?; let entry = crate::audit::attachment_terminal_entry( self.audit.profile(), @@ -879,6 +898,18 @@ impl PostgresRecordReadService { } } } + crate::subject_access_log::record_reads( + transaction.transaction(), + &plan.entity, + &request.entity_id, + &request.context, + &rows.iter().map(|row| row.id.clone()).collect::>(), + &request.operation_id, + &self.expected, + &request.correlation, + &self.audit, + ) + .await?; transaction .commit() .await @@ -1000,6 +1031,15 @@ fn summarize_query_plan_for_test(plan: &Value, nodes: &mut Vec) { } impl RecordReadService for PostgresRecordReadService { + fn access_log( + &self, + request: RecordReadRequest, + cursor: Option, + limit: u16, + ) -> ServiceFuture<'_, Result, ReadServiceError>> { + Box::pin(self.read_access_log(request, cursor, limit)) + } + fn get( &self, request: RecordReadRequest, diff --git a/crates/registry-breg/src/postgres/revision_read.rs b/crates/registry-breg/src/postgres/revision_read.rs index a55a24b53a..b4a0632eea 100644 --- a/crates/registry-breg/src/postgres/revision_read.rs +++ b/crates/registry-breg/src/postgres/revision_read.rs @@ -279,6 +279,20 @@ impl PostgresRevisionReadService { &mut context_visibility, ) .await?; + if !rows.is_empty() { + crate::subject_access_log::record_reads( + transaction.transaction(), + &plan.entity, + &request.entity_id, + &request.context, + std::slice::from_ref(&request.record_id), + &request.operation_id, + &self.expected, + &request.correlation, + &self.audit, + ) + .await?; + } transaction .commit() .await diff --git a/crates/registry-breg/src/startup.rs b/crates/registry-breg/src/startup.rs index f4dc8c758d..726da4cb62 100644 --- a/crates/registry-breg/src/startup.rs +++ b/crates/registry-breg/src/startup.rs @@ -514,6 +514,12 @@ impl VerifiedStartup { &self.expected_catalog } + /// Whether the database holds the subject access-log storage. A database + /// an earlier release activated gains it at its next apply. + pub fn subject_access_log_installed(&self) -> bool { + self.expected_catalog.includes_subject_access_log() + } + pub fn lock_key(&self) -> RegistryLockKey { self.lock_key } @@ -528,6 +534,7 @@ pub struct PreparedServer { webhook_worker: Option, attachment_verification_worker: Option, review_worker: Option, + access_log_retention_pool: Option, metrics: Option, #[cfg(feature = "wasm")] wasm_runtime: Option, @@ -598,6 +605,7 @@ impl PreparedServer { webhook_worker: None, attachment_verification_worker: None, review_worker: None, + access_log_retention_pool: None, metrics: None, postgres_advisories: Vec::new(), role_mode: RoleMode::Split, @@ -624,6 +632,7 @@ impl PreparedServer { webhook_worker: Some(webhook_worker), attachment_verification_worker: None, review_worker: None, + access_log_retention_pool: None, metrics: None, postgres_advisories: Vec::new(), role_mode: RoleMode::Split, @@ -1130,6 +1139,9 @@ async fn finish_prepared_server( // Captured before `pool` moves into the mutation service, so the metrics // listener can sample live pool gauges at scrape time. let telemetry_pool = pool.clone(); + // Retention runs only where the storage exists; a Registry that collects + // a subject access log is never verified without it. + let access_log_retention_pool = startup.subject_access_log_installed().then(|| pool.clone()); let event_destinations = Arc::new( config .activate_event_destinations(®istry) @@ -1409,6 +1421,7 @@ async fn finish_prepared_server( webhook_worker, attachment_verification_worker, review_worker, + access_log_retention_pool, metrics, postgres_advisories, role_mode: RoleMode::from_roles( @@ -1617,6 +1630,7 @@ pub async fn serve_until_shutdown( webhook_worker, attachment_verification_worker, review_worker, + access_log_retention_pool, metrics, #[cfg(feature = "wasm")] wasm_runtime: _wasm_runtime, @@ -1642,6 +1656,12 @@ pub async fn serve_until_shutdown( .map(|worker| tokio::spawn(worker.run(worker_shutdown_rx.clone()))); let mut review_worker = review_worker.map(|worker| tokio::spawn(worker.run(worker_shutdown_rx.clone()))); + let mut access_log_worker = access_log_retention_pool.map(|pool| { + tokio::spawn(crate::subject_access_log::run_retention( + pool, + worker_shutdown_rx.clone(), + )) + }); let mut worker = webhook_worker.map(|worker| tokio::spawn(worker.run(worker_shutdown_rx))); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); let (metrics_shutdown_tx, metrics_shutdown_rx) = oneshot::channel::<()>(); @@ -1687,6 +1707,9 @@ pub async fn serve_until_shutdown( if let Some(worker) = review_worker.as_mut() { let _ = worker.await; } + if let Some(worker) = access_log_worker.as_mut() { + let _ = worker.await; + } if let Some(metrics_server) = metrics_server.as_mut() { let _ = metrics_server.await; } @@ -1715,6 +1738,10 @@ pub async fn serve_until_shutdown( worker.abort(); let _ = worker.await; } + if let Some(worker) = access_log_worker.as_mut() { + worker.abort(); + let _ = worker.await; + } Err(StartupError::Shutdown) } }; @@ -1874,6 +1901,11 @@ async fn verify_opened_startup( crate::postgres::RegistryStateShape::Ledger => {} } verify_configured_runtime_role(&transaction, migration_role, runtime_role).await?; + let expected_catalog = + crate::postgres::installed_managed_catalog(&transaction, &expected_catalog) + .await + .map_err(|_| StartupError::DatabaseUnready)? + .into_owned(); // A separate runtime role missing its grants may not even read the state // the checks below read, so the grants are checked first. verify_runtime_grants( diff --git a/crates/registry-breg/src/subject_access_log.rs b/crates/registry-breg/src/subject_access_log.rs new file mode 100644 index 0000000000..b8367f4cc4 --- /dev/null +++ b/crates/registry-breg/src/subject_access_log.rs @@ -0,0 +1,378 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Registry-local, expiring access history, separate from the operational audit. + +use axum::http::HeaderMap; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_platform_audit::AuditEntry; +use serde_json::json; +use tokio_postgres::{types::Type, GenericClient}; + +use crate::api::{AuthorizedRequestContext, ReadServiceError}; +use crate::audit::RegistryAudit; +use crate::correlation::RequestCorrelation; +use crate::model::CompiledEntity; +use crate::postgres::{ExpectedRegistryIdentity, RuntimePool, RuntimeRevoke, SqlIdentifier}; + +pub(crate) const REQUESTER_HEADER: &str = "registry-access-requester"; +pub(crate) const PURPOSE_HEADER: &str = "registry-access-purpose"; +const MAX_VALUE_BYTES: usize = 512; +/// Rows one `expire_subject_access_log()` call erases at most. +const EXPIRY_BATCH_ROWS: i64 = 1_000; +/// Batches one retention tick erases before it yields to the next tick, so a +/// backlog drains without one tick running unbounded. +const MAX_EXPIRY_BATCHES_PER_TICK: u32 = 100; + +/// Provenance only. It never enters a claim context, row policy, or purpose grant. +#[derive(Clone, Eq, PartialEq)] +pub(crate) struct ForwardedAccessAttribution { + pub requester: String, + pub purpose: String, +} + +impl std::fmt::Debug for ForwardedAccessAttribution { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("") + } +} + +/// Both headers are host-authored, canonical base64url UTF-8. Only an explicitly +/// named, verified OAuth client may attest another requester's access purpose. +pub(crate) fn forwarded_attribution( + entity: &CompiledEntity, + context: &AuthorizedRequestContext, + headers: &HeaderMap, +) -> Result, ()> { + if !headers.contains_key(REQUESTER_HEADER) && !headers.contains_key(PURPOSE_HEADER) { + return Ok(None); + } + let policy = entity.access_log.as_ref().ok_or(())?; + let client = context.requester_client().ok_or(())?; + if !policy.trusted_intermediaries.contains(client) { + return Err(()); + } + Ok(Some(ForwardedAccessAttribution { + requester: decode_header(headers, REQUESTER_HEADER)?, + purpose: decode_header(headers, PURPOSE_HEADER)?, + })) +} + +fn decode_header(headers: &HeaderMap, name: &str) -> Result { + let mut values = headers.get_all(name).iter(); + let encoded = values.next().ok_or(())?.to_str().map_err(|_| ())?; + if values.next().is_some() || encoded.len() > 684 { + return Err(()); + } + let bytes = URL_SAFE_NO_PAD.decode(encoded).map_err(|_| ())?; + if URL_SAFE_NO_PAD.encode(&bytes) != encoded { + return Err(()); + } + let value = String::from_utf8(bytes).map_err(|_| ())?; + if !valid_value(&value) { + return Err(()); + } + Ok(value) +} + +fn valid_value(value: &str) -> bool { + !value.trim().is_empty() + && value.len() <= MAX_VALUE_BYTES + && !value.chars().any(char::is_control) +} + +pub(crate) async fn install( + migration: &impl GenericClient, + runtime_role: &SqlIdentifier, +) -> Result<(), tokio_postgres::Error> { + let revoke = RuntimeRevoke::detect(migration, runtime_role) + .await? + .with_public(); + migration + .batch_execute(&format!( + "CREATE TABLE IF NOT EXISTS registry_internal.registry_subject_access_log ( + event_id uuid PRIMARY KEY, + entity_id text NOT NULL, + record_id uuid NOT NULL, + requester text NOT NULL CHECK (octet_length(requester) BETWEEN 1 AND 512), + service_client text CHECK (octet_length(service_client) BETWEEN 1 AND 512), + purpose text CHECK (octet_length(purpose) BETWEEN 1 AND 512), + operation_id text NOT NULL, + authority_entity text NOT NULL, + access_profile text NOT NULL, + package_revision text NOT NULL, + request_id uuid NOT NULL, + accessed_at timestamptz NOT NULL DEFAULT transaction_timestamp(), + visible_after timestamptz NOT NULL, + expires_at timestamptz NOT NULL, + exemption_reason text CHECK (octet_length(exemption_reason) BETWEEN 1 AND 256), + CHECK (accessed_at <= visible_after AND visible_after < expires_at) + ); + CREATE INDEX IF NOT EXISTS registry_subject_access_log_record + ON registry_internal.registry_subject_access_log (entity_id, record_id, accessed_at DESC, event_id DESC); + CREATE INDEX IF NOT EXISTS registry_subject_access_log_expiry + ON registry_internal.registry_subject_access_log (expires_at); + REVOKE ALL ON registry_internal.registry_subject_access_log FROM {revoke}; + GRANT SELECT, INSERT ON registry_internal.registry_subject_access_log TO {role}; + CREATE OR REPLACE FUNCTION registry_internal.expire_subject_access_log() + RETURNS bigint LANGUAGE sql SECURITY DEFINER + SET search_path = pg_catalog, registry_internal AS $body$ + WITH expired AS ( + SELECT event_id FROM registry_internal.registry_subject_access_log + WHERE expires_at <= transaction_timestamp() + ORDER BY expires_at LIMIT {EXPIRY_BATCH_ROWS} FOR UPDATE SKIP LOCKED + ), removed AS ( + DELETE FROM registry_internal.registry_subject_access_log AS entry + USING expired WHERE entry.event_id = expired.event_id RETURNING 1 + ) SELECT count(*) FROM removed; + $body$; + REVOKE ALL ON FUNCTION registry_internal.expire_subject_access_log() FROM {revoke}; + GRANT EXECUTE ON FUNCTION registry_internal.expire_subject_access_log() TO {role};", + role = runtime_role.quoted(), + )) + .await +} + +/// Called inside the authorized source transaction, after paging has discarded +/// its lookahead row. Failure refuses the complete read, never just the entry. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn record_reads( + transaction: &impl GenericClient, + entity: &CompiledEntity, + authority_entity: &str, + context: &AuthorizedRequestContext, + record_ids: &[String], + operation_id: &str, + identity: &ExpectedRegistryIdentity, + correlation: &RequestCorrelation, + audit: &RegistryAudit, +) -> Result<(), ReadServiceError> { + let Some(policy) = &entity.access_log else { + return Ok(()); + }; + if record_ids.is_empty() { + return Ok(()); + } + let forwarded = context.forwarded_access_attribution(); + let requester = forwarded + .map(|value| value.requester.as_str()) + .or(context.requester_client()) + .or(context.principal()) + .filter(|value| valid_value(value)) + .ok_or(ReadServiceError::Unavailable)?; + let purpose = forwarded + .map(|value| value.purpose.as_str()) + .or(context.purpose()); + let service_client = forwarded.and(context.requester_client()); + let exemption = policy + .exemptions + .get(context.selected_profile()) + .filter(|exemption| { + exemption.source_entity.as_deref().unwrap_or(&entity.id) == authority_entity + }); + let delay = i32::from(exemption.map_or(0, |value| value.delay_days)); + let retention = i32::from(policy.retention_days); + let reason = exemption.map(|value| value.reason.as_str()); + // The separate exemption audit identifies the governed policy by a keyed + // reference. Neither its text, the subject, nor the purpose enters audit. + if let Some(reason) = reason { + let reference = audit + .profile() + .key_hasher() + .audit_reference_hash( + "breg-access-log-exemption-v1", + &identity.activation_id, + reason, + ) + .map_err(|_| ReadServiceError::Unavailable)?; + let correlation_id = format!("{}:access-log", correlation.request_id()); + let _attempt = audit + .begin( + AuditEntry::request( + "breg-access-log/v1", + &correlation_id, + json!({"operation":"visibility-delay", "entityId":entity.id, + "authorityEntity":authority_entity, + "packageRevision":identity.activation_id, + "selectedAccessProfile":context.selected_profile(), + "exemptionReference":reference,"delayDays":delay}), + ), + json!({"outcome":"unfinished"}), + ) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + audit + .append(AuditEntry::response( + "breg-access-log/v1", + correlation_id, + json!({"outcome":"authorized"}), + )) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + } + let event_ids = record_ids + .iter() + .map(|_| uuid::Uuid::new_v4().to_string()) + .collect::>(); + let request_id = correlation.request_id().to_string(); + // A page has one bounded insert, rather than one database round trip per + // subject. The parallel arrays retain a distinct event for every read hit. + transaction + .execute_typed( + "INSERT INTO registry_internal.registry_subject_access_log + (event_id, entity_id, record_id, requester, service_client, purpose, + operation_id, access_profile, package_revision, request_id, + authority_entity, + visible_after, expires_at, exemption_reason) + SELECT hit.event_id::uuid, $2, hit.record_id::uuid, $4, $5, $6, $7, $8, $9, + $10::text::uuid, $14, transaction_timestamp() + make_interval(days => $11), + transaction_timestamp() + make_interval(days => $12), $13 + FROM unnest($1::text[], $3::text[]) AS hit(event_id, record_id)", + &[ + (&event_ids, Type::TEXT_ARRAY), + (&entity.id, Type::TEXT), + (&record_ids, Type::TEXT_ARRAY), + (&requester, Type::TEXT), + (&service_client, Type::TEXT), + (&purpose, Type::TEXT), + (&operation_id, Type::TEXT), + (&context.selected_profile(), Type::TEXT), + (&identity.activation_id, Type::TEXT), + (&request_id, Type::TEXT), + (&delay, Type::INT4), + (&retention, Type::INT4), + (&reason, Type::TEXT), + (&authority_entity, Type::TEXT), + ], + ) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + Ok(()) +} + +/// Runs even when the active package stops collecting entries. Expired entries +/// are hidden immediately; bounded erasure keeps old purpose values out of the +/// live database without granting the runtime arbitrary DELETE authority. +pub(crate) async fn run_retention( + pool: RuntimePool, + mut shutdown: tokio::sync::watch::Receiver, +) { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + result = shutdown.changed() => { + if result.is_err() || *shutdown.borrow() { break; } + } + _ = interval.tick() => { + if let Ok(client) = pool.get().await { + match expire_tick(&**client, MAX_EXPIRY_BATCHES_PER_TICK).await { + Ok(tick) => tracing::debug!( + erased = tick.erased, + bound_reached = tick.bound_reached, + "subject access log expiry tick finished" + ), + Err(_) => tracing::error!("subject access log expiry failed"), + } + } else { + tracing::error!("subject access log expiry could not acquire a connection"); + } + } + } + } +} + +struct ExpiryTick { + erased: i64, + bound_reached: bool, +} + +/// Erases expired entries batch by batch until a batch comes back short, or +/// until `max_batches` batches ran. Each batch commits on its own, so a long +/// drain never holds one large transaction. Reaching the bound logs the +/// remaining backlog, counted up to one further tick's worth of rows. +async fn expire_tick( + client: &impl GenericClient, + max_batches: u32, +) -> Result { + let mut erased = 0; + for _ in 0..max_batches { + let removed: i64 = client + .query_one("SELECT registry_internal.expire_subject_access_log()", &[]) + .await? + .get(0); + erased += removed; + if removed < EXPIRY_BATCH_ROWS { + return Ok(ExpiryTick { + erased, + bound_reached: false, + }); + } + } + let cap = i64::from(MAX_EXPIRY_BATCHES_PER_TICK) * EXPIRY_BATCH_ROWS; + let remaining: i64 = client + .query_one( + "SELECT count(*) FROM ( + SELECT 1 FROM registry_internal.registry_subject_access_log + WHERE expires_at <= transaction_timestamp() LIMIT $1 + ) AS backlog", + &[&cap], + ) + .await? + .get(0); + tracing::warn!( + erased, + remaining_expired = remaining, + remaining_count_capped = remaining >= cap, + "subject access log expiry reached its per-tick bound; expired entries remain" + ); + Ok(ExpiryTick { + erased, + bound_reached: true, + }) +} + +/// Runs one retention tick, bounded by `max_batches` when given. +#[cfg(feature = "postgres-test")] +#[doc(hidden)] +pub async fn expire_subject_access_log_for_test( + pool: &RuntimePool, + max_batches: Option, +) -> Result<(i64, bool), tokio_postgres::Error> { + let client = pool.get_for_test().await.expect("test pool connection"); + let tick = expire_tick( + &**client, + max_batches.unwrap_or(MAX_EXPIRY_BATCHES_PER_TICK), + ) + .await?; + Ok((tick.erased, tick.bound_reached)) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + + #[test] + fn attribution_headers_refuse_duplicates_controls_oversize_and_noncanonical_encoding() { + let mut headers = HeaderMap::new(); + for bad in ["", "a=", "!", "YQ=="] { + headers.insert(REQUESTER_HEADER, HeaderValue::from_str(bad).unwrap()); + assert!(decode_header(&headers, REQUESTER_HEADER).is_err()); + } + for bad in ["bad\nvalue".to_owned(), " ".to_owned(), "x".repeat(513)] { + headers.insert( + REQUESTER_HEADER, + HeaderValue::from_str(&URL_SAFE_NO_PAD.encode(bad)).unwrap(), + ); + assert!(decode_header(&headers, REQUESTER_HEADER).is_err()); + } + let good = HeaderValue::from_str(&URL_SAFE_NO_PAD.encode("agence-éducation")).unwrap(); + headers.insert(REQUESTER_HEADER, good.clone()); + assert_eq!( + decode_header(&headers, REQUESTER_HEADER).unwrap(), + "agence-éducation" + ); + headers.append(REQUESTER_HEADER, good); + assert!(decode_header(&headers, REQUESTER_HEADER).is_err()); + } +} diff --git a/crates/registry-breg/tests/access_log.rs b/crates/registry-breg/tests/access_log.rs new file mode 100644 index 0000000000..b82ab4101f --- /dev/null +++ b/crates/registry-breg/tests/access_log.rs @@ -0,0 +1,401 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Compiler contract for the governed subject-facing access log. + +use registry_breg::{compile_project, parse_project_json, CompileFailure, CompileProfile}; +use registry_platform_canonical_json::parse_json_strict; +use serde_json::{json, Value}; + +fn source() -> Value { + json!({ + "apiVersion": "registry.registrystack.org/v1alpha1", + "kind": "RegistryProject", + "registry": { + "id": "access-log-registry", + "version": "1", + "defaultLanguage": "en", + "canonicalBaseIri": "https://registry.example.test/access-log" + }, + "entities": [{ + "id": "person", + "primaryDataset": "people", + "route": "people", + "mutationMode": "mutable", + "classification": "restricted", + "fields": [ + { + "id": "citizen-id", + "type": "string", + "minLength": 1, + "maxLength": 128, + "required": true, + "classification": "restricted" + }, + { + "id": "display-name", + "type": "string", + "maxLength": 200, + "required": true, + "classification": "restricted" + } + ], + "accessLog": { + "subjectField": "citizen-id", + "trustedIntermediaries": ["evidence-service"], + "exemptions": { + "investigator": { + "reason": "active-investigation", + "delayDays": 7 + } + } + } + }], + "accessProfiles": [ + { + "id": "subject", + "default": true, + "principalClaim": "registry_principal", + "permissions": [{ + "entity": "person", + "operations": ["get", "list"], + "readableFields": ["citizen-id", "display-name"], + "rowBoundaries": [] + }] + }, + { + "id": "investigator", + "principalClaim": "registry_principal", + "permissions": [{ + "entity": "person", + "operations": ["get"], + "readableFields": ["display-name"], + "rowBoundaries": [] + }] + }, + { + "id": "writer", + "principalClaim": "registry_principal", + "permissions": [{ + "entity": "person", + "operations": ["create"], + "writableFields": ["citizen-id", "display-name"], + "rowBoundaries": [] + }] + } + ] + }) +} + +fn compile(value: &Value) -> Result { + let project = parse_project_json(&serde_json::to_vec(value).unwrap()).expect("source parses"); + compile_project(&project, &[], CompileProfile::Authoring) +} + +fn refusal_codes(value: &Value) -> Vec { + compile(value) + .expect_err("source must be refused") + .diagnostics() + .iter() + .map(|diagnostic| diagnostic.code.clone()) + .collect() +} + +fn assert_refused(value: &Value, code: &str) { + let codes = refusal_codes(value); + assert!( + codes.iter().any(|candidate| candidate == code), + "expected {code}, got {codes:?}" + ); +} + +fn add_relationship_reader(value: &mut Value, anonymous: bool) { + value["entities"] + .as_array_mut() + .unwrap() + .extend([json!({ + "id": "case", + "primaryDataset": "people", + "route": "cases", + "mutationMode": "mutable", + "classification": "restricted", + "fields": [{ + "id": "case-code", "type": "string", "maxLength": 64, + "required": true, "classification": "restricted" + }], + "readPaths": [{ + "id": "people", "through": "case-person", "to": "person", "route": "people" + }] + }), json!({ + "id": "case-person", + "primaryDataset": "people", + "route": "case-people", + "mutationMode": "mutable", + "classification": "restricted", + "fields": [ + {"id": "case", "type": "reference", "target": "case", "required": true, "classification": "restricted"}, + {"id": "person", "type": "reference", "target": "person", "required": true, "classification": "restricted"} + ] + })]); + let mut profile = json!({ + "id": "relationship-investigator", + "anonymous": anonymous, + "principalClaim": "registry_principal", + "permissions": [{ + "entity": "case", + "operations": ["get"], + "readableFields": ["case-code"], + "rowBoundaries": [], + "readPaths": [{"path": "people", "readableFields": ["display-name"]}] + }] + }); + if anonymous { + profile.as_object_mut().unwrap().remove("principalClaim"); + } + value["accessProfiles"] + .as_array_mut() + .unwrap() + .push(profile); +} + +#[test] +fn access_log_compiles_governed_defaults_and_policy() { + let compiled = compile(&source()).expect("access-log policy compiles"); + let policy = compiled.entities()["person"] + .access_log + .as_ref() + .expect("compiled entity carries access-log policy"); + assert_eq!(policy.subject_field, "citizen-id"); + assert_eq!(policy.retention_days, 90); + assert_eq!( + policy.trusted_intermediaries, + ["evidence-service".to_owned()].into() + ); + assert_eq!(policy.exemptions["investigator"].delay_days, 7); + + let effective = serde_json::to_value(&compiled.entities()["person"]).unwrap(); + assert_eq!(effective["accessLog"]["retentionDays"], 90); + assert_eq!( + effective["accessLog"]["exemptions"]["investigator"]["reason"], + "active-investigation" + ); +} + +#[test] +fn generated_openapi_publishes_the_subject_owned_bounded_route() { + let compiled = compile(&source()).expect("access-log policy compiles"); + let artifact = compiled + .artifacts() + .get("generated/openapi.json") + .expect("OpenAPI is generated"); + let openapi = parse_json_strict(&artifact.bytes).expect("OpenAPI is strict JSON"); + let operation = &openapi["paths"]["/v1/records/people/{record_id}/access-log"]["get"]; + assert_eq!(operation["operationId"], "records.person.get.access-log"); + assert_eq!(operation["x-registry-operation"], "access_log"); + assert_eq!( + operation["x-registry-responseShape"], + "BRegSubjectAccessLogV1" + ); + assert_eq!(operation["security"], json!([{"bearerAuth": []}])); + assert_eq!( + operation["x-registry-accessProfiles"], + json!(["investigator", "subject"]) + ); + assert!(operation["description"] + .as_str() + .unwrap() + .contains("subject field must exactly match the verified principal")); + let parameters = operation["parameters"].as_array().unwrap(); + let limit = parameters + .iter() + .find(|parameter| parameter["name"] == "limit") + .unwrap(); + assert_eq!( + limit["schema"], + json!({"type":"integer", "minimum":1, "maximum":100, "default":50}) + ); + let cursor = parameters + .iter() + .find(|parameter| parameter["name"] == "cursor") + .unwrap(); + assert_eq!(cursor["schema"], json!({"type":"string", "format":"uuid"})); + assert_eq!( + operation["responses"]["200"]["headers"]["Cache-Control"]["schema"]["const"], + "no-store" + ); + assert_eq!( + operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/SubjectAccessLogPage" + ); + assert!(operation["responses"]["404"].is_object()); + let schema = &openapi["components"]["schemas"]["SubjectAccessLogPage"]; + assert_eq!(schema["properties"]["events"]["maxItems"], 100); + assert_eq!( + schema["properties"]["events"]["items"]["required"], + json!([ + "id", + "accessedAt", + "requester", + "serviceClient", + "purpose", + "operationId", + "visibleAfter", + "exemptionReason" + ]) + ); + assert_eq!( + schema["properties"]["events"]["items"]["properties"]["purpose"]["type"], + json!(["string", "null"]) + ); +} + +#[test] +fn generated_openapi_omits_access_log_route_without_entity_opt_in() { + let mut value = source(); + value["entities"][0] + .as_object_mut() + .unwrap() + .remove("accessLog"); + let compiled = compile(&value).expect("ordinary entity compiles"); + let artifact = compiled.artifacts().get("generated/openapi.json").unwrap(); + let openapi = parse_json_strict(&artifact.bytes).unwrap(); + assert!(openapi["paths"] + .get("/v1/records/people/{record_id}/access-log") + .is_none()); + assert!(openapi["components"]["schemas"] + .get("SubjectAccessLogPage") + .is_none()); +} + +#[test] +fn subject_field_must_be_required_bounded_plaintext_text() { + let mut unknown = source(); + unknown["entities"][0]["accessLog"]["subjectField"] = json!("unknown"); + assert_refused(&unknown, "access_log.subject_field.invalid"); + + let mut optional = source(); + optional["entities"][0]["fields"][0]["required"] = json!(false); + assert_refused(&optional, "access_log.subject_field.invalid"); + + let mut encrypted = source(); + encrypted["entities"][0]["fields"][0]["encrypted"] = json!(true); + assert_refused(&encrypted, "access_log.subject_field.invalid"); + + let mut wrong_type = source(); + wrong_type["entities"][0]["fields"][0] = json!({ + "id": "citizen-id", "type": "int64", "required": true, + "classification": "restricted" + }); + assert_refused(&wrong_type, "access_log.subject_field.invalid"); + + let mut too_long = source(); + too_long["entities"][0]["fields"][0]["maxLength"] = json!(513); + assert_refused(&too_long, "access_log.subject_field.invalid"); +} + +#[test] +fn retention_and_exemption_delays_are_bounded() { + for invalid in [0, 3_651] { + let mut value = source(); + value["entities"][0]["accessLog"]["retentionDays"] = json!(invalid); + assert_refused(&value, "access_log.retention_days.invalid"); + } + for invalid in [0, 90] { + let mut value = source(); + value["entities"][0]["accessLog"]["exemptions"]["investigator"]["delayDays"] = + json!(invalid); + assert_refused(&value, "access_log.exemption.delay_invalid"); + } +} + +#[test] +fn trusted_intermediaries_are_explicit_and_bounded() { + for invalid in ["", "evidence service", "evidence\nservice"] { + let mut value = source(); + value["entities"][0]["accessLog"]["trustedIntermediaries"] = json!([invalid]); + assert_refused(&value, "access_log.trusted_intermediary.invalid"); + } + + let mut too_many = source(); + too_many["entities"][0]["accessLog"]["trustedIntermediaries"] = json!((0..65) + .map(|index| format!("client-{index}")) + .collect::>()); + assert_refused(&too_many, "access_log.trusted_intermediaries.too_many"); +} + +#[test] +fn exemptions_name_read_profiles_and_bounded_policy_text() { + let mut unknown = source(); + let exemption = unknown["entities"][0]["accessLog"]["exemptions"] + .as_object_mut() + .unwrap() + .remove("investigator") + .unwrap(); + unknown["entities"][0]["accessLog"]["exemptions"]["missing"] = exemption; + assert_refused(&unknown, "access_log.exemption.profile_invalid"); + + let mut nonreader = source(); + let exemption = nonreader["entities"][0]["accessLog"]["exemptions"] + .as_object_mut() + .unwrap() + .remove("investigator") + .unwrap(); + nonreader["entities"][0]["accessLog"]["exemptions"]["writer"] = exemption; + assert_refused(&nonreader, "access_log.exemption.profile_invalid"); + + for invalid in ["", " padded", "line\nbreak"] { + let mut value = source(); + value["entities"][0]["accessLog"]["exemptions"]["investigator"]["reason"] = json!(invalid); + assert_refused(&value, "access_log.exemption.reason_invalid"); + } + + let mut too_long = source(); + too_long["entities"][0]["accessLog"]["exemptions"]["investigator"]["reason"] = + json!("x".repeat(257)); + assert_refused(&too_long, "access_log.exemption.reason_invalid"); +} + +#[test] +fn relationship_exemptions_bind_the_authority_entity_and_read_path() { + let mut value = source(); + add_relationship_reader(&mut value, false); + value["entities"][0]["accessLog"]["exemptions"]["relationship-investigator"] = json!({ + "sourceEntity": "case", + "reason": "active-investigation", + "delayDays": 7 + }); + let compiled = compile(&value).expect("relationship exemption compiles"); + let exemption = &compiled.entities()["person"] + .access_log + .as_ref() + .unwrap() + .exemptions["relationship-investigator"]; + assert_eq!(exemption.source_entity.as_deref(), Some("case")); + + let mut no_grant = value.clone(); + no_grant["accessProfiles"][3]["permissions"][0]["readPaths"] = json!([]); + assert_refused(&no_grant, "access_log.exemption.profile_invalid"); + + let mut wrong_target = value.clone(); + wrong_target["entities"][1]["readPaths"][0]["to"] = json!("case"); + assert_refused(&wrong_target, "access_log.exemption.profile_invalid"); + + let mut unknown_source = value; + unknown_source["entities"][0]["accessLog"]["exemptions"]["relationship-investigator"] + ["sourceEntity"] = json!("missing"); + assert_refused(&unknown_source, "access_log.exemption.profile_invalid"); +} + +#[test] +fn anonymous_profiles_cannot_read_logged_entities() { + let mut value = source(); + value["accessProfiles"][0]["anonymous"] = json!(true); + value["accessProfiles"][0] + .as_object_mut() + .unwrap() + .remove("principalClaim"); + assert_refused(&value, "access_log.anonymous_read_forbidden"); + + let mut relationship = source(); + add_relationship_reader(&mut relationship, true); + assert_refused(&relationship, "access_log.anonymous_read_forbidden"); +} diff --git a/crates/registry-breg/tests/postgres_access_log.rs b/crates/registry-breg/tests/postgres_access_log.rs new file mode 100644 index 0000000000..f06d18fae3 --- /dev/null +++ b/crates/registry-breg/tests/postgres_access_log.rs @@ -0,0 +1,1047 @@ +// SPDX-License-Identifier: Apache-2.0 +#![cfg(feature = "postgres-test")] + +#[path = "support/postgres_harness.rs"] +#[allow(dead_code)] +mod postgres_harness; + +use axum::{ + body::{to_bytes, Body}, + http::{Method, Request, StatusCode}, + Router, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use postgres_harness::TestDatabase; +use registry_breg::api::{ + authenticated_router, HttpService, ReadRuntimeIdentity, ReadinessProbe, ServiceFuture, +}; +use registry_breg::auth::{AuthorityClaimConfig, RegistryAuthenticator}; +use registry_breg::cursor::CursorCodec; +use registry_breg::postgres::{ + initialize_compiled_registry_state_for_test, install_compiled_schema, + PostgresRecordMutationService, PostgresRecordReadService, PostgresRevisionReadService, + PostgresSnapshotReadService, RegistryLockKey, RegistryStateTestIdentity, +}; +use registry_breg::{compile_project, parse_project_json, CompileProfile, CompiledRegistry}; +use registry_platform_audit::AuditProfile; +use registry_platform_httputil::FetchUrlPolicy; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig}; +use registry_platform_testing::{oidc_verifier_config, MockIdp}; +use serde_json::{json, Value}; +use std::{sync::Arc, time::Duration}; +use tower::ServiceExt; +use zeroize::Zeroizing; + +const RECORD_A: &str = "00000000-0000-4000-8000-000000000001"; +const RECORD_B: &str = "00000000-0000-4000-8000-000000000002"; +const CASE_A: &str = "00000000-0000-4000-8000-000000000003"; +const CASE_PERSON_A: &str = "00000000-0000-4000-8000-000000000004"; +const SUBJECT: &str = "subject-private-canary"; +const PURPOSE: &str = "service-purpose-canary"; +const PACKAGE: &str = "subject-access-test"; +const AUDIENCE: &str = "urn:breg:subject-access-test"; + +struct Ready; +impl ReadinessProbe for Ready { + fn is_ready(&self) -> ServiceFuture<'_, bool> { + Box::pin(async { true }) + } +} + +fn compiled() -> CompiledRegistry { + let permission = json!({"entity":"entry", "operations":["get","list","lookup"], + "readableFields":["label"], "lookups":[{"selector":"by-subject","valueOrigin":"request"}], + "rowBoundaries":[]}); + let source = json!({ + "apiVersion":"registry.registrystack.org/v1alpha1", "kind":"RegistryProject", + "registry":{"id":PACKAGE,"version":"1","defaultLanguage":"en","canonicalBaseIri":"https://registry.example.test"}, + "entities":[{"id":"entry","primaryDataset":"records","route":"entries","mutationMode":"mutable","classification":"restricted", + "fields":[{"id":"subject","type":"string","minLength":1,"maxLength":128,"required":true,"classification":"restricted"}, + {"id":"label","type":"string","maxLength":128,"required":true,"classification":"restricted"}], + "selectorProfiles":[{"id":"by-subject","fields":["subject"]}], + "accessLog":{"subjectField":"subject","trustedIntermediaries":["evidence-service"], + "exemptions":{"investigator":{"reason":"investigation-policy-canary","delayDays":7}}}}], + "accessProfiles":[ + {"id":"reader","default":true,"principalClaim":"sub","requiredPurposes":[PURPOSE],"permissions":[permission.clone()]}, + {"id":"investigator","principalClaim":"sub","requiredPurposes":[PURPOSE],"actorKind":"service","requesterClients":["agency"],"permissions":[permission]} + ] + }); + compile_project( + &parse_project_json(&serde_json::to_vec(&source).unwrap()).unwrap(), + &[], + CompileProfile::Authoring, + ) + .unwrap() +} + +fn compiled_relationships() -> CompiledRegistry { + let source = json!({ + "apiVersion":"registry.registrystack.org/v1alpha1", "kind":"RegistryProject", + "registry":{"id":PACKAGE,"version":"1","defaultLanguage":"en","canonicalBaseIri":"https://registry.example.test"}, + "entities":[ + {"id":"case","primaryDataset":"records","route":"cases","mutationMode":"mutable","classification":"restricted", + "fields":[{"id":"case-code","type":"string","maxLength":64,"required":true,"classification":"restricted"}], + "readPaths":[{"id":"people","through":"case-person","to":"person","route":"people"}]}, + {"id":"case-person","primaryDataset":"records","route":"case-people","mutationMode":"mutable","classification":"restricted", + "fields":[ + {"id":"case","type":"reference","target":"case","required":true,"classification":"restricted"}, + {"id":"person","type":"reference","target":"person","required":true,"classification":"restricted"}]}, + {"id":"person","primaryDataset":"records","route":"people","mutationMode":"mutable","classification":"restricted", + "fields":[ + {"id":"subject","type":"string","minLength":1,"maxLength":128,"required":true,"classification":"restricted"}, + {"id":"label","type":"string","maxLength":128,"required":true,"classification":"restricted"}], + "accessLog":{"subjectField":"subject","exemptions":{ + "investigator":{"reason":"target-local-policy-canary","delayDays":7}, + "relationship-investigator":{"sourceEntity":"case","reason":"relationship-policy-canary","delayDays":7}}}} + ], + "accessProfiles":[ + {"id":"reader","default":true,"principalClaim":"sub","requiredPurposes":[PURPOSE],"permissions":[ + {"entity":"person","operations":["get"],"readableFields":["label"],"rowBoundaries":[]}]}, + {"id":"investigator","principalClaim":"sub","requiredPurposes":[PURPOSE],"actorKind":"service","requesterClients":["agency"],"permissions":[ + {"entity":"case","operations":["get"],"readableFields":["case-code"],"rowBoundaries":[], + "readPaths":[{"path":"people","readableFields":["label"]}]}, + {"entity":"person","operations":["get"],"readableFields":["label"],"rowBoundaries":[]}]}, + {"id":"relationship-investigator","principalClaim":"sub","requiredPurposes":[PURPOSE],"actorKind":"service","requesterClients":["agency"],"permissions":[ + {"entity":"case","operations":["get"],"readableFields":["case-code"],"rowBoundaries":[], + "readPaths":[{"path":"people","readableFields":["label"]}]}]} + ] + }); + compile_project( + &parse_project_json(&serde_json::to_vec(&source).unwrap()).unwrap(), + &[], + CompileProfile::Authoring, + ) + .unwrap() +} + +async fn setup_relationships() -> (TestDatabase, Router, MockIdp) { + let database = TestDatabase::create(2).await; + let registry = Arc::new(compiled_relationships()); + let (migration, task) = database.connect_migration().await; + install_compiled_schema(&migration, ®istry, &database.runtime_role) + .await + .unwrap(); + let identity = initialize_compiled_registry_state_for_test( + &migration, + &database.runtime_role, + ®istry, + RegistryStateTestIdentity { + package_id: PACKAGE, + database_id: "relationship-access-database", + label: "relationship-access-package-1", + }, + ) + .await + .unwrap(); + let person = ®istry.entities()["person"]; + database + .admin + .execute( + &format!( + "INSERT INTO registry_data.\"{}\" + (record_id,record_revision,record_lifecycle,active_package_revision,\"{}\",\"{}\") + VALUES ($1::text::uuid,1,'active',$4,$2,$3)", + person.physical_table, + person.fields["subject"].physical_name, + person.fields["label"].physical_name, + ), + &[&RECORD_A, &SUBJECT, &"protected-label", &identity.activation_id], + ) + .await + .unwrap(); + let case = ®istry.entities()["case"]; + database + .admin + .execute( + &format!( + "INSERT INTO registry_data.\"{}\" + (record_id,record_revision,record_lifecycle,active_package_revision,\"{}\") + VALUES ($1::text::uuid,1,'active',$3,$2)", + case.physical_table, case.fields["case-code"].physical_name, + ), + &[&CASE_A, &"CASE-1763", &identity.activation_id], + ) + .await + .unwrap(); + let link = ®istry.entities()["case-person"]; + database + .admin + .execute( + &format!( + "INSERT INTO registry_data.\"{}\" + (record_id,record_revision,record_lifecycle,active_package_revision,\"{}\",\"{}\") + VALUES ($1::text::uuid,1,'active',$4,$2::text::uuid,$3::text::uuid)", + link.physical_table, + link.fields["case"].physical_name, + link.fields["person"].physical_name, + ), + &[&CASE_PERSON_A, &CASE_A, &RECORD_A, &identity.activation_id], + ) + .await + .unwrap(); + drop(migration); + task.abort(); + + let idp = MockIdp::start().await; + let keys = Arc::new(JwksFetcher::new_with_fetch_url_policy( + idp.jwks_uri(), + JwksFetcherConfig::defaults(), + FetchUrlPolicy::dev(), + )); + let mut verifier = oidc_verifier_config(idp.issuer(), vec![AUDIENCE.to_owned()]); + verifier.allowed_clients = vec!["portal".into(), "agency".into()]; + let auth = Arc::new( + RegistryAuthenticator::new( + ®istry, + verifier, + keys, + AuthorityClaimConfig::new("sub", Some("registry_purpose".into())), + ) + .unwrap(), + ); + let cursors = Arc::new( + CursorCodec::new(Zeroizing::new(vec![0x63; 32]), Duration::from_secs(300)).unwrap(), + ); + let reads = PostgresRecordReadService::new( + database.runtime_config.build_pool().unwrap(), + registry.clone(), + identity.clone(), + RegistryLockKey::derive(PACKAGE).unwrap(), + Duration::from_secs(2), + database.audit(AuditProfile::production_from_secret_bytes(vec![0x52; 32].into()).unwrap()), + cursors.clone(), + ); + let service = HttpService::new( + registry, + ReadRuntimeIdentity { + package_revision: identity.activation_id, + schema_fingerprint: identity.schema_fingerprint, + }, + Arc::new(reads), + Arc::new(Ready), + cursors, + ); + (database, authenticated_router(Arc::new(service), auth), idp) +} + +async fn setup() -> (TestDatabase, Router, MockIdp) { + let database = TestDatabase::create(2).await; + let registry = Arc::new(compiled()); + let (migration, task) = database.connect_migration().await; + install_compiled_schema(&migration, ®istry, &database.runtime_role) + .await + .unwrap(); + let identity = initialize_compiled_registry_state_for_test( + &migration, + &database.runtime_role, + ®istry, + RegistryStateTestIdentity { + package_id: PACKAGE, + database_id: "access-database", + label: "access-package-1", + }, + ) + .await + .unwrap(); + let entity = ®istry.entities()["entry"]; + for (id, subject) in [(RECORD_A, SUBJECT), (RECORD_B, "other-subject")] { + database.admin.execute(&format!("INSERT INTO registry_data.\"{}\" (record_id,record_revision,record_lifecycle,active_package_revision,\"{}\",\"{}\") VALUES ($1::text::uuid,1,'active',$3,$2,'protected-label')", + entity.physical_table, entity.fields["subject"].physical_name, entity.fields["label"].physical_name), &[&id,&subject,&identity.activation_id]).await.unwrap(); + } + drop(migration); + task.abort(); + let idp = MockIdp::start().await; + let keys = Arc::new(JwksFetcher::new_with_fetch_url_policy( + idp.jwks_uri(), + JwksFetcherConfig::defaults(), + FetchUrlPolicy::dev(), + )); + let mut verifier = oidc_verifier_config(idp.issuer(), vec![AUDIENCE.to_owned()]); + verifier.allowed_clients = vec!["portal".into(), "agency".into(), "evidence-service".into()]; + let auth = Arc::new( + RegistryAuthenticator::new( + ®istry, + verifier, + keys, + AuthorityClaimConfig::new("sub", Some("registry_purpose".into())), + ) + .unwrap(), + ); + let cursors = Arc::new( + CursorCodec::new(Zeroizing::new(vec![0x63; 32]), Duration::from_secs(300)).unwrap(), + ); + let reads = PostgresRecordReadService::new( + database.runtime_config.build_pool().unwrap(), + registry.clone(), + identity.clone(), + RegistryLockKey::derive(PACKAGE).unwrap(), + Duration::from_secs(2), + database.audit(AuditProfile::production_from_secret_bytes(vec![0x52; 32].into()).unwrap()), + cursors.clone(), + ); + let service = HttpService::new( + registry, + ReadRuntimeIdentity { + package_revision: identity.activation_id, + schema_fingerprint: identity.schema_fingerprint, + }, + Arc::new(reads), + Arc::new(Ready), + cursors, + ); + (database, authenticated_router(Arc::new(service), auth), idp) +} + +fn token(idp: &MockIdp, client: &str, subject: &str) -> String { + idp.mint_token(json!({"aud":AUDIENCE,"sub":subject,"client_id":client,"scope":"registry.read","registry_actor_kind":"service","registry_purpose":PURPOSE})) +} + +async fn send( + app: &Router, + token: &str, + method: Method, + uri: &str, + body: Option, + attribution: Option<(&str, &str)>, +) -> (StatusCode, Value) { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header("authorization", format!("Bearer {token}")); + if let Some((requester, purpose)) = attribution { + request = request + .header( + "Registry-Access-Requester", + URL_SAFE_NO_PAD.encode(requester), + ) + .header("Registry-Access-Purpose", URL_SAFE_NO_PAD.encode(purpose)); + } + let body = if let Some(body) = body { + request = request.header("content-type", "application/json"); + Body::from(serde_json::to_vec(&body).unwrap()) + } else { + Body::empty() + }; + let response = app + .clone() + .oneshot(request.body(body).unwrap()) + .await + .unwrap(); + let status = response.status(); + if status == StatusCode::OK { + assert_eq!(response.headers()["cache-control"], "no-store"); + } + let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + (status, serde_json::from_slice(&bytes).unwrap()) +} + +async fn history(app: &Router, token: &str, record: &str, query: &str) -> (StatusCode, Value) { + send( + app, + token, + Method::GET, + &format!("/v1/records/entries/{record}/access-log{query}"), + None, + None, + ) + .await +} + +#[tokio::test] +async fn subject_access_log_requires_current_record_ownership_and_records_each_read_hit() { + let (db, app, idp) = setup().await; + let agency = token(&idp, "agency", "officer"); + let owner = token(&idp, "portal", SUBJECT); + assert_eq!( + send( + &app, + &agency, + Method::GET, + &format!("/v1/records/entries/{RECORD_A}"), + None, + None + ) + .await + .0, + StatusCode::OK + ); + assert_eq!( + send( + &app, + &agency, + Method::GET, + "/v1/records/entries?$top=1", + None, + None + ) + .await + .0, + StatusCode::OK + ); + assert_eq!( + send( + &app, + &agency, + Method::POST, + "/v1/records/entries:lookup", + Some(json!({"selector":"by-subject","values":{"subject":SUBJECT}})), + None + ) + .await + .0, + StatusCode::OK + ); + let (status, page) = history(&app, &owner, RECORD_A, "?limit=2").await; + assert_eq!(status, StatusCode::OK, "{page}"); + assert_eq!(page["events"].as_array().unwrap().len(), 2); + for event in page["events"].as_array().unwrap() { + assert_eq!(event["requester"], "agency"); + assert_eq!(event["purpose"], PURPOSE); + assert!(event["serviceClient"].is_null()); + } + let next = page["nextCursor"].as_str().unwrap(); + let (_, second) = history(&app, &owner, RECORD_A, &format!("?cursor={next}")).await; + assert_eq!(second["events"].as_array().unwrap().len(), 1); + let (_, again) = history(&app, &owner, RECORD_A, "").await; + assert_eq!( + again["events"].as_array().unwrap().len(), + 3, + "reading the history does not recursively log a record read" + ); + assert_eq!( + history(&app, &agency, RECORD_A, "").await.0, + StatusCode::NOT_FOUND + ); + assert_eq!( + history(&app, &owner, RECORD_B, "").await.0, + StatusCode::NOT_FOUND + ); + let (_, other) = history(&app, &token(&idp, "portal", "other-subject"), RECORD_B, "").await; + assert_eq!( + other["events"].as_array().unwrap().len(), + 0, + "list lookahead never becomes a hit" + ); + assert_eq!( + send( + &app, + &agency, + Method::GET, + "/v1/records/entries?$top=2", + None, + None + ) + .await + .0, + StatusCode::OK + ); + let (_, first_subject) = history(&app, &owner, RECORD_A, "").await; + let (_, second_subject) = + history(&app, &token(&idp, "portal", "other-subject"), RECORD_B, "").await; + assert_eq!(first_subject["events"].as_array().unwrap().len(), 4); + assert_eq!( + second_subject["events"].as_array().unwrap().len(), + 1, + "a returned page records a separate access for each subject" + ); + let audit = db + .audit_entries() + .into_iter() + .map(|entry| entry.to_string()) + .collect::(); + for secret in [SUBJECT, PURPOSE, "officer", RECORD_A, "protected-label"] { + assert!(!audit.contains(secret)); + } + db.assert_every_audit_request_answered_once(); + drop(app); + drop(idp); + db.cleanup().await; +} + +#[tokio::test] +async fn forwarded_access_attribution_requires_a_verified_trusted_client_and_never_grants_subject_access( +) { + let (db, app, idp) = setup().await; + let route = format!("/v1/records/entries/{RECORD_A}"); + let forwarding = ("requesting-agency-éducation", "requesting-purpose-canary"); + let agency = token(&idp, "agency", "officer"); + let intermediary = token(&idp, "evidence-service", "source-account"); + assert_eq!( + send(&app, &agency, Method::GET, &route, None, Some(forwarding)) + .await + .0, + StatusCode::NOT_FOUND + ); + assert_eq!( + send( + &app, + &intermediary, + Method::POST, + "/v1/records/entries:lookup", + Some(json!({"selector":"by-subject","values":{"subject":SUBJECT}})), + Some(forwarding) + ) + .await + .0, + StatusCode::OK + ); + assert_eq!( + history(&app, &intermediary, RECORD_A, "").await.0, + StatusCode::NOT_FOUND + ); + assert_eq!( + send( + &app, + &intermediary, + Method::GET, + &format!("{route}/access-log"), + None, + Some((SUBJECT, PURPOSE)) + ) + .await + .0, + StatusCode::NOT_FOUND + ); + let (_, log) = history(&app, &token(&idp, "portal", SUBJECT), RECORD_A, "").await; + let entries = log["events"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["requester"], forwarding.0); + assert_eq!(entries[0]["purpose"], forwarding.1); + assert_eq!(entries[0]["serviceClient"], "evidence-service"); + let audit = db + .audit_entries() + .into_iter() + .map(|entry| entry.to_string()) + .collect::(); + assert!(!audit.contains(forwarding.0)); + assert!(!audit.contains(forwarding.1)); + drop(app); + drop(idp); + db.cleanup().await; +} + +#[tokio::test] +async fn access_log_exemptions_are_delayed_audited_and_expire_without_runtime_delete_authority() { + let (db, app, idp) = setup().await; + let agency = token(&idp, "agency", "investigator"); + let owner = token(&idp, "portal", SUBJECT); + assert_eq!( + send( + &app, + &agency, + Method::GET, + &format!("/v1/records/entries/{RECORD_A}?accessProfile=investigator"), + None, + None + ) + .await + .0, + StatusCode::OK + ); + let (_, hidden) = history(&app, &owner, RECORD_A, "").await; + assert_eq!(hidden["events"].as_array().unwrap().len(), 0); + assert!(db + .audit_entries() + .iter() + .any(|entry| entry["schema"] == "breg-access-log/v1" + && entry["record"]["operation"] == "visibility-delay")); + db.admin.execute("UPDATE registry_internal.registry_subject_access_log SET accessed_at=accessed_at-interval '8 days',visible_after=visible_after-interval '8 days',expires_at=expires_at-interval '8 days'",&[]).await.unwrap(); + let (_, visible) = history(&app, &owner, RECORD_A, "").await; + assert_eq!( + visible["events"][0]["exemptionReason"], + "investigation-policy-canary" + ); + db.admin.execute("UPDATE registry_internal.registry_subject_access_log SET accessed_at=accessed_at-interval '90 days',visible_after=visible_after-interval '90 days',expires_at=expires_at-interval '90 days'",&[]).await.unwrap(); + let (_, expired) = history(&app, &owner, RECORD_A, "").await; + assert_eq!(expired["events"].as_array().unwrap().len(), 0); + let pool = db.runtime_config.build_pool().unwrap(); + let client = pool.get_for_test().await.unwrap(); + assert!(client + .execute( + "DELETE FROM registry_internal.registry_subject_access_log", + &[] + ) + .await + .is_err()); + assert!(client + .execute( + "UPDATE registry_internal.registry_subject_access_log SET purpose='spoof'", + &[] + ) + .await + .is_err()); + let removed: i64 = client + .query_one("SELECT registry_internal.expire_subject_access_log()", &[]) + .await + .unwrap() + .get(0); + assert_eq!(removed, 1); + db.assert_every_audit_request_answered_once(); + drop(client); + drop(pool); + drop(app); + drop(idp); + db.cleanup().await; +} + +#[tokio::test] +async fn relationship_exemptions_bind_the_source_entity_without_profile_name_collisions() { + let (db, app, idp) = setup_relationships().await; + let agency = token(&idp, "agency", "officer"); + let owner = token(&idp, "portal", SUBJECT); + let relationship = format!("/v1/records/cases/{CASE_A}/people"); + + let (status, same_name) = send( + &app, + &agency, + Method::GET, + &format!("{relationship}?accessProfile=investigator"), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{same_name}"); + assert_eq!(same_name["items"].as_array().map(Vec::len), Some(1)); + + let (status, source_scoped) = send( + &app, + &agency, + Method::GET, + &format!("{relationship}?accessProfile=relationship-investigator"), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{source_scoped}"); + assert_eq!(source_scoped["items"].as_array().map(Vec::len), Some(1)); + + let rows = db + .admin + .query( + "SELECT access_profile, authority_entity, exemption_reason, + visible_after = accessed_at, visible_after > transaction_timestamp() + FROM registry_internal.registry_subject_access_log + WHERE entity_id='person' AND record_id=$1::text::uuid + ORDER BY access_profile", + &[&RECORD_A], + ) + .await + .unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].get::<_, String>(0), "investigator"); + assert_eq!(rows[0].get::<_, String>(1), "case"); + assert!(rows[0].get::<_, Option>(2).is_none()); + assert!(rows[0].get::<_, bool>(3)); + assert_eq!(rows[1].get::<_, String>(0), "relationship-investigator"); + assert_eq!(rows[1].get::<_, String>(1), "case"); + assert_eq!( + rows[1].get::<_, Option>(2).as_deref(), + Some("relationship-policy-canary") + ); + assert!(!rows[1].get::<_, bool>(3)); + assert!(rows[1].get::<_, bool>(4)); + + let (status, before_delay) = send( + &app, + &owner, + Method::GET, + &format!("/v1/records/people/{RECORD_A}/access-log?accessProfile=reader"), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{before_delay}"); + let events = before_delay["events"].as_array().unwrap(); + assert_eq!(events.len(), 1, "the source-scoped exemption stays hidden"); + assert!(events[0]["exemptionReason"].is_null()); + + let exemption_audits = db + .audit_entries() + .into_iter() + .filter(|entry| { + entry["schema"] == "breg-access-log/v1" + && entry["phase"] == "request" + && entry["record"]["operation"] == "visibility-delay" + }) + .collect::>(); + assert_eq!(exemption_audits.len(), 1); + assert_eq!(exemption_audits[0]["record"]["entityId"], "person"); + assert_eq!(exemption_audits[0]["record"]["authorityEntity"], "case"); + assert_eq!( + exemption_audits[0]["record"]["selectedAccessProfile"], + "relationship-investigator" + ); + assert!(!exemption_audits[0] + .to_string() + .contains("relationship-policy-canary")); + + db.admin + .execute( + "UPDATE registry_internal.registry_subject_access_log + SET accessed_at=accessed_at-interval '8 days', + visible_after=visible_after-interval '8 days', + expires_at=expires_at-interval '8 days' + WHERE access_profile='relationship-investigator'", + &[], + ) + .await + .unwrap(); + let (_, after_delay) = send( + &app, + &owner, + Method::GET, + &format!("/v1/records/people/{RECORD_A}/access-log?accessProfile=reader"), + None, + None, + ) + .await; + let events = after_delay["events"].as_array().unwrap(); + assert_eq!(events.len(), 2); + assert!(events + .iter() + .any(|event| event["exemptionReason"] == "relationship-policy-canary")); + + db.assert_every_audit_request_answered_once(); + drop(app); + drop(idp); + db.cleanup().await; +} + +#[tokio::test] +async fn a_failed_subject_access_log_insert_prevents_record_release() { + let (db, app, idp) = setup().await; + db.admin + .batch_execute(&format!( + "REVOKE INSERT ON registry_internal.registry_subject_access_log FROM \"{}\"", + db.runtime_role.as_str() + )) + .await + .unwrap(); + let (status, body) = send( + &app, + &token(&idp, "agency", "officer"), + Method::GET, + &format!("/v1/records/entries/{RECORD_A}"), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert!(!body.to_string().contains("protected-label")); + let count: i64 = db + .admin + .query_one( + "SELECT count(*) FROM registry_internal.registry_subject_access_log", + &[], + ) + .await + .unwrap() + .get(0); + assert_eq!(count, 0); + drop(app); + drop(idp); + db.cleanup().await; +} + +async fn seed_access_log_rows(db: &TestDatabase, rows: i32, expired: bool) { + let expires_at = if expired { + "transaction_timestamp() - interval '1 day'" + } else { + "transaction_timestamp() + interval '1 day'" + }; + db.admin + .execute( + &format!( + "INSERT INTO registry_internal.registry_subject_access_log + (event_id, entity_id, record_id, requester, operation_id, authority_entity, + access_profile, package_revision, request_id, accessed_at, visible_after, + expires_at) + SELECT gen_random_uuid(), 'entry', $1::text::uuid, 'agency', 'seeded-read', + 'entry', 'reader', 'seeded-package', gen_random_uuid(), + transaction_timestamp() - interval '100 days', + transaction_timestamp() - interval '100 days', {expires_at} + FROM generate_series(1, $2)" + ), + &[&RECORD_A, &rows], + ) + .await + .unwrap(); +} + +async fn access_log_rows(db: &TestDatabase) -> i64 { + db.admin + .query_one( + "SELECT count(*) FROM registry_internal.registry_subject_access_log", + &[], + ) + .await + .unwrap() + .get(0) +} + +#[tokio::test] +async fn one_retention_tick_erases_every_expired_batch_and_reports_a_bounded_backlog() { + let (db, app, idp) = setup().await; + // More expired rows than one erasure batch, plus a live row that must stay. + seed_access_log_rows(&db, 2_500, true).await; + seed_access_log_rows(&db, 1, false).await; + let pool = db.runtime_config.build_pool().unwrap(); + + let (erased, bound_reached) = registry_breg::expire_subject_access_log_for_test(&pool, Some(1)) + .await + .unwrap(); + assert_eq!(erased, 1_000); + assert!( + bound_reached, + "a tick that stops at its bound reports the backlog" + ); + assert_eq!(access_log_rows(&db).await, 1_501); + + let (erased, bound_reached) = registry_breg::expire_subject_access_log_for_test(&pool, None) + .await + .unwrap(); + assert_eq!(erased, 1_500, "one tick keeps erasing until a short batch"); + assert!(!bound_reached); + assert_eq!(access_log_rows(&db).await, 1, "the live entry is retained"); + + drop(pool); + drop(app); + drop(idp); + db.cleanup().await; +} + +fn compiled_with_history() -> CompiledRegistry { + let source = json!({ + "apiVersion":"registry.registrystack.org/v1alpha1", "kind":"RegistryProject", + "registry":{"id":PACKAGE,"version":"1","defaultLanguage":"en","canonicalBaseIri":"https://registry.example.test"}, + "entities":[{"id":"entry","primaryDataset":"records","route":"entries","mutationMode":"mutable","classification":"restricted", + "fields":[{"id":"subject","type":"string","minLength":1,"maxLength":128,"required":true,"classification":"restricted"}, + {"id":"label","type":"string","maxLength":128,"required":true,"classification":"restricted"}], + "accessLog":{"subjectField":"subject"}}], + "accessProfiles":[ + {"id":"reader","default":true,"principalClaim":"sub","requiredPurposes":[PURPOSE],"permissions":[ + {"entity":"entry","operations":["get","snapshot","revisions"],"readableFields":["label"], + "revisionAccess":true,"rowBoundaries":[]}]}, + {"id":"steward","principalClaim":"sub","requiredPurposes":[PURPOSE],"permissions":[ + {"entity":"entry","operations":["create"],"readableFields":["subject","label"], + "writableFields":["subject","label"],"rowBoundaries":[]}]} + ] + }); + compile_project( + &parse_project_json(&serde_json::to_vec(&source).unwrap()).unwrap(), + &[], + CompileProfile::Authoring, + ) + .unwrap() +} + +/// Serves record, snapshot, revision, and mutation routes, so a record gets +/// its revision history through the ordinary create path. +async fn setup_with_history() -> (TestDatabase, Router, MockIdp, Arc) { + let database = TestDatabase::create(4).await; + let registry = Arc::new(compiled_with_history()); + let (migration, task) = database.connect_migration().await; + install_compiled_schema(&migration, ®istry, &database.runtime_role) + .await + .unwrap(); + let identity = initialize_compiled_registry_state_for_test( + &migration, + &database.runtime_role, + ®istry, + RegistryStateTestIdentity { + package_id: PACKAGE, + database_id: "history-access-database", + label: "history-access-package-1", + }, + ) + .await + .unwrap(); + drop(migration); + task.abort(); + let idp = MockIdp::start().await; + let keys = Arc::new(JwksFetcher::new_with_fetch_url_policy( + idp.jwks_uri(), + JwksFetcherConfig::defaults(), + FetchUrlPolicy::dev(), + )); + let mut verifier = oidc_verifier_config(idp.issuer(), vec![AUDIENCE.to_owned()]); + verifier.allowed_clients = vec!["portal".into(), "agency".into()]; + let auth = Arc::new( + RegistryAuthenticator::new( + ®istry, + verifier, + keys, + AuthorityClaimConfig::new("sub", Some("registry_purpose".into())), + ) + .unwrap(), + ); + let pool = database.runtime_config.build_pool().unwrap(); + let lock_key = RegistryLockKey::derive(PACKAGE).unwrap(); + let audit = + database.audit(AuditProfile::production_from_secret_bytes(vec![0x52; 32].into()).unwrap()); + let cursors = Arc::new( + CursorCodec::new(Zeroizing::new(vec![0x63; 32]), Duration::from_secs(300)).unwrap(), + ); + let records = PostgresRecordReadService::new( + pool.clone(), + registry.clone(), + identity.clone(), + lock_key, + Duration::from_secs(2), + audit.clone(), + cursors.clone(), + ); + let revisions = PostgresRevisionReadService::new( + pool.clone(), + registry.clone(), + identity.clone(), + lock_key, + Duration::from_secs(2), + audit.clone(), + ); + let snapshots = PostgresSnapshotReadService::new( + pool.clone(), + registry.clone(), + identity.clone(), + lock_key, + Duration::from_secs(2), + audit.clone(), + cursors.clone(), + ); + let mutations = PostgresRecordMutationService::new( + pool, + registry.clone(), + identity.clone(), + "history-access-instance", + lock_key, + Duration::from_secs(2), + audit, + ); + let service = HttpService::new( + registry.clone(), + ReadRuntimeIdentity { + package_revision: identity.activation_id, + schema_fingerprint: identity.schema_fingerprint, + }, + Arc::new(records), + Arc::new(Ready), + cursors, + ) + .with_postgres_revisions(Arc::new(revisions)) + .with_snapshots(Arc::new(snapshots)) + .with_postgres_mutations(Arc::new(mutations)); + ( + database, + authenticated_router(Arc::new(service), auth), + idp, + registry, + ) +} + +fn route_operation( + registry: &CompiledRegistry, + operation: registry_breg::contract::Operation, +) -> String { + registry + .routes() + .routes + .iter() + .find(|route| route.entity_id == "entry" && route.operation == operation) + .unwrap() + .id + .clone() +} + +async fn logged_operations(db: &TestDatabase, record: &str) -> Vec { + db.admin + .query( + "SELECT operation_id FROM registry_internal.registry_subject_access_log + WHERE entity_id='entry' AND record_id=$1::text::uuid ORDER BY accessed_at, operation_id", + &[&record], + ) + .await + .unwrap() + .into_iter() + .map(|row| row.get(0)) + .collect() +} + +#[tokio::test] +async fn snapshot_and_revision_reads_write_subject_access_log_entries() { + let (db, app, idp, registry) = setup_with_history().await; + let steward = token(&idp, "agency", "steward"); + let reader = token(&idp, "agency", "officer"); + let request = Request::builder() + .method(Method::POST) + .uri("/v1/records/entries?accessProfile=steward") + .header("authorization", format!("Bearer {steward}")) + .header("content-type", "application/json") + .header("idempotency-key", "history-access-create") + .body(Body::from( + serde_json::to_vec(&json!({"data":{"subject":SUBJECT,"label":"protected-label"}})) + .unwrap(), + )) + .unwrap(); + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + let created: Value = + serde_json::from_slice(&to_bytes(response.into_body(), 1024 * 1024).await.unwrap()) + .unwrap(); + let record = created["data"]["recordIdentifier"] + .as_str() + .unwrap() + .to_owned(); + assert!( + logged_operations(&db, &record).await.is_empty(), + "a write is not a logged read" + ); + + let (status, revisions) = send( + &app, + &reader, + Method::GET, + &format!("/v1/records/entries/{record}/revisions"), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{revisions}"); + assert_eq!(revisions["items"].as_array().map(Vec::len), Some(1)); + let revision_operation = + route_operation(®istry, registry_breg::contract::Operation::Revisions); + assert_eq!( + logged_operations(&db, &record).await, + [revision_operation.as_str()] + ); + + let (status, snapshot) = send( + &app, + &reader, + Method::GET, + "/v1/records/entries:snapshot", + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{snapshot}"); + assert_eq!(snapshot["items"].as_array().map(Vec::len), Some(1)); + let mut logged = logged_operations(&db, &record).await; + logged.sort(); + let mut expected = vec![ + revision_operation, + route_operation(®istry, registry_breg::contract::Operation::Snapshot), + ]; + expected.sort(); + assert_eq!(logged, expected); + + let (status, log) = history(&app, &token(&idp, "portal", SUBJECT), &record, "").await; + assert_eq!(status, StatusCode::OK, "{log}"); + let events = log["events"].as_array().unwrap(); + assert_eq!(events.len(), 2); + assert!(events.iter().all(|event| event["requester"] == "agency")); + + db.assert_every_audit_request_answered_once(); + drop(app); + drop(idp); + db.cleanup().await; +} diff --git a/crates/registry-breg/tests/postgres_change_requests.rs b/crates/registry-breg/tests/postgres_change_requests.rs index 004501c573..7aa0ed9a42 100644 --- a/crates/registry-breg/tests/postgres_change_requests.rs +++ b/crates/registry-breg/tests/postgres_change_requests.rs @@ -2171,6 +2171,124 @@ async fn retained_attachment_apply_access_checks_frozen_guard_row_boundaries() { database.cleanup().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn attachment_downloads_write_a_subject_access_log_entry() { + let database = TestDatabase::create(8).await; + let mut source = serde_json::to_value(attachment_project()).unwrap(); + assert_eq!(source["entities"][2]["id"], "correction-request"); + source["entities"][2]["accessLog"] = json!({"subjectField":"tenant"}); + let project = parse_project_json(&serde_json::to_vec(&source).unwrap()).unwrap(); + let registry = Arc::new(compile_project(&project, &[], CompileProfile::Authoring).unwrap()); + let get_route = registry + .routes() + .routes + .iter() + .find(|route| { + route.entity_id == "correction-request" + && route.operation == registry_breg::contract::Operation::Get + }) + .unwrap() + .id + .clone(); + let download_operation = format!("{get_route}.attachment.evidence.get"); + let identity = install_registry(&database, ®istry, PACKAGE_ID, false).await; + let app = router(change_request_service_with_attachment_storage( + &database, + registry, + identity, + PACKAGE_ID, + None, + None, + registry_breg::attachment_storage::AttachmentStorage::Database, + )); + let steward = claims("steward", "access-log-attachment-steward", None); + let submitter = claims("submitter", SUBMITTER, None); + let old_site = create_record( + &app, + "/v1/records/sites?accessProfile=steward", + steward.clone(), + "access-log-attachment-old-site", + json!({"tenant":TENANT,"name":"old"}), + ) + .await; + let new_site = create_record( + &app, + "/v1/records/sites?accessProfile=steward", + steward.clone(), + "access-log-attachment-new-site", + json!({"tenant":TENANT,"name":"new"}), + ) + .await; + let placement = create_record( + &app, + "/v1/records/placements?accessProfile=steward", + steward, + "access-log-attachment-placement", + json!({"tenant":TENANT,"site":old_site.id}), + ) + .await; + let draft = create_record(&app, "/v1/records/correction-requests?accessProfile=submitter", submitter.clone(), + "access-log-attachment-request", json!({"tenant":TENANT,"placement":placement.id,"proposedSite":new_site.id,"reason":"access log attachment"})).await; + let before = get_record( + &app, + &format!( + "/v1/records/correction-requests/{}?accessProfile=submitter", + draft.id + ), + submitter.clone(), + ) + .await; + let uploaded = send( + &app, + Method::PATCH, + &format!( + "/v1/records/correction-requests/{}/attachments/evidence?accessProfile=submitter", + draft.id + ), + Some(submitter.clone()), + &[ + ("content-type", "application/octet-stream"), + ("idempotency-key", "access-log-attachment-upload"), + ("if-match", &before.etag), + ], + b"access log attachment".to_vec(), + ) + .await; + assert_eq!(uploaded.status(), StatusCode::OK); + let logged_downloads = || async { + database + .admin + .query( + "SELECT record_id::text, requester FROM registry_internal.registry_subject_access_log + WHERE entity_id='correction-request' AND operation_id=$1", + &[&download_operation], + ) + .await + .unwrap() + .into_iter() + .map(|row| (row.get::<_, String>(0), row.get::<_, String>(1))) + .collect::>() + }; + assert!(logged_downloads().await.is_empty()); + + let downloaded = send( + &app, + Method::GET, + &format!("/v1/records/correction-requests/{}/attachments/evidence?proposalVersion=1&accessProfile=submitter", draft.id), + Some(submitter), + &[], + vec![], + ) + .await; + assert_eq!(downloaded.status(), StatusCode::OK); + assert_eq!( + logged_downloads().await, + [(draft.id.clone(), SUBMITTER.to_owned())], + "one download writes one entry naming the downloaded record and caller" + ); + database.cleanup().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[ignore = "requires disposable PostgreSQL and BREG_TEST_S3_ENDPOINT/BREG_TEST_S3_BUCKET"] async fn real_s3_http_attachments_preserve_proposals_and_complete_operator_erasure() { diff --git a/crates/registry-breg/tests/postgres_migration.rs b/crates/registry-breg/tests/postgres_migration.rs index 9b1897510d..745c9fd942 100644 --- a/crates/registry-breg/tests/postgres_migration.rs +++ b/crates/registry-breg/tests/postgres_migration.rs @@ -42,8 +42,8 @@ use registry_breg::package::{ }; use registry_breg::postgres::{ install_compiled_schema, managed_schema_fingerprint, rehearse_successor_migration, - ExpectedManagedCatalog, ExpectedRegistryIdentity, MigrationRehearsalError, PostgresFailure, - RehearsalOutcome, SuccessorMigrationRehearsal, + verify_catalog_identity_for_catalog, ExpectedManagedCatalog, ExpectedRegistryIdentity, + MigrationRehearsalError, PostgresFailure, RehearsalOutcome, SuccessorMigrationRehearsal, }; use registry_breg::CompiledRegistry; use registry_platform_audit::AuditProfile; @@ -72,6 +72,206 @@ journeys: expect: {outcome: success, status: 200, count: 0} "#; +/// An existing activation gains the product-owned access-log storage through +/// normal successor apply. Later disabling collection retains its rows and the +/// bounded expiry authority instead of treating the log as disposable schema. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn subject_access_log_upgrades_an_existing_catalog_and_survives_disabled_collection() { + let database = TestDatabase::create(1).await; + let (prior, fingerprint, active, _old_package) = + activate_before_subject_access_log(&database).await; + + let enabled = compile_variant(Variant::SubjectWithLog); + // The log policy changes the package contract, not its entity DDL; the + // fresh catalog fingerprint includes the new product-owned log objects. + let target_fingerprint = fingerprint; + let successor = publish_and_load( + prepare_package(build_request( + Variant::SubjectWithLog, + Some(&active.package_digest), + &target_fingerprint, + PackageMigrationPlanInput::Successor { + prior_registry: Box::new(prior.clone()), + }, + )) + .unwrap(), + local_context(), + ); + let enabled_active = apply( + &database, + &successor, + ApplyPrecondition::Successor { current: &active }, + ) + .await + .unwrap(); + database.admin.execute("INSERT INTO registry_internal.registry_subject_access_log + (event_id,entity_id,record_id,requester,operation_id,authority_entity,access_profile,package_revision,request_id,visible_after,expires_at) + VALUES ($1,'asset',$2,'requesting-service','records.asset.get','asset','reader',$3,$4,transaction_timestamp(),transaction_timestamp()+interval '1 day')", + &[&Uuid::new_v4(),&Uuid::new_v4(),&enabled_active.activation_id,&Uuid::new_v4()]).await.unwrap(); + + let disabled = publish_and_load( + prepare_package(build_request( + Variant::SubjectWithoutLog, + Some(&enabled_active.package_digest), + &target_fingerprint, + PackageMigrationPlanInput::Successor { + prior_registry: Box::new(enabled), + }, + )) + .unwrap(), + local_context(), + ); + let disabled_active = apply( + &database, + &disabled, + ApplyPrecondition::Successor { + current: &enabled_active, + }, + ) + .await + .unwrap(); + assert_ready_target(&database, &disabled_active).await; + let pool = database.runtime_config.build_pool().unwrap(); + let runtime = pool.get_for_test().await.unwrap(); + let retained: i64 = runtime + .query_one( + "SELECT count(*) FROM registry_internal.registry_subject_access_log", + &[], + ) + .await + .unwrap() + .get(0); + assert_eq!(retained, 1); + let removed: i64 = runtime + .query_one("SELECT registry_internal.expire_subject_access_log()", &[]) + .await + .unwrap() + .get(0); + assert_eq!( + removed, 0, + "disabling collection never erases unexpired entries" + ); + database.admin.batch_execute("UPDATE registry_internal.registry_subject_access_log SET accessed_at=accessed_at-interval '2 days',visible_after=visible_after-interval '2 days',expires_at=expires_at-interval '2 days'").await.unwrap(); + let removed: i64 = runtime + .query_one("SELECT registry_internal.expire_subject_access_log()", &[]) + .await + .unwrap() + .get(0); + assert_eq!( + removed, 1, + "the runtime retains only expired-entry erasure authority" + ); + drop(runtime); + drop(pool); + database.cleanup().await; +} + +/// A database an earlier release activated holds no subject access-log +/// storage until its next apply installs it. Until then the upgraded runtime +/// and operator tooling verify its active package as it stands, and applying +/// that package again is refused as already active rather than activated. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_catalog_from_before_the_subject_access_log_keeps_its_active_package_until_the_next_apply( +) { + let database = TestDatabase::create(1).await; + let (prior, _fingerprint, active, old_package) = + activate_before_subject_access_log(&database).await; + + let pool = database.runtime_config.build_pool().unwrap(); + let runtime = pool.get_for_test().await.unwrap(); + verify_catalog_identity_for_catalog( + &**runtime, + &active, + &ExpectedManagedCatalog::compiled(&prior), + &database.migration_role, + &database.runtime_role, + ) + .await + .expect("the upgraded catalog check accepts the storage-free catalog it inherits"); + // A registry that collects the log is never served without its storage. + verify_catalog_identity_for_catalog( + &**runtime, + &active, + &ExpectedManagedCatalog::compiled(&compile_variant(Variant::SubjectWithLog)), + &database.migration_role, + &database.runtime_role, + ) + .await + .expect_err("a collecting registry requires the access-log storage"); + drop(runtime); + drop(pool); + + assert_value_free( + apply( + &database, + &old_package, + ApplyPrecondition::RoleChange { current: &active }, + ) + .await + .err(), + MigrationError::AlreadyActive, + ); + assert_ready_target(&database, &active).await; + let installed: bool = database + .admin + .query_one( + "SELECT pg_catalog.to_regclass('registry_internal.registry_subject_access_log') IS NOT NULL", + &[], + ) + .await + .unwrap() + .get(0); + assert!(!installed, "only a new activation installs the storage"); + database.cleanup().await; +} + +/// Activate the storage-free variant, then reproduce the exact catalog and +/// recorded package binding a release before subject access-log storage +/// left behind. This fixture surgery stands in for the older binary; every +/// later step uses the maintained APIs. Returns the compiled registry, the +/// fingerprint of a fresh catalog, the active identity, and the active +/// package. +async fn activate_before_subject_access_log( + database: &TestDatabase, +) -> ( + CompiledRegistry, + String, + ExpectedRegistryIdentity, + VerifiedPackage, +) { + database + .admin + .batch_execute("CREATE EXTENSION btree_gist") + .await + .unwrap(); + let prior = compile_variant(Variant::SubjectWithoutLog); + let fingerprint = initial_fingerprint(database, &prior).await; + let initial = + prepare_and_load_initial_variant(Variant::SubjectWithoutLog, &prior, &fingerprint); + let mut active = apply(database, &initial, ApplyPrecondition::InitialActivation) + .await + .unwrap(); + + let (migration, task) = database.connect_migration().await; + migration.batch_execute("DROP FUNCTION registry_internal.expire_subject_access_log(); DROP TABLE registry_internal.registry_subject_access_log;").await.unwrap(); + let old_fingerprint = managed_schema_fingerprint( + &migration, + &database.runtime_role, + &ExpectedManagedCatalog::compiled(&prior).without_subject_access_log_for_test(), + ) + .await + .unwrap(); + let old_package = + prepare_and_load_initial_variant(Variant::SubjectWithoutLog, &prior, &old_fingerprint); + active.schema_fingerprint = old_fingerprint; + active.package_digest = old_package.package_digest().to_owned(); + migration.execute("UPDATE registry_internal.registry_state SET active_package_digest=$1, schema_fingerprint=$2 WHERE singleton", &[&active.package_digest,&active.schema_fingerprint]).await.unwrap(); + migration.execute("UPDATE registry_internal.registry_migrations SET package_digest=$1 WHERE activation_id=$2::text::uuid", &[&active.package_digest,&active.activation_id]).await.unwrap(); + drop(migration); + task.abort(); + (prior, fingerprint, active, old_package) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn real_postgres_backfill_and_destructive_recovery_are_bounded_resumable_and_activation_closed( ) { @@ -4730,6 +4930,8 @@ fn assert_flip_value_free(error: &MigrationError, canaries: &[String]) { #[derive(Clone, Copy)] enum Variant { Base, + SubjectWithoutLog, + SubjectWithLog, RankRequired, LegacyRemoved, BatchAddedRequired, @@ -5060,6 +5262,18 @@ fn project_bytes(digest: &str) -> Vec { } fn module_bytes(variant: Variant) -> Vec { + if matches!( + variant, + Variant::SubjectWithoutLog | Variant::SubjectWithLog + ) { + let mut source: serde_json::Value = + serde_json::from_slice(&module_bytes(Variant::Base)).unwrap(); + source["entities"][0]["fields"][0]["required"] = serde_json::json!(true); + if matches!(variant, Variant::SubjectWithLog) { + source["entities"][0]["accessLog"] = serde_json::json!({"subjectField":"code"}); + } + return serde_json::to_vec(&source).unwrap(); + } let rank_required = if matches!(variant, Variant::RankRequired | Variant::LegacyRemoved) { r#","required":true"# } else { diff --git a/crates/registry-breg/tests/postgres_spatial_read.rs b/crates/registry-breg/tests/postgres_spatial_read.rs index e5dde0259d..a40b3906a7 100644 --- a/crates/registry-breg/tests/postgres_spatial_read.rs +++ b/crates/registry-breg/tests/postgres_spatial_read.rs @@ -641,6 +641,41 @@ async fn real_postgres_spatial_bbox_reads_preserve_authority_and_geojson_audit() harness.cleanup().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn gis_item_reads_write_a_subject_access_log_entry_per_feature() { + let harness = SpatialHarness::create(compiled_spatial_registry_with_access_log()).await; + seed_spatial_rows(&harness).await; + let app = harness.router(None, cursor_codec()); + let response = send( + &app, + "/v1/gis/collections/service-site.map-reader/items?bbox=100.25,13.25,100.25,13.25&limit=20&f=json", + Some(claims(["zone-a"])), + None, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(feature_ids(&body_json(response).await), [ZERO_AREA]); + let logged = harness + .database + .admin + .query( + "SELECT record_id::text, requester FROM registry_internal.registry_subject_access_log + WHERE entity_id='service-site'", + &[], + ) + .await + .expect("access-log rows are readable by the administrator") + .into_iter() + .map(|row| (row.get::<_, String>(0), row.get::<_, String>(1))) + .collect::>(); + assert_eq!( + logged, + [(ZERO_AREA.to_owned(), PRINCIPAL_CANARY.to_owned())], + "each returned GIS feature is one logged record read" + ); + harness.cleanup().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn real_postgres_spatial_response_budget_refuses_oversized_payloads_atomically() { let harness = SpatialHarness::create(compiled_spatial_registry()).await; @@ -2172,6 +2207,26 @@ fn compiled_spatial_registry() -> registry_breg::CompiledRegistry { .expect("spatial fixture compiles to trusted inventories") } +fn compiled_spatial_registry_with_access_log() -> registry_breg::CompiledRegistry { + let source = spatial_registry_module_source().replacen( + r#""tombstone":true,"#, + r#""tombstone":true,"accessLog":{"subjectField":"code"},"#, + 1, + ); + let module = parse_module_json(source.as_bytes()).expect("spatial fixture module parses"); + let assets = vec![ + spatial_module_asset("sql/map-label.sql", spatial_map_label_sql()), + spatial_module_asset("sql/zone-site-count.sql", spatial_zone_site_count_sql()), + spatial_module_asset("sql/zone-label.sql", spatial_zone_label_sql()), + spatial_module_asset("sql/region-label.sql", spatial_region_label_sql()), + ]; + let digest = module_digest_with_assets(&module, &assets); + let project_source = spatial_registry_project_source(&digest); + let project = parse_project_json(project_source.as_bytes()).expect("spatial fixture parses"); + compile_project_with_assets(&project, &[module], &assets, CompileProfile::Authoring) + .expect("access-logged spatial fixture compiles") +} + fn spatial_module_asset(path: &str, sql: &str) -> ModuleAssetSource { ModuleAssetSource { module: Some("core".to_owned()), diff --git a/crates/registry-evidence/src/config.rs b/crates/registry-evidence/src/config.rs index ad3940de18..e8f2c8087e 100644 --- a/crates/registry-evidence/src/config.rs +++ b/crates/registry-evidence/src/config.rs @@ -2863,6 +2863,7 @@ impl SourceConnectionConfig { /// `extract_script`, `fact_schema`, `statement`, `prepare_script`, `adapter_parameters`, /// `adapter_parameters_schema`, `selector_inputs`, `selector_bindings`, /// `prior_fact_bindings`, `fixed_headers`, `projection`, +/// `forwards_access_attribution`, /// `timeout_milliseconds`, `maximum_response_bytes`, and `concurrency_limit`. /// /// The tag is internal, so a field belonging to one transport is an unknown @@ -2884,6 +2885,11 @@ pub enum SourceConfig { /// unrelated export provenance and whole-provider package changes. #[serde(default, skip_serializing_if = "Option::is_none")] behavior_revision: Option, + /// Forward the verified, authorized Evidence requester and purpose in + /// Rust-owned reserved headers to a source that is explicitly prepared + /// to account for an intermediary read. + #[serde(default, skip_serializing_if = "is_false")] + forward_access_attribution: bool, posture: AcquisitionPosture, /// Optional exact upstream Problem Details tuple which means that the /// source deliberately did not resolve this lookup. The transport @@ -3234,6 +3240,18 @@ impl SourceConfig { } } + /// Whether this fixed HTTP source receives the verified requester and + /// authorized purpose as host-owned access-attribution headers. + pub fn forwards_access_attribution(&self) -> bool { + match self { + Self::HttpJson { + forward_access_attribution, + .. + } => *forward_access_attribution, + Self::SqliteExtract { .. } => false, + } + } + pub fn projection(&self) -> &[String] { match self { Self::HttpJson { request, .. } => &request.projection, @@ -5179,13 +5197,17 @@ pub(crate) fn is_http_token_byte(byte: u8) -> bool { ) } +const fn is_false(value: &bool) -> bool { + !*value +} + /// The complete closed set of header names no bundle may configure. /// /// Authentication, host and routing, cookie, framing, hop-by-hop, forwarding, /// proxy, and tracing headers are owned by Rust or by the operator's network /// path. A bundle that could set them could redirect a source request, forge a /// client identity, or smuggle a second request past the reviewed contract. -const RESERVED_HEADER_NAMES: [&str; 38] = [ +const RESERVED_HEADER_NAMES: [&str; 40] = [ "authorization", "proxy-authorization", "www-authenticate", @@ -5224,6 +5246,8 @@ const RESERVED_HEADER_NAMES: [&str; 38] = [ "x-original-url", "x-rewrite-url", "x-original-method", + "registry-access-requester", + "registry-access-purpose", ]; /// The complete closed set of reserved header-name prefix families. @@ -5246,7 +5270,7 @@ const RESERVED_HEADER_PREFIXES: [&str; 7] = [ /// Both the startup configuration contract and the source plan compiler are /// tested against this one list, which is how their shared classifier is /// proven to be a single closed deny set rather than two drifting copies. -pub const RESERVED_HEADER_CONTRACT_CASES: [&str; 51] = [ +pub const RESERVED_HEADER_CONTRACT_CASES: [&str; 53] = [ "Authorization", "authorization", "AUTHORIZATION", @@ -5298,6 +5322,8 @@ pub const RESERVED_HEADER_CONTRACT_CASES: [&str; 51] = [ "X-Envoy-External-Address", "X-Datadog-Trace-Id", "Sec-Fetch-Mode", + "Registry-Access-Requester", + "Registry-Access-Purpose", ]; /// The one closed reserved-header classifier. diff --git a/crates/registry-evidence/src/runtime.rs b/crates/registry-evidence/src/runtime.rs index eea4acdfe0..fef39c21e5 100644 --- a/crates/registry-evidence/src/runtime.rs +++ b/crates/registry-evidence/src/runtime.rs @@ -61,7 +61,8 @@ use crate::{ }, signing::{EvidenceSigner, EvidenceSigningError}, source::{ - statement_inputs, ResolvedSourceSelector, SourceError, SourceExecutor, SourceResponse, + statement_inputs, ResolvedSourceSelector, SourceAccessAttribution, SourceError, + SourceExecutor, SourceResponse, }, EVIDENCE_DEFINITIONS_SCHEMA_V1, EVIDENCE_JWS_MEDIA_TYPE, EVIDENCE_REQUEST_BATCH_MEDIA_TYPE, EVIDENCE_REQUEST_BATCH_SCHEMA_V1, EVIDENCE_SD_JWT_VC_BATCH_MEDIA_TYPE, @@ -1423,6 +1424,8 @@ impl EvidenceRuntime { audit, }); } + let source_access_attribution = + SourceAccessAttribution::new(context.principal(), &batch.purpose); let audit_material = RequestBatchAuditMaterial { assurance_profile: self.bundle().config.assurance_profile, @@ -1449,6 +1452,7 @@ impl EvidenceRuntime { let outcomes = match self .execute_optimized_request_batch( &audit_material, + &source_access_attribution, &authorized, operation, &source_id, @@ -1490,6 +1494,7 @@ impl EvidenceRuntime { } else { self.evaluate_request_batch_item( &audit_material, + &source_access_attribution, item_index, item, operation, @@ -1883,6 +1888,8 @@ impl EvidenceRuntime { return Err(map_authority(error)); } }; + let source_access_attribution = + SourceAccessAttribution::new(context.principal(), &request.purpose); // A holder-bound operation is scoped to no relying party, so the audit // pseudonyms of what it releases cannot be derived under one. Its scope @@ -1953,6 +1960,7 @@ impl EvidenceRuntime { let stage = self .execute_source_stage( &material, + &source_access_attribution, operation, &source, &resolved, @@ -2011,6 +2019,7 @@ impl EvidenceRuntime { let search_stage = self .execute_source_stage( &material, + &source_access_attribution, operation, &search, &resolved, @@ -2065,6 +2074,7 @@ impl EvidenceRuntime { let fetch_stage = self .execute_source_stage( &material, + &source_access_attribution, operation, &fetch, &resolved, @@ -2150,6 +2160,7 @@ impl EvidenceRuntime { } = self .execute_source_stage( &material, + &source_access_attribution, operation, &stage.source, &resolved, @@ -2731,6 +2742,7 @@ impl EvidenceRuntime { async fn evaluate_request_batch_item( &self, audit_material: &RequestBatchAuditMaterial, + source_access_attribution: &SourceAccessAttribution, item_index: u8, item: &AuthorizedRequestBatchItem, operation: &str, @@ -2741,6 +2753,7 @@ impl EvidenceRuntime { let Some(facts) = self .acquire_request_batch_item( audit_material, + source_access_attribution, item_index, &item.resolved, operation, @@ -2830,6 +2843,7 @@ impl EvidenceRuntime { async fn execute_optimized_request_batch( &self, audit_material: &RequestBatchAuditMaterial, + source_access_attribution: &SourceAccessAttribution, items: &[AuthorizedRequestBatchItem], operation: &str, source_id: &str, @@ -2889,7 +2903,7 @@ impl EvidenceRuntime { .get(source_id) .ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "source-plan"))?; let response = executor - .execute_batch(prepared.request()) + .execute_batch_attributed(prepared.request(), source_access_attribution) .await .map_err(|error| { failure( @@ -2931,6 +2945,7 @@ impl EvidenceRuntime { async fn acquire_request_batch_item( &self, audit_material: &RequestBatchAuditMaterial, + source_access_attribution: &SourceAccessAttribution, item_index: u8, resolved: &ResolvedAuthorization, operation: &str, @@ -2947,6 +2962,7 @@ impl EvidenceRuntime { let stage = self .execute_request_batch_source_stage( audit_material, + source_access_attribution, item_index, operation, &source, @@ -2976,6 +2992,7 @@ impl EvidenceRuntime { let search_stage = self .execute_request_batch_source_stage( audit_material, + source_access_attribution, item_index, operation, &search, @@ -3003,6 +3020,7 @@ impl EvidenceRuntime { let fetch_stage = self .execute_request_batch_source_stage( audit_material, + source_access_attribution, item_index, operation, &fetch, @@ -3046,6 +3064,7 @@ impl EvidenceRuntime { let outcome = self .execute_request_batch_source_stage( audit_material, + source_access_attribution, item_index, operation, &stage.source, @@ -3116,6 +3135,7 @@ impl EvidenceRuntime { async fn execute_request_batch_source_stage( &self, audit_material: &RequestBatchAuditMaterial, + source_access_attribution: &SourceAccessAttribution, item_index: u8, operation: &str, source_id: &str, @@ -3167,8 +3187,13 @@ impl EvidenceRuntime { .await .map_err(|_| failure(ProblemCode::ServiceUnavailable, "access-audit"))?; - let execution = - executor.execute_with_prior_facts(&selectors, prior_facts, &request, observed_at); + let execution = executor.execute_with_prior_facts_attributed( + &selectors, + prior_facts, + &request, + observed_at, + source_access_attribution, + ); let executed = match deadline { Some(deadline) => { let Some(remaining) = stage_time_budget(deadline, Instant::now()) else { @@ -3241,6 +3266,7 @@ impl EvidenceRuntime { async fn execute_source_stage( &self, material: &AuditMaterial, + source_access_attribution: &SourceAccessAttribution, operation: &str, source_id: &str, resolved: &ResolvedAuthorization, @@ -3310,8 +3336,13 @@ impl EvidenceRuntime { // knowing whether the entry is on disk, so it could neither answer as // the entry records nor report the entry missing. That is why the // timeout never crosses an append. - let execution = - executor.execute_with_prior_facts(&selectors, prior_facts, &request, observed_at); + let execution = executor.execute_with_prior_facts_attributed( + &selectors, + prior_facts, + &request, + observed_at, + source_access_attribution, + ); let executed = match deadline { Some(deadline) => { // A ceiling already spent by the durable append above, or by diff --git a/crates/registry-evidence/src/runtime_tests.rs b/crates/registry-evidence/src/runtime_tests.rs index baf24b17d2..756337c8e9 100644 --- a/crates/registry-evidence/src/runtime_tests.rs +++ b/crates/registry-evidence/src/runtime_tests.rs @@ -3004,6 +3004,50 @@ async fn an_encodable_path_selector_value_still_reaches_the_source() { assert_eq!(received[0].url.path(), "/v1/facts/Diallo%20Ba"); } +#[tokio::test] +async fn authorized_runtime_forwards_verified_requester_and_purpose_only_to_opted_source() { + let server = MockServer::start().await; + let requester = "agence-citoyenne:José"; + let purpose = "fixture-eligibility"; + Mock::given(method("POST")) + .and(path("/v1/facts")) + .and(header( + "registry-access-requester", + URL_SAFE_NO_PAD.encode(requester.as_bytes()), + )) + .and(header( + "registry-access-purpose", + URL_SAFE_NO_PAD.encode(purpose.as_bytes()), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "total": 1, + "date_of_birth": "2000-01-01" + }))) + .expect(1) + .mount(&server) + .await; + let prepared = prepare_fixture_with_mutation( + "subject-binding-secret-canary-32-bytes-minimum", + &server.uri(), + &FixtureCeilings::deployment_defaults(), + |bundle_root| enable_access_attribution(bundle_root, "source-a"), + ); + let runtime = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime with attributed source initializes"); + + runtime + .evaluate( + "operation-source-access-attribution", + &access_token_for(requester, None), + &adult_request(), + ) + .await + .expect("authorized request succeeds"); + assert_eq!(server.received_requests().await.unwrap().len(), 1); +} + #[tokio::test] async fn missing_principal_never_falls_back_to_client_id_or_azp() { let fixture = acceptance_runtime().await; @@ -10410,6 +10454,16 @@ fn declare_unresolved_problem(bundle_root: &Path, source_id: &str) { fs::write(path, config).expect("declared unresolved source config is written"); } +fn enable_access_attribution(bundle_root: &Path, source_id: &str) { + let path = bundle_root.join("evidence.yaml"); + let mut config = fs::read_to_string(&path).expect("source config is readable"); + let source = format!(" {source_id}:\n transport: http-json\n"); + let attributed = + format!(" {source_id}:\n transport: http-json\n forwardAccessAttribution: true\n"); + replace_exact(&mut config, &source, &attributed, 1); + fs::write(path, config).expect("attributed source config is written"); +} + fn declared_unresolved_response(trace_id: &str) -> ResponseTemplate { ResponseTemplate::new(404).set_body_raw( format!( diff --git a/crates/registry-evidence/src/source.rs b/crates/registry-evidence/src/source.rs index b01527b0dd..eed15884c4 100644 --- a/crates/registry-evidence/src/source.rs +++ b/crates/registry-evidence/src/source.rs @@ -47,6 +47,9 @@ const PROJECTED_RESPONSE_MAXIMUM_BYTES: usize = 65_536; const JSON_MEDIA_TYPE: &str = "application/json"; const GRAPHQL_JSON_MEDIA_TYPE: &str = "application/graphql-response+json"; const PROBLEM_JSON_MEDIA_TYPE: &str = "application/problem+json"; +const ACCESS_REQUESTER_HEADER: &str = "registry-access-requester"; +const ACCESS_PURPOSE_HEADER: &str = "registry-access-purpose"; +const MAXIMUM_ACCESS_ATTRIBUTION_BYTES: usize = 512; /// Scheme used when a source states no other, and the only scheme RFC 6750 /// admits for an access token the runtime acquired itself. const DEFAULT_AUTHORIZATION_SCHEME: &str = "Bearer"; @@ -84,6 +87,45 @@ pub struct ResolvedSourceSelector { pub values: BTreeMap, } +/// Verified, authorized caller context that an explicitly opted-in HTTP source +/// receives for its own subject-facing access accounting. +/// +/// Values are validated and encoded when an opted-in HTTP source builds its +/// headers, so arbitrary UTF-8 identities cannot become header syntax. The +/// type has no `Debug` implementation because it contains the requester's +/// direct identity. +pub struct SourceAccessAttribution { + requester: String, + purpose: String, +} + +impl SourceAccessAttribution { + pub fn new(requester: &str, purpose: &str) -> Self { + Self { + requester: requester.to_owned(), + purpose: purpose.to_owned(), + } + } + + fn encoded(value: &str) -> Result { + if value.trim().is_empty() + || value.len() > MAXIMUM_ACCESS_ATTRIBUTION_BYTES + || value.chars().any(char::is_control) + { + return Err(SourceError::InvalidPlan); + } + let value = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(value.as_bytes()); + HeaderValue::from_bytes(value.as_bytes()).map_err(|_| SourceError::InvalidPlan) + } + + fn headers(&self) -> Result<(HeaderValue, HeaderValue), SourceError> { + Ok(( + Self::encoded(&self.requester)?, + Self::encoded(&self.purpose)?, + )) + } +} + /// A safe status category that does not retain a response or request URL. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum SourceStatus { @@ -576,6 +618,7 @@ struct RequestPlan { path: SourcePath, method: HttpMethod, fixed_headers: HeaderMap, + forward_access_attribution: bool, selector_inputs: BTreeMap>>, allowed_selector_sets: Vec, posture: AcquisitionPosture, @@ -881,8 +924,31 @@ impl SourceExecutor { request: &PreparedSourceRequest, evaluation_instant: DateTime, ) -> Result { - self.execute_with_prior_facts(selectors, &BTreeMap::new(), request, evaluation_instant) - .await + self.execute_with_prior_facts_and_attribution( + selectors, + &BTreeMap::new(), + request, + evaluation_instant, + None, + ) + .await + } + + pub async fn execute_attributed( + &self, + selectors: &[ResolvedSourceSelector], + request: &PreparedSourceRequest, + evaluation_instant: DateTime, + attribution: &SourceAccessAttribution, + ) -> Result { + self.execute_with_prior_facts_and_attribution( + selectors, + &BTreeMap::new(), + request, + evaluation_instant, + Some(attribution), + ) + .await } /// Execute one already-selected optimized HTTP batch request. @@ -894,12 +960,29 @@ impl SourceExecutor { pub async fn execute_batch( &self, request: &PreparedSourceBatchRequest, + ) -> Result { + self.execute_batch_with_attribution(request, None).await + } + + pub async fn execute_batch_attributed( + &self, + request: &PreparedSourceBatchRequest, + attribution: &SourceAccessAttribution, + ) -> Result { + self.execute_batch_with_attribution(request, Some(attribution)) + .await + } + + async fn execute_batch_with_attribution( + &self, + request: &PreparedSourceBatchRequest, + attribution: Option<&SourceAccessAttribution>, ) -> Result { let SourceTransport::Http(http) = &self.transport else { return Err(SourceError::InvalidPlan); }; let materialized = http.materialize_batch_request(request.parts())?; - http.execute_batch(&materialized, self.observer.as_ref()) + http.execute_batch(&materialized, attribution, self.observer.as_ref()) .await } @@ -909,12 +992,49 @@ impl SourceExecutor { prior_facts: &BTreeMap, request: &PreparedSourceRequest, evaluation_instant: DateTime, + ) -> Result { + self.execute_with_prior_facts_and_attribution( + selectors, + prior_facts, + request, + evaluation_instant, + None, + ) + .await + } + + pub async fn execute_with_prior_facts_attributed( + &self, + selectors: &[ResolvedSourceSelector], + prior_facts: &BTreeMap, + request: &PreparedSourceRequest, + evaluation_instant: DateTime, + attribution: &SourceAccessAttribution, + ) -> Result { + self.execute_with_prior_facts_and_attribution( + selectors, + prior_facts, + request, + evaluation_instant, + Some(attribution), + ) + .await + } + + async fn execute_with_prior_facts_and_attribution( + &self, + selectors: &[ResolvedSourceSelector], + prior_facts: &BTreeMap, + request: &PreparedSourceRequest, + evaluation_instant: DateTime, + attribution: Option<&SourceAccessAttribution>, ) -> Result { let materialized = self.materialize_request_with_prior_facts(selectors, prior_facts, request)?; match &self.transport { SourceTransport::Http(http) => { - http.execute(&materialized, self.observer.as_ref()).await + http.execute(&materialized, attribution, self.observer.as_ref()) + .await } SourceTransport::Statement(statement) => statement .execute(&materialized, evaluation_instant) @@ -1026,6 +1146,7 @@ impl HttpTransport { request: configured_request, batch, unresolved_problem, + forward_access_attribution, .. } = source else { @@ -1070,6 +1191,7 @@ impl HttpTransport { *posture, base_url, &resources.authentication, + *forward_access_attribution, )?; let batch_projection = batch .as_deref() @@ -1087,22 +1209,29 @@ impl HttpTransport { async fn execute( &self, materialized: &MaterializedSourceRequest, + attribution: Option<&SourceAccessAttribution>, observer: Option<&SourceObserver>, ) -> Result { - self.execute_with_projection(materialized, &self.request.projection, observer) - .await + self.execute_with_projection( + materialized, + &self.request.projection, + attribution, + observer, + ) + .await } async fn execute_batch( &self, materialized: &MaterializedSourceRequest, + attribution: Option<&SourceAccessAttribution>, observer: Option<&SourceObserver>, ) -> Result { let projection = self .batch_projection .as_ref() .ok_or(SourceError::InvalidPlan)?; - self.execute_with_projection(materialized, projection, observer) + self.execute_with_projection(materialized, projection, attribution, observer) .await } @@ -1110,11 +1239,17 @@ impl HttpTransport { &self, materialized: &MaterializedSourceRequest, projection: &ProjectionNode, + attribution: Option<&SourceAccessAttribution>, observer: Option<&SourceObserver>, ) -> Result { let MaterializedSourceRequest::Http { url, .. } = materialized else { return Err(SourceError::InvalidPlan); }; + let attribution_headers = match (self.request.forward_access_attribution, attribution) { + (true, Some(attribution)) => Some(attribution.headers()?), + (true, None) => return Err(SourceError::InvalidPlan), + (false, _) => None, + }; let _permit = acquire_source_slot( &self.resources.concurrency, self.resources.admission_timeout, @@ -1135,6 +1270,11 @@ impl HttpTransport { { request = request.header(authentication_name, authentication_value); } + if let Some((requester, purpose)) = attribution_headers { + request = request + .header(ACCESS_REQUESTER_HEADER, requester) + .header(ACCESS_PURPOSE_HEADER, purpose); + } if !self.request.fixed_headers.contains_key(ACCEPT) { request = request.header(ACCEPT, HeaderValue::from_static(JSON_MEDIA_TYPE)); } @@ -1632,6 +1772,7 @@ fn compile_request( posture: AcquisitionPosture, base_url: Url, authentication: &AuthenticationPlan, + forward_access_attribution: bool, ) -> Result { if request.method == HttpMethod::GET && request.preparation_limits.json_body != PreparationChannelPolicy::Forbidden @@ -1650,6 +1791,7 @@ fn compile_request( path, method: request.method, fixed_headers, + forward_access_attribution, selector_inputs, allowed_selector_sets, posture, diff --git a/crates/registry-evidence/tests/source_contracts.rs b/crates/registry-evidence/tests/source_contracts.rs index c8169c1251..8798b9980d 100644 --- a/crates/registry-evidence/tests/source_contracts.rs +++ b/crates/registry-evidence/tests/source_contracts.rs @@ -33,8 +33,8 @@ use registry_evidence::rhai_runtime::{ use registry_evidence::secrets::{SecretProvider, SecretResolver}; use registry_evidence::signing::{jwks_document, EvidenceSigner}; use registry_evidence::source::{ - project_fixture_response, PreparedSourceRequest, ResolvedSourceSelector, SourceError, - SourceExecutor, SourceResponse, SourceStatus, + project_fixture_response, PreparedSourceRequest, ResolvedSourceSelector, + SourceAccessAttribution, SourceError, SourceExecutor, SourceResponse, SourceStatus, }; use registry_evidence::verifier::{verify_flattened_jws, EvidenceVerificationPolicy}; use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, SigningProvider}; @@ -821,6 +821,10 @@ async fn exact_request_applies_path_query_body_headers_auth_and_projection_once( ); let requests = server.received_requests().await.expect("requests recorded"); assert_eq!(requests.len(), 1); + assert!(!requests[0] + .headers + .contains_key("registry-access-requester")); + assert!(!requests[0].headers.contains_key("registry-access-purpose")); assert_eq!( requests[0].url.query(), Some("filter=first%20value&filter=second%2Fvalue%25") @@ -842,6 +846,95 @@ async fn exact_request_applies_path_query_body_headers_auth_and_projection_once( ); } +#[tokio::test] +async fn authorized_access_attribution_is_host_owned_and_opt_in() { + let server = MockServer::start().await; + let requester = "agence-citoyenne:José"; + let purpose = "benefit-review"; + let encoded_requester = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(requester.as_bytes()); + let encoded_purpose = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(purpose.as_bytes()); + Mock::given(method("POST")) + .and(path("/v1/records/A%20B")) + .and(header("registry-access-requester", encoded_requester)) + .and(header("registry-access-purpose", encoded_purpose)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(1) + .mount(&server) + .await; + let (_root, secrets) = resolver(&[("key", "secret")]); + let mut source = serde_json::to_value(source_config( + &server.uri(), + json!({"kind": "static-api-key", "headerName": "X-Api-Key", "valueRef": "secret:file/key"}), + json!(["record_id"]), + json!([]), + json!(["/ok"]), + )) + .unwrap(); + source["forwardAccessAttribution"] = json!(true); + let source: SourceConfig = serde_json::from_value(source).unwrap(); + let executor = SourceExecutor::new(&source, secrets).expect("executor builds"); + let attribution = SourceAccessAttribution::new(requester, purpose); + let response = executor + .execute_attributed( + &[selector("A B")], + &prepared_http_request(&parts()), + Utc::now(), + &attribution, + ) + .await + .expect("attributed source call succeeds"); + assert_eq!(response.into_data(), Some(json!({"ok": true}))); + + let error = executor + .execute( + &[selector("A B")], + &prepared_http_request(&parts()), + Utc::now(), + ) + .await + .expect_err("an opted-in source refuses a call without verified attribution"); + assert_eq!(error, SourceError::InvalidPlan); + assert_eq!( + server.received_requests().await.unwrap().len(), + 1, + "missing attribution fails before source I/O" + ); + for invalid in [ + "".to_owned(), + " ".to_owned(), + "requester\nforged".to_owned(), + "x".repeat(513), + ] { + let invalid = SourceAccessAttribution::new(&invalid, purpose); + assert!(matches!( + executor + .execute_attributed( + &[selector("A B")], + &prepared_http_request(&parts()), + Utc::now(), + &invalid, + ) + .await, + Err(SourceError::InvalidPlan) + )); + } + let invalid_purpose = SourceAccessAttribution::new(requester, "purpose\nforged"); + assert!(matches!( + executor + .execute_attributed( + &[selector("A B")], + &prepared_http_request(&parts()), + Utc::now(), + &invalid_purpose, + ) + .await, + Err(SourceError::InvalidPlan) + )); + assert_eq!(server.received_requests().await.unwrap().len(), 1); +} + #[test] fn materialized_request_reuses_path_template_query_and_body_without_auth_material() { let (_root, secrets) = resolver(&[]); diff --git a/crates/registry-scheduling-client/README.md b/crates/registry-scheduling-client/README.md index ef01df0c67..34dfd879ae 100644 --- a/crates/registry-scheduling-client/README.md +++ b/crates/registry-scheduling-client/README.md @@ -14,3 +14,7 @@ validated against the pinned bound before any network input or output. Responses are read under a bounded byte ceiling, and exactly validated product problems surface as their typed `ProblemCode`; every other failure is a caller-side request defect, a transport failure, or a protocol failure. + +Appointments and holds may carry typed opaque external record references. The +client can list the authenticated caller's appointments by one exact reference; +Scheduling stores the tuple and never contacts the product it names. diff --git a/crates/registry-scheduling-client/src/client.rs b/crates/registry-scheduling-client/src/client.rs index 0684a95623..8f499db584 100644 --- a/crates/registry-scheduling-client/src/client.rs +++ b/crates/registry-scheduling-client/src/client.rs @@ -11,10 +11,10 @@ use registry_platform_httputil::{ read_bounded, url::append_path_segments, validate_response_headers, }; use registry_scheduling_core::{ - type_uri, valid_identifier, AdmissionRequest, AppointmentDocument, + type_uri, valid_identifier, valid_reference, AdmissionRequest, AppointmentDocument, AppointmentHistoryEntryDocument, AvailabilityEntry, CancelAppointmentRequest, - CreateAppointmentRequest, ExplainDocument, HoldDocument, LocationDocument, OfferingDocument, - PageDocument, ProblemCode, RescheduleAppointmentRequest, ResourceDocument, + CreateAppointmentRequest, ExplainDocument, ExternalReference, HoldDocument, LocationDocument, + OfferingDocument, PageDocument, ProblemCode, RescheduleAppointmentRequest, ResourceDocument, SchedulingServiceDocument, ServiceDocument, APPOINTMENTS_PATH, AVAILABILITY_EXPLAIN_PATH, AVAILABILITY_PATH, CURSOR_QUERY_PARAMETER, HOLDS_PATH, IDEMPOTENCY_KEY_HEADER, LIMIT_QUERY_PARAMETER, LOCATIONS_PATH, MAXIMUM_IDEMPOTENCY_KEY_BYTES, OFFERINGS_PATH, @@ -38,6 +38,9 @@ const MAXIMUM_IDENTIFIER_BYTES: usize = 128; const OFFERING_QUERY_PARAMETER: &str = "offering"; const START_QUERY_PARAMETER: &str = "start"; const END_QUERY_PARAMETER: &str = "end"; +const EXTERNAL_REFERENCE_PRODUCT_QUERY_PARAMETER: &str = "externalReferenceProduct"; +const EXTERNAL_REFERENCE_RECORD_TYPE_QUERY_PARAMETER: &str = "externalReferenceRecordType"; +const EXTERNAL_REFERENCE_IDENTIFIER_QUERY_PARAMETER: &str = "externalReferenceIdentifier"; pub struct SchedulingClient { http: reqwest::Client, @@ -216,6 +219,45 @@ impl SchedulingClient { .await } + pub async fn list_appointments( + &self, + auth: SchedulingAuth<'_>, + reference: &ExternalReference, + cursor: Option<&str>, + limit: Option, + ) -> Result>, SchedulingClientError> { + validate_external_reference(reference)?; + validate_cursor(cursor)?; + validate_limit(limit)?; + let mut query = vec![ + ( + EXTERNAL_REFERENCE_PRODUCT_QUERY_PARAMETER, + reference.product.clone(), + ), + ( + EXTERNAL_REFERENCE_RECORD_TYPE_QUERY_PARAMETER, + reference.record_type.clone(), + ), + ( + EXTERNAL_REFERENCE_IDENTIFIER_QUERY_PARAMETER, + reference.identifier.clone(), + ), + ]; + if let Some(cursor) = cursor { + query.push((CURSOR_QUERY_PARAMETER, cursor.to_owned())); + } + if let Some(limit) = limit { + query.push((LIMIT_QUERY_PARAMETER, limit.to_string())); + } + let request = self.authorized( + self.http + .get(self.url_from_constant(APPOINTMENTS_PATH)?) + .query(&query), + &auth, + ); + self.send_json(request, StatusCode::OK).await + } + pub async fn reschedule_appointment( &self, auth: SchedulingAuth<'_>, @@ -586,7 +628,11 @@ fn validate_availability( ) -> Result<(), SchedulingClientError> { validate_offering(offering)?; validate_cursor(cursor)?; - if limit.is_some_and(|value| value == 0) { + validate_limit(limit) +} + +fn validate_limit(limit: Option) -> Result<(), SchedulingClientError> { + if limit == Some(0) { return Err(SchedulingClientError::invalid_request( "the page size is outside the accepted range", )); @@ -594,6 +640,18 @@ fn validate_availability( Ok(()) } +fn validate_external_reference(reference: &ExternalReference) -> Result<(), SchedulingClientError> { + if valid_identifier(&reference.product) + && valid_identifier(&reference.record_type) + && valid_reference(&reference.identifier) + { + return Ok(()); + } + Err(SchedulingClientError::invalid_request( + "the external reference is invalid", + )) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/registry-scheduling-client/src/lib.rs b/crates/registry-scheduling-client/src/lib.rs index ca77d81a85..9cdff75fc5 100644 --- a/crates/registry-scheduling-client/src/lib.rs +++ b/crates/registry-scheduling-client/src/lib.rs @@ -40,11 +40,11 @@ pub use registry_platform_httputil::client::{BearerToken, TransportKind}; pub use registry_scheduling_core::{ type_uri, AdmissionRequest, AppointmentDocument, AppointmentHistoryEntryDocument, AppointmentStateDocument, AvailabilityEntry, CancelAppointmentRequest, - CreateAppointmentRequest, ExplainDocument, HoldDocument, LocationDocument, OfferingDocument, - PageDocument, PartyCounts, ProblemCode, ReminderDocument, RescheduleAppointmentRequest, - ResourceDocument, SchedulingModeDocument, SchedulingServiceDocument, ServiceDocument, - WindowDocument, APPOINTMENTS_PATH, AVAILABILITY_EXPLAIN_PATH, AVAILABILITY_PATH, - CURSOR_QUERY_PARAMETER, HOLDS_PATH, IDEMPOTENCY_KEY_HEADER, LIMIT_QUERY_PARAMETER, - LOCATIONS_PATH, MAXIMUM_IDEMPOTENCY_KEY_BYTES, OFFERINGS_PATH, RESOURCES_PATH, SCHEDULING_PATH, - SCHEDULING_PROBLEM_TYPE_BASE, SERVICES_PATH, + CreateAppointmentRequest, ExplainDocument, ExternalReference, HoldDocument, LocationDocument, + OfferingDocument, PageDocument, PartyCounts, ProblemCode, ReminderDocument, + RescheduleAppointmentRequest, ResourceDocument, SchedulingModeDocument, + SchedulingServiceDocument, ServiceDocument, WindowDocument, APPOINTMENTS_PATH, + AVAILABILITY_EXPLAIN_PATH, AVAILABILITY_PATH, CURSOR_QUERY_PARAMETER, HOLDS_PATH, + IDEMPOTENCY_KEY_HEADER, LIMIT_QUERY_PARAMETER, LOCATIONS_PATH, MAXIMUM_IDEMPOTENCY_KEY_BYTES, + OFFERINGS_PATH, RESOURCES_PATH, SCHEDULING_PATH, SCHEDULING_PROBLEM_TYPE_BASE, SERVICES_PATH, }; diff --git a/crates/registry-scheduling-client/tests/http_boundary.rs b/crates/registry-scheduling-client/tests/http_boundary.rs index d4ea8a5fd5..22b575737f 100644 --- a/crates/registry-scheduling-client/tests/http_boundary.rs +++ b/crates/registry-scheduling-client/tests/http_boundary.rs @@ -11,9 +11,9 @@ use axum::Router; use chrono::{TimeZone as _, Utc}; use registry_scheduling_client::{ type_uri, AdmissionRequest, AvailabilityEntry, BearerToken, CancelAppointmentRequest, - CreateAppointmentRequest, PartyCounts, ProblemCode, RescheduleAppointmentRequest, - SchedulingAuth, SchedulingClient, SchedulingClientConfig, SchedulingClientError, - SchedulingProtocolFailure, TransportKind, + CreateAppointmentRequest, ExternalReference, PartyCounts, ProblemCode, + RescheduleAppointmentRequest, SchedulingAuth, SchedulingClient, SchedulingClientConfig, + SchedulingClientError, SchedulingProtocolFailure, TransportKind, }; use url::Url; @@ -148,6 +148,7 @@ fn admission() -> AdmissionRequest { window_revision: None, capabilities: Vec::new(), prerequisites: Vec::new(), + external_references: Vec::new(), } } @@ -158,7 +159,7 @@ const APPOINTMENT_DOCUMENT: &str = concat!( r#"{"appointmentId":"appt-1","offering":"registry-update-30","#, r#""start":"2026-10-05T02:00:00Z","end":"2026-10-05T02:30:00Z","#, r#""resource":"station-1","units":1,"channel":null,"revision":2,"state":"confirmed","#, - r#""policyRevision":4,"createdAt":"2026-10-04T09:00:00Z","cancelledAt":null}"# + r#""policyRevision":4,"createdAt":"2026-10-04T09:00:00Z","cancelledAt":null,"externalReferences":[]}"# ); #[tokio::test] @@ -268,6 +269,64 @@ async fn catalogue_listings_forward_and_round_trip_the_cursor() { server.abort(); } +#[tokio::test] +async fn appointment_listing_forwards_the_exact_external_reference_and_bounds_it_locally() { + let observations: Observations = Arc::new(Mutex::new(Vec::new())); + let page = format!(r#"{{"items":[{APPOINTMENT_DOCUMENT}],"nextCursor":"next"}}"#); + let app = Router::new() + .route("/v1/appointments", get(capture_get)) + .with_state(Fixture::json(&observations, StatusCode::OK, &page)); + let (address, server) = spawn(app).await; + + let token = BearerToken::new("fixture-secret").expect("fixture token"); + let client = client(&address); + let reference = ExternalReference { + product: "registry-casework".to_owned(), + record_type: "case".to_owned(), + identifier: "case:1764".to_owned(), + }; + let page = client + .list_appointments(auth(&token), &reference, None, Some(25)) + .await + .expect("the filtered appointment page"); + assert_eq!(page.value.items.len(), 1); + assert_eq!(page.value.next_cursor.as_deref(), Some("next")); + + { + let observations = observations.lock().expect("observations"); + assert_eq!(observations.len(), 1); + assert_eq!( + observations[0].uri, + "/v1/appointments?externalReferenceProduct=registry-casework&externalReferenceRecordType=case&externalReferenceIdentifier=case%3A1764&limit=25" + ); + } + + for invalid in [ + ExternalReference { + product: "Registry-Casework".to_owned(), + ..reference.clone() + }, + ExternalReference { + identifier: String::new(), + ..reference.clone() + }, + ] { + assert!(matches!( + client + .list_appointments(auth(&token), &invalid, None, None) + .await, + Err(SchedulingClientError::InvalidRequest { .. }) + )); + } + assert!(matches!( + client + .list_appointments(auth(&token), &reference, None, Some(0)) + .await, + Err(SchedulingClientError::InvalidRequest { .. }) + )); + server.abort(); +} + #[tokio::test] async fn availability_forwards_the_exact_query_and_validates_its_selectors_locally() { let observations: Observations = Arc::new(Mutex::new(Vec::new())); @@ -456,7 +515,7 @@ async fn create_hold_sends_the_admission_body_under_its_idempotency_key() { r#"{"holdId":"hold-7","offering":"registry-update-30","#, r#""start":"2026-10-05T02:00:00Z","end":"2026-10-05T02:30:00Z","#, r#""resource":"station-1","units":1,"expiresAt":"2026-10-05T01:45:00Z","#, - r#""policyRevision":4}"# + r#""policyRevision":4,"externalReferences":[]}"# ); let app = Router::new() .route("/v1/holds", post(capture_call)) diff --git a/crates/registry-scheduling-core/src/admission.rs b/crates/registry-scheduling-core/src/admission.rs index 12857f7041..9c802851bb 100644 --- a/crates/registry-scheduling-core/src/admission.rs +++ b/crates/registry-scheduling-core/src/admission.rs @@ -636,6 +636,7 @@ mod tests { window_revision: None, capabilities: Vec::new(), prerequisites: Vec::new(), + external_references: Vec::new(), } } @@ -1246,6 +1247,7 @@ mod tests { window_revision: Some(2), capabilities: Vec::new(), prerequisites: Vec::new(), + external_references: Vec::new(), } } diff --git a/crates/registry-scheduling-core/src/fixture.rs b/crates/registry-scheduling-core/src/fixture.rs index 2a1e43b203..7877518bb0 100644 --- a/crates/registry-scheduling-core/src/fixture.rs +++ b/crates/registry-scheduling-core/src/fixture.rs @@ -98,6 +98,7 @@ impl FixtureAdmissionRequest { window_revision: *window_revision, capabilities: capabilities.clone(), prerequisites: prerequisites.clone(), + external_references: Vec::new(), } } } diff --git a/crates/registry-scheduling-core/src/model.rs b/crates/registry-scheduling-core/src/model.rs index 32ab49cbf4..25e984baab 100644 --- a/crates/registry-scheduling-core/src/model.rs +++ b/crates/registry-scheduling-core/src/model.rs @@ -10,6 +10,8 @@ use sha2::{Digest, Sha256}; use registry_platform_calendar::{CalendarException, CalendarExceptionKind}; +use crate::wire::ExternalReference; + /// Lifecycle of a temporary hold. An expired hold stops consuming capacity the /// moment it expires, whether or not a cleanup worker has run since. #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] @@ -338,6 +340,10 @@ pub struct AdmissionRequest { /// The party's held prerequisite references, matched against the /// offering's requirements. pub prerequisites: Vec, + /// Opaque record links Scheduling retains with the hold or booking. Their + /// order is not significant. + #[serde(default)] + pub external_references: Vec, } /// The idempotent hash of an admission request. @@ -347,29 +353,50 @@ pub struct AdmissionRequest { /// the same idempotency key with a different payload hashes differently, which /// is exactly the distinction a retry must be able to make. /// -/// Capabilities and prerequisites are sets the caller happens to send in an -/// order, so they are sorted before hashing: two spellings of one set are one -/// request, and a retry that reorders them replays rather than executing a -/// second time. Repeats are kept, because a hash never edits its input. +/// Capabilities, prerequisites, and external references are sets the caller +/// happens to send in an order, so they are sorted before hashing: two +/// spellings of one set are one request, and a retry that reorders them +/// replays rather than executing a second time. Repeats are kept, because a +/// hash never edits its input; the HTTP edge refuses repeated references. #[must_use] pub fn admission_request_hash(request: &AdmissionRequest) -> String { let mut capabilities = request.capabilities.clone(); capabilities.sort(); let mut prerequisites = request.prerequisites.clone(); prerequisites.sort(); - let fixed = ( - &request.offering, - request.start.to_rfc3339(), - request.party.recipients, - request.party.attendees, - &request.channel, - &request.duplicate_key, - request.policy_revision, - request.window_revision, - &capabilities, - &prerequisites, - ); - let value = serde_json::to_value(&fixed).expect("the fixed tuple always serializes"); + let mut external_references = request.external_references.clone(); + external_references.sort(); + // Preserve the pre-external-reference tuple for requests without links. + // Their stored idempotency hashes must remain replayable across upgrade. + let value = if external_references.is_empty() { + serde_json::to_value(( + &request.offering, + request.start.to_rfc3339(), + request.party.recipients, + request.party.attendees, + &request.channel, + &request.duplicate_key, + request.policy_revision, + request.window_revision, + &capabilities, + &prerequisites, + )) + } else { + serde_json::to_value(( + &request.offering, + request.start.to_rfc3339(), + request.party.recipients, + request.party.attendees, + &request.channel, + &request.duplicate_key, + request.policy_revision, + request.window_revision, + &capabilities, + &prerequisites, + &external_references, + )) + } + .expect("the fixed tuple always serializes"); let canonical = registry_platform_canonical_json::canonicalize_json(&value) .expect("the fixed tuple always canonicalizes"); let digest = Sha256::digest(&canonical); @@ -409,6 +436,7 @@ mod tests { window_revision: None, capabilities: Vec::new(), prerequisites: Vec::new(), + external_references: Vec::new(), } } @@ -626,6 +654,11 @@ mod tests { admission_request_hash(&request()).len(), "sha256:".len() + 64 ); + assert_eq!( + admission_request_hash(&request()), + "sha256:2e9fc6fb84480aff3532f84255ef1ac3a40d46ac60d4eb528671fae633852f69", + "an empty external-reference set preserves the pre-upgrade idempotency hash" + ); } /// AT-06, pure half: a changed payload under the same caller-visible key @@ -725,19 +758,33 @@ mod tests { ); } - /// Capabilities and prerequisites are sets the caller happens to send in - /// an order. Two spellings of the same set are the same request, so a - /// retry that reorders them replays instead of executing again. + /// Capabilities, prerequisites, and external references are sets the + /// caller happens to send in an order. Two spellings of the same set are + /// the same request, so a retry that reorders them replays instead of + /// executing again. #[test] fn set_order_never_changes_the_request_hash() { let ordered = AdmissionRequest { capabilities: vec!["cap-a".to_owned(), "cap-b".to_owned()], prerequisites: vec!["proof-a".to_owned(), "proof-b".to_owned()], + external_references: vec![ + ExternalReference { + product: "registry-casework".to_owned(), + record_type: "case".to_owned(), + identifier: "case:1".to_owned(), + }, + ExternalReference { + product: "registry-breg".to_owned(), + record_type: "record".to_owned(), + identifier: "record:2".to_owned(), + }, + ], ..request() }; let reordered = AdmissionRequest { capabilities: vec!["cap-b".to_owned(), "cap-a".to_owned()], prerequisites: vec!["proof-b".to_owned(), "proof-a".to_owned()], + external_references: ordered.external_references.iter().rev().cloned().collect(), ..request() }; assert_eq!( @@ -755,5 +802,18 @@ mod tests { admission_request_hash(&ordered), admission_request_hash(&widened) ); + + let relinked = AdmissionRequest { + external_references: vec![ExternalReference { + product: "registry-casework".to_owned(), + record_type: "case".to_owned(), + identifier: "case:3".to_owned(), + }], + ..ordered.clone() + }; + assert_ne!( + admission_request_hash(&ordered), + admission_request_hash(&relinked) + ); } } diff --git a/crates/registry-scheduling-core/src/wire.rs b/crates/registry-scheduling-core/src/wire.rs index 11aba4e245..ab17194807 100644 --- a/crates/registry-scheduling-core/src/wire.rs +++ b/crates/registry-scheduling-core/src/wire.rs @@ -153,6 +153,19 @@ pub struct PageDocument { pub next_cursor: Option, } +/// An opaque link to a record owned by another product. +/// +/// Scheduling stores and returns this tuple without resolving it. The +/// identifier names only the referenced record; callers must not put personal +/// data in any field. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalReference { + pub product: String, + pub record_type: String, + pub identifier: String, +} + /// A minted hold, the answer to `POST /v1/holds`. The hold request body is /// the same admission request shape a direct create carries: a hold is an /// admission ask that reserves instead of committing. @@ -168,6 +181,7 @@ pub struct HoldDocument { pub units: u32, pub expires_at: DateTime, pub policy_revision: u64, + pub external_references: Vec, } /// The lifecycle state of an appointment on the wire. @@ -205,6 +219,7 @@ pub struct AppointmentDocument { pub policy_revision: u64, pub created_at: DateTime, pub cancelled_at: Option>, + pub external_references: Vec, } /// The request behind `POST /v1/appointments`: confirm a held allocation, or @@ -408,6 +423,7 @@ mod tests { units: 1, expires_at: utc(5, 1), policy_revision: 1, + external_references: Vec::new(), }); tolerates_a_later_member(&AppointmentDocument { appointment_id: "6e97f6a3-8524-4a13-9db8-ad0c4eb4d64b".to_owned(), @@ -422,6 +438,7 @@ mod tests { policy_revision: 1, created_at: utc(4, 9), cancelled_at: None, + external_references: Vec::new(), }); tolerates_a_later_member(&AppointmentHistoryEntryDocument { event_id: "b31c0f4e-cc7f-4ba8-88f2-9c1a9102f0a1".to_owned(), @@ -501,6 +518,7 @@ mod tests { window_revision: None, capabilities: Vec::new(), prerequisites: Vec::new(), + external_references: Vec::new(), }; let direct = CreateAppointmentRequest { @@ -544,6 +562,7 @@ mod tests { policy_revision: 1, created_at: utc(4, 9), cancelled_at: None, + external_references: Vec::new(), }; let json = serde_json::to_value(&appointment).unwrap(); assert_eq!(json["state"], "confirmed"); diff --git a/crates/registry-scheduling/migrations/0010_external_references.sql b/crates/registry-scheduling/migrations/0010_external_references.sql new file mode 100644 index 0000000000..7b47334dde --- /dev/null +++ b/crates/registry-scheduling/migrations/0010_external_references.sql @@ -0,0 +1,6 @@ +ALTER TABLE scheduling_claims + ADD COLUMN IF NOT EXISTS external_references jsonb NOT NULL DEFAULT '[]'::jsonb; + +CREATE INDEX IF NOT EXISTS scheduling_claims_external_references_idx + ON scheduling_claims USING gin (external_references jsonb_path_ops) + WHERE kind = 'booking'; diff --git a/crates/registry-scheduling/src/hooks.rs b/crates/registry-scheduling/src/hooks.rs index 2c7d16e14e..475db8b159 100644 --- a/crates/registry-scheduling/src/hooks.rs +++ b/crates/registry-scheduling/src/hooks.rs @@ -1158,6 +1158,7 @@ mod tests { occupied_end: now + TimeDelta::minutes(35), units: 1, duplicate_key: Some("secret-duplicate-key".to_owned()), + external_references: Vec::new(), hold_expires_at: None, revision: 4, policy_revision: 2, @@ -1210,6 +1211,7 @@ mod tests { occupied_end: now + TimeDelta::minutes(30), units: 1, duplicate_key: None, + external_references: Vec::new(), hold_expires_at: None, revision: 5, policy_revision: 2, diff --git a/crates/registry-scheduling/src/http.rs b/crates/registry-scheduling/src/http.rs index b8ff80ea4a..22f0b8598d 100644 --- a/crates/registry-scheduling/src/http.rs +++ b/crates/registry-scheduling/src/http.rs @@ -19,6 +19,7 @@ //! re-renders its pinned problem under the answering request's trace, and a //! release's empty receipt answers as an empty 204 again. +use std::collections::BTreeSet; use std::sync::Arc; use axum::extract::{Path, Query, State}; @@ -37,10 +38,10 @@ use registry_platform_httpsec::{ }; use registry_scheduling_core::{ type_uri, valid_identifier, valid_reference, AdmissionRequest, CancelAppointmentRequest, - CreateAppointmentRequest, ProblemCode, RescheduleAppointmentRequest, APPOINTMENTS_PATH, - AVAILABILITY_EXPLAIN_PATH, AVAILABILITY_PATH, HOLDS_PATH, IDEMPOTENCY_KEY_HEADER, - LOCATIONS_PATH, MAXIMUM_COLLECTION_ENTRIES, MAXIMUM_IDEMPOTENCY_KEY_BYTES, OFFERINGS_PATH, - RESOURCES_PATH, SCHEDULING_PATH, SERVICES_PATH, + CreateAppointmentRequest, ExternalReference, ProblemCode, RescheduleAppointmentRequest, + APPOINTMENTS_PATH, AVAILABILITY_EXPLAIN_PATH, AVAILABILITY_PATH, HOLDS_PATH, + IDEMPOTENCY_KEY_HEADER, LOCATIONS_PATH, MAXIMUM_COLLECTION_ENTRIES, + MAXIMUM_IDEMPOTENCY_KEY_BYTES, OFFERINGS_PATH, RESOURCES_PATH, SCHEDULING_PATH, SERVICES_PATH, }; use serde::Deserialize; use serde_json::Value; @@ -81,6 +82,7 @@ pub fn router(state: HttpState) -> Router { .route(AVAILABILITY_EXPLAIN_PATH, get(explain)) .route(HOLDS_PATH, post(create_hold)) .route(HOLD_ROUTE, delete(release_hold)) + .route(APPOINTMENTS_PATH, get(list_appointments)) .route(APPOINTMENTS_PATH, post(create_appointment)) .route(APPOINTMENT_ROUTE, get(get_appointment)) .route(APPOINTMENT_RESCHEDULE_ROUTE, post(reschedule_appointment)) @@ -377,6 +379,35 @@ async fn get_appointment( )) } +async fn list_appointments( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result< + Json>, + HttpError, +> { + let caller = authenticate_read(&state, &headers).await?; + let reference = ExternalReference { + product: query.external_reference_product, + record_type: query.external_reference_record_type, + identifier: query.external_reference_identifier, + }; + bounded_external_reference(&reference)?; + Ok(Json( + state + .service + .list_appointments( + &caller, + &reference, + query.cursor.as_deref(), + query.limit, + state.store.observed_now(), + ) + .await?, + )) +} + async fn reschedule_appointment( State(state): State, headers: HeaderMap, @@ -473,6 +504,18 @@ struct ListingQuery { limit: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AppointmentListingQuery { + external_reference_product: String, + external_reference_record_type: String, + external_reference_identifier: String, + #[serde(default)] + cursor: Option, + #[serde(default)] + limit: Option, +} + #[derive(Debug, Deserialize)] struct AvailabilityQuery { offering: String, @@ -568,7 +611,28 @@ fn bounded_admission(request: &AdmissionRequest) -> Result<(), HttpError> { bounded_reference(duplicate_key)?; } bounded_references(&request.capabilities)?; - bounded_references(&request.prerequisites) + bounded_references(&request.prerequisites)?; + if request.external_references.len() > MAXIMUM_COLLECTION_ENTRIES { + return Err(HttpError(ProblemCode::RequestInvalid)); + } + let mut unique = BTreeSet::new(); + for reference in &request.external_references { + bounded_external_reference(reference)?; + if !unique.insert(( + reference.product.as_str(), + reference.record_type.as_str(), + reference.identifier.as_str(), + )) { + return Err(HttpError(ProblemCode::RequestInvalid)); + } + } + Ok(()) +} + +fn bounded_external_reference(reference: &ExternalReference) -> Result<(), HttpError> { + bounded_identifier(&reference.product)?; + bounded_identifier(&reference.record_type)?; + bounded_reference(&reference.identifier) } /// One policy identifier as the authored grammar admits it. @@ -1082,6 +1146,7 @@ mod tests { window_revision: None, capabilities: Vec::new(), prerequisites: Vec::new(), + external_references: Vec::new(), } } @@ -1121,6 +1186,25 @@ mod tests { prerequisites: vec!["req".to_owned(); MAXIMUM_COLLECTION_ENTRIES + 1], ..admission() }, + AdmissionRequest { + external_references: vec![ + ExternalReference { + product: "registry-casework".to_owned(), + record_type: "case".to_owned(), + identifier: "case:1".to_owned(), + }; + MAXIMUM_COLLECTION_ENTRIES + 1 + ], + ..admission() + }, + AdmissionRequest { + external_references: vec![ExternalReference { + product: "Registry-Casework".to_owned(), + record_type: "case".to_owned(), + identifier: "case:1".to_owned(), + }], + ..admission() + }, // A reference is a stable scoped identifier, so an empty one and // one carrying a control byte are both malformed. AdmissionRequest { @@ -1141,6 +1225,19 @@ mod tests { "an unbounded caller string reached the store: {request:?}" ); } + + let duplicate = ExternalReference { + product: "registry-casework".to_owned(), + record_type: "case".to_owned(), + identifier: "case:1".to_owned(), + }; + assert!(matches!( + bounded_admission(&AdmissionRequest { + external_references: vec![duplicate.clone(), duplicate], + ..admission() + }), + Err(HttpError(ProblemCode::RequestInvalid)) + )); } #[test] diff --git a/crates/registry-scheduling/src/service.rs b/crates/registry-scheduling/src/service.rs index a6c5fdc473..e93fda1f58 100644 --- a/crates/registry-scheduling/src/service.rs +++ b/crates/registry-scheduling/src/service.rs @@ -27,11 +27,11 @@ use registry_scheduling_core::{ location_closure_intervals, location_open_intervals, type_uri, AdmissionRequest, AppointmentDocument, AppointmentHistoryEntryDocument, AppointmentStateDocument, AvailabilityEntry, CancelAppointmentRequest, Channel, CreateAppointmentRequest, - ExactTimeContext, ExactTimeOffering, LedgerKind, LedgerSnapshot, OfferingDocument, - OfferingPolicy, PageDocument, PoolMember, ProblemCode, PublishedWindow, ReminderDocument, - RescheduleAppointmentRequest, ResourceDocument, SchedulingFacts, SchedulingMode, - SchedulingModeDocument, SchedulingPolicy, SchedulingServiceDocument, ServiceDocument, - WindowContext, WindowDocument, + ExactTimeContext, ExactTimeOffering, ExternalReference, LedgerKind, LedgerSnapshot, + OfferingDocument, OfferingPolicy, PageDocument, PoolMember, ProblemCode, PublishedWindow, + ReminderDocument, RescheduleAppointmentRequest, ResourceDocument, SchedulingFacts, + SchedulingMode, SchedulingModeDocument, SchedulingPolicy, SchedulingServiceDocument, + ServiceDocument, WindowContext, WindowDocument, }; use serde_json::{json, Value}; use sha2::{Digest as _, Sha256}; @@ -493,6 +493,7 @@ impl SchedulingService { window_revision: None, capabilities: offering.requires_capabilities.clone(), prerequisites: offering.prerequisites.clone(), + external_references: Vec::new(), }; let refusal = match self.supply(offering).await?.0 { ResolvedSupply::ExactTime { @@ -834,6 +835,62 @@ impl SchedulingService { )) } + /// List this caller's appointments carrying one opaque external record + /// reference. Ownership is applied on every page; the cursor also binds + /// the same actor and exact reference so it cannot be moved across either + /// boundary. + pub async fn list_appointments( + &self, + caller: &Caller, + reference: &ExternalReference, + cursor: Option<&str>, + limit: Option, + now: DateTime, + ) -> Result, ServiceError> { + let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; + let reference_hash = canonical_hash(&serde_json::to_value(reference).map_err(|_| { + ServiceError::internal("the external reference could not be canonicalized") + })?)?; + let context = format!("appointments:{actor}:{reference_hash}"); + let position = self.resolve_position(cursor, &context, now).await?; + let after_id = id_position(position)? + .map(|value| Uuid::parse_str(&value)) + .transpose() + .map_err(|_| ServiceError::Problem(ProblemCode::CursorInvalid))?; + let limit = page_limit(limit); + let mut claims = self + .store + .list_appointments_by_external_reference( + &actor, + reference, + after_id, + i64::try_from(limit + 1).unwrap_or(i64::MAX), + ) + .await?; + let more = claims.len() > limit; + claims.truncate(limit); + let next_cursor = if more { + let last_id = claims + .last() + .expect("a page with more rows has one row") + .claim_id + .to_string(); + Some( + self.mint_cursor(&context, &ListingPosition::AfterId { last_id }, now) + .await?, + ) + } else { + None + }; + Ok(PageDocument { + items: claims + .iter() + .map(|claim| appointment_document(claim, &self.policy, self.revision(), None)) + .collect(), + next_cursor, + }) + } + pub async fn reschedule_appointment( &self, caller: &Caller, @@ -858,6 +915,9 @@ impl SchedulingService { if request.admission.offering != appointment.offering { return Err(ServiceError::Problem(ProblemCode::RequestUnprocessable)); } + if !request.admission.external_references.is_empty() { + return Err(ServiceError::Problem(ProblemCode::RequestUnprocessable)); + } let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; let (supply, facts_revision) = self.supply(offering).await?; let request_hash = canonical_hash(&json!({ @@ -2103,6 +2163,7 @@ fn hold_document( units: u32::try_from(claim.units).unwrap_or(u32::MAX), expires_at: claim.hold_expires_at.unwrap_or(claim.created_at), policy_revision: projected_policy_revision(policy_revision, receipt), + external_references: claim.external_references.clone(), } } @@ -2129,6 +2190,7 @@ fn appointment_document( policy_revision: projected_policy_revision(policy_revision, receipt), created_at: claim.created_at, cancelled_at: claim.closed_at, + external_references: claim.external_references.clone(), } } @@ -2806,6 +2868,7 @@ mod tests { occupied_end: at(2, 35), units: 1, duplicate_key: None, + external_references: Vec::new(), hold_expires_at: None, revision: 1, policy_revision: 4, @@ -2814,12 +2877,16 @@ mod tests { created_at: at(0, 0), closed_at: None, }; - let receipt = json!({ + let mut receipt = json!({ "kind": "booking", "claim": claim.clone(), "resource": "station-1", "policyRevision": 4 }); + receipt["claim"] + .as_object_mut() + .expect("a claim receipt object") + .remove("externalReferences"); let answer: CommitmentAnswer = CommitmentAnswer::Replay { status_code: 201, receipt, diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index 9b8c6cdbb7..811777dc32 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -48,9 +48,9 @@ use registry_platform_calendar::CalendarInterval; use registry_platform_config::SecretResolver; use registry_scheduling_core::{ assess_window_record_impact, evaluate_exact_time_admission, evaluate_hold_state, - evaluate_window_admission, AdmissionRefusal, ExactTimeContext, LedgerClaim, LedgerKind, - LedgerSnapshot, PolicyCheckReason, PoolMember, PublishedWindow, SchedulingDiagnostic, - SchedulingFacts, SchedulingPolicy, APPOINTMENT_CANCELLED_TRIGGER, + evaluate_window_admission, AdmissionRefusal, ExactTimeContext, ExternalReference, LedgerClaim, + LedgerKind, LedgerSnapshot, PolicyCheckReason, PoolMember, PublishedWindow, + SchedulingDiagnostic, SchedulingFacts, SchedulingPolicy, APPOINTMENT_CANCELLED_TRIGGER, APPOINTMENT_CONFIRMED_TRIGGER, APPOINTMENT_RESCHEDULED_TRIGGER, }; use serde::{Deserialize, Serialize}; @@ -87,9 +87,12 @@ const AUDIT_WRITER_MIGRATION: &str = include_str!("../migrations/0008_audit_writ const AUDIT_WRITER_MIGRATION_VERSION: i64 = 8; const ACTIVATIONS_MIGRATION: &str = include_str!("../migrations/0009_activations.sql"); const ACTIVATIONS_MIGRATION_VERSION: i64 = 9; +const EXTERNAL_REFERENCES_MIGRATION: &str = + include_str!("../migrations/0010_external_references.sql"); +const EXTERNAL_REFERENCES_MIGRATION_VERSION: i64 = 10; /// Every schema version in ledger order. -const SCHEMA_VERSIONS: [i64; 9] = [ +const SCHEMA_VERSIONS: [i64; 10] = [ 1, 2, HOOK_DELIVERY_MIGRATION_VERSION, @@ -99,6 +102,7 @@ const SCHEMA_VERSIONS: [i64; 9] = [ DUPLICATE_LOOKUP_INDEX_MIGRATION_VERSION, AUDIT_WRITER_MIGRATION_VERSION, ACTIVATIONS_MIGRATION_VERSION, + EXTERNAL_REFERENCES_MIGRATION_VERSION, ]; /// Serializes schema migration and package activation on one transaction @@ -506,6 +510,8 @@ pub struct ClaimRow { pub occupied_end: DateTime, pub units: i32, pub duplicate_key: Option, + #[serde(default)] + pub external_references: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub hold_expires_at: Option>, pub revision: i64, @@ -1107,6 +1113,32 @@ impl PostgresStore { row.map(map_claim_row).transpose() } + /// Appointments owned by `actor` that carry exactly the requested opaque + /// reference, ordered by identifier for stable cursor paging. + pub async fn list_appointments_by_external_reference( + &self, + actor: &str, + reference: &ExternalReference, + after_id: Option, + limit: i64, + ) -> Result, StoreError> { + let client = self.client().await?; + let reference = serde_json::to_value([reference]).map_err(|_| StoreError::Corrupt)?; + let rows = client + .query( + "SELECT claim_id, kind, state, offering, supply_id, channel, \ + displayed_start, displayed_end, occupied_start, occupied_end, units, duplicate_key, \ + external_references, hold_expires_at, revision, policy_revision, actor, reason, \ + created_at, closed_at FROM scheduling_claims \ + WHERE kind='booking' AND actor=$1 AND external_references @> $2::jsonb \ + AND ($3::uuid IS NULL OR claim_id > $3) \ + ORDER BY claim_id LIMIT $4", + &[&actor, &reference, &after_id, &limit], + ) + .await?; + rows.into_iter().map(map_claim_row).collect() + } + /// The offering as the policy revision `policy_revision` published it. /// /// Every claim names the revision it was committed under, and every @@ -1326,6 +1358,7 @@ impl PostgresStore { occupied_end: admission.occupied_end, units: i32::try_from(admission.units).map_err(|_| StoreError::Corrupt)?, duplicate_key: request.duplicate_key.as_deref(), + external_references: &request.external_references, hold_expires_at: Some(expires_at), revision: 1, policy_revision: commitment.policy_revision, @@ -1418,6 +1451,7 @@ impl PostgresStore { occupied_end: admission.occupied_end, units: i32::try_from(admission.units).map_err(|_| StoreError::Corrupt)?, duplicate_key: request.duplicate_key.as_deref(), + external_references: &request.external_references, hold_expires_at: None, revision: 1, policy_revision: commitment.policy_revision, @@ -1544,6 +1578,7 @@ impl PostgresStore { occupied_end: hold.occupied_end, units: hold.units, duplicate_key: hold.duplicate_key.as_deref(), + external_references: &hold.external_references, hold_expires_at: None, revision: 1, policy_revision: commitment.policy_revision, @@ -2273,7 +2308,7 @@ impl PostgresStore { const SELECT_CLAIM: &str = "SELECT claim_id, kind, state, offering, supply_id, channel, \ displayed_start, displayed_end, occupied_start, occupied_end, units, duplicate_key, \ - hold_expires_at, revision, policy_revision, actor, reason, created_at, closed_at \ + external_references, hold_expires_at, revision, policy_revision, actor, reason, created_at, closed_at \ FROM scheduling_claims WHERE claim_id=$1"; /// The consuming-claim filter, with hold expiry evaluated in the query. This @@ -2313,13 +2348,15 @@ fn map_claim_row(row: Row) -> Result { occupied_end: row.get(9), units: row.get(10), duplicate_key: row.get(11), - hold_expires_at: row.get(12), - revision: row.get(13), - policy_revision: row.get(14), - actor: row.get(15), - reason: row.get(16), - created_at: row.get(17), - closed_at: row.get(18), + external_references: serde_json::from_value(row.get(12)) + .map_err(|_| StoreError::Corrupt)?, + hold_expires_at: row.get(13), + revision: row.get(14), + policy_revision: row.get(15), + actor: row.get(16), + reason: row.get(17), + created_at: row.get(18), + closed_at: row.get(19), }) } @@ -3332,6 +3369,7 @@ struct NewClaim<'c> { occupied_end: DateTime, units: i32, duplicate_key: Option<&'c str>, + external_references: &'c [ExternalReference], hold_expires_at: Option>, revision: i64, policy_revision: i64, @@ -3452,8 +3490,8 @@ impl CapacityStatements for deadpool_postgres::Transaction<'_> { .query_one( "INSERT INTO scheduling_claims(claim_id, kind, state, offering, supply_id, \ channel, displayed_start, displayed_end, occupied_start, occupied_end, units, \ - duplicate_key, hold_expires_at, revision, policy_revision, actor, reason) \ - VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) \ + duplicate_key, external_references, hold_expires_at, revision, policy_revision, actor, reason) \ + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18) \ RETURNING created_at", &[ &claim.claim_id, @@ -3468,6 +3506,7 @@ impl CapacityStatements for deadpool_postgres::Transaction<'_> { &claim.occupied_end, &claim.units, &claim.duplicate_key, + &serde_json::to_value(claim.external_references).map_err(|_| StoreError::Corrupt)?, &claim.hold_expires_at, &claim.revision, &claim.policy_revision, @@ -3489,6 +3528,7 @@ impl CapacityStatements for deadpool_postgres::Transaction<'_> { occupied_end: claim.occupied_end, units: claim.units, duplicate_key: claim.duplicate_key.map(str::to_owned), + external_references: claim.external_references.to_vec(), hold_expires_at: claim.hold_expires_at, revision: claim.revision, policy_revision: claim.policy_revision, diff --git a/crates/registry-scheduling/src/store/activation.rs b/crates/registry-scheduling/src/store/activation.rs index ffc51c1694..110bed4d73 100644 --- a/crates/registry-scheduling/src/store/activation.rs +++ b/crates/registry-scheduling/src/store/activation.rs @@ -20,10 +20,11 @@ use super::{ refuse_combined_conflicts, PolicyPublication, PostgresStore, StoreError, ACTIVATIONS_MIGRATION, ACTIVATIONS_MIGRATION_VERSION, AUDIT_WRITER_MIGRATION, AUDIT_WRITER_MIGRATION_VERSION, DUPLICATE_LOOKUP_INDEX_MIGRATION, DUPLICATE_LOOKUP_INDEX_MIGRATION_VERSION, - FACTS_REVISION_MIGRATION, HOOK_DELIVERY_MIGRATION_VERSION, MIGRATION_LOCK_KEY, - POLICY_DOCUMENT_MIGRATION, POLICY_DOCUMENT_MIGRATION_VERSION, SCHEDULING_MIGRATION, - SCHEMA_VERSIONS, WINDOW_RECORDS_MIGRATION, WINDOW_RECORDS_MIGRATION_VERSION, - WINDOW_REVISION_HEADS_MIGRATION, WINDOW_REVISION_HEADS_MIGRATION_VERSION, + EXTERNAL_REFERENCES_MIGRATION, EXTERNAL_REFERENCES_MIGRATION_VERSION, FACTS_REVISION_MIGRATION, + HOOK_DELIVERY_MIGRATION_VERSION, MIGRATION_LOCK_KEY, POLICY_DOCUMENT_MIGRATION, + POLICY_DOCUMENT_MIGRATION_VERSION, SCHEDULING_MIGRATION, SCHEMA_VERSIONS, + WINDOW_RECORDS_MIGRATION, WINDOW_RECORDS_MIGRATION_VERSION, WINDOW_REVISION_HEADS_MIGRATION, + WINDOW_REVISION_HEADS_MIGRATION_VERSION, }; /// Said wherever the effective role mode is `single`. @@ -1163,6 +1164,11 @@ pub(super) async fn apply_migrations_in( ACTIVATIONS_MIGRATION_VERSION => { transaction.batch_execute(ACTIVATIONS_MIGRATION).await?; } + EXTERNAL_REFERENCES_MIGRATION_VERSION => { + transaction + .batch_execute(EXTERNAL_REFERENCES_MIGRATION) + .await?; + } _ => return Err(StoreError::Corrupt), } transaction @@ -1245,12 +1251,12 @@ mod tests { #[test] fn a_newer_schema_version_names_the_release_that_applied_it() { let state = SchemaState { - applied: vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + applied: vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], pending: Vec::new(), }; assert!(matches!( state.check(), - Err(StoreError::SchemaNewer { version: 10 }) + Err(StoreError::SchemaNewer { version: 11 }) )); let damaged = SchemaState { applied: vec![0], diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index f5112617fc..d0fd1f8bd5 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -802,6 +802,201 @@ async fn booked(fx: &Fixture, from_minutes: i64, to_minutes: i64, key: &str) -> ) } +fn case_reference(identifier: &str) -> Value { + json!({ + "product": "registry-casework", + "recordType": "case", + "identifier": identifier, + }) +} + +fn appointments_by_reference_uri(identifier: &str, limit: usize, cursor: Option<&str>) -> String { + let mut uri = format!( + "/v1/appointments?externalReferenceProduct=registry-casework&externalReferenceRecordType=case&externalReferenceIdentifier={identifier}&limit={limit}" + ); + if let Some(cursor) = cursor { + uri.push_str("&cursor="); + uri.push_str(cursor); + } + uri +} + +#[tokio::test] +async fn external_references_survive_the_lifecycle_and_filter_only_owned_appointments() { + let fx = fixture().await; + let reference = case_reference("case-1764"); + + let held_slot = first_slot(&fx, OFFERING, 300, 440).await; + let mut hold_admission = admission(&fx, OFFERING, held_slot); + hold_admission["externalReferences"] = json!([reference.clone()]); + let (status, hold) = fx + .post("/v1/holds", &fx.agent, "referenced-hold", hold_admission) + .await; + assert_eq!(status, StatusCode::CREATED, "{hold}"); + assert_eq!(hold["externalReferences"], json!([reference.clone()])); + let hold_id = hold["holdId"].as_str().expect("a hold identifier"); + + let (status, confirmed) = fx + .post( + "/v1/appointments", + &fx.agent, + "referenced-confirmation", + json!({"hold": hold_id, "admission": null}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{confirmed}"); + assert_eq!(confirmed["externalReferences"], json!([reference.clone()])); + let confirmed_id = confirmed["appointmentId"] + .as_str() + .expect("an appointment identifier") + .to_owned(); + + let moved_slot = first_slot(&fx, OFFERING, 480, 620).await; + let (status, moved) = fx + .post( + &format!("/v1/appointments/{confirmed_id}/reschedule"), + &fx.agent, + "referenced-reschedule", + json!({ + "observedRevision": confirmed["revision"], + "admission": admission(&fx, OFFERING, moved_slot), + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{moved}"); + assert_eq!(moved["externalReferences"], json!([reference.clone()])); + + let mut relink_admission = admission(&fx, OFFERING, moved_slot); + relink_admission["externalReferences"] = json!([case_reference("case-relinked")]); + let (status, relink_refusal) = fx + .post( + &format!("/v1/appointments/{confirmed_id}/reschedule"), + &fx.agent, + "referenced-reschedule-relink", + json!({ + "observedRevision": moved["revision"], + "admission": relink_admission, + }), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{relink_refusal}"); + assert_eq!(relink_refusal["code"], "request.unprocessable"); + + let direct_slot = first_slot(&fx, OFFERING, 660, 800).await; + let mut direct_admission = admission(&fx, OFFERING, direct_slot); + direct_admission["externalReferences"] = json!([reference.clone()]); + let direct_body = json!({"hold": null, "admission": direct_admission}); + let (status, direct) = fx + .post( + "/v1/appointments", + &fx.agent, + "referenced-direct", + direct_body.clone(), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{direct}"); + assert_eq!(direct["externalReferences"], json!([reference.clone()])); + let direct_id = direct["appointmentId"] + .as_str() + .expect("an appointment identifier") + .to_owned(); + + let (status, replayed) = fx + .post( + "/v1/appointments", + &fx.agent, + "referenced-direct", + direct_body.clone(), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{replayed}"); + assert_eq!(replayed, direct, "an exact retry retains the references"); + let mut relinked = direct_body; + relinked["admission"]["externalReferences"] = json!([case_reference("case-other")]); + let (status, reused) = fx + .post("/v1/appointments", &fx.agent, "referenced-direct", relinked) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{reused}"); + assert_eq!(reused["code"], "idempotency.key-reused"); + + let (status, cancelled) = fx + .post( + &format!("/v1/appointments/{direct_id}/cancel"), + &fx.agent, + "referenced-cancel", + json!({"observedRevision": direct["revision"], "reason": null}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{cancelled}"); + assert_eq!(cancelled["state"], "cancelled"); + assert_eq!(cancelled["externalReferences"], json!([reference.clone()])); + + let first_uri = appointments_by_reference_uri("case-1764", 1, None); + let (status, first_page) = fx.get(&first_uri, &fx.agent).await; + assert_eq!(status, StatusCode::OK, "{first_page}"); + assert_eq!(first_page["items"].as_array().map(Vec::len), Some(1)); + let cursor = first_page["nextCursor"] + .as_str() + .expect("two matching appointments mint a continuation"); + let (status, second_page) = fx + .get( + &appointments_by_reference_uri("case-1764", 1, Some(cursor)), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::OK, "{second_page}"); + assert!(second_page["nextCursor"].is_null()); + let mut listed = vec![ + first_page["items"][0]["appointmentId"].as_str().unwrap(), + second_page["items"][0]["appointmentId"].as_str().unwrap(), + ]; + listed.sort_unstable(); + let mut expected = vec![confirmed_id.as_str(), direct_id.as_str()]; + expected.sort_unstable(); + assert_eq!(listed, expected); + for appointment in [&first_page["items"][0], &second_page["items"][0]] { + assert_eq!( + appointment["externalReferences"], + json!([reference.clone()]) + ); + } + + let other_actor = agent_token_for("principal-other"); + let (status, hidden) = fx.get(&first_uri, &other_actor).await; + assert_eq!(status, StatusCode::OK, "{hidden}"); + assert_eq!( + hidden["items"], + json!([]), + "owner scope hides both appointments" + ); + let (status, foreign_cursor) = fx + .get( + &appointments_by_reference_uri("case-1764", 1, Some(cursor)), + &other_actor, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{foreign_cursor}"); + assert_eq!(foreign_cursor["code"], "cursor.invalid"); + let (status, foreign_filter) = fx + .get( + &appointments_by_reference_uri("case-other", 1, Some(cursor)), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{foreign_filter}"); + assert_eq!(foreign_filter["code"], "cursor.invalid"); + + let no_read_scope = token(json!({ + "sub": "principal-no-read", + "azp": CLIENT, + "registry_scopes": "scheduling-explain", + "registry_actor_kind": "service", + })); + let (status, blocked) = fx.get(&first_uri, &no_read_scope).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{blocked}"); + assert_eq!(blocked["code"], "profile.not-authorized"); +} + async fn hook_fixture() -> Fixture { hook_fixture_at("http://127.0.0.1:9/scheduling-hooks").await } @@ -853,6 +1048,7 @@ async fn appointment_hooks_capture_matching_lifecycle_rows_with_bounded_projecti let first = first_slot(&fx, OFFERING, 300, 440).await; let mut create = admission(&fx, OFFERING, first); create["duplicateKey"] = json!("HOOK_DUPLICATE_CANARY"); + create["externalReferences"] = json!([case_reference("HOOK_REFERENCE_CANARY")]); let (status, confirmed) = fx .post( "/v1/appointments", @@ -966,12 +1162,20 @@ async fn appointment_hooks_capture_matching_lifecycle_rows_with_bounded_projecti for canary in [ "HOOK_DUPLICATE_CANARY", "HOOK_REASON_CANARY", + "HOOK_REFERENCE_CANARY", "principal-agent", "registry_grant_id", ] { assert!(!spelled.contains(canary), "payload leaked {canary}"); } - for excluded in ["actor", "reason", "duplicateKey", "resource", "channel"] { + for excluded in [ + "actor", + "reason", + "duplicateKey", + "externalReferences", + "resource", + "channel", + ] { assert!( envelope.data.get(excluded).is_none(), "projection exposed {excluded}" @@ -6621,6 +6825,7 @@ async fn a_records_swap_refuses_a_commitment_resolving_the_old_facts() { window_revision: None, capabilities: Vec::new(), prerequisites: Vec::new(), + external_references: Vec::new(), }; let commitment = Commitment { now: pinned_now(), diff --git a/crates/registry-schedulingctl/tests/activation_postgres.rs b/crates/registry-schedulingctl/tests/activation_postgres.rs index d1a855241b..72a8903726 100644 --- a/crates/registry-schedulingctl/tests/activation_postgres.rs +++ b/crates/registry-schedulingctl/tests/activation_postgres.rs @@ -383,7 +383,7 @@ async fn plan_on_an_empty_database_reports_the_initial_activation_and_writes_not assert_eq!(report["schemaVersion"], 0); assert_eq!( report["pendingSchemaVersions"], - serde_json::json!([1, 2, 3, 4, 5, 6, 7, 8, 9]) + serde_json::json!([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) ); assert_eq!(report["policy"]["revisionAdvances"], true); assert_eq!(report["policy"]["revision"], 2); @@ -478,7 +478,7 @@ async fn apply_records_one_row_per_activation_and_a_previous_package_is_a_new_ro .starts_with("single-role mode")); assert_eq!( initial["schemaVersionsApplied"], - serde_json::json!([1, 2, 3, 4, 5, 6, 7, 8, 9]) + serde_json::json!([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) ); assert_eq!(initial["effects"]["schedulingIdAdopted"], true); assert_eq!(initial["effects"]["policyRevision"], 2); @@ -575,12 +575,60 @@ async fn apply_records_one_row_per_activation_and_a_previous_package_is_a_new_ro assert_eq!(history[2]["planKind"], "successor"); assert_eq!(history[0]["roleMode"], "single"); assert!(history[0]["runtimeRole"].is_string(), "{report}"); - assert_eq!(report["schemaVersion"], 9); + assert_eq!(report["schemaVersion"], 10); assert_eq!(report["roleMode"], "single"); assert!(report["roleModeStatement"].is_string()); deployment.drop().await; } +#[tokio::test] +async fn external_reference_migration_preserves_existing_claims_with_an_empty_set() { + let deployment = Deployment::single("activation_external_references").await; + let first = deployment.package("first", POLICY); + let second = deployment.package("second", &successor_policy()); + let first_config = deployment.config("first.yaml", &first, ConfigOptions::default()); + let second_config = deployment.config("second.yaml", &second, ConfigOptions::default()); + apply(&first_config).expect("the initial package activates"); + + deployment + .admin + .batch_execute(&format!( + "INSERT INTO {schema}.scheduling_claims + (claim_id, kind, state, offering, supply_id, displayed_start, + displayed_end, occupied_start, occupied_end, units, revision, + policy_revision, actor) + VALUES ('00000000-0000-4000-8000-000000001764', 'booking', 'active', + 'registry-update-30', 'update-stations', now(), now() + interval '30 minutes', + now(), now() + interval '30 minutes', 1, 1, 1, 'legacy-actor'); + ALTER TABLE {schema}.scheduling_claims DROP COLUMN external_references; + DELETE FROM {schema}.scheduling_schema_migrations WHERE version=10;", + schema = deployment.schema + )) + .await + .expect("simulate a populated schema immediately before migration 10"); + + let report = apply(&second_config).expect("the successor applies migration 10"); + assert_eq!(report["schemaVersionsApplied"], serde_json::json!([10])); + let row = deployment + .admin + .query_one( + &format!( + "SELECT offering, actor, external_references + FROM {}.scheduling_claims + WHERE claim_id='00000000-0000-4000-8000-000000001764'", + deployment.schema + ), + &[], + ) + .await + .expect("the pre-migration claim remains"); + assert_eq!(row.get::<_, String>(0), "registry-update-30"); + assert_eq!(row.get::<_, String>(1), "legacy-actor"); + assert_eq!(row.get::<_, Value>(2), serde_json::json!([])); + + deployment.drop().await; +} + #[tokio::test] async fn apply_refuses_the_active_package_and_a_foreign_database_without_writing() { let deployment = Deployment::single("activation_refusals").await; @@ -1674,7 +1722,7 @@ async fn apply_takes_the_publication_locks_before_it_migrates() { .admin .batch_execute(&format!( "DROP TABLE {schema}.scheduling_activations;\ - DELETE FROM {schema}.scheduling_schema_migrations WHERE version = 9;", + DELETE FROM {schema}.scheduling_schema_migrations WHERE version IN (9, 10);", schema = deployment.schema )) .await @@ -1738,6 +1786,6 @@ async fn apply_takes_the_publication_locks_before_it_migrates() { .join() .expect("the apply does not panic") .expect("the apply proceeds once the anchor is released"); - assert_eq!(report["schemaVersionsApplied"], serde_json::json!([9])); + assert_eq!(report["schemaVersionsApplied"], serde_json::json!([9, 10])); deployment.drop().await; } diff --git a/docs/site/src/content/docs/operate/retention-and-persistent-state.mdx b/docs/site/src/content/docs/operate/retention-and-persistent-state.mdx index 512f49b291..a9bd269b5d 100644 --- a/docs/site/src/content/docs/operate/retention-and-persistent-state.mdx +++ b/docs/site/src/content/docs/operate/retention-and-persistent-state.mdx @@ -238,6 +238,14 @@ clears a retained payload on the first successful delivery, and clears an undeli its expiry has passed, which is also the point after which that delivery can no longer be replayed. The delivery rows themselves stay; only the payload goes. +An entity that declares `accessLog` writes one row per logged record read to +`registry_internal.registry_subject_access_log`. Each row takes its expiry from the entity's +`retentionDays` (default 90) when the read happens, and the subject's access-log route hides it once +that expiry passes. A background job erases expired rows every minute in batches of 1000 until none +remain, up to 100,000 rows a minute; a run that stops at that bound logs a warning with the +remaining backlog. The job keeps running after a later package turns `accessLog` off, and a package +change never extends an existing row's expiry. Database backups keep their own copies of these rows. + Import and export checkpoints live on the operator host, not in the database. `bregctl data import` writes a checkpoint file beside a `.state` sidecar it owns, and `bregctl data export` writes its output file beside a checkpoint that binds the package revision, schema fingerprint, entity, @@ -254,6 +262,8 @@ every activation, and store secrets separately. crates/registry-breg/src/request_retention.rs; crates/registry-breg/src/audit.rs; crates/registry-breg/src/webhook.rs; + crates/registry-breg/src/subject_access_log.rs, run_retention() and expire_tick(); + products/breg/ACCESS-LOG.md; crates/registry-breg/src/runtime_config.rs, DEFAULT_WEBHOOK_PAYLOAD_RETENTION_DAYS and MAX_WEBHOOK_PAYLOAD_RETENTION_DAYS; crates/registry-bregctl/src/data_lifecycle.rs. */} diff --git a/products/breg/ACCESS-LOG.md b/products/breg/ACCESS-LOG.md new file mode 100644 index 0000000000..105f9360b4 --- /dev/null +++ b/products/breg/ACCESS-LOG.md @@ -0,0 +1,211 @@ +# Subject-facing access logs + +An entity can opt in to a subject-facing log of reads of its records. The log +answers who accessed one subject's record, when, and for which declared purpose. +It is stored separately from BReg's operational audit, which keeps its existing +redaction and retention rules. Exemptions add the bounded policy events described +below. + +The log is registry-local and keyed to the record that was read. BReg does not +create a global subject pseudonym, share a subject key with another registry, or +make the log searchable across registries. There is no backfill for reads that +happened before a package enabling the log became active. + +The log's storage is installed by an apply. A database activated by a release +without it keeps serving its active package after an upgrade, as long as that +package declares no `accessLog`; the next successor apply installs the storage, +and a package that declares `accessLog` is never served without it. + +## Author the policy + +Add `accessLog` to each entity whose reads must be visible to its subjects: + +```yaml +entities: + - id: person + primaryDataset: people + route: people + mutationMode: mutable + classification: restricted + fields: + - id: citizen-id + type: string + minLength: 1 + maxLength: 128 + required: true + classification: restricted + - id: display-name + type: string + maxLength: 200 + required: true + classification: restricted + accessLog: + subjectField: citizen-id + retentionDays: 90 + trustedIntermediaries: [evidence-service] + exemptions: + investigator: + reason: active-investigation + delayDays: 30 +``` + +`subjectField` names the entity field compared with the subject's verified +principal when the subject retrieves the log. It must be a required, +plaintext, stored `string` or `text` field with `maxLength` no greater than +512. A subject uses an authenticated access profile that currently grants +`get` for the record, and BReg returns the access log only when the verified +principal selected by that profile's `principalClaim` exactly equals the +stored `subjectField` value. The access-log route is not a separate grant and +does not bypass current record visibility. + +The HTTP route is only for the record subject under that current `get` grant +and ownership check. BReg does not expose a separate officer or operator HTTP +listing of subject logs. Database administrators can inspect the underlying +rows, which contain the actual requester and purpose, so deployments must scope +that database access as sensitive operational access. Operators also govern +backup retention separately from the live subject-log retention described +below. + +The subject reads: + +```http +GET /v1/records/people/00000000-0000-4000-8000-000000000001/access-log?accessProfile=subject&limit=50 +``` + +The response is a bounded page: + +```json +{ + "events": [ + { + "id": "00000000-0000-4000-8000-000000000002", + "accessedAt": "2026-09-29T10:00:00Z", + "requester": "benefits-agency", + "serviceClient": "evidence-service", + "purpose": "eligibility-check", + "operationId": "lookup", + "visibleAfter": "2026-09-29T10:00:00Z", + "exemptionReason": null + } + ], + "nextCursor": null +} +``` + +`limit` accepts 1 through 100 and defaults to 50. `cursor` is the opaque event +identifier returned as `nextCursor`; it is always scoped to this record and the +currently verified subject. It conveys no authority. Once retention has removed +the referenced history, the cursor yields an empty page and the caller restarts +without it. + +`retentionDays` defaults to 90 and accepts 1 through 3650. Each row records its +own expiry when the read occurs. A later package can change the policy for new +rows, but it does not extend already-recorded expiry, remove retained history, +or turn disabling `accessLog` into erasure. Every minute, the background +retention worker erases expired rows in batches of 1000, each committed on its +own, until a batch comes back short, including after a later package disables +logging. One tick stops after 100 batches (100,000 rows) and logs a warning +with the remaining expired backlog; the next tick continues from there. Expired +rows are hidden immediately even when their physical deletion awaits a later +tick. Operator backup retention is separate from the live subject-log retention +contract. + +An access-logged entity cannot grant an anonymous profile a direct, list, +lookup, snapshot, or revision read. A source entity also cannot grant an +anonymous relationship read path that reaches the logged entity. A named +verified caller is required for every log entry. + +## Preserve requester attribution through an intermediary + +By default, an entry identifies the verified client that called BReg. A service +such as Evidence may need the entry to identify the original requester and +purpose rather than the intermediary alone. `trustedIntermediaries` is the +closed set of verified OAuth client IDs allowed to supply that forwarded +attribution. Each value is a nonempty, non-whitespace client identifier of at +most 512 UTF-8 bytes, and an entity can list at most 64. + +BReg accepts forwarded attribution only after authentication, when the selected +profile and verified token identify a client in that set. The forwarded values +travel together in `Registry-Access-Requester` and `Registry-Access-Purpose`. +Each header is the canonical base64url encoding without padding of a nonblank +UTF-8 value of at most 512 bytes, with no control characters. Duplicate or +incomplete pairs are refused. This is the same +[source attribution contract](../evidence/reference/request-adapter/ADAPTER-API.md) +Evidence uses, and it grants no record authority. +An unlisted caller cannot make another client appear in the +subject log. BReg never derives this trust from a request header, issuer name, +or a Registry Manifest projection. + +The BReg Evidence exporter enables forwarding for an entity with `accessLog`. +The operator must configure the exported Evidence source connection to +authenticate as a client listed in that entity's `trustedIntermediaries`; BReg +refuses the forwarded request when the verified connection client is absent. + +Evidence forwards attribution only for a verified requester and a declared +purpose. Other read paths record the direct verified client and the request's +verified purpose. Operational audit continues to record only its existing +bounded metadata and does not gain raw purpose or subject values from this +feature. + +## Delay a documented entry + +Some authorized reads cannot be disclosed immediately. `exemptions` maps an +existing read-capable access profile to a governed policy reason and delay. An +entity can declare at most 64 exemptions. `reason` must be trimmed printable +text of at most 256 UTF-8 bytes. `delayDays` must be at least 1 and less than +`retentionDays`. + +An exemption without `sourceEntity` applies to direct reads authorized by the +named profile on the logged entity. A relationship read uses the profile and +entity at the start of its read path, so a delayed relationship entry names +that authority entity explicitly: + +```yaml +exemptions: + relationship-investigator: + sourceEntity: case + reason: active-investigation + delayDays: 30 +``` + +The compiler accepts this form only when the named profile belongs to +`sourceEntity` and grants a declared read path from that entity to the logged +entity. BReg matches both the source entity and profile when applying the +delay, so an unrelated profile with the same ID cannot inherit the exemption. + +An exempt read is still written. Its row retains the policy reason and an exact +`visibleAfter` time, and the operational audit records that the governed exemption +was applied without copying the subject, purpose, or other access-log values. +That audit entry uses the `breg-access-log/v1` schema and includes only a +policy-keyed reference, delay, profile, entity, and package binding. It does not +copy the policy reason. The subject route withholds the row until `visibleAfter`; an exemption never +silently omits a read. A package upgrade does not retroactively hide a visible +entry, reveal a delayed entry early, or rewrite its reason. + +## Reads covered + +BReg logs each authorized record materialized from storage through direct get, +list (including GIS collection items), lookup, relationship traversal, snapshot, +revision, and attachment download routes. Paging lookahead rows are removed first, so they do not become +events. A refusal before record access and a query that matches no record create +no subject-log entry. An unresolved ambiguous lookup can materialize candidate +records before it refuses the response; those accesses are recorded. Each +materialized record gets its own entry, so a list or historical query cannot be +represented as one ambiguous subject event. + +The access-log insert occurs before response release and its failure refuses the +read. Once the entry commits, a later serialization, terminal-audit, or network +failure does not erase it. An entry therefore records that BReg accessed and +prepared the record under the caller's authority, even when the caller did not +ultimately receive response bytes. + +The log contains the accessing client attribution, access time, operation, and +verified purpose value, plus delayed-disclosure metadata when an exemption +applies. `purpose` is null when the verified token carries no purpose; BReg does +not invent one. On a direct read, `requester` identifies the verified OAuth +client when one is available and otherwise the verified principal; +`serviceClient` is null. On a trusted forwarded read, `requester` is the original +verified requester and `serviceClient` identifies the verified intermediary. +The log does not copy record fields, query values, tokens, or a global subject +identifier. Access-log persistence and the read response are governed product +state; they are not reconstructed from the operational audit. diff --git a/products/breg/CHANGELOG.md b/products/breg/CHANGELOG.md index 601ccb6125..ff6288d784 100644 --- a/products/breg/CHANGELOG.md +++ b/products/breg/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- An upgraded runtime and `bregctl` keep verifying and serving a database a + release before subject access-log storage activated, without an apply. The + next successor apply installs the storage. + ## v0.37.0 - 2026-09-29 - BREAKING: a record or query `400` names the member or parameter at fault diff --git a/products/breg/EVIDENCE.md b/products/breg/EVIDENCE.md index 82e5a9a1a7..78b6c6021e 100644 --- a/products/breg/EVIDENCE.md +++ b/products/breg/EVIDENCE.md @@ -89,6 +89,12 @@ TLS trust profile in the target's `sourceConnections`. Credentials remain logical secret references resolved by that deployment. A BReg package or metadata response never supplies Evidence caller authority. +When the exported entity declares `accessLog`, the source opts into forwarding +the Evidence requester's verified identity and purpose. The Evidence +connection's verified OAuth client ID must also appear in that entity's +`accessLog.trustedIntermediaries`; otherwise BReg refuses the forwarded +request. Forwarded attribution does not grant record authority. + Use the existing authentication mode appropriate for the source. A named connection shares its HTTP pool, admission limit and OAuth token state across the sources which explicitly reference it within one Evidence process. diff --git a/products/breg/README.md b/products/breg/README.md index e52caadbed..f2178cf5ee 100644 --- a/products/breg/README.md +++ b/products/breg/README.md @@ -234,6 +234,9 @@ route mapping. For atomic interval corrections, saved historical queries and their access and retention boundaries, see [Corrections and historical queries](HISTORY.md). +For the opt-in log that lets a record subject see named reads of that record, +including retention, intermediary attribution, and delayed disclosure, see +[Subject-facing access logs](ACCESS-LOG.md). For the entity `events` to `hooks` rewrite, see [Breaking authoring change: entity hooks](HISTORY.md). diff --git a/products/breg/contracts/security-invariant-matrix.yaml b/products/breg/contracts/security-invariant-matrix.yaml index 89af4f90d1..0adf85149d 100644 --- a/products/breg/contracts/security-invariant-matrix.yaml +++ b/products/breg/contracts/security-invariant-matrix.yaml @@ -133,3 +133,8 @@ invariants: - {id: BREG-SEC-130, state: enforced, targetWave: W5, threat: "A read made under delegated authority, or a delegated attempt at an immediate action, is journaled as though the principal acted directly, so the agent or task grant behind it cannot be traced.", enforcementPoint: "authorization audit projection on read terminal records and immediate-action records", refusal: "Carry the authorization object from the verified claims into the read and action claim contexts and write it on read terminal records and immediate-action records, with the grant fields for a task grant, a keyed actor pseudonym for a trusted actor, and no object for a direct token.", negativeId: BREG-NEG-130, negativeTest: {path: crates/registry-breg/tests/postgres_task_grants.rs, name: standing_agent_audit_records_the_delegated_actor_by_pseudonym}} - {id: BREG-SEC-131, state: enforced, targetWave: W5, threat: "A standing agent profile, which carries no human approval, invokes an immediate action that commits its effects with no draft for the person to confirm.", enforcementPoint: "compiler validation of every project access profile declaring actorKind agent without a taskGrant; module-contributed entity profiles cannot grant invoke", refusal: "Refuse the package with access_profile.standing_agent.action_forbidden when such a profile holds an action permission.", negativeId: BREG-NEG-131, negativeTest: {path: crates/registry-breg/tests/standing_agent_ceiling.rs, name: standing_agent_cannot_hold_an_immediate_action}} - {id: BREG-SEC-132, state: enforced, targetWave: W5, threat: "A module contributes an access profile with a taskGrant, so it escapes the standing-agent ceiling and meets no task-grant ceiling, because task-grant ceilings are checked only on project accessProfiles.", enforcementPoint: "compiler validation of module-contributed entity access profiles before project access expansion", refusal: "Refuse the package with access_profile.task_grant.module_forbidden when a module entity or entity extension contributes an access profile declaring a taskGrant.", negativeId: BREG-NEG-132, negativeTest: {path: crates/registry-breg/tests/standing_agent_ceiling.rs, name: module_contributed_profiles_cannot_declare_a_task_grant}} + - {id: BREG-SEC-133, state: enforced, targetWave: W5, threat: "A caller with record-read authority reads another subject's access history.", enforcementPoint: "current GET authorization and stored subjectField equality inside the guarded PostgreSQL transaction", refusal: "Conceal a log unless the current verified principal owns the currently visible record.", negativeId: BREG-NEG-133, negativeTest: {path: crates/registry-breg/tests/postgres_access_log.rs, name: subject_access_log_requires_current_record_ownership_and_records_each_read_hit}} + - {id: BREG-SEC-134, state: enforced, targetWave: W5, threat: "An ordinary caller forges the requester or purpose in a subject access log.", enforcementPoint: "base64url attribution parser and explicitly trusted verified OAuth client check after authorization", refusal: "Refuse forwarded attribution from an unlisted client; forwarded values never grant record or log authority.", negativeId: BREG-NEG-134, negativeTest: {path: crates/registry-breg/tests/postgres_access_log.rs, name: forwarded_access_attribution_requires_a_verified_trusted_client_and_never_grants_subject_access}} + - {id: BREG-SEC-135, state: enforced, targetWave: W5, threat: "A delayed entry is silently omitted or a runtime deletes unexpired subject access history.", enforcementPoint: "immutable access entries with governed visibility delay and expiring rows; bounded expiry-only PostgreSQL function", refusal: "Persist exempt reads and audit their policy reference; conceal delayed and expired entries and refuse arbitrary runtime updates or deletes.", negativeId: BREG-NEG-135, negativeTest: {path: crates/registry-breg/tests/postgres_access_log.rs, name: access_log_exemptions_are_delayed_audited_and_expire_without_runtime_delete_authority}} + - {id: BREG-SEC-136, state: enforced, targetWave: W5, threat: "A protected record is returned without its required subject access entry.", enforcementPoint: "access log insertion inside the materialized read transaction before response release", refusal: "Refuse the complete read when its access log insertion fails.", negativeId: BREG-NEG-136, negativeTest: {path: crates/registry-breg/tests/postgres_access_log.rs, name: a_failed_subject_access_log_insert_prevents_record_release}} + - {id: BREG-SEC-137, state: enforced, targetWave: W5, threat: "A relationship read inherits the exemption of an unrelated same-named target profile or loses its governed source-profile delay.", enforcementPoint: "compiled exemption sourceEntity and verified source entity/profile matching when each target access is recorded", refusal: "Apply a visibility delay only for the declared source entity and profile; an unrelated profile name cannot conceal an access.", negativeId: BREG-NEG-137, negativeTest: {path: crates/registry-breg/tests/postgres_access_log.rs, name: relationship_exemptions_bind_the_source_entity_without_profile_name_collisions}} diff --git a/products/breg/contracts/security-test-traceability.yaml b/products/breg/contracts/security-test-traceability.yaml index 6a5b75cede..bb4a3f1c74 100644 --- a/products/breg/contracts/security-test-traceability.yaml +++ b/products/breg/contracts/security-test-traceability.yaml @@ -133,3 +133,8 @@ traceability: - {id: BREG-SEC-130, state: enforced, negativeId: BREG-NEG-130, negativeTest: {path: crates/registry-breg/tests/postgres_task_grants.rs, name: standing_agent_audit_records_the_delegated_actor_by_pseudonym}} - {id: BREG-SEC-131, state: enforced, negativeId: BREG-NEG-131, negativeTest: {path: crates/registry-breg/tests/standing_agent_ceiling.rs, name: standing_agent_cannot_hold_an_immediate_action}} - {id: BREG-SEC-132, state: enforced, negativeId: BREG-NEG-132, negativeTest: {path: crates/registry-breg/tests/standing_agent_ceiling.rs, name: module_contributed_profiles_cannot_declare_a_task_grant}} + - {id: BREG-SEC-133, state: enforced, negativeId: BREG-NEG-133, negativeTest: {path: crates/registry-breg/tests/postgres_access_log.rs, name: subject_access_log_requires_current_record_ownership_and_records_each_read_hit}} + - {id: BREG-SEC-134, state: enforced, negativeId: BREG-NEG-134, negativeTest: {path: crates/registry-breg/tests/postgres_access_log.rs, name: forwarded_access_attribution_requires_a_verified_trusted_client_and_never_grants_subject_access}} + - {id: BREG-SEC-135, state: enforced, negativeId: BREG-NEG-135, negativeTest: {path: crates/registry-breg/tests/postgres_access_log.rs, name: access_log_exemptions_are_delayed_audited_and_expire_without_runtime_delete_authority}} + - {id: BREG-SEC-136, state: enforced, negativeId: BREG-NEG-136, negativeTest: {path: crates/registry-breg/tests/postgres_access_log.rs, name: a_failed_subject_access_log_insert_prevents_record_release}} + - {id: BREG-SEC-137, state: enforced, negativeId: BREG-NEG-137, negativeTest: {path: crates/registry-breg/tests/postgres_access_log.rs, name: relationship_exemptions_bind_the_source_entity_without_profile_name_collisions}} diff --git a/products/breg/generated/authoring/registry-module.schema.json b/products/breg/generated/authoring/registry-module.schema.json index 278ac86918..70d4ae74d2 100644 --- a/products/breg/generated/authoring/registry-module.schema.json +++ b/products/breg/generated/authoring/registry-module.schema.json @@ -1,5 +1,87 @@ { "$defs": { + "AccessLogExemptionSource": { + "additionalProperties": false, + "description": "Delayed subject disclosure for reads under one access profile.", + "properties": { + "delayDays": { + "description": "Days after the read when the entry becomes visible to the subject.", + "format": "uint16", + "maximum": 3649, + "minimum": 1, + "type": "integer" + }, + "reason": { + "description": "Bounded policy reason retained and audited with each delayed entry.", + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "sourceEntity": { + "description": "Entity whose access profile authorizes the read. Omitted for direct reads of the logged entity.", + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_-]*$", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "reason", + "delayDays" + ], + "type": "object" + }, + "AccessLogSource": { + "additionalProperties": false, + "description": "Governed subject-facing access-log policy for an entity.", + "properties": { + "exemptions": { + "additionalProperties": { + "$ref": "#/$defs/AccessLogExemptionSource" + }, + "description": "Access profiles whose entries become subject-visible only after a policy delay.", + "maxProperties": 64, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_-]*$", + "type": "string" + }, + "type": "object" + }, + "retentionDays": { + "default": 90, + "description": "Days each entry remains available before bounded background erasure.", + "format": "uint16", + "maximum": 3650, + "minimum": 1, + "type": "integer" + }, + "subjectField": { + "description": "Required plaintext string or text field matched to the subject's verified principal.", + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_-]*$", + "type": "string" + }, + "trustedIntermediaries": { + "description": "Verified intermediary client IDs allowed to forward original requester attribution.", + "items": { + "type": "string" + }, + "maxItems": 64, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "subjectField" + ], + "type": "object" + }, "AccessRequirementsSource": { "additionalProperties": false, "description": "Compile-time requirements, not grants. Profiles must explicitly satisfy them.", @@ -2199,6 +2281,17 @@ "EntitySource": { "additionalProperties": false, "properties": { + "accessLog": { + "anyOf": [ + { + "$ref": "#/$defs/AccessLogSource" + }, + { + "type": "null" + } + ], + "description": "Subject-facing record access history, separate from the operational audit." + }, "accessRequirements": { "anyOf": [ { diff --git a/products/breg/generated/authoring/registry-project.schema.json b/products/breg/generated/authoring/registry-project.schema.json index 7a48917fe9..a0e554888e 100644 --- a/products/breg/generated/authoring/registry-project.schema.json +++ b/products/breg/generated/authoring/registry-project.schema.json @@ -1,5 +1,87 @@ { "$defs": { + "AccessLogExemptionSource": { + "additionalProperties": false, + "description": "Delayed subject disclosure for reads under one access profile.", + "properties": { + "delayDays": { + "description": "Days after the read when the entry becomes visible to the subject.", + "format": "uint16", + "maximum": 3649, + "minimum": 1, + "type": "integer" + }, + "reason": { + "description": "Bounded policy reason retained and audited with each delayed entry.", + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "sourceEntity": { + "description": "Entity whose access profile authorizes the read. Omitted for direct reads of the logged entity.", + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_-]*$", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "reason", + "delayDays" + ], + "type": "object" + }, + "AccessLogSource": { + "additionalProperties": false, + "description": "Governed subject-facing access-log policy for an entity.", + "properties": { + "exemptions": { + "additionalProperties": { + "$ref": "#/$defs/AccessLogExemptionSource" + }, + "description": "Access profiles whose entries become subject-visible only after a policy delay.", + "maxProperties": 64, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_-]*$", + "type": "string" + }, + "type": "object" + }, + "retentionDays": { + "default": 90, + "description": "Days each entry remains available before bounded background erasure.", + "format": "uint16", + "maximum": 3650, + "minimum": 1, + "type": "integer" + }, + "subjectField": { + "description": "Required plaintext string or text field matched to the subject's verified principal.", + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_-]*$", + "type": "string" + }, + "trustedIntermediaries": { + "description": "Verified intermediary client IDs allowed to forward original requester attribution.", + "items": { + "type": "string" + }, + "maxItems": 64, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "subjectField" + ], + "type": "object" + }, "AccessPermissionSource": { "anyOf": [ { @@ -2362,6 +2444,17 @@ "EntitySource": { "additionalProperties": false, "properties": { + "accessLog": { + "anyOf": [ + { + "$ref": "#/$defs/AccessLogSource" + }, + { + "type": "null" + } + ], + "description": "Subject-facing record access history, separate from the operational audit." + }, "accessRequirements": { "anyOf": [ { diff --git a/products/breg/scripts/test-postgres.sh b/products/breg/scripts/test-postgres.sh index 03450e524a..d801824750 100755 --- a/products/breg/scripts/test-postgres.sh +++ b/products/breg/scripts/test-postgres.sh @@ -48,6 +48,7 @@ if [[ "$lane" == all || "$lane" == postgres ]]; then --test postgres_partial_unique \ --test postgres_constraint_races \ --test postgres_read \ + --test postgres_access_log \ --test postgres_read_dependencies \ --test postgres_record_profile_conformance \ --test postgres_client_capabilities \ diff --git a/products/breg/scripts/test_validate_product.py b/products/breg/scripts/test_validate_product.py index 773b831062..19aea635b3 100644 --- a/products/breg/scripts/test_validate_product.py +++ b/products/breg/scripts/test_validate_product.py @@ -71,7 +71,7 @@ def test_security_range_is_closed_through_field_encryption_invariants(self) -> N ) extension_rows = matrix["invariants"][24:] self.assertEqual( - [f"BREG-NEG-{index:02d}" for index in range(25, 133)], + [f"BREG-NEG-{index:02d}" for index in range(25, 138)], [invariant["negativeId"] for invariant in extension_rows], ) for invariant in extension_rows: diff --git a/products/breg/scripts/validate_product.py b/products/breg/scripts/validate_product.py index 522afea163..5d5eb6e3cd 100644 --- a/products/breg/scripts/validate_product.py +++ b/products/breg/scripts/validate_product.py @@ -30,7 +30,7 @@ CONTRACT_STATES = {"enforced", "partial", "planned"} V1_REQUIREMENT_IDS = tuple(f"BREG-V1-{index:02d}" for index in range(1, 45)) ACCEPTANCE_JOURNEY_IDS = tuple(f"BREG-J{index:02d}" for index in range(1, 24)) -SECURITY_INVARIANT_IDS = tuple(f"BREG-SEC-{index:02d}" for index in range(1, 133)) +SECURITY_INVARIANT_IDS = tuple(f"BREG-SEC-{index:02d}" for index in range(1, 138)) ACCEPTANCE_FIXTURES = { "BREG-J01": ("asset-site-placement", "acceptance/asset-site-placement"), "BREG-J02": ("asset-site-placement", "acceptance/asset-site-placement"), @@ -92,6 +92,7 @@ "cargo test --locked -p registry-breg --features postgres-test,tooling,schema --test postgres_partial_unique", "cargo test --locked -p registry-breg --features postgres-test,tooling,schema --test postgres_constraint_races", "cargo test --locked -p registry-breg --features postgres-test,tooling,schema --test postgres_read", + "cargo test --locked -p registry-breg --features postgres-test,tooling,schema --test postgres_access_log", "cargo test --locked -p registry-breg --features postgres-test,tooling,schema --test postgres_read_dependencies", "cargo test --locked -p registry-breg --features postgres-test,tooling,schema --test postgres_record_profile_conformance", "cargo test --locked -p registry-breg --features postgres-test,tooling,schema --test postgres_client_capabilities", diff --git a/products/evidence/contracts/README.md b/products/evidence/contracts/README.md index 0212d7c4d0..2a9332ddff 100644 --- a/products/evidence/contracts/README.md +++ b/products/evidence/contracts/README.md @@ -104,6 +104,13 @@ Gateway. That default does not make an older strict client able to read the new response or understand its new verification fields. Do not upgrade Evidence Gateway first while an older client or adapter remains in service. +The fixed HTTP source contract also adds optional +`forwardAccessAttribution`, defaulting to `false`. Enabling it requires a +Gateway version that emits the reserved base64url requester and purpose +headers and a source that explicitly trusts the authenticated Evidence service +to supply them. Existing source files retain their prior wire behavior when the +member is absent. + All schemas use source-neutral identifiers. Names of compatibility targets may appear only below `../fixtures/source-shapes/`. Acceptance-case vocabulary is confined to test-only bundles below `../fixtures/acceptance/`; it is not core diff --git a/products/evidence/contracts/bundle.schema.yaml b/products/evidence/contracts/bundle.schema.yaml index c69b22b509..a8ba2c5279 100644 --- a/products/evidence/contracts/bundle.schema.yaml +++ b/products/evidence/contracts/bundle.schema.yaml @@ -1006,6 +1006,13 @@ $defs: dependency; unrelated export provenance stays outside requirement revisions. type: string pattern: '^sha256:[0-9a-f]{64}$' + forwardAccessAttribution: + description: >- + Opts this HTTP source into host-owned forwarding of the verified access requester + and request purpose. The Gateway sends bounded base64url-encoded values in the + reserved Registry-Access-Requester and Registry-Access-Purpose headers only after + authentication, authorization, and selector resolution. Omission is false. + type: boolean baseUrl: description: >- Fixed HTTPS origin the source's evidence-data requests target, with no path, query, diff --git a/products/evidence/contracts/security-invariant-matrix.yaml b/products/evidence/contracts/security-invariant-matrix.yaml index a797565026..a51b3c561a 100644 --- a/products/evidence/contracts/security-invariant-matrix.yaml +++ b/products/evidence/contracts/security-invariant-matrix.yaml @@ -368,6 +368,10 @@ cross_cutting: threat: A configured fixed or API-key header name collides with an authorization, framing, routing, cookie, forwarding, proxy, or tracing header, including case variants and known infrastructure aliases. enforcement: One closed ASCII-case-insensitive deny set shared by startup configuration validation and source plan compilation, with prefix families denied before exact names and both checks running before any credential is resolved. negative_test: sec-reserved-header-aliases-closed + source_access_attribution: + threat: A caller, governed fixed header, API-key name, or Rhai adapter forges another requester or purpose at an authoritative source, or Evidence discloses identity context to an ordinary source that did not opt in. + enforcement: Each fixed HTTP source explicitly opts in. After token verification and the complete Evidence authorization and selector decision, Rust base64url-encodes the bounded non-control UTF-8 requester and purpose into two reserved headers. The shared reserved-header classifier rejects both names for fixed and authentication headers, scripts have no header channel, omission forwards nothing, and an opted source call without host-owned attribution fails before I/O. + negative_test: sec-source-access-attribution-host-owned sd_jwt_vc_projection_integrity: threat: The SD-JWT VC serialization carries different content from the signed assertion, or a relying party accepts a modified disclosure, digest, payload, or protected header. enforcement: The credential is a projection of the identical constructed evidence payload. Unprojected supported values have one root disclosure. A configured reviewed structured value has one nested disclosure per direct field, after complete schema validation, plus signed metadata that binds its public claim name back to the original concept, schema, and position. The verifier rebuilds the exact payload, requires an exact digest-to-disclosure match in both directions, and then applies the same output-contract and policy checks as signed JWS. Each serialization is rejected by the other format's verifier. diff --git a/products/evidence/contracts/security-test-traceability.yaml b/products/evidence/contracts/security-test-traceability.yaml index d2854e5f80..8ad14d052a 100644 --- a/products/evidence/contracts/security-test-traceability.yaml +++ b/products/evidence/contracts/security-test-traceability.yaml @@ -328,6 +328,10 @@ entries: tests: - {file: crates/registry-evidence/src/config.rs, name: path_templates_headers_and_projection_fail_closed} - {file: crates/registry-evidence/tests/source_contracts.rs, name: forbidden_header_collisions_and_invalid_projection_contracts_fail_at_compilation} + - id: sec-source-access-attribution-host-owned + tests: + - {file: crates/registry-evidence/tests/source_contracts.rs, name: authorized_access_attribution_is_host_owned_and_opt_in} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: authorized_runtime_forwards_verified_requester_and_purpose_only_to_opted_source} - id: sec-sd-jwt-projection-integrity tests: - {file: crates/registry-evidence-verifier/src/verifier.rs, name: external_rfc9901_and_draft18_vectors_verify_shared_cryptography_and_preserve_profile_boundary} diff --git a/products/evidence/contracts/source-contract.yaml b/products/evidence/contracts/source-contract.yaml index 03f37761bb..b99a5a0c6f 100644 --- a/products/evidence/contracts/source-contract.yaml +++ b/products/evidence/contracts/source-contract.yaml @@ -4,7 +4,7 @@ ownership: governed_bundle: - requirement acquisition kind, every source that kind fixes, each fetch stage's declared prior-fact allowlist, and any whole-acquisition ceiling the kind declares - the gated acquisition kinds and source-call optimizations the bundle declares it uses - - each source's fixed origin, acquisition posture, authentication kind, logical secret references, and optional exact unresolved Problem Details tuple + - each source's fixed origin, acquisition posture, authentication kind, logical secret references, explicit access-attribution forwarding choice, and optional exact unresolved Problem Details tuple - fixed method and fixed path, or complete-segment pathTemplate and pathBindings - fixed non-secret headers, closed selectorInputs, prepareScript, extractScript, adapter parameters, and the adapter-parameter, response, and fact schemas - preparation channel policy and bounds, response projection, redirect denial, timeout, response byte limit, and concurrency limit @@ -140,8 +140,19 @@ evidence_data_request: - x-original-url - x-rewrite-url - x-original-method + - registry-access-requester + - registry-access-purpose core_headers: Rust owns authentication and framing and adds Content-Type application/json for a JSON body. script_authority: prohibited + access_attribution: + configuration: Each http-json source independently opts in with forwardAccessAttribution true; omission is false. SQLite sources have no outbound attribution channel. + authority: Rust constructs attribution only after token verification and the complete Evidence authorization and selector decision. Caller fields, fixed headers, authentication header names, RequestParts, and Rhai cannot supply or replace it. + headers: + requester: Registry-Access-Requester + purpose: Registry-Access-Purpose + encoding: Each value is the base64url encoding without padding of its complete UTF-8 bytes. Each decoded value is non-blank, contains no control character, and is at most 512 bytes. + recipient_trust: The authenticated source decides whether its authenticated Evidence service identity is permitted to assert the forwarded values. Forwarded attribution creates no source read authority and does not replace service authentication. + request_batch: Sequential and optimized HTTP batch calls carry the one verified requester and common authorized purpose of the outer request. url_security: production: HTTPS only. deterministic_local_mock: HTTP only when the parsed host is a numeric address in 127.0.0.0/8 or exactly ::1. diff --git a/products/evidence/reference/request-adapter/ADAPTER-API.md b/products/evidence/reference/request-adapter/ADAPTER-API.md index 013e8f9d26..8dfe7a41b4 100644 --- a/products/evidence/reference/request-adapter/ADAPTER-API.md +++ b/products/evidence/reference/request-adapter/ADAPTER-API.md @@ -113,6 +113,7 @@ sources: source-a: transport: http-json baseUrl: https://source.example + forwardAccessAttribution: true posture: record-transformed authentication: {kind: static-authorization, tokenRef: secret:file/source-token} request: @@ -434,6 +435,16 @@ headers. Rust adds `Content-Type: application/json` for a JSON body and owns all authentication and framing headers. Header names and values are bounded and reject controls, CR, and LF. Scripts cannot observe or modify headers. +`forwardAccessAttribution: true` is an explicit per-source choice for an +authenticated source that keeps its own subject-facing access history. After +the complete Evidence authorization and selector decision, Rust sends +`Registry-Access-Requester` and `Registry-Access-Purpose`. Each value is the +base64url encoding without padding of its bounded, non-control UTF-8 value. +Both names are reserved from `fixedHeaders` and API-key authentication, and +Rhai has no header channel. Omission forwards no attribution. The source must +separately trust the authenticated Evidence service to assert these values; +they grant no read authority. + The governed bundle and operator runtime split, the Basic, static-Authorization, static-API-key, and OAuth profiles, the local-only credential-free loopback boundary, and logical private-CA bindings are defined in diff --git a/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md index 077ebfb198..f5845d7ca7 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md +++ b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md @@ -465,6 +465,7 @@ Beyond the shared keys, an `http-json` source declares: |---|---|---| | `connection` | no | Explicit `sourceConnections` owner. Every copied endpoint, authentication, TLS and concurrency value must equal that owner at startup. | | `behaviorRevision` | no | Provider-selected behavior digest, exactly `sha256:` followed by 64 lowercase hexadecimal characters. This reached source dependency changes its questions' revisions independently of export provenance. | +| `forwardAccessAttribution` | no | Defaults to `false`. When `true`, Rust sends the verified authorized requester and purpose as base64url UTF-8 in the reserved `Registry-Access-Requester` and `Registry-Access-Purpose` headers. The authenticated source must independently trust this Evidence service as an intermediary; the headers grant no source authority. | | `baseUrl` | yes | Fixed HTTPS origin, except for the `kind: none` local loopback boundary below. No path, query, fragment, user information, wildcard, or runtime substitution. | | `tlsTrustProfile` | no | Logical profile name bound by `runtime.yaml`. Omission uses configured system roots only. | | `authentication` | yes | One closed source-authentication profile below. `kind: none` is restricted to explicit local authoring at a numeric-loopback origin. | @@ -1822,6 +1823,7 @@ sources.*.connection sources.*.extractProfile sources.*.extractScript sources.*.factSchema +sources.*.forwardAccessAttribution sources.*.maximumExtractAgeSeconds sources.*.posture sources.*.request diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index 2f81efe112..eb18be42f5 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -3276,7 +3276,7 @@ }, "artifact": { "path": "products/breg/generated/authoring/registry-module.schema.json", - "sha256": "532f2e49816a38ac82595c32d1cff93c53bfce72edb7f84ce2012d580e883338", + "sha256": "5f3fd780bccddb5d203bd51e9bc6d404b4c2f37a054dad1a5bd96265841cfdda", "mediaType": "application/schema+json" } }, @@ -3294,7 +3294,7 @@ }, "artifact": { "path": "products/breg/generated/authoring/registry-project.schema.json", - "sha256": "ab22f032a73aea122021286a735b78bc942831a371eefe100a124e658a03e0e5", + "sha256": "e0fc372a81dec402834eb63e7e19e14bbd285a2f859f89403cfacee2c27daefd", "mediaType": "application/schema+json" } }, diff --git a/products/scheduling/CHANGELOG.md b/products/scheduling/CHANGELOG.md index 5c5df3a500..c0ba8e9bef 100644 --- a/products/scheduling/CHANGELOG.md +++ b/products/scheduling/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- BREAKING: publish the `v1alpha2` HTTP contract with typed opaque + `externalReferences` on hold and appointment + documents and to hold or direct-booking admissions. Hold confirmation, + reschedule, cancellation, reads, and idempotent replays retain the reference + set. Add the owner-scoped `GET /v1/appointments` filter over one exact + product, record type, and record identifier tuple, with bounded paging whose + cursor is bound to both the caller and filter. + ## v0.37.0 - 2026-09-29 - Read each request's `now`, and the hold-expiry, retention, and reminder diff --git a/products/scheduling/README.md b/products/scheduling/README.md index 488efecc83..6d5b2b8437 100644 --- a/products/scheduling/README.md +++ b/products/scheduling/README.md @@ -271,9 +271,32 @@ exclusions rather than accidents: someone's behalf. Every commitment's grant names the caller, and the party a request carries is the party the caller commits. -`externalReferences` and the integration seams that would attach a Scheduling -appointment to another product's record are Phase 4 work. They are absent from -the wire types today, and adding them is a contract change, not a fill-in. +## External record references + +The `v1alpha2` HTTP contract adds external references on the existing `/v1` +routes. Upgrade the runtime before sending the new admission field. Existing +admissions may omit it, and existing appointments return an empty set. + +An admission may carry `externalReferences`, each a typed opaque tuple of +`product`, `recordType`, and `identifier`. Scheduling stores only that tuple. +It never calls the referenced product, resolves the identifier, or grants any +authority through the link. Identifiers name records only; callers must not put +personal data in a reference. + +A hold returns the references it was created with. Confirming that hold copies +the same immutable references to the appointment; a direct booking takes them +from its admission. A reschedule must omit `externalReferences`; rescheduling +and cancellation retain the original set. Exact retries +may reorder the set, but changing a reference under the same idempotency key is +`idempotency.key-reused`. + +`GET /v1/appointments` requires `externalReferenceProduct`, +`externalReferenceRecordType`, and `externalReferenceIdentifier`, and accepts +the ordinary `cursor` and `limit` parameters. It returns only appointments +owned by the authenticated caller that carry the exact tuple. Its cursor is +bound to both that caller and tuple, and ownership is rechecked on every page. +The link lets another product find appointments created by its stable service +principal without giving Scheduling access to that product. ## Arrival windows and channel subquotas diff --git a/products/scheduling/contracts/security-invariant-matrix.yaml b/products/scheduling/contracts/security-invariant-matrix.yaml index 6f809b8f7a..480e4ae70f 100644 --- a/products/scheduling/contracts/security-invariant-matrix.yaml +++ b/products/scheduling/contracts/security-invariant-matrix.yaml @@ -138,8 +138,9 @@ invariants: One caller reads or pages another caller's appointment, history, or listing by guessing an identifier or replaying a cursor. enforcementPoint: >- - owned_booking on the appointment read and history paths, and cursors that - are opaque version 4 UUIDs bound to their listing context and expiring + owned_booking on the appointment read and history paths; actor predicates + on filtered appointment listings; and cursors that are opaque version 4 + UUIDs bound to their listing context, actor, and exact filter and expiring fifteen minutes out, with ownership re-checked before a cursor is honoured. refusal: >- @@ -147,7 +148,7 @@ invariants: one, and refuse a cursor presented against a different context. negativeTest: path: crates/registry-scheduling/tests/postgres_commitments.rs - name: cursors_page_their_own_listing_and_refuse_foreign_contexts + name: external_references_survive_the_lifecycle_and_filter_only_owned_appointments - id: SCHEDULING-SEC-09 state: enforced threat: >- diff --git a/products/scheduling/contracts/security-test-traceability.yaml b/products/scheduling/contracts/security-test-traceability.yaml index 15b4d2a568..9f36803e50 100644 --- a/products/scheduling/contracts/security-test-traceability.yaml +++ b/products/scheduling/contracts/security-test-traceability.yaml @@ -61,6 +61,7 @@ entries: - id: SCHEDULING-SEC-08 tests: - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: cursors_page_their_own_listing_and_refuse_foreign_contexts} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: external_references_survive_the_lifecycle_and_filter_only_owned_appointments, features: [postgres-test]} - {path: crates/registry-scheduling/src/cursors.rs, name: a_cursor_binds_to_its_listing_context_and_expires} - {path: crates/registry-scheduling/src/cursors.rs, name: cursor_expiry_is_fifteen_minutes_out} - {path: crates/registry-scheduling/src/cursors.rs, name: positions_round_trip_and_refuse_foreign_shapes} diff --git a/products/scheduling/generated/registry-scheduling.openapi.json b/products/scheduling/generated/registry-scheduling.openapi.json index e54f422558..08a119d8c1 100644 --- a/products/scheduling/generated/registry-scheduling.openapi.json +++ b/products/scheduling/generated/registry-scheduling.openapi.json @@ -44,6 +44,12 @@ } ] }, + "externalReferences": { + "items": { + "$ref": "#/components/schemas/ExternalReference" + }, + "type": "array" + }, "offering": { "type": "string" }, @@ -123,6 +129,12 @@ "format": "date-time", "type": "string" }, + "externalReferences": { + "items": { + "$ref": "#/components/schemas/ExternalReference" + }, + "type": "array" + }, "offering": { "type": "string" }, @@ -167,7 +179,8 @@ "revision", "state", "policyRevision", - "createdAt" + "createdAt", + "externalReferences" ], "type": "object" }, @@ -237,6 +250,32 @@ ], "type": "object" }, + "AppointmentPage": { + "additionalProperties": true, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AppointmentDocument" + }, + "type": "array" + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "items", + "nextCursor" + ], + "type": "object" + }, "AppointmentState": { "enum": [ "confirmed", @@ -430,6 +469,26 @@ ], "type": "object" }, + "ExternalReference": { + "additionalProperties": false, + "properties": { + "identifier": { + "type": "string" + }, + "product": { + "type": "string" + }, + "recordType": { + "type": "string" + } + }, + "required": [ + "product", + "recordType", + "identifier" + ], + "type": "object" + }, "HoldDocument": { "additionalProperties": true, "properties": { @@ -441,6 +500,12 @@ "format": "date-time", "type": "string" }, + "externalReferences": { + "items": { + "$ref": "#/components/schemas/ExternalReference" + }, + "type": "array" + }, "holdId": { "type": "string" }, @@ -478,7 +543,8 @@ "end", "units", "expiresAt", - "policyRevision" + "policyRevision", + "externalReferences" ], "type": "object" }, @@ -1647,6 +1713,7 @@ }, "RescheduleAppointmentRequest": { "additionalProperties": false, + "description": "Fresh admission facts for the new time. externalReferences must be omitted; the appointment retains the immutable set established by its hold or direct booking.", "properties": { "admission": { "$ref": "#/components/schemas/AdmissionRequest" @@ -1830,7 +1897,7 @@ "name": "Apache-2.0" }, "title": "Registry Scheduling API", - "version": "v1alpha1" + "version": "v1alpha2" }, "openapi": "3.1.0", "paths": { @@ -1998,6 +2065,243 @@ } }, "/v1/appointments": { + "get": { + "description": "Lists only the authenticated caller's appointments carrying the exact opaque reference. The 15-minute cursor is bound to both the caller and the reference, and ownership is rechecked on every page. Scheduling stores the tuple without calling the referenced product.", + "operationId": "listAppointmentsByExternalReference", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Product identifier that owns the referenced record.", + "in": "query", + "name": "externalReferenceProduct", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Record type within the referenced product.", + "in": "query", + "name": "externalReferenceRecordType", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Opaque identifier of the referenced record.", + "in": "query", + "name": "externalReferenceIdentifier", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Opaque 15-minute cursor bound to this listing. A malformed, unknown, or foreign cursor is cursor.invalid; an expired one is cursor.expired, and the listing restarts from its first page. Deduplicate entries by id.", + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "maxLength": 256, + "type": "string" + } + }, + { + "description": "Page size from 1 through 200; the default is 50 and larger values are served as 200.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppointmentPage" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemCursorInvalid" + } + ] + } + } + }, + "description": "Problem response: request.invalid, cursor.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + } + } + }, + "description": "Problem response: profile.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemCursorExpired" + } + } + }, + "description": "Problem response: cursor.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List owned appointments by external reference", + "x-scheduling-authority": "reads-scope" + }, "post": { "description": "Carries exactly one of hold or admission. Confirming transfers the hold's own reservation, so capacity is re-checked only for identity: the hold must still be active and unexpired, its policy revision still current, and its holder still the caller. A direct create is evaluated against the live ledger like a hold, and commits instead of reserving. Both branches demand a complete task grant naming the offering's service, location, and appointment.create.", "operationId": "createAppointment", @@ -3085,7 +3389,7 @@ }, "/v1/appointments/{appointment_id}/reschedule": { "post": { - "description": "Re-books the appointment under the task grant that names appointment.reschedule, with a fresh admission evaluation; the appointment it replaces is excluded from the conflict checks, so a reschedule never competes with itself. The policy guard wins over the revision guard: a caller whose observed revision is also stale learns the policy moved first.", + "description": "Re-books the appointment under the task grant that names appointment.reschedule, with a fresh admission evaluation while retaining its original externalReferences; supplying externalReferences is request.unprocessable. The appointment it replaces is excluded from the conflict checks, so a reschedule never competes with itself. The policy guard wins over the revision guard: a caller whose observed revision is also stale learns the policy moved first.", "operationId": "rescheduleAppointment", "parameters": [ { diff --git a/products/scheduling/scripts/generate_openapi.py b/products/scheduling/scripts/generate_openapi.py index ec5bdc371b..606001fc66 100644 --- a/products/scheduling/scripts/generate_openapi.py +++ b/products/scheduling/scripts/generate_openapi.py @@ -115,6 +115,7 @@ "WindowDocument": "WindowDocument", "ResourceDocument": "ResourceDocument", "LocationDocument": "LocationDocument", + "ExternalReference": "ExternalReference", "HoldDocument": "HoldDocument", "AppointmentDocument": "AppointmentDocument", "CreateAppointmentRequest": "CreateAppointmentRequest", @@ -144,6 +145,7 @@ ("POST", "/v1/holds"): "create_hold", ("DELETE", "/v1/holds/{hold_id}"): "release_hold", ("POST", "/v1/appointments"): "create_appointment", + ("GET", "/v1/appointments"): "list_appointments", ("GET", "/v1/appointments/{appointment_id}"): "get_appointment", ("POST", "/v1/appointments/{appointment_id}/reschedule"): "reschedule_appointment", ("POST", "/v1/appointments/{appointment_id}/cancel"): "cancel_appointment", @@ -163,6 +165,7 @@ ("POST", "/v1/holds"): "createHold", ("DELETE", "/v1/holds/{hold_id}"): "releaseHold", ("POST", "/v1/appointments"): "createAppointment", + ("GET", "/v1/appointments"): "listAppointmentsByExternalReference", ("GET", "/v1/appointments/{appointment_id}"): "getAppointment", ("POST", "/v1/appointments/{appointment_id}/reschedule"): "rescheduleAppointment", ("POST", "/v1/appointments/{appointment_id}/cancel"): "cancelAppointment", @@ -235,6 +238,7 @@ + ["hold.released", "idempotency.expired", "service.unavailable"], ("POST", "/v1/appointments"): EDGE + AUTHENTICATION + JSON_BODY + ADMISSION + IDEMPOTENCY + AUTHORITY + OFFERING + ["hold.expired", "hold.released", "service.unavailable"], + ("GET", "/v1/appointments"): EDGE + AUTHENTICATION + QUERY + CURSOR + STORAGE, ("GET", "/v1/appointments/{appointment_id}"): EDGE + AUTHENTICATION + PATH + AUTHORITY + STORAGE, # A reschedule resolves its offering from the appointment rather than from # the caller, so an unknown offering is operator state, not a refusal the @@ -483,6 +487,10 @@ def schemas(problem_entries: list[dict]) -> dict: mode = {"type": "string", "enum": ["exact-time", "arrival-window"]} appointment_state = {"type": "string", "enum": ["confirmed", "cancelled"]} party = obj({"recipients": count, "attendees": count}, ["recipients", "attendees"]) + external_reference = obj( + {"product": text, "recordType": text, "identifier": text}, + ["product", "recordType", "identifier"], + ) admission = obj( { "offering": text, @@ -494,6 +502,7 @@ def schemas(problem_entries: list[dict]) -> dict: "windowRevision": nullable(revision), "capabilities": array(text), "prerequisites": array(text), + "externalReferences": array(ref("ExternalReference")), }, ["offering", "start", "party", "policyRevision", "capabilities", "prerequisites"], ) @@ -556,6 +565,7 @@ def schemas(problem_entries: list[dict]) -> dict: "ResourcePage": page("ResourceDocument"), "LocationDocument": answer({"locationId": text, "timezone": text}, ["locationId", "timezone"]), "LocationPage": page("LocationDocument"), + "ExternalReference": external_reference, "AvailabilitySlot": answer( {"kind": {"const": "slot"}, "start": instant, "end": instant, "free": count}, ["kind", "start", "end", "free"], @@ -586,8 +596,9 @@ def schemas(problem_entries: list[dict]) -> dict: "units": count, "expiresAt": instant, "policyRevision": revision, + "externalReferences": array(ref("ExternalReference")), }, - ["holdId", "offering", "start", "end", "units", "expiresAt", "policyRevision"], + ["holdId", "offering", "start", "end", "units", "expiresAt", "policyRevision", "externalReferences"], ), "PartyCounts": party, "AdmissionRequest": admission, @@ -598,10 +609,13 @@ def schemas(problem_entries: list[dict]) -> dict: "properties": {"hold": nullable(text), "admission": nullable(ref("AdmissionRequest"))}, "description": "Exactly one of hold or admission: confirming a held allocation, or a direct create. Both, or neither, is request.invalid.", }, - "RescheduleAppointmentRequest": obj( - {"observedRevision": revision, "admission": ref("AdmissionRequest")}, - ["observedRevision", "admission"], - ), + "RescheduleAppointmentRequest": { + **obj( + {"observedRevision": revision, "admission": ref("AdmissionRequest")}, + ["observedRevision", "admission"], + ), + "description": "Fresh admission facts for the new time. externalReferences must be omitted; the appointment retains the immutable set established by its hold or direct booking.", + }, "CancelAppointmentRequest": obj( {"observedRevision": revision, "reason": nullable(text)}, ["observedRevision"], @@ -621,6 +635,7 @@ def schemas(problem_entries: list[dict]) -> dict: "policyRevision": revision, "createdAt": instant, "cancelledAt": nullable(instant), + "externalReferences": array(ref("ExternalReference")), }, [ "appointmentId", @@ -632,8 +647,10 @@ def schemas(problem_entries: list[dict]) -> dict: "state", "policyRevision", "createdAt", + "externalReferences", ], ), + "AppointmentPage": page("AppointmentDocument"), "AppointmentHistoryEntryDocument": answer( { "eventId": text, @@ -777,7 +794,18 @@ def document(contract: dict) -> dict: parameters=[HOLD_ID], description="Gives a hold's capacity back before it expires, under the task grant that names hold.release. The hold's own identifier is the idempotency key, so a retried release answers as the first one did; a receipt retained past its window is idempotency.expired. A claim that is not an active hold is hold.released, whether unknown, already confirmed, or already released, and a grant naming another holder is operation.not-authorized.", )}, - "/v1/appointments": {"post": operation( + "/v1/appointments": {"get": operation( + "List owned appointments by external reference", + "AppointmentPage", + parameters=[ + parameter("externalReferenceProduct", "query", "Product identifier that owns the referenced record."), + parameter("externalReferenceRecordType", "query", "Record type within the referenced product."), + parameter("externalReferenceIdentifier", "query", "Opaque identifier of the referenced record."), + CURSOR_QUERY, + LIMIT_QUERY, + ], + description="Lists only the authenticated caller's appointments carrying the exact opaque reference. The 15-minute cursor is bound to both the caller and the reference, and ownership is rechecked on every page. Scheduling stores the tuple without calling the referenced product.", + ), "post": operation( "Confirm a hold or book directly", "AppointmentDocument", authority="task-grant", @@ -799,7 +827,7 @@ def document(contract: dict) -> dict: body="RescheduleAppointmentRequest", idempotency=True, parameters=[APPOINTMENT_ID], - description="Re-books the appointment under the task grant that names appointment.reschedule, with a fresh admission evaluation; the appointment it replaces is excluded from the conflict checks, so a reschedule never competes with itself. The policy guard wins over the revision guard: a caller whose observed revision is also stale learns the policy moved first.", + description="Re-books the appointment under the task grant that names appointment.reschedule, with a fresh admission evaluation while retaining its original externalReferences; supplying externalReferences is request.unprocessable. The appointment it replaces is excluded from the conflict checks, so a reschedule never competes with itself. The policy guard wins over the revision guard: a caller whose observed revision is also stale learns the policy moved first.", )}, "/v1/appointments/{appointment_id}/cancel": {"post": operation( "Cancel an owned appointment", @@ -825,7 +853,7 @@ def document(contract: dict) -> dict: "openapi": "3.1.0", "info": { "title": "Registry Scheduling API", - "version": "v1alpha1", + "version": "v1alpha2", "description": "Implemented Scheduling HTTP contract: published openings, exact-time offerings over interchangeable resource pools, published arrival windows with channel subquotas, holds, and accountable bookings. Mutating authority is a complete task grant; every refusal is one problem from the closed vocabulary.", "license": {"name": "Apache-2.0", "identifier": "Apache-2.0"}, }, @@ -1318,6 +1346,7 @@ def verify_dto_schemas(repository_root: Path, openapi: dict) -> None: "ResourcePage", "LocationPage", "AvailabilityPage", + "AppointmentPage", "AppointmentHistoryPage", ): if set(openapi_schemas[schema_name]["properties"]) != page_fields: diff --git a/products/scheduling/scripts/test_generate_openapi.py b/products/scheduling/scripts/test_generate_openapi.py index 7f0ae8b411..58135e9b93 100644 --- a/products/scheduling/scripts/test_generate_openapi.py +++ b/products/scheduling/scripts/test_generate_openapi.py @@ -76,7 +76,7 @@ def test_every_documented_problem_matches_its_pinned_rust_catalog_entry(self) -> self.assertEqual(entry["description"], properties["detail"]["const"]) self.assertEqual(entry["httpStatuses"][0], properties["status"]["const"]) self.assertEqual(32, len(self.contract["entries"])) - self.assertEqual(59, len(self.openapi["components"]["schemas"])) + self.assertEqual(61, len(self.openapi["components"]["schemas"])) def test_every_operation_answers_exactly_the_problems_it_was_mapped(self) -> None: for key, expected in GENERATOR.OPERATION_PROBLEMS.items(): @@ -198,6 +198,7 @@ def test_an_operation_documents_the_authority_its_handler_uses(self) -> None: ("POST", "/v1/holds"): "task-grant", ("DELETE", "/v1/holds/{hold_id}"): "task-grant", ("POST", "/v1/appointments"): "task-grant", + ("GET", "/v1/appointments"): "reads-scope", ("GET", "/v1/appointments/{appointment_id}"): "reads-scope", ("POST", "/v1/appointments/{appointment_id}/reschedule"): "task-grant", ("POST", "/v1/appointments/{appointment_id}/cancel"): "task-grant", @@ -218,7 +219,7 @@ def test_an_operation_documents_the_authority_its_handler_uses(self) -> None: self.assertEqual(1, authorities.count("explain-scope")) self.assertEqual(2, authorities.count("unauthenticated")) self.assertEqual(5, authorities.count("task-grant")) - self.assertEqual(8, authorities.count("reads-scope")) + self.assertEqual(9, authorities.count("reads-scope")) def test_an_idempotency_key_is_demanded_exactly_where_the_handler_reads_it(self) -> None: expected = { @@ -471,7 +472,7 @@ def test_the_document_carries_no_tag_list_and_no_timestamp(self) -> None: self.assertNotIn("tags", self.openapi) self.assertEqual("3.1.0", self.openapi["openapi"]) self.assertEqual("Registry Scheduling API", self.openapi["info"]["title"]) - self.assertEqual("v1alpha1", self.openapi["info"]["version"]) + self.assertEqual("v1alpha2", self.openapi["info"]["version"]) self.assertEqual( [{ "url": "https://scheduling.example.test",