diff --git a/crates/registry-breg/src/lib.rs b/crates/registry-breg/src/lib.rs index 967a495dae..d1c2fab1a1 100644 --- a/crates/registry-breg/src/lib.rs +++ b/crates/registry-breg/src/lib.rs @@ -124,6 +124,7 @@ pub mod migration_reconcile; pub mod model; #[cfg(feature = "runtime")] pub mod mutation; +pub mod odrl_export; #[cfg(feature = "runtime")] pub mod outbox; #[cfg(feature = "runtime")] diff --git a/crates/registry-breg/src/odrl_export.rs b/crates/registry-breg/src/odrl_export.rs new file mode 100644 index 0000000000..d2defd520c --- /dev/null +++ b/crates/registry-breg/src/odrl_export.rs @@ -0,0 +1,494 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Conservative offline ODRL projection for operator review. +//! +//! This export is descriptive and partial. It neither grants authority nor +//! changes the runtime, package, or public Registry Manifest projection. + +use std::collections::BTreeMap; + +use registry_manifest_core::odrl::{ + render_policy_collection, Constraint, Operand, Permission, Policy, PolicyAsset, +}; +use registry_platform_canonical_json::canonicalize_json; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +use crate::contract::{AccessProfileSource, ActorKindSource, Operation}; +use crate::diagnostics::Diagnostic; +use crate::model::{CompiledEntity, CompiledRegistry}; +use crate::GeneratedArtifact; + +pub const COVERAGE_API_VERSION: &str = "id.registrystack.org/formats/breg/odrl-coverage/v1alpha1"; +pub const PROFILE_IRI: &str = "https://id.registrystack.org/profiles/breg-odrl/v1"; +pub const VOCAB: &str = "https://id.registrystack.org/vocab/breg-odrl/"; + +const ODRL_EQ: &str = "http://www.w3.org/ns/odrl/2/eq"; +const ODRL_HAS_PART: &str = "http://www.w3.org/ns/odrl/2/hasPart"; +const ODRL_IS_PART_OF: &str = "http://www.w3.org/ns/odrl/2/isPartOf"; +const ODRL_READ: &str = "http://www.w3.org/ns/odrl/2/read"; +const DCTERMS_DESCRIPTION: &str = "http://purl.org/dc/terms/description"; +const PROFILE_BYTES: &[u8] = include_bytes!("../../../products/breg/contracts/odrl-profile.jsonld"); + +const EXCLUDED_SURFACES: &[&str] = &[ + "actions", + "proposals", + "statistics", + "lookups", + "read-paths", + "revisions", + "snapshots", + "mutations", + "counts", + "filters", + "sorting", + "spatial-queries", + "data-export", + "provenance", +]; + +#[derive(Debug)] +pub struct OdrlExport { + pub artifacts: Vec, + pub coverage: Value, +} + +pub fn export_odrl( + registry: &CompiledRegistry, + selected_profile: Option<&str>, +) -> Result { + if let Some(selected) = selected_profile { + if !registry + .access_profile_displays() + .iter() + .any(|profile| profile.id == selected) + && !registry + .entities() + .values() + .any(|entity| entity.access_profiles.contains_key(selected)) + { + return Err(refusal( + "breg.odrl.unknown-access-profile", + "accessProfile", + "select an access profile declared by the compiled registry", + )); + } + } + + let ids = ExportIds::new(registry); + let mut policies = BTreeMap::::new(); + let mut grants = Vec::new(); + let mut exported = 0_u64; + + for entity in registry.entities().values() { + for (profile_id, access) in &entity.access_profiles { + audit_access_profile(access); + if selected_profile.is_some_and(|selected| selected != profile_id) { + continue; + } + for operation in &access.operations { + let operation_name = operation_name(*operation); + let reasons = omission_reasons(access, *operation); + let status = if reasons.is_empty() { + let policy = policies + .entry(profile_id.clone()) + .or_insert_with(|| Policy { + uid: ids.policy(profile_id), + profile: vec![PROFILE_IRI.to_owned()], + permissions: Vec::new(), + }); + policy.permissions.push(permission( + &ids, + entity, + access, + profile_id, + operation_name, + )); + exported += 1; + "exported" + } else { + "omitted" + }; + grants.push(json!({ + "recordType": entity.id, + "accessProfile": profile_id, + "operation": operation_name, + "status": status, + "reasons": reasons, + })); + } + } + } + + let considered = grants.len() as u64; + let coverage = json!({ + "apiVersion": COVERAGE_API_VERSION, + "kind": "BRegOdrlCoverage", + "registryId": registry.registry_id(), + "revision": registry.revision(), + "partial": true, + "audience": "operator-review", + "scope": "record-operation-grants", + "selectedAccessProfile": selected_profile, + "totals": { + "considered": considered, + "exported": exported, + "omitted": considered - exported, + }, + "grants": grants, + "excludedSurfaces": EXCLUDED_SURFACES, + }); + + let policies = policies.into_values().collect::>(); + let mut rendered = render_policy_collection(&ids.collection(), &policies).map_err(|_| { + refusal( + "breg.odrl.render-failed", + "odrl", + "the selected access rules could not be rendered as the supported ODRL profile", + ) + })?; + let collection = rendered.as_object_mut().ok_or_else(|| { + refusal( + "breg.odrl.render-failed", + "odrl", + "the ODRL renderer returned an invalid collection", + ) + })?; + collection.insert(format!("{VOCAB}partial"), Value::Bool(true)); + let members = collection + .get_mut("@graph") + .and_then(Value::as_array_mut) + .ok_or_else(|| { + refusal( + "breg.odrl.render-failed", + "odrl", + "the ODRL renderer returned an invalid policy graph", + ) + })?; + for policy in members { + policy[format!("{VOCAB}partial")] = Value::Bool(true); + } + collection.insert( + DCTERMS_DESCRIPTION.to_owned(), + Value::String( + "Partial, non-enforcing projection of supported BReg record-operation grants; review coverage.json before use." + .to_owned(), + ), + ); + + let mut artifacts = vec![ + json_artifact( + "generated/odrl/coverage.json", + "application/json", + &coverage, + )?, + json_artifact( + "generated/odrl/policies.jsonld", + "application/ld+json", + &rendered, + )?, + artifact( + "generated/odrl/profile.jsonld", + "application/ld+json", + PROFILE_BYTES.to_vec(), + ), + ]; + artifacts.sort_by(|left, right| left.path.cmp(&right.path)); + + Ok(OdrlExport { + artifacts, + coverage, + }) +} + +fn permission( + ids: &ExportIds, + entity: &CompiledEntity, + access: &AccessProfileSource, + profile_id: &str, + operation: &str, +) -> Permission { + let readable_fields = access + .readable_fields + .iter() + .map(|field| json!({"@value": field})) + .collect::>(); + let properties = BTreeMap::from([ + (format!("{VOCAB}recordType"), json!(entity.id)), + ( + format!("{VOCAB}readableFields"), + json!({"@list": readable_fields}), + ), + ]); + + Permission { + target: PolicyAsset { + uid: ids.projection(&entity.id, profile_id, operation), + part_of: Some(ids.record_type(&entity.id)), + properties, + }, + action: ODRL_READ.to_owned(), + constraints: constraints(access, profile_id, operation), + } +} + +fn constraints(access: &AccessProfileSource, profile_id: &str, operation: &str) -> Vec { + let mut constraints = vec![ + literal_constraint("authenticated", ODRL_EQ, json!(true)), + set_constraint( + "verifiedNonemptyClaims", + ODRL_HAS_PART, + std::iter::once(access.principal_claim.as_str()), + ), + literal_constraint("accessProfile", ODRL_EQ, json!(profile_id)), + literal_constraint("operation", ODRL_EQ, json!(operation)), + literal_constraint( + "delegatedActor", + ODRL_EQ, + json!(access.actor_kind == Some(ActorKindSource::Agent)), + ), + literal_constraint("taskGrantPresent", ODRL_EQ, json!(false)), + ]; + if !access.required_scopes.is_empty() { + constraints.push(set_constraint( + "tokenScopes", + ODRL_HAS_PART, + access.required_scopes.iter().map(String::as_str), + )); + } + if !access.required_purposes.is_empty() { + constraints.push(set_constraint( + "tokenPurpose", + ODRL_IS_PART_OF, + access.required_purposes.iter().map(String::as_str), + )); + } + if !access.requester_clients.is_empty() { + constraints.push(set_constraint( + "requesterClient", + ODRL_IS_PART_OF, + access.requester_clients.iter().map(String::as_str), + )); + } + if let Some(actor_kind) = access.actor_kind { + constraints.push(literal_constraint( + "actorKind", + ODRL_EQ, + json!(actor_kind_name(actor_kind)), + )); + } + constraints +} + +fn literal_constraint(term: &str, operator: &str, value: Value) -> Constraint { + Constraint { + left_operand: format!("{VOCAB}{term}"), + operator: operator.to_owned(), + right_operand: Operand::Literal(value), + } +} + +fn set_constraint<'a>( + term: &str, + operator: &str, + values: impl Iterator, +) -> Constraint { + Constraint { + left_operand: format!("{VOCAB}{term}"), + operator: operator.to_owned(), + right_operand: Operand::Set(values.map(|value| json!(value)).collect()), + } +} + +fn omission_reasons(access: &AccessProfileSource, operation: Operation) -> Vec<&'static str> { + let mut reasons = Vec::new(); + if !matches!(operation, Operation::Get | Operation::List) { + reasons.push("unsupported-operation"); + } + if !access.row_boundaries.is_empty() { + reasons.push("row-boundaries"); + } + if !access.membership_boundaries.is_empty() { + reasons.push("membership-boundaries"); + } + if !access.require_consent.is_empty() { + reasons.push("consent"); + } + if access.task_grant.is_some() { + reasons.push("task-grant"); + } + reasons +} + +/// Force every compiled access-profile member through an explicit review when +/// a new member is added. Export logic may ignore only the fields named here. +fn audit_access_profile(access: &AccessProfileSource) { + let AccessProfileSource { + id: _, + display: _, + default: _, + actor_kind: _, + requester_clients: _, + task_grant: _, + principal_claim: _, + required_scopes: _, + required_purposes: _, + operations: _, + readable_fields: _, + writable_fields: _, + filterable_fields: _, + sortable_fields: _, + spatial_queries: _, + row_boundaries: _, + membership_boundaries: _, + require_consent: _, + lookups: _, + read_paths: _, + allow_count: _, + revision_access: _, + provenance_fields: _, + allow_data_export: _, + } = access; +} + +fn operation_name(operation: Operation) -> &'static str { + match operation { + Operation::Create => "create", + Operation::Get => "get", + Operation::Lookup => "lookup", + Operation::List => "list", + Operation::Patch => "patch", + Operation::Tombstone => "tombstone", + Operation::Batch => "batch", + Operation::Revisions => "revisions", + Operation::Snapshot => "snapshot", + Operation::Invoke => "invoke", + Operation::Start => "start", + Operation::Import => "import", + } +} + +fn actor_kind_name(actor_kind: ActorKindSource) -> &'static str { + match actor_kind { + ActorKindSource::Human => "human", + ActorKindSource::Agent => "agent", + ActorKindSource::Service => "service", + } +} + +struct ExportIds { + root: String, + separator: char, +} + +impl ExportIds { + fn new(registry: &CompiledRegistry) -> Self { + let revision = segment(registry.revision()); + registry.manifest_projection().map_or_else( + || Self { + root: format!( + "urn:registrystack:breg:{}:revision:{}:odrl", + segment(registry.registry_id()), + revision + ), + separator: ':', + }, + |projection| Self { + root: format!( + "{}/.well-known/registrystack/odrl/revisions/{}", + projection.canonical_base_iri.trim_end_matches('/'), + revision + ), + separator: '/', + }, + ) + } + + fn child(&self, segments: &[&str]) -> String { + let separator = self.separator.to_string(); + format!( + "{}{}{}", + self.root, + self.separator, + segments + .iter() + .map(|value| segment(value)) + .collect::>() + .join(&separator) + ) + } + + fn collection(&self) -> String { + self.child(&["policies"]) + } + + fn policy(&self, profile: &str) -> String { + self.child(&["profiles", profile, "policy"]) + } + + fn record_type(&self, entity: &str) -> String { + self.child(&["record-types", entity]) + } + + fn projection(&self, entity: &str, profile: &str, operation: &str) -> String { + self.child(&[ + "record-types", + entity, + "profiles", + profile, + "operations", + operation, + "projection", + ]) + } +} + +fn segment(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(char::from(byte)); + } else { + encoded.push('%'); + encoded.push_str(&format!("{byte:02X}")); + } + } + encoded +} + +fn json_artifact( + path: &str, + media_type: &str, + value: &Value, +) -> Result { + let bytes = canonicalize_json(value).map_err(|_| { + refusal( + "breg.odrl.canonicalization-failed", + path, + "the ODRL export could not be canonicalized", + ) + })?; + Ok(artifact(path, media_type, bytes)) +} + +fn artifact(path: &str, media_type: &str, bytes: Vec) -> GeneratedArtifact { + GeneratedArtifact { + path: path.to_owned(), + media_type: media_type.to_owned(), + sha256: format!("sha256:{}", digest(&bytes)), + bytes, + } +} + +fn digest(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn refusal(code: &str, path: &str, message: &str) -> Diagnostic { + Diagnostic::error(code, path, message) +} + +#[cfg(all(test, feature = "runtime"))] +mod tests; diff --git a/crates/registry-breg/src/odrl_export/tests.rs b/crates/registry-breg/src/odrl_export/tests.rs new file mode 100644 index 0000000000..f1bdc36da1 --- /dev/null +++ b/crates/registry-breg/src/odrl_export/tests.rs @@ -0,0 +1,304 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{BTreeMap, BTreeSet}; + +use registry_manifest_core::odrl::{Constraint, Operand}; +use registry_platform_oidc::{grant_claims, ActorKind, Audience, ClaimNames, Claims, GrantClaims}; +use serde_json::{json, Map, Value}; + +use super::{constraints, ODRL_EQ, ODRL_HAS_PART, ODRL_IS_PART_OF, VOCAB}; +use crate::api::{authorize_profile_claims, VerifiedRequestClaims}; +use crate::contract::{AccessProfileSource, ActorKindSource, Operation}; + +fn profile(id: &str, actor_kind: Option) -> AccessProfileSource { + let mut value = json!({ + "id": id, + "principalClaim": "registry_principal", + "requiredScopes": ["registry:directory:read", "registry:records:read"], + "requiredPurposes": ["directory-administration", "service-delivery"], + "operations": ["get"], + "readableFields": ["email", "name"], + "rowBoundaries": [] + }); + if let Some(actor_kind) = actor_kind { + value["actorKind"] = json!(match actor_kind { + ActorKindSource::Human => "human", + ActorKindSource::Agent => "agent", + ActorKindSource::Service => "service", + }); + value["requesterClients"] = json!(["registry-console"]); + } + serde_json::from_value(value).unwrap() +} + +fn task_grant() -> GrantClaims { + let extra = json!({ + "registry_actor_kind": "agent", + "registry_grant_id": "grant-canary", + "registry_grant_source_issuer": "https://casework.example", + "registry_grant_client": "registry-console", + "registry_grant_resource": "urn:registry:breg", + "registry_grant_exp": 1_900, + "registry_grant_bounds": { + "type": "evidence", + "requirement": "urn:requirement:one" + }, + "registry_purpose": "directory-administration", + "registry_approver": "h:approver-canary" + }); + grant_claims( + &Claims { + sub: Some("principal-canary".to_owned()), + iss: Some("https://issuer.example".to_owned()), + aud: Some(Audience::One("urn:registry:breg".to_owned())), + exp: Some(2_000), + iat: Some(1_000), + nbf: None, + azp: Some("registry-console".to_owned()), + client_id: None, + extra: extra.as_object().cloned().unwrap_or_else(Map::new), + }, + &ClaimNames::default(), + 1_500, + ) + .unwrap() + .unwrap() +} + +struct Scenario<'a> { + selected_profile: &'a str, + operation: Operation, + principal_claim: &'a str, + scopes: &'a [&'a str], + purpose: Option<&'a str>, + actor_kind: Option, + requester_client: Option<&'a str>, + actor_subject: Option<&'a str>, + task_grant_present: bool, +} + +fn verified(scenario: &Scenario<'_>) -> VerifiedRequestClaims { + VerifiedRequestClaims::authenticated( + scenario.principal_claim, + "principal-canary", + scenario + .scopes + .iter() + .map(|scope| (*scope).to_owned()) + .collect::>(), + scenario.purpose.map(str::to_owned), + BTreeMap::new(), + ) + .unwrap() + .with_contextual_authority( + scenario.actor_kind, + scenario.requester_client.map(str::to_owned), + scenario.actor_subject.map(str::to_owned), + scenario.task_grant_present.then(task_grant), + BTreeMap::new(), + ) + .unwrap() +} + +fn value<'a>(operand: &'a Operand, expected_operator: &str, constraint: &Constraint) -> &'a Value { + assert_eq!(constraint.operator, expected_operator); + let Operand::Literal(value) = operand else { + panic!("expected literal operand") + }; + value +} + +fn values<'a>( + operand: &'a Operand, + expected_operator: &str, + constraint: &Constraint, +) -> &'a [Value] { + assert_eq!(constraint.operator, expected_operator); + let Operand::Set(values) = operand else { + panic!("expected set operand") + }; + values +} + +fn emitted_admits( + profile: &AccessProfileSource, + scenario: &Scenario<'_>, + claims: &VerifiedRequestClaims, +) -> bool { + constraints(profile, &profile.id, "get") + .iter() + .all(|constraint| { + let term = constraint.left_operand.strip_prefix(VOCAB).unwrap(); + match term { + "authenticated" => { + value(&constraint.right_operand, ODRL_EQ, constraint) == &json!(true) + && claims.principal().is_some() + } + "verifiedNonemptyClaims" => { + let required = values(&constraint.right_operand, ODRL_HAS_PART, constraint); + required.iter().all(|name| { + name.as_str() == claims.principal_claim() && claims.principal().is_some() + }) + } + "accessProfile" => { + value(&constraint.right_operand, ODRL_EQ, constraint) + == &json!(scenario.selected_profile) + } + "operation" => { + value(&constraint.right_operand, ODRL_EQ, constraint) + == &json!(match scenario.operation { + Operation::Get => "get", + Operation::List => "list", + _ => "other", + }) + } + "delegatedActor" => { + value(&constraint.right_operand, ODRL_EQ, constraint) + == &json!(claims.actor_subject().is_some()) + } + "taskGrantPresent" => { + value(&constraint.right_operand, ODRL_EQ, constraint) + == &json!(claims.grant().is_some()) + } + "tokenScopes" => values(&constraint.right_operand, ODRL_HAS_PART, constraint) + .iter() + .all(|scope| scope.as_str().is_some_and(|scope| claims.has_scope(scope))), + "tokenPurpose" => values(&constraint.right_operand, ODRL_IS_PART_OF, constraint) + .iter() + .any(|purpose| purpose.as_str() == claims.purpose()), + "requesterClient" => values(&constraint.right_operand, ODRL_IS_PART_OF, constraint) + .iter() + .any(|client| client.as_str() == claims.requester_client()), + "actorKind" => { + let actual = claims.actor_kind().map(|actor_kind| match actor_kind { + ActorKind::Human => "human", + ActorKind::Agent => "agent", + ActorKind::Service => "service", + }); + value(&constraint.right_operand, ODRL_EQ, constraint).as_str() == actual + } + other => panic!("unexpected constraint {other}"), + } + }) +} + +fn native_admits( + profile: &AccessProfileSource, + scenario: &Scenario<'_>, + claims: &VerifiedRequestClaims, +) -> bool { + scenario.selected_profile == profile.id + && profile.operations.contains(&scenario.operation) + && authorize_profile_claims(profile, claims).is_ok() +} + +fn assert_differential(profile: &AccessProfileSource, scenario: Scenario<'_>) { + let claims = verified(&scenario); + assert_eq!( + emitted_admits(profile, &scenario, &claims), + native_admits(profile, &scenario, &claims), + "profile={} selected={} operation={:?} actor={:?} client={:?} delegated={} grant={}", + profile.id, + scenario.selected_profile, + scenario.operation, + scenario.actor_kind, + scenario.requester_client, + scenario.actor_subject.is_some(), + scenario.task_grant_present, + ); +} + +fn valid<'a>(profile: &'a AccessProfileSource) -> Scenario<'a> { + Scenario { + selected_profile: &profile.id, + operation: Operation::Get, + principal_claim: "registry_principal", + scopes: &["registry:directory:read", "registry:records:read"], + purpose: Some("directory-administration"), + actor_kind: match profile.actor_kind { + Some(ActorKindSource::Human) => Some(ActorKind::Human), + Some(ActorKindSource::Agent) => Some(ActorKind::Agent), + Some(ActorKindSource::Service) => Some(ActorKind::Service), + None => None, + }, + requester_client: profile.actor_kind.is_some().then_some("registry-console"), + actor_subject: (profile.actor_kind == Some(ActorKindSource::Agent)) + .then_some("delegating-subject"), + task_grant_present: false, + } +} + +#[test] +fn emitted_constraints_match_native_profile_admission() { + for actor_kind in [ + None, + Some(ActorKindSource::Human), + Some(ActorKindSource::Service), + Some(ActorKindSource::Agent), + ] { + let id = match actor_kind { + None => "unspecified-reader", + Some(ActorKindSource::Human) => "human-reader", + Some(ActorKindSource::Service) => "service-reader", + Some(ActorKindSource::Agent) => "agent-reader", + }; + let profile = profile(id, actor_kind); + + let allowed = valid(&profile); + let claims = verified(&allowed); + assert!(native_admits(&profile, &allowed, &claims)); + assert!(emitted_admits(&profile, &allowed, &claims)); + assert_differential(&profile, allowed); + + let mut second_allowed_purpose = valid(&profile); + second_allowed_purpose.purpose = Some("service-delivery"); + assert_differential(&profile, second_allowed_purpose); + + let mut missing_scope = valid(&profile); + missing_scope.scopes = &["registry:directory:read"]; + assert_differential(&profile, missing_scope); + + let mut wrong_purpose = valid(&profile); + wrong_purpose.purpose = Some("unrelated-purpose"); + assert_differential(&profile, wrong_purpose); + + let mut wrong_profile = valid(&profile); + wrong_profile.selected_profile = "another-profile"; + assert_differential(&profile, wrong_profile); + + let mut wrong_operation = valid(&profile); + wrong_operation.operation = Operation::List; + assert_differential(&profile, wrong_operation); + + let mut wrong_principal_claim = valid(&profile); + wrong_principal_claim.principal_claim = "another_principal"; + assert_differential(&profile, wrong_principal_claim); + + let mut grant_present = valid(&profile); + grant_present.task_grant_present = true; + assert_differential(&profile, grant_present); + + if actor_kind.is_some() { + let mut wrong_actor = valid(&profile); + wrong_actor.actor_kind = Some(ActorKind::Human); + if actor_kind == Some(ActorKindSource::Human) { + wrong_actor.actor_kind = Some(ActorKind::Service); + } + assert_differential(&profile, wrong_actor); + + let mut wrong_client = valid(&profile); + wrong_client.requester_client = Some("other-client"); + assert_differential(&profile, wrong_client); + } + + if actor_kind == Some(ActorKindSource::Agent) { + let mut standing_actor_missing = valid(&profile); + standing_actor_missing.actor_subject = None; + assert_differential(&profile, standing_actor_missing); + } else { + let mut unexpected_delegation = valid(&profile); + unexpected_delegation.actor_subject = Some("delegating-subject"); + assert_differential(&profile, unexpected_delegation); + } + } +} diff --git a/crates/registry-breg/tests/odrl_export.rs b/crates/registry-breg/tests/odrl_export.rs new file mode 100644 index 0000000000..b227171b56 --- /dev/null +++ b/crates/registry-breg/tests/odrl_export.rs @@ -0,0 +1,608 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeSet; + +use registry_breg::compiler::{compile_project, module_digest, CompileProfile}; +use registry_breg::contract::{ + parse_module_json, parse_module_yaml, parse_project_json, parse_project_yaml, +}; +use registry_breg::odrl_export::{export_odrl, PROFILE_IRI, VOCAB}; +use registry_breg::{CompiledRegistry, GeneratedArtifact}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +const MINIMAL: &[u8] = include_bytes!("../../../products/breg/examples/minimal/registry.yaml"); +const MEMBERSHIP: &[u8] = + include_bytes!("../../../products/breg/fixtures/organization-membership-access/registry.yaml"); +const CONSENT: &[u8] = + include_bytes!("../../../products/breg/fixtures/consent-person-registry/registry.yaml"); +const CONSENT_PERSON: &[u8] = include_bytes!( + "../../../products/breg/fixtures/consent-person-registry/modules/consent-person/module.yaml" +); +const CONSENT_CORE: &[u8] = include_bytes!( + "../../../products/breg/fixtures/consent-person-registry/modules/consent-person-registry-core/module.yaml" +); + +const ODRL_EQ: &str = "http://www.w3.org/ns/odrl/2/eq"; +const ODRL_HAS_PART: &str = "http://www.w3.org/ns/odrl/2/hasPart"; +const ODRL_IS_PART_OF: &str = "http://www.w3.org/ns/odrl/2/isPartOf"; +const ODRL_READ: &str = "http://www.w3.org/ns/odrl/2/read"; + +fn compile_source(source: &Value) -> CompiledRegistry { + let project = parse_project_json(&serde_json::to_vec(source).unwrap()).unwrap(); + compile_project(&project, &[], CompileProfile::Authoring).expect("fixture compiles") +} + +fn minimal_source() -> Value { + registry_platform_yaml::Reader::new("minimal fixture") + .scan(MINIMAL) + .unwrap() + .unwrap() + .to_json_value() +} + +fn supported_source() -> Value { + let mut source = minimal_source(); + let profile = &mut source["accessProfiles"][0]; + profile["actorKind"] = json!("human"); + profile["requesterClients"] = json!(["registry-console", "registry-review"]); + profile["requiredScopes"] = json!(["registry:directory:read", "registry:records:read"]); + profile["requiredPurposes"] = json!(["directory-administration", "service-delivery"]); + profile["permissions"]["records"][0]["rowBoundaries"] = json!("unrestricted"); + source +} + +fn actor_profiles_source() -> Value { + let mut source = supported_source(); + let mut base = source["accessProfiles"][0].clone(); + let mut read_grant = base["permissions"]["records"][0].clone(); + read_grant["operations"] = json!(["get", "list"]); + read_grant.as_object_mut().unwrap().remove("writableFields"); + base["permissions"] = json!({"records": [read_grant]}); + let profiles = [ + ("unspecified-reader", None), + ("human-reader", Some("human")), + ("service-reader", Some("service")), + ("agent-reader", Some("agent")), + ] + .into_iter() + .map(|(id, actor_kind)| { + let mut profile = base.clone(); + profile["id"] = json!(id); + profile["default"] = json!(id == "unspecified-reader"); + if let Some(actor_kind) = actor_kind { + profile["actorKind"] = json!(actor_kind); + } else { + profile.as_object_mut().unwrap().remove("actorKind"); + profile.as_object_mut().unwrap().remove("requesterClients"); + } + profile + }) + .collect(); + source["accessProfiles"] = Value::Array(profiles); + source.as_object_mut().unwrap().remove("actions"); + source +} + +fn compile_with_modules(project: &[u8], module_sources: &[&[u8]]) -> CompiledRegistry { + let mut project = parse_project_yaml(project).expect("fixture project parses"); + let modules = module_sources + .iter() + .map(|source| parse_module_yaml(source).expect("fixture module parses")) + .collect::>(); + for lock in &mut project.modules { + let module = modules + .iter() + .find(|module| module.id == lock.id) + .expect("every locked module is supplied"); + lock.digest = Some(module_digest(module)); + } + compile_project(&project, &modules, CompileProfile::Authoring).expect("fixture compiles") +} + +fn artifact<'a>(artifacts: &'a [GeneratedArtifact], path: &str) -> &'a GeneratedArtifact { + artifacts + .iter() + .find(|artifact| artifact.path == path) + .unwrap_or_else(|| panic!("missing {path}")) +} + +fn document(export: ®istry_breg::odrl_export::OdrlExport, path: &str) -> Value { + serde_json::from_slice(&artifact(&export.artifacts, path).bytes).expect("artifact is JSON") +} + +fn constraint<'a>(permission: &'a Value, term: &str) -> &'a Value { + permission["odrl:constraint"] + .as_array() + .expect("permission constraints") + .iter() + .find(|constraint| constraint["odrl:leftOperand"]["@id"] == term) + .unwrap_or_else(|| panic!("missing constraint {term}")) +} + +fn grant<'a>(coverage: &'a Value, operation: &str) -> &'a Value { + coverage["grants"] + .as_array() + .expect("coverage grants") + .iter() + .find(|grant| { + grant["recordType"] == "contact" + && grant["accessProfile"] == "directory-editor" + && grant["operation"] == operation + }) + .unwrap_or_else(|| panic!("missing contact/directory-editor/{operation}")) +} + +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[test] +fn registered_coverage_example_matches_minimal_project() { + let registry = compile_source(&minimal_source()); + let export = export_odrl(®istry, None).unwrap(); + assert_eq!( + artifact(&export.artifacts, "generated/odrl/coverage.json").bytes, + include_bytes!("../../../products/breg/examples/formats/odrl-coverage.json") + ); +} + +#[test] +fn registered_policy_example_matches_authoring_display_fixture() { + let project = parse_project_yaml(include_bytes!( + "../../../products/breg/fixtures/authoring-display/registry.yaml" + )) + .unwrap(); + let registry = compile_project(&project, &[], CompileProfile::Authoring).unwrap(); + let export = export_odrl(®istry, None).unwrap(); + assert_eq!( + artifact(&export.artifacts, "generated/odrl/policies.jsonld").bytes, + include_bytes!("../../../products/breg/examples/formats/odrl-policies.jsonld") + ); + assert!(export.coverage["totals"]["exported"].as_u64().unwrap() > 0); +} + +#[test] +fn supported_reads_preserve_profile_conditions_and_projection_fields() { + let registry = compile_source(&supported_source()); + let source_artifacts = serde_json::to_value(registry.artifacts()).unwrap(); + + let first = export_odrl(®istry, Some("directory-editor")).expect("export succeeds"); + let second = export_odrl(®istry, Some("directory-editor")).expect("export repeats"); + + assert_eq!( + first.artifacts, second.artifacts, + "export bytes and digests are stable" + ); + assert_eq!( + first + .artifacts + .iter() + .map(|artifact| artifact.path.as_str()) + .collect::>(), + [ + "generated/odrl/coverage.json", + "generated/odrl/policies.jsonld", + "generated/odrl/profile.jsonld", + ] + ); + for generated in &first.artifacts { + assert_eq!( + generated.sha256, + format!("sha256:{}", sha256_hex(&generated.bytes)) + ); + } + assert_eq!( + artifact(&first.artifacts, "generated/odrl/profile.jsonld").bytes, + include_bytes!("../../../products/breg/contracts/odrl-profile.jsonld") + ); + assert_eq!( + serde_json::to_value(registry.artifacts()).unwrap(), + source_artifacts + ); + assert!(registry + .artifacts() + .entries() + .keys() + .all(|path| !path.starts_with("generated/odrl/"))); + + assert_eq!( + first.coverage["totals"], + json!({"considered":4,"exported":2,"omitted":2}) + ); + assert_eq!(grant(&first.coverage, "get")["status"], "exported"); + assert_eq!(grant(&first.coverage, "list")["status"], "exported"); + assert_eq!( + grant(&first.coverage, "create")["reasons"], + json!(["unsupported-operation"]) + ); + assert_eq!( + grant(&first.coverage, "patch")["reasons"], + json!(["unsupported-operation"]) + ); + assert!(first.coverage["excludedSurfaces"] + .as_array() + .is_some_and(|surfaces| !surfaces.is_empty())); + + let policies = document(&first, "generated/odrl/policies.jsonld"); + assert_eq!(policies[format!("{VOCAB}partial")], true); + assert!(policies["http://purl.org/dc/terms/description"].is_string()); + let policy = &policies["@graph"][0]; + assert_eq!(policy[format!("{VOCAB}partial")], true); + assert_eq!(policy["odrl:profile"], json!([{"@id":PROFILE_IRI}])); + let permissions = policy["odrl:permission"] + .as_array() + .expect("two read permissions"); + assert_eq!(permissions.len(), 2); + for permission in permissions { + assert_eq!(permission["odrl:action"]["@id"], ODRL_READ); + assert_eq!( + permission["odrl:target"][format!("{VOCAB}recordType")], + "contact" + ); + assert_eq!( + permission["odrl:target"][format!("{VOCAB}readableFields")], + json!({"@list":[{"@value":"email"},{"@value":"jurisdiction"},{"@value":"name"}]}) + ); + assert_eq!( + constraint(permission, &format!("{VOCAB}authenticated"))["odrl:rightOperand"], + json!({"@value":true}) + ); + assert_eq!( + constraint(permission, &format!("{VOCAB}authenticated"))["odrl:operator"]["@id"], + ODRL_EQ + ); + assert_eq!( + constraint(permission, &format!("{VOCAB}verifiedNonemptyClaims"))["odrl:rightOperand"], + json!({"@list":[{"@value":"registry_principal"}]}) + ); + assert_eq!( + constraint(permission, &format!("{VOCAB}verifiedNonemptyClaims"))["odrl:operator"] + ["@id"], + ODRL_HAS_PART + ); + assert_eq!( + constraint(permission, &format!("{VOCAB}tokenScopes"))["odrl:rightOperand"], + json!({"@list":[{"@value":"registry:directory:read"},{"@value":"registry:records:read"}]}) + ); + assert_eq!( + constraint(permission, &format!("{VOCAB}tokenPurpose"))["odrl:operator"]["@id"], + ODRL_IS_PART_OF + ); + assert_eq!( + constraint(permission, &format!("{VOCAB}tokenPurpose"))["odrl:rightOperand"], + json!({"@list":[{"@value":"directory-administration"},{"@value":"service-delivery"}]}) + ); + assert_eq!( + constraint(permission, &format!("{VOCAB}requesterClient"))["odrl:rightOperand"], + json!({"@list":[{"@value":"registry-console"},{"@value":"registry-review"}]}) + ); + assert_eq!( + constraint(permission, &format!("{VOCAB}actorKind"))["odrl:rightOperand"], + json!({"@value":"human"}) + ); + assert_eq!( + constraint(permission, &format!("{VOCAB}accessProfile"))["odrl:rightOperand"], + json!({"@value":"directory-editor"}) + ); + let operation = + &constraint(permission, &format!("{VOCAB}operation"))["odrl:rightOperand"]["@value"]; + assert!(operation == "get" || operation == "list"); + } +} + +#[test] +fn every_read_permission_preserves_delegation_and_task_grant_absence() { + let registry = compile_source(&actor_profiles_source()); + let export = export_odrl(®istry, None).unwrap(); + let policies = document(&export, "generated/odrl/policies.jsonld"); + let permissions = policies["@graph"] + .as_array() + .unwrap() + .iter() + .flat_map(|policy| policy["odrl:permission"].as_array().unwrap()) + .collect::>(); + + assert_eq!(permissions.len(), 8); + for permission in permissions { + let profile = constraint(permission, &format!("{VOCAB}accessProfile"))["odrl:rightOperand"] + ["@value"] + .as_str() + .unwrap(); + assert_eq!( + constraint(permission, &format!("{VOCAB}delegatedActor"))["odrl:rightOperand"], + json!({"@value":profile == "agent-reader"}) + ); + assert_eq!( + constraint(permission, &format!("{VOCAB}taskGrantPresent"))["odrl:rightOperand"], + json!({"@value":false}) + ); + } +} + +#[test] +fn row_membership_and_consent_restrictions_omit_the_complete_read_grant() { + let row = compile_source(&minimal_source()); + let row_export = export_odrl(&row, Some("directory-editor")).unwrap(); + for operation in ["get", "list"] { + assert_eq!(grant(&row_export.coverage, operation)["status"], "omitted"); + assert!(grant(&row_export.coverage, operation)["reasons"] + .as_array() + .unwrap() + .contains(&json!("row-boundaries"))); + } + assert_eq!( + document(&row_export, "generated/odrl/policies.jsonld")["@graph"], + json!([]) + ); + + let membership = compile_project( + &parse_project_yaml(MEMBERSHIP).unwrap(), + &[], + CompileProfile::Authoring, + ) + .unwrap(); + let membership_export = export_odrl(&membership, Some("member")).unwrap(); + let membership_reads = membership_export.coverage["grants"] + .as_array() + .unwrap() + .iter() + .filter(|grant| grant["operation"] == "get" || grant["operation"] == "list") + .collect::>(); + assert_eq!(membership_reads.len(), 2); + assert!(membership_reads.iter().all(|grant| { + grant["status"] == "omitted" + && grant["reasons"] + .as_array() + .unwrap() + .contains(&json!("membership-boundaries")) + })); + + let consent = compile_with_modules(CONSENT, &[CONSENT_PERSON, CONSENT_CORE]); + let consent_export = export_odrl(&consent, Some("food-targeting")).unwrap(); + assert!(consent_export.coverage["grants"] + .as_array() + .unwrap() + .iter() + .all(|grant| { + grant["status"] == "omitted" + && grant["reasons"] + .as_array() + .unwrap() + .contains(&json!("consent")) + })); +} + +#[test] +fn task_grant_mutant_is_not_broadened_and_profile_selection_is_exact() { + let mut source = supported_source(); + let mut other = source["accessProfiles"][0].clone(); + other["id"] = json!("other-reader"); + other["default"] = json!(false); + source["accessProfiles"].as_array_mut().unwrap().push(other); + let registry = compile_source(&source); + + let all = export_odrl(®istry, None).unwrap(); + assert!(all.coverage["grants"] + .as_array() + .unwrap() + .iter() + .any(|grant| grant["accessProfile"] == "directory-editor")); + assert!(all.coverage["grants"] + .as_array() + .unwrap() + .iter() + .any(|grant| grant["accessProfile"] == "other-reader")); + assert_eq!( + document(&all, "generated/odrl/policies.jsonld")["@graph"] + .as_array() + .unwrap() + .len(), + 2 + ); + + let selected = export_odrl(®istry, Some("directory-editor")).unwrap(); + assert!(selected.coverage["grants"] + .as_array() + .unwrap() + .iter() + .all(|grant| grant["accessProfile"] == "directory-editor")); + assert!(export_odrl(®istry, Some("directory")).is_err()); + let unknown = export_odrl(®istry, Some("not-a-profile")).unwrap_err(); + assert_eq!(unknown.code, "breg.odrl.unknown-access-profile"); + + let mut wire = serde_json::to_value(®istry).unwrap(); + wire["entities"]["contact"]["accessProfiles"]["directory-editor"]["taskGrant"] = json!({ + "sourceIssuer":"https://tasks.example.test", + "permissions":[{"collection":"record:contact","operations":["get"]}] + }); + let mutant: CompiledRegistry = serde_json::from_value(wire).unwrap(); + let export = export_odrl(&mutant, Some("directory-editor")).unwrap(); + for operation in ["get", "list"] { + assert_eq!(grant(&export.coverage, operation)["status"], "omitted"); + assert!(grant(&export.coverage, operation)["reasons"] + .as_array() + .unwrap() + .contains(&json!("task-grant"))); + } + assert_eq!( + document(&export, "generated/odrl/policies.jsonld")["@graph"], + json!([]) + ); +} + +#[test] +fn exact_selection_of_known_action_only_profile_is_empty_and_valid() { + let mut source = minimal_source(); + source["accessProfiles"][0]["id"] = json!("action-only"); + source["accessProfiles"][0]["permissions"] + .as_object_mut() + .unwrap() + .remove("records"); + source["accessProfiles"][0]["permissions"] + .as_object_mut() + .unwrap() + .remove("proposals"); + let registry = compile_source(&source); + let export = export_odrl(®istry, Some("action-only")).unwrap(); + + assert_eq!( + export.coverage["totals"], + json!({"considered":0,"exported":0,"omitted":0}) + ); + assert_eq!(export.coverage["selectedAccessProfile"], "action-only"); + assert_eq!( + document(&export, "generated/odrl/policies.jsonld")["@graph"], + json!([]) + ); + let surfaces = export.coverage["excludedSurfaces"].as_array().unwrap(); + for surface in [ + "actions", + "proposals", + "statistics", + "lookups", + "read-paths", + "revisions", + "snapshots", + "mutations", + ] { + assert!( + surfaces.contains(&json!(surface)), + "missing {surface}: {surfaces:?}" + ); + } +} + +#[test] +fn exact_selection_accepts_profiles_from_modules_and_extensions() { + let project = parse_project_json( + &serde_json::to_vec(&json!({ + "apiVersion": "id.registrystack.org/formats/breg/project/v1alpha1", + "kind": "BRegProject", + "project": {"id":"module-profile", "version":"1", "defaultLanguage":"en", + "canonicalBaseIri":"https://authoring.example.test"}, + "datasets": [{"id":"records"}], + "recordTypes": [{ + "id":"case-file", "primaryDataset":"records", "route":"case-files", + "mutationMode":"mutable", + "fields":[{"id":"code", "type":"string", "maximumLength":32, + "classification":"internal"}] + }], + "modules": [{"id":"core", "version":"1"}] + })) + .unwrap(), + ) + .unwrap(); + let profile = |id| { + json!({ + "id":id, "principalClaim":"sub", "operations":["get"], + "readableFields":["code"], "requiredScopes":"unrestricted", + "rowBoundaries":"unrestricted" + }) + }; + let module = parse_module_json( + &serde_json::to_vec(&json!({ + "apiVersion":"id.registrystack.org/formats/breg/module/v1alpha1", + "kind":"BRegModule", "id":"core", "version":"1", + "recordTypes":[{ + "id":"module-record", "primaryDataset":"records", "route":"module-records", + "mutationMode":"mutable", + "fields":[{"id":"code", "type":"string", "maximumLength":32, + "classification":"internal"}], + "accessProfiles":[profile("module-reader")] + }], + "extendRecordTypes":[{ + "recordType":"case-file", "accessProfiles":[profile("extension-reader")] + }] + })) + .unwrap(), + ) + .unwrap(); + let registry = compile_project(&project, &[module], CompileProfile::Authoring).unwrap(); + assert!(registry.access_profile_displays().is_empty()); + for (id, record_type) in [ + ("module-reader", "module-record"), + ("extension-reader", "case-file"), + ] { + let export = export_odrl(®istry, Some(id)).expect("compiled profile is selectable"); + assert_eq!(export.coverage["selectedAccessProfile"], id); + assert_eq!( + export.coverage["totals"], + json!({"considered":1,"exported":1,"omitted":0}) + ); + assert_eq!(export.coverage["grants"][0]["recordType"], record_type); + assert_eq!(export.coverage["grants"][0]["accessProfile"], id); + } + assert!(export_odrl(®istry, Some("module")).is_err()); +} + +#[test] +fn profile_declares_every_emitted_extension_and_adopts_read() { + fn emitted_terms(value: &Value, terms: &mut BTreeSet) { + match value { + Value::Object(object) => { + for (key, value) in object { + if key.starts_with(VOCAB) { + terms.insert(key.clone()); + } + emitted_terms(value, terms); + } + } + Value::Array(values) => { + for value in values { + emitted_terms(value, terms); + } + } + Value::String(value) if value.starts_with(VOCAB) || value == ODRL_READ => { + terms.insert(value.clone()); + } + _ => {} + } + } + + let registry = compile_source(&supported_source()); + let export = export_odrl(®istry, None).unwrap(); + let policies = document(&export, "generated/odrl/policies.jsonld"); + let profile = document(&export, "generated/odrl/profile.jsonld"); + let graph = profile["@graph"].as_array().unwrap(); + let collection = graph + .iter() + .find(|node| node["@type"] == "skos:Collection") + .expect("profile vocabulary is a machine-readable collection"); + let expand = |value: &Value| { + let id = value.as_str().unwrap(); + id.strip_prefix("breg:").map_or_else( + || id.replace("odrl:", "http://www.w3.org/ns/odrl/2/"), + |term| format!("{VOCAB}{term}"), + ) + }; + let members = collection["skos:member"] + .as_array() + .unwrap() + .iter() + .map(|member| expand(&member["@id"])) + .collect::>(); + let mut emitted = BTreeSet::new(); + emitted_terms(&policies, &mut emitted); + assert_eq!( + emitted.len(), + 14, + "thirteen BReg terms and odrl:read are exercised" + ); + assert_eq!(members, emitted); + for term in graph + .iter() + .filter(|node| node["@type"] != "skos:Collection") + { + assert_eq!(term["rdfs:isDefinedBy"]["@id"], PROFILE_IRI); + assert!(members.contains(&expand(&term["@id"]))); + } + assert_eq!( + graph.len(), + 14, + "one collection and thirteen term definitions" + ); +} diff --git a/crates/registry-bregctl/src/lib.rs b/crates/registry-bregctl/src/lib.rs index 5747aedc51..9b0aa58d55 100644 --- a/crates/registry-bregctl/src/lib.rs +++ b/crates/registry-bregctl/src/lib.rs @@ -624,7 +624,7 @@ struct GenerateArgs { #[arg(long, value_name = "DIRECTORY")] output: PathBuf, - /// Existing registry lookup access profile (evidence-source only). + /// Access profile: required for evidence-source; optional exact selection for odrl. #[arg(long)] access_profile: Option, /// Compiled Record type to export (evidence-source only). @@ -1590,6 +1590,8 @@ enum ArtifactSelector { Schemas, Actions, Manifest, + /// Partial ODRL read policies and coverage for operator review. + Odrl, Metadata, Sql, EvidenceSource, @@ -8223,6 +8225,9 @@ fn project_lock(project_path: &Path, check_only: bool) -> Result Result { + if args.artifact == ArtifactSelector::Odrl { + return generate_odrl(args); + } let fail = |message: &str| { source_failure( "generate", @@ -8240,7 +8245,7 @@ fn generate_requested(args: &GenerateArgs) -> Result Result Result { + if args.entity.is_some() + || !args.selectors.is_empty() + || !args.fields.is_empty() + || args.source_id.is_some() + || args.connection.is_some() + { + return Err(source_failure( + "generate", + diagnostic( + "odrl.arguments", + "arguments", + "generate odrl accepts --access-profile; lookup and Evidence source flags apply only to generate evidence-source", + ), + DiagnosticArtifact::CommandArguments, + SuggestedAction::CorrectCommandUsage, + )); + } + let profile = profile(args.production); + let compiled = compile(&args.project, profile, "generate")?; + let export = registry_breg::odrl_export::export_odrl(&compiled, args.access_profile.as_deref()) + .map_err(|diagnostic| { + let (artifact, action) = if diagnostic.code == "breg.odrl.unknown-access-profile" { + ( + DiagnosticArtifact::CommandArguments, + SuggestedAction::CorrectCommandUsage, + ) + } else { + ( + DiagnosticArtifact::RegistryProject, + SuggestedAction::CorrectAuthoringSource, + ) + }; + source_failure("generate", diagnostic, artifact, action) + })?; + write_artifacts(&args.output, &export.artifacts).map_err(|diagnostic| { + source_failure( + "generate", + diagnostic, + DiagnosticArtifact::GeneratedArtifacts, + SuggestedAction::RetryArtifactGeneration, + ) + })?; + let totals = &export.coverage["totals"]; + let summary = format!( + "ODRL coverage: {} of {} record-operation grants exported; {} omitted. Review generated/odrl/coverage.json for reasons.", + totals["exported"], totals["considered"], totals["omitted"], + ); + Ok(SuccessReport { + ok: true, + command: "generate", + profile, + revision: Some(compiled.revision().to_owned()), + registry_revision: None, + package_digest: None, + findings: compiler_findings(&compiled), + artifacts: export.artifacts.iter().map(|artifact| { + artifact_report(&artifact.path, &artifact.media_type, &artifact.bytes) + }).collect(), + explanation: Some(export.coverage), + next_steps: vec![ + summary, + "These partial policies contain configuration details for operator review. Review them before sharing; they do not establish runtime access or replace caller-filtered metadata.".to_owned(), + ], + }) +} + fn generate( selector: ArtifactSelector, project_path: &Path, @@ -9915,7 +9987,7 @@ impl ArtifactSelector { ArtifactSelector::Manifest => path.starts_with("generated/manifest/"), ArtifactSelector::Metadata => path == "generated/metadata/registry.json", ArtifactSelector::Sql => path == "generated/postgres/schema.sql", - ArtifactSelector::EvidenceSource => false, + ArtifactSelector::EvidenceSource | ArtifactSelector::Odrl => false, ArtifactSelector::Sdk => { path.starts_with("generated/sdk/") || path.starts_with("generated/review-context-schemas/") diff --git a/crates/registry-bregctl/tests/odrl_cli.rs b/crates/registry-bregctl/tests/odrl_cli.rs new file mode 100644 index 0000000000..564dede829 --- /dev/null +++ b/crates/registry-bregctl/tests/odrl_cli.rs @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 +#![allow( + clippy::disallowed_methods, + reason = "tests read back generated artifacts and author synthetic project fixtures" +)] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde_json::{json, Value}; + +fn fixture() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../products/breg/examples/minimal") +} + +fn run(project: &Path, output: &Path, extra: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_bregctl")) + .args(["--format", "json", "generate", "odrl"]) + .arg(project) + .arg("--output") + .arg(output) + .args(extra) + .output() + .expect("run bregctl") +} + +fn read(path: &Path) -> Value { + serde_json::from_slice(&std::fs::read(path).expect("generated file")).expect("valid JSON") +} + +#[test] +fn odrl_cli_exports_supported_reads_and_reports_omissions() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let project = temporary.path().join("project"); + std::fs::create_dir(&project).unwrap(); + let bytes = std::fs::read(fixture().join("registry.yaml")).unwrap(); + let mut source = registry_platform_yaml::Reader::new("registry.yaml") + .scan(&bytes) + .unwrap() + .unwrap() + .to_json_value(); + source["accessProfiles"][0]["permissions"]["records"][0]["rowBoundaries"] = + json!("unrestricted"); + std::fs::write( + project.join("registry.yaml"), + serde_norway::to_string(&source).unwrap(), + ) + .unwrap(); + + let output = temporary.path().join("output"); + let result = run(&project, &output, &["--access-profile", "directory-editor"]); + assert!(result.status.success(), "{result:?}"); + assert!(result.stderr.is_empty(), "{result:?}"); + let report: Value = serde_json::from_slice(&result.stdout).unwrap(); + assert_eq!(report["ok"], true); + let coverage = read(&output.join("generated/odrl/coverage.json")); + assert_eq!(coverage["kind"], "BRegOdrlCoverage"); + assert_eq!(coverage["partial"], true); + assert_eq!( + coverage["totals"], + json!({"considered": 4, "exported": 2, "omitted": 2}) + ); + let policies = read(&output.join("generated/odrl/policies.jsonld")); + assert!(!policies["@graph"].as_array().unwrap().is_empty()); + assert!(output.join("generated/odrl/profile.jsonld").is_file()); + assert!(!output.join("generated/manifest").exists()); + + let second = temporary.path().join("second"); + assert!( + run(&project, &second, &["--access-profile", "directory-editor"]) + .status + .success() + ); + for file in ["policies.jsonld", "coverage.json", "profile.jsonld"] { + assert_eq!( + std::fs::read(output.join("generated/odrl").join(file)).unwrap(), + std::fs::read(second.join("generated/odrl").join(file)).unwrap() + ); + } +} + +#[test] +fn odrl_cli_zero_coverage_is_explicit_and_does_not_fabricate_permissions() { + let temporary = tempfile::tempdir().unwrap(); + let output = temporary.path().join("output"); + let result = run(&fixture(), &output, &[]); + assert!(result.status.success(), "{result:?}"); + assert!(result.stderr.is_empty(), "{result:?}"); + let coverage = read(&output.join("generated/odrl/coverage.json")); + assert_eq!(coverage["totals"]["exported"], 0); + assert!(coverage["totals"]["omitted"].as_u64().unwrap() > 0); + let policies = read(&output.join("generated/odrl/policies.jsonld")); + assert_eq!(policies["@graph"], json!([])); +} + +#[test] +fn odrl_cli_unknown_profile_and_irrelevant_flags_refuse_before_writing() { + let temporary = tempfile::tempdir().unwrap(); + for (extra, expected_code) in [ + ( + vec!["--access-profile", "not-a-profile"], + "breg.odrl.unknown-access-profile", + ), + (vec!["--record-type", "contact"], "odrl.arguments"), + ] { + let output = temporary.path().join("absent"); + let result = run(&fixture(), &output, &extra); + assert!(!result.status.success()); + let report: Value = serde_json::from_slice(&result.stdout).expect("structured refusal"); + assert_eq!(report["ok"], false); + assert_eq!(report["command"], "generate"); + assert_eq!(report["diagnostics"][0]["code"], expected_code); + assert_eq!(report["diagnostics"][0]["artifact"], "command-arguments"); + assert_eq!( + report["diagnostics"][0]["suggestedAction"], + "correct-command-usage" + ); + assert!(result.stderr.is_empty(), "{result:?}"); + assert!(!output.exists()); + } +} diff --git a/crates/registry-cli-docs/catalog.json b/crates/registry-cli-docs/catalog.json index 42603446b7..f5c142b953 100644 --- a/crates/registry-cli-docs/catalog.json +++ b/crates/registry-cli-docs/catalog.json @@ -1997,6 +1997,7 @@ "schemas", "actions", "manifest", + "odrl", "metadata", "sql", "evidence-source", @@ -2035,7 +2036,7 @@ }, { "display": "--access-profile ", - "description": "Existing registry lookup access profile (evidence-source only)", + "description": "Access profile: required for evidence-source; optional exact selection for odrl", "always_required": false, "repeatable": false, "default_values": [], @@ -2103,7 +2104,7 @@ }, { "display": "-h, --help", - "description": "Print help", + "description": "Print help (see a summary with '-h')", "always_required": false, "repeatable": false, "default_values": [], diff --git a/crates/registry-cli-docs/src/lib.rs b/crates/registry-cli-docs/src/lib.rs index d1df715106..ba63b9419f 100644 --- a/crates/registry-cli-docs/src/lib.rs +++ b/crates/registry-cli-docs/src/lib.rs @@ -412,6 +412,7 @@ mod tests { "schemas", "actions", "manifest", + "odrl", "metadata", "sql", "evidence-source", diff --git a/crates/registry-manifest-core/README.md b/crates/registry-manifest-core/README.md index 5b4f39c3f5..2e89644b99 100644 --- a/crates/registry-manifest-core/README.md +++ b/crates/registry-manifest-core/README.md @@ -19,6 +19,9 @@ metadata model. - Pure renderers for catalog JSON, DCAT JSON-LD, BRegDCAT-AP JSON-LD, CPSV-AP JSON-LD, SHACL, JSON Schema Draft 2020-12, OGC API Records items, policies, and evidence offerings. +- A pure ODRL 2.2 policy-collection renderer with explicit profiles, projected + targets and constraints. Consumers own translation and profile semantics; + rendering a policy does not enforce it or establish interoperability. ## Typical Use diff --git a/crates/registry-manifest-core/src/lib.rs b/crates/registry-manifest-core/src/lib.rs index 4b579d5b1a..1c1acd07d6 100644 --- a/crates/registry-manifest-core/src/lib.rs +++ b/crates/registry-manifest-core/src/lib.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 //! Portable metadata model and pure renderers for registry catalogs. +pub mod odrl; + use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; diff --git a/crates/registry-manifest-core/src/odrl.rs b/crates/registry-manifest-core/src/odrl.rs new file mode 100644 index 0000000000..53cf1d9713 --- /dev/null +++ b/crates/registry-manifest-core/src/odrl.rs @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Runtime-free ODRL JSON-LD projection for source-owned policy exports. + +use std::collections::{BTreeMap, BTreeSet}; + +use oxiri::Iri; +use serde_json::{json, Map, Value}; +use thiserror::Error; + +const ODRL_NAMESPACE: &str = "http://www.w3.org/ns/odrl/2/"; +const DCTERMS_NAMESPACE: &str = "http://purl.org/dc/terms/"; +const ODRL_PART_OF: &str = "http://www.w3.org/ns/odrl/2/partOf"; +const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; + +#[derive(Clone, Debug, PartialEq)] +pub struct Policy { + pub uid: String, + pub profile: Vec, + pub permissions: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Permission { + pub target: PolicyAsset, + pub action: String, + pub constraints: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PolicyAsset { + pub uid: String, + pub part_of: Option, + pub properties: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Constraint { + pub left_operand: String, + pub operator: String, + pub right_operand: Operand, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Operand { + Literal(Value), + Set(Vec), +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum OdrlError { + #[error("{path} must be an absolute HTTP(S) or URN IRI: {value}")] + InvalidIri { path: String, value: String }, + #[error("policy UID is duplicated: {uid}")] + DuplicatePolicyUid { uid: String }, + #[error("policy must contain at least one permission: {uid}")] + EmptyPolicy { uid: String }, + #[error("set operand must contain at least one value: {path}")] + EmptySet { path: String }, + #[error("literal operand must be a non-null string, boolean, or number: {path}")] + InvalidLiteral { path: String }, + #[error("asset property aliases a structural member: {path}")] + StructuralAssetProperty { path: String }, +} + +/// Render a deterministic JSON-LD collection without inferring any policy. +pub fn render_policy_collection(id: &str, policies: &[Policy]) -> Result { + validate_iri(id, "collection.id")?; + + let mut seen = BTreeSet::new(); + for (policy_index, policy) in policies.iter().enumerate() { + validate_policy(policy, policy_index)?; + if !seen.insert(policy.uid.as_str()) { + return Err(OdrlError::DuplicatePolicyUid { + uid: policy.uid.clone(), + }); + } + } + + let mut ordered = policies.iter().collect::>(); + ordered.sort_by(|left, right| left.uid.cmp(&right.uid)); + let graph = ordered.into_iter().map(render_policy).collect::>(); + + Ok(json!({ + "@context": { + "odrl": ODRL_NAMESPACE, + "dcterms": DCTERMS_NAMESPACE, + }, + "@id": id, + "@graph": graph, + })) +} + +fn validate_policy(policy: &Policy, policy_index: usize) -> Result<(), OdrlError> { + let path = format!("policies[{policy_index}]"); + validate_iri(&policy.uid, &format!("{path}.uid"))?; + for (profile_index, profile) in policy.profile.iter().enumerate() { + validate_iri(profile, &format!("{path}.profile[{profile_index}]"))?; + } + if policy.permissions.is_empty() { + return Err(OdrlError::EmptyPolicy { + uid: policy.uid.clone(), + }); + } + for (permission_index, permission) in policy.permissions.iter().enumerate() { + validate_permission( + permission, + &format!("{path}.permissions[{permission_index}]"), + )?; + } + Ok(()) +} + +fn validate_permission(permission: &Permission, path: &str) -> Result<(), OdrlError> { + validate_iri(&permission.action, &format!("{path}.action"))?; + validate_asset(&permission.target, &format!("{path}.target"))?; + for (constraint_index, constraint) in permission.constraints.iter().enumerate() { + validate_constraint( + constraint, + &format!("{path}.constraints[{constraint_index}]"), + )?; + } + Ok(()) +} + +fn validate_asset(asset: &PolicyAsset, path: &str) -> Result<(), OdrlError> { + validate_iri(&asset.uid, &format!("{path}.uid"))?; + if let Some(part_of) = asset.part_of.as_deref() { + validate_iri(part_of, &format!("{path}.part_of"))?; + } + for property in asset.properties.keys() { + let property_path = format!("{path}.properties[{property}]"); + validate_iri(property, &property_path)?; + if property == ODRL_PART_OF || property == RDF_TYPE { + return Err(OdrlError::StructuralAssetProperty { + path: property_path, + }); + } + } + Ok(()) +} + +fn validate_constraint(constraint: &Constraint, path: &str) -> Result<(), OdrlError> { + validate_iri(&constraint.left_operand, &format!("{path}.left_operand"))?; + validate_iri(&constraint.operator, &format!("{path}.operator"))?; + match &constraint.right_operand { + Operand::Literal(value) => validate_literal(value, &format!("{path}.right_operand")), + Operand::Set(values) => { + if values.is_empty() { + return Err(OdrlError::EmptySet { + path: format!("{path}.right_operand"), + }); + } + for (value_index, value) in values.iter().enumerate() { + validate_literal(value, &format!("{path}.right_operand[{value_index}]"))?; + } + Ok(()) + } + } +} + +fn validate_literal(value: &Value, path: &str) -> Result<(), OdrlError> { + if matches!(value, Value::String(_) | Value::Bool(_) | Value::Number(_)) { + Ok(()) + } else { + Err(OdrlError::InvalidLiteral { + path: path.to_owned(), + }) + } +} + +fn validate_iri(value: &str, path: &str) -> Result<(), OdrlError> { + let valid = Iri::parse(value).is_ok_and(|iri| match iri.scheme() { + scheme if scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") => { + iri.authority() + .is_some_and(|authority| !authority.trim().is_empty()) + } + scheme if scheme.eq_ignore_ascii_case("urn") => value + .strip_prefix("urn:") + .or_else(|| value.strip_prefix("URN:")) + .is_some_and(|identifier| !identifier.trim().is_empty()), + _ => false, + }); + if valid { + Ok(()) + } else { + Err(OdrlError::InvalidIri { + path: path.to_owned(), + value: value.to_owned(), + }) + } +} + +fn render_policy(policy: &Policy) -> Value { + let mut profiles = policy.profile.iter().collect::>(); + profiles.sort(); + profiles.dedup(); + + let mut permissions = policy + .permissions + .iter() + .map(render_permission) + .collect::>(); + permissions.sort_by_key(Value::to_string); + + json!({ + "@id": policy.uid, + "@type": "odrl:Set", + "odrl:uid": iri_object(&policy.uid), + "odrl:profile": profiles + .into_iter() + .map(|profile| iri_object(profile)) + .collect::>(), + "odrl:permission": permissions, + }) +} + +fn render_permission(permission: &Permission) -> Value { + let mut constraints = permission + .constraints + .iter() + .map(render_constraint) + .collect::>(); + constraints.sort_by_key(Value::to_string); + + let mut rendered = json!({ + "odrl:target": render_asset(&permission.target), + "odrl:action": iri_object(&permission.action), + }); + if !constraints.is_empty() { + rendered["odrl:constraint"] = Value::Array(constraints); + } + rendered +} + +fn render_asset(asset: &PolicyAsset) -> Value { + let mut rendered = Map::new(); + rendered.insert("@id".to_owned(), Value::String(asset.uid.clone())); + rendered.insert("@type".to_owned(), Value::String("odrl:Asset".to_owned())); + if let Some(part_of) = asset.part_of.as_deref() { + rendered.insert("odrl:partOf".to_owned(), iri_object(part_of)); + } + for (property, value) in &asset.properties { + rendered.insert(property.clone(), value.clone()); + } + Value::Object(rendered) +} + +fn render_constraint(constraint: &Constraint) -> Value { + json!({ + "odrl:leftOperand": iri_object(&constraint.left_operand), + "odrl:operator": iri_object(&constraint.operator), + "odrl:rightOperand": render_operand(&constraint.right_operand), + }) +} + +fn render_operand(operand: &Operand) -> Value { + match operand { + Operand::Literal(value) => json!({"@value": value}), + Operand::Set(values) => json!({ + "@list": values + .iter() + .map(|value| json!({"@value": value})) + .collect::>(), + }), + } +} + +fn iri_object(iri: &str) -> Value { + json!({"@id": iri}) +} diff --git a/crates/registry-manifest-core/tests/odrl.rs b/crates/registry-manifest-core/tests/odrl.rs new file mode 100644 index 0000000000..7cb6184377 --- /dev/null +++ b/crates/registry-manifest-core/tests/odrl.rs @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeMap; + +use oxjsonld::JsonLdParser; +use registry_manifest_core::odrl::{ + render_policy_collection, Constraint, OdrlError, Operand, Permission, Policy, PolicyAsset, +}; +use serde_json::{json, Value}; + +fn asset(uid: &str) -> PolicyAsset { + PolicyAsset { + uid: uid.to_owned(), + part_of: None, + properties: BTreeMap::new(), + } +} + +fn policy(uid: &str, target: &str) -> Policy { + Policy { + uid: uid.to_owned(), + profile: vec!["https://example.test/odrl/profile/v1".to_owned()], + permissions: vec![Permission { + target: asset(target), + action: "http://www.w3.org/ns/odrl/2/read".to_owned(), + constraints: Vec::new(), + }], + } +} + +#[test] +fn empty_collection_is_valid_and_fabricates_no_policy() { + let rendered = render_policy_collection("urn:registry:policies", &[]) + .expect("empty collection is a valid publication"); + + assert_eq!(rendered["@id"], "urn:registry:policies"); + assert_eq!(rendered["@graph"], json!([])); +} + +#[test] +fn renders_typed_assets_and_explicit_literal_operands_as_valid_json_ld() { + let mut properties = BTreeMap::new(); + properties.insert( + "https://example.test/ns/classification".to_owned(), + json!("restricted"), + ); + let policies = [Policy { + uid: "urn:registry:policy:reader".to_owned(), + profile: vec!["https://example.test/odrl/profile/v1".to_owned()], + permissions: vec![Permission { + target: PolicyAsset { + uid: "https://example.test/datasets/people".to_owned(), + part_of: Some("urn:registry:catalog:main".to_owned()), + properties, + }, + action: "http://www.w3.org/ns/odrl/2/read".to_owned(), + constraints: vec![ + Constraint { + left_operand: "http://www.w3.org/ns/odrl/2/purpose".to_owned(), + operator: "http://www.w3.org/ns/odrl/2/isA".to_owned(), + right_operand: Operand::Literal(json!("case-management")), + }, + Constraint { + left_operand: "urn:registry:constraint:scope".to_owned(), + operator: "http://www.w3.org/ns/odrl/2/isAnyOf".to_owned(), + right_operand: Operand::Set(vec![json!("district-a"), json!(7), json!(true)]), + }, + ], + }], + }]; + + let rendered = render_policy_collection("https://example.test/policies", &policies) + .expect("valid policy renders"); + let policy = &rendered["@graph"][0]; + let permission = &policy["odrl:permission"][0]; + + assert_eq!(rendered["@context"]["odrl"], "http://www.w3.org/ns/odrl/2/"); + assert_eq!(rendered["@context"]["dcterms"], "http://purl.org/dc/terms/"); + assert_eq!(policy["@type"], "odrl:Set"); + assert_eq!( + policy["odrl:uid"], + json!({"@id": "urn:registry:policy:reader"}) + ); + assert_eq!( + policy["odrl:profile"], + json!([{"@id": "https://example.test/odrl/profile/v1"}]) + ); + assert_eq!( + permission["odrl:action"], + json!({"@id": "http://www.w3.org/ns/odrl/2/read"}) + ); + assert_eq!( + permission["odrl:target"], + json!({ + "@id": "https://example.test/datasets/people", + "@type": "odrl:Asset", + "odrl:partOf": {"@id": "urn:registry:catalog:main"}, + "https://example.test/ns/classification": "restricted" + }) + ); + assert_eq!( + permission["odrl:constraint"][0]["odrl:rightOperand"], + json!({"@value": "case-management"}) + ); + assert_eq!( + permission["odrl:constraint"][1]["odrl:rightOperand"], + json!({"@list": [{"@value": "district-a"}, {"@value": 7}, {"@value": true}]}) + ); + assert!(parse_jsonld_to_rdf(&rendered).expect("ODRL JSON-LD parses as RDF") > 0); +} + +#[test] +fn policies_are_ordered_by_uid_and_properties_by_iri() { + let first = policy("urn:registry:policy:a", "urn:registry:asset:a"); + let second = policy("urn:registry:policy:b", "urn:registry:asset:b"); + + let rendered = + render_policy_collection("urn:registry:policies", &[second.clone(), first.clone()]) + .expect("valid policies render"); + let reordered = render_policy_collection("urn:registry:policies", &[first, second]) + .expect("valid policies render"); + + assert_eq!(rendered, reordered); + assert_eq!(rendered["@graph"][0]["@id"], "urn:registry:policy:a"); + assert_eq!(rendered["@graph"][1]["@id"], "urn:registry:policy:b"); +} + +#[test] +fn refuses_duplicate_or_empty_policies() { + let duplicate = policy("urn:registry:policy:duplicate", "urn:registry:asset:a"); + assert!(matches!( + render_policy_collection("urn:registry:policies", &[duplicate.clone(), duplicate]), + Err(OdrlError::DuplicatePolicyUid { .. }) + )); + + let empty = Policy { + uid: "urn:registry:policy:empty".to_owned(), + profile: Vec::new(), + permissions: Vec::new(), + }; + assert!(matches!( + render_policy_collection("urn:registry:policies", &[empty]), + Err(OdrlError::EmptyPolicy { .. }) + )); +} + +#[test] +fn refuses_invalid_identifiers_empty_sets_and_nonprimitive_literals() { + let mut invalid_iri = policy("not-an-iri", "urn:registry:asset:a"); + assert!(matches!( + render_policy_collection("urn:registry:policies", &[invalid_iri.clone()]), + Err(OdrlError::InvalidIri { .. }) + )); + + invalid_iri.uid = "urn:registry:policy:invalid".to_owned(); + invalid_iri.permissions[0].constraints.push(Constraint { + left_operand: "urn:registry:constraint:scope".to_owned(), + operator: "http://www.w3.org/ns/odrl/2/isAnyOf".to_owned(), + right_operand: Operand::Set(Vec::new()), + }); + assert!(matches!( + render_policy_collection("urn:registry:policies", &[invalid_iri]), + Err(OdrlError::EmptySet { .. }) + )); + + let mut invalid_literal = policy("urn:registry:policy:invalid", "urn:registry:asset:a"); + invalid_literal.permissions[0].constraints.push(Constraint { + left_operand: "urn:registry:constraint:value".to_owned(), + operator: "http://www.w3.org/ns/odrl/2/eq".to_owned(), + right_operand: Operand::Literal(json!({"nested": true})), + }); + assert!(matches!( + render_policy_collection("urn:registry:policies", &[invalid_literal]), + Err(OdrlError::InvalidLiteral { .. }) + )); +} + +#[test] +fn refuses_asset_properties_that_alias_structural_members() { + let mut properties = BTreeMap::new(); + properties.insert( + "http://www.w3.org/ns/odrl/2/partOf".to_owned(), + json!("urn:registry:catalog:other"), + ); + let mut invalid = policy("urn:registry:policy:invalid", "urn:registry:asset:a"); + invalid.permissions[0].target.properties = properties; + + assert!(matches!( + render_policy_collection("urn:registry:policies", &[invalid]), + Err(OdrlError::StructuralAssetProperty { .. }) + )); +} + +fn parse_jsonld_to_rdf(document: &Value) -> Result { + let raw = serde_json::to_string(document).map_err(|error| error.to_string())?; + let parser = JsonLdParser::new() + .with_base_iri("https://example.test/policies") + .map_err(|error| error.to_string())?; + let mut quad_count = 0; + for quad in parser.for_slice(&raw) { + quad.map_err(|error| error.to_string())?; + quad_count += 1; + } + Ok(quad_count) +} diff --git a/products/breg/CHANGELOG.md b/products/breg/CHANGELOG.md index c8aa0d9e27..ae5ec57164 100644 --- a/products/breg/CHANGELOG.md +++ b/products/breg/CHANGELOG.md @@ -17,6 +17,11 @@ starting this runtime, test, build, plan, and apply one governed successor with the current tooling. The successor installs the shared engine store; there is no separate timer schema command. +- Add `bregctl generate odrl` for an experimental, partial ODRL description of + configured `get` and `list` permissions. Preserve token requirements and field + projections; omit permissions with unsupported row, membership, consent or + task-grant restrictions and report coverage. The output is for operator review + and does not change runtime authorization or public Manifest publication. ## 0.44.0 - 2026-10-27 diff --git a/products/breg/README.md b/products/breg/README.md index b911d4d6ce..53c8c52a48 100644 --- a/products/breg/README.md +++ b/products/breg/README.md @@ -468,6 +468,11 @@ descriptor: subjects, occurred and effective time, sources, correction links and source event identity over fields it already stores, plus optional display purpose and grouping for Records and Actions. It adds no storage or authority. +For offline inspection, `bregctl generate odrl PROJECT --output DIRECTORY` +exports a [partial ODRL view of configured read permissions](odrl-export.md) +and a coverage report. These operator-review artifacts include configuration +details and do not establish caller authority or public publication safety. + Domain-semantic entries in `manifestProjection` are optional overlays on the configured data model. One project declares one publisher authority, one public service, one catalogue, one or more datasets, one or more data services, and diff --git a/products/breg/contracts/odrl-profile.jsonld b/products/breg/contracts/odrl-profile.jsonld new file mode 100644 index 0000000000..cb4d188115 --- /dev/null +++ b/products/breg/contracts/odrl-profile.jsonld @@ -0,0 +1,138 @@ +{ + "@context": { + "odrl": "http://www.w3.org/ns/odrl/2/", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "skos": "http://www.w3.org/2004/02/skos/core#", + "dcterms": "http://purl.org/dc/terms/", + "prof": "http://www.w3.org/ns/dx/prof/", + "breg": "https://id.registrystack.org/vocab/breg-odrl/" + }, + "@id": "https://id.registrystack.org/profiles/breg-odrl/v1", + "@type": "prof:Profile", + "dcterms:title": "BReg partial read-policy export", + "dcterms:conformsTo": {"@id": "https://www.w3.org/TR/odrl-model/"}, + "dcterms:description": "Experimental, descriptive ODRL 2.2 profile for operator review of configured BReg record-operation grants. This is a partial projection of source configuration, not an access token, an activated-package declaration, a complete authorization policy, or a public catalogue. Missing permissions are not prohibitions. Consumers that do not understand this profile must stop processing its policies. BReg independently authenticates and authorizes every actual request.", + "skos:scopeNote": "Every permission describes only its named field projection and exact get or list operation. The native Registry Record envelope is outside the field list. Lookup, relationship traversal, history, count requests, data export, mutations, Actions, Proposals and statistical datasets are outside this profile. Request selectors, pagination and query parameters must satisfy BReg's current route contract. All conditions are conjunctive; absent or unverifiable context never satisfies them. Context comes from the current BReg resource server's verified token and selected compiled profile, never from caller assertions or an ODRL document. An export carries source revision information but does not establish that source is active.", + "skos:note": "This profile adopts odrl:read from the ODRL Common Vocabulary for its exact BReg projection and operation semantics. It is not an Eclipse EDC PolicyDefinition or a Dataspace Protocol Offer or Agreement, and has not been imported into or evaluated by EDC. EDC's reviewed permission parser drops rule-level targets, losing BReg projection identifiers, Record types and readable fields. Direct wrapping and import are unsafe. An adapter must preserve those targets, normalize list operands, bind the action and every custom constraint in each evaluation scope, and use verified BReg context. No adapter or EDC extension is included.", + "@graph": [ + { + "@id": "https://id.registrystack.org/vocab/breg-odrl/", + "@type": "skos:Collection", + "rdfs:isDefinedBy": {"@id": "https://id.registrystack.org/profiles/breg-odrl/v1"}, + "skos:prefLabel": "BReg ODRL profile vocabulary", + "skos:member": [ + {"@id": "odrl:read"}, + {"@id": "breg:authenticated"}, + {"@id": "breg:verifiedNonemptyClaims"}, + {"@id": "breg:accessProfile"}, + {"@id": "breg:operation"}, + {"@id": "breg:tokenScopes"}, + {"@id": "breg:tokenPurpose"}, + {"@id": "breg:requesterClient"}, + {"@id": "breg:actorKind"}, + {"@id": "breg:delegatedActor"}, + {"@id": "breg:taskGrantPresent"}, + {"@id": "breg:recordType"}, + {"@id": "breg:readableFields"}, + {"@id": "breg:partial"} + ] + }, + { + "@id": "breg:authenticated", + "rdfs:isDefinedBy": {"@id": "https://id.registrystack.org/profiles/breg-odrl/v1"}, + "@type": ["odrl:LeftOperand", "skos:Concept"], + "skos:prefLabel": "Authenticated by BReg", + "skos:definition": "Boolean true only after the current BReg resource server accepts the caller's token under its configured issuer, audience, validity and cryptographic checks. The exporter emits an odrl:eq comparison to true." + }, + { + "@id": "breg:verifiedNonemptyClaims", + "rdfs:isDefinedBy": {"@id": "https://id.registrystack.org/profiles/breg-odrl/v1"}, + "@type": ["odrl:LeftOperand", "skos:Concept"], + "skos:prefLabel": "Verified nonempty principal claims", + "skos:definition": "A singleton set containing the resource server's configured principal-claim name only when BReg resolved a valid principal from that claim, or the empty set otherwise. Other populated token claims are not members. odrl:hasPart with a singleton literal list requires both the exact configured claim name and a resolved principal. A claim name in the policy is not a principal identity." + }, + { + "@id": "breg:accessProfile", + "rdfs:isDefinedBy": {"@id": "https://id.registrystack.org/profiles/breg-odrl/v1"}, + "@type": ["odrl:LeftOperand", "skos:Concept"], + "skos:prefLabel": "Selected access profile", + "skos:definition": "The exact native access profile selected for this request. odrl:eq compares its local identifier as a string. Permissions from different profiles must not be combined to authorize one request. Selection alone does not establish authentication or satisfy other conditions." + }, + { + "@id": "breg:operation", + "rdfs:isDefinedBy": {"@id": "https://id.registrystack.org/profiles/breg-odrl/v1"}, + "@type": ["odrl:LeftOperand", "skos:Concept"], + "skos:prefLabel": "Native read operation", + "skos:definition": "The current BReg route operation, expressed as the literal get or list. get retrieves one identified record; list enumerates permitted current records subject to the route's query and pagination contract. Neither permits lookup routes, relationship traversal, historical access, optional count requests or data export. odrl:read applies only to the named projection through this exact operation." + }, + { + "@id": "breg:tokenScopes", + "rdfs:isDefinedBy": {"@id": "https://id.registrystack.org/profiles/breg-odrl/v1"}, + "@type": ["odrl:LeftOperand", "skos:Concept"], + "skos:prefLabel": "Verified token scopes", + "skos:definition": "The set of scopes extracted from the accepted token using BReg's current scope contract. odrl:hasPart requires every right-operand list member. Multiple such constraints are conjunctive. Scope strings remain literals, including strings containing colons." + }, + { + "@id": "breg:tokenPurpose", + "rdfs:isDefinedBy": {"@id": "https://id.registrystack.org/profiles/breg-odrl/v1"}, + "@type": ["odrl:LeftOperand", "skos:Concept"], + "skos:prefLabel": "Verified token purpose", + "skos:definition": "The single purpose value from the accepted token under BReg's current purpose-claim contract. odrl:isPartOf requires membership in the allowed literal list. A purpose supplied only in a request body or policy document does not satisfy this condition. Absence of this constraint means the source profile imposes no purpose restriction." + }, + { + "@id": "breg:requesterClient", + "rdfs:isDefinedBy": {"@id": "https://id.registrystack.org/profiles/breg-odrl/v1"}, + "@type": ["odrl:LeftOperand", "skos:Concept"], + "skos:prefLabel": "Verified requester client", + "skos:definition": "The OAuth client identity resolved by BReg from its verified caller context. odrl:isPartOf requires membership in the allowed literal list. Absence of this constraint means the source profile imposes no requester-client restriction." + }, + { + "@id": "breg:actorKind", + "rdfs:isDefinedBy": {"@id": "https://id.registrystack.org/profiles/breg-odrl/v1"}, + "@type": ["odrl:LeftOperand", "skos:Concept"], + "skos:prefLabel": "Verified actor kind", + "skos:definition": "The actor kind resolved by BReg from its verified caller context, compared with odrl:eq to human, agent or service. Absence of this constraint means the source profile imposes no explicit actor-kind restriction; the delegatedActor constraint still applies." + }, + { + "@id": "breg:delegatedActor", + "rdfs:isDefinedBy": {"@id": "https://id.registrystack.org/profiles/breg-odrl/v1"}, + "@type": ["odrl:LeftOperand", "skos:Concept"], + "skos:prefLabel": "Trusted delegated actor present", + "skos:definition": "Boolean indicating whether BReg's verified caller context contains a trusted actor subject. Standing agent profiles require true; every other exported profile requires false, including profiles without an explicit actorKind. A caller-supplied actor-kind label alone does not establish a trusted actor." + }, + { + "@id": "breg:taskGrantPresent", + "rdfs:isDefinedBy": {"@id": "https://id.registrystack.org/profiles/breg-odrl/v1"}, + "@type": ["odrl:LeftOperand", "skos:Concept"], + "skos:prefLabel": "Verified task grant present", + "skos:definition": "Boolean indicating whether BReg's verified caller context carries a task grant. Every exported permission requires false because task-grant profiles are outside this profile, and native BReg refuses grant-bearing tokens against standing profiles." + }, + { + "@id": "breg:recordType", + "rdfs:isDefinedBy": {"@id": "https://id.registrystack.org/profiles/breg-odrl/v1"}, + "@type": ["rdf:Property", "skos:Concept"], + "rdfs:domain": {"@id": "odrl:Asset"}, + "rdfs:range": {"@id": "http://www.w3.org/2001/XMLSchema#string"}, + "skos:prefLabel": "Native Record type", + "skos:definition": "The authored local Record type identifier whose readable field projection is the target asset. This metadata does not grant access to the complete Record type." + }, + { + "@id": "breg:readableFields", + "rdfs:isDefinedBy": {"@id": "https://id.registrystack.org/profiles/breg-odrl/v1"}, + "@type": ["rdf:Property", "skos:Concept"], + "rdfs:domain": {"@id": "odrl:Asset"}, + "rdfs:range": {"@id": "rdf:List"}, + "skos:prefLabel": "Projection fields", + "skos:definition": "The exact list of authored field identifiers included in the target projection. It never denotes a wildcard, related-record expansion or the entire dataset. It does not include the native Registry Record envelope." + }, + { + "@id": "breg:partial", + "rdfs:isDefinedBy": {"@id": "https://id.registrystack.org/profiles/breg-odrl/v1"}, + "@type": ["rdf:Property", "skos:Concept"], + "rdfs:range": {"@id": "http://www.w3.org/2001/XMLSchema#boolean"}, + "skos:prefLabel": "Partial description", + "skos:definition": "True indicates that the export is deliberately incomplete. An omitted permission is not represented and must not be interpreted as an explicit prohibition." + } + ] +} diff --git a/products/breg/examples/formats/odrl-coverage.json b/products/breg/examples/formats/odrl-coverage.json new file mode 100644 index 0000000000..c91c418376 --- /dev/null +++ b/products/breg/examples/formats/odrl-coverage.json @@ -0,0 +1 @@ +{"apiVersion":"id.registrystack.org/formats/breg/odrl-coverage/v1alpha1","audience":"operator-review","excludedSurfaces":["actions","proposals","statistics","lookups","read-paths","revisions","snapshots","mutations","counts","filters","sorting","spatial-queries","data-export","provenance"],"grants":[{"accessProfile":"directory-editor","operation":"create","reasons":["unsupported-operation","row-boundaries"],"recordType":"contact","status":"omitted"},{"accessProfile":"directory-editor","operation":"get","reasons":["row-boundaries"],"recordType":"contact","status":"omitted"},{"accessProfile":"directory-editor","operation":"list","reasons":["row-boundaries"],"recordType":"contact","status":"omitted"},{"accessProfile":"directory-editor","operation":"patch","reasons":["unsupported-operation","row-boundaries"],"recordType":"contact","status":"omitted"}],"kind":"BRegOdrlCoverage","partial":true,"registryId":"local-directory","revision":"sha256:85a76e1d4ff31ba051ad0f9a6de3e9ca4dbef35a298284912e66f176aed28d16","scope":"record-operation-grants","selectedAccessProfile":null,"totals":{"considered":4,"exported":0,"omitted":4}} \ No newline at end of file diff --git a/products/breg/examples/formats/odrl-policies.jsonld b/products/breg/examples/formats/odrl-policies.jsonld new file mode 100644 index 0000000000..a386c29848 --- /dev/null +++ b/products/breg/examples/formats/odrl-policies.jsonld @@ -0,0 +1 @@ +{"@context":{"dcterms":"http://purl.org/dc/terms/","odrl":"http://www.w3.org/ns/odrl/2/"},"@graph":[{"@id":"urn:registrystack:breg:labelled-directory:revision:sha256%3A80a2c7abd225024a332fc5765219ed246269d33b65a2cc38d6ac8bcfa0d1369a:odrl:profiles:directory-editor:policy","@type":"odrl:Set","https://id.registrystack.org/vocab/breg-odrl/partial":true,"odrl:permission":[{"odrl:action":{"@id":"http://www.w3.org/ns/odrl/2/read"},"odrl:constraint":[{"odrl:leftOperand":{"@id":"https://id.registrystack.org/vocab/breg-odrl/accessProfile"},"odrl:operator":{"@id":"http://www.w3.org/ns/odrl/2/eq"},"odrl:rightOperand":{"@value":"directory-editor"}},{"odrl:leftOperand":{"@id":"https://id.registrystack.org/vocab/breg-odrl/authenticated"},"odrl:operator":{"@id":"http://www.w3.org/ns/odrl/2/eq"},"odrl:rightOperand":{"@value":true}},{"odrl:leftOperand":{"@id":"https://id.registrystack.org/vocab/breg-odrl/delegatedActor"},"odrl:operator":{"@id":"http://www.w3.org/ns/odrl/2/eq"},"odrl:rightOperand":{"@value":false}},{"odrl:leftOperand":{"@id":"https://id.registrystack.org/vocab/breg-odrl/operation"},"odrl:operator":{"@id":"http://www.w3.org/ns/odrl/2/eq"},"odrl:rightOperand":{"@value":"get"}},{"odrl:leftOperand":{"@id":"https://id.registrystack.org/vocab/breg-odrl/taskGrantPresent"},"odrl:operator":{"@id":"http://www.w3.org/ns/odrl/2/eq"},"odrl:rightOperand":{"@value":false}},{"odrl:leftOperand":{"@id":"https://id.registrystack.org/vocab/breg-odrl/tokenScopes"},"odrl:operator":{"@id":"http://www.w3.org/ns/odrl/2/hasPart"},"odrl:rightOperand":{"@list":[{"@value":"directory:write"}]}},{"odrl:leftOperand":{"@id":"https://id.registrystack.org/vocab/breg-odrl/verifiedNonemptyClaims"},"odrl:operator":{"@id":"http://www.w3.org/ns/odrl/2/hasPart"},"odrl:rightOperand":{"@list":[{"@value":"registry_principal"}]}}],"odrl:target":{"@id":"urn:registrystack:breg:labelled-directory:revision:sha256%3A80a2c7abd225024a332fc5765219ed246269d33b65a2cc38d6ac8bcfa0d1369a:odrl:record-types:entry:profiles:directory-editor:operations:get:projection","@type":"odrl:Asset","https://id.registrystack.org/vocab/breg-odrl/readableFields":{"@list":[{"@value":"active"},{"@value":"code"},{"@value":"recorded-on"}]},"https://id.registrystack.org/vocab/breg-odrl/recordType":"entry","odrl:partOf":{"@id":"urn:registrystack:breg:labelled-directory:revision:sha256%3A80a2c7abd225024a332fc5765219ed246269d33b65a2cc38d6ac8bcfa0d1369a:odrl:record-types:entry"}}},{"odrl:action":{"@id":"http://www.w3.org/ns/odrl/2/read"},"odrl:constraint":[{"odrl:leftOperand":{"@id":"https://id.registrystack.org/vocab/breg-odrl/accessProfile"},"odrl:operator":{"@id":"http://www.w3.org/ns/odrl/2/eq"},"odrl:rightOperand":{"@value":"directory-editor"}},{"odrl:leftOperand":{"@id":"https://id.registrystack.org/vocab/breg-odrl/authenticated"},"odrl:operator":{"@id":"http://www.w3.org/ns/odrl/2/eq"},"odrl:rightOperand":{"@value":true}},{"odrl:leftOperand":{"@id":"https://id.registrystack.org/vocab/breg-odrl/delegatedActor"},"odrl:operator":{"@id":"http://www.w3.org/ns/odrl/2/eq"},"odrl:rightOperand":{"@value":false}},{"odrl:leftOperand":{"@id":"https://id.registrystack.org/vocab/breg-odrl/operation"},"odrl:operator":{"@id":"http://www.w3.org/ns/odrl/2/eq"},"odrl:rightOperand":{"@value":"list"}},{"odrl:leftOperand":{"@id":"https://id.registrystack.org/vocab/breg-odrl/taskGrantPresent"},"odrl:operator":{"@id":"http://www.w3.org/ns/odrl/2/eq"},"odrl:rightOperand":{"@value":false}},{"odrl:leftOperand":{"@id":"https://id.registrystack.org/vocab/breg-odrl/tokenScopes"},"odrl:operator":{"@id":"http://www.w3.org/ns/odrl/2/hasPart"},"odrl:rightOperand":{"@list":[{"@value":"directory:write"}]}},{"odrl:leftOperand":{"@id":"https://id.registrystack.org/vocab/breg-odrl/verifiedNonemptyClaims"},"odrl:operator":{"@id":"http://www.w3.org/ns/odrl/2/hasPart"},"odrl:rightOperand":{"@list":[{"@value":"registry_principal"}]}}],"odrl:target":{"@id":"urn:registrystack:breg:labelled-directory:revision:sha256%3A80a2c7abd225024a332fc5765219ed246269d33b65a2cc38d6ac8bcfa0d1369a:odrl:record-types:entry:profiles:directory-editor:operations:list:projection","@type":"odrl:Asset","https://id.registrystack.org/vocab/breg-odrl/readableFields":{"@list":[{"@value":"active"},{"@value":"code"},{"@value":"recorded-on"}]},"https://id.registrystack.org/vocab/breg-odrl/recordType":"entry","odrl:partOf":{"@id":"urn:registrystack:breg:labelled-directory:revision:sha256%3A80a2c7abd225024a332fc5765219ed246269d33b65a2cc38d6ac8bcfa0d1369a:odrl:record-types:entry"}}}],"odrl:profile":[{"@id":"https://id.registrystack.org/profiles/breg-odrl/v1"}],"odrl:uid":{"@id":"urn:registrystack:breg:labelled-directory:revision:sha256%3A80a2c7abd225024a332fc5765219ed246269d33b65a2cc38d6ac8bcfa0d1369a:odrl:profiles:directory-editor:policy"}}],"@id":"urn:registrystack:breg:labelled-directory:revision:sha256%3A80a2c7abd225024a332fc5765219ed246269d33b65a2cc38d6ac8bcfa0d1369a:odrl:policies","http://purl.org/dc/terms/description":"Partial, non-enforcing projection of supported BReg record-operation grants; review coverage.json before use.","https://id.registrystack.org/vocab/breg-odrl/partial":true} \ No newline at end of file diff --git a/products/breg/odrl-export.md b/products/breg/odrl-export.md new file mode 100644 index 0000000000..df2a649551 --- /dev/null +++ b/products/breg/odrl-export.md @@ -0,0 +1,120 @@ +# Inspect access profiles as ODRL + +Use `bregctl generate odrl` to inspect a conservative description of your +configured record-read permissions. The exporter derives ODRL 2.2 JSON-LD from +the compiled project and writes a coverage report explaining omitted grants. +It does not contact a running registry or read records. + +```sh +bregctl generate odrl ./my-registry --output ./access-policy-review +``` + +To inspect one access profile, add `--access-profile PROFILE`. Without that +option, the exporter considers every compiled record access profile. An unknown +profile is refused before any output is written. The output directory must be +new, as for other `generate` commands. + +The command writes: + +| File under the output directory | Purpose | +|---|---| +| `generated/odrl/policies.jsonld` | Partial ODRL policies for supported grants | +| `generated/odrl/coverage.json` | Every considered record-operation grant and its outcome | +| `generated/odrl/profile.jsonld` | The vocabulary and interpretation rules consumers need | + +These are **operator-review artifacts**. They can include non-public Record +types, field names, profile identifiers and token requirements. Review the +files before sharing them. This command does not apply the public +`manifestProjection` filter, publish an endpoint, or replace Manifest/DCAT +generation. + +## What is translated + +The supported subset is `get` and `list` on current records with unrestricted +row reach. Each permission targets a named projection containing exactly the +profile's readable fields. The native Registry Record envelope is outside that +field list. Every rule preserves the selected profile, exact operation, +authentication, principal-claim requirement, required scopes, permitted +purposes, requester-client restrictions and actor-kind restriction. It also +preserves native trusted-actor requirements and requires the caller context +to carry no task grant. These implicit admission rules apply even when the +source does not spell them out as separate restrictions. + +Required scopes are conjunctive: every configured scope is required. Permitted +purposes and requester clients are alternatives: one listed value must match. +Conditions refer to BReg's verified caller context. A caller supplying a profile +name or purpose value does not establish authority. + +The exporter uses `odrl:read` with the documented BReg profile at +`https://id.registrystack.org/profiles/breg-odrl/v1`. This is an experimental local +profile, not a claim of interoperability with every ODRL consumer. Consumers +must understand its terms, particularly the projected target and exact native +operation. The shipped `profile.jsonld` defines those terms without requiring a +network fetch. + +## Eclipse EDC compatibility + +This export is neither an Eclipse EDC Management API `PolicyDefinition` nor a +Dataspace Protocol Offer or Agreement. It has not been imported into or evaluated +by EDC. **Do not import it by wrapping a generated policy in `PolicyDefinition`.** +The reviewed EDC parser reads permission actions, constraints and duties but +drops rule-level targets. That loses the BReg projection identifier, Record type +and readable-field list, which define the permission's scope. A successful +parse would not establish equivalent behavior. + +An EDC adapter would need to preserve every projection and its exact operation, +normalize JSON-LD list operands, bind `odrl:read` in each applicable evaluation +scope, and register functions for every BReg constraint. Those functions must +use BReg's verified caller context and refuse absent, unsupported or unverifiable +conditions. BReg would still authorize the actual request. The exporter includes +no such adapter or EDC extension. + +These limits were checked against EDC's +[policy-definition schema](https://github.com/eclipse-edc/Connector/blob/cd7e556d365cd3e7195e5728cb9bf695c483599d/extensions/common/api/management-api-schema-validator/src/main/resources/schema/management/v4/policy-definition-schema.json), +[permission transformer](https://github.com/eclipse-edc/Connector/blob/cd7e556d365cd3e7195e5728cb9bf695c483599d/core/control-plane/control-plane-transform/src/main/java/org/eclipse/edc/connector/controlplane/transform/odrl/to/JsonObjectToPermissionTransformer.java) +and [policy-engine contract](https://github.com/eclipse-edc/Connector/blob/cd7e556d365cd3e7195e5728cb9bf695c483599d/spi/control-plane-spi/src/main/java/org/eclipse/edc/policy/engine/spi/PolicyEngine.java). + +## What is omitted + +A permission with row boundaries, current-membership requirements, subject +consent requirements or a task grant is omitted in full. The exporter never +removes an unsupported restriction to produce a broader permission. Operations +other than `get` and `list` are omitted as unsupported. + +Actions, Proposals, statistical datasets, lookup grants, relationship paths, +historical reads, count requests and data export are outside the supported +surface. Their absence is not a prohibition and does not mean BReg lacks them. + +`coverage.json` counts **record-operation grants**, after any profile selection. +A profile granting `get`, `list` and `patch` contributes three candidates. +The report identifies each exported or omitted candidate and gives reasons for +omissions. Its totals are not a percentage of the registry's entire +authorization model. Reasons can overlap when one grant has several unsupported +restrictions. + +Zero supported grants is a successful, explicit empty export. It produces an +empty policy collection and a coverage report, never an unconditional default +permission. Invalid source, an unknown selected profile, or a failed write +remains an error. + +## How to use the result + +Treat the policies as a partial description of source configuration. The source +revision identifies what was inspected; it does not prove that the package is +active. BReg continues to authenticate and authorize every request using its +current package and state. The export does not describe downstream usage +obligations that were never present in the access profiles. + +For application discovery, use the existing +[caller-filtered metadata](metadata.md) endpoint. For a publication intended for +anonymous readers, use the separate classified Manifest projection and review +any policy you attach to that offering. + +The implementation is in `crates/registry-breg/src/odrl_export.rs`; the pure +ODRL serializer belongs to `registry-manifest-core`. The export tests cover +restriction preservation and omission, while the CLI tests run the native +command through output publication and refusal paths. + +Standards references: [ODRL Information Model 2.2](https://www.w3.org/TR/odrl-model/), +[ODRL Vocabulary and Expression 2.2](https://www.w3.org/TR/odrl-vocab/), and +[ODRL Profile Best Practices](https://www.w3.org/community/reports/odrl/CG-FINAL-profile-bp-20240808.html#profile-vocab). diff --git a/products/identifiers/contracts/catalog-source.json b/products/identifiers/contracts/catalog-source.json index 399eaa9d71..633e32060e 100644 --- a/products/identifiers/contracts/catalog-source.json +++ b/products/identifiers/contracts/catalog-source.json @@ -602,6 +602,30 @@ } ], "records": [ + { + "uri": "https://id.registrystack.org/profiles/breg-odrl/v1", + "kind": "profile", + "status": "active", + "compatibilityLine": "v1", + "owner": "breg", + "title": "BReg partial read-policy export profile", + "description": "Experimental ODRL profile for operator review of a conservative subset of configured BReg read permissions.", + "sourcePath": "products/breg/contracts/odrl-profile.jsonld", + "artifactPath": "products/breg/contracts/odrl-profile.jsonld", + "mediaType": "application/ld+json" + }, + { + "uri": "https://id.registrystack.org/vocab/breg-odrl/", + "kind": "vocabulary", + "status": "active", + "compatibilityLine": "v1", + "owner": "breg", + "title": "BReg partial read-policy vocabulary", + "description": "Experimental BReg ODRL constraint and projection terms, defined by the shipped read-policy export profile.", + "sourcePath": "products/breg/contracts/odrl-profile.jsonld", + "artifactPath": "products/breg/contracts/odrl-profile.jsonld", + "mediaType": "application/ld+json" + }, { "uri": "https://id.registrystack.org/profiles/registry-record/v1", "kind": "profile", diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index e84dee9907..242b20f37e 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -48,7 +48,7 @@ "description": "JSON-LD namespace for Registry Manifest terms.", "source": { "path": "crates/registry-manifest-core/src/lib.rs", - "sha256": "e1d4b066f89f5df821aa348bc86e62145593798a7433fc3120ce13914900559f" + "sha256": "707b3d0306d9eab8e4f8148310482ceff7912368c50b147931dceafe54dadc74" } }, { @@ -4098,6 +4098,24 @@ ] } }, + { + "uri": "https://id.registrystack.org/profiles/breg-odrl/v1", + "kind": "profile", + "status": "active", + "compatibilityLine": "v1", + "owner": "breg", + "title": "BReg partial read-policy export profile", + "description": "Experimental ODRL profile for operator review of a conservative subset of configured BReg read permissions.", + "source": { + "path": "products/breg/contracts/odrl-profile.jsonld", + "sha256": "24d0950eb2c1a76d5005850e1145f675ad65df5231867907b41d11fdd2979cdc" + }, + "artifact": { + "path": "products/breg/contracts/odrl-profile.jsonld", + "sha256": "24d0950eb2c1a76d5005850e1145f675ad65df5231867907b41d11fdd2979cdc", + "mediaType": "application/ld+json" + } + }, { "uri": "https://id.registrystack.org/profiles/registry-record/v1", "kind": "profile", @@ -5466,6 +5484,24 @@ "mediaType": "application/schema+json" } }, + { + "uri": "https://id.registrystack.org/vocab/breg-odrl/", + "kind": "vocabulary", + "status": "active", + "compatibilityLine": "v1", + "owner": "breg", + "title": "BReg partial read-policy vocabulary", + "description": "Experimental BReg ODRL constraint and projection terms, defined by the shipped read-policy export profile.", + "source": { + "path": "products/breg/contracts/odrl-profile.jsonld", + "sha256": "24d0950eb2c1a76d5005850e1145f675ad65df5231867907b41d11fdd2979cdc" + }, + "artifact": { + "path": "products/breg/contracts/odrl-profile.jsonld", + "sha256": "24d0950eb2c1a76d5005850e1145f675ad65df5231867907b41d11fdd2979cdc", + "mediaType": "application/ld+json" + } + }, { "uri": "https://id.registrystack.org/vocab/registry-record/", "kind": "vocabulary", diff --git a/products/platform/config-formats.yaml b/products/platform/config-formats.yaml index 9b968d65e8..85d14815f3 100644 --- a/products/platform/config-formats.yaml +++ b/products/platform/config-formats.yaml @@ -1194,6 +1194,78 @@ formats: conformance: none securityMembers: [] restrictingMembers: [] + - id: breg/odrl-coverage + product: breg + title: Partial ODRL export coverage + files: [] + syntax: json + audience: generated + stability: experimental + current: + apiVersion: id.registrystack.org/formats/breg/odrl-coverage/v1alpha1 + kind: BRegOdrlCoverage + checkedBy: + file: crates/registry-breg/src/odrl_export.rs + symbol: COVERAGE_API_VERSION + target: + apiVersion: id.registrystack.org/formats/breg/odrl-coverage/v1alpha1 + kind: BRegOdrlCoverage + schema: none + reader: none + emittedBy: + file: crates/registry-breg/src/odrl_export.rs + symbol: export_odrl + check: none + example: products/breg/examples/formats/odrl-coverage.json + conformance: none + securityMembers: [] + restrictingMembers: [] + - id: breg/odrl-policies + product: breg + title: Partial ODRL policy collection + files: [] + syntax: json + audience: generated + stability: experimental + exceptionClass: external-format + current: + apiVersion: none + kind: none + checkedBy: none + target: none + schema: none + reader: none + emittedBy: + file: crates/registry-breg/src/odrl_export.rs + symbol: export_odrl + check: none + example: products/breg/examples/formats/odrl-policies.jsonld + conformance: none + securityMembers: [] + restrictingMembers: [] + - id: breg/odrl-profile + product: breg + title: BReg ODRL profile and vocabulary + files: [] + syntax: json + audience: generated + stability: experimental + exceptionClass: external-format + current: + apiVersion: none + kind: none + checkedBy: none + target: none + schema: none + reader: none + emittedBy: + file: crates/registry-breg/src/odrl_export.rs + symbol: export_odrl + check: none + example: products/breg/contracts/odrl-profile.jsonld + conformance: none + securityMembers: [] + restrictingMembers: [] - id: breg/linkml-schema product: breg title: LinkML schema read by bregctl init --from