feat(server): viewer-token view-only/control split - closes CRITICAL #1
Authz-strength fix (coord todo c8916c89), code-reviewed APPROVED. Replaces the
weak "view" gate (held by every role) with a permission-tiered access mode
stamped inside the signed viewer token:
- mint: is_admin() || has_permission("control") -> CONTROL token; else
has_permission("view") -> VIEW_ONLY token; else 403.
- enforce: the relay drops MouseEvent/KeyEvent/SpecialKey for a VIEW_ONLY token
before forwarding (video still streams); CONTROL tokens forward under the
Task-3 throttle. Mode is unforgeable (in the signature) and unbypassable
(all other viewer->agent payloads hit the catch-all and are never forwarded).
A low-privilege viewer-role user can now at most watch, never control. New
ViewerAccess enum (view_only|control) on ViewerClaims; 3 unit tests.
Audit CRITICAL #1 now fully closed (mechanism in Task 3 + this authz strength).
Not cargo-check-verified locally (no toolchain) - the push triggers CI
(clippy -D warnings + build + test) which is the verification gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,15 +7,25 @@
|
|||||||
//! verifies signature + expiry + blacklist + that the token's `session_id`
|
//! verifies signature + expiry + blacklist + that the token's `session_id`
|
||||||
//! matches the requested session.
|
//! matches the requested session.
|
||||||
//!
|
//!
|
||||||
//! Authorization (Phase 1): the requester must present a valid dashboard JWT
|
//! Authorization (Phase 1) — TIERED by permission (the authz-strength split that
|
||||||
//! (the [`AuthenticatedUser`] extractor), MUST be authorized to view sessions
|
//! fully closes audit CRITICAL #1). The requester must present a valid dashboard
|
||||||
//! (`is_admin()` OR the `view` permission — see [`SESSION_VIEW_PERMISSION`]),
|
//! JWT (the [`AuthenticatedUser`] extractor), the target session must exist in
|
||||||
//! AND the target session must exist in the live session manager. This minting
|
//! the live session manager, AND the access mode of the minted token is decided
|
||||||
//! endpoint is the authorization decision point: it is what actually closes
|
//! by the requester's permission:
|
||||||
//! audit CRITICAL #1 (any authenticated user could previously obtain viewer
|
//! - `is_admin()` OR the `control` permission (see [`SESSION_CONTROL_PERMISSION`])
|
||||||
//! access to any session). The WS layer then trusts the session-scoped token.
|
//! → a CONTROL token (input is forwarded to the agent).
|
||||||
//! Per-tenant / per-machine ACL narrowing is a Phase-4 concern; the tenancy
|
//! - else the `view` permission (see [`SESSION_VIEW_PERMISSION`])
|
||||||
//! claim is already carried so the WS and future phases can enforce it.
|
//! → a VIEW-ONLY token (the relay refuses to forward this viewer's input;
|
||||||
|
//! video still streams).
|
||||||
|
//! - else → 403 (standard envelope).
|
||||||
|
//!
|
||||||
|
//! This is why the gate is no longer a single `view` check: `view` is held by
|
||||||
|
//! EVERY default role (incl. `viewer`), so a `view`-only token still granting
|
||||||
|
//! input control was an intra-tenant privilege escalation. The access mode is
|
||||||
|
//! stamped INSIDE the signed token; the relay enforces it (it cannot be forged
|
||||||
|
//! or upgraded client-side). This minting endpoint is the authorization decision
|
||||||
|
//! point. Per-tenant / per-machine ACL narrowing is a Phase-4 concern; the
|
||||||
|
//! tenancy claim is already carried so the WS and future phases can enforce it.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
@@ -25,7 +35,7 @@ use axum::{
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::AuthenticatedUser;
|
use crate::auth::{AuthenticatedUser, ViewerAccess};
|
||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
@@ -34,12 +44,17 @@ use super::machine_keys::ApiError;
|
|||||||
type ApiResult<T> = Result<T, (StatusCode, Json<ApiError>)>;
|
type ApiResult<T> = Result<T, (StatusCode, Json<ApiError>)>;
|
||||||
|
|
||||||
/// Permission (in GC's existing catalog — see `api::users` `valid_permissions`:
|
/// Permission (in GC's existing catalog — see `api::users` `valid_permissions`:
|
||||||
/// `view`, `control`, `transfer`, `manage_users`, `manage_clients`) that gates
|
/// `view`, `control`, `transfer`, `manage_users`, `manage_clients`) that grants
|
||||||
/// obtaining a viewer token. `view` is the established "may view sessions"
|
/// a CONTROL viewer token (input is forwarded to the agent). Held by the default
|
||||||
/// permission, held by every non-degraded role (admin/operator/viewer) and
|
/// `admin` (implicitly, via `is_admin()`) and `operator` roles, NOT by `viewer`.
|
||||||
/// required of `viewer`. Admins bypass it via `is_admin()`. No NEW permission is
|
/// This is the discriminator that closes the authz-strength gap: a viewer token
|
||||||
/// introduced: the intra-tenant role distinction is honored using the existing
|
/// no longer implies input control.
|
||||||
/// `view` grant. (Policy DECIDED — Mike, 2026-05-29: admin-or-view-permission.)
|
const SESSION_CONTROL_PERMISSION: &str = "control";
|
||||||
|
|
||||||
|
/// Permission that grants a VIEW-ONLY viewer token (watch only; the relay drops
|
||||||
|
/// this viewer's input). `view` is held by every default role (admin/operator/
|
||||||
|
/// viewer), so it is the floor for obtaining ANY viewer token. A user with
|
||||||
|
/// neither `control` nor `view` cannot mint a token at all (403).
|
||||||
const SESSION_VIEW_PERMISSION: &str = "view";
|
const SESSION_VIEW_PERMISSION: &str = "view";
|
||||||
|
|
||||||
/// Response carrying a freshly minted viewer token.
|
/// Response carrying a freshly minted viewer token.
|
||||||
@@ -52,6 +67,12 @@ pub struct ViewerTokenResponse {
|
|||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
/// Token lifetime in seconds (for client-side refresh scheduling).
|
/// Token lifetime in seconds (for client-side refresh scheduling).
|
||||||
pub expires_in_secs: i64,
|
pub expires_in_secs: i64,
|
||||||
|
/// Access mode granted by this token: `"control"` (input forwarded) or
|
||||||
|
/// `"view_only"` (watch only — the relay drops this viewer's input). Decided
|
||||||
|
/// server-side by the requester's permission; echoed so the (future) UI can
|
||||||
|
/// reflect the mode. Authoritative enforcement is the signed claim, not this
|
||||||
|
/// field.
|
||||||
|
pub access: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a standard error envelope (mirrors `machine_keys`).
|
/// Build a standard error envelope (mirrors `machine_keys`).
|
||||||
@@ -77,15 +98,24 @@ pub async fn mint_viewer_token(
|
|||||||
let session_id = Uuid::parse_str(&id)
|
let session_id = Uuid::parse_str(&id)
|
||||||
.map_err(|_| err(StatusCode::BAD_REQUEST, "INVALID_SESSION_ID", "Invalid session ID"))?;
|
.map_err(|_| err(StatusCode::BAD_REQUEST, "INVALID_SESSION_ID", "Invalid session ID"))?;
|
||||||
|
|
||||||
// AUTHORIZATION GATE (closes audit CRITICAL #1). Authentication alone is not
|
// TIERED AUTHORIZATION GATE (closes audit CRITICAL #1 — authz-strength split).
|
||||||
// enough: the user must be an admin OR hold the `view` permission. A user
|
// Authentication alone is not enough, and a single `view` check is too coarse
|
||||||
// with no view grant cannot obtain a viewer token, and therefore cannot join
|
// because `view` is held by EVERY default role: granting a `viewer`-role user
|
||||||
// any session's viewer WS (which now requires this session-scoped token).
|
// an input-capable token was intra-tenant privilege escalation. So the access
|
||||||
if !(user.is_admin() || user.has_permission(SESSION_VIEW_PERMISSION)) {
|
// mode is decided by permission and stamped into the signed token:
|
||||||
|
// - admin OR `control` → CONTROL token (relay forwards input)
|
||||||
|
// - else `view` → VIEW-ONLY token (relay drops input)
|
||||||
|
// - else → 403
|
||||||
|
let access = if user.is_admin() || user.has_permission(SESSION_CONTROL_PERMISSION) {
|
||||||
|
ViewerAccess::Control
|
||||||
|
} else if user.has_permission(SESSION_VIEW_PERMISSION) {
|
||||||
|
ViewerAccess::ViewOnly
|
||||||
|
} else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"User {} denied viewer-token mint for session {} (lacks '{}' permission)",
|
"User {} denied viewer-token mint for session {} (lacks '{}'/'{}' permission)",
|
||||||
user.username,
|
user.username,
|
||||||
session_id,
|
session_id,
|
||||||
|
SESSION_CONTROL_PERMISSION,
|
||||||
SESSION_VIEW_PERMISSION
|
SESSION_VIEW_PERMISSION
|
||||||
);
|
);
|
||||||
return Err(err(
|
return Err(err(
|
||||||
@@ -93,7 +123,7 @@ pub async fn mint_viewer_token(
|
|||||||
"FORBIDDEN",
|
"FORBIDDEN",
|
||||||
"You do not have permission to view sessions",
|
"You do not have permission to view sessions",
|
||||||
));
|
));
|
||||||
}
|
};
|
||||||
|
|
||||||
// The session must exist (live session manager is the
|
// The session must exist (live session manager is the
|
||||||
// source of truth for joinable sessions, matching GET /api/sessions/:id).
|
// source of truth for joinable sessions, matching GET /api/sessions/:id).
|
||||||
@@ -111,7 +141,7 @@ pub async fn mint_viewer_token(
|
|||||||
|
|
||||||
let token = state
|
let token = state
|
||||||
.jwt_config
|
.jwt_config
|
||||||
.create_viewer_token(&user.user_id, session_id, tenant_id)
|
.create_viewer_token(&user.user_id, session_id, tenant_id, access)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("Failed to mint viewer token: {}", e);
|
tracing::error!("Failed to mint viewer token: {}", e);
|
||||||
err(
|
err(
|
||||||
@@ -122,8 +152,9 @@ pub async fn mint_viewer_token(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"User {} minted a viewer token for session {} (agent {})",
|
"User {} minted a {} viewer token for session {} (agent {})",
|
||||||
user.username,
|
user.username,
|
||||||
|
access.as_str(),
|
||||||
session_id,
|
session_id,
|
||||||
session.agent_id
|
session.agent_id
|
||||||
);
|
);
|
||||||
@@ -132,5 +163,6 @@ pub async fn mint_viewer_token(
|
|||||||
token,
|
token,
|
||||||
session_id: session_id.to_string(),
|
session_id: session_id.to_string(),
|
||||||
expires_in_secs: crate::auth::jwt::VIEWER_TOKEN_TTL_SECS,
|
expires_in_secs: crate::auth::jwt::VIEWER_TOKEN_TTL_SECS,
|
||||||
|
access: access.as_str().to_string(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,49 @@ pub const VIEWER_TOKEN_PURPOSE: &str = "viewer";
|
|||||||
/// Default lifetime of a minted viewer token, in seconds (5 minutes).
|
/// Default lifetime of a minted viewer token, in seconds (5 minutes).
|
||||||
pub const VIEWER_TOKEN_TTL_SECS: i64 = 300;
|
pub const VIEWER_TOKEN_TTL_SECS: i64 = 300;
|
||||||
|
|
||||||
|
/// Access mode carried inside a viewer token's signed claims.
|
||||||
|
///
|
||||||
|
/// This is the authz-strength split that fully closes audit CRITICAL #1. A
|
||||||
|
/// `view`-permission-only user (e.g. the `viewer` role) is minted a
|
||||||
|
/// [`ViewerAccess::ViewOnly`] token; an admin or a `control`-permission user
|
||||||
|
/// (admin/operator) is minted a [`ViewerAccess::Control`] token. The mode lives
|
||||||
|
/// INSIDE the signed token (it cannot be forged client-side) and is enforced at
|
||||||
|
/// the relay: for a view-only token the relay refuses to forward ANY input event
|
||||||
|
/// to the agent while still streaming video out to the viewer.
|
||||||
|
///
|
||||||
|
/// Serialized as a lowercase string (`"view_only"` / `"control"`) so the wire
|
||||||
|
/// form is stable and human-readable in logs/debugging. Mirrors the proto's
|
||||||
|
/// `SessionType.VIEW_ONLY` vs `SCREEN_CONTROL` distinction at the token layer;
|
||||||
|
/// tying the agent-side capture mode to it is a deeper Phase-2 refinement (for
|
||||||
|
/// now the relay simply refuses to forward input for a view-only token — the
|
||||||
|
/// agent still performs full capture).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ViewerAccess {
|
||||||
|
/// Watch-only. The relay drops all viewer→agent input events; video still
|
||||||
|
/// streams out to the viewer.
|
||||||
|
#[serde(rename = "view_only")]
|
||||||
|
ViewOnly,
|
||||||
|
/// Full control. The relay forwards input (subject to the existing Task-3
|
||||||
|
/// rate throttle).
|
||||||
|
#[serde(rename = "control")]
|
||||||
|
Control,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ViewerAccess {
|
||||||
|
/// True if this token may forward input to the agent.
|
||||||
|
pub fn can_control(&self) -> bool {
|
||||||
|
matches!(self, ViewerAccess::Control)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable lowercase wire/log string.
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ViewerAccess::ViewOnly => "view_only",
|
||||||
|
ViewerAccess::Control => "control",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Claims for a session-scoped viewer token.
|
/// Claims for a session-scoped viewer token.
|
||||||
///
|
///
|
||||||
/// Minted by an authenticated + authorized dashboard user for ONE specific
|
/// Minted by an authenticated + authorized dashboard user for ONE specific
|
||||||
@@ -76,6 +119,12 @@ pub struct ViewerClaims {
|
|||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
/// Tenancy scope (Phase-4-ready). Resolved via `db::tenancy` at mint time.
|
/// Tenancy scope (Phase-4-ready). Resolved via `db::tenancy` at mint time.
|
||||||
pub tenant_id: String,
|
pub tenant_id: String,
|
||||||
|
/// Access mode (authz-strength split, audit CRITICAL #1). Determined at mint
|
||||||
|
/// time by the requester's permission: `control`/admin → [`ViewerAccess::Control`],
|
||||||
|
/// `view`-only → [`ViewerAccess::ViewOnly`]. The relay reads this from the
|
||||||
|
/// VERIFIED token and refuses to forward input for a view-only token. Being
|
||||||
|
/// inside the signed claims, it cannot be forged or upgraded client-side.
|
||||||
|
pub access: ViewerAccess,
|
||||||
/// Fixed discriminator — always [`VIEWER_TOKEN_PURPOSE`].
|
/// Fixed discriminator — always [`VIEWER_TOKEN_PURPOSE`].
|
||||||
pub purpose: String,
|
pub purpose: String,
|
||||||
/// Expiration time (unix timestamp).
|
/// Expiration time (unix timestamp).
|
||||||
@@ -168,17 +217,25 @@ impl JwtConfig {
|
|||||||
Ok(token_data.claims)
|
Ok(token_data.claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mint a short-lived, session-scoped viewer token.
|
/// Mint a short-lived, session-scoped viewer token with an explicit access
|
||||||
|
/// mode.
|
||||||
///
|
///
|
||||||
/// Signed with the same secret as login tokens but carrying a distinct
|
/// Signed with the same secret as login tokens but carrying a distinct
|
||||||
/// [`ViewerClaims`] shape and a fixed `purpose`. TTL defaults to
|
/// [`ViewerClaims`] shape and a fixed `purpose`. TTL defaults to
|
||||||
/// [`VIEWER_TOKEN_TTL_SECS`] (5 minutes) — short by design so a leaked
|
/// [`VIEWER_TOKEN_TTL_SECS`] (5 minutes) — short by design so a leaked
|
||||||
/// viewer token has a tiny window and no standing access is granted.
|
/// viewer token has a tiny window and no standing access is granted.
|
||||||
|
///
|
||||||
|
/// `access` is decided by the caller from the requester's permission
|
||||||
|
/// (`mint_viewer_token`): a `view`-only user gets [`ViewerAccess::ViewOnly`]
|
||||||
|
/// (the relay drops their input), an admin/`control` user gets
|
||||||
|
/// [`ViewerAccess::Control`]. The mode is stamped into the signed claims and
|
||||||
|
/// therefore cannot be forged or upgraded by the client.
|
||||||
pub fn create_viewer_token(
|
pub fn create_viewer_token(
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
session_id: Uuid,
|
session_id: Uuid,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
|
access: ViewerAccess,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let exp = now + Duration::seconds(VIEWER_TOKEN_TTL_SECS);
|
let exp = now + Duration::seconds(VIEWER_TOKEN_TTL_SECS);
|
||||||
@@ -187,6 +244,7 @@ impl JwtConfig {
|
|||||||
sub: user_id.to_string(),
|
sub: user_id.to_string(),
|
||||||
session_id: session_id.to_string(),
|
session_id: session_id.to_string(),
|
||||||
tenant_id: tenant_id.to_string(),
|
tenant_id: tenant_id.to_string(),
|
||||||
|
access,
|
||||||
purpose: VIEWER_TOKEN_PURPOSE.to_string(),
|
purpose: VIEWER_TOKEN_PURPOSE.to_string(),
|
||||||
exp: exp.timestamp(),
|
exp: exp.timestamp(),
|
||||||
iat: now.timestamp(),
|
iat: now.timestamp(),
|
||||||
@@ -268,4 +326,52 @@ mod tests {
|
|||||||
let config = JwtConfig::new("test-secret".to_string(), 24);
|
let config = JwtConfig::new("test-secret".to_string(), 24);
|
||||||
assert!(config.validate_token("invalid.token.here").is_err());
|
assert!(config.validate_token("invalid.token.here").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_viewer_token_carries_access_mode() {
|
||||||
|
let config = JwtConfig::new("test-secret".to_string(), 24);
|
||||||
|
let session_id = Uuid::new_v4();
|
||||||
|
let tenant_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
// A control token round-trips with Control access.
|
||||||
|
let control = config
|
||||||
|
.create_viewer_token("user-1", session_id, tenant_id, ViewerAccess::Control)
|
||||||
|
.unwrap();
|
||||||
|
let claims = config.validate_viewer_token(&control).unwrap();
|
||||||
|
assert_eq!(claims.access, ViewerAccess::Control);
|
||||||
|
assert!(claims.access.can_control());
|
||||||
|
assert_eq!(claims.session_id, session_id.to_string());
|
||||||
|
|
||||||
|
// A view-only token round-trips with ViewOnly access and cannot control.
|
||||||
|
let view_only = config
|
||||||
|
.create_viewer_token("user-2", session_id, tenant_id, ViewerAccess::ViewOnly)
|
||||||
|
.unwrap();
|
||||||
|
let claims = config.validate_viewer_token(&view_only).unwrap();
|
||||||
|
assert_eq!(claims.access, ViewerAccess::ViewOnly);
|
||||||
|
assert!(!claims.access.can_control());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_viewer_access_serializes_lowercase() {
|
||||||
|
// Wire form must be the stable lowercase strings the proto mirrors.
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&ViewerAccess::ViewOnly).unwrap(),
|
||||||
|
"\"view_only\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&ViewerAccess::Control).unwrap(),
|
||||||
|
"\"control\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_login_token_rejected_as_viewer_token() {
|
||||||
|
// A login JWT must never satisfy the viewer-token validator (no viewer
|
||||||
|
// claim shape / purpose), so it can never carry a forged access mode.
|
||||||
|
let config = JwtConfig::new("test-secret".to_string(), 24);
|
||||||
|
let login = config
|
||||||
|
.create_token(Uuid::new_v4(), "u", "admin", vec!["view".to_string()])
|
||||||
|
.unwrap();
|
||||||
|
assert!(config.validate_viewer_token(&login).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ pub mod jwt;
|
|||||||
pub mod password;
|
pub mod password;
|
||||||
pub mod token_blacklist;
|
pub mod token_blacklist;
|
||||||
|
|
||||||
pub use jwt::{Claims, JwtConfig, ViewerClaims};
|
pub use jwt::{Claims, JwtConfig, ViewerAccess, ViewerClaims};
|
||||||
pub use password::{generate_random_password, hash_password, verify_password};
|
pub use password::{generate_random_password, hash_password, verify_password};
|
||||||
pub use token_blacklist::TokenBlacklist;
|
pub use token_blacklist::TokenBlacklist;
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use std::net::SocketAddr;
|
|||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth::ViewerAccess;
|
||||||
use crate::db::{self, Database};
|
use crate::db::{self, Database};
|
||||||
use crate::proto;
|
use crate::proto;
|
||||||
use crate::session::SessionManager;
|
use crate::session::SessionManager;
|
||||||
@@ -439,9 +440,17 @@ pub async fn viewer_ws_handler(
|
|||||||
return Err(StatusCode::FORBIDDEN);
|
return Err(StatusCode::FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The access mode comes from the VERIFIED token claims (signed; cannot be
|
||||||
|
// forged or upgraded by the client). The relay enforces it: a view-only token
|
||||||
|
// has its input silently dropped, a control token forwards input as today.
|
||||||
|
let access = claims.access;
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Viewer (user {}) authenticated via session-scoped token for session {} from {}",
|
"Viewer (user {}) authenticated via session-scoped {} token for session {} from {}",
|
||||||
claims.sub, requested_session_id, client_ip
|
claims.sub,
|
||||||
|
access.as_str(),
|
||||||
|
requested_session_id,
|
||||||
|
client_ip
|
||||||
);
|
);
|
||||||
|
|
||||||
let session_id = params.session_id;
|
let session_id = params.session_id;
|
||||||
@@ -463,6 +472,7 @@ pub async fn viewer_ws_handler(
|
|||||||
db,
|
db,
|
||||||
session_id,
|
session_id,
|
||||||
viewer_name,
|
viewer_name,
|
||||||
|
access,
|
||||||
Some(client_ip),
|
Some(client_ip),
|
||||||
)
|
)
|
||||||
}))
|
}))
|
||||||
@@ -773,12 +783,19 @@ async fn handle_agent_connection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a viewer WebSocket connection
|
/// Handle a viewer WebSocket connection
|
||||||
|
///
|
||||||
|
/// `access` is the VERIFIED access mode from the viewer token's signed claims.
|
||||||
|
/// For [`ViewerAccess::ViewOnly`] the relay refuses to forward ANY input event
|
||||||
|
/// to the agent (video still streams out); for [`ViewerAccess::Control`] input
|
||||||
|
/// is forwarded subject to the per-viewer rate throttle below.
|
||||||
|
#[allow(clippy::too_many_arguments)] // signature mirrors the relay/session protocol contract; refactor into a params struct tracked in docs/specs/native-remote-control/
|
||||||
async fn handle_viewer_connection(
|
async fn handle_viewer_connection(
|
||||||
socket: WebSocket,
|
socket: WebSocket,
|
||||||
sessions: SessionManager,
|
sessions: SessionManager,
|
||||||
db: Option<Database>,
|
db: Option<Database>,
|
||||||
session_id_str: String,
|
session_id_str: String,
|
||||||
viewer_name: String,
|
viewer_name: String,
|
||||||
|
access: ViewerAccess,
|
||||||
client_ip: Option<std::net::IpAddr>,
|
client_ip: Option<std::net::IpAddr>,
|
||||||
) {
|
) {
|
||||||
// Parse session ID
|
// Parse session ID
|
||||||
@@ -850,6 +867,10 @@ async fn handle_viewer_connection(
|
|||||||
let mut input_tokens: f64 = VIEWER_INPUT_EVENTS_PER_SEC as f64;
|
let mut input_tokens: f64 = VIEWER_INPUT_EVENTS_PER_SEC as f64;
|
||||||
let mut last_refill = std::time::Instant::now();
|
let mut last_refill = std::time::Instant::now();
|
||||||
let mut dropped_input: u64 = 0;
|
let mut dropped_input: u64 = 0;
|
||||||
|
// Count of input events refused solely because this is a view-only token, so
|
||||||
|
// the refusal can be observed (logged once-per-power-of-two) without leaking
|
||||||
|
// event contents or spamming the log on a noisy viewer.
|
||||||
|
let mut refused_viewonly_input: u64 = 0;
|
||||||
|
|
||||||
// Main loop: receive input from viewer and forward to agent
|
// Main loop: receive input from viewer and forward to agent
|
||||||
while let Some(msg) = ws_receiver.next().await {
|
while let Some(msg) = ws_receiver.next().await {
|
||||||
@@ -859,9 +880,34 @@ async fn handle_viewer_connection(
|
|||||||
match proto::Message::decode(data.as_ref()) {
|
match proto::Message::decode(data.as_ref()) {
|
||||||
Ok(proto_msg) => {
|
Ok(proto_msg) => {
|
||||||
match &proto_msg.payload {
|
match &proto_msg.payload {
|
||||||
|
Some(proto::message::Payload::MouseEvent(_))
|
||||||
|
| Some(proto::message::Payload::KeyEvent(_))
|
||||||
|
| Some(proto::message::Payload::SpecialKey(_))
|
||||||
|
if !access.can_control() =>
|
||||||
|
{
|
||||||
|
// VIEW-ONLY ENFORCEMENT (authz-strength split,
|
||||||
|
// audit CRITICAL #1). This viewer holds a
|
||||||
|
// view-only token: the relay refuses to forward
|
||||||
|
// ANY injected-input event to the agent. We drop
|
||||||
|
// it silently (the viewer keeps receiving video).
|
||||||
|
// The access mode came from the SIGNED token, so a
|
||||||
|
// view-only viewer cannot escalate to control by
|
||||||
|
// crafting messages — the relay simply never
|
||||||
|
// relays their input.
|
||||||
|
refused_viewonly_input += 1;
|
||||||
|
if refused_viewonly_input.is_power_of_two() {
|
||||||
|
warn!(
|
||||||
|
"View-only viewer {} sent input on session {}; \
|
||||||
|
refusing to forward ({} input events refused so far)",
|
||||||
|
viewer_id, session_id, refused_viewonly_input
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(proto::message::Payload::MouseEvent(_))
|
Some(proto::message::Payload::MouseEvent(_))
|
||||||
| Some(proto::message::Payload::KeyEvent(_))
|
| Some(proto::message::Payload::KeyEvent(_))
|
||||||
| Some(proto::message::Payload::SpecialKey(_)) => {
|
| Some(proto::message::Payload::SpecialKey(_)) => {
|
||||||
|
// Control token: forward input (subject to the
|
||||||
|
// per-viewer rate throttle below).
|
||||||
// Refill the token bucket based on elapsed time,
|
// Refill the token bucket based on elapsed time,
|
||||||
// capped at one second's worth of capacity.
|
// capped at one second's worth of capacity.
|
||||||
let elapsed = last_refill.elapsed().as_secs_f64();
|
let elapsed = last_refill.elapsed().as_secs_f64();
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
# v2 Secure Session Core — Implementation Plan
|
# v2 Secure Session Core — Implementation Plan
|
||||||
|
|
||||||
> Spec created: 2026-05-29
|
> Spec created: 2026-05-29
|
||||||
> Status: in progress — Tasks 1-3 DONE 2026-05-29 (Task 3 code-reviewed APPROVED). REQUIRED follow-up
|
> Status: in progress — Tasks 1-3 DONE 2026-05-29 (Task 3 code-reviewed APPROVED). Viewer-token authz
|
||||||
> before Phase-1 exit: viewer-token authz STRENGTH — the gate uses `view` (held by EVERY default role
|
> STRENGTH split IMPLEMENTED 2026-05-29 (self-reviewed; no Rust toolchain on this machine — not yet
|
||||||
> incl. `viewer`) but a viewer token grants input CONTROL; flip to `control`, or split VIEW_ONLY/CONTROL
|
> `cargo check`-verified; pending Code Review). This was the REQUIRED Phase-1-exit follow-up: the gate
|
||||||
> tokens (proto already models SCREEN_CONTROL vs VIEW_ONLY). PENDING Mike. Also: nothing revokes a minted
|
> previously used `view` (held by EVERY default role incl. `viewer`) but a viewer token granted input
|
||||||
> viewer token on logout (bounded by 5-min TTL) — follow-up todo. Task 4 (rate limiting + single-use codes) next.
|
> CONTROL. DECIDED (Mike, 2026-05-29) + IMPLEMENTED: SPLIT VIEW_ONLY/CONTROL tokens — `view`-perm users
|
||||||
|
> get a watch-only token (relay refuses their input), admin/`control` users get a control token. See the
|
||||||
|
> "Task 3 authz-strength fix" block under Task 3 below. Resolves coord todo c8916c89 (coordinator marks
|
||||||
|
> done after review). Remaining follow-up: nothing revokes a minted viewer token on logout (bounded by
|
||||||
|
> 5-min TTL) — follow-up todo. Task 4 (rate limiting + single-use codes) next.
|
||||||
> CARRY-FORWARD: Task 3 MUST add a viewer-token AUTHORIZATION check (admin/permission gate) — Task 2
|
> CARRY-FORWARD: Task 3 MUST add a viewer-token AUTHORIZATION check (admin/permission gate) — Task 2
|
||||||
> fixed only the token *mechanism*; the authz gate is what actually closes audit CRITICAL #1.
|
> fixed only the token *mechanism*; the authz gate is what actually closes audit CRITICAL #1.
|
||||||
> Policy DECIDED (Mike, 2026-05-29): admin-or-view-permission (`is_admin() || has_permission(...)`).
|
> Policy DECIDED (Mike, 2026-05-29): admin-or-view-permission (`is_admin() || has_permission(...)`).
|
||||||
@@ -115,6 +119,40 @@ Reference: `relay/mod.rs:224` (`validate_agent_api_key` — the CRITICAL), `auth
|
|||||||
> `server/src/api/sessions.rs`, `server/src/db/machines.rs`, `server/src/auth/mod.rs`,
|
> `server/src/api/sessions.rs`, `server/src/db/machines.rs`, `server/src/auth/mod.rs`,
|
||||||
> `server/src/auth/jwt.rs`, `server/src/main.rs`.
|
> `server/src/auth/jwt.rs`, `server/src/main.rs`.
|
||||||
|
|
||||||
|
### Task 3 authz-strength fix — VIEW_ONLY/CONTROL token split [IMPLEMENTED 2026-05-29 — self-reviewed; no Rust toolchain on this machine, not yet `cargo check`-verified; pending Code Review]
|
||||||
|
|
||||||
|
> Closes audit CRITICAL #1 at full strength (coord todo c8916c89). The Task-3 gate
|
||||||
|
> minted a viewer token for any `is_admin() || has_permission("view")` user, but `view`
|
||||||
|
> is held by EVERY default role (incl. `viewer`) and the token granted input CONTROL —
|
||||||
|
> intra-tenant privilege escalation. Now the token carries an ACCESS MODE inside its
|
||||||
|
> signed claims and the relay enforces it:
|
||||||
|
>
|
||||||
|
> - `auth/jwt.rs`: new `ViewerAccess` enum (`ViewOnly` | `Control`, serde-renamed to
|
||||||
|
> `"view_only"`/`"control"`); `ViewerClaims` gains an `access: ViewerAccess` field;
|
||||||
|
> `create_viewer_token(..., access)` stamps it; `validate_viewer_token` returns it as
|
||||||
|
> part of the claims (sig+exp+`purpose` checks unchanged). New unit tests cover the
|
||||||
|
> round-trip, the lowercase wire form, and login-JWT rejection.
|
||||||
|
> - `auth/mod.rs`: re-export `ViewerAccess`.
|
||||||
|
> - `api/sessions.rs` (`mint_viewer_token`): TIERED mint — `is_admin() || has_permission("control")`
|
||||||
|
> → CONTROL token; else `has_permission("view")` → VIEW_ONLY token; else → 403 (standard
|
||||||
|
> envelope). Permission constants `SESSION_CONTROL_PERMISSION="control"` /
|
||||||
|
> `SESSION_VIEW_PERMISSION="view"`. Response echoes `access` (advisory; the signed claim
|
||||||
|
> is authoritative).
|
||||||
|
> - `relay/mod.rs`: `viewer_ws_handler` reads `claims.access` from the VERIFIED token and
|
||||||
|
> threads it into `handle_viewer_connection` (new `access: ViewerAccess` param). In the
|
||||||
|
> input path, a view-only token's `MouseEvent`/`KeyEvent`/`SpecialKey` are refused (a
|
||||||
|
> guarded match arm `if !access.can_control()` that silently drops + logs once-per-
|
||||||
|
> power-of-two), BEFORE the throttle/`try_send`. A control token forwards as before (with
|
||||||
|
> the Task-3 throttle). Video still streams to a view-only viewer; chat (not an injected-
|
||||||
|
> input vector) is still relayed. The mode cannot be forged — it lives in the signed token.
|
||||||
|
>
|
||||||
|
> Everything else from Task 3 (session_id-claim match, blacklist, frame caps, throttle,
|
||||||
|
> agent identity binding) is intact — this is purely additive access-mode enforcement.
|
||||||
|
>
|
||||||
|
> PHASE-2 REFINEMENT: this refuses to FORWARD input for a view-only token; it does NOT yet
|
||||||
|
> tie the viewer mode to the agent-side `SessionType.VIEW_ONLY` capture mode (the agent still
|
||||||
|
> does full capture). Deferred (deeper agent change).
|
||||||
|
|
||||||
Files touched: `server/src/relay/mod.rs`, `server/src/session/mod.rs`.
|
Files touched: `server/src/relay/mod.rs`, `server/src/session/mod.rs`.
|
||||||
|
|
||||||
- **`viewer_ws_handler`** (`relay/mod.rs:242`): verify the viewer token's **signature + expiry +
|
- **`viewer_ws_handler`** (`relay/mod.rs:242`): verify the viewer token's **signature + expiry +
|
||||||
|
|||||||
Reference in New Issue
Block a user