From 4ea178b05af90d0ea348bdc8bb4c5381bbf64385 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Tue, 10 Nov 2026 15:29:50 +1000 Subject: [PATCH] feat!: a VAC states purposes of use, and verify_chain holds a chain to them DTG Core Credentials' Purposes of Use (trustoverip/dtgwg-cred-spec#84): an optional credentialSubject.purposes on a VAC, any or a non-empty list, under context v2. verify_chain enforces the chain rule (a link stating none takes its parent's; a wider link is WidensPurposes) and reports the effective purposes. The member was refused outright before: the VAC subject denies unknown fields, so no verifier using this library could read a VAC carrying it. Breaking: new public fields on CredentialSubjectAuthority and VerifiedAuthority, and a new AuthorityError variant. Signed-off-by: Glenn Gore --- CHANGELOG.md | 21 ++++ src/authority.rs | 38 ++++++ src/create.rs | 63 ++++++++++ src/lib.rs | 37 +++++- src/purposes.rs | 247 +++++++++++++++++++++++++++++++++++++++ tests/authority_chain.rs | 126 ++++++++++++++++++++ 6 files changed, 529 insertions(+), 3 deletions(-) create mode 100644 src/purposes.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 89516bb..32c0f15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **A VAC states purposes of use** (DTG Core Credentials, *Purposes of Use*; + trustoverip/dtgwg-cred-spec#84). `credentialSubject.purposes` is optional, `{"scope":"any"}` + or `{"scope":"listed","purposes":[...]}` with a non-empty list of registered names or + absolute IRIs, each once; absent means `any`, and `any` never grants `modelTraining`. + `dtg_credentials::purposes::PurposeScope` models it, read strictly (an `any` carrying + `purposes`, an empty list or a duplicate is refused at parse). A VAC stating it lists + `DTG_CONTEXT_V2` (`https://registry.trustoverip.org/dtg/context/v2`) second, which is now + recognized beside `v1`; a VAC stating it under `v1` is refused. +- **`verify_chain` enforces the chain rule and reports the effective purposes.** A link + stating none takes its parent's; a link stating purposes its parent does not grant is + `AuthorityError::WidensPurposes`. The root states the grant, so only a root naming + `modelTraining` can pass it on. `VerifiedAuthority::purposes` carries the result. +- `DTGCredential::with_purposes` states them (switching the context to `v2`), and + `with_purposes_under` refuses at issue purposes wider than those the parent states. + +### Changed (breaking) + +- `CredentialSubjectAuthority` gains `purposes`, `VerifiedAuthority` gains `purposes`, and + `AuthorityError` gains `WidensPurposes`; `DTGCredentialError` gains `InvalidPurposes` + (non-exhaustive). + - **did:scid parties are matched by their DID.** Chain verification (`verify_chain`, delegation chains) and the acknowledgement/acceptance pairing compare an issuer, subject, presenter, governing party or principal by DID, ignoring a did:scid `?src=` diff --git a/src/authority.rs b/src/authority.rs index 055ccf6..e02d86a 100644 --- a/src/authority.rs +++ b/src/authority.rs @@ -103,6 +103,17 @@ pub enum AuthorityError { #[error("authority chain is empty")] EmptyChain, + /// A link states purposes its parent's effective purposes do not grant — `any` + /// beneath a list, a purpose the list does not name, or `modelTraining` its parent + /// does not name (DTG Core Credentials, *Purposes of Use*). + #[error("chain link {index} states purposes {purposes:?} its parent does not grant")] + WidensPurposes { + /// Position in the chain, leaf first. + index: usize, + /// The purposes beyond the parent's, or `["any"]`. + purposes: Vec, + }, + /// A link's digest could not be computed, or one it carries could not be read. /// /// Distinct from [AuthorityError::BrokenLink]: a digest that cannot be *read* is not a @@ -291,6 +302,10 @@ pub struct VerifiedAuthority { pub actions: Vec, /// The party governing the scope, which issued the chain root. pub governing_party: String, + /// The chain's **effective purposes**: what the presented VAC states, or, where it + /// states none, what the nearest link above it that does states — `any` where none + /// does. A verifier holds a content-disclosing request to these (*Purposes of Use*). + pub purposes: crate::purposes::PurposeScope, } /// Verify a chain of VACs and return what it permits. @@ -509,6 +524,28 @@ pub fn verify_chain( }); } + // Purposes, root to leaf: a link stating none takes its parent's, and a link stating + // purposes its parent does not grant invalidates the chain, as a widened action does. + let mut effective = crate::purposes::PurposeScope::Any; + for index in (0..chain.len()).rev() { + let stated = match &chain[index].credential().credential_subject { + crate::CredentialSubject::Authority(subject) => subject.purposes.as_ref(), + _ => None, + }; + if let Some(stated) = stated { + // The root has no parent: what the governing party states is the grant, and + // only it can name `modelTraining` for every link beneath. + let is_root = index == chain.len() - 1; + if !is_root && !stated.is_within(&effective) { + return Err(AuthorityError::WidensPurposes { + index, + purposes: stated.beyond(&effective), + }); + } + effective = stated.clone(); + } + } + // Finally, what was asked for. if leaf_grant.scope != requested_scope { return Err(AuthorityError::ScopeMismatch { @@ -527,5 +564,6 @@ pub fn verify_chain( scope: leaf_grant.scope.clone(), actions: leaf_grant.actions.clone(), governing_party: governing_party.to_string(), + purposes: effective, }) } diff --git a/src/create.rs b/src/create.rs index 729b683..bbc2b69 100644 --- a/src/create.rs +++ b/src/create.rs @@ -548,6 +548,7 @@ impl DTGCredential { parent: None, max_attenuation: None, }, + purposes: None, }), )) } @@ -640,6 +641,65 @@ impl DTGCredential { Ok(self) } + /// States `purposes` on a VAC: what its subject may use content it obtains under the + /// authority for (DTG Core Credentials, *Purposes of Use*). Lists [crate::DTG_CONTEXT_V2] + /// second, which defines the member. + /// + /// On a link this library attenuated, the purposes must be within the parent's + /// effective purposes — a link MUST NOT widen them — and this refuses ones wider than + /// purposes the parent states itself. A parent that states none takes its own parent's, + /// which this credential cannot see, so [crate::authority::verify_chain] remains the + /// authority on the chain rule. + /// + /// # Set it before signing + /// + /// Same caveat as [DTGCredential::with_id]. + /// + /// # Errors + /// + /// [DTGCredentialError::NotAnAuthorityCredential] on anything but a VAC, and + /// [DTGCredentialError::InvalidPurposes] for a malformed scope. + pub fn with_purposes( + mut self, + purposes: crate::purposes::PurposeScope, + ) -> Result { + purposes + .check() + .map_err(DTGCredentialError::InvalidPurposes)?; + match &mut self.credential.credential_subject { + CredentialSubject::Authority(subject) => subject.purposes = Some(purposes), + _ => return Err(DTGCredentialError::NotAnAuthorityCredential), + } + if let Some(second) = self.credential.context.get_mut(1) { + *second = crate::DTG_CONTEXT_V2.to_string(); + } + Ok(self) + } + + /// [DTGCredential::with_purposes] on a link attenuated from `parent`, refusing purposes + /// wider than those `parent` states. + /// + /// # Errors + /// + /// As [DTGCredential::with_purposes], and [DTGCredentialError::AttenuationWidens] where + /// `parent` states purposes these are not within. + pub fn with_purposes_under( + self, + parent: &DTGCredential, + purposes: crate::purposes::PurposeScope, + ) -> Result { + if let CredentialSubject::Authority(p) = &parent.credential.credential_subject + && let Some(stated) = &p.purposes + && !purposes.is_within(stated) + { + return Err(DTGCredentialError::AttenuationWidens(format!( + "purposes {:?} are wider than the parent's", + purposes.beyond(stated) + ))); + } + self.with_purposes(purposes) + } + /// Derive a narrower VAC from one this holder already holds. /// /// This is what lets a member equip an agent, a device, or a short-lived session with @@ -865,6 +925,9 @@ impl DTGCredential { parent: Some(parent_digest), max_attenuation, }, + // A link stating none takes its parent's; state narrower ones with + // [DTGCredential::with_purposes]. + purposes: None, }), )) } diff --git a/src/lib.rs b/src/lib.rs index 179ab48..9903269 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ pub mod accept; pub mod authority; pub mod create; pub mod delegation; +pub mod purposes; pub mod statement; pub use accept::{ @@ -52,6 +53,14 @@ pub const W3C_VC_V1_CONTEXT: &str = "https://www.w3.org/2018/credentials/v1"; /// recognized. A credential listing one is refused at parse rather than aliased. pub const DTG_CONTEXT_V1: &str = "https://registry.trustoverip.org/dtg/context/v1"; +/// The DTG credential context, version 2: [DTG_CONTEXT_V1] and the VAC's `purposes` +/// (DTG Core Credentials, *Purposes of Use*). +/// +/// A VAC stating `purposes` lists this one second, since `v1` is frozen and does not define +/// the member; a credential carrying no member `v2` added MAY continue to list `v1`. Both +/// are recognized, and nothing else is. +pub const DTG_CONTEXT_V2: &str = "https://registry.trustoverip.org/dtg/context/v2"; + /// The correlation scope an issuer declares for its own identifier, carried as the /// REQUIRED top-level `issuerScope` of every DTG credential (DTG Core Credentials /// §Correlation Scope). @@ -229,10 +238,16 @@ pub enum DTGCredentialError { #[error("taskContext is required here, and so is taskDigestMultibase, which is absent")] MissingTaskDigest, - /// `@context` does not list the W3C VC context first and [DTG_CONTEXT_V1] second. + /// `@context` does not list the W3C VC context first and [DTG_CONTEXT_V1] or + /// [DTG_CONTEXT_V2] second, or a VAC states `purposes` under `v1`. #[error("malformed @context: {0}")] InvalidContext(String), + /// A VAC's `purposes` is not a well-formed purpose scope: an empty list, a purpose + /// named twice, or one that is neither a registered name nor an absolute IRI. + #[error("malformed purposes: {0}")] + InvalidPurposes(String), + /// `type` is not `VerifiableCredential`, `DTGCredential` and exactly one concrete /// subtype (with `PersonhoodCredential` permitted as a hint on a VMC only), or names a /// subtype this specification has retired. @@ -826,6 +841,16 @@ impl DTGCredential { // construct one by deserialization either. return Err(DTGCredentialError::EmptyAuthorityActions); } + if let Some(scope) = &subject.purposes { + scope.check().map_err(DTGCredentialError::InvalidPurposes)?; + // `v1` is frozen and defines no `purposes`: a VAC stating them lists the + // context that does. + if c.context.get(1).map(String::as_str) != Some(DTG_CONTEXT_V2) { + return Err(DTGCredentialError::InvalidContext(format!( + "a VAC stating `purposes` lists `{DTG_CONTEXT_V2}` second" + ))); + } + } } (DTGCredentialType::Delegation, CredentialSubject::Delegation(subject)) => { check_delegation_shape(&subject.delegation)?; @@ -1457,9 +1482,9 @@ fn check_context(context: &[String]) -> Result _ => return Err(DTGCredentialError::UnknownVCVersion), }; match context.get(1).map(String::as_str) { - Some(DTG_CONTEXT_V1) => Ok(version), + Some(DTG_CONTEXT_V1 | DTG_CONTEXT_V2) => Ok(version), Some(other) => Err(DTGCredentialError::InvalidContext(format!( - "second entry is `{other}`, not `{DTG_CONTEXT_V1}`" + "second entry is `{other}`, not `{DTG_CONTEXT_V1}` or `{DTG_CONTEXT_V2}`" ))), None => Err(DTGCredentialError::InvalidContext(format!( "`{DTG_CONTEXT_V1}` is not listed second" @@ -2126,6 +2151,12 @@ pub struct CredentialSubjectAuthority { /// What the subject may do, and where. pub authority: AuthorityGrant, + + /// What the subject may use content it obtains under this authority **for** + /// (DTG Core Credentials, *Purposes of Use*). `None` states none: a root stating none + /// grants `any`, and a link stating none takes its parent's. Requires [DTG_CONTEXT_V2]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub purposes: Option, } /// Membership Credential subject diff --git a/src/purposes.rs b/src/purposes.rs new file mode 100644 index 0000000..1fa3f32 --- /dev/null +++ b/src/purposes.rs @@ -0,0 +1,247 @@ +//! Purposes of use on a VAC (DTG Core Credentials, *Purposes of Use*). +//! +//! `authority` says what the subject may do, and where; `purposes` says what for — the +//! purposes for which the subject may use content it obtains by exercising that authority. +//! The member has the meaning the VTI specification gives an access control entry's +//! purposes (VTI-ACL-100 – VTI-ACL-110). +//! +//! A VAC that states no `purposes` grants `any`, and `any` never includes +//! [MODEL_TRAINING]. Along a chain a link that states none takes its parent's, and a link +//! stating wider purposes than its parent's makes the chain invalid; the chain rule is +//! [crate::authority::verify_chain]'s. + +use serde::{Deserialize, Serialize}; + +/// The registered purpose `any` never grants: training, fine-tuning or evaluating a +/// machine-learning model on the content. +pub const MODEL_TRAINING: &str = "modelTraining"; + +/// The longest purpose this library accepts. +pub const MAX_PURPOSE_LEN: usize = 2048; + +/// The most purposes one VAC may list. +pub const MAX_LISTED_PURPOSES: usize = 32; + +/// A VAC's purposes: `{"scope": "any"}` or `{"scope": "listed", "purposes": [...]}`. +/// +/// The list is never empty, and an empty list is not `any`; [PurposeScope::check] refuses +/// one, and refuses a purpose named twice. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "WireScope", into = "WireScope")] +#[non_exhaustive] +pub enum PurposeScope { + /// Every purpose except [MODEL_TRAINING]. + Any, + /// Exactly these purposes, compared as exact, case-sensitive strings. + Listed { + /// Registered names or absolute IRIs, each named once. + purposes: Vec, + }, +} + +/// The wire form, read strictly. An internally tagged enum would accept +/// `{"scope": "any", "purposes": [...]}` — serde lets a unit variant ignore extra members — +/// and a credential is parsed with no schema in front of it, so the shape is checked here. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct WireScope { + scope: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + purposes: Option>, +} + +impl TryFrom for PurposeScope { + type Error = String; + + fn try_from(w: WireScope) -> Result { + match (w.scope.as_str(), w.purposes) { + ("any", None) => Ok(Self::Any), + ("any", Some(_)) => Err("`any` carries no `purposes`".into()), + ("listed", Some(purposes)) => Ok(Self::Listed { purposes }), + ("listed", None) => Err("`listed` names its `purposes`".into()), + (other, _) => Err(format!( + "`{other}` is not a purpose scope: `any` or `listed`" + )), + } + } +} + +impl From for WireScope { + fn from(p: PurposeScope) -> Self { + match p { + PurposeScope::Any => Self { + scope: "any".into(), + purposes: None, + }, + PurposeScope::Listed { purposes } => Self { + scope: "listed".into(), + purposes: Some(purposes), + }, + } + } +} + +impl Default for PurposeScope { + /// `any`: what a VAC stating no `purposes` grants. + fn default() -> Self { + Self::Any + } +} + +/// Whether `p` is a purpose: a lowerCamelCase name of at most 64 characters, as the VTI +/// purpose registry spells them, or an absolute IRI. +/// +/// A well-formed name the registry does not hold is accepted — it equals only itself — so +/// the registry can grow without a new release of this library. +pub fn is_purpose(p: &str) -> bool { + if p.is_empty() || p.len() > MAX_PURPOSE_LEN { + return false; + } + let mut chars = p.chars(); + let Some(first) = chars.next() else { + return false; + }; + if let Some((scheme, rest)) = p.split_once(':') { + return first.is_ascii_alphabetic() + && scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-')) + && !rest.is_empty() + && !rest.chars().any(char::is_whitespace); + } + first.is_ascii_lowercase() && p.len() <= 64 && chars.all(|c| c.is_ascii_alphanumeric()) +} + +impl PurposeScope { + /// A `listed` scope. + pub fn listed(purposes: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self::Listed { + purposes: purposes.into_iter().map(Into::into).collect(), + } + } + + /// The shape rules: a non-empty list of at most [MAX_LISTED_PURPOSES] purposes, each + /// well formed and named once. + pub fn check(&self) -> Result<(), String> { + let Self::Listed { purposes } = self else { + return Ok(()); + }; + if purposes.is_empty() { + return Err( + "a listed purpose scope names no purpose, and an empty list is not `any`".into(), + ); + } + if purposes.len() > MAX_LISTED_PURPOSES { + return Err(format!( + "a purpose scope lists at most {MAX_LISTED_PURPOSES} purposes" + )); + } + for (i, p) in purposes.iter().enumerate() { + if !is_purpose(p) { + return Err(format!( + "`{p}` is neither a registered purpose name nor an absolute IRI" + )); + } + if purposes[..i].contains(p) { + return Err(format!("`{p}` is listed twice")); + } + } + Ok(()) + } + + /// Whether this grants `purpose`: `any` grants every purpose but [MODEL_TRAINING]; a + /// list grants exactly its members. + pub fn grants(&self, purpose: &str) -> bool { + match self { + Self::Any => purpose != MODEL_TRAINING, + Self::Listed { purposes } => purposes.iter().any(|p| p == purpose), + } + } + + /// Whether a link stating `self` may sit beneath a parent whose effective purposes are + /// `parent`: `any` only beneath `any`, and a list only of purposes `parent` grants. + pub fn is_within(&self, parent: &PurposeScope) -> bool { + match (self, parent) { + (Self::Any, Self::Any) => true, + (Self::Any, Self::Listed { .. }) => false, + (Self::Listed { purposes }, parent) => purposes.iter().all(|p| parent.grants(p)), + } + } + + /// The purposes in `self` that `parent` does not grant — `["any"]` for `any` beneath a + /// list — for a refusal that names them. + pub fn beyond(&self, parent: &PurposeScope) -> Vec { + match (self, parent) { + (Self::Any, Self::Listed { .. }) => vec!["any".to_string()], + (Self::Any, Self::Any) => Vec::new(), + (Self::Listed { purposes }, parent) => purposes + .iter() + .filter(|p| !parent.grants(p)) + .cloned() + .collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn the_two_shapes_round_trip_and_nothing_else_parses() { + assert_eq!( + serde_json::to_value(PurposeScope::Any).unwrap(), + json!({ "scope": "any" }) + ); + let listed: PurposeScope = + serde_json::from_value(json!({ "scope": "listed", "purposes": ["taskExecution"] })) + .unwrap(); + assert_eq!(listed, PurposeScope::listed(["taskExecution"])); + assert!(serde_json::from_value::(json!({ "scope": "none" })).is_err()); + assert!( + serde_json::from_value::(json!({ "scope": "any", "purposes": [] })) + .is_err() + ); + } + + #[test] + fn an_empty_list_a_duplicate_or_a_malformed_purpose_is_refused() { + assert!(PurposeScope::listed(Vec::::new()).check().is_err()); + assert!( + PurposeScope::listed(["analytics", "analytics"]) + .check() + .is_err() + ); + assert!(PurposeScope::listed(["Analytics"]).check().is_err()); + assert!( + PurposeScope::listed(["https://w3id.org/dpv#ServicePersonalisation", "someNewName"]) + .check() + .is_ok() + ); + } + + #[test] + fn any_never_grants_model_training_and_a_list_grants_exactly_its_members() { + assert!(!PurposeScope::Any.grants(MODEL_TRAINING)); + assert!(PurposeScope::Any.grants("analytics")); + let l = PurposeScope::listed(["personalisation"]); + assert!(l.grants("personalisation")); + assert!(!l.grants("https://w3id.org/dpv#ServicePersonalisation")); + } + + #[test] + fn a_link_narrows_and_never_widens() { + let pa = PurposeScope::listed(["personalisation", "analytics"]); + assert!(PurposeScope::Any.is_within(&PurposeScope::Any)); + assert!(pa.is_within(&PurposeScope::Any)); + assert!(PurposeScope::listed(["analytics"]).is_within(&pa)); + assert!(!PurposeScope::Any.is_within(&pa)); + assert!(!PurposeScope::listed([MODEL_TRAINING]).is_within(&PurposeScope::Any)); + assert_eq!(PurposeScope::Any.beyond(&pa), vec!["any".to_string()]); + } +} diff --git a/tests/authority_chain.rs b/tests/authority_chain.rs index 389f73e..deb4c06 100644 --- a/tests/authority_chain.rs +++ b/tests/authority_chain.rs @@ -1010,3 +1010,129 @@ fn a_did_scid_party_is_matched_by_its_did() { "got {err:?}" ); } + +// ─── Purposes of use (DTG Core Credentials, *Purposes of Use*) ────────────────── + +mod purposes { + use super::*; + use dtg_credentials::purposes::{MODEL_TRAINING, PurposeScope}; + + fn with(c: DTGCredential, p: PurposeScope) -> DTGCredential { + c.with_purposes(p).expect("a well-formed scope") + } + + /// A chain stating no purposes grants `any`, as a VAC from before the member did. + #[test] + fn a_chain_stating_none_grants_any() { + let root = root_grant(); + let agent = agent_grant(&root); + let v = verify_chain(&[agent, root], ROOM, ROOM, "read", AGENT, t(1)).unwrap(); + assert_eq!(v.purposes, PurposeScope::Any); + assert!(!v.purposes.grants(MODEL_TRAINING)); + } + + /// The case the member exists for: a principal equips an agent for one task, and the + /// chain says so. + #[test] + fn an_agent_link_narrows_the_purposes_and_the_chain_reports_them() { + let root = with( + root_grant(), + PurposeScope::listed(["personalisation", "taskExecution"]), + ); + let agent = with(agent_grant(&root), PurposeScope::listed(["taskExecution"])); + let v = verify_chain(&[agent, root], ROOM, ROOM, "read", AGENT, t(1)).unwrap(); + assert_eq!(v.purposes, PurposeScope::listed(["taskExecution"])); + } + + /// A link stating none takes its parent's. + #[test] + fn a_link_stating_none_inherits_its_parents() { + let root = with(root_grant(), PurposeScope::listed(["personalisation"])); + let agent = agent_grant(&root); + let v = verify_chain(&[agent, root], ROOM, ROOM, "read", AGENT, t(1)).unwrap(); + assert_eq!(v.purposes, PurposeScope::listed(["personalisation"])); + } + + /// Widening is refused, whichever way it is attempted. + #[test] + fn a_link_widening_its_parents_purposes_invalidates_the_chain() { + let root = with(root_grant(), PurposeScope::listed(["personalisation"])); + for (wider, beyond) in [ + (PurposeScope::Any, vec!["any".to_string()]), + ( + PurposeScope::listed(["personalisation", "analytics"]), + vec!["analytics".to_string()], + ), + ] { + let agent = with(agent_grant(&root), wider); + let err = + verify_chain(&[agent, root.clone()], ROOM, ROOM, "read", AGENT, t(1)).unwrap_err(); + assert_eq!( + err, + AuthorityError::WidensPurposes { + index: 0, + purposes: beyond + } + ); + } + } + + /// `any` never includes model training, so a link beneath a root stating none cannot + /// state it; only a root naming it can pass it on. + #[test] + fn model_training_reaches_a_link_only_from_a_root_that_names_it() { + let root = root_grant(); + let agent = with(agent_grant(&root), PurposeScope::listed([MODEL_TRAINING])); + assert!(matches!( + verify_chain(&[agent, root], ROOM, ROOM, "read", AGENT, t(1)), + Err(AuthorityError::WidensPurposes { .. }) + )); + + let root = with(root_grant(), PurposeScope::listed([MODEL_TRAINING])); + let agent = agent_grant(&root); + let v = verify_chain(&[agent, root], ROOM, ROOM, "read", AGENT, t(1)).unwrap(); + assert!(v.purposes.grants(MODEL_TRAINING)); + } + + /// `with_purposes_under` refuses at issue what the verifier would refuse at use, where + /// the parent states its purposes. + #[test] + fn attenuation_refuses_wider_purposes_at_issue() { + let root = with(root_grant(), PurposeScope::listed(["personalisation"])); + let err = agent_grant(&root) + .with_purposes_under(&root, PurposeScope::Any) + .unwrap_err(); + assert!( + matches!(err, DTGCredentialError::AttenuationWidens(_)), + "{err:?}" + ); + } + + /// The member is defined by context v2: stating it lists v2 second, and a VAC stating + /// it under v1 is refused at parse. + #[test] + fn a_vac_stating_purposes_lists_context_v2_and_v1_refuses_it() { + let vac = with(root_grant(), PurposeScope::listed(["analytics"])); + let mut json = serde_json::to_value(vac.credential()).unwrap(); + assert_eq!(json["@context"][1], dtg_credentials::DTG_CONTEXT_V2); + assert_eq!( + json["credentialSubject"]["purposes"], + serde_json::json!({ "scope": "listed", "purposes": ["analytics"] }) + ); + assert!(DTGCredential::try_from(json.clone()).is_ok(), "v2 parses"); + + json["@context"][1] = serde_json::json!(dtg_credentials::DTG_CONTEXT_V1); + assert!( + DTGCredential::try_from(json.clone()).is_err(), + "purposes under v1" + ); + + json["@context"][1] = serde_json::json!(dtg_credentials::DTG_CONTEXT_V2); + json["credentialSubject"]["purposes"] = + serde_json::json!({ "scope": "listed", "purposes": [] }); + assert!( + DTGCredential::try_from(json).is_err(), + "an empty list is not any" + ); + } +}