feat(rbac): wire AccessGuard into HTTP server and retrieval pipeline

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.
This commit is contained in:
2026-09-01 08:41:21 -07:00
parent 2448e5ebe2
commit 41cdff3676
3 changed files with 96 additions and 6 deletions
+11 -4
View File
@@ -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
---
+83
View File
@@ -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<Arc<OpenSearchClient>>,
/// M3.8 Query Optimizer (optional, from environment)
pub optimizer_service: Option<Arc<mem_core::optimizer::OptimizerService>>,
/// RBAC Access Guard (optional, for fine-grained access control)
pub access_guard: Option<Arc<AccessGuard>>,
}
/// 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<ResourceMeta> = 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 {
+2 -2
View File
@@ -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()));