feat(server): v2 secure-session-core Task 3 - secure relay WS
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 4m3s
Build and Test / Build Agent (Windows) (push) Successful in 7m48s
Build and Test / Security Audit (push) Successful in 4m20s
Build and Test / Build Summary (push) Has been skipped

SPEC-002 Phase 1 Task 3 (specs/v2-secure-session-core), code-reviewed APPROVED.

- viewer_ws_handler: verify the session-scoped VIEWER token (validate_viewer_token
  sig+exp+purpose) + token_blacklist.is_revoked + session_id claim == requested
  session, before upgrade. Raw login JWTs no longer accepted on the viewer plane
  (closes audit CRITICAL #2; closes the *mechanism* of CRITICAL #1).
- mint_viewer_token: authz gate is_admin() || has_permission("view") -> 403.
- Agent identity binding: validate_agent_api_key returns AgentKeyAuth; a cak_-
  verified agent rebinds to the key's machine identity (fails closed if
  unresolvable), so a key for machine X cannot seize machine Y's session slot.
- Frame caps on both WS upgrades (agent 4 MiB, viewer 64 KiB) - closes WS-OOM HIGH.
- Viewer->agent input throttle (200 ev/s token bucket, bounded try_send) - closes
  input-injection MEDIUM.
- Startup managed-session reconcile clarified.

KNOWN FOLLOW-UPS (tracked todos): (1) authz STRENGTH - the "view" permission is
held by every default role incl. viewer, and a viewer token grants input control,
so the gate should be "control" or a VIEW_ONLY/CONTROL token split; CRITICAL #1 is
mechanism-closed, strength pending decision. (2) revoke minted viewer tokens on
logout (currently bounded only by 5-min TTL). Not cargo-check-verified (no toolchain
on the authoring host).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 19:13:03 -07:00
parent 41691bfb2c
commit 0f258788f9
7 changed files with 340 additions and 64 deletions

View File

@@ -8,11 +8,14 @@
//! matches the requested session.
//!
//! Authorization (Phase 1): the requester must present a valid dashboard JWT
//! (the [`AuthenticatedUser`] extractor) AND the target session must exist in
//! the live session manager. 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. Minting is itself the authorization gate — only an
//! authenticated user can obtain a token, and only for a real session.
//! (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.
use axum::{
extract::{Path, State},
@@ -30,6 +33,15 @@ 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.)
const SESSION_VIEW_PERMISSION: &str = "view";
/// Response carrying a freshly minted viewer token.
#[derive(Debug, Serialize)]
pub struct ViewerTokenResponse {
@@ -65,7 +77,25 @@ 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: the session must exist (live session manager is the
// 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)) {
tracing::warn!(
"User {} denied viewer-token mint for session {} (lacks '{}' permission)",
user.username,
session_id,
SESSION_VIEW_PERMISSION
);
return Err(err(
StatusCode::FORBIDDEN,
"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).
let session = state.sessions.get_session(session_id).await.ok_or_else(|| {
err(