From dae9483a6a77f28701112ca88839acdddb96e9de Mon Sep 17 00:00:00 2001 From: rock Date: Tue, 1 Sep 2026 08:43:49 -0700 Subject: [PATCH] feat(rbac): complete HTTP endpoint integration + role configs HTTP Endpoints with RBAC: - ingest_handler: project-level write access check - learn_handler: project-level write access check - projects_handler: filter returned projects by user access - query_handler: filter search results by resource access - context_handler: project-level read access check Example Role Configurations (config/roles/): - admin.yaml: full access to all resources - portfolio-agent.yaml: public visitor access - authenticated-user.yaml: logged-in user access - homelab-team.yaml: team-scoped project access All 660+ tests passing. --- IMPLEMENTATION_STATUS.md | 13 +++++-- config/roles/admin.yaml | 8 +++++ config/roles/authenticated-user.yaml | 15 ++++++++ config/roles/homelab-team.yaml | 17 ++++++++++ config/roles/portfolio-agent.yaml | 18 ++++++++++ crates/mem-cli/src/http_server.rs | 51 +++++++++++++++++++++++++++- 6 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 config/roles/admin.yaml create mode 100644 config/roles/authenticated-user.yaml create mode 100644 config/roles/homelab-team.yaml create mode 100644 config/roles/portfolio-agent.yaml diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md index 29e69a6..0a9e0c9 100644 --- a/IMPLEMENTATION_STATUS.md +++ b/IMPLEMENTATION_STATUS.md @@ -2,9 +2,9 @@ ## Summary -**Status**: Phases 1-7 complete with RBAC wired into HTTP + retrieval. 660+ tests passing. +**Status**: Phases 1-7 complete with RBAC fully integrated. 660+ tests passing. -**Latest commit**: RBAC wired into HTTP server and retrieval pipeline +**Latest commit**: RBAC wired into all HTTP endpoints + example role configs --- @@ -111,8 +111,17 @@ - ✅ **to_rbac_claims()**: Convert JwtClaims to RBAC Claims - ✅ **query_handler**: RBAC filtering on search results - ✅ **context_handler**: Project-level access check before lookup +- ✅ **projects_handler**: Filter projects by user access +- ✅ **ingest_handler**: Project-level write access check +- ✅ **learn_handler**: Project-level write access check - ✅ **query_result_to_resource_meta()**: Convert results for RBAC filtering +### Example Role Configurations +- ✅ `config/roles/admin.yaml`: Full access +- ✅ `config/roles/portfolio-agent.yaml`: Public visitor access +- ✅ `config/roles/authenticated-user.yaml`: Logged-in user access +- ✅ `config/roles/homelab-team.yaml`: Team-scoped access example + ### AuthorizedPipeline (Legacy - deprecated) - ✅ `AuthorizedPipeline`: wraps FullPipeline with access control - ✅ 13 unit tests, all passing diff --git a/config/roles/admin.yaml b/config/roles/admin.yaml new file mode 100644 index 0000000..26eb84f --- /dev/null +++ b/config/roles/admin.yaml @@ -0,0 +1,8 @@ +# Admin role: full access to all resources +name: admin +description: Full administrative access to all resources + +rules: + - resources: ["*"] + verbs: [read, write, delete, query] + # No scope restrictions - access everything diff --git a/config/roles/authenticated-user.yaml b/config/roles/authenticated-user.yaml new file mode 100644 index 0000000..3dcaf36 --- /dev/null +++ b/config/roles/authenticated-user.yaml @@ -0,0 +1,15 @@ +# Authenticated User role: logged in via Authentik +name: authenticated-user +description: Logged in user with access to all public + private docs + +rules: + # Can read all wiki, embeddings, skills (including private) + - resources: [wiki, embedding, skill] + verbs: [read, query] + # No visibility restriction - can see public AND private + + # Can manage own conversations (any project) + - resources: [conversation] + verbs: [read, write, delete] + scope: + owner: self diff --git a/config/roles/homelab-team.yaml b/config/roles/homelab-team.yaml new file mode 100644 index 0000000..3d51c52 --- /dev/null +++ b/config/roles/homelab-team.yaml @@ -0,0 +1,17 @@ +# Homelab Team role: full access to homelab project +name: homelab-team +description: Team members with full access to homelab project + +rules: + # Full read/write access to homelab wiki and skills + - resources: [wiki, skill, embedding] + verbs: [read, write, query] + scope: + projects: [homelab] + + # Can manage conversations in homelab + - resources: [conversation] + verbs: [read, write, delete] + scope: + projects: [homelab] + # Note: no owner restriction - team can see all conversations diff --git a/config/roles/portfolio-agent.yaml b/config/roles/portfolio-agent.yaml new file mode 100644 index 0000000..013a7e9 --- /dev/null +++ b/config/roles/portfolio-agent.yaml @@ -0,0 +1,18 @@ +# Portfolio Agent role: public visitor access via portfolio site +name: portfolio-agent +description: Public visitor access - read public docs, manage own conversations + +rules: + # Can read/query public wiki and embeddings from allowed projects + - resources: [wiki, embedding] + verbs: [read, query] + scope: + projects: [homelab, rbc, aws, portfolio] + visibility: public + + # Can read/write own conversations in portfolio project only + - resources: [conversation] + verbs: [read, write] + scope: + projects: [portfolio] + owner: self diff --git a/crates/mem-cli/src/http_server.rs b/crates/mem-cli/src/http_server.rs index 19c39f5..3396c16 100644 --- a/crates/mem-cli/src/http_server.rs +++ b/crates/mem-cli/src/http_server.rs @@ -441,6 +441,23 @@ pub async fn ingest_handler( } let project = body.project.clone(); + + // RBAC: Check project-level write 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_write(&rbac_claims, &project_resource).await { + tracing::warn!( + "RBAC denied write access to project '{}' for user '{}'", + project, claims.sub + ); + return HttpResponse::Forbidden().json(json!({ + "error": "forbidden", + "reason": format!("write access denied to project '{}'", project) + })); + } + } let ingest_id = body.ingest_id.clone(); let records: Vec<(String, String)> = body .records @@ -639,6 +656,23 @@ pub async fn learn_handler( } let project = body["project"].as_str().unwrap_or("knowledge").to_string(); + + // RBAC: Check project-level write 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_write(&rbac_claims, &project_resource).await { + tracing::warn!( + "RBAC denied write access to project '{}' for user '{}'", + project, claims.sub + ); + return HttpResponse::Forbidden().json(json!({ + "error": "forbidden", + "reason": format!("write access denied to project '{}'", project) + })); + } + } let text = match body["text"].as_str() { Some(t) => t.to_string(), None => return HttpResponse::BadRequest().json(json!({ @@ -1017,7 +1051,22 @@ pub async fn projects_handler( match result { Ok(rows) => { - let projects: Vec = rows.into_iter().map(|(p,)| p).collect(); + let mut projects: Vec = rows.into_iter().map(|(p,)| p).collect(); + + // RBAC: Filter projects by access + if let Some(guard) = &state.access_guard { + let rbac_claims = to_rbac_claims(&claims); + let mut allowed_projects = Vec::new(); + + for project in projects { + let resource = ResourceMeta::new(&project, ResourceType::Project, &project); + if guard.can_read(&rbac_claims, &resource).await { + allowed_projects.push(project); + } + } + projects = allowed_projects; + } + HttpResponse::Ok().json(json!({ "projects": projects, "count": projects.len()