feat(server): viewer-token view-only/control split - closes CRITICAL #1
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m20s
Build and Test / Build Agent (Windows) (push) Successful in 6m9s
Build and Test / Security Audit (push) Successful in 4m21s
Build and Test / Build Summary (push) Has been skipped

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:
2026-05-29 19:24:32 -07:00
parent 0f258788f9
commit a453e7984e
5 changed files with 256 additions and 34 deletions

View File

@@ -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(),
}))
}