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

View File

@@ -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;

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 viewer_name: Option<String>,
pub details: Option<JsonValue>, pub details: Option<JsonValue>,
pub ip_address: Option<String>, 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 /// Event types for session audit logging
@@ -84,7 +86,7 @@ pub async fn get_session_events(
session_id: Uuid, session_id: Uuid,
) -> Result<Vec<SessionEvent>, sqlx::Error> { ) -> Result<Vec<SessionEvent>, sqlx::Error> {
sqlx::query_as::<_, SessionEvent>( 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) .bind(session_id)
.fetch_all(pool) .fetch_all(pool)
@@ -98,7 +100,7 @@ pub async fn get_recent_events(
limit: i64, limit: i64,
) -> Result<Vec<SessionEvent>, sqlx::Error> { ) -> Result<Vec<SessionEvent>, sqlx::Error> {
sqlx::query_as::<_, SessionEvent>( 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) .bind(limit)
.fetch_all(pool) .fetch_all(pool)
@@ -113,7 +115,7 @@ pub async fn get_events_by_type(
limit: i64, limit: i64,
) -> Result<Vec<SessionEvent>, sqlx::Error> { ) -> Result<Vec<SessionEvent>, sqlx::Error> {
sqlx::query_as::<_, SessionEvent>( 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(event_type)
.bind(limit) .bind(limit)
@@ -128,7 +130,7 @@ pub async fn get_events_for_machine(
) -> Result<Vec<SessionEvent>, sqlx::Error> { ) -> Result<Vec<SessionEvent>, sqlx::Error> {
sqlx::query_as::<_, SessionEvent>( sqlx::query_as::<_, SessionEvent>(
r#" 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 FROM connect_session_events e
JOIN connect_sessions s ON e.session_id = s.id JOIN connect_sessions s ON e.session_id = s.id
WHERE s.machine_id = $1 WHERE s.machine_id = $1

View File

@@ -20,6 +20,8 @@ pub struct Machine {
pub status: String, pub status: String,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_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) /// Get or create a machine by agent_id (upsert)

View File

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

View File

@@ -16,6 +16,15 @@ pub struct DbSession {
pub is_support_session: bool, pub is_support_session: bool,
pub support_code: Option<String>, pub support_code: Option<String>,
pub status: 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 /// Create a new session record

View File

@@ -19,6 +19,11 @@ pub struct DbSupportCode {
pub client_name: Option<String>, pub client_name: Option<String>,
pub client_machine: Option<String>, pub client_machine: Option<String>,
pub connected_at: Option<DateTime<Utc>>, 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 /// 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/ // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
pub last_login: Option<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) /// User without password hash (for API responses)

View File

@@ -1,7 +1,7 @@
# v2 Secure Session Core — Implementation Plan # v2 Secure Session Core — Implementation Plan
> Spec created: 2026-05-29 > 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) > Parent: `docs/specs/SPEC-002-v2-modernization-architecture.md` (Phase 1)
> Keystone: Tasks 14 are the "get-right-first" secure auth/session core — every audit CRITICAL/HIGH > Keystone: Tasks 14 are the "get-right-first" secure auth/session core — every audit CRITICAL/HIGH
> is closed there. Tasks 57 deliver the product capability on top. Do them in order. > is closed there. Tasks 57 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: 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`, `agent_keys.rs` [new], `sessions.rs`, `machines.rs`, `support_codes.rs`, `events.rs`, `users.rs`,