From fef8111ff306e3f72208dda397486494c100265c Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Fri, 29 May 2026 18:33:26 -0700 Subject: [PATCH] feat(server): v2 secure-session-core Task 1 - schema + per-agent keys 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) --- .../migrations/004_v2_secure_session_core.sql | 114 ++++++++++++++++++ server/src/db/agent_keys.rs | 105 ++++++++++++++++ server/src/db/events.rs | 10 +- server/src/db/machines.rs | 2 + server/src/db/mod.rs | 2 + server/src/db/sessions.rs | 9 ++ server/src/db/support_codes.rs | 5 + server/src/db/tenancy.rs | 31 +++++ server/src/db/users.rs | 3 + specs/v2-secure-session-core/plan.md | 8 +- 10 files changed, 283 insertions(+), 6 deletions(-) create mode 100644 server/migrations/004_v2_secure_session_core.sql create mode 100644 server/src/db/agent_keys.rs create mode 100644 server/src/db/tenancy.rs diff --git a/server/migrations/004_v2_secure_session_core.sql b/server/migrations/004_v2_secure_session_core.sql new file mode 100644 index 0000000..18a0fe2 --- /dev/null +++ b/server/migrations/004_v2_secure_session_core.sql @@ -0,0 +1,114 @@ +-- Migration: 004_v2_secure_session_core.sql +-- Purpose: v2 Secure Session Core (Task 1) - per-agent keys + tenancy-ready schema. +-- Adds the tenants table + a fixed default tenant, the connect_agent_keys +-- table, a nullable tenant_id on every scoped table (backfilled to the +-- default tenant), and the new session/support-code state columns. +-- +-- Idempotent: CREATE TABLE IF NOT EXISTS / ADD COLUMN IF NOT EXISTS / ON CONFLICT. +-- Applied on server startup by sqlx::migrate!(); never pre-applied via psql. +-- See .claude/standards/gururmm/sqlx-migrations.md. + +-- pgcrypto provides gen_random_uuid(); enabled in 001 but re-asserted for safety. +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- ============================================================================ +-- Tenants (tenancy-ready; isolation NOT enforced until Phase 4) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Seed exactly one default tenant with a FIXED, hard-coded UUID. This constant +-- is the Phase-4 multi-tenancy switch point (mirrored by +-- db::tenancy::DEFAULT_TENANT_ID). Idempotent via ON CONFLICT. +INSERT INTO tenants (id, name) +VALUES ('00000000-0000-0000-0000-000000000001', 'Default Tenant') +ON CONFLICT (id) DO NOTHING; + +-- ============================================================================ +-- Per-agent keys (connect_agent_keys) - replaces the shared AGENT_API_KEY +-- ============================================================================ +-- Stores ONLY the key hash. Hashing + cak_ issuance is Task 2; this table +-- persists an already-hashed value. revoked_at set marks a key inactive. + +CREATE TABLE IF NOT EXISTS connect_agent_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + machine_id UUID NOT NULL REFERENCES connect_machines(id) ON DELETE CASCADE, + key_hash TEXT NOT NULL UNIQUE, + tenant_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_connect_agent_keys_machine ON connect_agent_keys(machine_id); +CREATE INDEX IF NOT EXISTS idx_connect_agent_keys_key_hash ON connect_agent_keys(key_hash); + +-- Backfill the agent-keys tenant_id to the default tenant (table is empty on a +-- fresh DB; this is a no-op there but keeps the migration self-consistent). +UPDATE connect_agent_keys +SET tenant_id = '00000000-0000-0000-0000-000000000001' +WHERE tenant_id IS NULL; + +-- ============================================================================ +-- tenant_id on all scoped tables (nullable; backfilled to the default tenant) +-- ============================================================================ + +ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS tenant_id UUID; +ALTER TABLE connect_sessions ADD COLUMN IF NOT EXISTS tenant_id UUID; +ALTER TABLE connect_support_codes ADD COLUMN IF NOT EXISTS tenant_id UUID; +ALTER TABLE connect_session_events ADD COLUMN IF NOT EXISTS tenant_id UUID; +ALTER TABLE users ADD COLUMN IF NOT EXISTS tenant_id UUID; + +UPDATE connect_machines +SET tenant_id = '00000000-0000-0000-0000-000000000001' +WHERE tenant_id IS NULL; + +UPDATE connect_sessions +SET tenant_id = '00000000-0000-0000-0000-000000000001' +WHERE tenant_id IS NULL; + +UPDATE connect_support_codes +SET tenant_id = '00000000-0000-0000-0000-000000000001' +WHERE tenant_id IS NULL; + +UPDATE connect_session_events +SET tenant_id = '00000000-0000-0000-0000-000000000001' +WHERE tenant_id IS NULL; + +UPDATE users +SET tenant_id = '00000000-0000-0000-0000-000000000001' +WHERE tenant_id IS NULL; + +CREATE INDEX IF NOT EXISTS idx_connect_machines_tenant ON connect_machines(tenant_id); +CREATE INDEX IF NOT EXISTS idx_connect_sessions_tenant ON connect_sessions(tenant_id); +CREATE INDEX IF NOT EXISTS idx_connect_support_codes_tenant ON connect_support_codes(tenant_id); +CREATE INDEX IF NOT EXISTS idx_connect_session_events_tenant ON connect_session_events(tenant_id); +CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id); + +-- ============================================================================ +-- Session state columns (managed/unattended + source + consent) +-- ============================================================================ + +ALTER TABLE connect_sessions + ADD COLUMN IF NOT EXISTS is_managed BOOLEAN NOT NULL DEFAULT false; + +-- source: 'standalone' (ad-hoc support code) | 'gururmm' (managed via RMM). +ALTER TABLE connect_sessions + ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'standalone' + CHECK (source IN ('standalone', 'gururmm')); + +-- consent_state: not_required (managed/unattended) | pending | granted | denied. +-- Attended-consent enforcement is Task 5; this is the schema column only. +ALTER TABLE connect_sessions + ADD COLUMN IF NOT EXISTS consent_state TEXT NOT NULL DEFAULT 'not_required' + CHECK (consent_state IN ('not_required', 'pending', 'granted', 'denied')); + +-- ============================================================================ +-- Support-code single-use marker (consumed in Task 4 - schema only here) +-- ============================================================================ + +ALTER TABLE connect_support_codes ADD COLUMN IF NOT EXISTS consumed_at TIMESTAMPTZ; diff --git a/server/src/db/agent_keys.rs b/server/src/db/agent_keys.rs new file mode 100644 index 0000000..59c410f --- /dev/null +++ b/server/src/db/agent_keys.rs @@ -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, + pub created_at: DateTime, + pub last_used_at: Option>, + pub revoked_at: Option>, +} + +/// 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, +) -> Result { + 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, 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(()) +} diff --git a/server/src/db/events.rs b/server/src/db/events.rs index 1e64aba..007b185 100644 --- a/server/src/db/events.rs +++ b/server/src/db/events.rs @@ -18,6 +18,8 @@ pub struct SessionEvent { pub viewer_name: Option, pub details: Option, pub ip_address: Option, + /// Tenancy-ready (Phase 4). Backfilled to the default tenant by migration 004. + pub tenant_id: Option, } /// Event types for session audit logging @@ -84,7 +86,7 @@ pub async fn get_session_events( session_id: Uuid, ) -> Result, 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, 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, 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, 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 diff --git a/server/src/db/machines.rs b/server/src/db/machines.rs index 70121f9..6986436 100644 --- a/server/src/db/machines.rs +++ b/server/src/db/machines.rs @@ -20,6 +20,8 @@ pub struct Machine { pub status: String, pub created_at: DateTime, pub updated_at: DateTime, + /// Tenancy-ready (Phase 4). Backfilled to the default tenant by migration 004. + pub tenant_id: Option, } /// Get or create a machine by agent_id (upsert) diff --git a/server/src/db/mod.rs b/server/src/db/mod.rs index fcb4e2b..655ef81 100644 --- a/server/src/db/mod.rs +++ b/server/src/db/mod.rs @@ -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; diff --git a/server/src/db/sessions.rs b/server/src/db/sessions.rs index a3e4c7e..9867557 100644 --- a/server/src/db/sessions.rs +++ b/server/src/db/sessions.rs @@ -16,6 +16,15 @@ pub struct DbSession { pub is_support_session: bool, pub support_code: Option, pub status: String, + /// Tenancy-ready (Phase 4). Backfilled to the default tenant by migration 004. + pub tenant_id: Option, + /// 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 diff --git a/server/src/db/support_codes.rs b/server/src/db/support_codes.rs index 56181b5..dcde18a 100644 --- a/server/src/db/support_codes.rs +++ b/server/src/db/support_codes.rs @@ -19,6 +19,11 @@ pub struct DbSupportCode { pub client_name: Option, pub client_machine: Option, pub connected_at: Option>, + /// 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>, + /// Tenancy-ready (Phase 4). Backfilled to the default tenant by migration 004. + pub tenant_id: Option, } /// Create a new support code diff --git a/server/src/db/tenancy.rs b/server/src/db/tenancy.rs new file mode 100644 index 0000000..a60aa11 --- /dev/null +++ b/server/src/db/tenancy.rs @@ -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 +} diff --git a/server/src/db/users.rs b/server/src/db/users.rs index e20137f..23e1a8b 100644 --- a/server/src/db/users.rs +++ b/server/src/db/users.rs @@ -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, pub last_login: Option>, + /// 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, } /// User without password hash (for API responses) diff --git a/specs/v2-secure-session-core/plan.md b/specs/v2-secure-session-core/plan.md index 99676eb..98315fa 100644 --- a/specs/v2-secure-session-core/plan.md +++ b/specs/v2-secure-session-core/plan.md @@ -1,7 +1,7 @@ # v2 Secure Session Core — Implementation Plan > Spec created: 2026-05-29 -> Status: not started +> Status: in progress — Task 1 (schema) DONE 2026-05-29; Task 2 (auth) next > Parent: `docs/specs/SPEC-002-v2-modernization-architecture.md` (Phase 1) > Keystone: Tasks 1–4 are the "get-right-first" secure auth/session core — every audit CRITICAL/HIGH > is closed there. Tasks 5–7 deliver the product capability on top. Do them in order. @@ -19,7 +19,11 @@ Do not start Task 1 until this commit exists. --- -## Task 1 (KEYSTONE): v2 schema — per-agent keys + tenancy-ready tables +## Task 1 (KEYSTONE) [DONE 2026-05-29]: v2 schema — per-agent keys + tenancy-ready tables + +> [DONE] migration `004_v2_secure_session_core.sql` + `db/agent_keys.rs` + `db/tenancy.rs` + struct/query +> updates across machines/sessions/support_codes/events/users. Code-reviewed APPROVED. Note: GC's db +> layer already uses runtime `sqlx::query()` (no macros) — the v2 "switch to runtime" was already true. Files touched: `server/migrations/` (new v2 migration files), `server/src/db/` (rebuilt modules: `agent_keys.rs` [new], `sessions.rs`, `machines.rs`, `support_codes.rs`, `events.rs`, `users.rs`,