mirror of
https://github.com/DaLaw2/NetGuardia.git
synced 2026-08-24 14:10:28 +09:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
009f95d735
commit
234f98cbfb
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit c06eb58c3892a562fb073d63f3577a5d1a2d4cdc
|
||||
Subproject commit 9e7deb839f577220d99d9991d1da6abd7ee1ece1
|
||||
@ -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<Claims> {
|
||||
req.extensions().get::<Claims>().cloned()
|
||||
}
|
||||
|
||||
fn has_permission(claims: &Claims, permission: &str) -> bool {
|
||||
claims.permissions.iter().any(|p| p == permission)
|
||||
}
|
||||
|
||||
async fn login(
|
||||
body: web::Json<LoginRequest>,
|
||||
db: web::Data<Repo>,
|
||||
@ -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<RegisterRequest>,
|
||||
db: web::Data<Repo>,
|
||||
) -> impl Responder {
|
||||
// Check caller is admin
|
||||
let claims = req.extensions().get::<Claims>().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::<Claims>().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<Repo>) -> impl Responder {
|
||||
match extract_claims(&req) {
|
||||
Some(claims) => {
|
||||
let user_groups = db.get_user_groups(claims.sub).unwrap_or_default();
|
||||
let group_names: Vec<String> = 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<ChangePasswordRequest>,
|
||||
db: web::Data<Repo>,
|
||||
) -> impl Responder {
|
||||
let claims = match req.extensions().get::<Claims>().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<Repo>,
|
||||
) -> 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<serde_json::Value> = 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<serde_json::Value> = 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<i64>,
|
||||
db: web::Data<Repo>,
|
||||
) -> 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<i64>,
|
||||
body: web::Json<serde_json::Value>,
|
||||
db: web::Data<Repo>,
|
||||
) -> 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<i64>,
|
||||
body: web::Json<serde_json::Value>,
|
||||
db: web::Data<Repo>,
|
||||
) -> 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<Repo>,
|
||||
) -> 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<serde_json::Value> = 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<serde_json::Value>,
|
||||
db: web::Data<Repo>,
|
||||
) -> 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::<serde_json::Value>(&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<i64>,
|
||||
db: web::Data<Repo>,
|
||||
) -> 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<i64>,
|
||||
body: web::Json<serde_json::Value>,
|
||||
db: web::Data<Repo>,
|
||||
) -> 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::<serde_json::Value>(&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<i64>,
|
||||
db: web::Data<Repo>,
|
||||
) -> 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<i64>,
|
||||
body: web::Json<serde_json::Value>,
|
||||
db: web::Data<Repo>,
|
||||
) -> 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<i64> = 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::*;
|
||||
|
||||
@ -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<i64> = conn_ref.query_row(
|
||||
"SELECT id FROM user_groups WHERE name = 'Administrator'", [],
|
||||
|row| row.get(0),
|
||||
).ok();
|
||||
let viewer_group_id: Option<i64> = 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<i64> = 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<i64> = 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<i64, Error> {
|
||||
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<Vec<(i64, String, String, bool, String)>, 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<bool, Error> {
|
||||
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<Option<crate::interface::port::repository::UserTuple>, 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<Vec<(i64, String, String, String, String)>, 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<i64, Error> {
|
||||
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<bool, Error> {
|
||||
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<Option<(i64, String, String, String, String)>, 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<Vec<(i64, String, String, String)>, 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<Vec<String>, 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::<Vec<String>>(&perms_json) {
|
||||
for p in perms {
|
||||
all_perms.insert(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut result: Vec<String> = 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<Vec<i64>, 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<u64>), 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<Option<String>, 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<Option<crate::interface::port::repository::UserTuple>, 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<i64, Error> { 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<i64, Error> { self.user_count() }
|
||||
fn list_users(&self) -> Result<Vec<(i64, String, String, bool, String)>, Error> { self.list_users() }
|
||||
fn delete_user(&self, user_id: i64) -> Result<bool, Error> { 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<Option<crate::interface::port::repository::UserTuple>, Error> { self.find_user_by_id(user_id) }
|
||||
fn list_user_groups(&self) -> Result<Vec<(i64, String, String, String, String)>, Error> { self.list_user_groups() }
|
||||
fn create_user_group(&self, name: &str, description: &str, permissions: &str) -> Result<i64, Error> { 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<bool, Error> { self.delete_user_group(id) }
|
||||
fn get_user_group(&self, id: i64) -> Result<Option<(i64, String, String, String, String)>, Error> { self.get_user_group(id) }
|
||||
fn get_user_groups(&self, user_id: i64) -> Result<Vec<(i64, String, String, String)>, 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<Vec<String>, 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<Vec<i64>, Error> { self.get_group_member_ids(group_id) }
|
||||
fn record_login_failure(&self, username: &str) -> Result<(u32, Option<u64>), Error> { self.record_login_failure(username) }
|
||||
fn check_login_locked(&self, username: &str) -> Result<Option<u64>, Error> { self.check_login_locked(username) }
|
||||
fn clear_login_failures(&self, username: &str) -> Result<(), Error> { self.clear_login_failures(username) }
|
||||
|
||||
@ -32,7 +32,7 @@ impl JwtService {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_token(&self, user_id: i64, username: &str, role: &str) -> Result<String, Error> {
|
||||
pub fn create_token(&self, user_id: i64, username: &str, role: &str, permissions: Vec<String>) -> Result<String, Error> {
|
||||
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());
|
||||
}
|
||||
|
||||
@ -32,6 +32,37 @@ pub struct AuthMiddlewareService<S> {
|
||||
service: Rc<S>,
|
||||
}
|
||||
|
||||
fn required_permission(path: &str, method: &actix_web::http::Method) -> Option<String> {
|
||||
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<S, B> Service<ServiceRequest> for AuthMiddlewareService<S>
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, 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
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
|
||||
@ -6,13 +6,14 @@ pub struct TokenClaims {
|
||||
pub sub: i64,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub permissions: Vec<String>,
|
||||
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<String, Error>;
|
||||
fn create_token(&self, user_id: i64, username: &str, role: &str, permissions: Vec<String>) -> Result<String, Error>;
|
||||
fn validate_token(&self, token: &str) -> Result<TokenClaims, Error>;
|
||||
fn hash_password(&self, password: &str) -> Result<String, Error>;
|
||||
fn verify_password(&self, password: &str, hash: &str) -> Result<bool, Error>;
|
||||
|
||||
@ -34,10 +34,31 @@ pub trait RepositoryPort: Send + Sync {
|
||||
|
||||
// --- Users ---
|
||||
fn find_user(&self, username: &str) -> Result<Option<UserTuple>, 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<i64, Error>;
|
||||
fn update_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error>;
|
||||
fn user_count(&self) -> Result<i64, Error>;
|
||||
|
||||
// --- User Management ---
|
||||
fn list_users(&self) -> Result<Vec<(i64, String, String, bool, String)>, Error>;
|
||||
fn delete_user(&self, user_id: i64) -> Result<bool, Error>;
|
||||
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<Option<UserTuple>, Error>;
|
||||
|
||||
// --- User Groups ---
|
||||
fn list_user_groups(&self) -> Result<Vec<(i64, String, String, String, String)>, Error>;
|
||||
fn create_user_group(&self, name: &str, description: &str, permissions: &str) -> Result<i64, Error>;
|
||||
fn update_user_group(&self, id: i64, name: &str, description: &str, permissions: &str) -> Result<(), Error>;
|
||||
fn delete_user_group(&self, id: i64) -> Result<bool, Error>;
|
||||
fn get_user_group(&self, id: i64) -> Result<Option<(i64, String, String, String, String)>, Error>;
|
||||
|
||||
// --- User Group Membership ---
|
||||
fn get_user_groups(&self, user_id: i64) -> Result<Vec<(i64, String, String, String)>, Error>;
|
||||
fn set_user_groups(&self, user_id: i64, group_ids: &[i64]) -> Result<(), Error>;
|
||||
fn get_user_permissions(&self, user_id: i64) -> Result<Vec<String>, Error>;
|
||||
fn cleanup_user_memberships(&self, user_id: i64) -> Result<(), Error>;
|
||||
fn get_group_member_ids(&self, group_id: i64) -> Result<Vec<i64>, Error>;
|
||||
|
||||
// --- Login Rate Limiting ---
|
||||
fn record_login_failure(&self, username: &str) -> Result<(u32, Option<u64>), Error>;
|
||||
fn check_login_locked(&self, username: &str) -> Result<Option<u64>, Error>;
|
||||
|
||||
@ -5,5 +5,6 @@ pub struct Claims {
|
||||
pub sub: i64,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub permissions: Vec<String>,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user