From 234f98cbfbfd0c4be78c8fc1b1083bb2b8979043 Mon Sep 17 00:00:00 2001 From: DaLaw2 Date: Sun, 22 Mar 2026 17:54:11 +0800 Subject: [PATCH] feat: RBAC user groups, permission middleware, account management APIs Backend: - User groups with junction table (user_group_members) - Permission-based middleware replacing role-based (viewer=GET only) - Permissions resolved as union of all user's group permissions - Default groups: Administrator (all perms) + Viewer (read-only) - Auto-migration: seed groups + assign existing users on first run - User management APIs: list, delete, reset-password - Group management APIs: CRUD + member assignment - Protected: admin account (no delete/group change), built-in groups (no edit/delete) - JWT claims include permissions array from groups Deploy: - setup.sh uses absolute path for compose file - config.toml: combined_queue_count=1 for veth interfaces Frontend submodule updated to include RBAC UI + i18n. Co-Authored-By: Claude Opus 4.6 (1M context) --- config.toml | 2 +- deploy/scripts/setup.sh | 8 +- net-guardia-frontend | 2 +- net-guardia/src/adapter/http/auth.rs | 573 +++++++++++++++++- .../src/adapter/persistence/repository.rs | 300 ++++++++- net-guardia/src/core/auth/jwt.rs | 12 +- net-guardia/src/core/auth/middleware.rs | 43 +- .../src/infrastructure/service_factory.rs | 8 +- net-guardia/src/interface/port/auth.rs | 3 +- net-guardia/src/interface/port/repository.rs | 23 +- net-guardia/src/model/auth.rs | 1 + 11 files changed, 939 insertions(+), 36 deletions(-) diff --git a/config.toml b/config.toml index 9b8a7ad..6cadc3e 100644 --- a/config.toml +++ b/config.toml @@ -5,7 +5,7 @@ jwt_expiry_hours = 24 [Network] ingress_ifname = "ng-ext" egress_ifname = "ng-int" -combined_queue_count = 16 +combined_queue_count = 1 channel_size = 4096 fill_queue_size = 4096 comp_queue_size = 4096 diff --git a/deploy/scripts/setup.sh b/deploy/scripts/setup.sh index ca6a272..e760a46 100644 --- a/deploy/scripts/setup.sh +++ b/deploy/scripts/setup.sh @@ -1,11 +1,15 @@ #!/bin/bash set -e +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DEPLOY_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +COMPOSE_FILE="$DEPLOY_DIR/compose/podman-compose.yml" + if command -v podman-compose &>/dev/null; then - COMPOSE="podman-compose" + COMPOSE="podman-compose -f $COMPOSE_FILE" RT="podman" elif command -v docker &>/dev/null && docker compose version &>/dev/null 2>&1; then - COMPOSE="docker compose" + COMPOSE="docker compose -f $COMPOSE_FILE" RT="docker" else echo "ERROR: No container runtime found" diff --git a/net-guardia-frontend b/net-guardia-frontend index c06eb58..9e7deb8 160000 --- a/net-guardia-frontend +++ b/net-guardia-frontend @@ -1 +1 @@ -Subproject commit c06eb58c3892a562fb073d63f3577a5d1a2d4cdc +Subproject commit 9e7deb839f577220d99d9991d1da6abd7ee1ece1 diff --git a/net-guardia/src/adapter/http/auth.rs b/net-guardia/src/adapter/http/auth.rs index 67ecb82..82c2c77 100644 --- a/net-guardia/src/adapter/http/auth.rs +++ b/net-guardia/src/adapter/http/auth.rs @@ -33,6 +33,16 @@ pub fn initialize() -> Scope { .route("/register", web::post().to(register)) .route("/me", web::get().to(me)) .route("/change-password", web::post().to(change_password)) + .route("/users", web::get().to(list_users)) + .route("/users/{id}", web::delete().to(delete_user)) + .route("/users/{id}/role", web::put().to(update_role)) + .route("/users/{id}/reset-password", web::post().to(reset_password)) + .route("/users/{id}/groups", web::put().to(set_user_groups)) + .route("/groups", web::get().to(list_groups)) + .route("/groups", web::post().to(create_group)) + .route("/groups/{id}", web::get().to(get_group)) + .route("/groups/{id}", web::put().to(update_group)) + .route("/groups/{id}", web::delete().to(delete_group)) } fn validate_username(username: &str) -> Result<(), &'static str> { @@ -52,6 +62,14 @@ fn validate_password(password: &str) -> Result<(), &'static str> { Ok(()) } +fn extract_claims(req: &HttpRequest) -> Option { + req.extensions().get::().cloned() +} + +fn has_permission(claims: &Claims, permission: &str) -> bool { + claims.permissions.iter().any(|p| p == permission) +} + async fn login( body: web::Json, db: web::Data, @@ -81,7 +99,7 @@ async fn login( } }; - let (id, username, hash, role, force_password_change) = user; + let (id, username, hash, _db_role, force_password_change) = user; match password::verify_password(&req.password, &hash) { Ok(true) => {} @@ -95,7 +113,18 @@ async fn login( // Clear login failures on success let _ = db.clear_login_failures(&req.username); - match jwt.create_token(id, &username, &role) { + // Permissions come exclusively from groups — no role-based fallback + let permissions = db.get_user_permissions(id).unwrap_or_default(); + + // Derive role from groups for backwards compat in JWT + let groups = db.get_user_groups(id).unwrap_or_default(); + let role = if groups.iter().any(|(_id, name, _desc, _perms)| name == "Administrator") { + "admin".to_string() + } else { + "viewer".to_string() + }; + + match jwt.create_token(id, &username, &role, permissions) { Ok(token) => HttpResponse::Ok().json(serde_json::json!({ "token": token, "role": role, @@ -111,15 +140,14 @@ async fn register( body: web::Json, db: web::Data, ) -> impl Responder { - // Check caller is admin - let claims = req.extensions().get::().cloned(); - match claims { - Some(c) if c.role == "admin" => {} + // Check caller has users:admin permission + let _claims = match extract_claims(&req) { + Some(c) if has_permission(&c, "users:admin") => c, _ => { return HttpResponse::Forbidden() .json(serde_json::json!({"error": "Admin access required"})); } - } + }; let reg = body.into_inner(); @@ -146,21 +174,46 @@ async fn register( }; match db.insert_user(®.username, &hash, ®.role, false) { - Ok(_) => HttpResponse::Created() - .json(serde_json::json!({"username": reg.username, "role": reg.role})), + Ok(new_user_id) => { + // Auto-assign to default group based on role + let default_group_name = if reg.role == "admin" { "Administrator" } else { "Viewer" }; + if let Ok(groups) = db.list_user_groups() { + if let Some((group_id, _, _, _, _)) = groups.into_iter().find(|(_, name, _, _, _)| name == default_group_name) { + let _ = db.set_user_groups(new_user_id, &[group_id]); + } + } + HttpResponse::Created() + .json(serde_json::json!({"username": reg.username, "role": reg.role})) + } Err(e) => { HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()})) } } } -async fn me(req: HttpRequest) -> impl Responder { - match req.extensions().get::().cloned() { - Some(claims) => HttpResponse::Ok().json(serde_json::json!({ - "id": claims.sub, - "username": claims.username, - "role": claims.role, - })), +async fn me(req: HttpRequest, db: web::Data) -> impl Responder { + match extract_claims(&req) { + Some(claims) => { + let user_groups = db.get_user_groups(claims.sub).unwrap_or_default(); + let group_names: Vec = user_groups.iter() + .map(|(_id, name, _desc, _perms)| name.clone()) + .collect(); + // Derive role from groups for backwards compat + let role = if group_names.iter().any(|n| n == "Administrator") { + "admin" + } else { + "viewer" + }; + // Get fresh permissions from groups (not from JWT claims which may be stale) + let permissions = db.get_user_permissions(claims.sub).unwrap_or_default(); + HttpResponse::Ok().json(serde_json::json!({ + "id": claims.sub, + "username": claims.username, + "role": role, + "permissions": permissions, + "groups": group_names, + })) + } None => HttpResponse::Unauthorized() .json(serde_json::json!({"error": "Not authenticated"})), } @@ -171,7 +224,7 @@ async fn change_password( body: web::Json, db: web::Data, ) -> impl Responder { - let claims = match req.extensions().get::().cloned() { + let claims = match extract_claims(&req) { Some(c) => c, None => { return HttpResponse::Unauthorized() @@ -222,6 +275,492 @@ async fn change_password( } } +// --- User Management (admin only) --- + +async fn list_users( + req: HttpRequest, + db: web::Data, +) -> impl Responder { + let claims = match extract_claims(&req) { + Some(c) => c, + None => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})); + } + }; + + if !has_permission(&claims, "users:admin") { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})); + } + + match db.list_users() { + Ok(users) => { + let result: Vec = users.into_iter().map(|(id, username, _role, force_pw, created_at)| { + let user_groups = db.get_user_groups(id).unwrap_or_default(); + let groups: Vec = user_groups.iter() + .map(|(gid, name, _desc, _perms)| serde_json::json!({"id": gid, "name": name})) + .collect(); + // Derive role from groups for backwards compat + let role = if user_groups.iter().any(|(_id, name, _desc, _perms)| name == "Administrator") { + "admin" + } else { + "viewer" + }; + serde_json::json!({ + "id": id, + "username": username, + "role": role, + "force_password_change": force_pw, + "created_at": created_at, + "groups": groups, + }) + }).collect(); + HttpResponse::Ok().json(result) + } + Err(e) => HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})), + } +} + +async fn delete_user( + req: HttpRequest, + path: web::Path, + db: web::Data, +) -> impl Responder { + let claims = match extract_claims(&req) { + Some(c) => c, + None => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})); + } + }; + + if !has_permission(&claims, "users:admin") { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})); + } + + let user_id = path.into_inner(); + + // Can't delete self + if claims.sub == user_id { + return HttpResponse::BadRequest() + .json(serde_json::json!({"error": "Cannot delete your own account"})); + } + + // Protect the built-in admin account + match db.find_user_by_id(user_id) { + Ok(Some((_, ref username, _, _, _))) if username == "admin" => { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Cannot delete the built-in admin account"})); + } + _ => {} + } + + match db.delete_user(user_id) { + Ok(true) => HttpResponse::Ok() + .json(serde_json::json!({"message": "User deleted successfully"})), + Ok(false) => HttpResponse::NotFound() + .json(serde_json::json!({"error": "User not found"})), + Err(e) => HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})), + } +} + +async fn update_role( + req: HttpRequest, + path: web::Path, + body: web::Json, + db: web::Data, +) -> impl Responder { + let claims = match extract_claims(&req) { + Some(c) => c, + None => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})); + } + }; + + if !has_permission(&claims, "users:admin") { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})); + } + + let user_id = path.into_inner(); + + // Can't change own role + if claims.sub == user_id { + return HttpResponse::BadRequest() + .json(serde_json::json!({"error": "Cannot change your own role"})); + } + + let role = match body.get("role").and_then(|v| v.as_str()) { + Some(r) if r == "admin" || r == "viewer" => r, + _ => { + return HttpResponse::BadRequest() + .json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"})); + } + }; + + // Check target user exists + match db.find_user_by_id(user_id) { + Ok(Some(_)) => {} + Ok(None) => { + return HttpResponse::NotFound() + .json(serde_json::json!({"error": "User not found"})); + } + Err(e) => { + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})); + } + } + + match db.update_user_role(user_id, role) { + Ok(_) => HttpResponse::Ok() + .json(serde_json::json!({"message": "Role updated successfully", "role": role})), + Err(e) => HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})), + } +} + +async fn reset_password( + req: HttpRequest, + path: web::Path, + body: web::Json, + db: web::Data, +) -> impl Responder { + let claims = match extract_claims(&req) { + Some(c) => c, + None => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})); + } + }; + + if !has_permission(&claims, "users:admin") { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})); + } + + let user_id = path.into_inner(); + + let new_password = match body.get("new_password").or_else(|| body.get("password")).and_then(|v| v.as_str()) { + Some(p) => p, + None => { + return HttpResponse::BadRequest() + .json(serde_json::json!({"error": "Password is required"})); + } + }; + + if let Err(msg) = validate_password(new_password) { + return HttpResponse::BadRequest().json(serde_json::json!({"error": msg})); + } + + // Check target user exists + match db.find_user_by_id(user_id) { + Ok(Some(_)) => {} + Ok(None) => { + return HttpResponse::NotFound() + .json(serde_json::json!({"error": "User not found"})); + } + Err(e) => { + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})); + } + } + + let hash = match password::hash_password(new_password) { + Ok(h) => h, + Err(_) => { + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": "Failed to hash password"})); + } + }; + + match db.reset_user_password(user_id, &hash) { + Ok(_) => HttpResponse::Ok() + .json(serde_json::json!({"message": "Password reset successfully"})), + Err(e) => HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})), + } +} + +// --- User Group Management (users:admin required) --- + +async fn list_groups( + req: HttpRequest, + db: web::Data, +) -> impl Responder { + let claims = match extract_claims(&req) { + Some(c) => c, + None => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})); + } + }; + + if !has_permission(&claims, "users:admin") { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})); + } + + match db.list_user_groups() { + Ok(groups) => { + let result: Vec = groups.into_iter().map(|(id, name, description, permissions, created_at)| { + let perms: serde_json::Value = serde_json::from_str(&permissions).unwrap_or(serde_json::json!([])); + serde_json::json!({ + "id": id, + "name": name, + "description": description, + "permissions": perms, + "created_at": created_at, + }) + }).collect(); + HttpResponse::Ok().json(result) + } + Err(e) => HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})), + } +} + +async fn create_group( + req: HttpRequest, + body: web::Json, + db: web::Data, +) -> impl Responder { + let claims = match extract_claims(&req) { + Some(c) => c, + None => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})); + } + }; + + if !has_permission(&claims, "users:admin") { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})); + } + + let name = match body.get("name").and_then(|v| v.as_str()) { + Some(n) if !n.is_empty() => n, + _ => { + return HttpResponse::BadRequest() + .json(serde_json::json!({"error": "Group name is required"})); + } + }; + + let description = body.get("description").and_then(|v| v.as_str()).unwrap_or(""); + let permissions = match body.get("permissions") { + Some(p) if p.is_array() => p.to_string(), + _ => "[]".to_string(), + }; + + match db.create_user_group(name, description, &permissions) { + Ok(id) => HttpResponse::Created().json(serde_json::json!({ + "id": id, + "name": name, + "description": description, + "permissions": serde_json::from_str::(&permissions).unwrap_or(serde_json::json!([])), + })), + Err(e) => HttpResponse::Conflict() + .json(serde_json::json!({"error": e.to_string()})), + } +} + +async fn get_group( + req: HttpRequest, + path: web::Path, + db: web::Data, +) -> impl Responder { + let claims = match extract_claims(&req) { + Some(c) => c, + None => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})); + } + }; + + if !has_permission(&claims, "users:admin") { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})); + } + + let group_id = path.into_inner(); + + match db.get_user_group(group_id) { + Ok(Some((id, name, description, permissions, created_at))) => { + let perms: serde_json::Value = serde_json::from_str(&permissions).unwrap_or(serde_json::json!([])); + let members = db.get_group_member_ids(group_id).unwrap_or_default(); + HttpResponse::Ok().json(serde_json::json!({ + "id": id, + "name": name, + "description": description, + "permissions": perms, + "created_at": created_at, + "members": members, + })) + } + Ok(None) => HttpResponse::NotFound() + .json(serde_json::json!({"error": "Group not found"})), + Err(e) => HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})), + } +} + +async fn update_group( + req: HttpRequest, + path: web::Path, + body: web::Json, + db: web::Data, +) -> impl Responder { + let claims = match extract_claims(&req) { + Some(c) => c, + None => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})); + } + }; + + if !has_permission(&claims, "users:admin") { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})); + } + + let group_id = path.into_inner(); + + // Check group exists + let existing = match db.get_user_group(group_id) { + Ok(Some(g)) => { + // Protect built-in groups + if g.1 == "Administrator" || g.1 == "Viewer" { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Cannot modify built-in groups"})); + } + g + } + Ok(None) => { + return HttpResponse::NotFound() + .json(serde_json::json!({"error": "Group not found"})); + } + Err(e) => { + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})); + } + }; + + let name = body.get("name").and_then(|v| v.as_str()).unwrap_or(&existing.1); + let description = body.get("description").and_then(|v| v.as_str()).unwrap_or(&existing.2); + let permissions = match body.get("permissions") { + Some(p) if p.is_array() => p.to_string(), + _ => existing.3.clone(), + }; + + match db.update_user_group(group_id, name, description, &permissions) { + Ok(_) => HttpResponse::Ok().json(serde_json::json!({ + "id": group_id, + "name": name, + "description": description, + "permissions": serde_json::from_str::(&permissions).unwrap_or(serde_json::json!([])), + })), + Err(e) => HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})), + } +} + +async fn delete_group( + req: HttpRequest, + path: web::Path, + db: web::Data, +) -> impl Responder { + let claims = match extract_claims(&req) { + Some(c) => c, + None => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})); + } + }; + + if !has_permission(&claims, "users:admin") { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})); + } + + let group_id = path.into_inner(); + + // Protect built-in groups + match db.get_user_group(group_id) { + Ok(Some(g)) if g.1 == "Administrator" || g.1 == "Viewer" => { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Cannot delete built-in groups"})); + } + _ => {} + } + + match db.delete_user_group(group_id) { + Ok(true) => HttpResponse::Ok() + .json(serde_json::json!({"message": "Group deleted successfully"})), + Ok(false) => HttpResponse::NotFound() + .json(serde_json::json!({"error": "Group not found"})), + Err(e) => HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})), + } +} + +async fn set_user_groups( + req: HttpRequest, + path: web::Path, + body: web::Json, + db: web::Data, +) -> impl Responder { + let claims = match extract_claims(&req) { + Some(c) => c, + None => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})); + } + }; + + if !has_permission(&claims, "users:admin") { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})); + } + + let user_id = path.into_inner(); + + // Protect the default admin account + match db.find_user_by_id(user_id) { + Ok(Some((_, ref username, _, _, _))) if username == "admin" => { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Cannot modify groups for the built-in admin account"})); + } + Ok(Some(_)) => {} + Ok(None) => { + return HttpResponse::NotFound() + .json(serde_json::json!({"error": "User not found"})); + } + Err(e) => { + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})); + } + } + + let group_ids: Vec = match body.get("group_ids").and_then(|v| v.as_array()) { + Some(arr) => arr.iter().filter_map(|v| v.as_i64()).collect(), + None => { + return HttpResponse::BadRequest() + .json(serde_json::json!({"error": "group_ids array is required"})); + } + }; + + match db.set_user_groups(user_id, &group_ids) { + Ok(_) => HttpResponse::Ok() + .json(serde_json::json!({"message": "User groups updated successfully", "group_ids": group_ids})), + Err(e) => HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/net-guardia/src/adapter/persistence/repository.rs b/net-guardia/src/adapter/persistence/repository.rs index 359a89a..008c2a3 100644 --- a/net-guardia/src/adapter/persistence/repository.rs +++ b/net-guardia/src/adapter/persistence/repository.rs @@ -51,6 +51,20 @@ impl Database { key TEXT PRIMARY KEY, value TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS user_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + description TEXT NOT NULL DEFAULT '', + permissions TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS user_group_members ( + user_id INTEGER NOT NULL, + group_id INTEGER NOT NULL, + PRIMARY KEY (user_id, group_id), + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (group_id) REFERENCES user_groups(id) + ); ")?; // Migration: add force_password_change column if missing (for existing DBs) @@ -64,6 +78,78 @@ impl Database { )?; } + // Migration: seed default user groups if table is empty + let group_count: i64 = conn_ref.query_row( + "SELECT COUNT(*) FROM user_groups", [], |row| row.get(0), + )?; + if group_count == 0 { + let all_permissions = serde_json::json!([ + "dashboard:read", "statistics:read", "traffic_map:read", "drops:read", + "ai_detection:read", "ai_detection:write", + "access_control:read", "access_control:write", + "geo_block:read", "geo_block:write", + "dns_filter:read", "dns_filter:write", + "rate_limit:read", "rate_limit:write", + "protocol_filter:read", "protocol_filter:write", + "system:read", "system:write", + "users:read", "users:write", "users:admin" + ]).to_string(); + let viewer_permissions = serde_json::json!([ + "dashboard:read", "statistics:read", "traffic_map:read", "drops:read", + "ai_detection:read", "access_control:read", "geo_block:read", + "dns_filter:read", "rate_limit:read", "protocol_filter:read", + "system:read" + ]).to_string(); + + conn_ref.execute( + "INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)", + params!["Administrator", "Full system access with all permissions", &all_permissions], + )?; + conn_ref.execute( + "INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)", + params!["Viewer", "Read-only access to all modules", &viewer_permissions], + )?; + } + + // Migration: assign existing users to default groups if user_group_members is empty + let member_count: i64 = conn_ref.query_row( + "SELECT COUNT(*) FROM user_group_members", [], |row| row.get(0), + )?; + if member_count == 0 { + // Get admin group id and viewer group id + let admin_group_id: Option = conn_ref.query_row( + "SELECT id FROM user_groups WHERE name = 'Administrator'", [], + |row| row.get(0), + ).ok(); + let viewer_group_id: Option = conn_ref.query_row( + "SELECT id FROM user_groups WHERE name = 'Viewer'", [], + |row| row.get(0), + ).ok(); + + if let Some(ag_id) = admin_group_id { + let mut stmt = conn_ref.prepare("SELECT id FROM users WHERE role = 'admin'")?; + let admin_ids: Vec = stmt.query_map([], |row| row.get(0))? + .filter_map(|r| r.ok()).collect(); + for uid in admin_ids { + conn_ref.execute( + "INSERT OR IGNORE INTO user_group_members (user_id, group_id) VALUES (?1, ?2)", + params![uid, ag_id], + )?; + } + } + if let Some(vg_id) = viewer_group_id { + let mut stmt = conn_ref.prepare("SELECT id FROM users WHERE role = 'viewer'")?; + let viewer_ids: Vec = stmt.query_map([], |row| row.get(0))? + .filter_map(|r| r.ok()).collect(); + for uid in viewer_ids { + conn_ref.execute( + "INSERT OR IGNORE INTO user_group_members (user_id, group_id) VALUES (?1, ?2)", + params![uid, vg_id], + )?; + } + } + } + Ok(()) } @@ -215,7 +301,7 @@ impl Database { } } - pub fn insert_user(&self, username: &str, password_hash: &str, role: &str, force_password_change: bool) -> Result<(), Error> { + pub fn insert_user(&self, username: &str, password_hash: &str, role: &str, force_password_change: bool) -> Result { let conn = self.conn.lock(); conn.execute( "INSERT INTO users (username, password_hash, role, force_password_change) VALUES (?1, ?2, ?3, ?4)", @@ -227,7 +313,7 @@ impl Database { e.into() } })?; - Ok(()) + Ok(conn.last_insert_rowid()) } pub fn update_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> { @@ -244,6 +330,199 @@ impl Database { Ok(conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?) } + pub fn list_users(&self) -> Result, Error> { + let conn = self.conn.lock(); + let mut stmt = conn.prepare("SELECT id, username, role, force_password_change, created_at FROM users ORDER BY id")?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)? != 0, + row.get::<_, String>(4)?, + )) + })?; + let mut results = Vec::new(); + for row in rows { + results.push(row?); + } + Ok(results) + } + + pub fn delete_user(&self, user_id: i64) -> Result { + self.cleanup_user_memberships(user_id)?; + let conn = self.conn.lock(); + let affected = conn.execute("DELETE FROM users WHERE id = ?1", params![user_id])?; + Ok(affected > 0) + } + + pub fn update_user_role(&self, user_id: i64, role: &str) -> Result<(), Error> { + let conn = self.conn.lock(); + conn.execute("UPDATE users SET role = ?1 WHERE id = ?2", params![role, user_id])?; + Ok(()) + } + + pub fn reset_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> { + let conn = self.conn.lock(); + conn.execute( + "UPDATE users SET password_hash = ?1, force_password_change = 1 WHERE id = ?2", + params![password_hash, user_id], + )?; + Ok(()) + } + + pub fn find_user_by_id(&self, user_id: i64) -> Result, Error> { + let conn = self.conn.lock(); + let result = conn.query_row( + "SELECT id, username, password_hash, role, force_password_change FROM users WHERE id = ?1", + params![user_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get::<_, i64>(4)? != 0)), + ); + match result { + Ok(user) => Ok(Some(user)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + // --- User Groups --- + pub fn list_user_groups(&self) -> Result, Error> { + let conn = self.conn.lock(); + let mut stmt = conn.prepare("SELECT id, name, description, permissions, created_at FROM user_groups ORDER BY id")?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + )) + })?; + let mut results = Vec::new(); + for row in rows { + results.push(row?); + } + Ok(results) + } + + pub fn create_user_group(&self, name: &str, description: &str, permissions: &str) -> Result { + let conn = self.conn.lock(); + conn.execute( + "INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)", + params![name, description, permissions], + ).map_err(|e| -> Error { + if e.to_string().contains("UNIQUE constraint") { + DatabaseError::QueryFailed { reason: format!("Group '{}' already exists", name) }.into() + } else { + e.into() + } + })?; + Ok(conn.last_insert_rowid()) + } + + pub fn update_user_group(&self, id: i64, name: &str, description: &str, permissions: &str) -> Result<(), Error> { + let conn = self.conn.lock(); + conn.execute( + "UPDATE user_groups SET name = ?1, description = ?2, permissions = ?3 WHERE id = ?4", + params![name, description, permissions, id], + )?; + Ok(()) + } + + pub fn delete_user_group(&self, id: i64) -> Result { + let conn = self.conn.lock(); + conn.execute("DELETE FROM user_group_members WHERE group_id = ?1", params![id])?; + let affected = conn.execute("DELETE FROM user_groups WHERE id = ?1", params![id])?; + Ok(affected > 0) + } + + pub fn get_user_group(&self, id: i64) -> Result, Error> { + let conn = self.conn.lock(); + let result = conn.query_row( + "SELECT id, name, description, permissions, created_at FROM user_groups WHERE id = ?1", + params![id], + |row| Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + )), + ); + match result { + Ok(group) => Ok(Some(group)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + // --- User Group Membership --- + pub fn get_user_groups(&self, user_id: i64) -> Result, Error> { + let conn = self.conn.lock(); + let mut stmt = conn.prepare( + "SELECT g.id, g.name, g.description, g.permissions FROM user_groups g \ + INNER JOIN user_group_members m ON g.id = m.group_id \ + WHERE m.user_id = ?1 ORDER BY g.id" + )?; + let rows = stmt.query_map(params![user_id], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + })?; + let mut results = Vec::new(); + for row in rows { + results.push(row?); + } + Ok(results) + } + + pub fn set_user_groups(&self, user_id: i64, group_ids: &[i64]) -> Result<(), Error> { + let conn = self.conn.lock(); + conn.execute("DELETE FROM user_group_members WHERE user_id = ?1", params![user_id])?; + for &gid in group_ids { + conn.execute( + "INSERT INTO user_group_members (user_id, group_id) VALUES (?1, ?2)", + params![user_id, gid], + )?; + } + Ok(()) + } + + pub fn get_user_permissions(&self, user_id: i64) -> Result, Error> { + let groups = self.get_user_groups(user_id)?; + let mut all_perms = std::collections::HashSet::new(); + for (_id, _name, _desc, perms_json) in groups { + if let Ok(perms) = serde_json::from_str::>(&perms_json) { + for p in perms { + all_perms.insert(p); + } + } + } + let mut result: Vec = all_perms.into_iter().collect(); + result.sort(); + Ok(result) + } + + pub fn cleanup_user_memberships(&self, user_id: i64) -> Result<(), Error> { + let conn = self.conn.lock(); + conn.execute("DELETE FROM user_group_members WHERE user_id = ?1", params![user_id])?; + Ok(()) + } + + pub fn get_group_member_ids(&self, group_id: i64) -> Result, Error> { + let conn = self.conn.lock(); + let mut stmt = conn.prepare("SELECT user_id FROM user_group_members WHERE group_id = ?1")?; + let rows = stmt.query_map(params![group_id], |row| row.get::<_, i64>(0))?; + let mut results = Vec::new(); + for row in rows { + results.push(row?); + } + Ok(results) + } + // --- Login Rate Limiting --- pub fn record_login_failure(&self, username: &str) -> Result<(u32, Option), Error> { let key_count = format!("login_failures:{}", username); @@ -310,9 +589,24 @@ impl crate::interface::port::repository::RepositoryPort for Database { fn get_setting(&self, key: &str) -> Result, Error> { self.get_setting(key) } fn set_setting(&self, key: &str, value: &str) -> Result<(), Error> { self.set_setting(key, value) } fn find_user(&self, username: &str) -> Result, Error> { self.find_user(username) } - fn insert_user(&self, username: &str, password_hash: &str, role: &str, force_password_change: bool) -> Result<(), Error> { self.insert_user(username, password_hash, role, force_password_change) } + fn insert_user(&self, username: &str, password_hash: &str, role: &str, force_password_change: bool) -> Result { self.insert_user(username, password_hash, role, force_password_change) } fn update_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> { self.update_user_password(user_id, password_hash) } fn user_count(&self) -> Result { self.user_count() } + fn list_users(&self) -> Result, Error> { self.list_users() } + fn delete_user(&self, user_id: i64) -> Result { self.delete_user(user_id) } + fn update_user_role(&self, user_id: i64, role: &str) -> Result<(), Error> { self.update_user_role(user_id, role) } + fn reset_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> { self.reset_user_password(user_id, password_hash) } + fn find_user_by_id(&self, user_id: i64) -> Result, Error> { self.find_user_by_id(user_id) } + fn list_user_groups(&self) -> Result, Error> { self.list_user_groups() } + fn create_user_group(&self, name: &str, description: &str, permissions: &str) -> Result { self.create_user_group(name, description, permissions) } + fn update_user_group(&self, id: i64, name: &str, description: &str, permissions: &str) -> Result<(), Error> { self.update_user_group(id, name, description, permissions) } + fn delete_user_group(&self, id: i64) -> Result { self.delete_user_group(id) } + fn get_user_group(&self, id: i64) -> Result, Error> { self.get_user_group(id) } + fn get_user_groups(&self, user_id: i64) -> Result, Error> { self.get_user_groups(user_id) } + fn set_user_groups(&self, user_id: i64, group_ids: &[i64]) -> Result<(), Error> { self.set_user_groups(user_id, group_ids) } + fn get_user_permissions(&self, user_id: i64) -> Result, Error> { self.get_user_permissions(user_id) } + fn cleanup_user_memberships(&self, user_id: i64) -> Result<(), Error> { self.cleanup_user_memberships(user_id) } + fn get_group_member_ids(&self, group_id: i64) -> Result, Error> { self.get_group_member_ids(group_id) } fn record_login_failure(&self, username: &str) -> Result<(u32, Option), Error> { self.record_login_failure(username) } fn check_login_locked(&self, username: &str) -> Result, Error> { self.check_login_locked(username) } fn clear_login_failures(&self, username: &str) -> Result<(), Error> { self.clear_login_failures(username) } diff --git a/net-guardia/src/core/auth/jwt.rs b/net-guardia/src/core/auth/jwt.rs index f0b5b2b..dc0d53e 100644 --- a/net-guardia/src/core/auth/jwt.rs +++ b/net-guardia/src/core/auth/jwt.rs @@ -32,7 +32,7 @@ impl JwtService { }) } - pub fn create_token(&self, user_id: i64, username: &str, role: &str) -> Result { + pub fn create_token(&self, user_id: i64, username: &str, role: &str, permissions: Vec) -> Result { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() @@ -42,6 +42,7 @@ impl JwtService { sub: user_id, username: username.to_string(), role: role.to_string(), + permissions, exp: (now + self.expiry_hours * 3600) as usize, }; @@ -83,11 +84,13 @@ mod tests { #[test] fn test_create_and_validate_token() { let jwt = test_jwt_service(); - let token = jwt.create_token(1, "admin", "admin").unwrap(); + let perms = vec!["dashboard:read".to_string()]; + let token = jwt.create_token(1, "admin", "admin", perms.clone()).unwrap(); let claims = jwt.validate_token(&token).unwrap(); assert_eq!(claims.sub, 1); assert_eq!(claims.username, "admin"); assert_eq!(claims.role, "admin"); + assert_eq!(claims.permissions, perms); } #[test] @@ -107,6 +110,7 @@ mod tests { sub: 1, username: "admin".to_string(), role: "admin".to_string(), + permissions: vec![], exp: 0, // epoch = expired }; let token = encode(&Header::default(), &claims, &jwt.encoding_key).unwrap(); @@ -120,7 +124,7 @@ mod tests { // First creation generates and stores secret let jwt1 = JwtService::new(&db, 24).unwrap(); - let token = jwt1.create_token(1, "admin", "admin").unwrap(); + let token = jwt1.create_token(1, "admin", "admin", vec![]).unwrap(); // Second creation reuses stored secret let jwt2 = JwtService::new(&db, 24).unwrap(); @@ -133,7 +137,7 @@ mod tests { let jwt1 = test_jwt_service(); let jwt2 = test_jwt_service(); // different in-memory DB = different secret - let token = jwt1.create_token(1, "admin", "admin").unwrap(); + let token = jwt1.create_token(1, "admin", "admin", vec![]).unwrap(); let result = jwt2.validate_token(&token); assert!(result.is_err()); } diff --git a/net-guardia/src/core/auth/middleware.rs b/net-guardia/src/core/auth/middleware.rs index 414a3d0..6c862ca 100644 --- a/net-guardia/src/core/auth/middleware.rs +++ b/net-guardia/src/core/auth/middleware.rs @@ -32,6 +32,37 @@ pub struct AuthMiddlewareService { service: Rc, } +fn required_permission(path: &str, method: &actix_web::http::Method) -> Option { + let resource = if path.starts_with("/api/auth/") { + return None; // Auth endpoints handled separately + } else if path.starts_with("/api/health/") || path.starts_with("/api/stats/") { + "dashboard" + } else if path.starts_with("/api/ml/") { + "ai_detection" + } else if path.starts_with("/api/acl/geo/") { + "geo_block" + } else if path.starts_with("/api/acl/") { + "access_control" + } else if path.starts_with("/api/filter/dns/") { + "dns_filter" + } else if path.starts_with("/api/filter/http/") || path.starts_with("/api/filter/ssh/") { + "protocol_filter" + } else if path.starts_with("/api/rate-limit/") { + "rate_limit" + } else if path.starts_with("/api/system/") { + "system" + } else { + return None; + }; + + let action = match *method { + actix_web::http::Method::GET => "read", + _ => "write", + }; + + Some(format!("{}:{}", resource, action)) +} + impl Service for AuthMiddlewareService where S: Service, Error = ActixError> + 'static, @@ -100,11 +131,13 @@ where } }; - // RBAC: viewer can only GET - if claims.role == "viewer" && req.method() != actix_web::http::Method::GET { - let resp = HttpResponse::Forbidden() - .json(serde_json::json!({"error": "Insufficient permissions"})); - return Ok(req.into_response(resp).map_into_right_body()); + // Permission-based RBAC check + if let Some(required) = required_permission(&path, req.method()) { + if !claims.permissions.contains(&required) { + let resp = HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Insufficient permissions"})); + return Ok(req.into_response(resp).map_into_right_body()); + } } // Store claims in request extensions diff --git a/net-guardia/src/infrastructure/service_factory.rs b/net-guardia/src/infrastructure/service_factory.rs index 6123df4..42074a1 100644 --- a/net-guardia/src/infrastructure/service_factory.rs +++ b/net-guardia/src/infrastructure/service_factory.rs @@ -90,7 +90,13 @@ impl ServiceFactory { // Create default admin user if no users exist if db.user_count().unwrap_or(0) == 0 { let hash = password::hash_password("admin")?; - db.insert_user("admin", &hash, "admin", true)?; + let admin_user_id = db.insert_user("admin", &hash, "admin", true)?; + // Assign to Administrator group + if let Ok(groups) = db.list_user_groups() { + if let Some((group_id, _, _, _, _)) = groups.into_iter().find(|(_, name, _, _, _)| name == "Administrator") { + let _ = db.set_user_groups(admin_user_id, &[group_id]); + } + } tracing::warn!("Default admin user created with password 'admin' — you must change it on first login"); } diff --git a/net-guardia/src/interface/port/auth.rs b/net-guardia/src/interface/port/auth.rs index 341362b..8c9639d 100644 --- a/net-guardia/src/interface/port/auth.rs +++ b/net-guardia/src/interface/port/auth.rs @@ -6,13 +6,14 @@ pub struct TokenClaims { pub sub: i64, pub username: String, pub role: String, + pub permissions: Vec, pub exp: usize, } /// Port for authentication operations. /// Adapters: JWT (current), could be OAuth, etc. pub trait AuthPort: Send + Sync { - fn create_token(&self, user_id: i64, username: &str, role: &str) -> Result; + fn create_token(&self, user_id: i64, username: &str, role: &str, permissions: Vec) -> Result; fn validate_token(&self, token: &str) -> Result; fn hash_password(&self, password: &str) -> Result; fn verify_password(&self, password: &str, hash: &str) -> Result; diff --git a/net-guardia/src/interface/port/repository.rs b/net-guardia/src/interface/port/repository.rs index 6d6b685..38a9e93 100644 --- a/net-guardia/src/interface/port/repository.rs +++ b/net-guardia/src/interface/port/repository.rs @@ -34,10 +34,31 @@ pub trait RepositoryPort: Send + Sync { // --- Users --- fn find_user(&self, username: &str) -> Result, Error>; - fn insert_user(&self, username: &str, password_hash: &str, role: &str, force_password_change: bool) -> Result<(), Error>; + fn insert_user(&self, username: &str, password_hash: &str, role: &str, force_password_change: bool) -> Result; fn update_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error>; fn user_count(&self) -> Result; + // --- User Management --- + fn list_users(&self) -> Result, Error>; + fn delete_user(&self, user_id: i64) -> Result; + fn update_user_role(&self, user_id: i64, role: &str) -> Result<(), Error>; + fn reset_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error>; + fn find_user_by_id(&self, user_id: i64) -> Result, Error>; + + // --- User Groups --- + fn list_user_groups(&self) -> Result, Error>; + fn create_user_group(&self, name: &str, description: &str, permissions: &str) -> Result; + fn update_user_group(&self, id: i64, name: &str, description: &str, permissions: &str) -> Result<(), Error>; + fn delete_user_group(&self, id: i64) -> Result; + fn get_user_group(&self, id: i64) -> Result, Error>; + + // --- User Group Membership --- + fn get_user_groups(&self, user_id: i64) -> Result, Error>; + fn set_user_groups(&self, user_id: i64, group_ids: &[i64]) -> Result<(), Error>; + fn get_user_permissions(&self, user_id: i64) -> Result, Error>; + fn cleanup_user_memberships(&self, user_id: i64) -> Result<(), Error>; + fn get_group_member_ids(&self, group_id: i64) -> Result, Error>; + // --- Login Rate Limiting --- fn record_login_failure(&self, username: &str) -> Result<(u32, Option), Error>; fn check_login_locked(&self, username: &str) -> Result, Error>; diff --git a/net-guardia/src/model/auth.rs b/net-guardia/src/model/auth.rs index 564235a..a5eb270 100644 --- a/net-guardia/src/model/auth.rs +++ b/net-guardia/src/model/auth.rs @@ -5,5 +5,6 @@ pub struct Claims { pub sub: i64, pub username: String, pub role: String, + pub permissions: Vec, pub exp: usize, }