use serde_json::Value;
use super::types::{Consumer, PolicyInput, PolicyRequest, TaskClass};
const SUBJECT_FIELDS: &[&str] = &["did", "mnemonic", "subject", "target", "credentialId", "id"];
const CONTEXT_FIELDS: &[&str] = &["contextId", "context_id"];
pub fn subject_of(payload: &Value) -> Option<String> {
first_string(payload, SUBJECT_FIELDS).map(str::to_string)
}
pub const ORIGIN_EXT_KEY: &str = "openvtc.origin";
pub fn origin_of(payload: &Value) -> Option<String> {
payload
.get("ext")
.and_then(|e| e.get(ORIGIN_EXT_KEY))
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn first_string<'a>(payload: &'a Value, fields: &[&str]) -> Option<&'a str> {
fields
.iter()
.find_map(|f| payload.get(*f).and_then(Value::as_str))
.filter(|s| !s.is_empty())
}
pub fn build_policy_input(
type_uri: &str,
payload: &Value,
caller_did: &str,
caller_acr: &str,
caller_amr: &[String],
class: Option<TaskClass>,
) -> PolicyInput {
let class = class.unwrap_or_else(TaskClass::floor);
let context_id = first_string(payload, CONTEXT_FIELDS)
.unwrap_or("default")
.to_string();
PolicyInput {
request: PolicyRequest {
type_uri: type_uri.to_string(),
kind: None,
subject: first_string(payload, SUBJECT_FIELDS).map(str::to_string),
payload_digest: None,
side_effects: class.side_effects,
exposure: class.exposure,
},
site: None,
context_id,
consumer: Consumer {
did: caller_did.to_string(),
kind: None,
device_id: None,
last_user_verification_at: None,
network_class: None,
acr: Some(if caller_acr.is_empty() {
"aal1".to_string()
} else {
caller_acr.to_string()
}),
amr: caller_amr.to_vec(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Discloses, SideEffectLevel};
use serde_json::json;
#[test]
fn uses_supplied_class_and_extracts_subject_and_context() {
let payload = json!({ "did": "did:webvh:abc", "contextId": "ctxA", "foo": 1 });
let class = Some(TaskClass::new(
SideEffectLevel::Destructive,
Discloses::None,
false,
));
let input = build_policy_input(
"https://…/delete/0.1",
&payload,
"did:key:zCaller",
"",
&[],
class,
);
assert_eq!(input.request.side_effects, SideEffectLevel::Destructive);
assert_eq!(input.request.subject.as_deref(), Some("did:webvh:abc"));
assert_eq!(input.context_id, "ctxA");
assert_eq!(input.consumer.did, "did:key:zCaller");
}
#[test]
fn an_unelevated_session_reports_aal1_rather_than_nothing() {
let input = build_policy_input(
"https://…/grant/0.1",
&json!({}),
"did:key:zCaller",
"", &[],
None,
);
assert_eq!(input.consumer.acr.as_deref(), Some("aal1"));
let elevated = build_policy_input(
"https://…/grant/0.1",
&json!({}),
"did:key:zCaller",
"aal2",
&[],
None,
);
assert_eq!(elevated.consumer.acr.as_deref(), Some("aal2"));
}
#[test]
fn unclassified_task_gets_the_fail_safe_floor() {
let input = build_policy_input(
"https://…/unknown/0.1",
&json!({}),
"did:key:z",
"",
&[],
None,
);
assert_eq!(input.request.side_effects, SideEffectLevel::Mutating);
assert_eq!(input.request.exposure.discloses, Discloses::Secret);
assert!(input.request.exposure.acts_as_subject);
assert_eq!(
input.context_id, "default",
"missing context falls back to default"
);
assert!(input.request.subject.is_none());
}
#[test]
fn subject_precedence_prefers_did_over_mnemonic() {
let payload = json!({ "mnemonic": "alice", "did": "did:webvh:xyz" });
let input = build_policy_input("t", &payload, "c", "", &[], Some(TaskClass::floor()));
assert_eq!(input.request.subject.as_deref(), Some("did:webvh:xyz"));
}
}