use std::collections::BTreeMap;
use std::sync::Arc;
use act_policy::provider::CompiledCeiling;
use wasmtime::component::{HasSelf, Linker};
use crate::bindings::act::consent::{consent_authority, types};
use crate::store::HostState;
pub(crate) use types::Decision;
const ACTION: &str = "request";
fn mark_never_rollup(
mut record: crate::audit::CapDecisionRecord,
) -> crate::audit::CapDecisionRecord {
record.never_rollup = true;
record
}
pub(crate) struct ConsentGate {
semantic_ceilings: Arc<BTreeMap<String, Arc<dyn CompiledCeiling>>>,
prompter: Arc<dyn act_policy::consent::ConsentPrompter>,
cache: Arc<act_policy::consent::DecisionCache>,
component: String,
}
impl ConsentGate {
fn from_accessor(
accessor: &wasmtime::component::Accessor<HostState, HasSelf<HostState>>,
) -> Self {
accessor.with(|mut access| {
let state: &mut HostState = access.get();
Self {
semantic_ceilings: state.semantic_ceilings.clone(),
prompter: state.consent_prompter.clone(),
cache: state.consent_cache.clone(),
component: state.component_ref.clone(),
}
})
}
pub(crate) async fn decide(
&self,
class: &str,
key: &str,
summary: &str,
args: &serde_json::Value,
) -> Decision {
use crate::audit::{CapDecisionRecord, Decision4, emit_cap_decision};
if class.is_empty() {
emit_cap_decision(&mark_never_rollup(CapDecisionRecord::statik_with_reason(
class,
key,
ACTION,
Decision4::Deny,
"deny",
None,
Some("empty capability class"),
)));
return Decision::Deny;
}
let Some(ceiling) = self.semantic_ceilings.get(class) else {
let reason = if act_policy::ceilings::PHYSICALLY_INTERCEPTED.contains(&class) {
"class is enforced on the boundary, not through consent"
} else {
"class not declared in act:component"
};
emit_cap_decision(&mark_never_rollup(CapDecisionRecord::statik_with_reason(
class,
key,
ACTION,
Decision4::Deny,
"deny",
None,
Some(reason),
)));
return Decision::Deny;
};
let op = act_policy::provider::ResourceOp {
cap_id: class.to_string(),
key: key.to_string(),
action: ACTION.to_string(),
attrs: args.clone(),
};
let explained = ceiling.classify_explained(&op);
let mode = ceiling.effective_mode().to_string();
match explained.decision {
act_policy::Decision::Allow => {
emit_cap_decision(&mark_never_rollup(CapDecisionRecord::statik(
class,
key,
ACTION,
Decision4::Allow,
&mode,
explained.rule,
)));
Decision::Allow
}
act_policy::Decision::Deny => {
emit_cap_decision(&mark_never_rollup(CapDecisionRecord::statik(
class,
key,
ACTION,
Decision4::Deny,
&mode,
explained.rule,
)));
Decision::Deny
}
act_policy::Decision::Ask => {
let has_channel = self.prompter.has_channel();
let allowed = self
.cache
.decide_cached(
&*self.prompter,
act_policy::consent::ConsentAsk {
cap_id: class.to_string(),
key: key.to_string(),
summary: crate::consent::prompt_line(
Some(&self.component),
class,
key,
summary,
),
},
)
.await;
emit_cap_decision(&mark_never_rollup(CapDecisionRecord::answered(
class,
key,
allowed,
has_channel,
)));
if allowed {
Decision::Allow
} else {
Decision::Deny
}
}
}
}
#[cfg(test)]
fn for_test(
semantic_ceilings: BTreeMap<String, Arc<dyn CompiledCeiling>>,
prompter: Arc<dyn act_policy::consent::ConsentPrompter>,
) -> Self {
Self {
semantic_ceilings: Arc::new(semantic_ceilings),
prompter,
cache: Arc::new(act_policy::consent::DecisionCache::new()),
component: "./test.wasm".to_string(),
}
}
}
fn args_to_attrs(args: &[u8]) -> serde_json::Value {
match act_types::cbor::cbor_to_json(args) {
Ok(v @ serde_json::Value::Object(_)) => v,
_ => serde_json::Value::Null,
}
}
impl consent_authority::Host for &mut HostState {}
impl types::Host for &mut HostState {}
pub(crate) fn add_to_linker(linker: &mut Linker<HostState>) -> anyhow::Result<()> {
types::add_to_linker::<HostState, HasSelf<HostState>>(linker, |s| s)
.map_err(|e| anyhow::anyhow!("failed to add act:consent/types to linker: {e}"))?;
consent_authority::add_to_linker::<HostState, HasSelf<HostState>>(linker, |s| s).map_err(
|e| anyhow::anyhow!("failed to add act:consent/consent-authority to linker: {e}"),
)?;
Ok(())
}
impl consent_authority::HostWithStore<HostState> for HasSelf<HostState> {
async fn request(
accessor: &wasmtime::component::Accessor<HostState, Self>,
req: consent_authority::ConsentRequest,
_meta: consent_authority::Metadata,
) -> Decision {
let gate = ConsentGate::from_accessor(accessor);
let attrs = args_to_attrs(&req.args);
gate.decide(&req.class, &req.key, &req.summary, &attrs)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use act_policy::consent::{ConsentAsk, ConsentPrompter};
use act_policy::grant::PolicyMode;
use serde_json::json;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
struct PanickingPrompter;
#[async_trait::async_trait]
impl ConsentPrompter for PanickingPrompter {
async fn decide(&self, ask: &ConsentAsk) -> bool {
panic!("the operator must not be consulted, but was asked: {ask:?}");
}
}
struct CountingPrompter {
allow: bool,
calls: AtomicUsize,
}
impl CountingPrompter {
fn allowing() -> Self {
Self {
allow: true,
calls: AtomicUsize::new(0),
}
}
fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
#[async_trait::async_trait]
impl ConsentPrompter for CountingPrompter {
async fn decide(&self, _ask: &ConsentAsk) -> bool {
self.calls.fetch_add(1, Ordering::SeqCst);
self.allow
}
}
async fn ceilings_declaring(
class: &str,
declared: &[serde_json::Value],
mode: PolicyMode,
) -> BTreeMap<String, Arc<dyn act_policy::provider::CompiledCeiling>> {
ceilings_granted(
class,
declared,
act_policy::grant::CapabilityGrant {
mode,
allow: Vec::new(),
deny: Vec::new(),
},
)
.await
}
async fn ceilings_granted(
class: &str,
declared: &[serde_json::Value],
grant: act_policy::grant::CapabilityGrant,
) -> BTreeMap<String, Arc<dyn act_policy::provider::CompiledCeiling>> {
use act_policy::grant::{GrantPolicy, PolicyMode};
let policy = GrantPolicy {
default: PolicyMode::Deny,
entries: BTreeMap::from([(class.to_string(), grant)]),
};
let declared = BTreeMap::from([(class.to_string(), declared.to_vec())]);
let all = act_policy::ceilings::resolve_ceilings(
&act_policy::provider::ProviderRegistry::with_builtins(),
&declared,
&policy,
)
.await
.expect("resolve");
all.into_iter()
.filter(|(id, _)| !act_policy::ceilings::PHYSICALLY_INTERCEPTED.contains(&id.as_str()))
.collect()
}
#[tokio::test]
async fn an_undeclared_class_denies_without_reaching_the_prompter() {
let gate = ConsentGate::for_test(BTreeMap::new(), Arc::new(PanickingPrompter));
let decision = gate
.decide(
"db:drop",
"analytics",
"Drop database \"analytics\"",
&json!({}),
)
.await;
assert_eq!(decision, Decision::Deny);
}
#[tokio::test]
async fn a_declared_class_outside_its_ceiling_denies() {
let gate = ConsentGate::for_test(
ceilings_declaring("db:drop", &[json!({"key": "test_*"})], PolicyMode::Open).await,
Arc::new(PanickingPrompter),
);
assert_eq!(
gate.decide("db:drop", "production", "Drop production", &json!({}))
.await,
Decision::Deny
);
}
#[tokio::test]
async fn ask_reaches_the_prompter_once_per_key_and_is_remembered() {
let prompter = Arc::new(CountingPrompter::allowing());
let gate = ConsentGate::for_test(
ceilings_declaring("db:drop", &[], PolicyMode::Ask).await,
prompter.clone(),
);
assert_eq!(
gate.decide("db:drop", "a", "s", &json!({})).await,
Decision::Allow
);
assert_eq!(
gate.decide("db:drop", "a", "s", &json!({})).await,
Decision::Allow
);
assert_eq!(
prompter.calls(),
1,
"the same (class, key) must not re-prompt"
);
assert_eq!(
gate.decide("db:drop", "b", "s", &json!({})).await,
Decision::Allow
);
assert_eq!(
prompter.calls(),
2,
"a different key is a different question"
);
}
#[tokio::test]
async fn a_physically_enforced_class_is_not_reachable_through_consent() {
let gate = ConsentGate::for_test(
ceilings_declaring("wasi:http", &[], PolicyMode::Open).await,
Arc::new(PanickingPrompter),
);
assert_eq!(
gate.decide("wasi:http", "api.example.com", "s", &json!({}))
.await,
Decision::Deny
);
}
#[tokio::test]
async fn a_declared_physical_class_denies_with_the_true_reason_not_undeclared() {
use crate::audit::layer::AuditWriter;
use std::sync::{Arc as StdArc, Mutex};
use tracing_subscriber::layer::SubscriberExt;
struct CapturingWriter(StdArc<Mutex<Vec<String>>>);
impl AuditWriter for CapturingWriter {
fn write_line(&self, line: &str) {
self.0.lock().unwrap().push(line.to_string());
}
}
let sink = StdArc::new(Mutex::new(Vec::new()));
let layer = crate::audit::AuditLayer::new(
CapturingWriter(sink.clone()),
crate::audit::Detail::Full,
);
let sub = tracing_subscriber::registry().with(layer);
let _guard = tracing::subscriber::set_default(sub);
let gate = ConsentGate::for_test(
ceilings_declaring("wasi:http", &[], PolicyMode::Open).await,
Arc::new(PanickingPrompter),
);
let decision = gate
.decide("wasi:http", "api.example.com", "s", &json!({}))
.await;
drop(_guard);
assert_eq!(decision, Decision::Deny);
let lines = sink.lock().unwrap().clone();
let line = lines
.iter()
.find(|l| l.contains("wasi:http"))
.unwrap_or_else(|| panic!("no audit line naming wasi:http, got {lines:?}"));
assert!(
line.contains("enforced on the boundary"),
"must carry the true reason, got: {line}"
);
assert!(
!line.contains("not declared"),
"must not claim undeclared when the manifest declares it, got: {line}"
);
}
#[tokio::test]
async fn an_empty_class_denies_even_when_the_manifest_declares_it() {
for mode in [PolicyMode::Ask, PolicyMode::Open] {
let ceilings = ceilings_declaring("", &[], mode).await;
assert!(
ceilings.contains_key(""),
"the fixture must declare the empty class, or this test is \
just the map-miss case again"
);
let gate = ConsentGate::for_test(ceilings, Arc::new(PanickingPrompter));
assert_eq!(
gate.decide("", "analytics", "s", &json!({})).await,
Decision::Deny,
"an empty class must be refused under {mode:?}"
);
}
}
#[tokio::test]
async fn a_deny_constraint_beats_an_otherwise_open_grant() {
let ceilings = ceilings_granted(
"db:drop",
&[],
act_policy::grant::CapabilityGrant {
mode: PolicyMode::Open,
allow: Vec::new(),
deny: vec![json!({"key": "production"})],
},
)
.await;
let gate = ConsentGate::for_test(ceilings, Arc::new(PanickingPrompter));
assert_eq!(
gate.decide("db:drop", "production", "s", &json!({})).await,
Decision::Deny
);
assert_eq!(
gate.decide("db:drop", "analytics", "s", &json!({})).await,
Decision::Allow,
"the deny constraint must bound the key it names and nothing else"
);
}
#[tokio::test]
async fn a_deny_mode_grant_refuses_a_declared_class_without_asking() {
let gate = ConsentGate::for_test(
ceilings_declaring("db:drop", &[], PolicyMode::Deny).await,
Arc::new(PanickingPrompter),
);
assert_eq!(
gate.decide("db:drop", "analytics", "s", &json!({})).await,
Decision::Deny
);
}
#[tokio::test]
async fn an_allowlist_grant_bounds_the_key_without_asking() {
let ceilings = ceilings_granted(
"db:drop",
&[],
act_policy::grant::CapabilityGrant {
mode: PolicyMode::Allowlist,
allow: vec![json!({"key": "test_*"})],
deny: Vec::new(),
},
)
.await;
let gate = ConsentGate::for_test(ceilings, Arc::new(PanickingPrompter));
assert_eq!(
gate.decide("db:drop", "test_scratch", "s", &json!({}))
.await,
Decision::Allow
);
assert_eq!(
gate.decide("db:drop", "production", "s", &json!({})).await,
Decision::Deny
);
}
#[tokio::test]
async fn an_ask_grant_carrying_an_allowlist_refuses_outside_it_rather_than_prompting() {
let ceilings = ceilings_granted(
"db:drop",
&[],
act_policy::grant::CapabilityGrant {
mode: PolicyMode::Ask,
allow: vec![json!({"key": "test_*"})],
deny: Vec::new(),
},
)
.await;
let gate = ConsentGate::for_test(ceilings, Arc::new(PanickingPrompter));
assert_eq!(
gate.decide("db:drop", "production", "s", &json!({})).await,
Decision::Deny
);
}
#[tokio::test]
async fn a_key_hidden_in_args_cannot_shadow_the_one_that_was_shown() {
let gate = ConsentGate::for_test(
ceilings_declaring("db:drop", &[json!({"key": "test_*"})], PolicyMode::Open).await,
Arc::new(PanickingPrompter),
);
assert_eq!(
gate.decide(
"db:drop",
"production",
"s",
&json!({"key": "test_scratch"})
)
.await,
Decision::Deny
);
}
#[test]
fn args_that_are_not_a_cbor_map_carry_no_dimensions() {
let mut text = Vec::new();
ciborium::into_writer(&"not a map", &mut text).unwrap();
assert_eq!(args_to_attrs(&text), serde_json::Value::Null);
assert_eq!(args_to_attrs(&[]), serde_json::Value::Null);
assert_eq!(args_to_attrs(&[0xff, 0xff, 0xff]), serde_json::Value::Null);
let mut map = Vec::new();
ciborium::into_writer(&json!({"table": "events"}), &mut map).unwrap();
assert_eq!(args_to_attrs(&map), json!({"table": "events"}));
}
#[tokio::test]
async fn a_declared_dimension_outside_key_is_matched_from_args() {
let gate = ConsentGate::for_test(
ceilings_declaring("db:drop", &[json!({"table": "events"})], PolicyMode::Open).await,
Arc::new(PanickingPrompter),
);
assert_eq!(
gate.decide("db:drop", "analytics", "s", &json!({"table": "events"}))
.await,
Decision::Allow
);
assert_eq!(
gate.decide("db:drop", "analytics", "s", &json!({"table": "users"}))
.await,
Decision::Deny
);
}
#[tokio::test]
async fn no_channel_degrades_to_deny() {
let gate = ConsentGate::for_test(
ceilings_declaring("db:drop", &[], PolicyMode::Ask).await,
Arc::new(act_policy::consent::DenyPrompter),
);
assert_eq!(
gate.decide("db:drop", "a", "s", &json!({})).await,
Decision::Deny
);
}
}