Files
autopilot/crates/shared/src/contracts/operator_auth.rs
T
Oleksandr Bezdieniezhnykh ccf929af69 [AZ-676] [AZ-677] [AZ-678] [AZ-679] telemetry+operator foundation
Batch 15 ships the four foundation tickets sitting on top of AZ-675
(gRPC server) and AZ-667 (mapobjects_store hydrate):

* AZ-676: telemetry_stream video path (rtsp_forward + bytes_inline)
  with ai_locked atomic + session counter, SubscribeVideo RPC.
* AZ-677: MapObjects snapshot-on-subscribe + diff broadcast +
  reconnect-resync (StartThen stream-prepend pattern).
* AZ-678: HmacOperatorValidator with per-session monotonic seq,
  in-process session registry + TTL, constant-time HMAC compare,
  rejection-reason counters, sliding 60 s sig-failure red-health gate.
  Trait OperatorCommandValidator in shared::contracts::operator_auth.
* AZ-679: PoiSurfaceMapper produces OperatorPoiEvent per architecture
  §7.10; PoiDequeued events on rotate/age-out/complete; pushed via
  new TelemetrySink::push_operator_event extension on Topic::OperatorEvent.

Cross-task wiring: TelemetrySink trait extended with
push_operator_event; OperatorBridge gets optional builder methods
with_telemetry_sink / with_validator (composition root wires in
AZ-680). Workspace deps: hmac = "0.12"; per-crate adds bytes,
serde_json, parking_lot, chrono, uuid, sha2, thiserror.

Tests: 14/14 ACs verified locally (4 + 3 + 5 + 3 by AC) plus
6 supporting unit tests + 7 integration tests + 2 shared serde
roundtrips. cargo clippy clean on touched crates. Cumulative
review for batches 13-15 produced; verdict PASS_WITH_WARNINGS
(0 Critical, 0 High, 1 Medium, 4 Low — all carry-overs or
deferred-producer notes for AZ-680/AZ-684).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 16:18:40 +03:00

100 lines
3.9 KiB
Rust

//! AZ-678 — operator command authentication contract.
//!
//! `OperatorCommandValidator` is the boundary every operator-bound
//! command crosses before any business logic runs. The default
//! implementation (`HmacOperatorValidator` in
//! `operator_bridge::internal::auth`) uses HMAC-SHA256 over
//! `(session_token || sequence_number || payload_bytes)`. The trait
//! lives here so the dispatch surface (`scan_controller`,
//! `mission_executor`) can depend on the contract without importing
//! `operator_bridge`.
use thiserror::Error;
use crate::models::operator::{OperatorCommand, OperatorCommandKind};
/// A command as it arrives over the operator-link, prior to any
/// authentication. Mirrors the validated `OperatorCommand` shape
/// closely so a successful `validate` is a near-identity transform.
#[derive(Debug, Clone)]
pub struct SignedCommand {
pub session_token: String,
pub sequence_number: u64,
pub kind: OperatorCommandKind,
pub payload: serde_json::Value,
/// HMAC over `(session_token || sequence_number || canonical
/// JSON of payload)`. Length depends on the scheme; for HMAC-SHA256
/// this is exactly 32 bytes.
pub signature: Vec<u8>,
/// Wall-clock time the Ground Station stamped the command. Carried
/// through `validate` for downstream audit logging; not used by
/// the auth check itself.
pub issued_at_wallclock: chrono::DateTime<chrono::Utc>,
pub command_id: uuid::Uuid,
}
impl SignedCommand {
/// Convert into a canonical [`OperatorCommand`] once validation
/// has succeeded. The signature is retained on the result for
/// downstream audit logging.
pub fn into_command(self) -> OperatorCommand {
OperatorCommand {
command_id: self.command_id,
session_token: self.session_token,
sequence_number: self.sequence_number,
issued_at_wallclock: self.issued_at_wallclock,
kind: self.kind,
payload: self.payload,
signature: self.signature,
}
}
}
/// Validated command. Returned by `OperatorCommandValidator::validate`
/// on the happy path. Holding a `ValidatedCommand` is the proof that
/// dispatching the inner command is safe.
#[derive(Debug, Clone)]
pub struct ValidatedCommand {
pub command: OperatorCommand,
}
/// Why an operator command was rejected. Each variant maps 1-1 to a
/// `auth_rejections_total{reason}` metric counter and to a structured
/// log line. Order MUST match
/// `operator_bridge::internal::auth::REJECTION_REASONS` for the
/// counter array layout.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum AuthError {
#[error("signature does not match computed HMAC")]
SignatureInvalid,
#[error("replay detected — sequence number not greater than last seen")]
ReplayDetected,
#[error("session token unknown or never established")]
SessionUnknown,
#[error("session token expired (TTL elapsed)")]
SessionExpired,
}
impl AuthError {
/// Stable kebab-case label for the rejection-reason metric.
pub fn reason_label(&self) -> &'static str {
match self {
Self::SignatureInvalid => "signature_invalid",
Self::ReplayDetected => "replay_detected",
Self::SessionUnknown => "session_unknown",
Self::SessionExpired => "session_expired",
}
}
}
/// Contract every operator-command validator must satisfy. The
/// default `HmacOperatorValidator` lives in `operator_bridge`; other
/// schemes (e.g. Q9 resolution to a JWS-based one) implement the
/// same trait and can be swapped behind the same callsite.
pub trait OperatorCommandValidator: Send + Sync {
/// Validate one signed command. On `Ok`, the per-session
/// sequence-number tracker advances; on `Err`, it does NOT
/// advance (so the rejected `seq` does not poison the session).
fn validate(&self, cmd: SignedCommand) -> Result<ValidatedCommand, AuthError>;
}