Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions crates/openspine-kernel/src/disclosure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,18 @@ use std::collections::BTreeSet;
use jiff::Timestamp;
use openspine_schemas::action::ActionId;
use openspine_schemas::artifact::{ArtifactRef, Lifecycle};
use openspine_schemas::briefcase::{BriefcaseSection, VisibilityClass};
use openspine_schemas::briefcase::{BriefcaseSection, CounterpartyRef, VisibilityClass};
use openspine_schemas::disclosure_policy::{
check_egress, generalize_query, ClassifiedBriefcaseItem, DisclosureCarveOut, DisclosureClass,
DisclosurePolicy, DisclosurePolicyKey, DisclosureProvenance, OutboundQuery,
OwnerQuestionEscalation, PreparedQuery, PreparedQueryRef,
OwnerQuestionEscalation, PreparedQuery, PreparedQueryRef, RecipientIdentity,
};
use openspine_schemas::egress::EgressClass;
use openspine_schemas::escalation::EscalationEvent;
use openspine_schemas::grant::TaskGrant;
use openspine_schemas::identity::RelationshipKind;
use openspine_schemas::ids::IdentityRef;
use openspine_schemas::provenance::ProvenanceOrigin;
use openspine_schemas::standing_rule::{BudgetWindow, StandingRuleManifest};
use serde_json::Value;
use ulid::Ulid;
Expand All @@ -49,6 +51,10 @@ pub(crate) enum DisclosureError {
UnratedEgress(ActionId),
UnclassifiedSection(String),
BudgetExhausted(String),
/// D-174 / spec #220: the origin-closure stage blocked cross-identity data
/// whose typed origin lies outside the bound recipient's closure and is
/// unauthorized by any grant provenance caveat.
CrossIdentityBlocked,
Store(StoreError),
}

Expand All @@ -68,6 +74,8 @@ pub(crate) use self::preparation::*;
#[cfg(test)]
mod disclosure_messaging_tests;
#[cfg(test)]
mod disclosure_origin_closure_tests;
#[cfg(test)]
mod disclosure_regression_tests;
#[cfg(test)]
mod disclosure_tests;
83 changes: 82 additions & 1 deletion crates/openspine-kernel/src/disclosure/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,48 @@ pub(crate) async fn enforce_disclosure_egress(
) -> Result<EnforcedDisclosure, DisclosureError> {
let egress_class = trusted_egress_class(&request.action_id)
.ok_or_else(|| DisclosureError::UnratedEgress(request.action_id.clone()))?;
// Resolve the typed recipient this grant's task is bound to reach from the
// kernel-owned briefcase (mirrors the `is_counterparty_erased` precedent).
// An unresolved counterparty — or no briefcase — is fail-closed: the origin
// closure then matches nothing. Resolved uniformly here so every dispatch
// origin (worker-requested and kernel-origin/proactive) inherits the
// identical check through this one chokepoint — no second ungated path.
let recipient = match state
.store
.find_briefcase(grant.id)
.map_err(DisclosureError::Store)?
{
Some(briefcase) => match briefcase.task_shape.counterparty {
CounterpartyRef::Bound { identity_id, .. } => RecipientIdentity::Counterparty {
identity: IdentityRef::from(identity_id),
},
CounterpartyRef::Unresolved { .. } => RecipientIdentity::Unresolved,
},
None => RecipientIdentity::Unresolved,
};
// Pre-resolve the grant-authorized origin set (D-174 widening). The pure
// core stays grant-free and only tests membership. The v1 owner grant
// carries no `ProvenanceLabelAllowlist` caveat, so every present origin is
// authorized and the closure is dormant; a worker sub-grant's empty caveat
// authorizes none, closing the origin set strictly.
let mut authorized_origins: Vec<ProvenanceOrigin> = Vec::new();
for item in &request.provenance.items {
if let Some(origin) = &item.origin {
if !authorized_origins.contains(origin)
&& openspine_schemas::grant_chain::effectively_allows_provenance_label(
grant, origin,
)
{
authorized_origins.push(origin.clone());
}
}
}
let query = OutboundQuery::from_private_context(
&request.raw_query,
&request.sensitive_terms,
egress_class,
request.provenance,
recipient,
);
let now = Timestamp::now();
let all_policies = state
Expand Down Expand Up @@ -135,7 +172,7 @@ pub(crate) async fn enforce_disclosure_egress(
}
let blocked_query_digest =
openspine_schemas::digest::digest_of_bytes(query.generalized_query.as_bytes());
match check_egress(request.relationship, query, &policies) {
match check_egress(request.relationship, query, &policies, &authorized_origins) {
openspine_schemas::disclosure_policy::DisclosureGateDecision::Allow { query } => {
Ok(EnforcedDisclosure {
query,
Expand Down Expand Up @@ -171,6 +208,50 @@ pub(crate) async fn enforce_disclosure_egress(
.map_err(DisclosureError::Store)?;
Err(DisclosureError::Blocked(escalation))
}
openspine_schemas::disclosure_policy::DisclosureGateDecision::CrossIdentityBlock {
origin,
recipient,
disclosure_class,
egress_class: blocked_egress,
} => {
cancel_reservations(&state.store, &reservations);
// Auditor (D-174): the block is reconstructible from typed origin +
// sensitivity + recipient + egress class + relationship. Detail is
// kernel-side only; the worker sees the generic denial mapped in
// api/actions.rs, never these internals.
let detail = format!(
"cross-identity egress blocked: origin={origin:?} recipient={recipient:?} class={disclosure_class:?} egress={blocked_egress:?} relationship={:?}",
request.relationship
);
state
.store
.append_audit(
"disclosure.cross_identity_blocked",
Some(&request.action_id),
None,
Some(&detail),
Some(grant.id),
&[],
&[],
)
.map_err(DisclosureError::Store)?;
// Inform the owner (AD-133) but mint no disclosure pending question:
// unlike a coverage block, a cross-identity block is not resolved by
// a relationship/class "/disclosure allow" — widening an origin is a
// grant-caveat decision, never a runtime owner answer.
let event = EscalationEvent::owner_question(
grant.id,
format!(
"Blocked outbound {disclosure_class:?} data whose origin is outside the recipient's identity closure ({blocked_egress:?})."
),
grant.thread_id.clone(),
now,
);
route_escalation(state, grant, &event)
.await
.map_err(DisclosureError::Store)?;
Err(DisclosureError::CrossIdentityBlocked)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
//! Origin-closure (D-174 / spec #220) dispatch-integration regression tests.
//!
//! These exercise the closure at `enforce_disclosure_egress` — the single
//! connector-agnostic chokepoint EVERY dispatch origin funnels through
//! (worker-requested via `mediate_and_dispatch_action`, kernel-origin/proactive
//! via `..._kernel_origin`). The origin-symmetry of that chokepoint — that both
//! origins reach it with no second ungated path — is proven in
//! `api/disclosure_origin_tests`; the origin-closure stage lives inside the same
//! function, so it is enforced identically regardless of dispatch origin.
//!
//! The stage runs ONLY after disclosure coverage passes, so each case seeds a
//! covering (Client, Internal, Search) policy first, then varies origin ×
//! recipient × grant caveat. The closure is dormant on the v1 single-owner
//! owner grant (which carries no `ProvenanceLabelAllowlist` caveat, so every
//! origin is authorized) and strict once a grant carries a narrowing caveat —
//! exactly the shape a counterparty-scoped worker sub-grant adopts (user
//! story 10). The worker-facing fixed generic denial is covered by
//! `disclosure_regression_tests::worker_denial_has_no_debug_leak`; here we
//! assert the kernel-side outcome plus the reconstructible audit row.

use super::*;
use crate::api::dispatch_tests::{
insert_bound_briefcase_with_sections, mint_grant_with_selection_token,
};
use crate::test_support::fixtures::test_state_with_telegram;

/// `insert_bound_briefcase_with_sections` binds this counterparty id as the
/// recipient, so the origin closure compares each item's origin against it.
const RECIPIENT_ID: u128 = 11;

fn state() -> crate::pipeline::AppState {
test_state_with_telegram(crate::telegram::TelegramConnector::new(
"bottest-token".to_string(),
))
}

fn counterparty(id: u128) -> ProvenanceOrigin {
ProvenanceOrigin::Counterparty {
identity: IdentityRef::from(Ulid::from(id)),
}
}

fn internal_item(origin: ProvenanceOrigin) -> ClassifiedBriefcaseItem {
ClassifiedBriefcaseItem {
item_ref: openspine_schemas::artifact::ArtifactRef {
digest: openspine_schemas::digest::digest_of_bytes(b"payload"),
schema_version: 1,
},
disclosure_class: DisclosureClass::Internal,
origin: Some(origin),
}
}

fn request(origin: ProvenanceOrigin) -> DisclosureRequest {
DisclosureRequest {
raw_query: "reply body".to_string(),
sensitive_terms: BTreeSet::new(),
action_id: ActionId::new("web.search"),
relationship: RelationshipKind::Client,
provenance: DisclosureProvenance {
items: vec![internal_item(origin)],
},
}
}

/// Append the D-174 narrowing caveat a counterparty-scoped sub-grant carries.
/// `enforce_disclosure_egress` reads the caveat chain (the gate verified the MAC
/// upstream), so appending to the sealed tip is faithful for this seam test.
fn narrow_to(grant: &mut TaskGrant, origins: Vec<ProvenanceOrigin>) {
grant
.chain
.last_mut()
.unwrap()
.added_caveats
.push(openspine_schemas::grant_chain::Caveat::ProvenanceLabelAllowlist { origins });
}

/// Seed a covering (Client, Internal, Search) policy and a grant whose briefcase
/// is bound to the recipient counterparty, so only the origin closure varies.
async fn seed(state: &crate::pipeline::AppState) -> TaskGrant {
let now = Timestamp::now();
record_owner_answer(
&state.store,
DisclosurePolicyKey {
relationship: RelationshipKind::Client,
disclosure_class: DisclosureClass::Internal,
},
EgressClass::Search,
vec![],
now,
)
.unwrap();
let (grant, _) = mint_grant_with_selection_token(
state,
&["web.search"],
now + std::time::Duration::from_secs(120),
);
insert_bound_briefcase_with_sections(state, &grant, RelationshipKind::Client, vec![]);
grant
}

/// A counterparty-X datum is blocked from egress to the bound recipient
/// counterparty Y once the grant carries the narrowing provenance caveat, even
/// though (Client, Internal, Search) coverage passes. The block is
/// reconstructible from the typed origin + sensitivity + recipient + egress
/// class recorded in the `disclosure.cross_identity_blocked` audit row (Auditor
/// story). Closes Bell's cross-counterparty leak.
#[tokio::test]
async fn counterparty_origin_blocked_from_a_different_recipient() {
let state = state();
let mut grant = seed(&state).await;
narrow_to(&mut grant, vec![]);
let result = enforce_disclosure_egress(&state, &grant, request(counterparty(22))).await;
assert!(result.is_err(), "cross-counterparty egress must be blocked");
assert_eq!(
state
.store
.count_audit_events_of_kind("disclosure.cross_identity_blocked")
.unwrap(),
1,
"the block must record its reconstructible cross-identity audit row"
);
}

/// Bell "internal data to a stranger": owner-origin, non-public data cannot
/// reach a counterparty recipient under a narrowing grant (user story 3).
#[tokio::test]
async fn owner_origin_blocked_from_a_counterparty_recipient() {
let state = state();
let mut grant = seed(&state).await;
narrow_to(&mut grant, vec![]);
let owner = ProvenanceOrigin::Owner {
principal: openspine_schemas::ids::PrincipalId::from(Ulid::new()),
};
let result = enforce_disclosure_egress(&state, &grant, request(owner)).await;
assert!(
result.is_err(),
"owner-internal data to a stranger must block"
);
assert_eq!(
state
.store
.count_audit_events_of_kind("disclosure.cross_identity_blocked")
.unwrap(),
1
);
}

/// A counterparty's own datum reaches that same bound counterparty recipient.
#[tokio::test]
async fn counterparty_origin_reaches_its_own_recipient() {
let state = state();
let mut grant = seed(&state).await;
narrow_to(&mut grant, vec![]);
let result =
enforce_disclosure_egress(&state, &grant, request(counterparty(RECIPIENT_ID))).await;
assert!(result.is_ok(), "same-identity egress must be allowed");
assert_eq!(
state
.store
.count_audit_events_of_kind("disclosure.cross_identity_blocked")
.unwrap(),
0
);
}

/// The #226 widening path: a `ProvenanceLabelAllowlist` caveat that names
/// counterparty X lets X's datum egress to a different recipient.
#[tokio::test]
async fn an_authorizing_caveat_lets_a_named_origin_egress() {
let state = state();
let mut grant = seed(&state).await;
narrow_to(&mut grant, vec![counterparty(22)]);
let result = enforce_disclosure_egress(&state, &grant, request(counterparty(22))).await;
assert!(result.is_ok(), "an authorized origin must egress");
assert_eq!(
state
.store
.count_audit_events_of_kind("disclosure.cross_identity_blocked")
.unwrap(),
0
);
}

/// The v1 single-owner owner grant carries no narrowing caveat, so every origin
/// is authorized and the closure is dormant — a cross-identity send is NOT
/// over-blocked, preserving Lyra's owner-directed flows.
#[tokio::test]
async fn owner_grant_without_a_caveat_does_not_over_block() {
let state = state();
let grant = seed(&state).await;
let result = enforce_disclosure_egress(&state, &grant, request(counterparty(22))).await;
assert!(
result.is_ok(),
"the un-narrowed owner grant authorizes every origin"
);
assert_eq!(
state
.store
.count_audit_events_of_kind("disclosure.cross_identity_blocked")
.unwrap(),
0
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,13 @@ fn caller_omitted_class_is_still_enforced() {
&BTreeSet::new(),
EgressClass::Search,
provenance,
openspine_schemas::disclosure_policy::RecipientIdentity::Unresolved,
);
let decision = openspine_schemas::disclosure_policy::check_egress(
RelationshipKind::Client,
query,
&policies,
&[],
);
match decision {
openspine_schemas::disclosure_policy::DisclosureGateDecision::Block { escalation } => {
Expand All @@ -114,6 +116,9 @@ fn caller_omitted_class_is_still_enforced() {
openspine_schemas::disclosure_policy::DisclosureGateDecision::Allow { .. } => {
panic!("uncovered Sensitive class must block despite covered Internal")
}
openspine_schemas::disclosure_policy::DisclosureGateDecision::CrossIdentityBlock {
..
} => panic!("coverage must block the uncovered Sensitive class before the origin closure"),
}
}

Expand Down
4 changes: 3 additions & 1 deletion crates/openspine-kernel/src/disclosure/disclosure_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ fn provenance(class: DisclosureClass) -> DisclosureProvenance {
schema_version: 1,
},
disclosure_class: class,
origin: None,
origin: Some(ProvenanceOrigin::System {}),
}],
}
}
Expand Down Expand Up @@ -100,8 +100,10 @@ fn uncovered_egress_blocks_and_produces_owner_question() {
&BTreeSet::from(["condition X".to_string()]),
EgressClass::Search,
provenance(DisclosureClass::Sensitive),
RecipientIdentity::Unresolved,
),
&state.store.load_disclosure_policies().unwrap(),
&[],
);
let openspine_schemas::disclosure_policy::DisclosureGateDecision::Block { escalation } =
decision
Expand Down
Loading