act_runtime/consent/gate.rs
1//! Host side of `act:consent/consent-authority` — the gate a component asks
2//! before taking an action the host cannot see it taking.
3//!
4//! ## Two layers, the same shape as `act:credentials`
5//!
6//! [`ConsentGate`] is the whole of the decision logic. It owns no wasmtime
7//! types, so ACT-CONSENT.md §4's procedure is unit-testable against real
8//! compiled ceilings with no engine, no linker and no guest.
9//!
10//! Around it sits the generated-trait bridge. `request` is an `async func` in
11//! WIT, so bindgen lowers it through `func_wrap_concurrent`: the generated
12//! [`consent_authority::HostWithStore`] method is an **associated function
13//! taking a [`wasmtime::component::Accessor`]**, not a method on `&self`, and
14//! the impl target is the `HasData` marker rather than `HostState`. The
15//! bridge reaches host state through the accessor, decodes the request, and
16//! calls the gate. `credentials.rs`'s module header explains the same shape at
17//! greater length.
18//!
19//! ## Why refusals are indistinguishable
20//!
21//! Every path here returns `deny` — undeclared, denied by grant, refused by a
22//! human, no channel to ask on. That is ACT-CONSENT.md §8.4: a component that
23//! could tell those apart could map the operator's policy by varying its
24//! requests. The distinction lives in the audit trail, which the operator
25//! reads and the component cannot.
26
27use std::collections::BTreeMap;
28use std::sync::Arc;
29
30use act_policy::provider::CompiledCeiling;
31use wasmtime::component::{HasSelf, Linker};
32
33use crate::bindings::act::consent::{consent_authority, types};
34use crate::store::HostState;
35
36/// The host's answer, straight from the WIT enum. Not a host-side copy: one
37/// type means the bridge cannot lower a verdict into the wrong variant, and
38/// §8.4's "every refusal is the same value" is a property of the type rather
39/// than of a conversion someone has to keep right.
40pub(crate) use types::Decision;
41
42/// The sub-operation recorded for a consent decision. Consent has no
43/// sub-operations — the class *is* the action — so this is a constant, and it
44/// exists only to fill the audit's action column with something.
45///
46/// It reaches the record on the two statically-decided paths. The `ask` path
47/// goes through `CapDecisionRecord::answered`, which hardcodes
48/// `action: String::new()` for every capability class, so a consent decision
49/// taken by a human is recorded with an empty action like any other. That is
50/// pre-existing shared behaviour, not something this class chose.
51const ACTION: &str = "request";
52
53/// Every consent decision is a semantic-class decision, and must never fold
54/// into the tool call's rollup — see `CapDecisionRecord::never_rollup`'s doc.
55/// A free function rather than inlining `never_rollup: true` at each of the
56/// five call sites in `decide`: it also documents, in one place, why this
57/// gate never leaves the field at its default.
58///
59/// `never_rollup` is a plain field, not a constructor parameter on `statik` /
60/// `statik_with_reason` / `answered` — those are shared with every physical
61/// provider, which must keep defaulting to `false`, so this crate sets it
62/// after construction instead of widening the shared constructors' surface
63/// for one caller.
64fn mark_never_rollup(
65 mut record: crate::audit::CapDecisionRecord,
66) -> crate::audit::CapDecisionRecord {
67 record.never_rollup = true;
68 record
69}
70
71/// One component run's semantic-authorization gate.
72///
73/// Assembled per call from [`HostState`], which is why the shared pieces are
74/// `Arc`s rather than owned: `cache` in particular must be the run's one
75/// cache, or §5's "remember the decision for at least the component run"
76/// would hold only for the length of a single call, and a component could
77/// wear a human down by asking again.
78pub(crate) struct ConsentGate {
79 /// Every declared class the host does not wire interception for. **A miss
80 /// here is what "undeclared" means** — see [`ConsentGate::decide`].
81 ///
82 /// `create_store` builds it as every resolved ceiling minus
83 /// `PHYSICALLY_INTERCEPTED`, so the four classes the host enforces by
84 /// interception are absent and a consent request naming one of them is
85 /// refused. That is deliberate: those already have a gate on the
86 /// boundary, and a second door that could answer "allow" for them would
87 /// be either redundant or a way around the first.
88 semantic_ceilings: Arc<BTreeMap<String, Arc<dyn CompiledCeiling>>>,
89 prompter: Arc<dyn act_policy::consent::ConsentPrompter>,
90 cache: Arc<act_policy::consent::DecisionCache>,
91 /// The reference the operator supplied for this component, for the prompt
92 /// line. Never a name the guest chose (ACT-CONSENT.md §5).
93 component: String,
94}
95
96impl ConsentGate {
97 fn from_accessor(
98 accessor: &wasmtime::component::Accessor<HostState, HasSelf<HostState>>,
99 ) -> Self {
100 accessor.with(|mut access| {
101 let state: &mut HostState = access.get();
102 Self {
103 semantic_ceilings: state.semantic_ceilings.clone(),
104 prompter: state.consent_prompter.clone(),
105 cache: state.consent_cache.clone(),
106 component: state.component_ref.clone(),
107 }
108 })
109 }
110
111 /// The decision procedure of ACT-CONSENT.md §4, in order.
112 ///
113 /// Step 1 has two clauses — the class is empty, **or** it is absent from
114 /// the component's declared capabilities — and both run before anything
115 /// else and before the prompter exists as a possibility, so a refusal
116 /// depends on the manifest and on nothing else.
117 ///
118 /// A class the component never declared has no ceiling in
119 /// `semantic_ceilings` at all: that deny is on the **map miss**, not on a
120 /// fallback `resolve(class, None, ..)`. Resolving a ceiling for it would
121 /// reach the same verdict and lose the reason, which §7.2 requires the audit
122 /// to carry. The empty class gets its own check because the map miss does
123 /// not reliably cover it — see the comment on that check.
124 ///
125 /// Steps 2 to 4 are the compiled ceiling's own `classify_explained`: deny
126 /// constraints first, then the declaration, then the grant mode. Consent
127 /// adds nothing to that order — it is the same intersection every physical
128 /// class is decided by, which is the whole point of routing semantic
129 /// classes through the ordinary policy surface.
130 pub(crate) async fn decide(
131 &self,
132 class: &str,
133 key: &str,
134 summary: &str,
135 args: &serde_json::Value,
136 ) -> Decision {
137 use crate::audit::{CapDecisionRecord, Decision4, emit_cap_decision};
138
139 // §4 step 1, first clause: an empty class. Checked rather than left to
140 // the map lookup below, which would catch it only for as long as `""`
141 // happens to be absent. It need not be: §3.3 forbids declaring a
142 // non-concrete class, but nothing enforces §3.3, so a manifest with
143 // `[std.capabilities.""]` gets a real ceiling row from
144 // `resolve_ceilings` and would then run the ordinary grant path — up
145 // to and including asking a human a question that names no class.
146 if class.is_empty() {
147 emit_cap_decision(&mark_never_rollup(CapDecisionRecord::statik_with_reason(
148 class,
149 key,
150 ACTION,
151 Decision4::Deny,
152 "deny",
153 None,
154 Some("empty capability class"),
155 )));
156 return Decision::Deny;
157 }
158
159 let Some(ceiling) = self.semantic_ceilings.get(class) else {
160 // Two different reasons produce the identical map miss.
161 // `PHYSICALLY_INTERCEPTED` classes (wasi:http and friends) are
162 // never in `semantic_ceilings` at all, declared or not — see that
163 // constant's doc — so a request naming one lands here even when
164 // the manifest genuinely declares it. "class not declared" would
165 // be false in that case: the class is very much declared, it is
166 // just enforced somewhere else. §7.2 requires the audit reason to
167 // be true, so the two causes get two reasons; the decision itself
168 // (deny, without consulting anyone) is identical either way.
169 let reason = if act_policy::ceilings::PHYSICALLY_INTERCEPTED.contains(&class) {
170 "class is enforced on the boundary, not through consent"
171 } else {
172 "class not declared in act:component"
173 };
174 emit_cap_decision(&mark_never_rollup(CapDecisionRecord::statik_with_reason(
175 class,
176 key,
177 ACTION,
178 Decision4::Deny,
179 "deny",
180 None,
181 Some(reason),
182 )));
183 return Decision::Deny;
184 };
185
186 let op = act_policy::provider::ResourceOp {
187 cap_id: class.to_string(),
188 key: key.to_string(),
189 action: ACTION.to_string(),
190 attrs: args.clone(),
191 };
192 let explained = ceiling.classify_explained(&op);
193 let mode = ceiling.effective_mode().to_string();
194
195 match explained.decision {
196 act_policy::Decision::Allow => {
197 emit_cap_decision(&mark_never_rollup(CapDecisionRecord::statik(
198 class,
199 key,
200 ACTION,
201 Decision4::Allow,
202 &mode,
203 explained.rule,
204 )));
205 Decision::Allow
206 }
207 act_policy::Decision::Deny => {
208 emit_cap_decision(&mark_never_rollup(CapDecisionRecord::statik(
209 class,
210 key,
211 ACTION,
212 Decision4::Deny,
213 &mode,
214 explained.rule,
215 )));
216 Decision::Deny
217 }
218 // Deliberately silent until the verdict exists; the record is emitted
219 // below, mirroring `fs_policy::resolve_ask`.
220 act_policy::Decision::Ask => {
221 let has_channel = self.prompter.has_channel();
222 let allowed = self
223 .cache
224 .decide_cached(
225 &*self.prompter,
226 act_policy::consent::ConsentAsk {
227 cap_id: class.to_string(),
228 key: key.to_string(),
229 summary: crate::consent::prompt_line(
230 Some(&self.component),
231 class,
232 key,
233 summary,
234 ),
235 },
236 )
237 .await;
238 emit_cap_decision(&mark_never_rollup(CapDecisionRecord::answered(
239 class,
240 key,
241 allowed,
242 has_channel,
243 )));
244 if allowed {
245 Decision::Allow
246 } else {
247 Decision::Deny
248 }
249 }
250 }
251 }
252
253 #[cfg(test)]
254 fn for_test(
255 semantic_ceilings: BTreeMap<String, Arc<dyn CompiledCeiling>>,
256 prompter: Arc<dyn act_policy::consent::ConsentPrompter>,
257 ) -> Self {
258 Self {
259 semantic_ceilings: Arc::new(semantic_ceilings),
260 prompter,
261 cache: Arc::new(act_policy::consent::DecisionCache::new()),
262 component: "./test.wasm".to_string(),
263 }
264 }
265}
266
267/// Read the request's `args` as policy dimensions.
268///
269/// Anything that is not a CBOR map carries no dimensions rather than being an
270/// error (ACT-CONSENT.md §2.2): `key` still matches, and the guest learns
271/// nothing from a malformed blob that it would not have learned from an empty
272/// one. Undecodable bytes take the same path — refusing here would turn a
273/// component's encoding bug into a distinguishable outcome, which §8.4
274/// forbids.
275fn args_to_attrs(args: &[u8]) -> serde_json::Value {
276 match act_types::cbor::cbor_to_json(args) {
277 Ok(v @ serde_json::Value::Object(_)) => v,
278 _ => serde_json::Value::Null,
279 }
280}
281
282// ── WIT bridge ─────────────────────────────────────────────────────────────
283
284/// Both interfaces get a `Host` impl on `&mut HostState`, and on nothing
285/// else, for the same reason `act:credentials` does: `skip_mut_forwarding_impls`
286/// suppresses bindgen's blanket `&mut T` forwarding impls, and `add_to_linker`
287/// requires `for<'a> D::Data<'a>: Host` — which is `&'a mut HostState` under
288/// `HasSelf<HostState>`, and that is the whole of the requirement.
289impl consent_authority::Host for &mut HostState {}
290impl types::Host for &mut HostState {}
291
292/// Register both `act:consent` instances in the linker.
293///
294/// Both, not one: `consent-authority` uses types from `types`, so the
295/// elaborated world imports both instances, and a guest importing
296/// `act:consent/consent-authority` fails instantiation on an unregistered
297/// `act:consent/types@0.1.0`. The interface carries no functions, but the
298/// instance must still exist.
299pub(crate) fn add_to_linker(linker: &mut Linker<HostState>) -> anyhow::Result<()> {
300 types::add_to_linker::<HostState, HasSelf<HostState>>(linker, |s| s)
301 .map_err(|e| anyhow::anyhow!("failed to add act:consent/types to linker: {e}"))?;
302 consent_authority::add_to_linker::<HostState, HasSelf<HostState>>(linker, |s| s).map_err(
303 |e| anyhow::anyhow!("failed to add act:consent/consent-authority to linker: {e}"),
304 )?;
305 Ok(())
306}
307
308impl consent_authority::HostWithStore<HostState> for HasSelf<HostState> {
309 /// `meta` is accepted and not read. Its job in ACT-CONSENT.md §7.1 is to
310 /// anchor the decision to a session, and the record this emits is already
311 /// anchored: `emit_cap_decision` writes an event inside the in-flight
312 /// `act.tool_call` span, which carries `act.session.id`. It never selects
313 /// policy — that is keyed on the class and the key alone — so reading it
314 /// here could only widen what a guest-supplied value can influence.
315 async fn request(
316 accessor: &wasmtime::component::Accessor<HostState, Self>,
317 req: consent_authority::ConsentRequest,
318 _meta: consent_authority::Metadata,
319 ) -> Decision {
320 let gate = ConsentGate::from_accessor(accessor);
321 let attrs = args_to_attrs(&req.args);
322 gate.decide(&req.class, &req.key, &req.summary, &attrs)
323 .await
324 }
325}
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use act_policy::consent::{ConsentAsk, ConsentPrompter};
330 use act_policy::grant::PolicyMode;
331 use serde_json::json;
332 use std::collections::BTreeMap;
333 use std::sync::Arc;
334 use std::sync::atomic::{AtomicUsize, Ordering};
335
336 /// A prompter that must never run. Its `decide` panics, and that panic is
337 /// the assertion: a test using it asserts the operator was not consulted.
338 struct PanickingPrompter;
339
340 #[async_trait::async_trait]
341 impl ConsentPrompter for PanickingPrompter {
342 async fn decide(&self, ask: &ConsentAsk) -> bool {
343 panic!("the operator must not be consulted, but was asked: {ask:?}");
344 }
345 }
346
347 struct CountingPrompter {
348 allow: bool,
349 calls: AtomicUsize,
350 }
351
352 impl CountingPrompter {
353 fn allowing() -> Self {
354 Self {
355 allow: true,
356 calls: AtomicUsize::new(0),
357 }
358 }
359
360 fn calls(&self) -> usize {
361 self.calls.load(Ordering::SeqCst)
362 }
363 }
364
365 #[async_trait::async_trait]
366 impl ConsentPrompter for CountingPrompter {
367 async fn decide(&self, _ask: &ConsentAsk) -> bool {
368 self.calls.fetch_add(1, Ordering::SeqCst);
369 self.allow
370 }
371 }
372
373 /// One declared semantic class under a bare grant of `mode`.
374 async fn ceilings_declaring(
375 class: &str,
376 declared: &[serde_json::Value],
377 mode: PolicyMode,
378 ) -> BTreeMap<String, Arc<dyn act_policy::provider::CompiledCeiling>> {
379 ceilings_granted(
380 class,
381 declared,
382 act_policy::grant::CapabilityGrant {
383 mode,
384 allow: Vec::new(),
385 deny: Vec::new(),
386 },
387 )
388 .await
389 }
390
391 /// One declared semantic class, resolved through the real provider
392 /// registry against `grant` — the same call `create_store` makes, so
393 /// these tests hold the ceiling the host would actually build rather
394 /// than a stand-in.
395 ///
396 /// The `PHYSICALLY_INTERCEPTED` filter below deliberately mirrors
397 /// `create_store`, which is the copy that actually enforces "a
398 /// physically-enforced class is not reachable through consent". This one
399 /// only reproduces the shape of the map the gate is handed.
400 async fn ceilings_granted(
401 class: &str,
402 declared: &[serde_json::Value],
403 grant: act_policy::grant::CapabilityGrant,
404 ) -> BTreeMap<String, Arc<dyn act_policy::provider::CompiledCeiling>> {
405 use act_policy::grant::{GrantPolicy, PolicyMode};
406
407 let policy = GrantPolicy {
408 // Deliberately the tighter fixture: `PolicyMode::Deny`, not
409 // `grant.mode`. This helper's job is to hand back one class
410 // resolved under `grant` — the physical classes (fs/http/
411 // sockets/credentials) are filtered out below regardless, so
412 // resolving them under the test's own `grant.mode` too was
413 // configuring more than any test here describes or depends on.
414 default: PolicyMode::Deny,
415 entries: BTreeMap::from([(class.to_string(), grant)]),
416 };
417 let declared = BTreeMap::from([(class.to_string(), declared.to_vec())]);
418 let all = act_policy::ceilings::resolve_ceilings(
419 &act_policy::provider::ProviderRegistry::with_builtins(),
420 &declared,
421 &policy,
422 )
423 .await
424 .expect("resolve");
425 all.into_iter()
426 .filter(|(id, _)| !act_policy::ceilings::PHYSICALLY_INTERCEPTED.contains(&id.as_str()))
427 .collect()
428 }
429
430 #[tokio::test]
431 async fn an_undeclared_class_denies_without_reaching_the_prompter() {
432 // ACT-CONSENT.md §4 step 1: the refusal must not depend on anything but
433 // the manifest, and the operator must not be consulted. A prompter that
434 // panics proves it was never called.
435 let gate = ConsentGate::for_test(BTreeMap::new(), Arc::new(PanickingPrompter));
436 let decision = gate
437 .decide(
438 "db:drop",
439 "analytics",
440 "Drop database \"analytics\"",
441 &json!({}),
442 )
443 .await;
444 assert_eq!(decision, Decision::Deny);
445 }
446
447 #[tokio::test]
448 async fn a_declared_class_outside_its_ceiling_denies() {
449 let gate = ConsentGate::for_test(
450 ceilings_declaring("db:drop", &[json!({"key": "test_*"})], PolicyMode::Open).await,
451 Arc::new(PanickingPrompter),
452 );
453 assert_eq!(
454 gate.decide("db:drop", "production", "Drop production", &json!({}))
455 .await,
456 Decision::Deny
457 );
458 }
459
460 // NOTE: this pins the caching *policy* (same gate, same `(class, key)`,
461 // one prompt) but not the *sharing* it depends on: `ConsentGate::for_test`
462 // hands every gate its own fresh `DecisionCache`, so this test cannot
463 // distinguish "the cache is the run's one cache" from "`from_accessor`
464 // builds a new cache per gate" — both would pass it identically, because
465 // it only ever calls `.decide()` on one already-constructed `gate`. That
466 // property — whether two *separately constructed* gates (as two real
467 // `request` calls produce) still share one cache — is proven end to end
468 // in `act-cli/tests/consent_e2e.rs`'s
469 // `a_repeated_question_is_asked_once_not_per_call` and
470 // `a_different_key_is_still_asked_about`, against a real MCP elicitation
471 // round-trip. Reproducing that here would mean rebuilding `create_store`'s
472 // full `HostState` (WASI, HTTP client, every ceiling) inside a unit test —
473 // a second copy of exactly the kind of construction this module's own
474 // docs warn drifts.
475 #[tokio::test]
476 async fn ask_reaches_the_prompter_once_per_key_and_is_remembered() {
477 let prompter = Arc::new(CountingPrompter::allowing());
478 let gate = ConsentGate::for_test(
479 ceilings_declaring("db:drop", &[], PolicyMode::Ask).await,
480 prompter.clone(),
481 );
482 assert_eq!(
483 gate.decide("db:drop", "a", "s", &json!({})).await,
484 Decision::Allow
485 );
486 assert_eq!(
487 gate.decide("db:drop", "a", "s", &json!({})).await,
488 Decision::Allow
489 );
490 assert_eq!(
491 prompter.calls(),
492 1,
493 "the same (class, key) must not re-prompt"
494 );
495 assert_eq!(
496 gate.decide("db:drop", "b", "s", &json!({})).await,
497 Decision::Allow
498 );
499 assert_eq!(
500 prompter.calls(),
501 2,
502 "a different key is a different question"
503 );
504 }
505
506 #[tokio::test]
507 async fn a_physically_enforced_class_is_not_reachable_through_consent() {
508 // wasi:http is declared here and still refused: it is enforced on the
509 // boundary, and consent must not become a second door that can answer
510 // "allow" for it.
511 //
512 // Note what actually holds this in production: `create_store`'s own
513 // `PHYSICALLY_INTERCEPTED` filter, which `ceilings_declaring`
514 // deliberately mirrors so these tests see the map the host would
515 // build. Deleting the filter in `store.rs` would not turn this test
516 // red — the e2e tests are the layer that can see that. What this
517 // pins is that the gate adds no bypass of its own on top of the
518 // filtered map.
519 let gate = ConsentGate::for_test(
520 ceilings_declaring("wasi:http", &[], PolicyMode::Open).await,
521 Arc::new(PanickingPrompter),
522 );
523 assert_eq!(
524 gate.decide("wasi:http", "api.example.com", "s", &json!({}))
525 .await,
526 Decision::Deny
527 );
528 }
529
530 #[tokio::test]
531 async fn a_declared_physical_class_denies_with_the_true_reason_not_undeclared() {
532 // M2: the test above pins the decision (Deny); this pins the audit
533 // *reason*. `wasi:http` is declared here — same fixture as above —
534 // and still hits the map miss, because it is physically intercepted
535 // and therefore never in `semantic_ceilings`. Before this fix, that
536 // miss was unconditionally audited as "class not declared in
537 // act:component", which is false: the manifest genuinely declares
538 // this class, it's just enforced somewhere else.
539 use crate::audit::layer::AuditWriter;
540 use std::sync::{Arc as StdArc, Mutex};
541 use tracing_subscriber::layer::SubscriberExt;
542
543 struct CapturingWriter(StdArc<Mutex<Vec<String>>>);
544 impl AuditWriter for CapturingWriter {
545 fn write_line(&self, line: &str) {
546 self.0.lock().unwrap().push(line.to_string());
547 }
548 }
549
550 let sink = StdArc::new(Mutex::new(Vec::new()));
551 let layer = crate::audit::AuditLayer::new(
552 CapturingWriter(sink.clone()),
553 crate::audit::Detail::Full,
554 );
555 let sub = tracing_subscriber::registry().with(layer);
556 let _guard = tracing::subscriber::set_default(sub);
557
558 let gate = ConsentGate::for_test(
559 ceilings_declaring("wasi:http", &[], PolicyMode::Open).await,
560 Arc::new(PanickingPrompter),
561 );
562 let decision = gate
563 .decide("wasi:http", "api.example.com", "s", &json!({}))
564 .await;
565 drop(_guard);
566
567 assert_eq!(decision, Decision::Deny);
568 let lines = sink.lock().unwrap().clone();
569 let line = lines
570 .iter()
571 .find(|l| l.contains("wasi:http"))
572 .unwrap_or_else(|| panic!("no audit line naming wasi:http, got {lines:?}"));
573 assert!(
574 line.contains("enforced on the boundary"),
575 "must carry the true reason, got: {line}"
576 );
577 assert!(
578 !line.contains("not declared"),
579 "must not claim undeclared when the manifest declares it, got: {line}"
580 );
581 }
582
583 #[tokio::test]
584 async fn an_empty_class_denies_even_when_the_manifest_declares_it() {
585 // ACT-CONSENT.md §4 step 1 has two clauses: absent from the declared
586 // capabilities, *or empty*. The map miss covers the first. This is the
587 // second, and it needs a check of its own: §3.3 forbids declaring such
588 // a class, but nothing enforces §3.3 -- `act-build validate` checks
589 // only `name` and `version` -- so a manifest carrying
590 // `[std.capabilities.""]` reaches `resolve_ceilings`, which hands the
591 // empty key a real ceiling row like any other.
592 //
593 // The ceilings below therefore *declare* `""`. Under a map-miss-only
594 // implementation the `ask` case would put the request to a human as
595 // `./test.wasm requests : analytics` -- a question naming no class at
596 // all -- and the `open` case would allow it outright.
597 for mode in [PolicyMode::Ask, PolicyMode::Open] {
598 let ceilings = ceilings_declaring("", &[], mode).await;
599 assert!(
600 ceilings.contains_key(""),
601 "the fixture must declare the empty class, or this test is \
602 just the map-miss case again"
603 );
604 let gate = ConsentGate::for_test(ceilings, Arc::new(PanickingPrompter));
605 assert_eq!(
606 gate.decide("", "analytics", "s", &json!({})).await,
607 Decision::Deny,
608 "an empty class must be refused under {mode:?}"
609 );
610 }
611 }
612
613 #[tokio::test]
614 async fn a_deny_constraint_beats_an_otherwise_open_grant() {
615 // §4 step 2: a deny constraint in the effective grant wins, and it
616 // wins before the mode is reached -- so `open` does not rescue a key
617 // the operator named in `deny`.
618 let ceilings = ceilings_granted(
619 "db:drop",
620 &[],
621 act_policy::grant::CapabilityGrant {
622 mode: PolicyMode::Open,
623 allow: Vec::new(),
624 deny: vec![json!({"key": "production"})],
625 },
626 )
627 .await;
628 let gate = ConsentGate::for_test(ceilings, Arc::new(PanickingPrompter));
629 assert_eq!(
630 gate.decide("db:drop", "production", "s", &json!({})).await,
631 Decision::Deny
632 );
633 assert_eq!(
634 gate.decide("db:drop", "analytics", "s", &json!({})).await,
635 Decision::Allow,
636 "the deny constraint must bound the key it names and nothing else"
637 );
638 }
639
640 #[tokio::test]
641 async fn a_deny_mode_grant_refuses_a_declared_class_without_asking() {
642 // §4 step 4, first bullet: mode `deny` refuses, and refuses without
643 // consulting anyone.
644 let gate = ConsentGate::for_test(
645 ceilings_declaring("db:drop", &[], PolicyMode::Deny).await,
646 Arc::new(PanickingPrompter),
647 );
648 assert_eq!(
649 gate.decide("db:drop", "analytics", "s", &json!({})).await,
650 Decision::Deny
651 );
652 }
653
654 #[tokio::test]
655 async fn an_allowlist_grant_bounds_the_key_without_asking() {
656 // §4 step 4: allowlist allows a matching request and denies the rest,
657 // and neither outcome consults a human.
658 let ceilings = ceilings_granted(
659 "db:drop",
660 &[],
661 act_policy::grant::CapabilityGrant {
662 mode: PolicyMode::Allowlist,
663 allow: vec![json!({"key": "test_*"})],
664 deny: Vec::new(),
665 },
666 )
667 .await;
668 let gate = ConsentGate::for_test(ceilings, Arc::new(PanickingPrompter));
669 assert_eq!(
670 gate.decide("db:drop", "test_scratch", "s", &json!({}))
671 .await,
672 Decision::Allow
673 );
674 assert_eq!(
675 gate.decide("db:drop", "production", "s", &json!({})).await,
676 Decision::Deny
677 );
678 }
679
680 #[tokio::test]
681 async fn an_ask_grant_carrying_an_allowlist_refuses_outside_it_rather_than_prompting() {
682 // §4 step 4, last sentence: a single approval must not be able to
683 // authorize what the operator's own allowlist excluded, so a request
684 // outside it is refused rather than put to a human.
685 let ceilings = ceilings_granted(
686 "db:drop",
687 &[],
688 act_policy::grant::CapabilityGrant {
689 mode: PolicyMode::Ask,
690 allow: vec![json!({"key": "test_*"})],
691 deny: Vec::new(),
692 },
693 )
694 .await;
695 let gate = ConsentGate::for_test(ceilings, Arc::new(PanickingPrompter));
696 assert_eq!(
697 gate.decide("db:drop", "production", "s", &json!({})).await,
698 Decision::Deny
699 );
700 }
701
702 #[tokio::test]
703 async fn a_key_hidden_in_args_cannot_shadow_the_one_that_was_shown() {
704 // §8.1: there is exactly one key, and it is the one a human was shown
705 // and the audit recorded. The gate must build `ResourceOp::key` from
706 // the request and `attrs` from `args` -- swapped, a component would
707 // pass an in-ceiling key in `args` and act on a different subject.
708 let gate = ConsentGate::for_test(
709 ceilings_declaring("db:drop", &[json!({"key": "test_*"})], PolicyMode::Open).await,
710 Arc::new(PanickingPrompter),
711 );
712 assert_eq!(
713 gate.decide(
714 "db:drop",
715 "production",
716 "s",
717 &json!({"key": "test_scratch"})
718 )
719 .await,
720 Decision::Deny
721 );
722 }
723
724 #[test]
725 fn args_that_are_not_a_cbor_map_carry_no_dimensions() {
726 // §2.2: not an error -- `key` still matches. A component whose args
727 // encoding is wrong must not get a distinguishable outcome (§8.4).
728 let mut text = Vec::new();
729 ciborium::into_writer(&"not a map", &mut text).unwrap();
730 assert_eq!(args_to_attrs(&text), serde_json::Value::Null);
731 assert_eq!(args_to_attrs(&[]), serde_json::Value::Null);
732 assert_eq!(args_to_attrs(&[0xff, 0xff, 0xff]), serde_json::Value::Null);
733
734 let mut map = Vec::new();
735 ciborium::into_writer(&json!({"table": "events"}), &mut map).unwrap();
736 assert_eq!(args_to_attrs(&map), json!({"table": "events"}));
737 }
738
739 #[tokio::test]
740 async fn a_declared_dimension_outside_key_is_matched_from_args() {
741 // §3.2: `key` resolves from the request, every other dimension from
742 // `args`. Without this the declared ceiling could only ever narrow on
743 // one axis.
744 let gate = ConsentGate::for_test(
745 ceilings_declaring("db:drop", &[json!({"table": "events"})], PolicyMode::Open).await,
746 Arc::new(PanickingPrompter),
747 );
748 assert_eq!(
749 gate.decide("db:drop", "analytics", "s", &json!({"table": "events"}))
750 .await,
751 Decision::Allow
752 );
753 assert_eq!(
754 gate.decide("db:drop", "analytics", "s", &json!({"table": "users"}))
755 .await,
756 Decision::Deny
757 );
758 }
759
760 #[tokio::test]
761 async fn no_channel_degrades_to_deny() {
762 let gate = ConsentGate::for_test(
763 ceilings_declaring("db:drop", &[], PolicyMode::Ask).await,
764 Arc::new(act_policy::consent::DenyPrompter),
765 );
766 assert_eq!(
767 gate.decide("db:drop", "a", "s", &json!({})).await,
768 Decision::Deny
769 );
770 }
771}