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(),
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user