From 41cdff3676b6113570e7c776d70e4be5ed6f1c3e Mon Sep 17 00:00:00 2001 From: rock Date: Tue, 1 Sep 2026 08:41:21 -0700 Subject: [PATCH] feat(rbac): wire AccessGuard into HTTP server and retrieval pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTP Layer Integration: - Add access_guard to AppState with builtin_role_provider - Add to_rbac_claims() to convert JwtClaims → RBAC Claims - Add query_result_to_resource_meta() for result filtering Query Handler (/memory/query): - RBAC filter applied after M3.8 optimization - Batch check_access for all results - Log filtered count per request Context Handler (/memory/context): - Project-level access check before lookup - Return 403 if user lacks project access Code Cleanup: - Move http_server from bin to lib module - Use mem_cli::http_server in main.rs All 660+ tests passing. --- IMPLEMENTATION_STATUS.md | 15 ++++-- crates/mem-cli/src/http_server.rs | 83 +++++++++++++++++++++++++++++++ crates/mem-cli/src/main.rs | 4 +- 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md index 835b7eb..29e69a6 100644 --- a/IMPLEMENTATION_STATUS.md +++ b/IMPLEMENTATION_STATUS.md @@ -2,9 +2,9 @@ ## Summary -**Status**: Phases 1-7 complete with hierarchical RBAC. 660+ tests passing. +**Status**: Phases 1-7 complete with RBAC wired into HTTP + retrieval. 660+ tests passing. -**Latest commit**: Hierarchical RBAC with fine-grained access control +**Latest commit**: RBAC wired into HTTP server and retrieval pipeline --- @@ -106,10 +106,17 @@ - ✅ Built-in roles: `admin`, `portfolio-agent`, `authenticated-user` - ✅ 77 unit tests, 25 integration tests, all passing -### AuthorizedPipeline (Legacy - to be replaced) +### HTTP + Retrieval Integration +- ✅ **AppState.access_guard**: AccessGuard added to HTTP server state +- ✅ **to_rbac_claims()**: Convert JwtClaims to RBAC Claims +- ✅ **query_handler**: RBAC filtering on search results +- ✅ **context_handler**: Project-level access check before lookup +- ✅ **query_result_to_resource_meta()**: Convert results for RBAC filtering + +### AuthorizedPipeline (Legacy - deprecated) - ✅ `AuthorizedPipeline`: wraps FullPipeline with access control - ✅ 13 unit tests, all passing -- ⚠️ Will be replaced by `AccessGuard` integration +- ⚠️ Superseded by AccessGuard integration in http_server.rs --- diff --git a/crates/mem-cli/src/http_server.rs b/crates/mem-cli/src/http_server.rs index 69c6cbf..19c39f5 100644 --- a/crates/mem-cli/src/http_server.rs +++ b/crates/mem-cli/src/http_server.rs @@ -18,6 +18,7 @@ use crate::dual_write_indexer::DualWriteIndexer; use crate::gateway_queue_adapter::GatewayQueueAdapter; use crate::queue_worker::{QueueWorker, QueueWorkerConfig}; use crate::queue_adapter::QueueAdapter; +use crate::rbac::{AccessGuard, Claims as RbacClaims, builtin_role_provider, ResourceMeta, ResourceType, Verb, Visibility}; /// Server state with database and workers pub struct AppState { @@ -35,6 +36,8 @@ pub struct AppState { pub opensearch_client: Option>, /// M3.8 Query Optimizer (optional, from environment) pub optimizer_service: Option>, + /// RBAC Access Guard (optional, for fine-grained access control) + pub access_guard: Option>, } /// Authentication mode @@ -144,6 +147,37 @@ fn extract_rate_limit_key(claims: &JwtClaims) -> String { claims.sub.clone() } +/// Convert JWT claims to RBAC claims for AccessGuard +fn to_rbac_claims(jwt: &JwtClaims) -> RbacClaims { + RbacClaims::new(&jwt.sub) + .with_groups(jwt.groups.clone().unwrap_or_default().iter().map(|s| s.as_str()).collect()) + .with_permissions(jwt.permissions.clone().unwrap_or_default().iter().map(|s| s.as_str()).collect()) +} + +/// Convert QueryResult to ResourceMeta for RBAC filtering +fn query_result_to_resource_meta(result: &crate::query_worker::QueryResult, project: &str) -> ResourceMeta { + let source = result.source.as_deref().unwrap_or("unknown"); + + // Determine resource type from source path + let resource_type = if source.contains("SKILL-") || source.contains("/skills/") { + ResourceType::Skill + } else if result.level == "corpus" || result.level == "R" { + ResourceType::Wiki // Reference docs are wiki-like + } else { + ResourceType::Embedding // L0, L1, L2 are learned embeddings + }; + + // Determine visibility - private if source path suggests it + let visibility = if source.contains("/private/") || source.contains("-private") { + Visibility::Private + } else { + Visibility::Public + }; + + ResourceMeta::new(source, resource_type, project) + .with_visibility(visibility) +} + /// Rate limit guard — call this in handlers to check rate limit fn check_rate_limit(claims: &JwtClaims, state: &AppState, endpoint: &str) -> Result<(), HttpResponse> { let key = extract_rate_limit_key(claims); @@ -328,6 +362,12 @@ pub async fn start_server(port: u16, api_key: String, database_url: &str) -> Res tracing::info!("M8.2 Queue Worker started (background task)"); } + // Initialize RBAC AccessGuard with built-in roles + let access_guard = { + let role_provider = Arc::new(builtin_role_provider()); + Some(Arc::new(AccessGuard::new(role_provider))) + }; + let state = web::Data::new(AppState { api_key, start_time: Instant::now(), @@ -342,6 +382,7 @@ pub async fn start_server(port: u16, api_key: String, database_url: &str) -> Res auth_mode, opensearch_client, optimizer_service, + access_guard, }); tracing::info!("Starting HTTP server on port {}", port); @@ -806,6 +847,31 @@ pub async fn query_handler( // M3.8: Optimize search results if optimizer is available semantic_results = optimize_search_results(semantic_results, state.optimizer_service.as_ref()).await; + // RBAC: Filter results by access control + if let Some(guard) = &state.access_guard { + let rbac_claims = to_rbac_claims(&claims); + let resources: Vec = semantic_results + .iter() + .map(|r| query_result_to_resource_meta(r, &project)) + .collect(); + + let decisions = guard.check_access_batch(&rbac_claims, &resources, Verb::Read).await; + + // Keep only allowed results + semantic_results = semantic_results + .into_iter() + .zip(decisions.iter()) + .filter(|(_, decision)| decision.is_allowed()) + .map(|(result, _)| result) + .collect(); + + tracing::debug!( + "RBAC filtered {} results for user {}", + decisions.iter().filter(|d| d.is_denied()).count(), + claims.sub + ); + } + // Handle different search methods match search_method { "semantic" => { @@ -1037,6 +1103,23 @@ pub async fn context_handler( let scope = body.scope.clone().unwrap_or_else(|| "project".to_string()); let budget = body.budget.unwrap_or(6000); + // RBAC: Check project-level access + if let Some(guard) = &state.access_guard { + let rbac_claims = to_rbac_claims(&claims); + let project_resource = ResourceMeta::new(&project, ResourceType::Project, &project); + + if !guard.can_read(&rbac_claims, &project_resource).await { + tracing::warn!( + "RBAC denied access to project '{}' for user '{}'", + project, claims.sub + ); + return HttpResponse::Forbidden().json(json!({ + "error": "forbidden", + "reason": format!("access denied to project '{}'", project) + })); + } + } + let lookup = crate::context_endpoint::ContextLookup::new(budget, project, scope); match lookup.lookup(body.into_inner()).await { diff --git a/crates/mem-cli/src/main.rs b/crates/mem-cli/src/main.rs index 8b9c2aa..64e61fd 100644 --- a/crates/mem-cli/src/main.rs +++ b/crates/mem-cli/src/main.rs @@ -1,5 +1,5 @@ mod lessons_cmd; -mod http_server; +// http_server is in lib.rs, use mem_cli::http_server mod endpoints; mod ingest_worker; mod query_worker; @@ -227,7 +227,7 @@ async fn main() -> anyhow::Result<()> { Commands::Serve { port, api_key, database_url } => { let api_key = api_key.unwrap_or_else(|| std::env::var("MEM_API_KEY").unwrap_or_else(|_| "test-key".to_string())); let database_url = database_url.unwrap_or_else(|| std::env::var("DATABASE_URL").unwrap_or_else(|_| "postgresql://app:poimen@localhost:5432/memory".to_string())); - http_server::start_server(port, api_key, &database_url).await? + mem_cli::http_server::start_server(port, api_key, &database_url).await? } Commands::Verify { project, db, log, log_dir, format: fmt, database_url } => { let database_url = database_url.unwrap_or_else(|| std::env::var("DATABASE_URL").unwrap_or_else(|_| "postgresql://app:poimen@localhost:5432/memory".to_string()));