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`
|
||||
//! matches the requested session.
|
||||
//!
|
||||
//! Authorization (Phase 1): the requester must present a valid dashboard JWT
|
||||
//! (the [`AuthenticatedUser`] extractor), MUST be authorized to view sessions
|
||||
//! (`is_admin()` OR the `view` permission — see [`SESSION_VIEW_PERMISSION`]),
|
||||
//! AND the target session must exist in the live session manager. This minting
|
||||
//! endpoint is the authorization decision point: it is what actually closes
|
||||
//! audit CRITICAL #1 (any authenticated user could previously obtain viewer
|
||||
//! access to any session). The WS layer then trusts the session-scoped token.
|
||||
//! 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.
|
||||
//! Authorization (Phase 1) — TIERED by permission (the authz-strength split that
|
||||
//! fully closes audit CRITICAL #1). The requester must present a valid dashboard
|
||||
//! JWT (the [`AuthenticatedUser`] extractor), the target session must exist in
|
||||
//! the live session manager, AND the access mode of the minted token is decided
|
||||
//! by the requester's permission:
|
||||
//! - `is_admin()` OR the `control` permission (see [`SESSION_CONTROL_PERMISSION`])
|
||||
//! → a CONTROL token (input is forwarded to the agent).
|
||||
//! - else the `view` permission (see [`SESSION_VIEW_PERMISSION`])
|
||||
//! → 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::{
|
||||
extract::{Path, State},
|
||||
@@ -25,7 +35,7 @@ use axum::{
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthenticatedUser;
|
||||
use crate::auth::{AuthenticatedUser, ViewerAccess};
|
||||
use crate::db;
|
||||
use crate::AppState;
|
||||
|
||||
@@ -34,12 +44,17 @@ use super::machine_keys::ApiError;
|
||||
type ApiResult<T> = Result<T, (StatusCode, Json<ApiError>)>;
|
||||
|
||||
/// Permission (in GC's existing catalog — see `api::users` `valid_permissions`:
|
||||
/// `view`, `control`, `transfer`, `manage_users`, `manage_clients`) that gates
|
||||
/// obtaining a viewer token. `view` is the established "may view sessions"
|
||||
/// permission, held by every non-degraded role (admin/operator/viewer) and
|
||||
/// required of `viewer`. Admins bypass it via `is_admin()`. No NEW permission is
|
||||
/// introduced: the intra-tenant role distinction is honored using the existing
|
||||
/// `view` grant. (Policy DECIDED — Mike, 2026-05-29: admin-or-view-permission.)
|
||||
/// `view`, `control`, `transfer`, `manage_users`, `manage_clients`) that grants
|
||||
/// a CONTROL viewer token (input is forwarded to the agent). Held by the default
|
||||
/// `admin` (implicitly, via `is_admin()`) and `operator` roles, NOT by `viewer`.
|
||||
/// This is the discriminator that closes the authz-strength gap: a viewer token
|
||||
/// no longer implies input control.
|
||||
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";
|
||||
|
||||
/// Response carrying a freshly minted viewer token.
|
||||
@@ -52,6 +67,12 @@ pub struct ViewerTokenResponse {
|
||||
pub session_id: String,
|
||||
/// Token lifetime in seconds (for client-side refresh scheduling).
|
||||
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`).
|
||||
@@ -77,15 +98,24 @@ pub async fn mint_viewer_token(
|
||||
let session_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| err(StatusCode::BAD_REQUEST, "INVALID_SESSION_ID", "Invalid session ID"))?;
|
||||
|
||||
// AUTHORIZATION GATE (closes audit CRITICAL #1). Authentication alone is not
|
||||
// enough: the user must be an admin OR hold the `view` permission. A user
|
||||
// with no view grant cannot obtain a viewer token, and therefore cannot join
|
||||
// any session's viewer WS (which now requires this session-scoped token).
|
||||
if !(user.is_admin() || user.has_permission(SESSION_VIEW_PERMISSION)) {
|
||||
// TIERED AUTHORIZATION GATE (closes audit CRITICAL #1 — authz-strength split).
|
||||
// Authentication alone is not enough, and a single `view` check is too coarse
|
||||
// because `view` is held by EVERY default role: granting a `viewer`-role user
|
||||
// an input-capable token was intra-tenant privilege escalation. So the access
|
||||
// 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!(
|
||||
"User {} denied viewer-token mint for session {} (lacks '{}' permission)",
|
||||
"User {} denied viewer-token mint for session {} (lacks '{}'/'{}' permission)",
|
||||
user.username,
|
||||
session_id,
|
||||
SESSION_CONTROL_PERMISSION,
|
||||
SESSION_VIEW_PERMISSION
|
||||
);
|
||||
return Err(err(
|
||||
@@ -93,7 +123,7 @@ pub async fn mint_viewer_token(
|
||||
"FORBIDDEN",
|
||||
"You do not have permission to view sessions",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// The session must exist (live session manager is the
|
||||
// source of truth for joinable sessions, matching GET /api/sessions/:id).
|
||||
@@ -111,7 +141,7 @@ pub async fn mint_viewer_token(
|
||||
|
||||
let token = state
|
||||
.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| {
|
||||
tracing::error!("Failed to mint viewer token: {}", e);
|
||||
err(
|
||||
@@ -122,8 +152,9 @@ pub async fn mint_viewer_token(
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"User {} minted a viewer token for session {} (agent {})",
|
||||
"User {} minted a {} viewer token for session {} (agent {})",
|
||||
user.username,
|
||||
access.as_str(),
|
||||
session_id,
|
||||
session.agent_id
|
||||
);
|
||||
@@ -132,5 +163,6 @@ pub async fn mint_viewer_token(
|
||||
token,
|
||||
session_id: session_id.to_string(),
|
||||
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).
|
||||
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.
|
||||
///
|
||||
/// Minted by an authenticated + authorized dashboard user for ONE specific
|
||||
@@ -76,6 +119,12 @@ pub struct ViewerClaims {
|
||||
pub session_id: String,
|
||||
/// Tenancy scope (Phase-4-ready). Resolved via `db::tenancy` at mint time.
|
||||
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`].
|
||||
pub purpose: String,
|
||||
/// Expiration time (unix timestamp).
|
||||
@@ -168,17 +217,25 @@ impl JwtConfig {
|
||||
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
|
||||
/// [`ViewerClaims`] shape and a fixed `purpose`. TTL defaults to
|
||||
/// [`VIEWER_TOKEN_TTL_SECS`] (5 minutes) — short by design so a leaked
|
||||
/// 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(
|
||||
&self,
|
||||
user_id: &str,
|
||||
session_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
access: ViewerAccess,
|
||||
) -> Result<String> {
|
||||
let now = Utc::now();
|
||||
let exp = now + Duration::seconds(VIEWER_TOKEN_TTL_SECS);
|
||||
@@ -187,6 +244,7 @@ impl JwtConfig {
|
||||
sub: user_id.to_string(),
|
||||
session_id: session_id.to_string(),
|
||||
tenant_id: tenant_id.to_string(),
|
||||
access,
|
||||
purpose: VIEWER_TOKEN_PURPOSE.to_string(),
|
||||
exp: exp.timestamp(),
|
||||
iat: now.timestamp(),
|
||||
@@ -268,4 +326,52 @@ mod tests {
|
||||
let config = JwtConfig::new("test-secret".to_string(), 24);
|
||||
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 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 token_blacklist::TokenBlacklist;
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ use std::net::SocketAddr;
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::ViewerAccess;
|
||||
use crate::db::{self, Database};
|
||||
use crate::proto;
|
||||
use crate::session::SessionManager;
|
||||
@@ -439,9 +440,17 @@ pub async fn viewer_ws_handler(
|
||||
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!(
|
||||
"Viewer (user {}) authenticated via session-scoped token for session {} from {}",
|
||||
claims.sub, requested_session_id, client_ip
|
||||
"Viewer (user {}) authenticated via session-scoped {} token for session {} from {}",
|
||||
claims.sub,
|
||||
access.as_str(),
|
||||
requested_session_id,
|
||||
client_ip
|
||||
);
|
||||
|
||||
let session_id = params.session_id;
|
||||
@@ -463,6 +472,7 @@ pub async fn viewer_ws_handler(
|
||||
db,
|
||||
session_id,
|
||||
viewer_name,
|
||||
access,
|
||||
Some(client_ip),
|
||||
)
|
||||
}))
|
||||
@@ -773,12 +783,19 @@ async fn handle_agent_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(
|
||||
socket: WebSocket,
|
||||
sessions: SessionManager,
|
||||
db: Option<Database>,
|
||||
session_id_str: String,
|
||||
viewer_name: String,
|
||||
access: ViewerAccess,
|
||||
client_ip: Option<std::net::IpAddr>,
|
||||
) {
|
||||
// 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 last_refill = std::time::Instant::now();
|
||||
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
|
||||
while let Some(msg) = ws_receiver.next().await {
|
||||
@@ -859,9 +880,34 @@ async fn handle_viewer_connection(
|
||||
match proto::Message::decode(data.as_ref()) {
|
||||
Ok(proto_msg) => {
|
||||
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::KeyEvent(_))
|
||||
| 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,
|
||||
// capped at one second's worth of capacity.
|
||||
let elapsed = last_refill.elapsed().as_secs_f64();
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
# v2 Secure Session Core — Implementation Plan
|
||||
|
||||
> Spec created: 2026-05-29
|
||||
> Status: in progress — Tasks 1-3 DONE 2026-05-29 (Task 3 code-reviewed APPROVED). REQUIRED follow-up
|
||||
> before Phase-1 exit: viewer-token authz STRENGTH — the gate uses `view` (held by EVERY default role
|
||||
> incl. `viewer`) but a viewer token grants input CONTROL; flip to `control`, or split VIEW_ONLY/CONTROL
|
||||
> tokens (proto already models SCREEN_CONTROL vs VIEW_ONLY). PENDING Mike. Also: nothing revokes a minted
|
||||
> viewer token on logout (bounded by 5-min TTL) — follow-up todo. Task 4 (rate limiting + single-use codes) next.
|
||||
> Status: in progress — Tasks 1-3 DONE 2026-05-29 (Task 3 code-reviewed APPROVED). Viewer-token authz
|
||||
> STRENGTH split IMPLEMENTED 2026-05-29 (self-reviewed; no Rust toolchain on this machine — not yet
|
||||
> `cargo check`-verified; pending Code Review). This was the REQUIRED Phase-1-exit follow-up: the gate
|
||||
> previously used `view` (held by EVERY default role incl. `viewer`) but a viewer token granted input
|
||||
> 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
|
||||
> 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(...)`).
|
||||
@@ -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/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`.
|
||||
|
||||
- **`viewer_ws_handler`** (`relay/mod.rs:242`): verify the viewer token's **signature + expiry +
|
||||
|
||||
Reference in New Issue
Block a user