use serde_json::{Value, json};
use trust_tasks_rs::{ErrorPayload, StandardCode, TrustTask, TrustTaskCode};
use vta_sdk::trust_tasks as uris;
use vti_common::error::AppError;
use crate::audit;
use crate::auth::AuthClaims;
use crate::server::AppState;
use super::helpers::{
TrustTaskOutcome, error_response, parse_payload, reject_with_code, success_response,
};
const FAMILY_SLUG: &str = "persona";
fn slug_from_doc(doc: &TrustTask<Value>) -> String {
doc.type_uri
.to_string()
.strip_prefix("https://trusttasks.org/spec/")
.and_then(|rest| rest.rsplit_once('/'))
.map(|(slug, _ver)| slug.to_string())
.unwrap_or_else(|| FAMILY_SLUG.to_string())
}
fn ext(slug: &str, local: &str) -> TrustTaskCode {
TrustTaskCode::new_extended(slug, local).expect("persona extended code is grammar-valid")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Reach {
Holder,
Context,
Any,
}
pub const REACH: &[(&str, Reach)] = &[
(uris::TASK_PERSONA_ATTRIBUTE_PUT_1_0, Reach::Holder),
(uris::TASK_PERSONA_ATTRIBUTE_LIST_1_0, Reach::Holder),
(uris::TASK_PERSONA_ATTRIBUTE_DELETE_1_0, Reach::Holder),
(
uris::TASK_PERSONA_ATTRIBUTE_PURGE_VERSION_1_0,
Reach::Holder,
),
(uris::TASK_PERSONA_ATTRIBUTE_PROMOTE_1_0, Reach::Holder),
(uris::TASK_PERSONA_PROFILE_COMPOSE_1_0, Reach::Holder),
(uris::TASK_PERSONA_PROFILE_RETIRE_1_0, Reach::Holder),
(uris::TASK_PERSONA_PROFILE_REINSTATE_1_0, Reach::Holder),
(uris::TASK_PERSONA_PROFILE_USAGE_1_0, Reach::Holder),
(uris::TASK_PERSONA_PROFILE_TIMELINE_1_0, Reach::Holder),
(uris::TASK_PERSONA_PROFILE_PUT_1_0, Reach::Holder),
(uris::TASK_PERSONA_PROFILE_GET_1_0, Reach::Holder),
(uris::TASK_PERSONA_PROFILE_LIST_1_0, Reach::Holder),
(uris::TASK_PERSONA_PROFILE_DELETE_1_0, Reach::Holder),
(uris::TASK_PERSONA_FACET_PUT_1_0, Reach::Holder),
(uris::TASK_PERSONA_FACET_LIST_1_0, Reach::Holder),
(uris::TASK_PERSONA_FACET_DELETE_1_0, Reach::Holder),
(uris::TASK_PERSONA_BINDING_SET_1_0, Reach::Holder),
(uris::TASK_PERSONA_DISCLOSURE_HISTORY_1_0, Reach::Holder),
(uris::TASK_PERSONA_CORRELATION_ANALYZE_1_0, Reach::Holder),
(uris::TASK_PERSONA_BINDING_GET_1_0, Reach::Context),
(uris::TASK_PERSONA_BINDING_LIST_1_0, Reach::Context),
(uris::TASK_PERSONA_CONTACT_PUT_1_0, Reach::Context),
(uris::TASK_PERSONA_CONTACT_GET_1_0, Reach::Context),
(uris::TASK_PERSONA_CONTACT_LIST_1_0, Reach::Context),
(uris::TASK_PERSONA_CONTACT_DELETE_1_0, Reach::Context),
(uris::TASK_PERSONA_DISCLOSURE_PREVIEW_1_0, Reach::Context),
(uris::TASK_PERSONA_DISCLOSURE_PRESENT_1_0, Reach::Context),
(uris::TASK_PERSONA_RENDERERS_LIST_1_0, Reach::Any),
(uris::TASK_PERSONA_CLAIM_TYPES_LIST_1_0, Reach::Any),
(uris::TASK_PERSONA_LOCAL_PROFILE_PUT_1_0, Reach::Context),
(uris::TASK_PERSONA_LOCAL_PROFILE_GET_1_0, Reach::Context),
(uris::TASK_PERSONA_LOCAL_PROFILE_LIST_1_0, Reach::Context),
(uris::TASK_PERSONA_LOCAL_PROFILE_DELETE_1_0, Reach::Context),
(uris::TASK_PERSONA_LOCAL_BINDING_SET_1_0, Reach::Context),
];
#[must_use]
pub fn reach_of(uri: &str) -> Option<Reach> {
REACH.iter().find(|(u, _)| *u == uri).map(|(_, r)| *r)
}
async fn is_holder(state: &AppState, claims: &AuthClaims) -> bool {
claims.is_super_admin() || holder_capability_granted(state, claims).await
}
async fn holder_capability_granted(state: &AppState, claims: &AuthClaims) -> bool {
match vti_common::acl::get_acl_entry(&state.acl_ks, &claims.did).await {
Ok(Some(entry)) => vti_common::acl::entry_has_capability(
&entry,
vti_common::acl::Capability::PersonaHolder,
),
Ok(None) => false,
Err(e) => {
tracing::error!(
error = %e, did = %claims.did,
"could not read the ACL entry for a persona holder check; refusing"
);
false
}
}
}
pub async fn authorize(
state: &AppState,
claims: &AuthClaims,
uri: &str,
context_id: Option<&str>,
) -> Result<(), AppError> {
let granted = matches!(reach_of(uri), Some(Reach::Holder))
&& !claims.is_super_admin()
&& holder_capability_granted(state, claims).await;
decide(claims, uri, context_id, granted)
}
fn decide(
claims: &AuthClaims,
uri: &str,
context_id: Option<&str>,
holder_granted: bool,
) -> Result<(), AppError> {
match reach_of(uri) {
None => Err(AppError::Forbidden(format!(
"unknown persona task {uri}: refusing rather than defaulting a reach"
))),
Some(Reach::Holder) => {
if claims.is_super_admin() || holder_granted {
return Ok(());
}
Err(AppError::Forbidden(
"this task reads or writes the holder's attribute pool, which sits above every \
trust context. It requires an unscoped holder credential, or an ACL entry \
granted the `persona-holder` capability; an administrator scoped to a context \
and holding neither is refused here exactly as an application would be."
.into(),
))
}
Some(Reach::Context) => match context_id {
Some(ctx) => claims.require_context(ctx),
None => Err(AppError::Validation(
"a context-scoped persona task must name the context it acts in".into(),
)),
},
Some(Reach::Any) => Ok(()),
}
}
use trust_tasks_rs::specs::persona as spec;
use vta_persona::{
Listing, PersonaStore, ReleaseRequirement, Sensitivity, ValueType, ValueVisibility,
new_attribute,
};
pub(super) fn store(state: &AppState) -> PersonaStore {
PersonaStore::new(state.persona_ks.clone(), state.persona_correlation_key)
}
fn put_opt<T: serde::Serialize>(body: &mut Value, key: &str, value: Option<T>) {
if let Some(v) = value {
body[key] = json!(v);
}
}
async fn audit_persona(
state: &AppState,
action: &str,
auth: &AuthClaims,
resource: Option<&str>,
context_id: Option<&str>,
detail: Option<&str>,
) {
if let Err(e) = audit::record_with_detail(
&state.audit_sink,
action,
&auth.did,
resource,
"success",
Some(super::helpers::TRANSPORT_TRUST_TASK),
context_id,
detail,
)
.await
{
tracing::warn!(error = %e, action = %action, "audit record failed for persona task");
}
}
fn provenance_kind(p: &vta_persona::Provenance) -> &'static str {
match p {
vta_persona::Provenance::SelfAsserted => "selfAsserted",
vta_persona::Provenance::CredentialBacked { .. } => "credentialBacked",
vta_persona::Provenance::Generated { .. } => "generated",
vta_persona::Provenance::Derived { .. } => "derived",
}
}
fn wire_name<T: serde::Serialize>(v: T) -> String {
serde_json::to_value(v)
.ok()
.and_then(|j| j.as_str().map(str::to_string))
.unwrap_or_else(|| "unknown".to_string())
}
fn reject(doc: &TrustTask<Value>, e: AppError) -> TrustTaskOutcome {
let slug = slug_from_doc(doc);
let message = e.to_string();
let (code, details): (TrustTaskCode, Option<Value>) = match &e {
AppError::Forbidden(_) | AppError::Unauthorized(_) => {
(StandardCode::PermissionDenied.into(), None)
}
AppError::NotFound(_) => (ext(&slug, "notFound"), None),
AppError::Conflict(reason) => (
ext(&slug, "versionConflict"),
Some(json!({ "reason": reason })),
),
AppError::Validation(reason) => (
StandardCode::MalformedRequest.into(),
Some(json!({ "reason": reason })),
),
AppError::Gone(_) => (ext(&slug, "revisionReaped"), None),
_ => (StandardCode::InternalError.into(), None),
};
let mut payload = ErrorPayload::new(code).with_message(message);
if let Some(d) = details {
payload = payload.with_details(d);
}
error_response(doc.reject_with(format!("urn:uuid:{}", uuid::Uuid::new_v4()), payload))
}
fn step_up_required(doc: &TrustTask<Value>, details: Value) -> TrustTaskOutcome {
let payload = ErrorPayload::new(ext(&slug_from_doc(doc), "stepUpRequired"))
.with_message("a claim in this preview requires a fresh approval")
.with_details(details);
error_response(doc.reject_with(format!("urn:uuid:{}", uuid::Uuid::new_v4()), payload))
}
pub(super) async fn handle_attribute_put(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::attribute::put::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_ATTRIBUTE_PUT_1_0, None).await {
return reject(&doc, e);
}
let value_type = match serde_json::to_string(&req.value_type)
.ok()
.and_then(|s| serde_json::from_str::<ValueType>(&s).ok())
{
Some(v) => v,
None => {
return reject(&doc, AppError::Validation("unrecognised valueType".into()));
}
};
let provenance: vta_persona::Provenance = match serde_json::to_value(&req.provenance)
.ok()
.and_then(|v| serde_json::from_value(v).ok())
{
Some(p) => p,
None => return reject(&doc, AppError::Validation("unrecognised provenance".into())),
};
let provenance_kind = provenance_kind(&provenance);
let sensitivity: Option<Sensitivity> = match req.sensitivity.as_ref() {
None => None,
Some(s) => match serde_json::to_value(s)
.ok()
.and_then(|v| serde_json::from_value(v).ok())
{
Some(parsed) => Some(parsed),
None => {
return reject(
&doc,
AppError::Validation("unrecognised sensitivity".into()),
);
}
},
};
let release: Option<ReleaseRequirement> = match req.release.as_ref() {
None => None,
Some(r) => match serde_json::to_value(r)
.ok()
.and_then(|v| serde_json::from_value(v).ok())
{
Some(parsed) => Some(parsed),
None => {
return reject(&doc, AppError::Validation("unrecognised release".into()));
}
},
};
let mut attribute = new_attribute(
req.type_.to_string(),
value_type,
req.value.clone(),
provenance,
);
if let Some(id) = &req.attribute_id {
attribute.attribute_id = id.to_string();
}
attribute.label = req.label.as_ref().map(|l| (**l).clone());
attribute.sensitivity = sensitivity;
attribute.release = release;
let endorsements: Vec<String> = req
.endorsements
.iter()
.flatten()
.map(|e| e.to_string())
.collect();
let mut missing = Vec::new();
for id in &endorsements {
match crate::vault::storage::get(&state.vault_ks, id).await {
Ok(Some(c)) if c.lifecycle != vti_common::vault::VaultStatus::Deleted => {}
Ok(_) => missing.push(id.clone()),
Err(e) => return reject(&doc, e),
}
}
if !missing.is_empty() {
return reject_with_code(
&doc,
ext(&slug_from_doc(&doc), "endorsementNotFound"),
format!(
"{} endorsement(s) name a credential the vault does not hold",
missing.len()
),
Some(json!({ "credentialIds": missing })),
);
}
attribute.endorsements = endorsements;
let attribute_id = attribute.attribute_id.clone();
let value = attribute.value.clone();
let s = store(state);
let written = match s.put(attribute, req.expected_version.map(|v| *v)).await {
Ok(w) => w,
Err(e) => return reject(&doc, e),
};
let shared = match &value {
Some(v) => s.correlation_count(v, &attribute_id).await.unwrap_or(0),
None => 0,
};
let sensitivity_note = match sensitivity {
Some(s) => format!(", sensitivity {} set by the holder", wire_name(s)),
None => String::new(),
};
let release_note = match release {
Some(r) => format!(", release {} set by the holder", wire_name(r)),
None => String::new(),
};
let detail = format!(
"{} attribute {attribute_id}: claim type {}, valueType {}, provenance {}{}{}, now at \
version {}",
if written.created {
"created"
} else {
"updated"
},
req.type_.as_str(),
wire_name(value_type),
provenance_kind,
sensitivity_note,
release_note,
written.version,
);
audit_persona(
state,
"persona.attribute.put",
auth,
Some(&attribute_id),
None,
Some(&detail),
)
.await;
let reach = if written.created {
vta_persona::AttributeReach::default()
} else {
s.attribute_reach(&attribute_id).await.unwrap_or_default()
};
let mut body = serde_json::json!({
"attributeId": attribute_id,
"version": written.version,
"created": written.created,
"updatedAt": chrono::Utc::now().to_rfc3339(),
"correlation": {
"severity": if shared > 0 { "high" } else { "none" },
"sharedWithProfileCount": shared,
},
});
if !reach.refreshed.is_empty() {
body["refreshed"] = reach
.refreshed
.iter()
.take(256)
.map(|r| {
json!({
"profileId": r.profile_id,
"contextId": r.context_id,
"personaDid": r.persona_did,
})
})
.collect();
}
if !reach.held_by_pin.is_empty() {
body["heldByPin"] = reach
.held_by_pin
.iter()
.take(256)
.map(|h| json!({ "profileId": h.profile_id, "pinVersion": h.pin_version }))
.collect();
}
success_response(&doc, body)
}
pub(super) async fn handle_attribute_list(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::attribute::list::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_ATTRIBUTE_LIST_1_0, None).await {
return reject(&doc, e);
}
let visibility = ValueVisibility::from_flags(req.include_values, req.include_sensitive);
let s = store(state);
let prefix = req.type_prefix.as_ref().map(|p| p.as_str());
let listing = match s.list_attributes(prefix, visibility).await {
Ok(l) => l,
Err(e) => return reject(&doc, e),
};
let detail = list_detail(&listing, visibility, prefix);
audit_persona(
state,
"persona.attribute.list",
auth,
None,
None,
Some(&detail),
)
.await;
success_response(
&doc,
serde_json::json!({ "attributes": listing.attributes }),
)
}
fn list_detail(listing: &Listing, visibility: ValueVisibility, prefix: Option<&str>) -> String {
let scope = match prefix {
Some(p) => format!(" under {p}"),
None => String::new(),
};
let plaintext = match visibility {
ValueVisibility::Metadata => "metadata only, no values".to_string(),
ValueVisibility::Ordinary => format!(
"values included, {} sensitive value(s) withheld",
listing.withheld_sensitive
),
ValueVisibility::All => "values included, sensitive values included".to_string(),
};
format!(
"listed {} attribute(s){scope}: {plaintext}",
listing.attributes.len()
)
}
pub(super) async fn handle_attribute_delete(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::attribute::delete::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_ATTRIBUTE_DELETE_1_0, None).await {
return reject(&doc, e);
}
let id = req.attribute_id.to_string();
let out = match store(state).delete(&id, req.cascade).await {
Ok(o) => o,
Err(e) => return reject(&doc, e),
};
let detail = format!(
"attribute {id} {}; cascade {}; removed from {} profile(s)",
if out.existed {
"deleted"
} else {
"did not exist"
},
req.cascade,
out.referring_profiles.len(),
);
audit_persona(
state,
"persona.attribute.delete",
auth,
Some(&id),
None,
Some(&detail),
)
.await;
success_response(
&doc,
serde_json::json!({
"attributeId": id,
"existed": out.existed,
"removedFromProfiles": out.referring_profiles,
}),
)
}
pub(super) async fn handle_attribute_purge_version(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::attribute::purge_version::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(
state,
auth,
uris::TASK_PERSONA_ATTRIBUTE_PURGE_VERSION_1_0,
None,
)
.await
{
return reject(&doc, e);
}
let id = req.attribute_id.to_string();
let versions: Option<Vec<u64>> = req
.versions
.as_ref()
.map(|vs| vs.iter().map(|v| v.get()).collect());
let s = store(state);
if let (Some(asked), Ok(Some(current))) = (&versions, s.get(&id).await)
&& asked.contains(¤t.version)
{
return reject_with_code(
&doc,
ext(&slug_from_doc(&doc), "currentVersion"),
format!(
"version {} is the current value; remove it with persona/attribute/delete",
current.version
),
None,
);
}
let out = match s.purge_versions(&id, versions.as_deref()).await {
Ok(o) => o,
Err(e) => return reject(&doc, e),
};
let detail = format!(
"attribute {id}: purged {} kept version(s) {:?}; {} face(s) left presenting that \
entry as stale",
out.purged.len(),
out.purged,
out.stale_pins.len(),
);
audit_persona(
state,
"persona.attribute.purge_version",
auth,
Some(&id),
None,
Some(&detail),
)
.await;
let mut body = json!({ "attributeId": id, "purged": out.purged });
if !out.stale_pins.is_empty() {
body["stalePins"] = out
.stale_pins
.iter()
.take(256)
.map(|h| json!({ "profileId": h.profile_id, "pinVersion": h.pin_version }))
.collect();
}
success_response(&doc, body)
}
pub(super) async fn handle_profile_put(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::profile::put::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_PROFILE_PUT_1_0, None).await {
return reject(&doc, e);
}
let entries: Vec<vta_persona::ProfileEntry> = match serde_json::to_value(&req.entries)
.ok()
.and_then(|v| serde_json::from_value(v).ok())
{
Some(e) => e,
None => {
return reject(
&doc,
AppError::Validation("unrecognised profile entry".into()),
);
}
};
if let Some(slot) = vta_persona::model::duplicate_slot(&entries) {
return reject_with_code(
&doc,
ext(&slug_from_doc(&doc), "duplicateSlot"),
format!("two entries of this face both claim the slot {slot}"),
Some(json!({ "slot": slot })),
);
}
match store(state).unavailable_pins(&entries).await {
Ok(missing) if !missing.is_empty() => {
return reject_with_code(
&doc,
ext(&slug_from_doc(&doc), "pinnedVersionUnavailable"),
format!(
"{} pin(s) name a version this VTA does not hold",
missing.len()
),
Some(json!({
"pins": missing
.iter()
.map(|(a, v)| json!({ "attributeId": a, "pinVersion": v }))
.collect::<Vec<_>>(),
})),
);
}
Ok(_) => {}
Err(e) => return reject(&doc, e),
}
let mut profile = vta_persona::new_profile(req.name.to_string(), entries);
if let Some(id) = &req.profile_id {
profile.profile_id = id.to_string();
}
profile.credential_refs = req.credential_refs.iter().map(|c| (**c).clone()).collect();
match &req.reach {
Some(r) => match serde_json::to_value(r)
.ok()
.and_then(|v| serde_json::from_value::<vta_persona::FaceReach>(v).ok())
{
Some(reach) => profile.reach = reach,
None => return reject(&doc, AppError::Validation("unrecognised reach".into())),
},
None => match store(state).get_profile(&profile.profile_id).await {
Ok(Some(existing)) => profile.reach = existing.reach,
Ok(None) => {}
Err(e) => return reject(&doc, e),
},
}
let profile_id = profile.profile_id.clone();
let entry_count = profile.entries.len();
match store(state)
.reach_would_exclude(&profile_id, &profile.reach)
.await
{
Ok(excluded) if !excluded.is_empty() => {
return reject_with_code(
&doc,
ext(&slug_from_doc(&doc), "boundOutsideReach"),
format!(
"the new reach excludes {} context(s) this face is worn in",
excluded.len()
),
Some(json!({ "contextIds": excluded })),
);
}
Ok(_) => {}
Err(e) => return reject(&doc, e),
}
let written = match store(state)
.put_profile(profile, req.expected_version.map(|v| *v))
.await
{
Ok(w) => w,
Err(e) => return reject(&doc, e),
};
let detail = format!(
"{} profile {profile_id} with {} entr{}, now at version {}",
if written.created {
"created"
} else {
"updated"
},
entry_count,
if entry_count == 1 { "y" } else { "ies" },
written.version,
);
audit_persona(
state,
"persona.profile.put",
auth,
Some(&profile_id),
None,
Some(&detail),
)
.await;
success_response(
&doc,
json!({
"profileId": profile_id,
"version": written.version,
"created": written.created,
"updatedAt": chrono::Utc::now().to_rfc3339(),
}),
)
}
pub(super) async fn handle_profile_compose(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::profile::compose::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_PROFILE_COMPOSE_1_0, None).await {
return reject(&doc, e);
}
use spec::profile::compose::v1_0 as wire;
let mut claims = Vec::with_capacity(req.claims.len());
for claim in &req.claims {
claims.push(match claim {
wire::ComposeClaim::HeldClaim(h) => vta_persona::ComposeClaim::Held {
attribute_id: h.attribute_id.to_string(),
slot: h.slot.as_ref().map(|s| s.to_string()),
},
wire::ComposeClaim::NewClaim(n) => {
let Some(value_type) = serde_json::to_value(n.value_type)
.ok()
.and_then(|v| serde_json::from_value::<ValueType>(v).ok())
else {
return reject(&doc, AppError::Validation("unrecognised valueType".into()));
};
let share = match n.share {
wire::NewClaimShare::Local => vta_persona::Share::Local,
wire::NewClaimShare::Pool => vta_persona::Share::Pool,
#[allow(unreachable_patterns)]
_ => {
return reject(&doc, AppError::Validation("unrecognised share".into()));
}
};
vta_persona::ComposeClaim::New {
r#type: n.type_.to_string(),
value_type,
value: n.value.clone(),
label: n.label.as_ref().map(|l| l.to_string()),
slot: n.slot.as_ref().map(|s| s.to_string()),
share,
}
}
#[allow(unreachable_patterns)]
_ => return reject(&doc, AppError::Validation("unrecognised claim".into())),
});
}
let request = vta_persona::ComposeRequest {
context_id: req.context_id.to_string(),
name: req.name.to_string(),
claims,
persona_did: req.persona_did.as_ref().map(|d| d.to_string()),
label: req.label.as_ref().map(|l| l.to_string()),
until: req.until.map(|u| u.to_rfc3339()),
};
let s = store(state);
let slug = slug_from_doc(&doc);
match s.compose_refusal(&request).await {
Ok(None) => {}
Ok(Some(vta_persona::ComposeRefusal::UnresolvedReference(ids))) => {
return reject_with_code(
&doc,
ext(&slug, "unresolvedReference"),
format!(
"the face draws on {} attribute(s) the pool does not hold",
ids.len()
),
Some(json!({ "attributeIds": ids })),
);
}
Ok(Some(vta_persona::ComposeRefusal::DuplicateSlot(slot))) => {
return reject_with_code(
&doc,
ext(&slug, "duplicateSlot"),
format!("two claims of this face both claim the slot {slot}"),
Some(json!({ "slot": slot })),
);
}
Ok(Some(vta_persona::ComposeRefusal::LabelWithoutPersona)) => {
return reject_with_code(
&doc,
ext(&slug, "labelWithoutPersona"),
"a label names the face to the context it is worn in; give a personaDid or \
leave the label off",
None,
);
}
Ok(Some(vta_persona::ComposeRefusal::UntilNotFuture)) => {
return reject_with_code(
&doc,
ext(&slug, "untilNotFuture"),
"`until` must be in the future, and needs a personaDid to wear the face",
None,
);
}
Ok(Some(other)) => {
return reject(&doc, AppError::Validation(format!("{other:?}")));
}
Err(e) => return reject(&doc, e),
}
let claim_count = request.claims.len();
let context_id = request.context_id.clone();
let composed = match s.compose(request).await {
Ok(c) => c,
Err(e) => return reject(&doc, e),
};
let scope = match composed.scope {
vta_persona::FaceScope::Local => "local",
vta_persona::FaceScope::Pool => "pool",
};
let detail = format!(
"composed {scope} face {} for context {context_id} with {claim_count} claim(s), {} \
pooled ({} created){}",
composed.profile_id,
composed.pooled.len(),
composed.pooled.iter().filter(|p| p.created).count(),
composed
.binding
.as_ref()
.map_or_else(String::new, |b| format!(", worn by {}", b.persona_did)),
);
audit_persona(
state,
"persona.profile.compose",
auth,
Some(&composed.profile_id),
Some(&context_id),
Some(&detail),
)
.await;
let mut body = json!({
"profileId": composed.profile_id,
"scope": scope,
"version": composed.version,
"correlation": {
"severity": if composed.shared_count > 0 { "high" } else { "none" },
"sharedAttributeCount": composed.shared_count,
},
});
if !composed.pooled.is_empty() {
body["pooled"] = composed
.pooled
.iter()
.map(|p| json!({ "attributeId": p.attribute_id, "created": p.created }))
.collect();
}
if let Some(b) = &composed.binding {
body["binding"] = json!({
"personaDid": b.persona_did,
"version": b.version,
"alsoBoundPersonaCount": b.also_bound_persona_count,
});
}
success_response(&doc, body)
}
pub(super) async fn handle_attribute_promote(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::attribute::promote::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_ATTRIBUTE_PROMOTE_1_0, None).await {
return reject(&doc, e);
}
let context_id = req.context_id.to_string();
let profile_id = req.profile_id.to_string();
let expected = u64::from(req.expected_version.0);
let positions: Vec<usize> = req
.entries
.iter()
.map(|p| usize::try_from(*p).unwrap_or(usize::MAX))
.collect();
let s = store(state);
match s.get_local_profile(&context_id, &profile_id).await {
Ok(Some(face)) => {
let slug = slug_from_doc(&doc);
if face.version != expected {
return reject_with_code(
&doc,
ext(&slug, "versionConflict"),
format!(
"expectedVersion {expected} does not match current version {}",
face.version
),
Some(json!({ "currentVersion": face.version })),
);
}
if positions.iter().any(|&p| p >= face.entries.len()) {
return reject_with_code(
&doc,
ext(&slug, "entryOutOfRange"),
format!("the face has {} entries", face.entries.len()),
Some(json!({ "entryCount": face.entries.len() })),
);
}
}
Ok(None) => {
return reject(
&doc,
AppError::NotFound(format!(
"{profile_id} is not a context-local face in {context_id}"
)),
);
}
Err(e) => return reject(&doc, e),
}
let promoted = match s
.promote(&context_id, &profile_id, &positions, expected)
.await
{
Ok(p) => p,
Err(e) => return reject(&doc, e),
};
let detail = format!(
"promoted {} entr{} of face {profile_id} from context {context_id} into the pool ({} \
attribute(s) created); {} persona(s) rebound, now at version {}",
promoted.promoted.len(),
if promoted.promoted.len() == 1 {
"y"
} else {
"ies"
},
promoted.promoted.iter().filter(|p| p.created).count(),
promoted.rebound_persona_dids.len(),
promoted.version,
);
audit_persona(
state,
"persona.attribute.promote",
auth,
Some(&profile_id),
Some(&context_id),
Some(&detail),
)
.await;
let mut body = json!({
"profileId": promoted.profile_id,
"version": promoted.version,
"promoted": promoted
.promoted
.iter()
.map(|p| json!({ "entry": p.entry, "attributeId": p.attribute_id, "created": p.created }))
.collect::<Vec<_>>(),
});
if !promoted.rebound_persona_dids.is_empty() {
body["reboundPersonaDids"] = json!(promoted.rebound_persona_dids);
}
success_response(&doc, body)
}
pub(super) async fn handle_profile_retire(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::profile::retire::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_PROFILE_RETIRE_1_0, None).await {
return reject(&doc, e);
}
let id = req.profile_id.to_string();
let ctx = req.context_id.as_ref().map(|c| c.to_string());
let retired = match store(state)
.retire_profile(&id, ctx.as_deref(), req.expected_version.map(|v| *v))
.await
{
Ok(r) => r,
Err(e) => return reject(&doc, e),
};
let detail = format!(
"retired {}face {id}; {} binding(s) cleared, now at version {}",
if ctx.is_some() { "context-local " } else { "" },
retired.unbound.len(),
retired.version,
);
audit_persona(
state,
"persona.profile.retire",
auth,
Some(&id),
ctx.as_deref(),
Some(&detail),
)
.await;
let mut body = json!({
"profileId": id,
"version": retired.version,
"retiredAt": retired.retired_at,
});
if !retired.unbound.is_empty() {
body["unbound"] = retired
.unbound
.iter()
.take(256)
.map(|(c, p)| json!({ "contextId": c, "personaDid": p }))
.collect();
}
success_response(&doc, body)
}
pub(super) async fn handle_profile_reinstate(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::profile::reinstate::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_PROFILE_REINSTATE_1_0, None).await {
return reject(&doc, e);
}
let id = req.profile_id.to_string();
let ctx = req.context_id.as_ref().map(|c| c.to_string());
let version = match store(state)
.reinstate_profile(&id, ctx.as_deref(), req.expected_version.map(|v| *v))
.await
{
Ok(v) => v,
Err(e) => return reject(&doc, e),
};
let detail = format!("reinstated face {id}, worn nowhere, now at version {version}");
audit_persona(
state,
"persona.profile.reinstate",
auth,
Some(&id),
ctx.as_deref(),
Some(&detail),
)
.await;
success_response(&doc, json!({ "profileId": id, "version": version }))
}
pub(super) async fn handle_profile_usage(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::profile::usage::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_PROFILE_USAGE_1_0, None).await {
return reject(&doc, e);
}
let id = req.profile_id.to_string();
let ctx = req.context_id.as_ref().map(|c| c.to_string());
let (reach, usage) = match store(state).face_usage(&id, ctx.as_deref()).await {
Ok(u) => u,
Err(e) => return reject(&doc, e),
};
audit_persona(state, "persona.profile.usage", auth, Some(&id), None, None).await;
let mut body = json!({
"profileId": id,
"usage": usage
.iter()
.take(1024)
.map(|u| {
let mut row = json!({
"contextId": u.context_id,
"personaDid": u.persona_did,
"boundAt": u.bound_at,
});
put_opt(&mut row, "until", u.until.clone());
row
})
.collect::<Vec<_>>(),
});
put_opt(&mut body, "reach", reach);
success_response(&doc, body)
}
pub(super) async fn handle_profile_timeline(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::profile::timeline::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_PROFILE_TIMELINE_1_0, None).await {
return reject(&doc, e);
}
let id = req.profile_id.to_string();
let ctx = req.context_id.as_ref().map(|c| c.to_string());
let since = req.since.map(|t| t.to_rfc3339());
let limit = usize::try_from(req.limit.get()).unwrap_or(100).min(500);
let page = match store(state)
.face_timeline(
&id,
ctx.as_deref(),
since.as_deref(),
req.cursor.as_ref().map(|c| c.as_str()),
limit,
)
.await
{
Ok(p) => p,
Err(e) => return reject(&doc, e),
};
audit_persona(
state,
"persona.profile.timeline",
auth,
Some(&id),
None,
None,
)
.await;
let mut body = json!({ "profileId": id, "events": page.events });
put_opt(&mut body, "nextCursor", page.next_cursor);
success_response(&doc, body)
}
pub(super) async fn handle_profile_get(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::profile::get::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_PROFILE_GET_1_0, None).await {
return reject(&doc, e);
}
let id = req.profile_id.to_string();
let s = store(state);
let Some(profile) = (match s.get_profile(&id).await {
Ok(p) => p,
Err(e) => return reject(&doc, e),
}) else {
return reject(&doc, AppError::NotFound(format!("profile {id}")));
};
let resolved = if req.resolve {
match s.resolve_profile(&id).await {
Ok(r) => Some(r),
Err(e) => return reject(&doc, e),
}
} else {
None
};
let disclosed = match s.disclosed_to(&id).await {
Ok(d) => d,
Err(e) => return reject(&doc, e),
};
audit_persona(state, "persona.profile.get", auth, Some(&id), None, None).await;
let mut body = json!({
"profile": profile,
"disclosedTo": {
"partyCount": disclosed.party_count,
"contextCount": disclosed.context_count,
},
});
if let Some(r) = resolved {
body["resolved"] = json!(
r.iter()
.map(|c| {
let mut row = json!({
"type": c.r#type,
"value": c.value,
"valueType": c.value_type,
"provenance": c.provenance,
"stale": c.stale,
});
put_opt(&mut row, "attributeId", c.attribute_id.clone());
put_opt(&mut row, "label", c.label.clone());
put_opt(&mut row, "slot", c.slot.clone());
put_opt(&mut row, "version", c.version);
put_opt(&mut row, "updatedAt", c.updated_at.clone());
row
})
.collect::<Vec<_>>()
);
}
success_response(&doc, body)
}
pub(super) async fn handle_profile_list(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::profile::list::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_PROFILE_LIST_1_0, None).await {
return reject(&doc, e);
}
let profiles: Vec<_> = match store(state).list_profiles().await {
Ok(p) => p
.into_iter()
.filter(|f| req.include_retired || f.status.is_active())
.collect(),
Err(e) => return reject(&doc, e),
};
audit_persona(state, "persona.profile.list", auth, None, None, None).await;
success_response(&doc, json!({ "profiles": profiles }))
}
pub(super) async fn handle_profile_delete(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::profile::delete::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_PROFILE_DELETE_1_0, None).await {
return reject(&doc, e);
}
let id = req.profile_id.to_string();
let s = store(state);
let bound = match s.personas_bound_to_anywhere(&id).await {
Ok(b) => b,
Err(e) => return reject(&doc, e),
};
if !bound.is_empty() && !req.unbind {
let mut payload = ErrorPayload::new(ext(&slug_from_doc(&doc), "bound")).with_message(
format!("{} persona(s) are bound to this profile", bound.len()),
);
payload = payload.with_details(json!({ "personaDids": bound }));
return error_response(
doc.reject_with(format!("urn:uuid:{}", uuid::Uuid::new_v4()), payload),
);
}
let disclosed = match s.disclosed_to(&id).await {
Ok(d) => d,
Err(e) => return reject(&doc, e),
};
if req.unbind
&& let Err(e) = s.unbind_everywhere(&id).await
{
return reject(&doc, e);
}
let existed = match s.delete_profile(&id).await {
Ok(e) => e,
Err(e) => return reject(&doc, e),
};
let detail = format!(
"profile {id} {}; {}; {} persona(s) unbound",
if existed { "deleted" } else { "did not exist" },
if req.unbind {
"unbind requested"
} else {
"no unbind requested"
},
bound.len(),
);
audit_persona(
state,
"persona.profile.delete",
auth,
Some(&id),
None,
Some(&detail),
)
.await;
let mut body = json!({ "profileId": id, "existed": existed, "unboundPersonas": bound });
if existed {
body["disclosedTo"] = json!({
"partyCount": disclosed.party_count,
"contextCount": disclosed.context_count,
});
}
success_response(&doc, body)
}
pub(super) async fn handle_binding_set(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::binding::set::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_BINDING_SET_1_0, None).await {
return reject(&doc, e);
}
let ctx = req.context_id.to_string();
let persona = req.persona_did.to_string();
let profile_id = req.profile_id.as_ref().map(|p| p.to_string());
let public = req.public_entries.iter().map(|e| e.to_string()).collect();
let until = req.until.map(|u| u.to_rfc3339());
let s = store(state);
if let Some(refused) = refuse_binding(
&doc,
&s,
&ctx,
None,
profile_id.as_deref(),
until.as_deref(),
)
.await
{
return refused;
}
let bound = match s
.set_binding(
&ctx,
&persona,
profile_id.as_deref(),
public,
req.label.as_ref().map(|l| l.to_string()),
until.clone(),
req.expected_version.map(|v| *v),
)
.await
{
Ok(b) => b,
Err(e) => return reject(&doc, e),
};
let detail = format!(
"persona {persona} in context {ctx} bound to {}; {} claim(s) materialised, now at \
version {}",
profile_id
.as_deref()
.map_or_else(|| "unbound".to_string(), |p| format!("profile {p}")),
bound.materialised_claim_count,
bound.version,
);
audit_persona(
state,
"persona.binding.set",
auth,
Some(&persona),
Some(&ctx),
Some(&detail),
)
.await;
let mut body = json!({
"contextId": ctx,
"personaDid": persona,
"profileId": profile_id,
"version": bound.version,
"materialisedClaimCount": bound.materialised_claim_count,
"correlation": {
"severity": if bound.also_bound_persona_count > 0 { "high" } else { "none" },
"alsoBoundPersonaCount": bound.also_bound_persona_count,
},
"boundAt": chrono::Utc::now().to_rfc3339(),
});
put_opt(&mut body, "until", until);
success_response(&doc, body)
}
async fn refuse_binding(
doc: &TrustTask<Value>,
s: &PersonaStore,
bind_context: &str,
context_id: Option<&str>,
profile_id: Option<&str>,
until: Option<&str>,
) -> Option<TrustTaskOutcome> {
let slug = slug_from_doc(doc);
if let Some(u) = until {
let future = chrono::DateTime::parse_from_rfc3339(u).is_ok_and(|t| t > chrono::Utc::now());
if profile_id.is_none() || !future {
return Some(reject_with_code(
doc,
ext(&slug, "untilNotFuture"),
"`until` must be in the future, and ends a face being worn — a cleared binding \
has none",
None,
));
}
}
let id = profile_id?;
let face = match context_id {
None => s.get_profile(id).await,
Some(ctx) => s.get_local_profile(ctx, id).await,
};
match face {
Ok(Some(f)) if !f.status.is_active() => Some(reject_with_code(
doc,
ext(&slug, "profileRetired"),
format!("profile {id} is retired; reinstate it before wearing it"),
None,
)),
Ok(Some(f)) if context_id.is_none() && !f.reach.admits(bind_context) => {
Some(reject_with_code(
doc,
ext(&slug, "outsideReach"),
format!("profile {id} may not be worn in {bind_context}"),
None,
))
}
_ => None,
}
}
pub(super) async fn handle_binding_get(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::binding::get::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let ctx = req.context_id.to_string();
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_BINDING_GET_1_0, Some(&ctx)).await {
return reject(&doc, e);
}
let persona = req.persona_did.to_string();
let sum = match store(state).binding_summary(&ctx, &persona).await {
Ok(s) => s,
Err(e) => return reject(&doc, e),
};
audit_persona(
state,
"persona.binding.get",
auth,
Some(&persona),
Some(&ctx),
None,
)
.await;
let mut body = json!({
"contextId": ctx,
"personaDid": sum.persona_did,
"bound": sum.bound,
"claimCount": sum.claim_count,
});
put_opt(&mut body, "profileId", sum.profile_id);
put_opt(&mut body, "label", sum.label);
if is_holder(state, auth).await {
put_opt(&mut body, "profileName", sum.profile_name);
}
put_opt(&mut body, "boundAt", sum.bound_at);
put_opt(&mut body, "until", sum.until);
success_response(&doc, body)
}
pub(super) async fn handle_binding_list(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::binding::list::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let ctx = req.context_id.to_string();
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_BINDING_LIST_1_0, Some(&ctx)).await {
return reject(&doc, e);
}
let sums = match store(state).list_binding_summaries(&ctx).await {
Ok(s) => s,
Err(e) => return reject(&doc, e),
};
audit_persona(state, "persona.binding.list", auth, None, Some(&ctx), None).await;
let holder = is_holder(state, auth).await;
let personas: Vec<Value> = sums
.iter()
.map(|s| {
let mut row = json!({
"personaDid": s.persona_did,
"bound": s.bound,
"claimCount": s.claim_count,
});
put_opt(&mut row, "label", s.label.clone());
put_opt(&mut row, "until", s.until.clone());
if holder {
put_opt(&mut row, "profileName", s.profile_name.clone());
}
row
})
.collect();
success_response(&doc, json!({ "personas": personas }))
}
pub(super) async fn handle_contact_put(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::contact::put::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let ctx = req.context_id.to_string();
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_CONTACT_PUT_1_0, Some(&ctx)).await {
return reject(&doc, e);
}
let document = match serde_json::to_value(&req.document)
.ok()
.and_then(|v| serde_json::from_value(v).ok())
{
Some(d) => d,
None => {
return reject(
&doc,
AppError::Validation("unrecognised contact document".into()),
);
}
};
let filed = match store(state)
.file_contact(
&ctx,
&req.subject_did.to_string(),
&req.known_by_persona.to_string(),
document,
req.credential_refs.iter().map(|c| c.to_string()).collect(),
req.notes.as_ref().map(|n| n.to_string()),
)
.await
{
Ok(f) => f,
Err(e) => return reject(&doc, e),
};
audit_persona(
state,
"persona.contact.put",
auth,
Some(&filed.contact_id),
Some(&ctx),
None,
)
.await;
success_response(
&doc,
json!({
"contactId": filed.contact_id,
"rev": filed.rev,
"created": filed.created,
"changedClaims": filed.changed_claims,
}),
)
}
pub(super) async fn handle_contact_get(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::contact::get::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let ctx = req.context_id.to_string();
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_CONTACT_GET_1_0, Some(&ctx)).await {
return reject(&doc, e);
}
let id = req.contact_id.to_string();
let s = store(state);
let Some(contact) = (match s.get_contact(&ctx, &id).await {
Ok(c) => c,
Err(e) => return reject(&doc, e),
}) else {
return reject(&doc, AppError::NotFound(format!("contact {id}")));
};
let document = match req.rev {
None => serde_json::to_value(&contact.document).unwrap_or(Value::Null),
Some(rev) => match s.get_contact_revision(&ctx, &id, rev.get()).await {
Ok(r) => serde_json::to_value(&r.document).unwrap_or(Value::Null),
Err(e) => return reject(&doc, e),
},
};
let history = if req.include_history {
match s.contact_history(&ctx, &id).await {
Ok(h) => Some(
h.iter()
.map(|(rev, at, cited)| json!({ "rev": rev, "receivedAt": at, "cited": cited }))
.collect::<Vec<_>>(),
),
Err(e) => return reject(&doc, e),
}
} else {
None
};
audit_persona(
state,
"persona.contact.get",
auth,
Some(&id),
Some(&ctx),
None,
)
.await;
let mut body = json!({
"contactId": contact.contact_id,
"subjectDid": contact.subject_did,
"knownByPersona": contact.known_by_persona,
"rev": req.rev.map_or(contact.rev, std::num::NonZeroU64::get),
"document": document,
"credentialRefs": contact.credential_refs,
});
put_opt(&mut body, "notes", contact.notes.clone());
if let Some(h) = history {
body["history"] = json!(h);
}
success_response(&doc, body)
}
pub(super) async fn handle_contact_list(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::contact::list::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let ctx = req.context_id.to_string();
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_CONTACT_LIST_1_0, Some(&ctx)).await {
return reject(&doc, e);
}
let persona = req.known_by_persona.as_ref().map(|p| p.to_string());
let sums = match store(state)
.list_contact_summaries(&ctx, persona.as_deref())
.await
{
Ok(s) => s,
Err(e) => return reject(&doc, e),
};
audit_persona(state, "persona.contact.list", auth, None, Some(&ctx), None).await;
success_response(
&doc,
json!({
"contacts": sums.iter().map(|s| json!({
"contactId": s.contact_id,
"subjectDid": s.subject_did,
"knownByPersona": s.known_by_persona,
"rev": s.rev,
"claimCount": s.claim_count,
"receivedAt": s.received_at,
"hasUnreviewedChange": s.has_unreviewed_change,
})).collect::<Vec<_>>()
}),
)
}
pub(super) async fn handle_contact_delete(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::contact::delete::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let ctx = req.context_id.to_string();
if let Err(e) = authorize(
state,
auth,
uris::TASK_PERSONA_CONTACT_DELETE_1_0,
Some(&ctx),
)
.await
{
return reject(&doc, e);
}
let id = req.contact_id.to_string();
let (existed, removed, retained) = match store(state).delete_contact(&ctx, &id).await {
Ok(o) => o,
Err(e) => return reject(&doc, e),
};
audit_persona(
state,
"persona.contact.delete",
auth,
Some(&id),
Some(&ctx),
None,
)
.await;
success_response(
&doc,
json!({
"contactId": id,
"existed": existed,
"revisionsRemoved": removed,
"retainedForDisclosure": retained,
}),
)
}
pub(super) async fn handle_disclosure_history(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::disclosure::history::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_DISCLOSURE_HISTORY_1_0, None).await {
return reject(&doc, e);
}
let ctx = req.context_id.as_ref().map(|c| c.to_string());
let verifier = req.verifier_did.as_ref().map(|v| v.to_string());
let claim = req.attribute_type.as_ref().map(|t| t.to_string());
let since = req.since.map(|s| s.to_rfc3339());
let records = match store(state)
.disclosure_history(&vta_persona::HistoryQuery {
context_id: ctx.as_deref(),
verifier_did: verifier.as_deref(),
claim_type: claim.as_deref(),
since: since.as_deref(),
})
.await
{
Ok(r) => r,
Err(e) => return reject(&doc, e),
};
let s = store(state);
let mut rows = Vec::with_capacity(records.len());
for r in &records {
let currency = match s.claim_currency(r).await {
Ok(c) => c,
Err(e) => return reject(&doc, e),
};
let mut row = json!({
"disclosureId": r.disclosure_id,
"contextId": r.context_id,
"verifierDid": r.verifier_did,
"personaDid": r.persona_did,
"claimTypes": r.claims.iter().map(|c| c.r#type.clone()).collect::<Vec<_>>(),
"rungs": r.claims.iter().map(|c| c.rung).collect::<Vec<_>>(),
"claimCurrency": currency,
"disclosedAt": r.disclosed_at,
});
put_opt(&mut row, "subject", r.subject.clone());
put_opt(&mut row, "purpose", r.purpose.clone());
put_opt(&mut row, "renderer", r.renderer.clone());
put_opt(
&mut row,
"durableCredentialId",
r.durable_credential_id.clone(),
);
rows.push(row);
}
audit_persona(
state,
"persona.disclosure.history",
auth,
None,
ctx.as_deref(),
None,
)
.await;
success_response(&doc, json!({ "disclosures": rows }))
}
pub(super) async fn handle_correlation_analyze(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::correlation::analyze::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(
state,
auth,
uris::TASK_PERSONA_CORRELATION_ANALYZE_1_0,
None,
)
.await
{
return reject(&doc, e);
}
let s = store(state);
let attribute_id = req.attribute_id.as_ref().map(|a| a.to_string());
let candidate = req
.candidate
.as_ref()
.and_then(|c| serde_json::to_value(&c.value).ok());
let mut findings = Vec::new();
if let Some(profile_id) = &req.profile_id {
match s.analyze_face_correlation(&profile_id.to_string()).await {
Ok(f) => findings.extend(f),
Err(e) => return reject(&doc, e),
}
}
if req.profile_id.is_none() || attribute_id.is_some() || candidate.is_some() {
match s
.analyze_correlation(attribute_id.as_deref(), candidate.as_ref())
.await
{
Ok(f) => findings.extend(f),
Err(e) => return reject(&doc, e),
}
}
findings.truncate(256);
audit_persona(state, "persona.correlation.analyze", auth, None, None, None).await;
success_response(&doc, json!({ "findings": findings }))
}
pub(super) async fn handle_renderers_list(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let _req: spec::renderers::list::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_RENDERERS_LIST_1_0, None).await {
return reject(&doc, e);
}
success_response(
&doc,
json!({
"renderers": vta_persona::present::RENDERERS.iter().map(|r| json!({
"id": r.id,
"canonical": r.canonical,
"drops": if r.carries_provenance { vec![] } else { vec!["provenance"] },
"canCarryPredicates": r.carries_predicates,
})).collect::<Vec<_>>()
}),
)
}
pub(super) async fn handle_claim_types_list(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let _req: spec::claim_types::list::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_CLAIM_TYPES_LIST_1_0, None).await {
return reject(&doc, e);
}
let l = vta_persona::claim_types::registry_listing();
let axes = |a: &vta_persona::Axes| {
json!({
"sensitivity": wire_name(a.sensitivity),
"release": wire_name(a.release),
"mask": wire_name(a.mask),
})
};
let rejected = vta_persona::claim_types::rejected_extensions();
let file_error = vta_persona::claim_types::extension_file_error();
let mut body = json!({
"registryVersion": l.registry_version,
"entries": l.entries.iter().map(|r| {
let mut e = axes(&r.axes);
e["type"] = json!(r.claim_type);
e
}).collect::<Vec<_>>(),
"unregistered": axes(&l.unregistered),
"strictness": {
"sensitivity": l.strictness.sensitivity.iter().map(|v| wire_name(*v)).collect::<Vec<_>>(),
"release": l.strictness.release.iter().map(|v| wire_name(*v)).collect::<Vec<_>>(),
"mask": l.strictness.mask.iter().map(|v| wire_name(*v)).collect::<Vec<_>>(),
},
});
if !rejected.is_empty() || file_error.is_some() {
let mut report = serde_json::Map::new();
if !rejected.is_empty() {
report.insert(
"rejected".into(),
json!(
rejected
.iter()
.map(|r| json!({ "type": r.token, "reason": r.why }))
.collect::<Vec<_>>()
),
);
}
if let Some(e) = file_error {
report.insert("fileError".into(), json!(e));
}
body["ext"] = json!({ "org.openvtc.claim-types": report });
}
success_response(&doc, body)
}
pub(super) async fn handle_local_profile_put(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::local::profile::put::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let ctx = req.context_id.to_string();
if let Err(e) = authorize(
state,
auth,
uris::TASK_PERSONA_LOCAL_PROFILE_PUT_1_0,
Some(&ctx),
)
.await
{
return reject(&doc, e);
}
let entries: Option<Vec<vta_persona::ProfileEntry>> = req
.entries
.iter()
.map(|e| {
let value_type = serde_json::to_string(&e.inline.value_type)
.ok()
.and_then(|s| serde_json::from_str::<ValueType>(&s).ok())?;
Some(vta_persona::ProfileEntry::Inline {
inline: vta_persona::InlineValue {
r#type: e.inline.type_.to_string(),
value_type,
value: e.inline.value.clone(),
label: e.inline.label.as_ref().map(|l| l.to_string()),
provenance: vta_persona::Provenance::SelfAsserted,
},
slot: e.slot.as_ref().map(|s| s.to_string()),
})
})
.collect();
let Some(entries) = entries else {
return reject(&doc, AppError::Validation("unrecognised valueType".into()));
};
if let Some(slot) = vta_persona::model::duplicate_slot(&entries) {
return reject_with_code(
&doc,
ext(&slug_from_doc(&doc), "duplicateSlot"),
format!("two entries of this face both claim the slot {slot}"),
Some(json!({ "slot": slot })),
);
}
let mut profile = vta_persona::new_profile(req.name.to_string(), entries);
if let Some(id) = &req.profile_id {
profile.profile_id = id.to_string();
}
let profile_id = profile.profile_id.clone();
let entry_count = profile.entries.len();
let s = store(state);
let written = match s
.put_local_profile(&ctx, profile, req.expected_version.map(|v| *v))
.await
{
Ok(w) => w,
Err(e) => return reject(&doc, e),
};
let matches_pool = match s.get_local_profile(&ctx, &profile_id).await {
Ok(Some(p)) => {
let mut found = false;
for entry in &p.entries {
if let vta_persona::ProfileEntry::Inline { inline, .. } = entry
&& s.correlation_count(&inline.value, "").await.unwrap_or(0) > 0
{
found = true;
break;
}
}
found
}
_ => false,
};
let detail = format!(
"{} context-local profile {profile_id} in context {ctx} with {} entr{}, now at version \
{}; matches a pool value: {matches_pool}",
if written.created {
"created"
} else {
"updated"
},
entry_count,
if entry_count == 1 { "y" } else { "ies" },
written.version,
);
audit_persona(
state,
"persona.local.profile.put",
auth,
Some(&profile_id),
Some(&ctx),
Some(&detail),
)
.await;
success_response(
&doc,
json!({
"profileId": profile_id,
"version": written.version,
"created": written.created,
"correlation": {
"severity": if matches_pool { "high" } else { "none" },
"matchesPoolValue": matches_pool,
}
}),
)
}
pub(super) async fn handle_local_profile_get(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::local::profile::get::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let ctx = req.context_id.to_string();
if let Err(e) = authorize(
state,
auth,
uris::TASK_PERSONA_LOCAL_PROFILE_GET_1_0,
Some(&ctx),
)
.await
{
return reject(&doc, e);
}
let id = req.profile_id.to_string();
match store(state).get_local_profile(&ctx, &id).await {
Ok(Some(p)) => {
audit_persona(
state,
"persona.local.profile.get",
auth,
Some(&id),
Some(&ctx),
None,
)
.await;
success_response(
&doc,
json!({
"profile": {
"profileId": p.profile_id,
"name": p.name,
"entries": p.entries,
"version": p.version,
}
}),
)
}
Ok(None) => reject(&doc, AppError::NotFound(format!("local profile {id}"))),
Err(e) => reject(&doc, e),
}
}
pub(super) async fn handle_local_profile_list(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::local::profile::list::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let ctx = req.context_id.to_string();
if let Err(e) = authorize(
state,
auth,
uris::TASK_PERSONA_LOCAL_PROFILE_LIST_1_0,
Some(&ctx),
)
.await
{
return reject(&doc, e);
}
let profiles = match store(state).list_local_profiles(&ctx).await {
Ok(p) => p,
Err(e) => return reject(&doc, e),
};
audit_persona(
state,
"persona.local.profile.list",
auth,
None,
Some(&ctx),
None,
)
.await;
success_response(
&doc,
json!({
"profiles": profiles.iter().map(|p| json!({
"profileId": p.profile_id,
"name": p.name,
"entryCount": p.entries.len(),
})).collect::<Vec<_>>()
}),
)
}
pub(super) async fn handle_local_profile_delete(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::local::profile::delete::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let ctx = req.context_id.to_string();
if let Err(e) = authorize(
state,
auth,
uris::TASK_PERSONA_LOCAL_PROFILE_DELETE_1_0,
Some(&ctx),
)
.await
{
return reject(&doc, e);
}
let id = req.profile_id.to_string();
let s = store(state);
let mut unbound = 0usize;
if req.unbind {
let bound = match s.personas_bound_to(&ctx, &id).await {
Ok(p) => p,
Err(e) => return reject(&doc, e),
};
unbound = bound.len();
for persona_did in bound {
if let Err(e) = s
.set_local_binding(&ctx, &persona_did, None, None, None)
.await
{
return reject(&doc, e);
}
}
}
let existed = match s.delete_local_profile(&ctx, &id).await {
Ok(e) => e,
Err(e) => return reject(&doc, e),
};
let detail = format!(
"context-local profile {id} in context {ctx} {}; {unbound} persona(s) unbound",
if existed { "deleted" } else { "did not exist" },
);
audit_persona(
state,
"persona.local.profile.delete",
auth,
Some(&id),
Some(&ctx),
Some(&detail),
)
.await;
success_response(&doc, json!({ "profileId": id, "existed": existed }))
}
pub(super) async fn handle_local_binding_set(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::local::binding::set::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let ctx = req.context_id.to_string();
if let Err(e) = authorize(
state,
auth,
uris::TASK_PERSONA_LOCAL_BINDING_SET_1_0,
Some(&ctx),
)
.await
{
return reject(&doc, e);
}
let persona = req.persona_did.to_string();
let profile_id = req.profile_id.as_ref().map(|p| p.to_string());
let until = req.until.map(|u| u.to_rfc3339());
let s = store(state);
if let Some(refused) = refuse_binding(
&doc,
&s,
&ctx,
Some(&ctx),
profile_id.as_deref(),
until.as_deref(),
)
.await
{
return refused;
}
let version = match s
.set_local_binding(
&ctx,
&persona,
profile_id.as_deref(),
req.label.as_ref().map(|l| l.to_string()),
until,
)
.await
{
Ok(v) => v,
Err(e) => return reject(&doc, e),
};
let detail = format!(
"persona {persona} in context {ctx} bound to {}, now at version {version}",
profile_id.as_deref().map_or_else(
|| "unbound".to_string(),
|p| format!("context-local profile {p}")
),
);
audit_persona(
state,
"persona.local.binding.set",
auth,
Some(&persona),
Some(&ctx),
Some(&detail),
)
.await;
success_response(
&doc,
json!({
"contextId": ctx,
"personaDid": persona,
"profileId": profile_id,
"version": version,
}),
)
}
pub(super) async fn handle_disclosure_preview(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::disclosure::preview::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let ctx = req.context_id.to_string();
if let Err(e) = authorize(
state,
auth,
uris::TASK_PERSONA_DISCLOSURE_PREVIEW_1_0,
Some(&ctx),
)
.await
{
return reject(&doc, e);
}
let requested: Option<Vec<String>> = if req.requested_claims.is_empty() {
None
} else {
Some(req.requested_claims.iter().map(|c| c.to_string()).collect())
};
let preview = match store(state)
.create_preview(
&ctx,
&req.persona_did.to_string(),
&req.verifier_did.to_string(),
req.purpose.as_ref().map(|p| p.to_string()).as_deref(),
requested.as_deref(),
req.renderer.as_ref().map(|r| r.to_string()).as_deref(),
)
.await
{
Ok(p) => p,
Err(e) => return reject(&doc, e),
};
audit_persona(
state,
"persona.disclosure.preview",
auth,
Some(&preview.preview_id),
Some(&ctx),
None,
)
.await;
success_response(
&doc,
json!({
"previewId": preview.preview_id,
"subject": preview.subject,
"claims": preview.claims,
"renderer": { "id": preview.renderer_id, "drops": preview.renderer_drops },
"expiresAt": preview.expires_at,
}),
)
}
pub(super) async fn handle_disclosure_present(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::disclosure::present::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let ctx = req.context_id.to_string();
if let Err(e) = authorize(
state,
auth,
uris::TASK_PERSONA_DISCLOSURE_PRESENT_1_0,
Some(&ctx),
)
.await
{
return reject(&doc, e);
}
let durable = req.mint.as_ref().is_some_and(|m| m.durable);
let preview_id = req.preview_id.to_string();
match store(state).peek_preview(&preview_id).await {
Ok(Some(preview)) => {
if PersonaStore::requires_step_up(&preview) && preview.approved_at.is_none() {
let claim_types: Vec<String> =
preview.claims.iter().map(|c| c.r#type.clone()).collect();
return match super::step_up::initiate_disclosure_step_up(
state,
auth,
&preview_id,
&preview.verifier_did,
preview.purpose.as_deref(),
&claim_types,
)
.await
{
Ok(details) => step_up_required(&doc, details),
Err(reason) => super::helpers::reject_with(&doc, reason),
};
}
}
Ok(None) => {}
Err(e) => return reject(&doc, e),
}
let (artifact, record) = match store(state)
.present(
&preview_id,
req.challenge.as_ref().map(|c| c.to_string()).as_deref(),
durable,
)
.await
{
Ok(o) => o,
Err(e) => return reject(&doc, e),
};
audit_persona(
state,
"persona.disclosure.present",
auth,
Some(&record.disclosure_id),
Some(&ctx),
None,
)
.await;
let mut body = json!({
"disclosureId": record.disclosure_id,
"artifact": artifact,
"subject": record.subject,
"disclosedAt": record.disclosed_at,
});
put_opt(
&mut body,
"credentialId",
record.durable_credential_id.clone(),
);
success_response(&doc, body)
}
pub(super) async fn handle_facet_put(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::facet::put::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_FACET_PUT_1_0, None).await {
return reject(&doc, e);
}
let s = store(state);
let face_ids: Vec<String> = req.face_ids.iter().map(|u| u.to_string()).collect();
let attribute_ids: Vec<String> = req.attribute_ids.iter().map(|u| u.to_string()).collect();
let Some(colour) = colour_of(&req.colour) else {
return reject_with_code(
&doc,
ext(&slug_from_doc(&doc), "unsupportedColour"),
"this agent does not know that colour",
Some(json!({ "colour": req.colour.to_string() })),
);
};
let mut facet = vta_persona::new_facet(
req.name.to_string(),
colour,
req.icon.as_ref().map(|i| i.to_string()),
face_ids,
attribute_ids,
);
if let Some(id) = req.facet_id.as_ref() {
facet.facet_id = id.to_string();
}
let facet_id = facet.facet_id.clone();
let clash = match s
.placement_conflicts(&facet.face_ids, Some(&facet_id))
.await
{
Ok(c) => c,
Err(e) => return reject(&doc, e),
};
if !clash.placed.is_empty() {
return reject_with_code(
&doc,
ext(&slug_from_doc(&doc), "faceAlreadyPlaced"),
"one or more faces already belong to another facet",
Some(json!({ "placed": clash.placed })),
);
}
let written = match s
.put_facet(facet, req.expected_version.map(u64::from))
.await
{
Ok(w) => w,
Err(e) => return reject(&doc, e),
};
audit_persona(state, "persona.facet.put", auth, None, None, None).await;
success_response(
&doc,
json!({
"facetId": facet_id,
"version": written.version,
"created": written.created,
"updatedAt": chrono::Utc::now().to_rfc3339(),
}),
)
}
pub(super) async fn handle_facet_list(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let _req: spec::facet::list::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_FACET_LIST_1_0, None).await {
return reject(&doc, e);
}
let facets = match store(state).list_facets().await {
Ok(f) => f,
Err(e) => return reject(&doc, e),
};
audit_persona(state, "persona.facet.list", auth, None, None, None).await;
success_response(&doc, json!({ "facets": facets }))
}
pub(super) async fn handle_facet_delete(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: spec::facet::delete::v1_0::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) = authorize(state, auth, uris::TASK_PERSONA_FACET_DELETE_1_0, None).await {
return reject(&doc, e);
}
let (existed, released) = match store(state)
.delete_facet(
&req.facet_id.to_string(),
req.expected_version.map(u64::from),
)
.await
{
Ok(r) => r,
Err(e) => return reject(&doc, e),
};
audit_persona(state, "persona.facet.delete", auth, None, None, None).await;
success_response(
&doc,
json!({ "existed": existed, "releasedFaces": released }),
)
}
fn colour_of(c: &spec::facet::put::v1_0::FacetColour) -> Option<vta_persona::FacetColour> {
use spec::facet::put::v1_0::FacetColour as W;
use vta_persona::FacetColour as S;
Some(match c {
W::Slate => S::Slate,
W::Indigo => S::Indigo,
W::Teal => S::Teal,
W::Moss => S::Moss,
W::Sand => S::Sand,
W::Clay => S::Clay,
W::Rose => S::Rose,
W::Plum => S::Plum,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use vti_common::acl::Role;
fn claims(role: Role, contexts: &[&str]) -> AuthClaims {
AuthClaims {
role,
allowed_contexts: contexts.iter().map(|s| (*s).to_string()).collect(),
..Default::default()
}
}
#[test]
fn every_persona_task_declares_a_reach() {
let classified: std::collections::HashSet<&str> = REACH.iter().map(|(u, _)| *u).collect();
let missing: Vec<&&str> = uris::ALL_URIS
.iter()
.filter(|u| u.starts_with("https://trusttasks.org/spec/persona/"))
.filter(|u| !classified.contains(*u))
.collect();
assert!(
missing.is_empty(),
"these persona tasks declare no reach — add them to REACH. When unsure, \
`Holder` is the conservative answer: it refuses too much rather than \
disclosing the pool to a context. {missing:#?}"
);
}
#[test]
fn no_reach_without_a_task() {
let catalog: std::collections::HashSet<&str> = uris::ALL_URIS.iter().copied().collect();
let orphans: Vec<&&str> = REACH
.iter()
.map(|(u, _)| u)
.filter(|u| !catalog.contains(*u))
.collect();
assert!(
orphans.is_empty(),
"reach entries for tasks that do not exist: {orphans:#?}"
);
}
#[test]
fn a_context_scoped_admin_is_refused_every_holder_task() {
let scoped_admin = claims(Role::Admin, &["ctx-work"]);
for (uri, reach) in REACH {
if *reach != Reach::Holder {
continue;
}
let err = decide(&scoped_admin, uri, Some("ctx-work"), false).unwrap_err();
assert!(
matches!(err, AppError::Forbidden(_)),
"{uri} admitted an admin scoped to one context — an admin in ctx-work must be \
as powerless over the pool as an application in ctx-work"
);
}
}
#[test]
fn an_unscoped_holder_reaches_the_pool() {
let holder = claims(Role::Admin, &[]);
for (uri, reach) in REACH {
if *reach == Reach::Holder {
decide(&holder, uri, None, false).unwrap_or_else(|e| {
panic!("{uri} refused an unscoped holder: {e:?}");
});
}
}
}
#[test]
fn a_granted_scoped_admin_reaches_the_pool() {
let scoped_admin = claims(Role::Admin, &["ctx-work"]);
for (uri, reach) in REACH {
if *reach != Reach::Holder {
continue;
}
assert!(
decide(&scoped_admin, uri, Some("ctx-work"), false).is_err(),
"{uri} admitted a scoped admin who was granted nothing"
);
decide(&scoped_admin, uri, Some("ctx-work"), true)
.unwrap_or_else(|e| panic!("{uri} refused a granted holder: {e:?}"));
}
}
#[test]
fn the_grant_does_not_widen_a_context_task() {
let app = claims(Role::Application, &["ctx-a"]);
assert!(
decide(
&app,
uris::TASK_PERSONA_BINDING_GET_1_0,
Some("ctx-b"),
true
)
.is_err(),
"holder authority must not carry a caller into a context it has no claim to"
);
}
#[test]
fn a_granted_holder_is_still_refused_an_unclassified_task() {
let holder = claims(Role::Admin, &["ctx-work"]);
let unknown = format!("https://trusttasks.org/spec/persona/{}/9.9", "not-a-task");
assert!(decide(&holder, &unknown, None, true).is_err());
}
#[test]
fn every_non_admin_role_is_refused_the_pool() {
for role in [
Role::Application,
Role::Reader,
Role::Initiator,
Role::Monitor,
] {
let label = format!("{role:?}");
let c = claims(role, &[]);
let err = decide(&c, uris::TASK_PERSONA_ATTRIBUTE_LIST_1_0, None, false).unwrap_err();
assert!(
matches!(err, AppError::Forbidden(_)),
"{label} reached the pool"
);
}
}
#[test]
fn a_context_task_is_confined_to_its_own_context() {
let app = claims(Role::Application, &["ctx-a"]);
decide(
&app,
uris::TASK_PERSONA_BINDING_GET_1_0,
Some("ctx-a"),
false,
)
.expect("own context");
assert!(
decide(
&app,
uris::TASK_PERSONA_BINDING_GET_1_0,
Some("ctx-b"),
false
)
.is_err(),
"a caller scoped to ctx-a must not learn about ctx-b"
);
}
#[test]
fn an_unknown_task_is_refused_rather_than_defaulted() {
let app = claims(Role::Application, &["ctx"]);
let unknown = format!("https://trusttasks.org/spec/persona/{}/9.9", "made-up");
let err = decide(&app, &unknown, Some("ctx"), false).unwrap_err();
assert!(matches!(err, AppError::Forbidden(_)));
}
#[test]
fn binding_set_is_holder_only_and_local_binding_set_is_not() {
assert_eq!(
reach_of(uris::TASK_PERSONA_BINDING_SET_1_0),
Some(Reach::Holder)
);
assert_eq!(
reach_of(uris::TASK_PERSONA_LOCAL_BINDING_SET_1_0),
Some(Reach::Context)
);
}
}