use serde_json::{Value, json};
use trust_tasks_rs::{RejectReason, TrustTask};
use uuid::Uuid;
use super::TrustTaskOutcome;
use super::helpers::{app_error_to_reject, reject_with};
use crate::auth::AuthClaims;
use crate::policy::{self, Disposition, RequireConsent, consent};
use crate::server::AppState;
const CONSENT_PENDING_TTL_SECS: u64 = 900;
fn gate_now_secs() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[allow(deprecated)]
fn is_ceremony_task(type_uri: &str) -> bool {
use vta_sdk::trust_tasks as t;
type_uri == t::TASK_TASK_CONSENT_DECISION_0_1
|| type_uri == t::TASK_AUTH_STEP_UP_APPROVE_RESPONSE_0_1
|| type_uri == t::TASK_AUTH_STEP_UP_APPROVE_RESPONSE_0_2
}
const STEP_UP_TARGET_ACR: &str = "aal2";
#[allow(deprecated)]
fn op_class_for(type_uri: &str) -> Option<&'static str> {
use super::step_up::op;
use vta_sdk::trust_tasks as t;
match type_uri {
t::TASK_ACL_CREATE_1_0 => Some(op::ACL_GRANT),
t::TASK_ACL_UPDATE_1_0 => Some(op::ACL_CHANGE_ROLE),
t::TASK_ACL_DELETE_1_0 => Some(op::ACL_REVOKE),
t::TASK_CONTEXTS_DELETE_1_0 => Some(op::CONTEXT_DELETE),
t::TASK_KEYS_REVOKE_1_0 => Some(op::KEY_REVOKE),
t::TASK_VAULT_RELEASE_0_1 => Some(op::VAULT_RELEASE),
t::TASK_VAULT_PROXY_LOGIN_0_1 => Some(op::VAULT_PROXY_LOGIN),
t::TASK_VAULT_SIGN_TRUST_TASK_0_1 => Some(op::VAULT_SIGN_TRUST_TASK),
t::TASK_VTA_CREDENTIALS_ISSUE_0_1 => Some(op::CREDENTIALS_ISSUE),
t::TASK_VTA_CREDENTIALS_REVOKE_0_1 => Some(op::CREDENTIALS_REVOKE),
_ => None,
}
}
pub(super) async fn policy_gate(
state: &AppState,
auth: &AuthClaims,
type_uri: &str,
doc: &TrustTask<Value>,
delegated_out: &mut Vec<String>,
) -> Option<TrustTaskOutcome> {
if is_ceremony_task(type_uri) {
return None;
}
if let Some(op_class) = op_class_for(type_uri)
&& let Some(reject) = super::step_up::require_step_up(state, auth, op_class, doc).await
{
return Some(reject);
}
if !state.config.read().await.policy.enforcement {
return None;
}
let class = super::class_for(type_uri);
let input = policy::build_policy_input(
type_uri,
&doc.payload,
&auth.did,
&auth.acr,
&auth.amr,
class,
);
let policies = match policy::load_active_for_context(&state.policy_ks, &input.context_id).await
{
Ok(p) => p,
Err(e) => {
tracing::error!(error = %e, type_uri, "policy load failed — denying (fail-closed)");
return Some(reject_with(
doc,
RejectReason::PermissionDenied {
reason: "policy evaluation unavailable".to_string(),
},
));
}
};
let decision = policy::decide(&policies, &input);
match decision.decision {
Disposition::Allow => None,
Disposition::Deny => Some(reject_with(
doc,
RejectReason::PermissionDenied {
reason: decision
.explanation
.unwrap_or_else(|| "denied by policy".to_string()),
},
)),
Disposition::RequireStepUp => {
if auth.acr == STEP_UP_TARGET_ACR {
None
} else {
Some(super::step_up::initiate_self_step_up(state, auth, doc).await)
}
}
Disposition::RequireConsent => {
consent_gate(
state,
auth,
doc,
type_uri,
decision.require_consent,
delegated_out,
)
.await
}
}
}
async fn consent_gate(
state: &AppState,
auth: &AuthClaims,
doc: &TrustTask<Value>,
type_uri: &str,
require: Option<RequireConsent>,
delegated_out: &mut Vec<String>,
) -> Option<TrustTaskOutcome> {
let Some(require) = require else {
return Some(reject_with(
doc,
RejectReason::PermissionDenied {
reason: "policy requires consent but named no approver set".into(),
},
));
};
let digest = match consent::payload_digest(type_uri, &doc.payload) {
Ok(d) => d,
Err(e) => return Some(app_error_to_reject(doc, e)),
};
let now = gate_now_secs();
let members = state
.config
.read()
.await
.policy
.approver_sets
.get(&require.approver_set)
.cloned()
.unwrap_or_default();
if members.is_empty() {
return Some(reject_with(
doc,
RejectReason::PermissionDenied {
reason: format!(
"approver set '{}' is unknown or empty",
require.approver_set
),
},
));
}
match consent::consume_grant(&state.task_consent_ks, &auth.did, type_uri, &digest, now).await {
Ok(Some(grant)) => {
if let Err(why) = approvals_still_authorize(&require, &members, &grant, &auth.did) {
crate::audit::record_consent(
&state.audit_ks,
"consent.consumed",
&auth.did,
type_uri,
"denied:approver_no_longer_authorized",
Some(&format!("digest={digest}; {why}")),
)
.await;
return Some(reject_with(
doc,
RejectReason::PermissionDenied { reason: why },
));
}
if let Err(why) = super::planner::assert_plan_still_holds(
state,
auth,
type_uri,
&doc.payload,
grant.state_pin.as_ref(),
&grant.guards,
)
.await
{
tracing::info!(
consent_diag = true,
type_uri,
digest = %digest.chars().take(12).collect::<String>(),
grant_state_pin = ?grant.state_pin,
grant_guards = ?grant.guards,
%why,
"consent-diag: grant CONSUMED but the re-planned world no longer matches it \
— re-raising consent. If this line repeats, THIS is the loop."
);
crate::audit::record_consent(
&state.audit_ks,
"consent.consumed",
&auth.did,
type_uri,
"denied:plan_changed",
Some(&format!("digest={digest}; {why}")),
)
.await;
return Some(reject_with(
doc,
RejectReason::TaskFailed {
reason: "auth:consent_stale".into(),
details: Some(json!({ "explanation": why })),
},
));
}
crate::audit::record_consent(
&state.audit_ks,
"consent.consumed",
&auth.did,
type_uri,
"success",
Some(&format!(
"digest={digest}; approvers={}",
grant.approvers.join(",")
)),
)
.await;
*delegated_out = grant.delegated_contexts;
return None;
}
Ok(None) => {
tracing::info!(
consent_diag = true,
type_uri,
requester = %auth.did,
digest = %digest.chars().take(12).collect::<String>(),
"consent-diag: no grant found for (requester, digest) — the approval has not landed \
for this exact payload; will mint/reuse a pending below"
);
}
Err(e) => return Some(app_error_to_reject(doc, e)),
}
let plan = match super::planner::plan_task(state, auth, type_uri, &doc.payload).await {
Ok(p) => p,
Err(e) => return Some(app_error_to_reject(doc, e)),
};
let (effects, state_pin, guards, subject_context, requester_authorized) = match &plan {
Some(p) => (
p.effects.clone(),
p.state_pin.clone(),
p.guards.clone(),
p.subject_context.clone(),
p.requester_authorized,
),
None => (vec![], None, Default::default(), None, true),
};
let min_approvals = require.min_approvals.max(1);
if !requester_authorized && let Some(ctx) = subject_context.as_deref() {
let mut eligible = 0u32;
for member in &members {
if let Ok(Some(entry)) = crate::acl::get_acl_entry(&state.acl_ks, member).await
&& !entry.is_expired(now)
&& crate::operations::acl::acl_entry_can_confer(&entry, ctx)
{
eligible += 1;
}
}
if eligible < min_approvals {
crate::audit::record_consent(
&state.audit_ks,
"consent.required",
&auth.did,
type_uri,
"denied:unsatisfiable",
Some(&format!(
"context={ctx}; approverSet={}; eligible={eligible}/{min_approvals}",
require.approver_set
)),
)
.await;
return Some(reject_with(
doc,
RejectReason::PermissionDenied {
reason: format!(
"this task updates context `{ctx}`, which the requester is not authorized \
for and which consent cannot confer: {eligible} of the required \
{min_approvals} member(s) of approver set `{}` hold approve authority \
over `{ctx}`. Grant an approver approve-scope (or admin) over `{ctx}`, \
or run this as a principal that already holds the context — otherwise \
the approval would succeed but the update would still be refused.",
require.approver_set
),
},
));
}
}
let existing = match consent::get_pending(&state.task_consent_ks, &digest, now).await {
Ok(p) => p,
Err(e) => return Some(app_error_to_reject(doc, e)),
};
let mut newly_raised = true;
let pending = match existing {
Some(p) if p.state_pin == state_pin && p.guards == guards => {
newly_raised = false;
tracing::info!(
consent_diag = true,
type_uri,
digest = %digest.chars().take(12).collect::<String>(),
"consent-diag: REUSING the outstanding pending (state_pin + guards unchanged) — \
the code should stay the same; waiting on the approver's grant"
);
p
}
Some(stale) => {
tracing::info!(
consent_diag = true,
type_uri,
digest = %digest.chars().take(12).collect::<String>(),
state_pin_changed = stale.state_pin != state_pin,
guards_changed = stale.guards != guards,
old_guards = ?stale.guards,
new_guards = ?guards,
old_state_pin = ?stale.state_pin,
new_state_pin = ?state_pin,
"consent-diag: RE-MINTING — the outstanding pending went stale, so a fresh code is \
issued. `guards_changed`/`state_pin_changed` say which drifted. If this repeats, \
THIS is why the code changes every time."
);
if let Err(e) = consent::delete_pending(&state.task_consent_ks, &stale).await {
return Some(app_error_to_reject(doc, e));
}
match mint_pending(
state,
auth,
doc,
type_uri,
&require,
min_approvals,
now,
&state_pin,
&guards,
&subject_context,
requester_authorized,
)
.await
{
Ok(p) => p,
Err(e) => return Some(app_error_to_reject(doc, e)),
}
}
None => {
match mint_pending(
state,
auth,
doc,
type_uri,
&require,
min_approvals,
now,
&state_pin,
&guards,
&subject_context,
requester_authorized,
)
.await
{
Ok(p) => p,
Err(e) => return Some(app_error_to_reject(doc, e)),
}
}
};
let class = super::class_for(type_uri).unwrap_or_else(crate::policy::TaskClass::floor);
let subject = crate::policy::input::subject_of(&doc.payload);
let origin = crate::policy::input::origin_of(&doc.payload);
let requests = match super::consent_request::mint_signed_requests(
state,
&pending,
&members,
class,
&effects,
subject.as_deref(),
origin.as_deref(),
)
.await
{
Ok(r) => r,
Err(e) => return Some(app_error_to_reject(doc, e)),
};
if newly_raised {
super::consent_request::push_signed_requests(state, &requests).await;
}
crate::audit::record_consent(
&state.audit_ks,
"consent.required",
&auth.did,
type_uri,
if newly_raised {
"pending:raised"
} else {
"pending:reasked"
},
Some(&format!(
"digest={digest}; approverSet={}; minApprovals={min_approvals}; challenge={}",
require.approver_set, pending.challenge
)),
)
.await;
Some(reject_with(
doc,
RejectReason::TaskFailed {
reason: "auth:consent_required".into(),
details: Some(json!({
"reason": "auth:consent_required",
"payloadDigest": pending.wire_digest,
"challenge": pending.challenge,
"approverSet": require.approver_set,
"minApprovals": min_approvals,
"consentRequests": requests,
})),
},
))
}
fn approvals_still_authorize(
require: &RequireConsent,
members: &[String],
grant: &consent::TaskConsentGrant,
requester_did: &str,
) -> Result<(), String> {
let min_approvals = require.min_approvals.max(1);
let still_valid: Vec<&String> = grant
.approvers
.iter()
.filter(|a| members.iter().any(|m| m == *a))
.filter(|a| !(require.exclude_requester && a.as_str() == requester_did))
.collect();
if (still_valid.len() as u32) >= min_approvals {
return Ok(());
}
let revoked: Vec<&str> = grant
.approvers
.iter()
.filter(|a| !still_valid.contains(a))
.map(String::as_str)
.collect();
Err(format!(
"the approval for this task is no longer valid: {} of the {} required approver(s) are no \
longer permitted to approve it ({}). Re-submit to ask the current approver set.",
min_approvals as usize - still_valid.len(),
min_approvals,
if revoked.is_empty() {
"the approver set or its threshold changed".to_string()
} else {
revoked.join(", ")
},
))
}
#[allow(clippy::too_many_arguments)]
async fn mint_pending(
state: &AppState,
auth: &AuthClaims,
doc: &TrustTask<Value>,
type_uri: &str,
require: &RequireConsent,
min_approvals: u32,
now: u64,
state_pin: &Option<crate::policy::effects::StatePin>,
guards: &super::planner::Guards,
subject_context: &Option<String>,
requester_authorized: bool,
) -> Result<consent::PendingTaskConsent, vti_common::error::AppError> {
let challenge = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
let digest = consent::payload_digest(type_uri, &doc.payload)?;
let wire_digest = consent::wire_digest(type_uri, &doc.payload, &challenge)?;
let pending = consent::PendingTaskConsent {
digest,
wire_digest,
type_uri: type_uri.to_string(),
requester_did: auth.did.clone(),
approver_set: require.approver_set.clone(),
min_approvals,
exclude_requester: require.exclude_requester,
challenge,
approvals: vec![],
state_pin: state_pin.clone(),
guards: guards.clone(),
subject_context: subject_context.clone(),
requester_authorized,
created_at: now,
expires_at: now + CONSENT_PENDING_TTL_SECS,
};
consent::store_pending(&state.task_consent_ks, &pending).await?;
Ok(pending)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::types::PolicyModule;
fn module(id: &str, priority: i32, rego: &str) -> PolicyModule {
PolicyModule {
id: id.into(),
name: id.into(),
description: None,
module: rego.into(),
applies_to: vec![],
priority,
enabled: true,
version: 1,
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-01T00:00:00Z".into(),
}
}
const DENY_ALL: &str = "package vta.policy\nimport rego.v1\ndecision := {\"decision\": \"deny\", \"explanation\": \"blocked\"}";
const ALLOW_ALL: &str =
"package vta.policy\nimport rego.v1\ndecision := {\"decision\": \"allow\"}";
const STEPUP_IF_NOT_AAL2: &str = "package vta.policy\nimport rego.v1\ndecision := {\"decision\": \"requireStepUp\"} if input.consumer.acr != \"aal2\"\ndecision := {\"decision\": \"allow\"} if input.consumer.acr == \"aal2\"";
fn doc(type_uri: &str) -> TrustTask<Value> {
serde_json::from_value(serde_json::json!({
"id": "urn:uuid:00000000-0000-0000-0000-000000000001",
"type": type_uri,
"issuer": "did:key:zTestAdmin",
"recipient": "did:example:vta",
"issuedAt": "2026-05-20T00:00:00Z",
"payload": { "contextId": "default" }
}))
.expect("valid trust task")
}
const UNGATED_URI: &str = "https://trusttasks.org/spec/vta/memory/list/0.1";
#[tokio::test]
async fn gate_inert_when_disabled_enforces_when_enabled() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
let auth = crate::test_support::super_admin_claims();
let d = doc(UNGATED_URI);
assert!(
policy_gate(&state, &auth, UNGATED_URI, &d, &mut Vec::new())
.await
.is_none()
);
state.config.write().await.policy.enforcement = true;
assert!(
policy_gate(&state, &auth, UNGATED_URI, &d, &mut Vec::new())
.await
.is_some()
);
crate::policy::storage::store_policy(&state.policy_ks, &module("deny", 0, DENY_ALL))
.await
.unwrap();
assert!(
policy_gate(&state, &auth, UNGATED_URI, &d, &mut Vec::new())
.await
.is_some()
);
crate::policy::storage::store_policy(&state.policy_ks, &module("allow", 10, ALLOW_ALL))
.await
.unwrap();
assert!(
policy_gate(&state, &auth, UNGATED_URI, &d, &mut Vec::new())
.await
.is_none()
);
}
#[tokio::test]
async fn rego_requires_step_up_when_session_not_elevated() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
let mut auth = crate::test_support::super_admin_claims();
let d = doc(UNGATED_URI);
state.config.write().await.policy.enforcement = true;
crate::policy::storage::store_policy(
&state.policy_ks,
&module("su", 0, STEPUP_IF_NOT_AAL2),
)
.await
.unwrap();
auth.acr = "aal1".into();
assert!(
policy_gate(&state, &auth, UNGATED_URI, &d, &mut Vec::new())
.await
.is_some(),
"aal1 session must be sent to step-up"
);
auth.acr = "aal2".into();
assert!(
policy_gate(&state, &auth, UNGATED_URI, &d, &mut Vec::new())
.await
.is_none(),
"aal2 session must pass the step-up gate"
);
}
const OTHER_UNGATED_URI: &str = "https://trusttasks.org/spec/vta/memory/delete/0.1";
const REQUIRE_CONSENT: &str = "package vta.policy\nimport rego.v1\ndecision := {\"decision\": \"requireConsent\", \"requireConsent\": {\"approverSet\": \"ops\"}}";
#[tokio::test]
async fn consent_reject_carries_vta_signed_requests() {
use crate::policy::consent;
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
let auth = crate::test_support::super_admin_claims();
let d = doc(UNGATED_URI);
{
let mut cfg = state.config.write().await;
cfg.policy.enforcement = true;
cfg.policy
.approver_sets
.insert("ops".into(), vec!["did:key:zApprover".into()]);
}
crate::policy::storage::store_policy(
&state.policy_ks,
&module("consent", 0, REQUIRE_CONSENT),
)
.await
.unwrap();
let outcome = policy_gate(&state, &auth, UNGATED_URI, &d, &mut Vec::new())
.await
.expect("first submit is rejected pending consent");
let body: Value = serde_json::from_slice(&outcome.body).expect("reject body");
let details = body
.pointer("/payload/details")
.expect("reject carries details");
assert_eq!(
details["reason"].as_str(),
Some("auth:consent_required"),
"consent rejects must carry a machine-readable reason in details"
);
let requests = details["consentRequests"]
.as_array()
.expect("consentRequests present");
assert_eq!(requests.len(), 1, "one request per eligible approver");
let req = &requests[0];
assert!(
req.get("proof").is_some(),
"the request must be signed — an unsigned one lets anyone author what the human reads"
);
let vta_did = state.config.read().await.vta_did.clone().unwrap();
assert_eq!(req["issuer"], serde_json::json!(vta_did));
assert_eq!(req["recipient"], serde_json::json!("did:key:zApprover"));
assert_eq!(
req["type"],
serde_json::json!(super::super::consent_request::TASK_CONSENT_REQUEST_0_1)
);
let wire = req["payload"]["payloadDigest"].as_str().unwrap();
assert_eq!(details["payloadDigest"].as_str().unwrap(), wire);
assert_eq!(
req["payload"]["challenge"].as_str().unwrap(),
details["challenge"].as_str().unwrap()
);
let challenge = details["challenge"].as_str().unwrap();
assert_eq!(
wire,
consent::wire_digest(UNGATED_URI, &d.payload, challenge).unwrap()
);
assert_ne!(
wire,
consent::payload_digest(UNGATED_URI, &d.payload).unwrap(),
"the internal digest must never reach the wire"
);
let compiled = serde_json::to_value(
super::super::class_for(UNGATED_URI).expect("this URI is in the dispatch table"),
)
.unwrap();
assert_eq!(req["payload"]["sideEffects"], compiled["sideEffects"]);
assert_eq!(req["payload"]["exposure"], compiled["exposure"]);
assert_eq!(
req["payload"]["effects"],
serde_json::json!([]),
"a handler with no dry-run yields no effects"
);
assert_eq!(req["payload"]["taskType"], serde_json::json!(UNGATED_URI));
}
#[tokio::test]
async fn the_consent_request_names_the_origin_that_proposed_the_task() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
let auth = crate::test_support::super_admin_claims();
let mut d = doc(UNGATED_URI);
d.payload["ext"] = json!({ "openvtc.origin": "https://control.example.com" });
{
let mut cfg = state.config.write().await;
cfg.policy.enforcement = true;
cfg.policy
.approver_sets
.insert("ops".into(), vec!["did:key:zApprover".into()]);
}
crate::policy::storage::store_policy(
&state.policy_ks,
&module("consent", 0, REQUIRE_CONSENT),
)
.await
.unwrap();
let outcome = policy_gate(&state, &auth, UNGATED_URI, &d, &mut Vec::new())
.await
.unwrap();
let body: Value = serde_json::from_slice(&outcome.body).unwrap();
let req = &body.pointer("/payload/details/consentRequests").unwrap()[0];
assert_eq!(
req["payload"]["origin"],
json!("https://control.example.com"),
"the approver must be able to see which site asked"
);
}
#[tokio::test]
async fn a_task_with_no_page_behind_it_carries_no_origin() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
let auth = crate::test_support::super_admin_claims();
let d = doc(UNGATED_URI);
{
let mut cfg = state.config.write().await;
cfg.policy.enforcement = true;
cfg.policy
.approver_sets
.insert("ops".into(), vec!["did:key:zApprover".into()]);
}
crate::policy::storage::store_policy(
&state.policy_ks,
&module("consent", 0, REQUIRE_CONSENT),
)
.await
.unwrap();
let outcome = policy_gate(&state, &auth, UNGATED_URI, &d, &mut Vec::new())
.await
.unwrap();
let body: Value = serde_json::from_slice(&outcome.body).unwrap();
let req = &body.pointer("/payload/details/consentRequests").unwrap()[0];
assert!(req["payload"].get("origin").is_none());
}
#[tokio::test]
async fn the_requester_is_never_asked_to_approve_its_own_task() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
let auth = crate::test_support::super_admin_claims();
let d = doc(UNGATED_URI);
{
let mut cfg = state.config.write().await;
cfg.policy.enforcement = true;
cfg.policy
.approver_sets
.insert("ops".into(), vec![auth.did.clone()]);
}
crate::policy::storage::store_policy(
&state.policy_ks,
&module("consent", 0, REQUIRE_CONSENT_EXCLUDE_REQUESTER),
)
.await
.unwrap();
let outcome = policy_gate(&state, &auth, UNGATED_URI, &d, &mut Vec::new())
.await
.unwrap();
let body: Value = serde_json::from_slice(&outcome.body).unwrap();
let requests = body
.pointer("/payload/details/consentRequests")
.and_then(Value::as_array)
.expect("consentRequests present");
assert!(
requests.is_empty(),
"the only member of the set is the requester, and the policy excludes them — \
so there is nobody to ask, and we must not pretend otherwise"
);
}
const REQUIRE_CONSENT_EXCLUDE_REQUESTER: &str = "package vta.policy\nimport rego.v1\ndecision := {\"decision\": \"requireConsent\", \"requireConsent\": {\"approverSet\": \"ops\", \"excludeRequester\": true}}";
#[cfg(feature = "didcomm")]
#[tokio::test]
async fn a_resubmit_re_asks_nobody() {
use crate::messaging::registry::MediatorBinding;
const MEDIATOR: &str = "did:example:mediator";
const APPROVER: &str = "did:key:zApprover";
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
let auth = crate::test_support::super_admin_claims();
let d = doc(UNGATED_URI);
state
.mediator_registry
.record_activate(MediatorBinding {
mediator_did: MEDIATOR.into(),
endpoint: "https://mediator.test".into(),
})
.await;
{
let mut cfg = state.config.write().await;
cfg.policy.enforcement = true;
cfg.policy
.approver_sets
.insert("ops".into(), vec![APPROVER.into()]);
cfg.messaging = Some(vti_common::config::MessagingConfig {
mediator_url: String::new(),
mediator_did: MEDIATOR.into(),
mediator_host: None,
setup_acl: false,
drain_inbox_on_start: false,
});
}
crate::policy::storage::store_policy(
&state.policy_ks,
&module("consent", 0, REQUIRE_CONSENT),
)
.await
.unwrap();
assert!(
policy_gate(&state, &auth, UNGATED_URI, &d, &mut Vec::new())
.await
.is_some()
);
let pushed = state.mediator_registry.take_outbound(MEDIATOR).await;
assert_eq!(pushed.len(), 1, "the approver is asked exactly once");
assert_eq!(
pushed[0].message_type,
super::super::consent_request::TASK_CONSENT_REQUEST_0_1
);
assert_eq!(pushed[0].recipient_did, APPROVER);
assert!(
pushed[0].body.get("proof").is_some(),
"the pushed document is the same signed one the reject carries — one \
document on two transports, so a device cannot be shown different \
effects depending on how it arrived"
);
assert!(
policy_gate(&state, &auth, UNGATED_URI, &d, &mut Vec::new())
.await
.is_some()
);
assert!(
state
.mediator_registry
.take_outbound(MEDIATOR)
.await
.is_empty(),
"a re-submit must not re-push — otherwise a relying party can spam an \
approver by retrying a task it knows will be rejected"
);
}
#[tokio::test]
async fn a_revoked_approver_cannot_carry_a_grant_through() {
use crate::policy::consent;
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
let auth = crate::test_support::super_admin_claims();
let d = doc(UNGATED_URI);
{
let mut cfg = state.config.write().await;
cfg.policy.enforcement = true;
cfg.policy
.approver_sets
.insert("ops".into(), vec!["did:key:zApprover".into()]);
}
crate::policy::storage::store_policy(
&state.policy_ks,
&module("consent", 0, REQUIRE_CONSENT),
)
.await
.unwrap();
let now = super::gate_now_secs();
let digest = consent::payload_digest(UNGATED_URI, &d.payload).unwrap();
let grant = consent::TaskConsentGrant {
digest: digest.clone(),
requester_did: auth.did.clone(),
type_uri: UNGATED_URI.into(),
approvers: vec!["did:key:zApprover".into()],
state_pin: None,
guards: Default::default(),
delegated_contexts: vec![],
granted_at: now,
expires_at: now + 600,
};
consent::store_grant(&state.task_consent_ks, &grant)
.await
.unwrap();
{
let mut cfg = state.config.write().await;
cfg.policy
.approver_sets
.insert("ops".into(), vec!["did:key:zSomeoneElse".into()]);
}
assert!(
policy_gate(&state, &auth, UNGATED_URI, &d, &mut Vec::new())
.await
.is_some(),
"a grant signed by a now-revoked approver must NOT carry the task through"
);
}
#[tokio::test]
async fn a_still_enrolled_approver_carries_the_grant_through() {
use crate::policy::consent;
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
let auth = crate::test_support::super_admin_claims();
let d = doc(UNGATED_URI);
{
let mut cfg = state.config.write().await;
cfg.policy.enforcement = true;
cfg.policy
.approver_sets
.insert("ops".into(), vec!["did:key:zApprover".into()]);
}
crate::policy::storage::store_policy(
&state.policy_ks,
&module("consent", 0, REQUIRE_CONSENT),
)
.await
.unwrap();
let now = super::gate_now_secs();
let digest = consent::payload_digest(UNGATED_URI, &d.payload).unwrap();
consent::store_grant(
&state.task_consent_ks,
&consent::TaskConsentGrant {
digest,
requester_did: auth.did.clone(),
type_uri: UNGATED_URI.into(),
approvers: vec!["did:key:zApprover".into()],
state_pin: None,
guards: Default::default(),
delegated_contexts: vec![],
granted_at: now,
expires_at: now + 600,
},
)
.await
.unwrap();
assert!(
policy_gate(&state, &auth, UNGATED_URI, &d, &mut Vec::new())
.await
.is_none(),
"an approver still in the set must carry the task through"
);
}
#[tokio::test]
async fn a_threshold_raised_mid_flight_invalidates_the_grant() {
use crate::policy::consent;
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
let auth = crate::test_support::super_admin_claims();
let d = doc(UNGATED_URI);
{
let mut cfg = state.config.write().await;
cfg.policy.enforcement = true;
cfg.policy
.approver_sets
.insert("ops".into(), vec!["did:key:zA".into(), "did:key:zB".into()]);
}
crate::policy::storage::store_policy(
&state.policy_ks,
&module("consent", 0, REQUIRE_CONSENT_MIN_TWO),
)
.await
.unwrap();
let now = super::gate_now_secs();
let digest = consent::payload_digest(UNGATED_URI, &d.payload).unwrap();
consent::store_grant(
&state.task_consent_ks,
&consent::TaskConsentGrant {
digest,
requester_did: auth.did.clone(),
type_uri: UNGATED_URI.into(),
approvers: vec!["did:key:zA".into()],
state_pin: None,
guards: Default::default(),
delegated_contexts: vec![],
granted_at: now,
expires_at: now + 600,
},
)
.await
.unwrap();
assert!(
policy_gate(&state, &auth, UNGATED_URI, &d, &mut Vec::new())
.await
.is_some(),
"a single approval must not satisfy a threshold that has since risen to two"
);
}
const REQUIRE_CONSENT_MIN_TWO: &str = "package vta.policy\nimport rego.v1\ndecision := {\"decision\": \"requireConsent\", \"requireConsent\": {\"approverSet\": \"ops\", \"minApprovals\": 2}}";
#[tokio::test]
async fn grant_for_one_task_uri_does_not_authorize_another() {
use crate::policy::consent;
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
let auth = crate::test_support::super_admin_claims();
let approved = doc(UNGATED_URI);
let substituted = doc(OTHER_UNGATED_URI);
assert_eq!(
approved.payload, substituted.payload,
"the two tasks must share a payload for this test to mean anything"
);
{
let mut cfg = state.config.write().await;
cfg.policy.enforcement = true;
cfg.policy
.approver_sets
.insert("ops".into(), vec!["did:key:zApprover".into()]);
}
crate::policy::storage::store_policy(
&state.policy_ks,
&module("consent", 0, REQUIRE_CONSENT),
)
.await
.unwrap();
let now = super::gate_now_secs();
let digest = consent::payload_digest(UNGATED_URI, &approved.payload).unwrap();
consent::store_grant(
&state.task_consent_ks,
&consent::TaskConsentGrant {
digest: digest.clone(),
state_pin: None,
guards: Default::default(),
requester_did: auth.did.clone(),
type_uri: UNGATED_URI.into(),
approvers: vec!["did:key:zApprover".into()],
delegated_contexts: vec![],
granted_at: now,
expires_at: now + 600,
},
)
.await
.unwrap();
assert!(
policy_gate(
&state,
&auth,
OTHER_UNGATED_URI,
&substituted,
&mut Vec::new()
)
.await
.is_some(),
"a grant for a different task URI must not authorize this one"
);
assert!(
policy_gate(&state, &auth, UNGATED_URI, &approved, &mut Vec::new())
.await
.is_none(),
"the approved task must still consume its own grant"
);
}
#[tokio::test]
async fn require_consent_records_pending_then_grant_lets_resubmit_through() {
use crate::policy::consent;
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
let auth = crate::test_support::super_admin_claims();
let d = doc(UNGATED_URI);
{
let mut cfg = state.config.write().await;
cfg.policy.enforcement = true;
cfg.policy
.approver_sets
.insert("ops".into(), vec!["did:key:zApprover".into()]);
}
crate::policy::storage::store_policy(
&state.policy_ks,
&module("consent", 0, REQUIRE_CONSENT),
)
.await
.unwrap();
assert!(
policy_gate(&state, &auth, UNGATED_URI, &d, &mut Vec::new())
.await
.is_some(),
"first submit must be rejected pending consent"
);
let digest = consent::payload_digest(UNGATED_URI, &d.payload).unwrap();
let now = super::gate_now_secs();
assert!(
consent::get_pending(&state.task_consent_ks, &digest, now)
.await
.unwrap()
.is_some(),
"a pending consent record must exist"
);
consent::store_grant(
&state.task_consent_ks,
&consent::TaskConsentGrant {
digest: digest.clone(),
state_pin: None,
guards: Default::default(),
requester_did: auth.did.clone(),
type_uri: UNGATED_URI.into(),
approvers: vec!["did:key:zApprover".into()],
delegated_contexts: vec![],
granted_at: now,
expires_at: now + 600,
},
)
.await
.unwrap();
assert!(
policy_gate(&state, &auth, UNGATED_URI, &d, &mut Vec::new())
.await
.is_none(),
"a valid grant must let the re-submit proceed"
);
assert!(
policy_gate(&state, &auth, UNGATED_URI, &d, &mut Vec::new())
.await
.is_some(),
"grant is single-use; a further submit re-requires consent"
);
}
}