9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
331 lines
14 KiB
Rust
331 lines
14 KiB
Rust
//! Independent translation of `docs/spec/MultiTenantRelay.tla`'s `Next`
|
|
//! transition relation into Rust.
|
|
//!
|
|
//! This module is the heart of the conformance gate. It is deliberately
|
|
//! **independent** of the production reducer: it reads only the trace
|
|
//! schema in [`crate`] and the spec text in `docs/spec/MultiTenantRelay.tla`.
|
|
//! It does not import `buzz-relay`, `buzz-db`, `buzz-auth`, or any other
|
|
//! production crate that could share a normalization bug with the emitter.
|
|
//!
|
|
//! ## What an "abstract state" means here
|
|
//!
|
|
//! The TLA+ spec models the relay as a multi-worker system whose state is
|
|
//! the set of accepted rows, projection rows, observations, etc. A runtime
|
|
//! trace covers ONE worker handling ONE request — so the model state we
|
|
//! carry is much smaller:
|
|
//!
|
|
//! - `resolved_community` — the server-resolved `TenantContext::community()`
|
|
//! for this request. `Inv_NonInterference` requires every row label
|
|
//! observed in this request be a subset of `{resolved_community}`.
|
|
//! - `bound_host` — the host label `TenantContext::host()` was bound from.
|
|
//! `AuthCheck` / channel-less reads require `HostCommunity[host]` agree
|
|
//! with the resolved community.
|
|
//!
|
|
//! The checker rebuilds this state independently from the FIRST trace step
|
|
//! it sees and then validates every subsequent step against it.
|
|
//!
|
|
//! ## Per-action obligations
|
|
//!
|
|
//! Each action has a triple of obligations distilled from the spec:
|
|
//!
|
|
//! 1. **State match.** `step.state_after.resolved_community` and
|
|
//! `bound_host` agree with the checker's running model (no mid-request
|
|
//! tenant flip).
|
|
//! 2. **Row-label confinement** (`Inv_NonInterference` line ~983,
|
|
//! `Inv_ReadConfinement` line ~1003). Every `row_communities` entry,
|
|
//! every accept label, must equal `resolved_community`. A single foreign
|
|
//! label fails the trace.
|
|
//! 3. **Action-specific guards.** AuthCheck `Allow` requires host/channel
|
|
//! agreement; channel-less reads require `HostCommunity[host] = c`;
|
|
//! `WriteInsert` claim-vs-resolved is recorded but a mismatch is
|
|
//! allowed at the abstract level — the spec ignores it ("host wins"),
|
|
//! so the gate that bites mismatches is the row-label confinement on
|
|
//! the *next* read.
|
|
|
|
use crate::{
|
|
AbstractState, ChannelLabel, CommunityLabel, SanitizedReason, TraceAction, TraceStep, Verdict,
|
|
};
|
|
|
|
/// A judgment about a single trace step. The checker walks the trace and
|
|
/// returns the first failure verdict (fail-fast); per the skill's "fail
|
|
/// closed on the first violation" guidance.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Verdict_ {
|
|
/// Reserved — internal placeholder.
|
|
Ok,
|
|
}
|
|
|
|
/// Failure reasons returned by [`check_step`]. The string payload is
|
|
/// human-readable; mechanical consumers should match on the variant.
|
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
|
pub enum TransitionError {
|
|
/// The traced action is not allowed from the checker's current model
|
|
/// state — e.g. an `AuthCheck { verdict: Allow }` with `claimed != real`.
|
|
#[error("illegal transition at step {step_index}: {detail}")]
|
|
IllegalTransition {
|
|
/// 0-based index of the offending step.
|
|
step_index: usize,
|
|
/// Human-readable detail.
|
|
detail: String,
|
|
},
|
|
|
|
/// The trace's `state_after` does not match the model state the checker
|
|
/// computed independently. Indicates the relay either reassigned the
|
|
/// tenant context mid-request, or emitted a step from a context other
|
|
/// than `TenantContext`.
|
|
#[error("state mismatch at step {step_index}: {detail}")]
|
|
StateMismatch {
|
|
/// 0-based index of the offending step.
|
|
step_index: usize,
|
|
/// Human-readable detail.
|
|
detail: String,
|
|
},
|
|
|
|
/// Row labels include a community other than the resolved tenant —
|
|
/// the master `Inv_NonInterference` failure.
|
|
#[error("non-interference breach at step {step_index}: {detail}")]
|
|
NonInterference {
|
|
/// 0-based index of the offending step.
|
|
step_index: usize,
|
|
/// Human-readable detail.
|
|
detail: String,
|
|
},
|
|
|
|
/// A coverage breach: ImplBug action, or a fixture-declared
|
|
/// `required_critical_actions` entry never appeared.
|
|
#[error("coverage breach: {detail}")]
|
|
CoverageBreach {
|
|
/// Human-readable detail naming the missing or broken coverage rule.
|
|
detail: String,
|
|
},
|
|
}
|
|
|
|
/// The model state the checker carries between steps.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ModelState {
|
|
/// The community the FIRST step's `state_after` told us was resolved.
|
|
/// Subsequent steps must agree.
|
|
pub resolved_community: CommunityLabel,
|
|
/// The host the FIRST step's `state_after` told us was bound. Channel-
|
|
/// bearing AuthCheck and channel-less reads enforce
|
|
/// `host_community(host) == resolved_community`. The checker does NOT
|
|
/// know `HostCommunity[_]` at large; it only knows the spec guarantees
|
|
/// `HostCommunity[bound_host] = resolved_community` whenever the relay
|
|
/// took the success branch.
|
|
pub bound_host: crate::HostLabel,
|
|
/// The actor for this request — opaque, equality-checked only.
|
|
pub actor: crate::ActorLabel,
|
|
}
|
|
|
|
impl ModelState {
|
|
/// Bootstrap the model from the very first step. Subsequent calls to
|
|
/// [`check_step`] return a `StateMismatch` if `state_after` disagrees.
|
|
pub fn bootstrap(first: &AbstractState) -> Self {
|
|
Self {
|
|
resolved_community: first.resolved_community,
|
|
bound_host: first.bound_host.clone(),
|
|
actor: first.actor.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Validate one step against the model. Updates nothing (the model is
|
|
/// immutable for the lifetime of a single trace); a violation returns the
|
|
/// matching [`TransitionError`].
|
|
///
|
|
/// Spec line numbers below refer to `docs/spec/MultiTenantRelay.tla` at the
|
|
/// snapshot pinned in this PR's `docs/spec/`.
|
|
pub fn check_step(
|
|
step_index: usize,
|
|
model: &ModelState,
|
|
step: &TraceStep,
|
|
) -> Result<(), TransitionError> {
|
|
// Universal obligation 1: state_after agrees with the bootstrapped model.
|
|
if step.state_after.resolved_community != model.resolved_community {
|
|
return Err(TransitionError::StateMismatch {
|
|
step_index,
|
|
detail: format!(
|
|
"resolved_community changed mid-request: bootstrap={:?}, step={:?}",
|
|
model.resolved_community, step.state_after.resolved_community
|
|
),
|
|
});
|
|
}
|
|
if step.state_after.bound_host != model.bound_host {
|
|
return Err(TransitionError::StateMismatch {
|
|
step_index,
|
|
detail: format!(
|
|
"bound_host changed mid-request: bootstrap={:?}, step={:?}",
|
|
model.bound_host, step.state_after.bound_host
|
|
),
|
|
});
|
|
}
|
|
if step.state_after.actor != model.actor {
|
|
return Err(TransitionError::StateMismatch {
|
|
step_index,
|
|
detail: format!(
|
|
"actor changed mid-request: bootstrap={:?}, step={:?}",
|
|
model.actor, step.state_after.actor
|
|
),
|
|
});
|
|
}
|
|
|
|
// Action-specific obligations.
|
|
match &step.action {
|
|
// --- Spec WriteInsert (lines 514-550) ---
|
|
// Resolution: real == ChannelCommunity(ch).
|
|
// Success branch requires HostCommunity[host] = real, which the
|
|
// emitter guarantees by emitting from inside the success path with
|
|
// state_after.resolved_community = real and state_after.bound_host
|
|
// = the bound host. The trace records claimed_community separately
|
|
// so M2/M8 (host/channel disagreement, claim≠resolved) surface here
|
|
// as a state mismatch on the *resolved* side.
|
|
//
|
|
// What we check at this step: nothing beyond the universal state
|
|
// match. The spec ignores claimed_community ("host wins"), so a
|
|
// mismatch is allowed at this exact action — the gate that bites
|
|
// it is the next read's row labels.
|
|
TraceAction::WriteInsert { .. } => Ok(()),
|
|
|
|
// --- Spec WriteInsertGlobal (lines 559-595) ---
|
|
// resolved == HostCommunity[host]. Same shape as WriteInsert.
|
|
TraceAction::WriteInsertGlobal { .. } => Ok(()),
|
|
|
|
// --- Spec WriteDuplicate (lines 606-637) ---
|
|
// Carries the same host-axis obligation as WriteInsert: an A-host
|
|
// presenting a B-channel id must not learn whether the id exists.
|
|
// Same observable: state_after.resolved_community must be the
|
|
// real ChannelCommunity(ch), enforced by the universal check.
|
|
TraceAction::WriteDuplicate { .. } => Ok(()),
|
|
|
|
// --- Spec SanitizedError (line 778) ---
|
|
// Closed-alphabet reason; labels = {}; carries no row data. The
|
|
// emitter must collapse every reject path into one of the three
|
|
// SanitizedReason variants. The schema-level type system already
|
|
// enforces that — we just check the variant is among the spec's
|
|
// closed set (trivially true by construction).
|
|
TraceAction::SanitizedError { reason } => match reason {
|
|
SanitizedReason::Restricted
|
|
| SanitizedReason::Invalid
|
|
| SanitizedReason::ServerError => Ok(()),
|
|
},
|
|
|
|
// --- Spec AuthCheck (lines 794-810) ---
|
|
// real == ChannelCommunity(ch).
|
|
// hostAgrees == real ∈ Communities ∧ HostCommunity[host] = real.
|
|
// allowed == hostAgrees ∧ ch ∈ ScopedAccessible(real, a).
|
|
// verdict == IF allowed THEN Allow ELSE Deny.
|
|
//
|
|
// The runtime checker cannot recompute ScopedAccessible (that's
|
|
// production state). What it CAN check: when verdict = Allow, the
|
|
// claimed_community MUST equal resolved_community. This is the
|
|
// M2 bite ("auth verdict driven by claimed instead of resolved")
|
|
// and the M8 bite ("A-host driving a B-channel verdict") — both
|
|
// collapse to "Allow with a foreign label leak".
|
|
//
|
|
// We deliberately do NOT bite Deny on claim mismatch (Deny with
|
|
// any claim is in-spec — the spec models Deny as the catch-all
|
|
// for hostAgrees=false or accessibility=false).
|
|
TraceAction::AuthCheck {
|
|
channel: _,
|
|
claimed_community,
|
|
verdict,
|
|
} => match (verdict, claimed_community) {
|
|
(Verdict::Allow, Some(c)) if c != &model.resolved_community => {
|
|
Err(TransitionError::IllegalTransition {
|
|
step_index,
|
|
detail: format!(
|
|
"AuthCheck verdict=Allow with claimed_community={:?} != resolved={:?} \
|
|
— M2/M8 (claim or host driving verdict) bite",
|
|
c, model.resolved_community
|
|
),
|
|
})
|
|
}
|
|
_ => Ok(()),
|
|
},
|
|
|
|
// --- Spec ReadMessageRows (line 643) / ReadByIdRows (line 681) ---
|
|
// The action emits rows; `RowLabels(rows)` is the observation's
|
|
// labels and Inv_NonInterference requires labels ⊆ {community}.
|
|
// For channel-less (ch = NoChannel) the spec ADDS:
|
|
// HostCommunity[host] = c ∧ IsAdmitted(c, a).
|
|
// The host-agreement piece is enforced at the universal check
|
|
// (state_after.resolved_community is host-derived); IsAdmitted is
|
|
// production state we cannot recompute, so it lives in fixture
|
|
// assertions rather than this generic checker.
|
|
//
|
|
// What this checker bites: every row label must equal the
|
|
// resolved community. ONE foreign label fails NI.
|
|
TraceAction::ReadMessageRows {
|
|
channel: _,
|
|
row_communities,
|
|
}
|
|
| TraceAction::ReadByIdRows {
|
|
channel: _,
|
|
row_communities,
|
|
} => check_row_labels(step_index, model, row_communities),
|
|
|
|
// --- Spec ReadHostFeedRows (line ~720) ---
|
|
// Community is host-derived; same row-label confinement.
|
|
TraceAction::ReadHostFeedRows { row_communities } => {
|
|
check_row_labels(step_index, model, row_communities)
|
|
}
|
|
|
|
// --- Coverage breach via the Drop guard ---
|
|
// The seam exited without emitting any recognized action.
|
|
// Per the skill: this is the load-bearing coverage mode — without
|
|
// it, trace conformance is decorative logging.
|
|
TraceAction::ImplBug { kind } => Err(TransitionError::CoverageBreach {
|
|
detail: format!("ImplBug action emitted by Drop guard: kind={kind:?}"),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Row-label confinement check shared by all three read actions.
|
|
///
|
|
/// `Inv_NonInterference` (spec line ~983):
|
|
/// `\A o \in observations : o.labels \subseteq {o.community}`.
|
|
///
|
|
/// Translated: every `row_communities` entry must equal `model
|
|
/// .resolved_community`. The check is on a `Vec`, not a `Set`, deliberately
|
|
/// — if a buggy relay returned the same foreign row twice the checker still
|
|
/// bites, and if a buggy emitter de-duped foreign labels to one occurrence
|
|
/// the checker still bites. Foreign-label count is unimportant; foreign-
|
|
/// label presence is the entire bar.
|
|
fn check_row_labels(
|
|
step_index: usize,
|
|
model: &ModelState,
|
|
row_communities: &[CommunityLabel],
|
|
) -> Result<(), TransitionError> {
|
|
if let Some(foreign) = row_communities
|
|
.iter()
|
|
.find(|c| **c != model.resolved_community)
|
|
{
|
|
return Err(TransitionError::NonInterference {
|
|
step_index,
|
|
detail: format!(
|
|
"row labeled {:?} returned in observation scoped to {:?} \
|
|
— Inv_NonInterference breach (foreign row leaked through tenant fence)",
|
|
foreign, model.resolved_community
|
|
),
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Helper: which channel (if any) does the action target? Used by the
|
|
/// checker to bind cross-step claims to a stable channel — and by fixtures
|
|
/// asserting that a particular channel surfaced at this seam.
|
|
pub fn action_channel(action: &TraceAction) -> Option<&ChannelLabel> {
|
|
match action {
|
|
TraceAction::WriteInsert { channel, .. } => Some(channel),
|
|
TraceAction::WriteDuplicate { channel, .. } => Some(channel),
|
|
TraceAction::AuthCheck { channel, .. } => Some(channel),
|
|
TraceAction::ReadMessageRows { channel, .. } => channel.as_ref(),
|
|
TraceAction::ReadByIdRows { channel, .. } => channel.as_ref(),
|
|
TraceAction::WriteInsertGlobal { .. }
|
|
| TraceAction::ReadHostFeedRows { .. }
|
|
| TraceAction::SanitizedError { .. }
|
|
| TraceAction::ImplBug { .. } => None,
|
|
}
|
|
}
|