feat(server): v2 secure-session-core Task 1 - schema + per-agent keys
All checks were successful
All checks were successful
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:
105
server/src/db/agent_keys.rs
Normal file
105
server/src/db/agent_keys.rs
Normal 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(())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
31
server/src/db/tenancy.rs
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user