use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
use zino_core::{
authentication::AccessKeyId, datetime::DateTime, error::Error, model::Model,
request::Validation, Map, Uuid,
};
use zino_derive::Schema;
#[derive(Debug, Clone, Default, Serialize, Deserialize, Schema)]
#[serde(rename_all = "snake_case")]
#[serde(default)]
pub struct User {
#[schema(readonly)]
id: Uuid,
#[schema(not_null, index_type = "text")]
name: String,
#[schema(default_value = "User::model_namespace", index_type = "hash")]
namespace: String,
#[schema(default_value = "internal")]
visibility: String,
#[schema(default_value = "active", index_type = "hash")]
status: String,
#[schema(index_type = "text")]
description: String,
#[schema(not_null, writeonly)]
access_key_id: String,
#[schema(not_null, writeonly)]
account: String,
#[schema(not_null, writeonly)]
password: String,
mobile: String,
email: String,
avatar: String,
roles: Vec<String>,
#[schema(index_type = "gin")]
tags: Vec<Uuid>, content: Map,
metrics: Map,
extras: Map,
manager_id: Uuid, maintainer_id: Uuid, #[schema(readonly, default_value = "now", index_type = "btree")]
created_at: DateTime,
#[schema(default_value = "now", index_type = "btree")]
updated_at: DateTime,
version: u64,
edition: u32,
}
impl Model for User {
#[inline]
fn new() -> Self {
Self {
id: Uuid::new_v4(),
access_key_id: AccessKeyId::new().to_string(),
..Self::default()
}
}
fn read_map(&mut self, data: &Map) -> Validation {
let mut validation = Validation::new();
if let Some(result) = Validation::parse_uuid(data.get("id")) {
match result {
Ok(id) => self.id = id,
Err(err) => validation.record_fail("id", err),
}
}
if let Some(name) = Validation::parse_string(data.get("name")) {
self.name = name.into_owned();
}
if self.name.is_empty() {
validation.record("name", "should be nonempty");
}
if let Some(roles) = Validation::parse_str_array(data.get("roles")) {
if let Err(err) = self.set_roles(roles) {
validation.record_fail("roles", err);
}
}
if self.roles.is_empty() && !validation.contains_key("roles") {
validation.record("roles", "should be nonempty");
}
validation
}
}
super::impl_model_accessor!(
User,
id,
name,
namespace,
visibility,
status,
description,
content,
metrics,
extras,
manager_id,
maintainer_id,
created_at,
updated_at,
version,
edition
);
impl User {
pub fn set_roles(&mut self, roles: Vec<&str>) -> Result<(), Error> {
let num_roles = roles.len();
let special_roles = ["superuser", "user", "guest"];
for role in &roles {
if special_roles.contains(role) && num_roles != 1 {
let message = format!("the special role `{role}` is exclusive");
return Err(Error::new(message));
} else if !USER_ROLE_PATTERN.is_match(role) {
let message = format!("the role `{role}` is invalid");
return Err(Error::new(message));
}
}
self.roles = roles.into_iter().map(|s| s.to_owned()).collect();
Ok(())
}
#[inline]
pub fn roles(&self) -> &[String] {
self.roles.as_slice()
}
#[inline]
pub fn is_superuser(&self) -> bool {
self.roles() == ["superuser"]
}
#[inline]
pub fn is_user(&self) -> bool {
self.roles() == ["user"]
}
#[inline]
pub fn is_guest(&self) -> bool {
self.roles() == ["guest"]
}
pub fn is_admin(&self) -> bool {
let role = "admin";
let role_prefix = format!("{role}:");
for r in &self.roles {
if r == role || r.starts_with(&role_prefix) {
return true;
}
}
false
}
pub fn is_worker(&self) -> bool {
let role = "worker";
let role_prefix = format!("{role}:");
for r in &self.roles {
if r == role || r.starts_with(&role_prefix) {
return true;
}
}
false
}
pub fn is_auditor(&self) -> bool {
let role = "auditor";
let role_prefix = format!("{role}:");
for r in &self.roles {
if r == role || r.starts_with(&role_prefix) {
return true;
}
}
false
}
pub fn has_user_role(&self) -> bool {
self.is_superuser()
|| self.is_user()
|| self.is_admin()
|| self.is_worker()
|| self.is_auditor()
}
pub fn has_admin_role(&self) -> bool {
self.is_superuser() || self.is_admin()
}
pub fn has_worker_role(&self) -> bool {
self.is_superuser() || self.is_worker()
}
pub fn has_auditor_role(&self) -> bool {
self.is_superuser() || self.is_auditor()
}
pub fn has_role(&self, role: &str) -> bool {
let length = role.len();
for r in &self.roles {
if r == role {
return true;
} else {
let remainder = if r.len() > length {
r.strip_prefix(role)
} else {
role.strip_prefix(r.as_str())
};
if let Some(s) = remainder && s.starts_with(':') {
return true;
}
}
}
false
}
}
static USER_ROLE_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[a-z]+[a-z:]+[a-z]+$").expect("fail to create the user role pattern")
});
#[cfg(test)]
mod tests {
use super::User;
use zino_core::{extension::JsonObjectExt, model::Model, Map};
#[test]
fn it_checks_user_roles() {
let mut alice = User::new();
let mut data = Map::new();
data.upsert("name", "alice");
data.upsert("roles", vec!["admin:user", "auditor"]);
let validation = alice.read_map(&data);
assert!(validation.is_success());
assert!(alice.is_admin());
assert!(!alice.is_worker());
assert!(alice.is_auditor());
assert!(alice.has_role("admin:user"));
assert!(!alice.has_role("admin:group"));
assert!(alice.has_role("auditor:log"));
assert!(!alice.has_role("auditor_record"));
}
}