feat(server,agent): v2 secure-session-core Task 5 - attended consent
SPEC-002 Phase 1 Task 5, code-reviewed APPROVED. An attended (support-code) session is invisible and inert to the technician until the end user accepts a consent prompt on their own machine. - proto: ConsentRequest / ConsentResponse + ConsentAccessMode enum (oneof fields 80/81; no existing field renumbered). - server: ConsentState on Session; attended -> Pending, managed -> NotRequired; join_session refuses viewers unless Granted/NotRequired (single chokepoint - StartStream only fires from join_session, so no frames or input flow pre- consent); run_consent_handshake sends ConsentRequest, 60s timeout, granted -> proceed, denied/timeout/disconnect -> teardown (end_session denied, machine offline, support code released). consent_state persisted; consent_requested/ granted/denied audited. - agent: Windows MessageBox (topmost/system-modal) on spawn_blocking; anything but an explicit Yes = deny; non-Windows build is a fail-closed stub. Not cargo-check-verified locally (no toolchain). Server verified on the build host; the Windows agent half is verified by CI build-agent (Pluto). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
155
agent/src/consent/mod.rs
Normal file
155
agent/src/consent/mod.rs
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
//! Attended-mode consent prompt (Task 5).
|
||||||
|
//!
|
||||||
|
//! For an attended (support-code) session, the GuruConnect server sends the
|
||||||
|
//! agent a `ConsentRequest` before the technician's session is allowed to go
|
||||||
|
//! live. The agent shows the end user a native dialog ("Allow <technician> to
|
||||||
|
//! VIEW/CONTROL this computer?") and returns the user's choice as a
|
||||||
|
//! `ConsentResponse`. The server holds the session in `consent_state = pending`
|
||||||
|
//! and tears it down on a denial or timeout.
|
||||||
|
//!
|
||||||
|
//! v1 uses a Windows `MessageBox` (Yes/No, top-most, foreground). It is
|
||||||
|
//! synchronous and reliable on every supported Windows version (7 SP1+), needs
|
||||||
|
//! no extra windowing, and cannot be dismissed into an ambiguous state — the
|
||||||
|
//! only outcomes are Yes (allow), No (deny), or the box being closed (treated
|
||||||
|
//! as deny). A nicer custom branded dialog (countdown, technician avatar) is a
|
||||||
|
//! possible future refinement; it is not required for correctness.
|
||||||
|
//!
|
||||||
|
//! The decision is the end user's and is purely advisory to the agent: the
|
||||||
|
//! server is the enforcement point (it will not surface the session to the
|
||||||
|
//! technician until it receives a `granted` response). The agent simply relays
|
||||||
|
//! the human's choice.
|
||||||
|
|
||||||
|
/// Whether the technician requested view-only or full control, used only to
|
||||||
|
/// phrase the prompt. Mirrors `proto::ConsentAccessMode`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ConsentAccessMode {
|
||||||
|
View,
|
||||||
|
Control,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConsentAccessMode {
|
||||||
|
/// Decode the proto enum value (defaults to the more conservative `Control`
|
||||||
|
/// wording on an unknown value so the prompt never under-states access).
|
||||||
|
pub fn from_proto(value: i32) -> Self {
|
||||||
|
match crate::proto::ConsentAccessMode::try_from(value) {
|
||||||
|
Ok(crate::proto::ConsentAccessMode::ConsentView) => ConsentAccessMode::View,
|
||||||
|
Ok(crate::proto::ConsentAccessMode::ConsentControl) => ConsentAccessMode::Control,
|
||||||
|
Err(_) => ConsentAccessMode::Control,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verb(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ConsentAccessMode::View => "VIEW",
|
||||||
|
ConsentAccessMode::Control => "VIEW and CONTROL",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the consent prompt body shown to the end user.
|
||||||
|
fn prompt_body(technician_name: &str, access: ConsentAccessMode) -> String {
|
||||||
|
let who = if technician_name.trim().is_empty() {
|
||||||
|
"A support technician"
|
||||||
|
} else {
|
||||||
|
technician_name
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"{who} is requesting a remote support session.\n\n\
|
||||||
|
If you allow this, they will be able to {verb} this computer.\n\n\
|
||||||
|
Do you want to allow this remote support session?",
|
||||||
|
who = who,
|
||||||
|
verb = access.verb()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the consent dialog and return the end user's decision.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the user ALLOWED the session, `false` if they denied it or
|
||||||
|
/// the dialog was closed/could not be shown. Blocking — callers should run this
|
||||||
|
/// off the async runtime (e.g. `tokio::task::spawn_blocking`).
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn prompt_consent(technician_name: &str, access: ConsentAccessMode) -> bool {
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
use windows::core::PCWSTR;
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
|
MessageBoxW, IDYES, MB_ICONQUESTION, MB_SETFOREGROUND, MB_SYSTEMMODAL, MB_TOPMOST,
|
||||||
|
MB_YESNO,
|
||||||
|
};
|
||||||
|
|
||||||
|
let title = "GuruConnect - Remote Support Request";
|
||||||
|
let body = prompt_body(technician_name, access);
|
||||||
|
|
||||||
|
let title_wide: Vec<u16> = OsStr::new(title)
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
let body_wide: Vec<u16> = OsStr::new(&body)
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// MB_YESNO - explicit Allow (Yes) / Deny (No)
|
||||||
|
// MB_ICONQUESTION - prompt styling
|
||||||
|
// MB_TOPMOST - sit above other windows so it cannot be hidden
|
||||||
|
// MB_SETFOREGROUND - bring to the foreground
|
||||||
|
// MB_SYSTEMMODAL - ensure visibility even from a service/elevated context
|
||||||
|
let result = unsafe {
|
||||||
|
MessageBoxW(
|
||||||
|
None,
|
||||||
|
PCWSTR(body_wide.as_ptr()),
|
||||||
|
PCWSTR(title_wide.as_ptr()),
|
||||||
|
MB_YESNO | MB_ICONQUESTION | MB_TOPMOST | MB_SETFOREGROUND | MB_SYSTEMMODAL,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Any outcome other than an explicit "Yes" is a denial (including the box
|
||||||
|
// being closed, which returns IDNO/IDCANCEL-style values).
|
||||||
|
result == IDYES
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-Windows stub. The agent is Windows-first; on other platforms there is no
|
||||||
|
/// native end-user consent surface yet, so we fail CLOSED (deny) rather than
|
||||||
|
/// silently allowing an unattended session.
|
||||||
|
///
|
||||||
|
// TODO(platform): provide a real consent dialog on macOS/Linux when the agent
|
||||||
|
// is ported there (e.g. a GTK/Cocoa modal). Until then, deny so a non-Windows
|
||||||
|
// build can never grant an attended session without an explicit human prompt.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn prompt_consent(_technician_name: &str, _access: ConsentAccessMode) -> bool {
|
||||||
|
tracing::warn!(
|
||||||
|
"Consent prompt requested on a non-Windows build; no native dialog available — denying"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_body_uses_control_wording() {
|
||||||
|
let body = prompt_body("Mike", ConsentAccessMode::Control);
|
||||||
|
assert!(body.contains("Mike"));
|
||||||
|
assert!(body.contains("VIEW and CONTROL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_body_uses_view_wording() {
|
||||||
|
let body = prompt_body("Mike", ConsentAccessMode::View);
|
||||||
|
assert!(body.contains("VIEW"));
|
||||||
|
assert!(!body.contains("CONTROL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_body_falls_back_on_empty_name() {
|
||||||
|
let body = prompt_body(" ", ConsentAccessMode::Control);
|
||||||
|
assert!(body.contains("A support technician"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn access_mode_from_proto_defaults_to_control() {
|
||||||
|
// An out-of-range proto value must not under-state access.
|
||||||
|
assert_eq!(ConsentAccessMode::from_proto(999), ConsentAccessMode::Control);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
mod capture;
|
mod capture;
|
||||||
mod chat;
|
mod chat;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod consent;
|
||||||
mod encoder;
|
mod encoder;
|
||||||
mod input;
|
mod input;
|
||||||
mod install;
|
mod install;
|
||||||
|
|||||||
@@ -369,6 +369,17 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
message::Payload::ConsentRequest(req) => {
|
||||||
|
// ATTENDED-MODE CONSENT (Task 5). The server is holding
|
||||||
|
// this session in `consent_state = pending` and will not
|
||||||
|
// surface it to the technician until we reply. Show the
|
||||||
|
// end user a native dialog and return their decision; the
|
||||||
|
// dialog blocks, so run it off the async runtime. If the
|
||||||
|
// user closes it / no choice is made, `prompt_consent`
|
||||||
|
// returns false (deny).
|
||||||
|
self.handle_consent_request(req.clone()).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -498,6 +509,65 @@ impl SessionManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle an attended-mode `ConsentRequest` from the server (Task 5).
|
||||||
|
///
|
||||||
|
/// Shows the end user a native consent dialog (off the async runtime, since
|
||||||
|
/// it blocks) and sends a `ConsentResponse` carrying their decision. A
|
||||||
|
/// closed dialog / unavailable surface is treated as a DENY. The server
|
||||||
|
/// gates the whole session on this reply, so we always send a response (even
|
||||||
|
/// on send failure the server's consent timeout will deny).
|
||||||
|
async fn handle_consent_request(&mut self, req: crate::proto::ConsentRequest) {
|
||||||
|
use crate::consent::{prompt_consent, ConsentAccessMode};
|
||||||
|
use crate::proto::ConsentResponse;
|
||||||
|
|
||||||
|
let session_id = req.session_id.clone();
|
||||||
|
let technician_name = req.technician_name.clone();
|
||||||
|
let access = ConsentAccessMode::from_proto(req.access_mode);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Consent requested for session {} by '{}' ({:?}); prompting end user",
|
||||||
|
session_id,
|
||||||
|
technician_name,
|
||||||
|
access
|
||||||
|
);
|
||||||
|
|
||||||
|
// The MessageBox blocks the calling thread; run it on the blocking pool
|
||||||
|
// so the agent's async loop is not stalled and heartbeats keep flowing.
|
||||||
|
let granted = tokio::task::spawn_blocking(move || {
|
||||||
|
prompt_consent(&technician_name, access)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
// The blocking task panicked — fail closed (deny).
|
||||||
|
tracing::error!("Consent dialog task failed: {}; denying", e);
|
||||||
|
false
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"End user {} consent for session {}",
|
||||||
|
if granted { "GRANTED" } else { "DENIED" },
|
||||||
|
session_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = Message {
|
||||||
|
payload: Some(message::Payload::ConsentResponse(ConsentResponse {
|
||||||
|
session_id,
|
||||||
|
granted,
|
||||||
|
reason: if granted {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
"user_declined".to_string()
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(transport) = self.transport.as_mut() {
|
||||||
|
if let Err(e) = transport.send(response).await {
|
||||||
|
tracing::error!("Failed to send ConsentResponse: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle incoming message from server
|
/// Handle incoming message from server
|
||||||
async fn handle_message(&mut self, msg: Message) -> Result<()> {
|
async fn handle_message(&mut self, msg: Message) -> Result<()> {
|
||||||
match msg.payload {
|
match msg.payload {
|
||||||
|
|||||||
@@ -294,6 +294,40 @@ enum AdminCommandType {
|
|||||||
ADMIN_UPDATE = 2; // Download and install update
|
ADMIN_UPDATE = 2; // Download and install update
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Attended-mode Consent (Task 5)
|
||||||
|
// ============================================================================
|
||||||
|
//
|
||||||
|
// For an ATTENDED (support-code) session, the end user on the managed machine
|
||||||
|
// must explicitly accept a consent prompt before the technician's session goes
|
||||||
|
// live. The server holds the session in `consent_state = pending` and does NOT
|
||||||
|
// surface it to the technician (no viewer join / no streaming) until the agent
|
||||||
|
// returns a ConsentResponse with granted = true. A denial or timeout tears the
|
||||||
|
// session down. Managed/unattended sessions are `not_required` and never see a
|
||||||
|
// ConsentRequest (Phase-1 default policy).
|
||||||
|
|
||||||
|
// Server/relay -> Agent: ask the end user to allow this support session.
|
||||||
|
message ConsentRequest {
|
||||||
|
string session_id = 1; // Session awaiting consent (UUID string)
|
||||||
|
string technician_name = 2; // Display name of the requesting technician
|
||||||
|
ConsentAccessMode access_mode = 3; // View vs. control, so the prompt is honest
|
||||||
|
int32 timeout_secs = 4; // How long the agent should wait before auto-deny
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent -> Server: the end user's decision.
|
||||||
|
message ConsentResponse {
|
||||||
|
string session_id = 1; // Echoes the ConsentRequest session_id
|
||||||
|
bool granted = 2; // true = allow, false = deny (incl. dialog closed)
|
||||||
|
string reason = 3; // Optional human-readable reason (e.g. "timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the technician is requesting view-only or full control. Mirrors the
|
||||||
|
// viewer access mode the server already enforces; used only to phrase the prompt.
|
||||||
|
enum ConsentAccessMode {
|
||||||
|
CONSENT_VIEW = 0; // Technician will VIEW the screen
|
||||||
|
CONSENT_CONTROL = 1; // Technician will VIEW and CONTROL
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Auto-Update Messages
|
// Auto-Update Messages
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -374,5 +408,9 @@ message Message {
|
|||||||
// Auto-update messages
|
// Auto-update messages
|
||||||
UpdateInfo update_info = 75; // Server -> Agent: update available
|
UpdateInfo update_info = 75; // Server -> Agent: update available
|
||||||
UpdateStatus update_status = 76; // Agent -> Server: update progress
|
UpdateStatus update_status = 76; // Agent -> Server: update progress
|
||||||
|
|
||||||
|
// Attended-mode consent (Task 5)
|
||||||
|
ConsentRequest consent_request = 80; // Server/relay -> Agent: prompt end user
|
||||||
|
ConsentResponse consent_response = 81; // Agent -> Server: end user's decision
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ pub struct SessionInfo {
|
|||||||
pub uptime_secs: i64,
|
pub uptime_secs: i64,
|
||||||
pub display_count: i32,
|
pub display_count: i32,
|
||||||
pub agent_version: Option<String>,
|
pub agent_version: Option<String>,
|
||||||
|
/// Attended-consent state (Task 5): `not_required` | `pending` | `granted`
|
||||||
|
/// | `denied`. The dashboard can surface "awaiting consent" for an attended
|
||||||
|
/// session that has not yet been accepted on the managed machine.
|
||||||
|
pub consent_state: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<crate::session::Session> for SessionInfo {
|
impl From<crate::session::Session> for SessionInfo {
|
||||||
@@ -75,6 +79,7 @@ impl From<crate::session::Session> for SessionInfo {
|
|||||||
uptime_secs: s.uptime_secs,
|
uptime_secs: s.uptime_secs,
|
||||||
display_count: s.display_count,
|
display_count: s.display_count,
|
||||||
agent_version: s.agent_version,
|
agent_version: s.agent_version,
|
||||||
|
consent_state: s.consent_state.as_db_str().to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,17 @@ impl EventTypes {
|
|||||||
#[allow(dead_code)] // TODO(audit-events): emit on cancelled-code bind rejection
|
#[allow(dead_code)] // TODO(audit-events): emit on cancelled-code bind rejection
|
||||||
pub const CONNECTION_REJECTED_CANCELLED_CODE: &'static str =
|
pub const CONNECTION_REJECTED_CANCELLED_CODE: &'static str =
|
||||||
"connection_rejected_cancelled_code";
|
"connection_rejected_cancelled_code";
|
||||||
|
|
||||||
|
// Attended-mode consent decisions (Task 5). Audited with the session id and
|
||||||
|
// the agent id in `details` so the consent decision is fully traceable.
|
||||||
|
/// End user accepted the attended-session consent prompt.
|
||||||
|
pub const CONSENT_GRANTED: &'static str = "consent_granted";
|
||||||
|
/// End user declined, the prompt timed out, or the agent disconnected
|
||||||
|
/// before answering — the attended session was torn down.
|
||||||
|
pub const CONSENT_DENIED: &'static str = "consent_denied";
|
||||||
|
/// A `ConsentRequest` was sent to the agent for an attended session (the
|
||||||
|
/// prompt is now awaiting the end user's decision).
|
||||||
|
pub const CONSENT_REQUESTED: &'static str = "consent_requested";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log a session event
|
/// Log a session event
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ pub struct DbSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new session record
|
/// Create a new session record
|
||||||
|
///
|
||||||
|
/// The consent/managed columns are derived from `is_support_session`:
|
||||||
|
/// - support session (attended): `is_managed = false`, `source = 'standalone'`,
|
||||||
|
/// `consent_state = 'pending'` — a viewer cannot be surfaced to the
|
||||||
|
/// technician until the end user accepts (Task 5).
|
||||||
|
/// - managed/persistent session: `is_managed = true`, `source = 'gururmm'`,
|
||||||
|
/// `consent_state = 'not_required'` (Phase-1 default policy).
|
||||||
pub async fn create_session(
|
pub async fn create_session(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
session_id: Uuid,
|
session_id: Uuid,
|
||||||
@@ -35,10 +42,18 @@ pub async fn create_session(
|
|||||||
is_support_session: bool,
|
is_support_session: bool,
|
||||||
support_code: Option<&str>,
|
support_code: Option<&str>,
|
||||||
) -> Result<DbSession, sqlx::Error> {
|
) -> Result<DbSession, sqlx::Error> {
|
||||||
|
let (is_managed, source, consent_state) = if is_support_session {
|
||||||
|
(false, "standalone", "pending")
|
||||||
|
} else {
|
||||||
|
(true, "gururmm", "not_required")
|
||||||
|
};
|
||||||
|
|
||||||
sqlx::query_as::<_, DbSession>(
|
sqlx::query_as::<_, DbSession>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO connect_sessions (id, machine_id, is_support_session, support_code, status)
|
INSERT INTO connect_sessions
|
||||||
VALUES ($1, $2, $3, $4, 'active')
|
(id, machine_id, is_support_session, support_code, status,
|
||||||
|
is_managed, source, consent_state)
|
||||||
|
VALUES ($1, $2, $3, $4, 'active', $5, $6, $7)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -46,10 +61,37 @@ pub async fn create_session(
|
|||||||
.bind(machine_id)
|
.bind(machine_id)
|
||||||
.bind(is_support_session)
|
.bind(is_support_session)
|
||||||
.bind(support_code)
|
.bind(support_code)
|
||||||
|
.bind(is_managed)
|
||||||
|
.bind(source)
|
||||||
|
.bind(consent_state)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update the attended-consent state on a session row (Task 5).
|
||||||
|
///
|
||||||
|
/// `state` must be one of `not_required` | `pending` | `granted` | `denied`
|
||||||
|
/// (the column CHECK enforces this). Persists the consent decision so it is
|
||||||
|
/// durable and auditable alongside the `consent_granted`/`consent_denied`
|
||||||
|
/// events. Best-effort from the relay: a failure here does not change the
|
||||||
|
/// authoritative in-memory consent gate.
|
||||||
|
pub async fn update_consent_state(
|
||||||
|
pool: &PgPool,
|
||||||
|
session_id: Uuid,
|
||||||
|
state: &str,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE connect_sessions SET consent_state = $1 WHERE id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(state)
|
||||||
|
.bind(session_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// End a session
|
/// End a session
|
||||||
pub async fn end_session(
|
pub async fn end_session(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
|
use futures_util::stream::{SplitSink, SplitStream};
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use prost::Message as ProstMessage;
|
use prost::Message as ProstMessage;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -49,6 +50,18 @@ const VIEWER_WS_MAX_MESSAGE_BYTES: usize = 64 * 1024;
|
|||||||
/// buffered unboundedly. (Closes the input-injection MEDIUM.)
|
/// buffered unboundedly. (Closes the input-injection MEDIUM.)
|
||||||
const VIEWER_INPUT_EVENTS_PER_SEC: u32 = 200;
|
const VIEWER_INPUT_EVENTS_PER_SEC: u32 = 200;
|
||||||
|
|
||||||
|
/// How long the server waits for the end user to answer the attended-session
|
||||||
|
/// consent prompt before treating it as a DENY and tearing the session down.
|
||||||
|
///
|
||||||
|
/// An attended (support-code) session is held with `consent_state = pending`
|
||||||
|
/// for at most this long: if the agent does not return a `ConsentResponse`
|
||||||
|
/// (user accepted/declined) within the window, the session is denied and torn
|
||||||
|
/// down. The agent is told the same value in `ConsentRequest.timeout_secs` so
|
||||||
|
/// its dialog can auto-deny in lock-step. 60s is long enough for a real person
|
||||||
|
/// to read and decide, short enough that an unattended/abandoned prompt does
|
||||||
|
/// not hold a session open indefinitely.
|
||||||
|
const CONSENT_TIMEOUT_SECS: u64 = 60;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct AgentParams {
|
pub struct AgentParams {
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
@@ -628,6 +641,76 @@ async fn handle_agent_connection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ATTENDED-MODE CONSENT GATE (Task 5).
|
||||||
|
//
|
||||||
|
// For an attended (support-code) session, the end user on the managed
|
||||||
|
// machine must SEE and ACCEPT a consent prompt before the technician's
|
||||||
|
// session goes live. The session was registered with `consent_state =
|
||||||
|
// Pending` (in-memory) and the DB row created with `consent_state =
|
||||||
|
// 'pending'`, so `join_session` already refuses any viewer until this
|
||||||
|
// resolves. Here we drive the handshake to completion BEFORE entering the
|
||||||
|
// main relay loop:
|
||||||
|
// 1. Send a `ConsentRequest` to the agent (it shows a native dialog).
|
||||||
|
// 2. Wait up to `CONSENT_TIMEOUT_SECS` for a `ConsentResponse`.
|
||||||
|
// 3. granted -> consent_state = Granted, audit, proceed to the main loop.
|
||||||
|
// denied/timeout/disconnect -> consent_state = Denied, audit, send a
|
||||||
|
// Disconnect to the agent, and tear the session down (return early).
|
||||||
|
//
|
||||||
|
// Managed/unattended sessions (no support code) are `NotRequired`: they skip
|
||||||
|
// this entirely and proceed straight to the relay loop.
|
||||||
|
if support_code.is_some() {
|
||||||
|
let consent_granted = run_consent_handshake(
|
||||||
|
&mut ws_sender,
|
||||||
|
&mut ws_receiver,
|
||||||
|
&sessions,
|
||||||
|
db.as_ref(),
|
||||||
|
session_id,
|
||||||
|
&agent_id,
|
||||||
|
&support_codes,
|
||||||
|
client_ip,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if !consent_granted {
|
||||||
|
// Denied / timed out / agent vanished. The session row is already
|
||||||
|
// marked denied + audited inside the handshake. Tear down the live
|
||||||
|
// session and DB record, then return — the technician never sees it.
|
||||||
|
info!(
|
||||||
|
"Attended session {} denied/abandoned at consent; tearing down",
|
||||||
|
session_id
|
||||||
|
);
|
||||||
|
|
||||||
|
sessions.mark_agent_disconnected(session_id).await;
|
||||||
|
|
||||||
|
if let Some(ref db) = db {
|
||||||
|
let _ = db::sessions::end_session(db.pool(), session_id, "denied").await;
|
||||||
|
let _ = db::machines::mark_machine_offline(db.pool(), &agent_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort: release the support code so a fresh attempt can start
|
||||||
|
// clean rather than colliding with a half-bound code.
|
||||||
|
if let Some(ref code) = support_code {
|
||||||
|
support_codes.mark_completed(code).await;
|
||||||
|
if let Some(ref db) = db {
|
||||||
|
let _ = db::support_codes::mark_code_completed(db.pool(), code).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell the agent why it is being dropped, then close the socket.
|
||||||
|
let disconnect_msg = proto::Message {
|
||||||
|
payload: Some(proto::message::Payload::Disconnect(proto::Disconnect {
|
||||||
|
reason: "Remote support was declined on the managed computer".to_string(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
if prost::Message::encode(&disconnect_msg, &mut buf).is_ok() {
|
||||||
|
let _ = ws_sender.send(Message::Binary(buf)).await;
|
||||||
|
}
|
||||||
|
let _ = ws_sender.close().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Use Arc<Mutex> for sender so we can use it from multiple places
|
// Use Arc<Mutex> for sender so we can use it from multiple places
|
||||||
let ws_sender = std::sync::Arc::new(tokio::sync::Mutex::new(ws_sender));
|
let ws_sender = std::sync::Arc::new(tokio::sync::Mutex::new(ws_sender));
|
||||||
let ws_sender_input = ws_sender.clone();
|
let ws_sender_input = ws_sender.clone();
|
||||||
@@ -758,6 +841,19 @@ async fn handle_agent_connection(
|
|||||||
// Agent acknowledged our heartbeat
|
// Agent acknowledged our heartbeat
|
||||||
sessions_status.update_heartbeat(session_id).await;
|
sessions_status.update_heartbeat(session_id).await;
|
||||||
}
|
}
|
||||||
|
Some(proto::message::Payload::ConsentResponse(_)) => {
|
||||||
|
// The consent handshake (Task 5) runs to
|
||||||
|
// completion BEFORE this loop is entered, so any
|
||||||
|
// ConsentResponse arriving here is a late/dup —
|
||||||
|
// the decision is already final. Acknowledge by
|
||||||
|
// ignoring it (do not re-open the gate), but log
|
||||||
|
// so a stray response is observable.
|
||||||
|
tracing::debug!(
|
||||||
|
"Late ConsentResponse from agent {} on session {}; ignoring \
|
||||||
|
(consent already finalized)",
|
||||||
|
agent_id, session_id
|
||||||
|
);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -826,6 +922,259 @@ async fn handle_agent_connection(
|
|||||||
info!("Session {} ended", session_id);
|
info!("Session {} ended", session_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drive the attended-mode consent handshake for a support-code session
|
||||||
|
/// (Task 5).
|
||||||
|
///
|
||||||
|
/// Sends a [`proto::ConsentRequest`] to the agent (which shows the end user a
|
||||||
|
/// native dialog), then waits up to [`CONSENT_TIMEOUT_SECS`] for a
|
||||||
|
/// [`proto::ConsentResponse`]. Returns `true` iff the end user GRANTED consent;
|
||||||
|
/// `false` for a denial, a timeout, a closed/errored socket, or any other
|
||||||
|
/// outcome. On every terminal outcome it updates the session's `consent_state`
|
||||||
|
/// (in-memory authoritative + best-effort DB mirror) and writes a
|
||||||
|
/// `consent_granted` / `consent_denied` audit event.
|
||||||
|
///
|
||||||
|
/// While waiting, non-consent inbound messages (e.g. heartbeats the agent may
|
||||||
|
/// emit) are tolerated and ignored — only a `ConsentResponse` (or the timeout /
|
||||||
|
/// disconnect) resolves the gate. No secret or code value is logged.
|
||||||
|
#[allow(clippy::too_many_arguments)] // mirrors the relay/session contract; tracked for a params-struct refactor in docs/specs/native-remote-control/
|
||||||
|
async fn run_consent_handshake(
|
||||||
|
ws_sender: &mut SplitSink<WebSocket, Message>,
|
||||||
|
ws_receiver: &mut SplitStream<WebSocket>,
|
||||||
|
sessions: &SessionManager,
|
||||||
|
db: Option<&Database>,
|
||||||
|
session_id: Uuid,
|
||||||
|
agent_id: &str,
|
||||||
|
support_codes: &crate::support_codes::SupportCodeManager,
|
||||||
|
client_ip: Option<std::net::IpAddr>,
|
||||||
|
) -> bool {
|
||||||
|
// Resolve the requesting technician's display name (best-effort) so the
|
||||||
|
// prompt is honest about who is asking. The support code carries the name
|
||||||
|
// of the technician who generated it (`created_by`); if the mapping is not
|
||||||
|
// yet available, fall back to a generic label rather than leaking the
|
||||||
|
// machine's own name. Attended sessions grant CONTROL.
|
||||||
|
let technician_name = match support_codes.get_by_session(session_id).await {
|
||||||
|
Some(code) => code.created_by,
|
||||||
|
None => "A support technician".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Send the ConsentRequest to the agent.
|
||||||
|
let request = proto::Message {
|
||||||
|
payload: Some(proto::message::Payload::ConsentRequest(
|
||||||
|
proto::ConsentRequest {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
technician_name,
|
||||||
|
// Attended support sessions are full-control by default; the
|
||||||
|
// viewer-token access split (Task 3) still governs what the relay
|
||||||
|
// actually forwards once joined.
|
||||||
|
access_mode: proto::ConsentAccessMode::ConsentControl as i32,
|
||||||
|
timeout_secs: CONSENT_TIMEOUT_SECS as i32,
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
if prost::Message::encode(&request, &mut buf).is_err() {
|
||||||
|
warn!(
|
||||||
|
"Failed to encode ConsentRequest for session {}; denying",
|
||||||
|
session_id
|
||||||
|
);
|
||||||
|
finalize_consent(sessions, db, session_id, agent_id, false, "encode_error", client_ip).await;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ws_sender.send(Message::Binary(buf)).await.is_err() {
|
||||||
|
warn!(
|
||||||
|
"Failed to send ConsentRequest to agent {} (session {}); denying",
|
||||||
|
agent_id, session_id
|
||||||
|
);
|
||||||
|
finalize_consent(sessions, db, session_id, agent_id, false, "send_error", client_ip).await;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Attended session {}: ConsentRequest sent to agent {}, awaiting end-user decision",
|
||||||
|
session_id, agent_id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Audit that consent was requested (prompt now pending).
|
||||||
|
if let Some(db) = db {
|
||||||
|
let _ = db::events::log_event(
|
||||||
|
db.pool(),
|
||||||
|
session_id,
|
||||||
|
db::events::EventTypes::CONSENT_REQUESTED,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(serde_json::json!({ "agent_id": agent_id })),
|
||||||
|
client_ip,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Wait for the ConsentResponse, bounded by the timeout.
|
||||||
|
let deadline = tokio::time::Instant::now()
|
||||||
|
+ std::time::Duration::from_secs(CONSENT_TIMEOUT_SECS);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||||
|
if remaining.is_zero() {
|
||||||
|
warn!(
|
||||||
|
"Attended session {}: consent timed out after {}s; denying",
|
||||||
|
session_id, CONSENT_TIMEOUT_SECS
|
||||||
|
);
|
||||||
|
finalize_consent(sessions, db, session_id, agent_id, false, "timeout", client_ip).await;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
match tokio::time::timeout(remaining, ws_receiver.next()).await {
|
||||||
|
// Timed out waiting for the next frame.
|
||||||
|
Err(_) => {
|
||||||
|
warn!(
|
||||||
|
"Attended session {}: consent timed out after {}s; denying",
|
||||||
|
session_id, CONSENT_TIMEOUT_SECS
|
||||||
|
);
|
||||||
|
finalize_consent(sessions, db, session_id, agent_id, false, "timeout", client_ip)
|
||||||
|
.await;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Socket closed before answering.
|
||||||
|
Ok(None) => {
|
||||||
|
warn!(
|
||||||
|
"Attended session {}: agent {} disconnected before consent; denying",
|
||||||
|
session_id, agent_id
|
||||||
|
);
|
||||||
|
finalize_consent(
|
||||||
|
sessions, db, session_id, agent_id, false, "agent_disconnected", client_ip,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Ok(Some(Ok(Message::Binary(data)))) => {
|
||||||
|
match proto::Message::decode(data.as_ref()) {
|
||||||
|
Ok(proto_msg) => match proto_msg.payload {
|
||||||
|
Some(proto::message::Payload::ConsentResponse(resp)) => {
|
||||||
|
// Verify the response is for THIS session (an agent
|
||||||
|
// only ever has one pending consent, but be strict).
|
||||||
|
if resp.session_id != session_id.to_string() {
|
||||||
|
warn!(
|
||||||
|
"Attended session {}: ConsentResponse for a different \
|
||||||
|
session ({}); ignoring",
|
||||||
|
session_id, resp.session_id
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if resp.granted {
|
||||||
|
info!("Attended session {}: end user GRANTED consent", session_id);
|
||||||
|
finalize_consent(
|
||||||
|
sessions, db, session_id, agent_id, true, "granted", client_ip,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
let reason = if resp.reason.is_empty() {
|
||||||
|
"denied".to_string()
|
||||||
|
} else {
|
||||||
|
resp.reason.clone()
|
||||||
|
};
|
||||||
|
info!(
|
||||||
|
"Attended session {}: end user DENIED consent ({})",
|
||||||
|
session_id, reason
|
||||||
|
);
|
||||||
|
finalize_consent(
|
||||||
|
sessions, db, session_id, agent_id, false, &reason, client_ip,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Any other message during the consent window is
|
||||||
|
// tolerated (e.g. heartbeats) — keep waiting.
|
||||||
|
_ => continue,
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"Attended session {}: failed to decode agent message during \
|
||||||
|
consent wait: {}",
|
||||||
|
session_id, e
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Agent-initiated close frame.
|
||||||
|
Ok(Some(Ok(Message::Close(_)))) => {
|
||||||
|
warn!(
|
||||||
|
"Attended session {}: agent {} closed during consent; denying",
|
||||||
|
session_id, agent_id
|
||||||
|
);
|
||||||
|
finalize_consent(
|
||||||
|
sessions, db, session_id, agent_id, false, "agent_closed", client_ip,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Ping/pong/text and transient frames: keep waiting.
|
||||||
|
Ok(Some(Ok(_))) => continue,
|
||||||
|
Ok(Some(Err(e))) => {
|
||||||
|
warn!(
|
||||||
|
"Attended session {}: WebSocket error during consent wait: {}; denying",
|
||||||
|
session_id, e
|
||||||
|
);
|
||||||
|
finalize_consent(
|
||||||
|
sessions, db, session_id, agent_id, false, "ws_error", client_ip,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a terminal consent decision: update the in-memory consent state
|
||||||
|
/// (authoritative), mirror it to the DB row (best-effort), and audit it.
|
||||||
|
async fn finalize_consent(
|
||||||
|
sessions: &SessionManager,
|
||||||
|
db: Option<&Database>,
|
||||||
|
session_id: Uuid,
|
||||||
|
agent_id: &str,
|
||||||
|
granted: bool,
|
||||||
|
reason: &str,
|
||||||
|
client_ip: Option<std::net::IpAddr>,
|
||||||
|
) {
|
||||||
|
use crate::session::ConsentState;
|
||||||
|
|
||||||
|
let new_state = if granted {
|
||||||
|
ConsentState::Granted
|
||||||
|
} else {
|
||||||
|
ConsentState::Denied
|
||||||
|
};
|
||||||
|
|
||||||
|
// Authoritative in-memory state — this is what `join_session` consults.
|
||||||
|
sessions.set_consent_state(session_id, new_state).await;
|
||||||
|
|
||||||
|
if let Some(db) = db {
|
||||||
|
// Durable mirror of the consent decision on the session row.
|
||||||
|
let _ = db::sessions::update_consent_state(db.pool(), session_id, new_state.as_db_str())
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Audit event (consent_granted | consent_denied) with the agent id and
|
||||||
|
// the (non-secret) reason so the decision is fully traceable.
|
||||||
|
let event_type = if granted {
|
||||||
|
db::events::EventTypes::CONSENT_GRANTED
|
||||||
|
} else {
|
||||||
|
db::events::EventTypes::CONSENT_DENIED
|
||||||
|
};
|
||||||
|
let _ = db::events::log_event(
|
||||||
|
db.pool(),
|
||||||
|
session_id,
|
||||||
|
event_type,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(serde_json::json!({ "agent_id": agent_id, "reason": reason })),
|
||||||
|
client_ip,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle a viewer WebSocket connection
|
/// Handle a viewer WebSocket connection
|
||||||
///
|
///
|
||||||
/// `access` is the VERIFIED access mode from the viewer token's signed claims.
|
/// `access` is the VERIFIED access mode from the viewer token's signed claims.
|
||||||
|
|||||||
@@ -26,6 +26,45 @@ pub struct ViewerInfo {
|
|||||||
pub connected_at: chrono::DateTime<chrono::Utc>,
|
pub connected_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attended-consent state for a session (Task 5).
|
||||||
|
///
|
||||||
|
/// Mirrors the `connect_sessions.consent_state` column
|
||||||
|
/// (`not_required` | `pending` | `granted` | `denied`). For an ATTENDED
|
||||||
|
/// (support-code) session the end user must accept a consent prompt before the
|
||||||
|
/// technician's session goes live: the session starts `Pending` and a viewer
|
||||||
|
/// cannot join (and the agent is not asked to stream) until it becomes
|
||||||
|
/// `Granted`. Managed/unattended sessions are `NotRequired` and join freely.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ConsentState {
|
||||||
|
/// Managed/unattended session — no consent prompt (Phase-1 default policy).
|
||||||
|
NotRequired,
|
||||||
|
/// Attended session awaiting the end user's decision. Viewers are blocked.
|
||||||
|
Pending,
|
||||||
|
/// End user accepted — the session may proceed (viewer join / streaming).
|
||||||
|
Granted,
|
||||||
|
/// End user declined, or the prompt timed out — the session is torn down.
|
||||||
|
Denied,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConsentState {
|
||||||
|
/// Database/wire string form (matches the `consent_state` column CHECK).
|
||||||
|
pub fn as_db_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ConsentState::NotRequired => "not_required",
|
||||||
|
ConsentState::Pending => "pending",
|
||||||
|
ConsentState::Granted => "granted",
|
||||||
|
ConsentState::Denied => "denied",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if a viewer is allowed to join a session in this consent state.
|
||||||
|
/// Only `NotRequired` (managed) and `Granted` (consented attended) admit a
|
||||||
|
/// viewer; `Pending` and `Denied` block the join.
|
||||||
|
pub fn allows_viewer(self) -> bool {
|
||||||
|
matches!(self, ConsentState::NotRequired | ConsentState::Granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Heartbeat timeout (90 seconds - 3x the agent's 30 second interval)
|
/// Heartbeat timeout (90 seconds - 3x the agent's 30 second interval)
|
||||||
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
|
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
|
||||||
const HEARTBEAT_TIMEOUT_SECS: u64 = 90;
|
const HEARTBEAT_TIMEOUT_SECS: u64 = 90;
|
||||||
@@ -42,6 +81,8 @@ pub struct Session {
|
|||||||
pub is_streaming: bool,
|
pub is_streaming: bool,
|
||||||
pub is_online: bool, // Whether agent is currently connected
|
pub is_online: bool, // Whether agent is currently connected
|
||||||
pub is_persistent: bool, // Persistent agent (no support code) vs support session
|
pub is_persistent: bool, // Persistent agent (no support code) vs support session
|
||||||
|
/// Attended-consent state (Task 5). Gates viewer join for attended sessions.
|
||||||
|
pub consent_state: ConsentState,
|
||||||
pub last_heartbeat: chrono::DateTime<chrono::Utc>,
|
pub last_heartbeat: chrono::DateTime<chrono::Utc>,
|
||||||
// Agent status info
|
// Agent status info
|
||||||
pub os_version: Option<String>,
|
pub os_version: Option<String>,
|
||||||
@@ -149,6 +190,13 @@ impl SessionManager {
|
|||||||
is_streaming: false,
|
is_streaming: false,
|
||||||
is_online: true,
|
is_online: true,
|
||||||
is_persistent,
|
is_persistent,
|
||||||
|
// Attended (support-code) sessions require consent before a viewer
|
||||||
|
// may join; managed/persistent sessions do not (Phase-1 policy).
|
||||||
|
consent_state: if is_persistent {
|
||||||
|
ConsentState::NotRequired
|
||||||
|
} else {
|
||||||
|
ConsentState::Pending
|
||||||
|
},
|
||||||
last_heartbeat: now,
|
last_heartbeat: now,
|
||||||
os_version: None,
|
os_version: None,
|
||||||
is_elevated: false,
|
is_elevated: false,
|
||||||
@@ -228,6 +276,29 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the attended-consent state for a session (Task 5).
|
||||||
|
///
|
||||||
|
/// Returns the previous state if the session exists, or `None` if it does
|
||||||
|
/// not. Used by the relay to move an attended session through
|
||||||
|
/// `Pending → Granted` (proceed) or `Pending → Denied` (tear down).
|
||||||
|
pub async fn set_consent_state(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
state: ConsentState,
|
||||||
|
) -> Option<ConsentState> {
|
||||||
|
let mut sessions = self.sessions.write().await;
|
||||||
|
let session_data = sessions.get_mut(&session_id)?;
|
||||||
|
let previous = session_data.info.consent_state;
|
||||||
|
session_data.info.consent_state = state;
|
||||||
|
Some(previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current attended-consent state for a session, if it exists.
|
||||||
|
pub async fn get_consent_state(&self, session_id: SessionId) -> Option<ConsentState> {
|
||||||
|
let sessions = self.sessions.read().await;
|
||||||
|
sessions.get(&session_id).map(|s| s.info.consent_state)
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if a session has timed out (no heartbeat for too long)
|
/// Check if a session has timed out (no heartbeat for too long)
|
||||||
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
|
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
|
||||||
pub async fn is_session_timed_out(&self, session_id: SessionId) -> bool {
|
pub async fn is_session_timed_out(&self, session_id: SessionId) -> bool {
|
||||||
@@ -277,6 +348,21 @@ impl SessionManager {
|
|||||||
let mut sessions = self.sessions.write().await;
|
let mut sessions = self.sessions.write().await;
|
||||||
let session_data = sessions.get_mut(&session_id)?;
|
let session_data = sessions.get_mut(&session_id)?;
|
||||||
|
|
||||||
|
// CONSENT GATE (Task 5): an attended session must have the end user's
|
||||||
|
// consent before a viewer may join. A `Pending`/`Denied` session is not
|
||||||
|
// joinable — the technician's session is not surfaced until `Granted`
|
||||||
|
// (or `NotRequired` for managed sessions). This is the enforcement point
|
||||||
|
// that keeps a support-code session invisible to the technician until
|
||||||
|
// the end user accepts.
|
||||||
|
if !session_data.info.consent_state.allows_viewer() {
|
||||||
|
tracing::warn!(
|
||||||
|
"Viewer join refused for session {}: consent_state = {}",
|
||||||
|
session_id,
|
||||||
|
session_data.info.consent_state.as_db_str()
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
let was_empty = session_data.viewers.is_empty();
|
let was_empty = session_data.viewers.is_empty();
|
||||||
|
|
||||||
// Add viewer info
|
// Add viewer info
|
||||||
@@ -519,6 +605,7 @@ impl SessionManager {
|
|||||||
is_streaming: false,
|
is_streaming: false,
|
||||||
is_online: false, // Offline until agent reconnects
|
is_online: false, // Offline until agent reconnects
|
||||||
is_persistent: true,
|
is_persistent: true,
|
||||||
|
consent_state: ConsentState::NotRequired, // managed/persistent
|
||||||
last_heartbeat: now,
|
last_heartbeat: now,
|
||||||
os_version: None,
|
os_version: None,
|
||||||
is_elevated: false,
|
is_elevated: false,
|
||||||
@@ -553,3 +640,95 @@ impl SessionManager {
|
|||||||
session_id
|
session_id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn consent_state_db_strings_match_column_check() {
|
||||||
|
assert_eq!(ConsentState::NotRequired.as_db_str(), "not_required");
|
||||||
|
assert_eq!(ConsentState::Pending.as_db_str(), "pending");
|
||||||
|
assert_eq!(ConsentState::Granted.as_db_str(), "granted");
|
||||||
|
assert_eq!(ConsentState::Denied.as_db_str(), "denied");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_granted_or_not_required_admit_a_viewer() {
|
||||||
|
assert!(ConsentState::NotRequired.allows_viewer());
|
||||||
|
assert!(ConsentState::Granted.allows_viewer());
|
||||||
|
assert!(!ConsentState::Pending.allows_viewer());
|
||||||
|
assert!(!ConsentState::Denied.allows_viewer());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn attended_session_starts_pending_and_blocks_viewer_until_granted() {
|
||||||
|
let mgr = SessionManager::new();
|
||||||
|
|
||||||
|
// Attended (support-code) session: is_persistent = false.
|
||||||
|
let (session_id, _frame_tx, _input_rx) = mgr
|
||||||
|
.register_agent("agent-att".to_string(), "Attended PC".to_string(), false)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Starts Pending.
|
||||||
|
assert_eq!(
|
||||||
|
mgr.get_consent_state(session_id).await,
|
||||||
|
Some(ConsentState::Pending)
|
||||||
|
);
|
||||||
|
|
||||||
|
// A viewer cannot join while Pending.
|
||||||
|
assert!(mgr
|
||||||
|
.join_session(session_id, "viewer-1".to_string(), "Tech".to_string())
|
||||||
|
.await
|
||||||
|
.is_none());
|
||||||
|
|
||||||
|
// Grant consent -> viewer may now join.
|
||||||
|
let previous = mgr
|
||||||
|
.set_consent_state(session_id, ConsentState::Granted)
|
||||||
|
.await;
|
||||||
|
assert_eq!(previous, Some(ConsentState::Pending));
|
||||||
|
assert!(mgr
|
||||||
|
.join_session(session_id, "viewer-2".to_string(), "Tech".to_string())
|
||||||
|
.await
|
||||||
|
.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn managed_session_is_not_required_and_joins_immediately() {
|
||||||
|
let mgr = SessionManager::new();
|
||||||
|
|
||||||
|
// Managed/persistent session: is_persistent = true.
|
||||||
|
let (session_id, _frame_tx, _input_rx) = mgr
|
||||||
|
.register_agent("agent-mgd".to_string(), "Managed PC".to_string(), true)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
mgr.get_consent_state(session_id).await,
|
||||||
|
Some(ConsentState::NotRequired)
|
||||||
|
);
|
||||||
|
|
||||||
|
// No consent prompt — a viewer joins immediately.
|
||||||
|
assert!(mgr
|
||||||
|
.join_session(session_id, "viewer-1".to_string(), "Tech".to_string())
|
||||||
|
.await
|
||||||
|
.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn denied_attended_session_keeps_viewer_blocked() {
|
||||||
|
let mgr = SessionManager::new();
|
||||||
|
let (session_id, _frame_tx, _input_rx) = mgr
|
||||||
|
.register_agent("agent-deny".to_string(), "Deny PC".to_string(), false)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
mgr.set_consent_state(session_id, ConsentState::Denied).await;
|
||||||
|
assert_eq!(
|
||||||
|
mgr.get_consent_state(session_id).await,
|
||||||
|
Some(ConsentState::Denied)
|
||||||
|
);
|
||||||
|
assert!(mgr
|
||||||
|
.join_session(session_id, "viewer-x".to_string(), "Tech".to_string())
|
||||||
|
.await
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -250,10 +250,61 @@ Reference: audit Pass B/E (rate limiting disabled/non-compiling; reusable codes)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task 5: Attended-mode consent
|
## Task 5 [IMPLEMENTED 2026-05-30 — self-reviewed; no Rust toolchain on this machine, not yet `cargo check`-verified; pending Code Review]: Attended-mode consent
|
||||||
|
|
||||||
Files touched: `proto/guruconnect.proto`, `agent/src/session/mod.rs`, `agent/src/` (consent UI dialog),
|
> [IMPLEMENTED] An ATTENDED (support-code) session now requires the end user to
|
||||||
`server/src/relay/mod.rs`, `server/src/session/mod.rs`.
|
> ACCEPT a native consent prompt before the technician's session is surfaced.
|
||||||
|
>
|
||||||
|
> - PROTO (`proto/guruconnect.proto`): added `ConsentRequest` (server/relay →
|
||||||
|
> agent: `session_id`, `technician_name`, `access_mode` (`ConsentAccessMode`
|
||||||
|
> = `CONSENT_VIEW`|`CONSENT_CONTROL`), `timeout_secs`) and `ConsentResponse`
|
||||||
|
> (agent → server: `session_id`, `granted`, `reason`), inserted AFTER
|
||||||
|
> `AdminCommand`. New `Message` oneof field numbers `consent_request = 80`,
|
||||||
|
> `consent_response = 81` (no existing field renumbered).
|
||||||
|
> - SERVER (`session/mod.rs`): new `ConsentState` enum
|
||||||
|
> (`NotRequired|Pending|Granted|Denied`, `as_db_str`/`allows_viewer`) added to
|
||||||
|
> the in-memory `Session`. `register_agent` starts attended (`!is_persistent`)
|
||||||
|
> sessions `Pending`, managed/persistent `NotRequired`. `join_session` REFUSES
|
||||||
|
> any viewer unless `consent_state.allows_viewer()` (only `Granted`/
|
||||||
|
> `NotRequired`) — this is the gate that keeps a support session invisible to
|
||||||
|
> the technician until accepted. New `set_consent_state`/`get_consent_state`.
|
||||||
|
> Unit tests cover the db-string mapping, the viewer-admission predicate, and
|
||||||
|
> the attended-pending-blocks / granted-admits / managed-admits / denied-blocks
|
||||||
|
> transitions.
|
||||||
|
> - SERVER (`relay/mod.rs`): after registering an attended agent,
|
||||||
|
> `run_consent_handshake` sends `ConsentRequest`, audits `consent_requested`,
|
||||||
|
> then waits up to `CONSENT_TIMEOUT_SECS = 60` for a `ConsentResponse`.
|
||||||
|
> granted → `consent_state = Granted` + audit `consent_granted` + proceed.
|
||||||
|
> denied/timeout/agent-disconnect → `consent_state = Denied` + audit
|
||||||
|
> `consent_denied`, send a Disconnect to the agent, end the session row
|
||||||
|
> (`status='denied'`), release the code, and TEAR DOWN (early return — the
|
||||||
|
> technician never sees the session). In-memory consent is authoritative; the
|
||||||
|
> DB `consent_state` (via `db::sessions::update_consent_state`) is a durable/
|
||||||
|
> audit mirror. A late/dup `ConsentResponse` in the main loop is logged+ignored
|
||||||
|
> (no silent unhandled variant). `db::events` gained `CONSENT_GRANTED`/
|
||||||
|
> `CONSENT_DENIED`/`CONSENT_REQUESTED`. `db::sessions::create_session` now sets
|
||||||
|
> `is_managed`/`source`/`consent_state` from `is_support_session` (attended →
|
||||||
|
> `false`/`standalone`/`pending`; managed → `true`/`gururmm`/`not_required`).
|
||||||
|
> `api::SessionInfo` echoes `consent_state` so the dashboard can show "awaiting
|
||||||
|
> consent".
|
||||||
|
> - AGENT (`consent/mod.rs` [new], `session/mod.rs`, `main.rs`): on
|
||||||
|
> `ConsentRequest`, `handle_consent_request` runs a blocking Windows
|
||||||
|
> `MessageBox` (MB_YESNO | TOPMOST | SETFOREGROUND | SYSTEMMODAL | ICONQUESTION)
|
||||||
|
> on `spawn_blocking` so the async loop/heartbeats are not stalled, phrasing
|
||||||
|
> the prompt VIEW vs VIEW-and-CONTROL from `access_mode`, then sends a
|
||||||
|
> `ConsentResponse`. Anything other than an explicit Yes (closed box, panic) is
|
||||||
|
> a DENY. Non-Windows build is a `// TODO(platform)` stub that fails CLOSED
|
||||||
|
> (denies). Unit tests cover the prompt wording + the access-mode decode
|
||||||
|
> fallback.
|
||||||
|
> - Managed/unattended sessions are `not_required`, never prompted (Phase-1
|
||||||
|
> default). PER-TENANT consent policy beyond this default is a future
|
||||||
|
> refinement — left as a TODO (no per-tenant policy table consulted yet).
|
||||||
|
> - No `.unwrap()` on a non-test path; runtime `sqlx::query`; no code/secret/
|
||||||
|
> token value logged. No Rust toolchain here — self-reviewed only.
|
||||||
|
|
||||||
|
Files touched: `proto/guruconnect.proto`, `agent/src/session/mod.rs`, `agent/src/consent/mod.rs` (new),
|
||||||
|
`agent/src/main.rs`, `server/src/relay/mod.rs`, `server/src/session/mod.rs`, `server/src/db/sessions.rs`,
|
||||||
|
`server/src/db/events.rs`, `server/src/api/mod.rs`.
|
||||||
|
|
||||||
- Add `ConsentRequest` / `ConsentResponse` to the proto (after `AdminCommand`).
|
- Add `ConsentRequest` / `ConsentResponse` to the proto (after `AdminCommand`).
|
||||||
- On an attended session, the agent shows a consent dialog to the end user; the server keeps the session
|
- On an attended session, the agent shows a consent dialog to the end user; the server keeps the session
|
||||||
|
|||||||
Reference in New Issue
Block a user