feat(server): v2 secure-session-core Task 1 - schema + per-agent keys
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m7s
Build and Test / Build Server (Linux) (push) Successful in 10m15s
Build and Test / Security Audit (push) Successful in 4m24s
Build and Test / Build Summary (push) Successful in 12s

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

Migration 004 (idempotent, server-applied): tenants + seeded default tenant,
connect_agent_keys (hash-only, revocable, FK->connect_machines), nullable
tenant_id on all scoped tables (tenancy-ready, not tenant-yet), connect_sessions
is_managed/source/consent_state, connect_support_codes consumed_at. New db
modules agent_keys.rs (stores only key_hash) + tenancy.rs (DEFAULT_TENANT_ID,
Phase-4 switch point). Struct/query updates across machines/sessions/
support_codes/events/users. Runtime sqlx throughout (GC db layer already uses
it - no compile-time macros).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 18:33:26 -07:00
parent 81e4b99a34
commit fef8111ff3
10 changed files with 283 additions and 6 deletions

105
server/src/db/agent_keys.rs Normal file
View File

@@ -0,0 +1,105 @@
//! Per-agent key database operations.
//!
//! Backs the `connect_agent_keys` table introduced in v2 (migration 004). A
//! per-agent key replaces the shared `AGENT_API_KEY`: each managed machine has
//! one or more revocable keys, stored ONLY as a hash.
//!
//! This module is hash-agnostic persistence. Computing the hash and minting the
//! plaintext `cak_` token is Task 2 - every function here takes an
//! already-hashed value. All queries use runtime `sqlx::query()` /
//! `sqlx::query_as()` per the v2 convention (no compile-time macros).
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
/// Per-agent key record from the database.
///
/// `key_hash` is the only representation of the key the server ever stores; the
/// plaintext is shown once at issuance (Task 2) and never persisted.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
#[allow(dead_code)] // Consumed by agent auth + key issuance endpoints in Task 2.
pub struct AgentKey {
pub id: Uuid,
pub machine_id: Uuid,
pub key_hash: String,
pub tenant_id: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
}
/// Insert a new per-agent key (already hashed) and return its id.
///
/// `tenant_id` is `None`-tolerant; pass `db::tenancy::current_tenant_id()` at
/// the call site once issuance lands in Task 2.
#[allow(dead_code)] // Wired by the key-issuance endpoint in Task 2.
pub async fn insert_agent_key(
pool: &PgPool,
machine_id: Uuid,
key_hash: &str,
tenant_id: Option<Uuid>,
) -> Result<Uuid, sqlx::Error> {
let id = sqlx::query_scalar::<_, Uuid>(
r#"
INSERT INTO connect_agent_keys (machine_id, key_hash, tenant_id)
VALUES ($1, $2, $3)
RETURNING id
"#,
)
.bind(machine_id)
.bind(key_hash)
.bind(tenant_id)
.fetch_one(pool)
.await?;
Ok(id)
}
/// Find an active (non-revoked) key by its hash.
///
/// Returns `None` if no row matches or the matching key has been revoked. Agent
/// authentication (Task 2) hashes the presented `cak_` token and looks it up
/// here, rejecting on `None`.
#[allow(dead_code)] // Wired by agent WS auth in Task 2/3.
pub async fn find_active_by_hash(
pool: &PgPool,
key_hash: &str,
) -> Result<Option<AgentKey>, sqlx::Error> {
sqlx::query_as::<_, AgentKey>(
r#"
SELECT id, machine_id, key_hash, tenant_id, created_at, last_used_at, revoked_at
FROM connect_agent_keys
WHERE key_hash = $1 AND revoked_at IS NULL
"#,
)
.bind(key_hash)
.fetch_optional(pool)
.await
}
/// Revoke a key by id (idempotent: re-revoking keeps the original timestamp).
#[allow(dead_code)] // Wired by the key-revocation endpoint in Task 2.
pub async fn revoke_agent_key(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
UPDATE connect_agent_keys
SET revoked_at = NOW()
WHERE id = $1 AND revoked_at IS NULL
"#,
)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
/// Stamp `last_used_at` on a successful authentication.
#[allow(dead_code)] // Wired by agent WS auth in Task 2/3.
pub async fn touch_last_used(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE connect_agent_keys SET last_used_at = NOW() WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
Ok(())
}

View File

@@ -18,6 +18,8 @@ pub struct SessionEvent {
pub viewer_name: Option<String>,
pub details: Option<JsonValue>,
pub ip_address: Option<String>,
/// Tenancy-ready (Phase 4). Backfilled to the default tenant by migration 004.
pub tenant_id: Option<Uuid>,
}
/// Event types for session audit logging
@@ -84,7 +86,7 @@ pub async fn get_session_events(
session_id: Uuid,
) -> Result<Vec<SessionEvent>, sqlx::Error> {
sqlx::query_as::<_, SessionEvent>(
"SELECT id, session_id, event_type, timestamp, viewer_id, viewer_name, details, ip_address::text as ip_address FROM connect_session_events WHERE session_id = $1 ORDER BY timestamp"
"SELECT id, session_id, event_type, timestamp, viewer_id, viewer_name, details, ip_address::text as ip_address, tenant_id FROM connect_session_events WHERE session_id = $1 ORDER BY timestamp"
)
.bind(session_id)
.fetch_all(pool)
@@ -98,7 +100,7 @@ pub async fn get_recent_events(
limit: i64,
) -> Result<Vec<SessionEvent>, sqlx::Error> {
sqlx::query_as::<_, SessionEvent>(
"SELECT id, session_id, event_type, timestamp, viewer_id, viewer_name, details, ip_address::text as ip_address FROM connect_session_events ORDER BY timestamp DESC LIMIT $1"
"SELECT id, session_id, event_type, timestamp, viewer_id, viewer_name, details, ip_address::text as ip_address, tenant_id FROM connect_session_events ORDER BY timestamp DESC LIMIT $1"
)
.bind(limit)
.fetch_all(pool)
@@ -113,7 +115,7 @@ pub async fn get_events_by_type(
limit: i64,
) -> Result<Vec<SessionEvent>, sqlx::Error> {
sqlx::query_as::<_, SessionEvent>(
"SELECT id, session_id, event_type, timestamp, viewer_id, viewer_name, details, ip_address::text as ip_address FROM connect_session_events WHERE event_type = $1 ORDER BY timestamp DESC LIMIT $2"
"SELECT id, session_id, event_type, timestamp, viewer_id, viewer_name, details, ip_address::text as ip_address, tenant_id FROM connect_session_events WHERE event_type = $1 ORDER BY timestamp DESC LIMIT $2"
)
.bind(event_type)
.bind(limit)
@@ -128,7 +130,7 @@ pub async fn get_events_for_machine(
) -> Result<Vec<SessionEvent>, sqlx::Error> {
sqlx::query_as::<_, SessionEvent>(
r#"
SELECT e.id, e.session_id, e.event_type, e.timestamp, e.viewer_id, e.viewer_name, e.details, e.ip_address::text as ip_address
SELECT e.id, e.session_id, e.event_type, e.timestamp, e.viewer_id, e.viewer_name, e.details, e.ip_address::text as ip_address, e.tenant_id
FROM connect_session_events e
JOIN connect_sessions s ON e.session_id = s.id
WHERE s.machine_id = $1

View File

@@ -20,6 +20,8 @@ pub struct Machine {
pub status: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// Tenancy-ready (Phase 4). Backfilled to the default tenant by migration 004.
pub tenant_id: Option<Uuid>,
}
/// Get or create a machine by agent_id (upsert)

View File

@@ -3,11 +3,13 @@
//! Handles persistence for machines, sessions, and audit logging.
//! Optional - server works without database if DATABASE_URL not set.
pub mod agent_keys;
pub mod events;
pub mod machines;
pub mod releases;
pub mod sessions;
pub mod support_codes;
pub mod tenancy;
pub mod users;
use anyhow::Result;

View File

@@ -16,6 +16,15 @@ pub struct DbSession {
pub is_support_session: bool,
pub support_code: Option<String>,
pub status: String,
/// Tenancy-ready (Phase 4). Backfilled to the default tenant by migration 004.
pub tenant_id: Option<Uuid>,
/// True for managed/unattended (RMM) sessions; false for ad-hoc support.
pub is_managed: bool,
/// Origin of the session: 'standalone' (support code) | 'gururmm' (managed).
pub source: String,
/// Attended-consent state: 'not_required' | 'pending' | 'granted' | 'denied'.
/// Enforcement is Task 5; this column carries the state only.
pub consent_state: String,
}
/// Create a new session record

View File

@@ -19,6 +19,11 @@ pub struct DbSupportCode {
pub client_name: Option<String>,
pub client_machine: Option<String>,
pub connected_at: Option<DateTime<Utc>>,
/// Single-use marker. Set when a code is consumed on first agent bind.
/// Consume logic is Task 4; this column carries the marker only.
pub consumed_at: Option<DateTime<Utc>>,
/// Tenancy-ready (Phase 4). Backfilled to the default tenant by migration 004.
pub tenant_id: Option<Uuid>,
}
/// Create a new support code

31
server/src/db/tenancy.rs Normal file
View File

@@ -0,0 +1,31 @@
//! Tenancy resolution.
//!
//! GuruConnect v2 ships a tenancy-*ready* schema: every scoped table carries a
//! nullable `tenant_id`, and a single fixed default tenant exists. Isolation is
//! NOT enforced in Phase 1 - every call resolves to the default tenant here.
//!
//! Phase 4 (multi-tenancy activation) flips this module to resolve the tenant
//! from the authenticated principal (viewer JWT claim / per-agent key row)
//! instead of returning the constant. Keeping the resolution behind a single
//! function means no schema rewrite and no scattered call-site changes.
use uuid::Uuid;
/// The fixed default tenant UUID.
///
/// Must stay in lockstep with the seed row in
/// `migrations/004_v2_secure_session_core.sql`
/// (`00000000-0000-0000-0000-000000000001`).
pub const DEFAULT_TENANT_ID: Uuid = Uuid::from_u128(0x0000_0000_0000_0000_0000_0000_0000_0001);
/// Resolve the tenant for the current operation.
///
/// PHASE-4 SWITCH POINT: today this unconditionally returns
/// [`DEFAULT_TENANT_ID`]. When multi-tenancy is activated, replace the body
/// with resolution from the authenticated principal; the signature should grow
/// the principal/context argument at that time. All scoped inserts route their
/// `tenant_id` through this function so the cutover is localized here.
#[allow(dead_code)] // Wired into inserts as call sites adopt tenancy (Tasks 2-4); single Phase-4 switch point.
pub fn current_tenant_id() -> Uuid {
DEFAULT_TENANT_ID
}

View File

@@ -19,6 +19,9 @@ pub struct User {
// TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
pub updated_at: DateTime<Utc>,
pub last_login: Option<DateTime<Utc>>,
/// Tenancy-ready (Phase 4). Backfilled to the default tenant by migration 004.
#[allow(dead_code)] // Surfaced by tenant-aware auth in Phase 4; column exists now.
pub tenant_id: Option<Uuid>,
}
/// User without password hash (for API responses)