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:
DaLaw2 2026-03-22 17:54:11 +08:00
parent 009f95d735
commit 234f98cbfb
11 changed files with 939 additions and 36 deletions

View File

@ -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

View File

@ -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

View File

@ -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(&reg.username, &hash, &reg.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::*;

View File

@ -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) }

View File

@ -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());
}

View File

@ -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

View File

@ -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");
}

View File

@ -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>;

View File

@ -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>;

View File

@ -5,5 +5,6 @@ pub struct Claims {
pub sub: i64,
pub username: String,
pub role: String,
pub permissions: Vec<String>,
pub exp: usize,
}